|
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). |
2 | 13 |
|
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 |
13 | 15 |
|
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. |
15 | 19 |
|
16 | 20 | ```ts |
| 21 | +// Before (0.6.0) |
17 | 22 | 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; |
21 | 35 | } |
22 | 36 | ``` |
23 | 37 |
|
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). |
46 | 110 |
|
47 | | -## Goal |
| 111 | +## Docs recipe deliverable |
48 | 112 |
|
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: |
51 | 116 |
|
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. |
55 | 119 |
|
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). |
64 | 124 |
|
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. |
66 | 131 |
|
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. |
71 | 135 |
|
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. |
76 | 139 |
|
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. |
78 | 145 |
|
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. |
80 | 149 |
|
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 | + ``` |
87 | 156 |
|
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. |
94 | 158 |
|
95 | | -## Brainstorm topics (open) |
| 159 | +The recipe is implementation work; the actual prose is produced by the plan, not this |
| 160 | +spec. |
96 | 161 |
|
97 | | -These need a brainstorm before implementation: |
| 162 | +## Tests + verification |
98 | 163 |
|
99 | | -### 1. Migration path for existing 0.6.0 TypeScript consumers |
| 164 | +### Unit tests (codegen-ts) |
100 | 165 |
|
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. |
102 | 172 |
|
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) |
105 | 174 |
|
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. |
113 | 178 |
|
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) |
117 | 180 |
|
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. |
120 | 185 |
|
121 | | -### 2. Type-only signature for the `db` parameter |
| 186 | +### Docs validation |
122 | 187 |
|
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. |
128 | 191 |
|
129 | | -### 3. Cross-port write-up timing |
| 192 | +## Migration story |
130 | 193 |
|
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: |
135 | 195 |
|
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")` | |
137 | 203 |
|
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: |
145 | 208 |
|
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 | +``` |
147 | 214 |
|
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. |
153 | 254 |
|
154 | 255 | ## Open questions |
155 | 256 |
|
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