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.ts — FindManyOptions<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.ts — ResolvedRelation + ResolvedRelations sidecar shape consumed by the compiler. Populated from extractSnapshot in 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 + 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.ts — extractSnapshot 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.ts — pickCompiler(dialect) returns compilePg for postgres, throw-stub for sqlite/mysql.
  • [x] packages/db/src/query/builder.ts — buildQueryNamespace(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.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() 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.ts — relations() 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.ts — SchemaToRelationsRegister<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.ts — RemoveEnumValue extended with values: readonly string[] (full new value list) + affectedColumns: readonly { table; column }[]. Old advisory-only kind retired.
  • [x] packages/db/src/diff/engine.ts — diffEnumsCreatePhase 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.ts — emitRemoveEnumValueRecreate(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.ts — RunnerOptions.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.ts — kick 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.ts — kickjsVitePlugin() 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.

Released under the MIT License. Built with TypeScript — runs on Express, Fastify, or h3.