Each sqlb generate run emits a self-contained client: the row types and operations for that project's tables, preceded by a full private copy of the runtime — Collection, Page, Problem, ProblemDetail, ApiRequest, Transport, the filter-grammar encoder, isProblem/allowedFor.
That is exactly right for one module. A modular monolith has many, all serving one frontend, and the second module is where it breaks.
Measured, on the second module in one app
Two modules in one app (family, then tutor), emitting into the same web/src/api and mobile/lib/api:
|
file A |
file B |
identical leading lines |
| TypeScript |
client.gen.ts 457 |
tutor.client.gen.ts 587 |
287 |
| Dart |
client.gen.dart 1563 |
tutor.client.gen.dart 1893 |
1187 |
The runtime is byte-identical in both — it is the same generator emitting the same template. A third module makes it three copies.
I had to set TSClientFile / TSQueriesFile / DartFile on the second module, because the defaults (client.gen.ts, queries.gen.ts, client.gen.dart) are per-project constants and the second project silently overwrites the first's files in a shared output directory. Those options exist, so this part has an answer — but the answer is a naming convention the adopter has to invent, and it does not address what follows.
In Dart it is a compile error, not duplication
Dart is nominally typed, so the two Page<T> are two unrelated classes. One file cannot consume both clients:
import 'client.gen.dart';
import 'tutor.client.gen.dart';
Future<void> probe(Transport t) async {
final Page<Child> kids = await listChildren(t);
final Page<Session> sessions = await listSessions(t);
}
error - The name 'Transport' is defined in the libraries 'client.gen.dart' and
'tutor.client.gen.dart'. Try using 'as prefix' for one of the import
directives, or hiding the name from all but one of the imports. - ambiguous_import
error - The name 'Page' is defined in the libraries ... - ambiguous_import
as prefix makes it compile and does not make it work:
family.Page<T> and tutor.Page<T> are distinct types, so no shared widget, pager, or error boundary can accept both. An infinite-scroll list widget — the thing the Dart cursor pager exists to make possible — has to be written once per module.
- The hand-written
http_transport.dart returns Transport. There are now two Transport typedefs, so the app's single transport (base URL, bearer token, 401 policy, ApiException) has to be duplicated per module, and each copy catches only its own module's Problem.
TypeScript escapes the compile error because it is structurally typed — the duplicate Page/Problem interfaces interoperate. It still ships N copies of the encoder and the runtime to the browser, and still asks the app to wire one Transport per module.
Why a convention cannot fix it
The generated file's own header says it imports nothing, and that is a real design property — no pub package, no bundler assumption. But "imports nothing" and "there are several of these" are only compatible if the runtime is emitted once, separately, and the per-module files import that.
Suggested shape
Emit the runtime as its own file, once per output directory, and have each module's client import it:
web/src/api/runtime.gen.ts ← Page, Problem, Transport, encodeListQuery, …
web/src/api/family.gen.ts ← imports './runtime.gen'
web/src/api/tutor.gen.ts ← imports './runtime.gen'
mobile/lib/api/runtime.gen.dart
mobile/lib/api/family.gen.dart ← export 'runtime.gen.dart' or import it
mobile/lib/api/tutor.gen.dart
The runtime is derived from nothing module-specific, so a second module writing it is a no-op and codegen.Check stays meaningful. A RuntimeFile/RuntimeImport option would let an adopter point several projects at one shared file without the generator needing to know they are related.
The alternative — one generator invocation over a registry containing every module's tables — is not available to this codebase: the whole point of the per-module registry is that modules do not import each other, so nothing is entitled to hold all of them at once.
Found taking a second module schema-first in the multi-app adoption (the subject-mono of docs/review-adoption-multi-app.md). Related: #20 (Dart emitter).
Each
sqlb generaterun emits a self-contained client: the row types and operations for that project's tables, preceded by a full private copy of the runtime —Collection,Page,Problem,ProblemDetail,ApiRequest,Transport, the filter-grammar encoder,isProblem/allowedFor.That is exactly right for one module. A modular monolith has many, all serving one frontend, and the second module is where it breaks.
Measured, on the second module in one app
Two modules in one app (
family, thentutor), emitting into the sameweb/src/apiandmobile/lib/api:client.gen.ts457tutor.client.gen.ts587client.gen.dart1563tutor.client.gen.dart1893The runtime is byte-identical in both — it is the same generator emitting the same template. A third module makes it three copies.
I had to set
TSClientFile/TSQueriesFile/DartFileon the second module, because the defaults (client.gen.ts,queries.gen.ts,client.gen.dart) are per-project constants and the second project silently overwrites the first's files in a shared output directory. Those options exist, so this part has an answer — but the answer is a naming convention the adopter has to invent, and it does not address what follows.In Dart it is a compile error, not duplication
Dart is nominally typed, so the two
Page<T>are two unrelated classes. One file cannot consume both clients:as prefixmakes it compile and does not make it work:family.Page<T>andtutor.Page<T>are distinct types, so no shared widget, pager, or error boundary can accept both. An infinite-scroll list widget — the thing the Dart cursor pager exists to make possible — has to be written once per module.http_transport.dartreturnsTransport. There are now twoTransporttypedefs, so the app's single transport (base URL, bearer token, 401 policy,ApiException) has to be duplicated per module, and each copy catches only its own module'sProblem.TypeScript escapes the compile error because it is structurally typed — the duplicate
Page/Probleminterfaces interoperate. It still ships N copies of the encoder and the runtime to the browser, and still asks the app to wire oneTransportper module.Why a convention cannot fix it
The generated file's own header says it imports nothing, and that is a real design property — no pub package, no bundler assumption. But "imports nothing" and "there are several of these" are only compatible if the runtime is emitted once, separately, and the per-module files import that.
Suggested shape
Emit the runtime as its own file, once per output directory, and have each module's client import it:
The runtime is derived from nothing module-specific, so a second module writing it is a no-op and
codegen.Checkstays meaningful. ARuntimeFile/RuntimeImportoption would let an adopter point several projects at one shared file without the generator needing to know they are related.The alternative — one generator invocation over a registry containing every module's tables — is not available to this codebase: the whole point of the per-module registry is that modules do not import each other, so nothing is entitled to hold all of them at once.
Found taking a second module schema-first in the multi-app adoption (the subject-mono of
docs/review-adoption-multi-app.md). Related: #20 (Dart emitter).