Skip to content

Commit 0e82fb8

Browse files
dmealingclaude
andcommitted
docs(specs): FR2 plan-of-record + ADR-0008 (param-passing generated repo helpers)
FR2 rewrite folds in the brainstormed decisions: - Option B scope: queries-side hard break in 0.7.0 + docs recipe; routesFile() unchanged; no runtime-ts split; no new generator factories. - Docs deliverable at docs/recipes/wiring-generated-queries.md with three framework worked examples (Hono primary edge, Fastify, raw fetch), composing-with-custom-routes pattern, plugin-model walkthrough for consumer-owned routes generators, and the migration guide. - Cross-language principle moved out to a separate ADR. ADR-0008 captures the cross-language principle: every port's generated repo helpers accept persistence-context as a parameter (or via the language- idiomatic equivalent — DI for C#, session arg for Python, @repository injection for Java). C# already conforms (EF Core DI). TS adopts in 0.7.0. Java + Python adopt when their codegen ships. Module-level singletons are not emitted by any port. Roadmap pointer updated: FR2 is now plan-of-record (no longer "design proposal"); ADR-0008 linked alongside. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4b0b4e5 commit 0e82fb8

3 files changed

Lines changed: 376 additions & 128 deletions

File tree

Lines changed: 219 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,163 +1,257 @@
1-
# FR: Parameter-passing for generated repo helpers (`db` as first arg)
1+
# FR: Parameter-passing for generated repo helpers
2+
3+
**Status:** Design — implementation-ready (plan-of-record)
4+
**Date:** 2026-05-25 (revised after brainstorm)
5+
**Scope:** TypeScript implementation (`@metaobjectsdev/codegen-ts`) + a docs recipe; the
6+
cross-language design principle is captured separately in
7+
[ADR-0008](../../../spec/decisions/ADR-0008-parameter-passing-generated-repo-helpers.md).
8+
**Depends on:** existing `queriesFile()` codegen pipeline; nothing else.
9+
**Breaking change for:** 0.6.0 → 0.7.0 TS consumers — every call site that invokes a
10+
generated `<Entity>.queries.ts` helper updates from `findUserById(id)` to
11+
`findUserById(db, id)`. Migration is mechanical (search-and-replace at call sites; run
12+
`meta gen` to update the generated files).
213

3-
**Status:** Design proposal — needs brainstorm before implementation
4-
**Date:** 2026-05-25
5-
**Scope:** Cross-language design decision; TypeScript impl first (`@metaobjectsdev/codegen-ts`)
6-
**Origin:** Friction observed in a downstream consumer adopting 0.6.0 on Cloudflare Workers,
7-
where per-request DB bindings make the current module-level `db` import unworkable. The
8-
underlying design question — "does the generated CRUD helper take a `db` argument, or
9-
import it from a known module path?" — is cross-language: every port's codegen makes
10-
the same choice.
11-
12-
## Current state
14+
## Goal
1315

14-
Generated `<Entity>.queries.ts` (TS, see `codegen-ts/src/templates/queries.ts`) emits:
16+
Generated CRUD helpers in `<Entity>.queries.ts` accept their Drizzle persistence-context
17+
as the first parameter. The module-level `import { db } from "../db"` they emit today is
18+
removed.
1519

1620
```ts
21+
// Before (0.6.0)
1722
import { db } from "../db";
18-
// ...
19-
export async function findCouncilById(id: string) {
20-
return db.select()...
23+
export async function findUserById(id: string): Promise<User | null> {
24+
const [u] = await db.select().from(users).where(eq(users.id, id));
25+
return u ?? null;
26+
}
27+
28+
// After (0.7.0)
29+
import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";
30+
type Db = BaseSQLiteDatabase<"async", Record<string, never>>;
31+
32+
export async function findUserById(db: Db, id: string): Promise<User | null> {
33+
const [u] = await db.select().from(users).where(eq(users.id, id));
34+
return u ?? null;
2135
}
2236
```
2337

