Skip to content

M3 — Plan: Close M2 deferreds

Status (2026-05-05):Shipped. All three sub-milestones (M3.A relational findMany({with}), M3.B lossless pgEnum value removal, M3.C Vite Babel devtools strip) landed on feat/db-relational-query-m3. See m3-release.md for 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 (PG json_agg / SQLite json_group_array / MySQL JSON_ARRAYAGG) and the type-level shape of the with clause. 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-milestoneScopeDaysBlockers
M3.A — Relational findMany({with})Spec + packages/db/src/query/ + dialect compilers + expectTypeOf suite8–10spec-relational-query.md must land first
M3.B — Removed-enum-value handlingDiff/invert path + emitted SQL templates + operator CLI flag (kick db migrate --confirm-enum-drop)3spec-enum-value-removal.md
M3.C — Vite AST strip via Babel@forinda/kickjs-vite Babel pass + golden fixtures + perf check3none

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 fixtures

Conventions

  • Same as M2. New: each compile-X module exports compile(query, schema): { sql, parameters } so the runtime stays dialect-agnostic. No leaking dialect names into packages/db/src/query/builder.ts.
  • New kick db migrate flag: --confirm-enum-drop. Without it, removed-enum migrations refuse to apply with a MigrationDriftError. 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() in packages/db/src/dsl/relations.ts already declares one/many shapes; 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.tsFindManyOptions<Table> + FindManyRow<Table, Opts> + KickDbRelationsRegister augmentable registry + QueryNamespace / TableQueryNamespace.
  • [x] expectTypeOf cases in packages/db/__tests__/unit/query-types.test.ts covering: 1-deep many, 1-deep one, 2-deep many→many, 2-deep many→one, 2-deep one→many, boolean shorthand, nested options, self-reference, cycle, bare findMany, empty with, 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's jsonArrayFrom / jsonObjectFrom from kysely/helpers/postgres. where / orderBy callbacks bridged via a Proxy-backed table-ref to keep the (table, ops) => Expression signature working.
  • [x] packages/db/src/query/relations.tsResolvedRelation + ResolvedRelations sidecar shape consumed by the compiler. Populated from extractSnapshot in A.4.
  • [x] packages/db/src/query/errors.tsRelationalQueryUnknownRelationError, 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 + unique modes, 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 — resolves relations() declarations into the JSON-serializable sidecar. one straight from fields/references; many via inverse one lookup, then FK introspection fallback (preserves M0/M1 schemas that declare many only). Throws RelationalQueryAliasCollisionError on column-name shadow + RelationalQueryMissingInverseError when neither inverse nor FK can resolve.
  • [x] packages/db/src/snapshot/types.ts — added optional relations?: Record<string, Record<string, RelationSnapshot>> to SchemaSnapshot. JSON-serializable; migration pipeline ignores.
  • [x] packages/db/src/snapshot/extract.tsextractSnapshot now populates the relations sidecar via extractRelations. Absent when no relations are declared (no shape change for callers that skip the query layer).
  • [x] packages/db/src/query/compilers.tspickCompiler(dialect) returns compilePg for postgres, throw-stub for sqlite/mysql.
  • [x] packages/db/src/query/builder.tsbuildQueryNamespace(qb, relations, compile) returns a Proxy-based QueryNamespace<DB>. Each method calls the compiler then qb.executeQuery(compiled), returning rows.
  • [x] packages/db/src/client/types.tsKickDbClient<DB>.query: QueryNamespace<DB> is now a public field.
  • [x] packages/db/src/client/wrap.tsInternalContext.query = { relations, compile } threads through; wrap() attaches query automatically (works inside transactions + savepoints + $extends re-wraps).
  • [x] packages/db/src/client/create.ts — calls extractSnapshot once at boot to resolve relations, picks the dialect compiler, populates the InternalContext. detectDialect now also inspects the adapter class so hand-rolled KyselyDialect literals (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: resolve one, resolve many via 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 via createDbClient + 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 [] not null), findFirst on empty table returns null, findFirst clamps via LIMIT 1, findUnique returns matched row, per-relation where + limit filters inner aggregation, row parity with hand-written nested SELECT. Lives in packages/db-pg/__tests__/integration/ because it needs a real pg.Pool + PostgresDialect (not just DummyDriver).
  • [x] examples/task-kickdb-api/src/modules/tasks/tasks.repository.ts — added findFullById(id) using db.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.tsrelations() and Helpers signatures preserve type info: relations() returns RelationsDecl<TSourceName, TRelationsMap>, helpers.one/helpers.many return RelationOne<TTarget>/RelationMany<TTarget>. Runtime shape unchanged; existing call sites stay assignable.
  • [x] packages/db/src/query/schema-relations-types.tsSchemaToRelationsRegister<S> walks the schema barrel for RelationsDecl entries and folds them into the registry shape (one entry per source table, each mapping relationName → { kind, target } with target as the literal table name).
  • [x] packages/db/src/index.ts — re-exports SchemaToRelationsRegister.
  • [x] packages/cli/src/typegen/builtin/db.ts — emits a third augmentation KickDbRelationsRegister.db = SchemaToRelationsRegister<typeof appSchema> alongside the existing KickDbSchema + 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: distributive keyof over schema, per-table kind + literal target, empty-schema fallback, mixed-content schema (tables + relations + non-relation entries).
  • [x] examples/task-kickdb-api — deleted the A.5 stop-gap src/db/relations-register.ts + its side-effect import in src/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

