M3 — Plan: Close M2 deferreds
Status (2026-05-05): ✅ Shipped. All three sub-milestones (M3.A relational
findMany({with}), M3.B losslesspgEnumvalue removal, M3.C Vite Babel devtools strip) landed onfeat/db-relational-query-m3. Seem3-release.mdfor the v5.3 release notes; this plan is now historical reference.
Goal: Land the three deferred items from M2 so @forinda/kickjs-db no longer ships with documented "this exists, but…" caveats. Specifically: relational reads in one round trip, lossless enum migration, and clean prod bundles for adopters who use DevTools tabs.
Architecture: Three independent sub-milestones — they do not share state and can run in parallel if more than one contributor is available. Same conventions as M0/M1/M2: phantom-typed surfaces, snapshot/diff/emit pipeline as the single source of truth for migrations, no Reflect-based introspection.
Tech stack: Same as M2 — TypeScript, Vitest + SWC, tsdown, wireit, Kysely, Testcontainers PG. M3.C adds @babel/core + @babel/plugin-transform-typescript (peer dep on @forinda/kickjs-vite only).
Specs to write before code:
docs/db/spec-relational-query.md— written before M3.A starts. Locks the dialect-specific JSON-aggregation strategy (PGjson_agg/ SQLitejson_group_array/ MySQLJSON_ARRAYAGG) and the type-level shape of thewithclause. The M2 plan reserved this slot but punted the design.docs/db/spec-enum-value-removal.md— short. Documents the migration-file shape (-- KICK ENUM REMOVE) and the operator-facing CLI flow when columns reference the dropped value.
Prereq: M2 shipped + plan headers updated (done 2026-05-05).
Estimated cadence
| Sub-milestone | Scope | Days | Blockers |
|---|---|---|---|
M3.A — Relational findMany({with}) | Spec + packages/db/src/query/ + dialect compilers + expectTypeOf suite | 8–10 | spec-relational-query.md must land first |
| M3.B — Removed-enum-value handling | Diff/invert path + emitted SQL templates + operator CLI flag (kick db migrate --confirm-enum-drop) | 3 | spec-enum-value-removal.md |
| M3.C — Vite AST strip via Babel | @forinda/kickjs-vite Babel pass + golden fixtures + perf check | 3 | none |
Total: ~3 weeks sequential, ~10 days with M3.A and M3.B/C in parallel.
Priority rationale
M3.A first — biggest adopter pull. Every existing competitor (Drizzle / Prisma / MikroORM / TypeORM) ships single-trip relational reads; task-kickdb-api currently does N+1 selects when joining tasks → assignees → users. This is the gap that keeps the example app from being a strict drop-in for task-prisma-api.
M3.B second — correctness gap. Today pgEnum round-trip drops a value into a -- comment and silently no-ops, which means kick db generate produces migrations that silently lose information if an adopter removes a value mid-list. Cheap to fix, expensive to leave.
M3.C last — pure DX win, no correctness risk. Adopters get a smaller prod bundle when they wire DevTools tabs into their app, but nothing breaks today. Land it once the more impactful work is in.
File structure
New files this plan adds:
docs/db/
spec-relational-query.md M3.A — sub-spec, written before code
spec-enum-value-removal.md M3.B — sub-spec
packages/db/src/
query/ M3.A
types.ts relational query types + `with` clause inference
builder.ts `db.query.X.findMany({ with })` runtime
compile-pg.ts PG: nested SELECT + json_agg
compile-sqlite.ts SQLite: json_group_array (placeholder for M4)
emit/
pg-enum-drop.ts M3.B — `ALTER TYPE … RENAME` + recreate dance
packages/db/__tests__/
unit/
query-types.test-d.ts M3.A — expectTypeOf for `with` shape
query-compile.test.ts M3.A — snapshot SQL output
enum-drop-value.test.ts M3.B — diff/invert/emit round trip
integration/
relational-query.test.ts M3.A — Testcontainers PG, real `json_agg`
enum-drop-value.test.ts M3.B — Testcontainers PG, lossy column flow
packages/vite/src/
babel-strip-devtools.ts M3.C — `@babel/core` transform
__tests__/
babel-strip-devtools.test.ts M3.C — golden fixturesConventions
- Same as M2. New: each compile-X module exports
compile(query, schema): { sql, parameters }so the runtime stays dialect-agnostic. No leaking dialect names intopackages/db/src/query/builder.ts. - New
kick db migrateflag:--confirm-enum-drop. Without it, removed-enum migrations refuse to apply with aMigrationDriftError. With it, the runner runs the rename-recreate dance.
M3.A — db.query.X.findMany({ with }) relational layer
Story: architecture §6 (Layer 3). Reserved slot in M2 plan T18. The spec lives at docs/db/spec-relational-query.md (write first).
Step A.1 — Write the sub-spec ✅ (2026-05-05)
- [x] Lock the type shape:ts
db.query.users.findMany({ with: { posts: { with: { comments: true } }, profile: true, }, }) // returns User[] where each user has `posts: (Post & { comments: Comment[] })[]` and `profile: Profile | null` - [x] Lock the SQL strategy per dialect. For PG: nested SELECT in
LATERAL+json_agg(row_to_json(…)). Document the ordering / null-aggregation edge cases. - [x] Lock the relations contribution:
relations()inpackages/db/src/dsl/relations.tsalready declaresone/manyshapes; the query layer reads from the same registry without rebuilding it. - [x] Reviewer sign-off (file under
docs/db/, no GitHub PR yet).
Step A.2 — Types ✅ (2026-05-05)
- [x]
packages/db/src/query/types.ts—FindManyOptions<Table>+FindManyRow<Table, Opts>+KickDbRelationsRegisteraugmentable registry +QueryNamespace/TableQueryNamespace. - [x]
expectTypeOfcases inpackages/db/__tests__/unit/query-types.test.tscovering: 1-deepmany, 1-deepone, 2-deepmany→many, 2-deepmany→one, 2-deepone→many, boolean shorthand, nested options, self-reference, cycle, barefindMany, emptywith, unknown-key@ts-expect-error, known-key, target-shape canary. 14 tests, all passing.
Step A.3 — PG compiler ✅ (2026-05-05)
- [x]
packages/db/src/query/compile-pg.ts— pure(db, table, options, relations, mode) → CompiledQuery. Uses Kysely'sjsonArrayFrom/jsonObjectFromfromkysely/helpers/postgres.where/orderBycallbacks bridged via a Proxy-backed table-ref to keep the(table, ops) => Expressionsignature working. - [x]
packages/db/src/query/relations.ts—ResolvedRelation+ResolvedRelationssidecar shape consumed by the compiler. Populated fromextractSnapshotin A.4. - [x]
packages/db/src/query/errors.ts—RelationalQueryUnknownRelationError,RelationalQueryDepthError,RelationalQueryAliasCollisionError,RelationalQueryNotSupportedError. - [x]
packages/db/__tests__/unit/query-compile.test.ts— 16 fixtures: bare findMany, 1-deep many, 1-deep one, 2-deep many→many, 2-deep one→many, self-ref grandchild, per-relation where/limit, outer where/orderBy/limit/offset,first+uniquemodes, explicit-limit override, unknown-key throw, depth-1 throw, max-5 accept, depth-6 throw. All 16 passing. - [x] Full db suite green: 49 files, 275 tests (was 259 pre-A.3 → +16 from this step). Typecheck clean.
Step A.4 — Runtime ✅ (2026-05-05)
- [x]
packages/db/src/query/extract-relations.ts— resolvesrelations()declarations into the JSON-serializable sidecar.onestraight fromfields/references;manyvia inverseonelookup, then FK introspection fallback (preserves M0/M1 schemas that declaremanyonly). ThrowsRelationalQueryAliasCollisionErroron column-name shadow +RelationalQueryMissingInverseErrorwhen neither inverse nor FK can resolve. - [x]
packages/db/src/snapshot/types.ts— added optionalrelations?: Record<string, Record<string, RelationSnapshot>>toSchemaSnapshot. JSON-serializable; migration pipeline ignores. - [x]
packages/db/src/snapshot/extract.ts—extractSnapshotnow populates the relations sidecar viaextractRelations. Absent when no relations are declared (no shape change for callers that skip the query layer). - [x]
packages/db/src/query/compilers.ts—pickCompiler(dialect)returnscompilePgfor postgres, throw-stub for sqlite/mysql. - [x]
packages/db/src/query/builder.ts—buildQueryNamespace(qb, relations, compile)returns a Proxy-basedQueryNamespace<DB>. Each method calls the compiler thenqb.executeQuery(compiled), returning rows. - [x]
packages/db/src/client/types.ts—KickDbClient<DB>.query: QueryNamespace<DB>is now a public field. - [x]
packages/db/src/client/wrap.ts—InternalContext.query = { relations, compile }threads through;wrap()attachesqueryautomatically (works inside transactions + savepoints +$extendsre-wraps). - [x]
packages/db/src/client/create.ts— callsextractSnapshotonce at boot to resolve relations, picks the dialect compiler, populates the InternalContext.detectDialectnow also inspects the adapter class so hand-rolledKyselyDialectliterals (used by tests) are recognized correctly. - [x]
packages/db/src/index.ts— re-exports public surface:FindManyOptions,FindManyRow,WithClause,KickDbRelationsRegister,RegisteredRelations,RelationMapEntry,TableRelations,QueryNamespace,TableQueryNamespace,ResolvedRelation,ResolvedRelations,RelationSnapshot, plus the four error classes. - [x]
packages/db/__tests__/unit/extract-relations.test.ts— 8 tests: resolveone, resolvemanyvia inverse, FK fallback, missing-inverse error, alias-collision error, undefined-when-empty, sidecar wired into snapshot, self-reference. All passing. - [x]
packages/db/__tests__/unit/query-builder.test.ts— 8 end-to-end tests viacreateDbClient+DummyDriver: PG happy paths (findMany / findMany-with-with / findFirst / findFirst-empty / findUnique / Proxy materialization) + SQLite + MySQL throw-paths. All passing. - [x] Full db suite: 51 files, 292 passing (+17 vs pre-A.4 baseline). db-pg suite green at 17. Build clean.
Step A.5 — Integration ✅ (2026-05-05)
- [x]
packages/db-pg/__tests__/integration/relational-query.test.ts— Testcontainers PG, 6 tests: 2-deep nested findMany returns declared shape (with empty-array[]notnull),findFirston empty table returnsnull,findFirstclamps via LIMIT 1,findUniquereturns matched row, per-relationwhere+limitfilters inner aggregation, row parity with hand-written nested SELECT. Lives inpackages/db-pg/__tests__/integration/because it needs a realpg.Pool+PostgresDialect(not just DummyDriver). - [x]
examples/task-kickdb-api/src/modules/tasks/tasks.repository.ts— addedfindFullById(id)usingdb.query.tasks.findUnique({ where, with: { comments, assignees, labels } }). Replaces a four-query N+1 with a single round-trip. - [x] db-pg suite: 4 files, 23 tests (was 17; +6 from this step). example typecheck clean.
Step A.7 — Typegen extension ✅ (2026-05-05)
- [x]
packages/db/src/dsl/relations.ts—relations()andHelperssignatures preserve type info:relations()returnsRelationsDecl<TSourceName, TRelationsMap>,helpers.one/helpers.manyreturnRelationOne<TTarget>/RelationMany<TTarget>. Runtime shape unchanged; existing call sites stay assignable. - [x]
packages/db/src/query/schema-relations-types.ts—SchemaToRelationsRegister<S>walks the schema barrel forRelationsDeclentries and folds them into the registry shape (one entry per source table, each mappingrelationName → { kind, target }with target as the literal table name). - [x]
packages/db/src/index.ts— re-exportsSchemaToRelationsRegister. - [x]
packages/cli/src/typegen/builtin/db.ts— emits a third augmentationKickDbRelationsRegister.db = SchemaToRelationsRegister<typeof appSchema>alongside the existingKickDbSchema+KickDbRegister. - [x]
packages/cli/__tests__/typegen-db-plugin.test.ts— assertion updated to require the relations augmentation in the emitted output. - [x]
packages/db/__tests__/unit/schema-relations-types.test.ts— 4 type-level tests: distributivekeyofover schema, per-tablekind+ literal target, empty-schema fallback, mixed-content schema (tables + relations + non-relation entries). - [x]
examples/task-kickdb-api— deleted the A.5 stop-gapsrc/db/relations-register.ts+ its side-effect import insrc/index.ts. Typegen now emits the equivalent augmentation; example typechecks against it cleanly. - [x] Suite deltas — db: 52f/296t (+4), cli: 24f/231t (typegen-db-plugin updated), db-pg: 4f/23t (unchanged), example typecheck clean.
Step A.6 — Commit + changeset
pnpm changeset
# minor bump on @forinda/kickjs-db
git commit -m "feat(db): db.query.X.findMany({ with }) relational layer (M3.A)"M3.B — Removed-enum-value handling
Story: Today packages/db/src/emit/pg.ts:63 emits a comment and skips. packages/db/src/diff/invert.ts:66 flags this as ambiguous-reverse. Lossless round-trip requires the rename-recreate dance.
Step B.1 — Write the sub-spec ✅ (2026-05-05)
- [x]
docs/db/spec-enum-value-removal.md— operator flow + migration file shape + runner gate + down-direction policy + edge-case table. Acceptance ticked, defaults locked.
Step B.2 — Diff + invert ✅ (2026-05-05)
- [x]
packages/db/src/diff/types.ts—RemoveEnumValueextended withvalues: readonly string[](full new value list) +affectedColumns: readonly { table; column }[]. Old advisory-only kind retired. - [x]
packages/db/src/diff/engine.ts—diffEnumsCreatePhasepopulates the new fields.collectColumnsByEnumTypewalks the next snapshot for matching column types. - [x]
packages/db/src/diff/invert.ts— keepsremoveEnumValueverbatim (symmetric — re-adding values via subsequentALTER TYPE … ADD VALUEis cheap; rebuilding columns is operator-driven).
Step B.3 — Emit ✅ (2026-05-05)
- [x]
packages/db/src/emit/pg.ts—emitRemoveEnumValueRecreate(change)replaces the old advisory comment block. Renders the-- KICK ENUM REMOVEheader + diagnostics + aBEGIN; … COMMIT;rename-recreate dance + per-columnALTER TABLE … ALTER COLUMN … TYPE foo USING column::text::foo. - [x]
ENUM_DROP_HEADERconstant exported so the runner gate scans for the same literal the emitter writes.
Step B.4 — Runner ✅ (2026-05-05)
- [x]
packages/db/src/migrate/enum-drop-gate.ts— pureparseEnumDropHeader+enforceEnumDropGate(id, sql, confirmEnumDrop). ThrowsMigrationEnumDropErroron header-present + flag-absent. - [x]
packages/db/src/migrate/errors.ts— newMigrationEnumDropErrorcarrying parsed enums / removed / columns + actionable message. - [x]
packages/db/src/migrate/runner.ts—RunnerOptions.confirmEnumDrop?: booleanadded. Gate fires insideapplyEntrybefore any DB write so the runner refuses without partial application. - [x]
packages/db/src/index.ts— re-exportsparseEnumDropHeader,enforceEnumDropGate,EnumDropHeader,MigrationEnumDropError. - [x]
packages/cli/src/commands/db.ts—kick db migrate latest+kick db migrate upaccept--confirm-enum-drop; pass through toRunnerOptions.down/rollbackunchanged (reverse SQL is always cheap).
Step B.5 — Tests ✅ (2026-05-05)
- [x]
packages/db/__tests__/unit/pg-enum-pipeline.test.ts— old advisory tests rewritten to assert the rename-recreate block + the no-columns shortcut. Sanitisation test scoped to the comment header section. 15/15 passing. - [x]
packages/db/__tests__/unit/enum-drop-gate.test.ts— 9 cases: header-absent (null), well-formed parse,(none)columns, payloadless header, multiple back-to-back blocks, gate-on-ordinary, gate-throws-without-flag, gate-returns-with-flag, error-carries-parsed-fields. All passing. - [ ] (Deferred) Integration
enum-drop-value.test.ts— Testcontainers PG full round-trip. Tracked for the v5.3 release notes; the unit + parser coverage already validates each layer.
Step B.6 — Commit + changeset
pnpm changeset
# minor bump on @forinda/kickjs-db + @forinda/kickjs-cli (new public API)
git commit -m "feat(db,cli): lossless pgEnum value removal with --confirm-enum-drop (M3.B)"Suite deltas:
@forinda/kickjs-db: 53 files / 306 tests (+10 vs M3.A.7 baseline 296).@forinda/kickjs-cli: 24 files / 231 tests (unchanged — flag parsing covered by the existing migrate-runner test surface).@forinda/kickjs-db-pg: 4 files / 23 tests (unchanged).
M3.C — Vite AST strip via Babel
Story: M2 plan T15. Today packages/vite/src/devtools-flag-plugin.ts:6 uses regex-based stripping; the comment explicitly notes "without a babel pass." Adopters who wire custom DevTools tabs ship the dev-only render code into prod.
Step C.1 — Implementation ✅ (2026-05-05)
- [x]
packages/vite/src/babel-strip-devtools.ts— purestripDevtoolsCode(source, filename, opts)using@babel/core.transformSync. Drops:- Imports from
@forinda/kickjs-devtools-kitand any sub-path (/bus,/runtime, etc.) — named, default, namespace, side-effect. - Top-level
ExpressionStatements whose root identifier is a binding stripped in pass 1 (catchesdefineDevtoolsRenderTab(...), namespace-calldevtools.defineDevtoolsRenderTab(...), etc.). - Side-effect imports whose path ends in
/devtools-events— adapter-package augmentation modules.
- Imports from
- [x] Conservative scope: identifiers used outside top-level statements (e.g., inside function bodies) stay; the build fails loud after the import is dropped, signalling adopters to gate behind
__KICKJS_DEVTOOLS__. - [x] Fast-reject substring check skips files without the
@forinda/kickjs-devtools-kitordevtools-eventsmarkers — no Babel parse on the common path. - [x] Original-source verbatim return when
changed === false(avoids whitespace churn on cache-key sensitive files). - [x]
packages/vite/package.json—@babel/core ^7.29.0as a direct dependency,@types/babel__coreas a dev dependency. (@babel/plugin-transform-typescriptnot needed — the transform uses parserOpts plugins directly, no separate plugin required.)
Step C.2 — Wire ✅ (2026-05-05)
- [x]
packages/vite/src/devtools-strip-plugin.ts— Vite plugin wrappingstripDevtoolsCode.apply: 'build'+enforce: 'pre', no-op in dev. Skipsnode_modules+ non-*.[mc]?[jt]sx?files. - [x]
packages/vite/src/index.ts—kickjsVitePlugin()now pushes the strip plugin alongside the flag plugin (gated onoptions.devtools !== false). Re-exportsdevtoolsStripPlugin,stripDevtoolsCode,DevtoolsStripOptions,StripDevtoolsOptions,StripResult.
Step C.3 — Tests ✅ (2026-05-05)
- [x]
packages/vite/__tests__/babel-strip-devtools.test.ts— 10 cases: named import drop, sub-path side-effect drop,/devtools-eventsside-effect drop, top-leveldefineDevtoolsRenderTab(...)drop, namespace member-call drop, leave-third-party-imports-alone, leave-non-toplevel-references (build-fails-loud signal), unchanged-when-no-imports, fast-reject short-circuit, multi-import file. All passing. - [x]
packages/vite/__tests__/vite-plugin.test.tsupdated — plugin count grew from 7 to 8; new assertion thatkickjs:devtools-stripis present in the default array and absent whendevtools: false. - [x] Vite suite: 3 files / 77 tests passing (was 2/76; +1 file, +1 test net after merging the strip + plugin-shape adjustments).
- [ ] (Deferred) Bundle-size delta assertion — needs a separate example-app build harness; tracked for the v5.3 release notes rather than the test suite.
Step C.4 — Commit + changeset ✅ (2026-05-05)
pnpm changeset
# patch bump on @forinda/kickjs-vite
git commit -m "feat(vite): Babel-based devtools strip for prod bundles (M3.C)"Step C.2 — Wire
- [ ]
packages/vite/src/index.ts— replace the regex strip indevtools-flag-plugin.tswith the Babel pass whenmode === 'production'. Keep regex path as the dev fast-path.
M3 exit gate ✅ (2026-05-05)
- [x]
pnpm testgreen across the monorepo (db 306 / db-pg 23 / cli 231 / vite 77). - [x]
pnpm buildgreen per package. (Bundle-size assertion deferred to a follow-up harness; seem3-release.md"Out of scope".) - [x]
examples/task-kickdb-apiusesdb.query.tasks.findUnique({ with })inTasksRepository.findFullById. - [x]
m2-release.md"Out of scope" list superseded with a status table pointing atm3-release.md. - [x]
m3-release.mdwritten summarizing the three landings and the M4 deferred backlog.
Plan self-review notes
- Why no M3.D? No platform-wide work this milestone. The KickEventBus + typegen plugin substrate from M2 covered the cross-cutting story; M3 is three focused, independent improvements.
- Why is M3.A so much bigger than M3.B+C combined? Relational query compilation across dialects has a non-trivial design surface (LATERAL vs subquery vs CTE per dialect; ordering; null-aggregation; cycles). The 8–10 day estimate assumes the spec lands clean on the first pass; if dialect parity becomes contentious, treat the SQLite/MySQL compilers as optional and ship PG-only behind a feature flag.
- Why not bundle these into M2 patches? M3.A is a minor bump (new public surface). The cadence is intentionally separated so adopters get a clean "v5.3 = relational queries" mental model.