24-
The `../db` module path is consumer-provided. The codegen assumes a singleton, module-level
25-
`db` instance exists at that path.
26-
27-
This works in:
28-
- Long-lived Node.js / Bun processes (the `db` is created once at boot).
29-
- Server runtimes where the DB connection is process-scoped (Postgres pool, libsql client).
30-
31-
This fails in:
32-
- **Cloudflare Workers / Vercel Edge / Deno Deploy.** The DB binding (`env.DB`) is
33-
request-scoped — there is no module-level `db` to import. Consumers ship a
34-
runtime-throwing stub purely to satisfy the typecheck:
35-
```ts
36-
export const db = (() => {
37-
throw new Error("type-only stub — use getDb(env) in the handler");
38-
})() as unknown as BaseSQLiteDatabase<...>;
39-
```
40-
Every Worker adopter pays this dead-code tax forever.
41-
- **Multi-tenant servers** where each tenant has its own DB connection.
42-
- **Test isolation** where each test wants its own in-memory DB.
43-
44-
The idiomatic 2026 ORM-helper pattern (current Drizzle docs, Kysely, Prisma, TypeORM,
45-
Knex) is parameter-passing. We're the outlier.
38+
## Why
39+
40+
The module-level singleton works in long-lived Node.js / Bun processes (the `db` is
41+
created once at boot). It fails on:
42+
43+
- **Cloudflare Workers / Vercel Edge / Deno Deploy** — the DB binding (`env.DB`) is
44+
request-scoped; no module-level `db` to import. Today's workers consumers ship a
45+
runtime-throwing stub purely to satisfy the typecheck.
46+
- **Multi-tenant servers** — each tenant has its own DB connection.
47+
- **Test isolation** — each test wants an in-memory DB.
48+
49+
The idiomatic 2026 ORM-helper pattern (Drizzle, Kysely, Prisma, TypeORM, Knex,
50+
SQLAlchemy 2.x, EF Core via DI) is parameter-passing. We are the outlier. ADR-0008
51+
documents this as the cross-language principle; this FR is the TypeScript implementation.
52+
53+
## Design — minimal scope (Option B from brainstorm)
54+
55+
The brainstorm considered four scopes (queries-only; queries + docs recipe; queries + a
56+
deferred Hono routes FR; queries + dual-target routes generator + runtime-ts split).
57+
Option B — **queries-only + docs recipe** — was chosen because:
58+
59+
- Workers consumers were blocked by module-level `db`, not by the absence of generated
60+
HTTP routes; they hand-write their HTTP layer regardless.
61+
- Pre-emptively adding a Hono routes generator forces a framework pick (Hono vs itty vs
62+
raw-fetch vs whatever comes next) that the user base hasn't asked for. The
63+
consumer-writable `Generator` plugin pattern already lets adopters ship their own.
64+
- `routesFile()` (Fastify) stays unchanged — well-served Node consumers see no
65+
regression on the routes side.
66+
67+
### What changes
68+
69+
1. **`codegen-ts/src/templates/queries.ts`** — every renderXxxFn() helper
70+
(`renderFindByIdFn`, `renderListFn`, `renderCreateFn`, `renderUpdateFn`,
71+
`renderDeleteByIdFn`) gains `db: Db` as the first parameter. No helper has a
72+
top-level `import { db } from "../db"`.
73+
74+
2. **`codegen-ts/src/templates/queries-file.ts`** — the file composer emits a dialect-
75+
correct `type Db = ...` alias at the top, then every helper signature references
76+
`Db`:
77+
78+
```ts
79+
// sqlite/d1 (covers libsql, Turso, Cloudflare D1 — all async sqlite)
80+
import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";
81+
type Db = BaseSQLiteDatabase<"async", Record<string, never>>;
82+
83+
// postgres
84+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
85+
type Db = NodePgDatabase<Record<string, never>>;
86+
```
87+
88+
`<Record<string, never>>` for the schema parameter lets consumers narrow the type if
89+
they want (`drizzle(connection, { schema })`); we don't impose a schema.
90+
91+
3. **Goldens** — all `<Entity>.queries.ts` snapshots regenerated:
92+
`test/golden/__snapshots__/{sqlite,postgres,package}/*.queries.ts`. The
93+
`UPDATE_GOLDEN=1 bun test` mechanism (per `test/golden/golden-output.test.ts:5`)
94+
handles regeneration.
95+
96+
4. **Unit tests**`codegen-ts/test/templates/queries.test.ts` updated. Each renderXxxFn
97+
test asserts the rendered code (a) declares `db: Db` as first parameter, (b) does
98+
NOT contain `import { db }`.
99+
100+
### What doesn't change
101+
102+
- `routesFile()` generator — still emits Fastify routes; consumers using it keep their
103+
wiring exactly as today.
104+
- `@metaobjectsdev/runtime-ts` — untouched. No split into `/core`. Fastify helpers still
105+
live at `/drizzle-fastify`.
106+
- `entity-file`, `barrel`, `payload-codegen`, `projection-decl`, all other generators —
107+
no signature change.
108+
- `dbImport` codegen config field — stays (still useful for cross-target entity imports
109+
unrelated to this change).
46110