bash
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.tsRemoveEnumValue extended with values: readonly string[] (full new value list) + affectedColumns: readonly { table; column }[]. Old advisory-only kind retired.
  • [x] packages/db/src/diff/engine.tsdiffEnumsCreatePhase populates the new fields. collectColumnsByEnumType walks the next snapshot for matching column types.
  • [x] packages/db/src/diff/invert.ts — keeps removeEnumValue verbatim (symmetric — re-adding values via subsequent ALTER TYPE … ADD VALUE is cheap; rebuilding columns is operator-driven).

Step B.3 — Emit ✅ (2026-05-05)

  • [x] packages/db/src/emit/pg.tsemitRemoveEnumValueRecreate(change) replaces the old advisory comment block. Renders the -- KICK ENUM REMOVE header + diagnostics + a BEGIN; … COMMIT; rename-recreate dance + per-column ALTER TABLE … ALTER COLUMN … TYPE foo USING column::text::foo.
  • [x] ENUM_DROP_HEADER constant 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 — pure parseEnumDropHeader + enforceEnumDropGate(id, sql, confirmEnumDrop). Throws MigrationEnumDropError on header-present + flag-absent.
  • [x] packages/db/src/migrate/errors.ts — new MigrationEnumDropError carrying parsed enums / removed / columns + actionable message.
  • [x] packages/db/src/migrate/runner.tsRunnerOptions.confirmEnumDrop?: boolean added. Gate fires inside applyEntry before any DB write so the runner refuses without partial application.
  • [x] packages/db/src/index.ts — re-exports parseEnumDropHeader, enforceEnumDropGate, EnumDropHeader, MigrationEnumDropError.
  • [x] packages/cli/src/commands/db.tskick db migrate latest + kick db migrate up accept --confirm-enum-drop; pass through to RunnerOptions. down / rollback unchanged (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

bash
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 — pure stripDevtoolsCode(source, filename, opts) using @babel/core.transformSync. Drops:
    • Imports from @forinda/kickjs-devtools-kit and any sub-path (/bus, /runtime, etc.) — named, default, namespace, side-effect.
    • Top-level ExpressionStatements whose root identifier is a binding stripped in pass 1 (catches defineDevtoolsRenderTab(...), namespace-call devtools.defineDevtoolsRenderTab(...), etc.).
    • Side-effect imports whose path ends in /devtools-events — adapter-package augmentation modules.
  • [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-kit or devtools-events markers — 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.0 as a direct dependency, @types/babel__core as a dev dependency. (@babel/plugin-transform-typescript not 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 wrapping stripDevtoolsCode. apply: 'build' + enforce: 'pre', no-op in dev. Skips node_modules + non-*.[mc]?[jt]sx? files.
  • [x] packages/vite/src/index.tskickjsVitePlugin() now pushes the strip plugin alongside the flag plugin (gated on options.devtools !== false). Re-exports devtoolsStripPlugin, 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-events side-effect drop, top-level defineDevtoolsRenderTab(...) 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.ts updated — plugin count grew from 7 to 8; new assertion that kickjs:devtools-strip is present in the default array and absent when devtools: 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)

bash
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 in devtools-flag-plugin.ts with the Babel pass when mode === 'production'. Keep regex path as the dev fast-path.

M3 exit gate ✅ (2026-05-05)

  • [x] pnpm test green across the monorepo (db 306 / db-pg 23 / cli 231 / vite 77).
  • [x] pnpm build green per package. (Bundle-size assertion deferred to a follow-up harness; see m3-release.md "Out of scope".)
  • [x] examples/task-kickdb-api uses db.query.tasks.findUnique({ with }) in TasksRepository.findFullById.
  • [x] m2-release.md "Out of scope" list superseded with a status table pointing at m3-release.md.
  • [x] m3-release.md written 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.