M3 Release Notes — v5.3.0
Theme: close every M2 carry-over, ship a real relational read API.
M3 is the milestone where @forinda/kickjs-db stops shipping with the "this exists, but…" caveat in its release notes. Three deferred items from M2 land at once:
- Relational reads in one round trip —
db.query.X.findMany({ with })compiles to a single PostgreSQL query withjson_agg/to_jsonaggregation. End-to-end typed via theKickDbRelationsRegisteraugmentation that the kick/db typegen plugin emits alongside the existing column-shape one. - Lossless
pgEnumvalue removal —kick db generateproduces a real rename-recreate migration when an adopter removes a value, gated behind--confirm-enum-dropon the runner so the operator confirms the safety check before any DB write. - Babel-based devtools strip —
kickjsVitePlugin()ships a Babel transform that dropsdefineDevtoolsRenderTab(...)calls +@forinda/kickjs-devtools-kitimports from production bundles without requiring adopters to wrap every call in__KICKJS_DEVTOOLS__.
Adopter-facing wins
Relational reads in one round trip
const rows = await db.query.users.findMany({
where: (u, eb) => eb('isActive', '=', true),
orderBy: (_u, eb) => eb.ref('createdAt'),
with: {
posts: {
where: (p, eb) => eb.isNotNull(p.publishedAt),
with: { comments: true },
limit: 5,
},
},
limit: 20,
})
// ^^^ User[] where each user has `posts: (Post & { comments: Comment[] })[]`findMany / findFirst / findUnique all read through Kysely's executeQuery so existing events, plugins, and pool reuse keep working. PostgreSQL only in this release; SQLite and MySQL clients throw RelationalQueryNotSupportedError on first call until M4 lands those compilers.
with keys are checked against the KickDbRelationsRegister augmentation. The kick/db typegen plugin emits it alongside the column-shape one — adopters who run kick typegen (or kick dev) get auto-completion + compile-time errors on mistyped relation names. Adopters who hand-rolled the augmentation as an M3.A.5 stop-gap can delete that file once the typegen output covers it.
Single-trip findFullById in task-kickdb-api
The example app's TasksRepository.findFullById(id) replaces what was a four-query N+1:
findFullById(id: string) {
return this.db.query.tasks.findUnique({
where: (_t, eb) => eb('id', '=', id),
with: {
comments: true,
assignees: true,
labels: true,
},
})
}One round trip; Postgres handles the JSON aggregation; the row shape lands fully typed at the call site.
Lossless pgEnum value removal
Removing a value from pgEnum(...) now produces a migration with a -- KICK ENUM REMOVE header + the rename-recreate dance:
-- KICK ENUM REMOVE
-- enum: "task_priority"
-- removed: 'unused', 'archived'
-- columns: tasks.priority
--
-- This migration drops values from a PostgreSQL ENUM type. The
-- runner refuses to apply it without the --confirm-enum-drop flag
-- (or `confirmEnumDrop: true` in RunnerOptions). Inspect the
-- column USING clauses below to confirm rows holding a removed
-- value will fail loudly rather than silently coerce.
BEGIN;
ALTER TYPE "task_priority" RENAME TO "task_priority__old";
CREATE TYPE "task_priority" AS ENUM ('critical', 'high', 'medium', 'low', 'none');
ALTER TABLE "tasks"
ALTER COLUMN "priority" TYPE "task_priority"
USING "priority"::text::"task_priority";
DROP TYPE "task_priority__old";
COMMIT;The USING column::text::foo cast is the safety check — rows holding a removed value fail the cast and the whole transaction rolls back.
kick db migrate latest and kick db migrate up refuse to apply such a migration unless --confirm-enum-drop is passed. Without the flag, MigrationEnumDropError fires with the affected enums / values / columns before any DB write. Down-direction commands (down, rollback) bypass the gate — reversing a value removal is ALTER TYPE … ADD VALUE, always cheap.
Babel-based devtools strip
Adopters wiring custom DevTools tabs at module top level previously had to wrap every call in if (__KICKJS_DEVTOOLS__) { ... } to keep dev-only code out of prod bundles. The new kickjs:devtools-strip plugin handles the top-level case automatically. On vite build:
- Imports from
@forinda/kickjs-devtools-kit(and any sub-path) — dropped. - Top-level
defineDevtoolsRenderTab(...)/defineDevtoolsTab(...)calls bound from those imports — dropped. - Side-effect imports of
*/devtools-eventsaugmentation modules — dropped.
In dev (kick dev), the plugin is a no-op so the devtools UI keeps working.
The strip is conservative: identifiers used inside function bodies stay. After the import is dropped those references become unresolved and the build fails loud — a deliberate signal so adopters who need __KICKJS_DEVTOOLS__ gating still get one.
Type-level wins
KickDbRelationsRegister augmentation
The kick/db typegen plugin now emits three augmentations in .kickjs/types/kick__db.d.ts:
declare module '@forinda/kickjs-db' {
interface KickDbRegister {
db: KickDbClient<KickDbSchema>
}
interface KickDbRelationsRegister {
db: SchemaToRelationsRegister<typeof appSchema>
}
}SchemaToRelationsRegister<S> is a new public type-level helper. It walks the schema barrel for relations() declarations and folds them into the registry shape — keyed by source table, each entry mapping relationName → { kind, target } with the target shrunk to the literal table name. Adding or removing a relation in src/db/schema/relations.ts flows through to call-site type-checking automatically.
Type-narrowed relations()
relations(source, builder) and the Helpers.one / Helpers.many factories preserve the source name and target literal at the type level:
relations()returnsRelationsDecl<TSourceName, TRelationsMap>(wasRelationsDecl).Helpers.onereturnsRelationOne<TTarget>(wasRelationOne).Helpers.manyreturnsRelationMany<TTarget>(wasRelationMany).
Both RelationsDecl<TSource, TRelations> and the relation interfaces default their generics to the prior open shape, so existing extends RelationsDecl checks keep working — strictly narrowing, no break.
Operator-facing wins
--confirm-enum-drop flag
kick db migrate latest --confirm-enum-drop
kick db migrate up --confirm-enum-dropDown-direction commands (down, rollback) do not require the flag.
MigrationEnumDropError
Operators running kick db migrate latest against a pending migration with the -- KICK ENUM REMOVE header without the flag see:
Migration 20260505_002000_drop_priority_archived drops value(s)
'unused', 'archived' from PostgreSQL enum(s) "task_priority". Re-run
with `--confirm-enum-drop` (CLI) or `confirmEnumDrop: true`
(RunnerOptions) after reviewing the column-USING clauses in up.sql.The runner refuses before any DB write — no partial application.
MigrationEnumDropError exposed as a public type
Adopters running migrations programmatically can catch the error and surface a custom prompt:
try {
await migrateLatest({ adapter, migrationsDir })
} catch (err) {
if (err instanceof MigrationEnumDropError) {
if (await confirm(`Drop ${err.removed.length} enum values?`)) {
await migrateLatest({ adapter, migrationsDir, confirmEnumDrop: true })
}
} else {
throw err
}
}New public surface
@forinda/kickjs-db
Type-level (relational query):
KickDbRelationsRegister— adopter-augmentable registry; typegen emits.RegisteredRelations,RelationMapEntry,TableRelations<Table>.FindManyOptions<DB, Table>,FindManyRow<DB, Table, Opts>,WithClause<DB, Rels>.QueryNamespace<DB>,TableQueryNamespace<DB, Table>.ResolvedRelation,ResolvedRelations,RelationSnapshot.SchemaToRelationsRegister<S>.
Runtime (relational query):
KickDbClient<DB>.query: QueryNamespace<DB>— Proxy-backed namespace.- Errors:
RelationalQueryUnknownRelationError,RelationalQueryDepthError,RelationalQueryAliasCollisionError,RelationalQueryMissingInverseError,RelationalQueryNotSupportedError.
Migration runner (enum drop):
RunnerOptions.confirmEnumDrop?: boolean.MigrationEnumDropError.parseEnumDropHeader(sql),enforceEnumDropGate(id, sql, confirmEnumDrop),EnumDropHeader.RemoveEnumValueextended withvalues,affectedColumnsfor adopters who read the diff output programmatically.
@forinda/kickjs-cli
kick db migrate latest --confirm-enum-drop(also onkick db migrate up).kick/dbtypegen plugin emitsKickDbRelationsRegister.db = SchemaToRelationsRegister<typeof appSchema>alongside the existingKickDbRegister.
@forinda/kickjs-vite
kickjsVitePlugin()now registerskickjs:devtools-stripalongsidekickjs:devtools-flag(gated onoptions.devtools !== false).- Standalone exports:
devtoolsStripPlugin(opts?),stripDevtoolsCode(source, filename, opts?),DevtoolsStripOptions,StripDevtoolsOptions,StripResult. - New direct dependency:
@babel/core ^7.29.0.
Out of scope (deferred to M4)
- SQLite + MySQL relational query compilers. The interface is in place; the dialects throw
RelationalQueryNotSupportedErroron first call. Implementing them is straightforward (json_group_array+JSON_ARRAYAGG), just dialect-shaped work. relationNamefor multi-FK disambiguation. When two tables share more than one FK, ourmanyresolver currently fails on the FK introspection fallback. Drizzle's symmetricrelationName: 'foo'pattern lands as a follow-up.- Composite-type detection for
pgEnumremoval (PG records / arrays containing the enum). v1 emits the rename-recreate without scanning composites. - Bundle-size assertion for the Babel devtools strip — needs an example-app build harness; tracked for a follow-up.
- Testcontainers integration test for
pgEnumvalue removal — unit + parser coverage in this release; full PG round-trip is a follow-up.
Migration notes
From v5.2 adopter projects using relations()
The relations() helper now returns a more specific type. Existing callers stay assignable to the prior open shape; nothing to update unless code explicitly types intermediate values:
- const usersRelations: RelationsDecl = relations(users, ({ many }) => ({ posts: many(posts) }))
+ const usersRelations = relations(users, ({ many }) => ({ posts: many(posts) }))The explicit type annotation widens to the open shape and loses the typegen path. Drop the annotation and let inference flow.
From v5.2 adopter projects with hand-rolled KickDbRelationsRegister
If you wrote the augmentation by hand (M3.A.5 stop-gap), delete it. Re-running kick typegen produces the equivalent .kickjs/types/kick__db.d.ts output.
From v5.2 adopter projects using DevTools tabs
If you wrap every devtools call in if (__KICKJS_DEVTOOLS__) { ... }, no change. The flag plugin's constant-folding still runs first; the new Babel strip handles the residual cases the flag couldn't reach.
If you call defineDevtoolsRenderTab(...) at module top level without gating, the strip handles it automatically. References inside function bodies still need the flag — the strip leaves them alone, and the build fails loud after the import is dropped.
Stats
- 6 commits across
packages/db,packages/cli,packages/vite,examples/task-kickdb-api, anddocs/db. - 5 changesets staged on
feat/db-relational-query-m3:db-relational-query-types-and-pg-compiler.md—@forinda/kickjs-dbminor (M3.A.2 + A.3).db-relational-query-runtime-wireup.md—@forinda/kickjs-dbminor (M3.A.4).db-cli-relations-typegen.md—@forinda/kickjs-db+@forinda/kickjs-climinor (M3.A.7).vite-devtools-babel-strip.md—@forinda/kickjs-viteminor (M3.C).db-cli-pgenum-value-removal.md—@forinda/kickjs-db+@forinda/kickjs-climinor (M3.B).
- Test counts (final):
@forinda/kickjs-db: 53 files / 306 tests (was 199 at M2 cut; +107 net across the milestone).@forinda/kickjs-db-pg: 4 files / 23 tests (was 17; +6 from M3.A.5 real-PG integration).@forinda/kickjs-cli: 24 files / 231 tests (typegen-db-plugin updated for the relations augmentation).@forinda/kickjs-vite: 3 files / 77 tests (was 2 / 76; +1 file, +1 test net).