47-
## Goal
111+
## Docs recipe deliverable
48112

49-
Generated CRUD helpers take a `db` instance as the first argument. Module-level `db`
50-
imports are no longer required:
113+
The other half of this FR is a consumer-facing docs page at
114+
`docs/recipes/wiring-generated-queries.md`. The page is paste-and-run, ~200-300 lines
115+
including code blocks. Outline:
51116

52-
```ts
53-
// Generated <Entity>.queries.ts (after this FR)
54-
import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";
117+
1. **TL;DR** — generated queries take `db` as the first arg; pass any compatible
118+
Drizzle instance.
55119

56-
export async function findCouncilById(
57-
db: BaseSQLiteDatabase<"async", { rowsAffected: number }>,
58-
id: string,
59-
): Promise<Council | null> {
60-
const [row] = await db.select().from(councils).where(eq(councils.id, id));
61-
return row ?? null;
62-
}
63-
```
120+
2. **Setting up `db` per dialect** — three subsections:
121+
- SQLite/libsql/Turso — `drizzle(libsqlClient)` at module init.
122+
- D1 (Cloudflare Workers) — `drizzle(env.DB)` inside the request handler.
123+
- Postgres — `drizzle(pool)` at module init or per-request (multi-tenant).
64124

65-
Consumer call sites pass their request-scoped (or module-level) `db`:
125+
3. **Wiring in Hono** — a 30-line worked example: full CRUD for one entity,
126+
request-scoped `getDb(c)`, custom auth middleware applied to a sub-app, the
127+
sub-app mounted at `/api/users`. Hono is chosen as the primary edge example
128+
because it is the de facto edge HTTP router in 2026, not because the framework
129+
is blessed — the same pattern works against itty, hono v5, or raw `fetch()`
130+
handlers with a different import line.
66131

67-
```ts
68-
// Worker handler
69-
const db = drizzle(env.DB, { schema });
70-
const council = await findCouncilById(db, "abc123");
132+
4. **Wiring in Fastify** — equivalent example. Two variants:
133+
(a) using the existing `routesFile()` generator (unchanged in 0.7.0);
134+
(b) hand-rolling routes with the generated queries.
71135

72-
// Node server (unchanged ergonomics)
73-
const db = drizzle(pool, { schema });
74-
const council = await findCouncilById(db, "abc123");
75-
```
136+
5. **Wiring with raw `fetch()` / itty-router** — short minimal example for
137+
consumers avoiding HTTP framework deps. Shows how the generated queries
138+
compose with any router.
76139

77-
## Why this is cross-language
140+
6. **Composing with custom routes** — pattern showing generated queries +
141+
hand-written endpoints living together (e.g. generated `findUserById` plus
142+
custom `POST /users/:id/promote`). Demonstrates the all-or-nothing-per-verb
143+
property of today's routes generator is non-binding when you use queries
144+
directly.
78145

79-
The same design choice exists in every port's codegen:
146+
7. **Wrapping into your own `Generator` factory** — for consumers who want
147+
their routes generated, the ~30-line plugin-model factory pattern. This
148+
doubles as the worked example for the codegen plugin system itself.
80149

81-
| Port | Today's choice (probably) | Idiomatic 2026 |
82-
|---|---|---|
83-
| TypeScript (codegen-ts) | module-level `db` import | parameter-passing |
84-
| C# (`MetaObjects.Codegen`) | `AppDbContext` constructor-injected (EF Core idiom) | already parameter-passing-ish via DI |
85-
| Java (not yet shipped) | Spring `@Repository` injection | per-Spring convention |
86-
| Python (planned post-H3) | SQLAlchemy session factory or module-level | parameter or context |
150+
8. **Migration from 0.6.0** — three-step guide:
151+
```
152+
bun add -E @metaobjectsdev/cli@0.7.0 # bump
153+
meta gen # regenerate
154+
# find/replace call sites: findX(args) → findX(db, args)
155+
```
87156

88-
C# already gets this right via DI. TypeScript is the obvious gap. The cross-language
89-
spec should:
90-
1. Declare that generated repo helpers take their persistence-context as a parameter
91-
(or via equivalent idiom: constructor injection for OO ports, `db` arg for functional).
92-
2. Document the per-port acceptable shapes.
93-
3. Let each port migrate at its own pace.
157+
9. **Why parameter-passing** — one-paragraph rationale linking to ADR-0008.
94158

95-
## Brainstorm topics (open)
159+
The recipe is implementation work; the actual prose is produced by the plan, not this
160+
spec.
96161

97-
These need a brainstorm before implementation:
162+
## Tests + verification
98163

99-
### 1. Migration path for existing 0.6.0 TypeScript consumers
164+
### Unit tests (codegen-ts)
100165

101-
Three options:
166+
- `test/templates/queries.test.ts` updated:
167+
- `renderFindByIdFn` test asserts `db: Db` is the first parameter; `db` symbol is
168+
typed.
169+
- Same for `renderListFn`, `renderCreateFn`, `renderUpdateFn`, `renderDeleteByIdFn`.
170+
- File-level test on `queries-file.ts` asserts the `type Db = ...` alias is emitted
171+
at the top and the dialect choice produces the correct Drizzle import path.
102172

103-
**(a) Hard break in 0.7.0.** Consumers update every call site. Clean, predictable, but
104-
forces a single coordinated cut for any active codebase. Documented in CHANGELOG.
173+
### Goldens (codegen-ts)
105174

106-
**(b) Soft transition: emit both shapes for one release.** Generated code includes:
107-
```ts
108-
// Legacy module-level import (deprecated; remove in 0.8.0)
109-
import { db as _db } from "../db";
110-
export async function findCouncilById(db: BaseSQLiteDatabase = _db, id: string) { ... }
111-
```
112-
Consumers migrate at leisure. Cleanup is automated in 0.8.0.
175+
- Regenerate with `UPDATE_GOLDEN=1 bun test packages/codegen-ts/test/golden/`. Affected
176+
files: ~20 `<Entity>.queries.ts` snapshots across `sqlite`, `postgres`, and the
177+
`package` layout fixture.
113178

114-
**(c) Codegen config flag: `queriesShape: "module-db" | "param-db"`.** Default flips to
115-
`param-db` in 0.7.0; consumers can opt back briefly. Aligns with the existing
116-
`columnNamingStrategy` precedent.
179+
### Runtime integration (runtime-ts)
117180

118-
Recommendation hypothesis: **(c)** — least disruptive, follows existing config-flag idiom,
119-
default flip in 0.7.0 with a CHANGELOG entry. But brainstorm should confirm.
181+
- `runtime-ts/test/`'s end-to-end suite already exercises a live libsql + a live
182+
postgres against generated queries. Update the test plumbing so the test creates
183+
its `db` and threads it through the helper calls (a few lines of test-side change).
184+
Test bodies confirming behaviour are unchanged.
120185

121-
### 2. Type-only signature for the `db` parameter
186+
### Docs validation
122187

123-
For Drizzle specifically: `BaseSQLiteDatabase<"async", { rowsAffected: number }>` is the
124-
narrowest shape covering both libsql and D1. For Postgres it's `NodePgDatabase` (or its
125-
generic). Codegen needs to emit the dialect-correct type — already known from
126-
`config.dialect`. Worth a small design note on how this type is imported (deep import
127-
from `drizzle-orm/*-core` vs. re-exported through a shim).
188+
- Type-check the recipe code blocks via a small CI helper that extracts the TypeScript
189+
fenced blocks and runs `tsc --noEmit` against them. Optional if extraction is fiddly;
190+
human review is acceptable for a v1 recipe.
128191

129-
### 3. Cross-port write-up timing
192+
## Migration story
130193

131-
TS implementation in 0.7.0 is concrete and we have the consumer pain to motivate it.
132-
Should the cross-language design land alongside, or as a follow-up after TS proves the
133-
shape? Recommendation: **TS first, cross-language doc in `spec/decisions/ADR-XXXX-...md`
134-
as TS lands**, so other ports inherit the rationale without blocking TS work.
194+
`bun add @metaobjectsdev/cli@0.7.0` → run `meta gen` → search-and-replace call sites:
135195

136-
## Out of scope (until brainstorm)
196+
| Before | After |
197+
|---|---|
198+
| `findUserById("abc")` | `findUserById(db, "abc")` |
199+
| `listUsers({ ... })` | `listUsers(db, { ... })` |
200+
| `createUser({ ... })` | `createUser(db, { ... })` |
201+
| `updateUserById("abc", { ... })` | `updateUserById(db, "abc", { ... })` |
202+
| `deleteUserById("abc")` | `deleteUserById(db, "abc")` |
137203

138-
- Touching `meta init` defaults to remove the consumer's expected `src/db/db.ts` from
139-
scaffold (will follow once the codegen change is in).
140-
- Removing the `dbImport` codegen config field (still useful for cross-target imports
141-
unrelated to this change).
142-
- Routes codegen (separate Generator). The `routesFile()` generator imports the same
143-
`db` via the queries module — once queries take `db` as a param, routes accept the
144-
same and forward it. Mechanical fallout.
204+
CHANGELOG entry covered by the existing
205+
[release-notes FR](./2026-05-25-fr-release-notes-and-naming-convention-docs.md).
206+
207+
Node-server consumers who prefer the old call-site shape write a one-line shim:
145208

146-
## Tests required when this lands
209+
```ts
210+
import { findUserById as _findUserById } from "./generated/User.queries";
211+
import { db as myDb } from "./db";
212+
export const findUserById = (id: string) => _findUserById(myDb, id);
213+
```
147214

148-
- `codegen-ts/test/templates/queries.test.ts` — every generated CRUD helper has `db` as
149-
its first parameter.
150-
- Golden snapshot updates in `codegen-ts/test/golden/__snapshots__/{sqlite,postgres}/`.
151-
- A new conformance check (or extension of existing one) verifying the parameter shape
152-
across dialects.
215+
This is the inverse of the dead-code stub Workers consumers ship today.
216+
217+
## Out of scope
218+
219+
- **New routes generator (Hono, Express, raw-fetch).** A separate follow-on FR ships
220+
if user demand surfaces. The docs recipe + plugin model are the answer in 0.7.0.
221+
- **Splitting `@metaobjectsdev/runtime-ts` into universal + framework-specific
222+
subpaths.** Not needed without a new framework target shipping.
223+
- **Per-verb skip mechanism** (`@routes: { create: false }`). YAGNI; today's routes
224+
generator has the same all-or-nothing property and consumers haven't asked.
225+
- **A new edge-runtime package** (`@metaobjectsdev/runtime-edge`). Workers consumers
226+
compose generated queries with their framework of choice; no new package needed.
227+
- **Codegen config flag** (`queriesShape: "module-db" | "param-db"`). Rejected
228+
during brainstorm: dual code paths + dead-code risk on the deprecated branch.
229+
- **Soft transition** (emit both shapes for one release). Rejected during brainstorm:
230+
generated code uglier than either pure form; doubles snapshot count; defers cleanup.
231+
232+
## File-level change summary
233+
234+
New files:
235+
236+
- `docs/recipes/wiring-generated-queries.md` — the consumer recipe (~200-300 lines).
237+
- `spec/decisions/ADR-0008-parameter-passing-generated-repo-helpers.md` — cross-language
238+
ADR (already landed alongside this FR).
239+
240+
Modified files:
241+
242+
- `server/typescript/packages/codegen-ts/src/templates/queries.ts` — all renderXxxFn()
243+
helpers updated to accept `db: Db` as first parameter.
244+
- `server/typescript/packages/codegen-ts/src/templates/queries-file.ts` — emits
245+
`type Db = ...` alias at top; drops the `import { db }` line.
246+
- `server/typescript/packages/codegen-ts/test/templates/queries.test.ts` — assertions
247+
updated.
248+
- `server/typescript/packages/codegen-ts/test/golden/__snapshots__/{sqlite,postgres,package}/**/*.queries.ts`
249+
regenerated via `UPDATE_GOLDEN=1`.
250+
- `server/typescript/packages/runtime-ts/test/**` — test plumbing creates `db` and
251+
passes it to helpers (test-side only, no production code changes).
252+
- `CHANGELOG.md` — 0.7.0 "Breaking" entry under "Changed" describing the migration.
253+
- `server/typescript/packages/codegen-ts/README.md` — link the new recipe.
153254

154255
## Open questions
155256

156-
1. What is the public commitment around backward compat between 0.6.x and 0.7.0? Hard
157-
break or soft transition?
158-
2. Should `@metaobjectsdev/runtime-ts` repo helpers (Kysely-based) follow the same
159-
pattern? Currently they take `db` already, so the runtime side is consistent. The
160-
gap is purely in codegen.
161-
3. Should this design extend to the routes generator (Fastify route registrar) so
162-
request-scoped DBs flow through the entire generated stack? Probably yes — Workers
163-
adopters need this end-to-end.
257+
None — all design decisions settled during brainstorm.

0 commit comments

Comments
 (0)