M0 — Diff Engine Spike: Implementation Plan
Status: ✅ Shipped — see
m2-release.md. All 22 tasks landed in@forinda/kickjs-db@5.0.0(and earlier dev cuts). Checklist marked[x]on 2026-05-05 as a janitorial pass; this doc is now historical reference only.
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Prove the schema-diff → Postgres-DDL pipeline works end-to-end. From a TypeScript schema file, produce a deterministic SQL migration that creates the target schema in a real Postgres instance.
Architecture: Code-first schema DSL → JSON SchemaSnapshot IR → diff engine producing a ChangeSet IR → per-dialect SQL emitter → CLI command writing migration files. Pure functions throughout. No Kysely yet, no client yet, no down-migrations yet — those land in M1.
Tech Stack: TypeScript, Vitest + SWC, tsdown (library bundler), wireit (build orchestration), @testcontainers/postgresql for the integration test, pg for the raw connection inside it.
Spec: ./architecture.md — sections 4 (Schema DSL), 5 (Migration engine), 13 (Roadmap M0). Stories: ./stories.md — M0-S1 through M0-S6.
File Structure
New package packages/db/ (@forinda/kickjs-db). New CLI command file in packages/cli/. No changes to other existing packages.
packages/db/
package.json NEW
tsconfig.json NEW
tsconfig.test.json NEW
tsdown.config.ts NEW
vitest.config.ts NEW
README.md NEW
LICENSE NEW (MIT, copy from another package)
src/
index.ts NEW (barrel)
snapshot/
types.ts Task 2: SchemaSnapshot IR
extract.ts Task 7: walk schema → snapshot
dsl/
table.ts Task 5: table() factory
relations.ts Task 6: relations() stub
columns/
types.ts Task 3: ColumnBuilder, base types
builders.ts Task 3, 4: column constructors
index.ts Task 3, 4: barrel
constraints.ts Task 5: index, unique, primaryKey
diff/
types.ts Task 8: ChangeSet, Change types
engine.ts Tasks 9-13: diff() function
emit/
pg.ts Tasks 14-17: emitPg() function
identifiers.ts Task 14: quoteIdent, quoteLiteral
cli/
config.ts Task 19: kick.config.ts loader
generate.ts Tasks 20-21: generate command core
__tests__/
unit/
snapshot-roundtrip.test.ts Task 2
extract.test.ts Task 7
diff-create-drop.test.ts Task 9
diff-columns.test.ts Task 10
diff-alter.test.ts Task 11
diff-indexes-fks.test.ts Task 12
diff-rename.test.ts Task 13
emit-pg-create-drop.test.ts Task 15
emit-pg-columns.test.ts Task 16
emit-pg-indexes-fks.test.ts Task 17
cli-generate.test.ts Task 21
integration/
spike.test.ts Task 18
packages/cli/src/commands/db.ts Task 22: register kick db generate
packages/cli/src/cli.ts Task 22: wire command into rootThe diff and emit modules are split by concern, not by change-type. Each test file targets one slice (~5 fixtures per file), so a failure narrows quickly.
Conventions
- TDD: every task starts with a failing test, then minimal code, then green, then commit.
- Commits: one commit per task. Conventional Commits style. Reference the M0 story ID in parens.
- No npm/yarn: always
pnpm. Run from repo root unless a step says otherwise. - Pre-commit hook runs
build → test → format:check. If it fails, fix the underlying issue, re-stage, create a NEW commit (never--amend). - Branch: all work on
feat/db(already current branch per session start).
Task 1: Bootstrap @forinda/kickjs-db package skeleton
Story: Foundation for all of M0. Files:
Create:
packages/db/package.jsonCreate:
packages/db/tsconfig.jsonCreate:
packages/db/tsconfig.test.jsonCreate:
packages/db/tsdown.config.tsCreate:
packages/db/vitest.config.tsCreate:
packages/db/README.mdCreate:
packages/db/LICENSECreate:
packages/db/src/index.tsCreate:
packages/db/__tests__/unit/.gitkeepCreate:
packages/db/__tests__/integration/.gitkeep[x] Step 1.1: Create
packages/db/package.json
{
"name": "@forinda/kickjs-db",
"version": "5.0.2",
"description": "KickJS-native ORM — code-first schema, reversible migrations, multi-dialect SQL builder",
"keywords": [
"kickjs",
"orm",
"typescript",
"postgres",
"sqlite",
"mysql",
"migrations",
"query-builder",
"@forinda/kickjs"
],
"type": "module",
"main": "dist/index.mjs",
"types": "dist/index.d.mts",
"exports": {
".": {
"import": "./dist/index.mjs",
"types": "./dist/index.d.mts"
}
},
"files": ["dist"],
"scripts": {
"build": "wireit",
"dev": "tsdown --watch",
"test": "vitest run --passWithNoTests",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist .wireit",
"lint": "tsc --noEmit"
},
"wireit": {
"build": {
"command": "tsdown",
"files": ["src/**/*.ts", "tsdown.config.ts", "tsconfig.json", "package.json"],
"output": ["dist/**"],
"dependencies": []
}
},
"dependencies": {},
"peerDependencies": {
"@forinda/kickjs": ">=5.0.0"
},
"devDependencies": {
"@forinda/kickjs": "workspace:*",
"@testcontainers/postgresql": "^10.16.0",
"@types/node": "^25.6.0",
"@types/pg": "^8.11.10",
"pg": "^8.13.1",
"typescript": "^5.9.2"
},
"publishConfig": { "access": "public" },
"license": "MIT",
"author": "Felix Orinda",
"engines": { "node": ">=20.0" },
"homepage": "https://forinda.github.io/kick-js/",
"repository": {
"type": "git",
"url": "https://github.com/forinda/kick-js.git",
"directory": "packages/db"
},
"bugs": { "url": "https://github.com/forinda/kick-js/issues" }
}- [x] Step 1.2: Create
packages/db/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}- [x] Step 1.3: Create
packages/db/tsconfig.test.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"baseUrl": ".",
"types": [],
"paths": {
"@forinda/kickjs": ["../kickjs/src/index.ts"],
"@forinda/kickjs/*": ["../kickjs/src/*"],
"@forinda/kickjs-db": ["src/index.ts"],
"@forinda/kickjs-db/*": ["src/*"]
}
},
"include": ["src", "__tests__"]
}- [x] Step 1.4: Create
packages/db/tsdown.config.ts
import { defineConfig } from 'tsdown'
import { createBanner, readPkg } from '../../build.utils.mjs'
const pkg = readPkg(import.meta.dirname)
export default defineConfig({
entry: {
index: 'src/index.ts',
},
format: ['esm'],
platform: 'node',
dts: true,
external: ['@forinda/kickjs', /^node:/],
banner: { js: createBanner(pkg.name, pkg.version) },
})- [x] Step 1.5: Create
packages/db/vitest.config.ts
import { defineConfig } from 'vitest/config'
import swc from 'unplugin-swc'
import path from 'node:path'
export default defineConfig({
plugins: [
swc.vite({
jsc: {
parser: { syntax: 'typescript', decorators: true },
transform: { legacyDecorator: true, decoratorMetadata: true },
},
}),
],
resolve: {
alias: {
'@forinda/kickjs': path.resolve(__dirname, '../kickjs/src/index.ts'),
'@forinda/kickjs-db': path.resolve(__dirname, 'src/index.ts'),
},
},
test: {
typecheck: {
tsconfig: './tsconfig.test.json',
},
environment: 'node',
include: ['__tests__/**/*.test.ts'],
globals: false,
pool: 'threads',
maxConcurrency: 1,
testTimeout: 60_000,
},
})- [x] Step 1.6: Create
packages/db/README.md
# @forinda/kickjs-db
> KickJS-native ORM. Code-first schema, reversible migrations, multi-dialect SQL.
**Status:** Pre-release. M0 spike. See [docs/db/architecture.md](../../docs/db/architecture.md).
## Install
Not yet published.
## License
MIT- [x] Step 1.7: Copy LICENSE from an existing package's
LICENSE
cp packages/core/LICENSE packages/db/LICENSE- [x] Step 1.8: Create empty barrel
packages/db/src/index.ts
// @forinda/kickjs-db — barrel. Populated as M0 progresses.
export {}- [x] Step 1.9: Create test directory placeholders
mkdir -p packages/db/__tests__/unit packages/db/__tests__/integration
touch packages/db/__tests__/unit/.gitkeep packages/db/__tests__/integration/.gitkeep- [x] Step 1.10: Install workspace dependencies
Run from repo root:
pnpm installExpected: pnpm links the new workspace package, creates packages/db/node_modules, no errors.
- [x] Step 1.11: Verify build + test scaffolding
pnpm --filter @forinda/kickjs-db build
pnpm --filter @forinda/kickjs-db test
pnpm --filter @forinda/kickjs-db typecheckExpected:
buildsucceeds;dist/index.mjsanddist/index.d.mtsexist.testexits 0 with "no test files found" (passWithNoTests).typecheckexits 0.[x] Step 1.12: Commit
git add packages/db
git commit -m "$(cat <<'EOF'
feat(db): bootstrap @forinda/kickjs-db package skeleton (M0-S1)
Package shell with tsdown + wireit + vitest+swc, matching the standard
package shape. Empty barrel ready for the diff-engine spike.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 2: SchemaSnapshot IR — types + roundtrip test (M0-S1)
Story: M0-S1 — typed JSON-serializable IR shared by extract, diff, emit. Files:
Create:
packages/db/src/snapshot/types.tsCreate:
packages/db/__tests__/unit/snapshot-roundtrip.test.tsModify:
packages/db/src/index.ts[x] Step 2.1: Write the failing test
Create packages/db/__tests__/unit/snapshot-roundtrip.test.ts:
import { describe, it, expect } from 'vitest'
import type { SchemaSnapshot } from '@forinda/kickjs-db'
describe('SchemaSnapshot JSON roundtrip', () => {
it('preserves a 2-table snapshot through stringify/parse', () => {
const original: SchemaSnapshot = {
version: 1,
dialect: 'postgres',
tables: {
users: {
name: 'users',
columns: {
id: { name: 'id', type: 'serial', nullable: false, default: null, primaryKey: true },
email: {
name: 'email',
type: 'varchar(255)',
nullable: false,
default: null,
primaryKey: false,
},
},
indexes: [{ name: 'users_email_unique', columns: ['email'], unique: true }],
foreignKeys: [],
checks: [],
},
posts: {
name: 'posts',
columns: {
id: { name: 'id', type: 'serial', nullable: false, default: null, primaryKey: true },
authorId: {
name: 'author_id',
type: 'integer',
nullable: false,
default: null,
primaryKey: false,
},
},
indexes: [],
foreignKeys: [
{
name: 'posts_author_fk',
columns: ['author_id'],
refTable: 'users',
refColumns: ['id'],
onDelete: 'cascade',
onUpdate: 'no_action',
},
],
checks: [],
},
},
}
const roundtripped: SchemaSnapshot = JSON.parse(JSON.stringify(original))
expect(roundtripped).toEqual(original)
})
})- [x] Step 2.2: Run test to verify it fails
pnpm --filter @forinda/kickjs-db testExpected: FAIL — Cannot find module '@forinda/kickjs-db' or its corresponding type declarations or SchemaSnapshot is not exported.
- [x] Step 2.3: Write
packages/db/src/snapshot/types.ts
export type Dialect = 'postgres' | 'sqlite' | 'mysql'
export type FkAction = 'cascade' | 'restrict' | 'set_null' | 'set_default' | 'no_action'
export interface ColumnSnapshot {
name: string
type: string
nullable: boolean
default: string | null
primaryKey: boolean
}
export interface IndexSnapshot {
name: string
columns: string[]
unique: boolean
}
export interface ForeignKeySnapshot {
name: string
columns: string[]
refTable: string
refColumns: string[]
onDelete: FkAction
onUpdate: FkAction
}
export interface CheckSnapshot {
name: string
expression: string
}
export interface TableSnapshot {
name: string
columns: Record<string, ColumnSnapshot>
indexes: IndexSnapshot[]
foreignKeys: ForeignKeySnapshot[]
checks: CheckSnapshot[]
}
export interface SchemaSnapshot {
version: 1
dialect: Dialect
tables: Record<string, TableSnapshot>
}- [x] Step 2.4: Re-export from barrel
Edit packages/db/src/index.ts:
export type {
Dialect,
FkAction,
ColumnSnapshot,
IndexSnapshot,
ForeignKeySnapshot,
CheckSnapshot,
TableSnapshot,
SchemaSnapshot,
} from './snapshot/types'- [x] Step 2.5: Run test to verify it passes
pnpm --filter @forinda/kickjs-db testExpected: PASS — 1 test passed.
- [x] Step 2.6: Commit
git add packages/db/src/snapshot/types.ts packages/db/src/index.ts packages/db/__tests__/unit/snapshot-roundtrip.test.ts
git commit -m "$(cat <<'EOF'
feat(db): add SchemaSnapshot IR with JSON roundtrip test (M0-S1)
Defines the canonical IR consumed by extract, diff, and emit.
JSON-serializable by construction (no functions, dates as strings).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 3: Column DSL — ColumnBuilder base + serial, integer
Story: M0-S2 — first two of six column types for the spike. Files:
Create:
packages/db/src/dsl/columns/types.tsCreate:
packages/db/src/dsl/columns/builders.tsCreate:
packages/db/src/dsl/columns/index.tsModify:
packages/db/src/index.tsCreate:
packages/db/__tests__/unit/columns.test.ts[x] Step 3.1: Write the failing test
Create packages/db/__tests__/unit/columns.test.ts:
import { describe, it, expect } from 'vitest'
import { serial, integer } from '@forinda/kickjs-db'
describe('column builders — serial, integer', () => {
it('serial defaults: not null, primary-key-eligible', () => {
const col = serial()
expect(col.toJSON('id')).toEqual({
name: 'id',
type: 'serial',
nullable: false,
default: null,
primaryKey: false,
})
})
it('serial().primaryKey() flips primaryKey', () => {
expect(serial().primaryKey().toJSON('id').primaryKey).toBe(true)
})
it('integer is nullable by default', () => {
expect(integer().toJSON('age').nullable).toBe(true)
})
it('integer().notNull().default("0") sets defaults', () => {
const col = integer().notNull().default('0').toJSON('counter')
expect(col.nullable).toBe(false)
expect(col.default).toBe('0')
})
})- [x] Step 3.2: Run test — fails
pnpm --filter @forinda/kickjs-db testExpected: FAIL on missing exports serial, integer.
- [x] Step 3.3: Create
packages/db/src/dsl/columns/types.ts
import type { ColumnSnapshot } from '../../snapshot/types'
export interface ColumnState {
type: string
nullable: boolean
default: string | null
primaryKey: boolean
unique: boolean
references: { table: string; column: string; onDelete: string; onUpdate: string } | null
}
export class ColumnBuilder {
protected state: ColumnState
constructor(type: string, defaults: Partial<ColumnState> = {}) {
this.state = {
type,
nullable: defaults.nullable ?? true,
default: defaults.default ?? null,
primaryKey: defaults.primaryKey ?? false,
unique: defaults.unique ?? false,
references: defaults.references ?? null,
}
}
notNull(): this {
this.state.nullable = false
return this
}
default(value: string): this {
this.state.default = value
return this
}
primaryKey(): this {
this.state.primaryKey = true
this.state.nullable = false
return this
}
unique(): this {
this.state.unique = true
return this
}
toJSON(name: string): ColumnSnapshot {
return {
name,
type: this.state.type,
nullable: this.state.nullable,
default: this.state.default,
primaryKey: this.state.primaryKey,
}
}
// Internal accessor for table()/diff to read full state including unique/references.
__state(): Readonly<ColumnState> {
return this.state
}
}- [x] Step 3.4: Create
packages/db/src/dsl/columns/builders.ts
import { ColumnBuilder } from './types'
export function serial(): ColumnBuilder {
return new ColumnBuilder('serial', { nullable: false })
}
export function integer(): ColumnBuilder {
return new ColumnBuilder('integer')
}- [x] Step 3.5: Create barrel
packages/db/src/dsl/columns/index.ts
export { ColumnBuilder } from './types'
export type { ColumnState } from './types'
export { serial, integer } from './builders'- [x] Step 3.6: Re-export from package barrel
Append to packages/db/src/index.ts:
export * from './dsl/columns'- [x] Step 3.7: Run test — passes
pnpm --filter @forinda/kickjs-db testExpected: PASS — 5 tests passed (1 from Task 2 + 4 new).
- [x] Step 3.8: Commit
git add packages/db/src/dsl packages/db/src/index.ts packages/db/__tests__/unit/columns.test.ts
git commit -m "$(cat <<'EOF'
feat(db): add ColumnBuilder + serial/integer column types (M0-S2)
Base DSL for fluent column declaration. Other types follow the same shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 4: Column DSL — varchar, text, boolean, timestamp
Story: M0-S2 — remaining four spike column types. Files:
Modify:
packages/db/src/dsl/columns/builders.tsModify:
packages/db/src/dsl/columns/index.tsModify:
packages/db/__tests__/unit/columns.test.ts[x] Step 4.1: Extend the failing test
Append to packages/db/__tests__/unit/columns.test.ts:
import { varchar, text, boolean, timestamp } from '@forinda/kickjs-db'
describe('column builders — varchar, text, boolean, timestamp', () => {
it('varchar(255) emits parameterised type string', () => {
expect(varchar(255).toJSON('email').type).toBe('varchar(255)')
})
it('varchar() default length 255', () => {
expect(varchar().toJSON('s').type).toBe('varchar(255)')
})
it('text uses unbounded type', () => {
expect(text().toJSON('body').type).toBe('text')
})
it('boolean defaults nullable false-ish until notNull()', () => {
expect(boolean().toJSON('flag').nullable).toBe(true)
expect(boolean().notNull().toJSON('flag').nullable).toBe(false)
})
it('timestamp().defaultNow() resolves to a SQL default token', () => {
const col = timestamp().defaultNow().toJSON('createdAt')
expect(col.default).toBe('CURRENT_TIMESTAMP')
})
})- [x] Step 4.2: Run — fails
pnpm --filter @forinda/kickjs-db testExpected: FAIL on missing varchar, text, boolean, timestamp and defaultNow().
- [x] Step 4.3: Extend
packages/db/src/dsl/columns/builders.ts
Replace the file contents with:
import { ColumnBuilder } from './types'
export function serial(): ColumnBuilder {
return new ColumnBuilder('serial', { nullable: false })
}
export function integer(): ColumnBuilder {
return new ColumnBuilder('integer')
}
export function varchar(length = 255): ColumnBuilder {
return new ColumnBuilder(`varchar(${length})`)
}
export function text(): ColumnBuilder {
return new ColumnBuilder('text')
}
export function boolean(): ColumnBuilder {
return new ColumnBuilder('boolean')
}
export class TimestampBuilder extends ColumnBuilder {
constructor() {
super('timestamp')
}
defaultNow(): this {
this.state.default = 'CURRENT_TIMESTAMP'
return this
}
}
export function timestamp(): TimestampBuilder {
return new TimestampBuilder()
}- [x] Step 4.4: Update barrel
packages/db/src/dsl/columns/index.ts
export { ColumnBuilder, type ColumnState } from './types'
export { serial, integer, varchar, text, boolean, timestamp, TimestampBuilder } from './builders'- [x] Step 4.5: Run — passes
pnpm --filter @forinda/kickjs-db testExpected: PASS — 10 tests passed total.
- [x] Step 4.6: Commit
git add packages/db/src/dsl/columns packages/db/__tests__/unit/columns.test.ts
git commit -m "$(cat <<'EOF'
feat(db): add varchar/text/boolean/timestamp column types (M0-S2)
TimestampBuilder adds defaultNow() returning CURRENT_TIMESTAMP literal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 5: table() factory + index/unique constraints + references()
Story: M0-S2 — wire columns into a Table descriptor with constraints and FKs. Files:
Create:
packages/db/src/dsl/constraints.tsCreate:
packages/db/src/dsl/table.tsModify:
packages/db/src/dsl/columns/types.ts(addreferences()method)Modify:
packages/db/src/index.tsCreate:
packages/db/__tests__/unit/table.test.ts[x] Step 5.1: Write the failing test
Create packages/db/__tests__/unit/table.test.ts:
import { describe, it, expect } from 'vitest'
import { table, serial, integer, varchar, index, unique } from '@forinda/kickjs-db'
describe('table() factory', () => {
const users = table(
'users',
{
id: serial().primaryKey(),
email: varchar(255).notNull(),
},
(t) => ({
emailIdx: index('users_email_idx').on(t.email),
}),
)
it('exposes the table name', () => {
expect(users.__name).toBe('users')
})
it('exposes columns by property name', () => {
expect(Object.keys(users.__columns)).toEqual(['id', 'email'])
})
it('records single-column indexes from the third arg', () => {
expect(users.__indexes).toEqual([
{ name: 'users_email_idx', columns: ['email'], unique: false },
])
})
it('table reference proxy carries column names back to the constraint helper', () => {
expect(users.email.__name).toBe('email')
})
})
describe('FK references', () => {
const users = table('users', {
id: serial().primaryKey(),
})
const posts = table('posts', {
id: serial().primaryKey(),
authorId: integer()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
})
it('records FK on the column state', () => {
expect(posts.authorId.__state().references).toEqual({
table: 'users',
column: 'id',
onDelete: 'cascade',
onUpdate: 'no_action',
})
})
})
describe('unique constraint helper', () => {
const t = table(
'posts',
{
title: varchar(200).notNull(),
authorId: integer().notNull(),
},
(t) => ({
uniqSlug: unique('posts_slug_unique').on(t.title, t.authorId),
}),
)
it('records multi-column unique', () => {
expect(t.__indexes).toEqual([
{ name: 'posts_slug_unique', columns: ['title', 'authorId'], unique: true },
])
})
})- [x] Step 5.2: Run — fails
Expected: FAIL on missing table, index, unique, references.
- [x] Step 5.3: Add
references()toColumnBuilder
Edit packages/db/src/dsl/columns/types.ts — add this method to the class (between unique() and toJSON()):
references(target: () => { __tableName: string; __name: string }, opts: { onDelete?: string; onUpdate?: string } = {}): this {
const ref = target()
this.state.references = {
table: ref.__tableName,
column: ref.__name,
onDelete: opts.onDelete ?? 'no_action',
onUpdate: opts.onUpdate ?? 'no_action',
}
return this
}- [x] Step 5.4: Create
packages/db/src/dsl/constraints.ts
export interface IndexDecl {
name: string
columns: string[]
unique: boolean
}
interface ColRef {
__name: string
}
export function index(name: string) {
return {
on(...cols: ColRef[]): IndexDecl {
return { name, columns: cols.map((c) => c.__name), unique: false }
},
}
}
export function unique(name: string) {
return {
on(...cols: ColRef[]): IndexDecl {
return { name, columns: cols.map((c) => c.__name), unique: true }
},
}
}- [x] Step 5.5: Create
packages/db/src/dsl/table.ts
import type { ColumnBuilder } from './columns/types'
import type { IndexDecl } from './constraints'
export interface ColumnRef {
__tableName: string
__name: string
__builder: ColumnBuilder
__state: () => ReturnType<ColumnBuilder['__state']>
}
export interface TableDecl<
C extends Record<string, ColumnBuilder> = Record<string, ColumnBuilder>,
> {
__isTable: true
__name: string
__columns: C
__indexes: IndexDecl[]
}
type TableRefs<C extends Record<string, ColumnBuilder>> = TableDecl<C> & {
[K in keyof C]: ColumnRef
}
type ConstraintBuilder<C extends Record<string, ColumnBuilder>> = (refs: {
[K in keyof C]: ColumnRef
}) => Record<string, IndexDecl>
export function table<C extends Record<string, ColumnBuilder>>(
name: string,
columns: C,
constraints?: ConstraintBuilder<C>,
): TableRefs<C> {
const decl: TableDecl<C> = {
__isTable: true,
__name: name,
__columns: columns,
__indexes: [],
}
const refs = {} as { [K in keyof C]: ColumnRef }
for (const [key, builder] of Object.entries(columns) as [keyof C, ColumnBuilder][]) {
refs[key] = {
__tableName: name,
__name: key as string,
__builder: builder,
__state: () => builder.__state(),
}
}
if (constraints) {
const declared = constraints(refs)
decl.__indexes = Object.values(declared)
}
return Object.assign(decl, refs)
}- [x] Step 5.6: Update barrel
packages/db/src/index.ts— append:
export * from './dsl/table'
export * from './dsl/constraints'- [x] Step 5.7: Run — passes
pnpm --filter @forinda/kickjs-db testExpected: PASS — table tests + FK tests + unique tests green.
- [x] Step 5.8: Commit
git add packages/db/src/dsl packages/db/src/index.ts packages/db/__tests__/unit/table.test.ts
git commit -m "$(cat <<'EOF'
feat(db): add table() factory with index/unique/references (M0-S2)
table() returns a value that's both the metadata descriptor and a record
of column refs, so the (t) => ({ idx: index(...).on(t.email) }) callback
typechecks against the column set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 6: relations() stub (registered, not in snapshot)
Story: M0-S2 — relations declared but excluded from the snapshot per spec §4. Files:
Create:
packages/db/src/dsl/relations.tsModify:
packages/db/src/index.tsCreate:
packages/db/__tests__/unit/relations.test.ts[x] Step 6.1: Write the failing test
Create packages/db/__tests__/unit/relations.test.ts:
import { describe, it, expect } from 'vitest'
import { table, serial, integer, relations } from '@forinda/kickjs-db'
describe('relations()', () => {
const users = table('users', { id: serial().primaryKey() })
const posts = table('posts', {
id: serial().primaryKey(),
authorId: integer().notNull(),
})
const usersRelations = relations(users, ({ many }) => ({ posts: many(posts) }))
const postsRelations = relations(posts, ({ one }) => ({
author: one(users, { fields: [posts.authorId], references: [users.id] }),
}))
it('marks relation declarations with __isRelations', () => {
expect(usersRelations.__isRelations).toBe(true)
expect(postsRelations.__isRelations).toBe(true)
})
it('records source table name', () => {
expect(usersRelations.__sourceTable).toBe('users')
})
it('exposes relation map', () => {
expect(usersRelations.__relations.posts.kind).toBe('many')
expect(postsRelations.__relations.author.kind).toBe('one')
})
})- [x] Step 6.2: Run — fails
Expected: FAIL on missing relations.
- [x] Step 6.3: Create
packages/db/src/dsl/relations.ts
import type { TableDecl, ColumnRef } from './table'
import type { ColumnBuilder } from './columns/types'
interface RelationOne {
kind: 'one'
target: TableDecl<Record<string, ColumnBuilder>>
fields: ColumnRef[]
references: ColumnRef[]
}
interface RelationMany {
kind: 'many'
target: TableDecl<Record<string, ColumnBuilder>>
}
type Relation = RelationOne | RelationMany
interface RelationsDecl {
__isRelations: true
__sourceTable: string
__relations: Record<string, Relation>
}
interface Helpers {
one: (
target: TableDecl<Record<string, ColumnBuilder>>,
opts: { fields: ColumnRef[]; references: ColumnRef[] },
) => RelationOne
many: (target: TableDecl<Record<string, ColumnBuilder>>) => RelationMany
}
export function relations<T extends TableDecl<Record<string, ColumnBuilder>>>(
source: T,
builder: (h: Helpers) => Record<string, Relation>,
): RelationsDecl {
const helpers: Helpers = {
one: (target, opts) => ({
kind: 'one',
target,
fields: opts.fields,
references: opts.references,
}),
many: (target) => ({ kind: 'many', target }),
}
return {
__isRelations: true,
__sourceTable: source.__name,
__relations: builder(helpers),
}
}- [x] Step 6.4: Re-export — append to
packages/db/src/index.ts:
export * from './dsl/relations'- [x] Step 6.5: Run — passes
pnpm --filter @forinda/kickjs-db testExpected: PASS.
- [x] Step 6.6: Commit
git add packages/db/src/dsl/relations.ts packages/db/src/index.ts packages/db/__tests__/unit/relations.test.ts
git commit -m "$(cat <<'EOF'
feat(db): add relations() stub (M0-S2)
Relations are declared but deliberately excluded from snapshots —
they are query-time joining sugar, not DDL. M0 only validates the
declaration surface; M1 wires them into Layer 3 query API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 7: extractSnapshot(schema) — walk DSL → SchemaSnapshot
Story: M0-S2. Files:
Create:
packages/db/src/snapshot/extract.tsModify:
packages/db/src/index.tsCreate:
packages/db/__tests__/unit/extract.test.ts[x] Step 7.1: Write the failing test
Create packages/db/__tests__/unit/extract.test.ts:
import { describe, it, expect } from 'vitest'
import {
table,
relations,
serial,
integer,
varchar,
index,
unique,
extractSnapshot,
} from '@forinda/kickjs-db'
describe('extractSnapshot()', () => {
const users = table(
'users',
{
id: serial().primaryKey(),
email: varchar(255).notNull().unique(),
},
(t) => ({
emailIdx: index('users_email_idx').on(t.email),
}),
)
const posts = table(
'posts',
{
id: serial().primaryKey(),
authorId: integer()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
title: varchar(200).notNull(),
},
(t) => ({
uniqTitle: unique('posts_title_author_unique').on(t.title, t.authorId),
}),
)
const usersRelations = relations(users, ({ many }) => ({ posts: many(posts) }))
const schema = { users, posts, usersRelations }
const snap = extractSnapshot(schema, 'postgres')
it('emits version + dialect', () => {
expect(snap.version).toBe(1)
expect(snap.dialect).toBe('postgres')
})
it('skips relations decls (not DDL)', () => {
expect(Object.keys(snap.tables).sort()).toEqual(['posts', 'users'])
})
it('captures users.email as nullable=false varchar(255)', () => {
expect(snap.tables.users.columns.email).toEqual({
name: 'email',
type: 'varchar(255)',
nullable: false,
default: null,
primaryKey: false,
})
})
it('captures the unique on email', () => {
expect(snap.tables.users.indexes).toContainEqual({
name: 'users_email_unique',
columns: ['email'],
unique: true,
})
})
it('captures the named index from the constraint callback', () => {
expect(snap.tables.users.indexes).toContainEqual({
name: 'users_email_idx',
columns: ['email'],
unique: false,
})
})
it('captures the FK on posts.authorId', () => {
expect(snap.tables.posts.foreignKeys).toEqual([
{
name: 'posts_authorId_fk',
columns: ['authorId'],
refTable: 'users',
refColumns: ['id'],
onDelete: 'cascade',
onUpdate: 'no_action',
},
])
})
it('captures the multi-column unique', () => {
expect(snap.tables.posts.indexes).toContainEqual({
name: 'posts_title_author_unique',
columns: ['title', 'authorId'],
unique: true,
})
})
})- [x] Step 7.2: Run — fails
Expected: FAIL on extractSnapshot is not a function.
- [x] Step 7.3: Create
packages/db/src/snapshot/extract.ts
import type { ColumnBuilder } from '../dsl/columns/types'
import type { TableDecl } from '../dsl/table'
import type {
Dialect,
ForeignKeySnapshot,
IndexSnapshot,
SchemaSnapshot,
TableSnapshot,
} from './types'
interface MaybeTable {
__isTable?: boolean
__name?: string
__columns?: Record<string, ColumnBuilder>
__indexes?: IndexSnapshot[]
}
function isTable(v: unknown): v is TableDecl<Record<string, ColumnBuilder>> {
return Boolean(v && typeof v === 'object' && (v as MaybeTable).__isTable === true)
}
export function extractSnapshot(schema: Record<string, unknown>, dialect: Dialect): SchemaSnapshot {
const tables: Record<string, TableSnapshot> = {}
for (const value of Object.values(schema)) {
if (!isTable(value)) continue
tables[value.__name] = extractTable(value)
}
return { version: 1, dialect, tables }
}
function extractTable(t: TableDecl<Record<string, ColumnBuilder>>): TableSnapshot {
const columns: TableSnapshot['columns'] = {}
const indexes: IndexSnapshot[] = [...t.__indexes]
const foreignKeys: ForeignKeySnapshot[] = []
for (const [colKey, builder] of Object.entries(t.__columns)) {
columns[colKey] = builder.toJSON(colKey)
const state = builder.__state()
if (state.unique) {
indexes.push({ name: `${t.__name}_${colKey}_unique`, columns: [colKey], unique: true })
}
if (state.references) {
foreignKeys.push({
name: `${t.__name}_${colKey}_fk`,
columns: [colKey],
refTable: state.references.table,
refColumns: [state.references.column],
onDelete: state.references.onDelete as ForeignKeySnapshot['onDelete'],
onUpdate: state.references.onUpdate as ForeignKeySnapshot['onUpdate'],
})
}
}
return { name: t.__name, columns, indexes, foreignKeys, checks: [] }
}- [x] Step 7.4: Re-export — append to
packages/db/src/index.ts:
export { extractSnapshot } from './snapshot/extract'- [x] Step 7.5: Run — passes
pnpm --filter @forinda/kickjs-db testExpected: PASS.
- [x] Step 7.6: Commit
git add packages/db/src/snapshot/extract.ts packages/db/src/index.ts packages/db/__tests__/unit/extract.test.ts
git commit -m "$(cat <<'EOF'
feat(db): add extractSnapshot() walking DSL → SchemaSnapshot (M0-S2)
Walks a schema-export record, identifies tables via __isTable marker,
emits the canonical IR. Relations decls deliberately skipped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 8: ChangeSet IR types
Story: M0-S3 — typed change set. Files:
- Create:
packages/db/src/diff/types.ts - Modify:
packages/db/src/index.ts
(No test in this task — pure type declarations. Tested through Task 9 onwards.)
- [x] Step 8.1: Create
packages/db/src/diff/types.ts
import type {
ColumnSnapshot,
ForeignKeySnapshot,
IndexSnapshot,
TableSnapshot,
} from '../snapshot/types'
export interface CreateTable {
kind: 'createTable'
table: TableSnapshot
}
export interface DropTable {
kind: 'dropTable'
table: TableSnapshot
}
export interface RenameTable {
kind: 'renameTable'
from: string
to: string
}
export interface AddColumn {
kind: 'addColumn'
table: string
column: ColumnSnapshot
}
export interface DropColumn {
kind: 'dropColumn'
table: string
column: ColumnSnapshot
}
export interface RenameColumn {
kind: 'renameColumn'
table: string
from: string
to: string
}
export interface AlterColumn {
kind: 'alterColumn'
table: string
column: string
before: ColumnSnapshot
after: ColumnSnapshot
}
export interface AddIndex {
kind: 'addIndex'
table: string
index: IndexSnapshot
}
export interface DropIndex {
kind: 'dropIndex'
table: string
index: IndexSnapshot
}
export interface AddForeignKey {
kind: 'addForeignKey'
table: string
fk: ForeignKeySnapshot
}
export interface DropForeignKey {
kind: 'dropForeignKey'
table: string
fk: ForeignKeySnapshot
}
export type Change =
| CreateTable
| DropTable
| RenameTable
| AddColumn
| DropColumn
| RenameColumn
| AlterColumn
| AddIndex
| DropIndex
| AddForeignKey
| DropForeignKey
export type ChangeSet = Change[]- [x] Step 8.2: Re-export — append to
packages/db/src/index.ts:
export type * from './diff/types'- [x] Step 8.3: Verify typecheck
pnpm --filter @forinda/kickjs-db typecheckExpected: exit 0.
- [x] Step 8.4: Commit
git add packages/db/src/diff/types.ts packages/db/src/index.ts
git commit -m "$(cat <<'EOF'
feat(db): add ChangeSet IR types (M0-S3)
Typed discriminated union covering every DDL change M0 needs to express.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 9: diff() — table create / drop
Story: M0-S3. Files:
Create:
packages/db/src/diff/engine.tsModify:
packages/db/src/index.tsCreate:
packages/db/__tests__/unit/diff-create-drop.test.ts[x] Step 9.1: Write the failing test
Create packages/db/__tests__/unit/diff-create-drop.test.ts:
import { describe, it, expect } from 'vitest'
import { diff } from '@forinda/kickjs-db'
import type { SchemaSnapshot } from '@forinda/kickjs-db'
const empty: SchemaSnapshot = { version: 1, dialect: 'postgres', tables: {} }
const oneTable: SchemaSnapshot = {
version: 1,
dialect: 'postgres',
tables: {
users: {
name: 'users',
columns: {
id: { name: 'id', type: 'serial', nullable: false, default: null, primaryKey: true },
},
indexes: [],
foreignKeys: [],
checks: [],
},
},
}
describe('diff() — create/drop tables', () => {
it('empty → empty produces no changes', () => {
expect(diff(empty, empty)).toEqual([])
})
it('empty → oneTable produces createTable', () => {
const changes = diff(empty, oneTable)
expect(changes).toHaveLength(1)
expect(changes[0]).toMatchObject({ kind: 'createTable', table: { name: 'users' } })
})
it('oneTable → empty produces dropTable', () => {
const changes = diff(oneTable, empty)
expect(changes).toHaveLength(1)
expect(changes[0]).toMatchObject({ kind: 'dropTable', table: { name: 'users' } })
})
it('idempotent — same snapshot twice produces no changes', () => {
expect(diff(oneTable, oneTable)).toEqual([])
})
})- [x] Step 9.2: Run — fails
Expected: FAIL on diff is not a function.
- [x] Step 9.3: Create
packages/db/src/diff/engine.ts
import type { SchemaSnapshot, TableSnapshot } from '../snapshot/types'
import type { Change, ChangeSet } from './types'
export function diff(prev: SchemaSnapshot, next: SchemaSnapshot): ChangeSet {
const changes: Change[] = []
const prevTables = new Set(Object.keys(prev.tables))
const nextTables = new Set(Object.keys(next.tables))
// Drops first (so FKs that depend on dropped tables are handled before drops below)
for (const name of prevTables) {
if (!nextTables.has(name)) {
changes.push({ kind: 'dropTable', table: prev.tables[name] })
}
}
// Creates second
for (const name of nextTables) {
if (!prevTables.has(name)) {
changes.push({ kind: 'createTable', table: next.tables[name] })
}
}
// Common tables — column/index/fk diff comes in Tasks 10-12
for (const name of nextTables) {
if (!prevTables.has(name)) continue
diffTable(prev.tables[name], next.tables[name], changes)
}
return changes
}
function diffTable(_prev: TableSnapshot, _next: TableSnapshot, _changes: Change[]) {
// Filled in Tasks 10-12
}- [x] Step 9.4: Re-export — append to
packages/db/src/index.ts:
export { diff } from './diff/engine'- [x] Step 9.5: Run — passes
pnpm --filter @forinda/kickjs-db testExpected: PASS.
- [x] Step 9.6: Commit
git add packages/db/src/diff/engine.ts packages/db/src/index.ts packages/db/__tests__/unit/diff-create-drop.test.ts
git commit -m "$(cat <<'EOF'
feat(db): diff engine — table create/drop (M0-S3)
Diffs sets of table names. Per-table column/index/FK diff is a stub
filled in in subsequent tasks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 10: diff() — column add / drop
Story: M0-S3. Files:
Modify:
packages/db/src/diff/engine.tsCreate:
packages/db/__tests__/unit/diff-columns.test.ts[x] Step 10.1: Write the failing test
Create packages/db/__tests__/unit/diff-columns.test.ts:
import { describe, it, expect } from 'vitest'
import { diff } from '@forinda/kickjs-db'
import type { SchemaSnapshot, TableSnapshot } from '@forinda/kickjs-db'
const baseTable = (cols: TableSnapshot['columns']): TableSnapshot => ({
name: 'users',
columns: cols,
indexes: [],
foreignKeys: [],
checks: [],
})
const wrap = (t: TableSnapshot): SchemaSnapshot => ({
version: 1,
dialect: 'postgres',
tables: { users: t },
})
describe('diff() — column add/drop', () => {
it('adds a new column', () => {
const prev = wrap(
baseTable({
id: { name: 'id', type: 'serial', nullable: false, default: null, primaryKey: true },
}),
)
const next = wrap(
baseTable({
id: { name: 'id', type: 'serial', nullable: false, default: null, primaryKey: true },
email: {
name: 'email',
type: 'varchar(255)',
nullable: false,
default: null,
primaryKey: false,
},
}),
)
const changes = diff(prev, next)
expect(changes).toHaveLength(1)
expect(changes[0]).toMatchObject({
kind: 'addColumn',
table: 'users',
column: { name: 'email' },
})
})
it('drops a removed column', () => {
const prev = wrap(
baseTable({
id: { name: 'id', type: 'serial', nullable: false, default: null, primaryKey: true },
legacy: { name: 'legacy', type: 'text', nullable: true, default: null, primaryKey: false },
}),
)
const next = wrap(
baseTable({
id: { name: 'id', type: 'serial', nullable: false, default: null, primaryKey: true },
}),
)
const changes = diff(prev, next)
expect(changes).toHaveLength(1)
expect(changes[0]).toMatchObject({
kind: 'dropColumn',
table: 'users',
column: { name: 'legacy' },
})
})
it('add + drop in same diff', () => {
const prev = wrap(
baseTable({
a: { name: 'a', type: 'text', nullable: true, default: null, primaryKey: false },
}),
)
const next = wrap(
baseTable({
b: { name: 'b', type: 'text', nullable: true, default: null, primaryKey: false },
}),
)
const changes = diff(prev, next)
expect(changes).toHaveLength(2)
expect(changes.find((c) => c.kind === 'dropColumn')?.column.name).toBe('a')
expect(changes.find((c) => c.kind === 'addColumn')?.column.name).toBe('b')
})
})[x] Step 10.2: Run — fails (column-level tests fail; the diff stub does nothing for common tables).
[x] Step 10.3: Implement column diff in
packages/db/src/diff/engine.ts
Replace the body of diffTable with:
function diffTable(prev: TableSnapshot, next: TableSnapshot, changes: Change[]) {
const prevCols = new Set(Object.keys(prev.columns))
const nextCols = new Set(Object.keys(next.columns))
for (const c of prevCols) {
if (!nextCols.has(c)) {
changes.push({ kind: 'dropColumn', table: next.name, column: prev.columns[c] })
}
}
for (const c of nextCols) {
if (!prevCols.has(c)) {
changes.push({ kind: 'addColumn', table: next.name, column: next.columns[c] })
}
}
}- [x] Step 10.4: Run — passes
pnpm --filter @forinda/kickjs-db testExpected: PASS.
- [x] Step 10.5: Commit
git add packages/db/src/diff/engine.ts packages/db/__tests__/unit/diff-columns.test.ts
git commit -m "$(cat <<'EOF'
feat(db): diff engine — column add/drop (M0-S3)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 11: diff() — alter column
Story: M0-S3. Files:
Modify:
packages/db/src/diff/engine.tsCreate:
packages/db/__tests__/unit/diff-alter.test.ts[x] Step 11.1: Write the failing test
Create packages/db/__tests__/unit/diff-alter.test.ts:
import { describe, it, expect } from 'vitest'
import { diff } from '@forinda/kickjs-db'
import type { SchemaSnapshot, ColumnSnapshot } from '@forinda/kickjs-db'
const wrap = (col: ColumnSnapshot): SchemaSnapshot => ({
version: 1,
dialect: 'postgres',
tables: { t: { name: 't', columns: { c: col }, indexes: [], foreignKeys: [], checks: [] } },
})
const base: ColumnSnapshot = {
name: 'c',
type: 'integer',
nullable: true,
default: null,
primaryKey: false,
}
describe('diff() — alter column', () => {
it('detects type change', () => {
const changes = diff(wrap(base), wrap({ ...base, type: 'bigint' }))
expect(changes[0]).toMatchObject({
kind: 'alterColumn',
table: 't',
column: 'c',
before: { type: 'integer' },
after: { type: 'bigint' },
})
})
it('detects nullable change', () => {
const changes = diff(wrap(base), wrap({ ...base, nullable: false }))
expect(changes[0]).toMatchObject({ kind: 'alterColumn' })
})
it('detects default change', () => {
const changes = diff(wrap(base), wrap({ ...base, default: '0' }))
expect(changes[0]).toMatchObject({ kind: 'alterColumn' })
})
it('no change when columns equal', () => {
expect(diff(wrap(base), wrap(base))).toEqual([])
})
})[x] Step 11.2: Run — fails.
[x] Step 11.3: Extend
diffTableinpackages/db/src/diff/engine.ts:
Replace diffTable with:
function diffTable(prev: TableSnapshot, next: TableSnapshot, changes: Change[]) {
const prevCols = new Set(Object.keys(prev.columns))
const nextCols = new Set(Object.keys(next.columns))
for (const c of prevCols) {
if (!nextCols.has(c)) {
changes.push({ kind: 'dropColumn', table: next.name, column: prev.columns[c] })
}
}
for (const c of nextCols) {
if (!prevCols.has(c)) {
changes.push({ kind: 'addColumn', table: next.name, column: next.columns[c] })
continue
}
const before = prev.columns[c]
const after = next.columns[c]
if (!columnsEqual(before, after)) {
changes.push({ kind: 'alterColumn', table: next.name, column: c, before, after })
}
}
}
function columnsEqual(
a: import('../snapshot/types').ColumnSnapshot,
b: import('../snapshot/types').ColumnSnapshot,
): boolean {
return (
a.type === b.type &&
a.nullable === b.nullable &&
a.default === b.default &&
a.primaryKey === b.primaryKey
)
}[x] Step 11.4: Run — passes.
[x] Step 11.5: Commit
git add packages/db/src/diff/engine.ts packages/db/__tests__/unit/diff-alter.test.ts
git commit -m "$(cat <<'EOF'
feat(db): diff engine — alter column (M0-S3)
Detects type / nullable / default / primaryKey changes via deep equality.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 12: diff() — indexes + foreign keys
Story: M0-S3. Files:
Modify:
packages/db/src/diff/engine.tsCreate:
packages/db/__tests__/unit/diff-indexes-fks.test.ts[x] Step 12.1: Write the failing test
Create packages/db/__tests__/unit/diff-indexes-fks.test.ts:
import { describe, it, expect } from 'vitest'
import { diff } from '@forinda/kickjs-db'
import type { SchemaSnapshot, IndexSnapshot, ForeignKeySnapshot } from '@forinda/kickjs-db'
const idx = (name: string): IndexSnapshot => ({ name, columns: ['x'], unique: false })
const fk = (name: string): ForeignKeySnapshot => ({
name,
columns: ['x'],
refTable: 'other',
refColumns: ['id'],
onDelete: 'no_action',
onUpdate: 'no_action',
})
const wrap = (indexes: IndexSnapshot[], foreignKeys: ForeignKeySnapshot[]): SchemaSnapshot => ({
version: 1,
dialect: 'postgres',
tables: { t: { name: 't', columns: {}, indexes, foreignKeys, checks: [] } },
})
describe('diff() — indexes & FKs', () => {
it('adds new index', () => {
const c = diff(wrap([], []), wrap([idx('i1')], []))
expect(c[0]).toMatchObject({ kind: 'addIndex', table: 't', index: { name: 'i1' } })
})
it('drops removed index', () => {
const c = diff(wrap([idx('i1')], []), wrap([], []))
expect(c[0]).toMatchObject({ kind: 'dropIndex', table: 't', index: { name: 'i1' } })
})
it('adds new FK', () => {
const c = diff(wrap([], []), wrap([], [fk('f1')]))
expect(c[0]).toMatchObject({ kind: 'addForeignKey', table: 't', fk: { name: 'f1' } })
})
it('drops removed FK', () => {
const c = diff(wrap([], [fk('f1')]), wrap([], []))
expect(c[0]).toMatchObject({ kind: 'dropForeignKey', table: 't', fk: { name: 'f1' } })
})
})[x] Step 12.2: Run — fails.
[x] Step 12.3: Extend
diffTableinpackages/db/src/diff/engine.ts— append after the column loop:
diffByName(
prev.indexes,
next.indexes,
(i) => changes.push({ kind: 'dropIndex', table: next.name, index: i }),
(i) => changes.push({ kind: 'addIndex', table: next.name, index: i }),
)
diffByName(
prev.foreignKeys,
next.foreignKeys,
(f) => changes.push({ kind: 'dropForeignKey', table: next.name, fk: f }),
(f) => changes.push({ kind: 'addForeignKey', table: next.name, fk: f }),
)And add at module scope:
function diffByName<T extends { name: string }>(
prev: T[],
next: T[],
onDrop: (item: T) => void,
onAdd: (item: T) => void,
) {
const prevByName = new Map(prev.map((p) => [p.name, p]))
const nextByName = new Map(next.map((n) => [n.name, n]))
for (const [n, p] of prevByName) if (!nextByName.has(n)) onDrop(p)
for (const [n, x] of nextByName) if (!prevByName.has(n)) onAdd(x)
}[x] Step 12.4: Run — passes.
[x] Step 12.5: Commit
git add packages/db/src/diff/engine.ts packages/db/__tests__/unit/diff-indexes-fks.test.ts
git commit -m "$(cat <<'EOF'
feat(db): diff engine — indexes & foreign keys (M0-S3)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 13: diff() — rename heuristic
Story: M0-S3. Detects column rename when prev has a dropped column and next has an added column with same type + same constraints. Conservative — falls back to drop+add when ambiguous.
Files:
Modify:
packages/db/src/diff/engine.tsCreate:
packages/db/__tests__/unit/diff-rename.test.ts[x] Step 13.1: Write the failing test
Create packages/db/__tests__/unit/diff-rename.test.ts:
import { describe, it, expect } from 'vitest'
import { diff } from '@forinda/kickjs-db'
import type { SchemaSnapshot, ColumnSnapshot } from '@forinda/kickjs-db'
const col = (name: string, overrides: Partial<ColumnSnapshot> = {}): ColumnSnapshot => ({
name,
type: 'varchar(255)',
nullable: false,
default: null,
primaryKey: false,
...overrides,
})
const wrap = (cols: ColumnSnapshot[]): SchemaSnapshot => ({
version: 1,
dialect: 'postgres',
tables: {
t: {
name: 't',
columns: Object.fromEntries(cols.map((c) => [c.name, c])),
indexes: [],
foreignKeys: [],
checks: [],
},
},
})
describe('diff() — rename heuristic', () => {
it('detects rename when one drop + one add with identical attrs', () => {
const changes = diff(wrap([col('emailAddr')]), wrap([col('email')]))
expect(changes).toHaveLength(1)
expect(changes[0]).toMatchObject({
kind: 'renameColumn',
table: 't',
from: 'emailAddr',
to: 'email',
})
})
it('falls back to drop+add when types differ', () => {
const changes = diff(
wrap([col('a', { type: 'varchar(50)' })]),
wrap([col('b', { type: 'text' })]),
)
expect(changes.map((c) => c.kind).sort()).toEqual(['addColumn', 'dropColumn'])
})
it('does not rename when ambiguous (multiple matching adds/drops)', () => {
const changes = diff(wrap([col('a'), col('b')]), wrap([col('c'), col('d')]))
expect(changes.filter((c) => c.kind === 'renameColumn')).toHaveLength(0)
expect(changes).toHaveLength(4)
})
})[x] Step 13.2: Run — fails.
[x] Step 13.3: Implement rename detection in
packages/db/src/diff/engine.ts
Replace diffTable entirely:
function diffTable(prev: TableSnapshot, next: TableSnapshot, changes: Change[]) {
const prevCols = new Map(Object.entries(prev.columns))
const nextCols = new Map(Object.entries(next.columns))
const drops: string[] = []
const adds: string[] = []
for (const c of prevCols.keys()) if (!nextCols.has(c)) drops.push(c)
for (const c of nextCols.keys()) if (!prevCols.has(c)) adds.push(c)
// Rename heuristic — pair only if exactly one drop + one add with identical attrs.
if (drops.length === 1 && adds.length === 1) {
const before = prevCols.get(drops[0])!
const after = nextCols.get(adds[0])!
if (columnAttrsEqual(before, after)) {
changes.push({ kind: 'renameColumn', table: next.name, from: drops[0], to: adds[0] })
drops.length = 0
adds.length = 0
}
}
for (const c of drops) {
changes.push({ kind: 'dropColumn', table: next.name, column: prevCols.get(c)! })
}
for (const c of adds) {
changes.push({ kind: 'addColumn', table: next.name, column: nextCols.get(c)! })
}
// Common columns — alter detection
for (const c of nextCols.keys()) {
if (!prevCols.has(c)) continue
const before = prevCols.get(c)!
const after = nextCols.get(c)!
if (!columnsEqual(before, after)) {
changes.push({ kind: 'alterColumn', table: next.name, column: c, before, after })
}
}
diffByName(
prev.indexes,
next.indexes,
(i) => changes.push({ kind: 'dropIndex', table: next.name, index: i }),
(i) => changes.push({ kind: 'addIndex', table: next.name, index: i }),
)
diffByName(
prev.foreignKeys,
next.foreignKeys,
(f) => changes.push({ kind: 'dropForeignKey', table: next.name, fk: f }),
(f) => changes.push({ kind: 'addForeignKey', table: next.name, fk: f }),
)
}
function columnAttrsEqual(
a: import('../snapshot/types').ColumnSnapshot,
b: import('../snapshot/types').ColumnSnapshot,
): boolean {
// Like columnsEqual but ignores name (since rename is *about* name change).
return (
a.type === b.type &&
a.nullable === b.nullable &&
a.default === b.default &&
a.primaryKey === b.primaryKey
)
}[x] Step 13.4: Run — passes.
[x] Step 13.5: Commit
git add packages/db/src/diff/engine.ts packages/db/__tests__/unit/diff-rename.test.ts
git commit -m "$(cat <<'EOF'
feat(db): diff engine — column rename heuristic (M0-S3)
Conservative: only renames on exactly one drop + one add with identical
attrs. Ambiguous cases fall back to drop+add.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 14: PG identifier + literal helpers
Story: M0-S4. Foundation for all SQL emission. Files:
Create:
packages/db/src/emit/identifiers.tsCreate:
packages/db/__tests__/unit/identifiers.test.ts[x] Step 14.1: Write the failing test
Create packages/db/__tests__/unit/identifiers.test.ts:
import { describe, it, expect } from 'vitest'
import { quoteIdent, quoteLiteral } from '../../src/emit/identifiers'
describe('quoteIdent', () => {
it('wraps in double quotes', () => {
expect(quoteIdent('users')).toBe('"users"')
})
it('escapes embedded double quotes', () => {
expect(quoteIdent('we"ird')).toBe('"we""ird"')
})
it('handles dotted refs by quoting each segment', () => {
expect(quoteIdent('public.users')).toBe('"public"."users"')
})
})
describe('quoteLiteral', () => {
it('wraps in single quotes', () => {
expect(quoteLiteral('hello')).toBe("'hello'")
})
it('escapes single quotes', () => {
expect(quoteLiteral("it's")).toBe("'it''s'")
})
})[x] Step 14.2: Run — fails.
[x] Step 14.3: Create
packages/db/src/emit/identifiers.ts
export function quoteIdent(name: string): string {
return name
.split('.')
.map((part) => '"' + part.replace(/"/g, '""') + '"')
.join('.')
}
export function quoteLiteral(value: string): string {
return "'" + value.replace(/'/g, "''") + "'"
}[x] Step 14.4: Run — passes.
[x] Step 14.5: Commit
git add packages/db/src/emit/identifiers.ts packages/db/__tests__/unit/identifiers.test.ts
git commit -m "$(cat <<'EOF'
feat(db): add quoteIdent + quoteLiteral helpers (M0-S4)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 15: PG emit — CreateTable + DropTable + RenameTable
Story: M0-S4. Files:
Create:
packages/db/src/emit/pg.tsModify:
packages/db/src/index.tsCreate:
packages/db/__tests__/unit/emit-pg-create-drop.test.ts[x] Step 15.1: Write the failing test
Create packages/db/__tests__/unit/emit-pg-create-drop.test.ts:
import { describe, it, expect } from 'vitest'
import { emitPg } from '@forinda/kickjs-db'
import type { ChangeSet, TableSnapshot } from '@forinda/kickjs-db'
const usersTable: TableSnapshot = {
name: 'users',
columns: {
id: { name: 'id', type: 'serial', nullable: false, default: null, primaryKey: true },
email: {
name: 'email',
type: 'varchar(255)',
nullable: false,
default: null,
primaryKey: false,
},
},
indexes: [],
foreignKeys: [],
checks: [],
}
describe('emitPg() — create/drop/rename table', () => {
it('emits CREATE TABLE', () => {
const changes: ChangeSet = [{ kind: 'createTable', table: usersTable }]
expect(emitPg(changes)).toBe(
'CREATE TABLE "users" (\n' +
' "id" serial NOT NULL,\n' +
' "email" varchar(255) NOT NULL,\n' +
' PRIMARY KEY ("id")\n' +
');',
)
})
it('emits DROP TABLE', () => {
const changes: ChangeSet = [{ kind: 'dropTable', table: usersTable }]
expect(emitPg(changes)).toBe('DROP TABLE "users";')
})
it('emits ALTER TABLE RENAME', () => {
const changes: ChangeSet = [{ kind: 'renameTable', from: 'users', to: 'accounts' }]
expect(emitPg(changes)).toBe('ALTER TABLE "users" RENAME TO "accounts";')
})
})[x] Step 15.2: Run — fails.
[x] Step 15.3: Create
packages/db/src/emit/pg.ts
import type { Change, ChangeSet } from '../diff/types'
import type { ColumnSnapshot, TableSnapshot } from '../snapshot/types'
import { quoteIdent, quoteLiteral } from './identifiers'
export function emitPg(changes: ChangeSet): string {
return changes.map(emitChange).join('\n')
}
function emitChange(change: Change): string {
switch (change.kind) {
case 'createTable':
return emitCreateTable(change.table)
case 'dropTable':
return `DROP TABLE ${quoteIdent(change.table.name)};`
case 'renameTable':
return `ALTER TABLE ${quoteIdent(change.from)} RENAME TO ${quoteIdent(change.to)};`
default:
return `-- unsupported in M0: ${change.kind}`
}
}
function emitCreateTable(t: TableSnapshot): string {
const cols = Object.values(t.columns).map(emitColumnDecl)
const pk = Object.values(t.columns)
.filter((c) => c.primaryKey)
.map((c) => quoteIdent(c.name))
const lines = [...cols]
if (pk.length > 0) lines.push(`PRIMARY KEY (${pk.join(', ')})`)
return `CREATE TABLE ${quoteIdent(t.name)} (\n ${lines.join(',\n ')}\n);`
}
function emitColumnDecl(c: ColumnSnapshot): string {
let s = `${quoteIdent(c.name)} ${c.type}`
if (!c.nullable) s += ' NOT NULL'
if (c.default !== null) s += ` DEFAULT ${formatDefault(c.default)}`
return s
}
function formatDefault(value: string): string {
// SQL keywords/functions stay bare; everything else is treated as a literal.
const upper = value.toUpperCase()
if (upper === 'CURRENT_TIMESTAMP' || upper === 'NOW()') return value
if (/^-?\d+(\.\d+)?$/.test(value)) return value // numeric
if (value === 'true' || value === 'false') return value // boolean literal
return quoteLiteral(value)
}- [x] Step 15.4: Re-export — append to
packages/db/src/index.ts:
export { emitPg } from './emit/pg'[x] Step 15.5: Run — passes.
[x] Step 15.6: Commit
git add packages/db/src/emit packages/db/src/index.ts packages/db/__tests__/unit/emit-pg-create-drop.test.ts
git commit -m "$(cat <<'EOF'
feat(db): pg emitter — CREATE/DROP/RENAME TABLE (M0-S4)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 16: PG emit — column add/drop/alter/rename
Story: M0-S4. Files:
Modify:
packages/db/src/emit/pg.tsCreate:
packages/db/__tests__/unit/emit-pg-columns.test.ts[x] Step 16.1: Write the failing test
Create packages/db/__tests__/unit/emit-pg-columns.test.ts:
import { describe, it, expect } from 'vitest'
import { emitPg } from '@forinda/kickjs-db'
import type { ChangeSet } from '@forinda/kickjs-db'
const before = { name: 'age', type: 'integer', nullable: true, default: null, primaryKey: false }
const after = { name: 'age', type: 'bigint', nullable: false, default: '0', primaryKey: false }
describe('emitPg() — column changes', () => {
it('ADD COLUMN', () => {
const cs: ChangeSet = [
{
kind: 'addColumn',
table: 'users',
column: {
name: 'email',
type: 'varchar(255)',
nullable: false,
default: null,
primaryKey: false,
},
},
]
expect(emitPg(cs)).toBe('ALTER TABLE "users" ADD COLUMN "email" varchar(255) NOT NULL;')
})
it('DROP COLUMN', () => {
const cs: ChangeSet = [
{
kind: 'dropColumn',
table: 'users',
column: { name: 'legacy', type: 'text', nullable: true, default: null, primaryKey: false },
},
]
expect(emitPg(cs)).toBe('ALTER TABLE "users" DROP COLUMN "legacy";')
})
it('RENAME COLUMN', () => {
const cs: ChangeSet = [{ kind: 'renameColumn', table: 'users', from: 'emailAddr', to: 'email' }]
expect(emitPg(cs)).toBe('ALTER TABLE "users" RENAME COLUMN "emailAddr" TO "email";')
})
it('ALTER COLUMN — type + nullable + default', () => {
const cs: ChangeSet = [{ kind: 'alterColumn', table: 'users', column: 'age', before, after }]
expect(emitPg(cs)).toBe(
'ALTER TABLE "users" ALTER COLUMN "age" TYPE bigint USING "age"::bigint;\n' +
'ALTER TABLE "users" ALTER COLUMN "age" SET NOT NULL;\n' +
'ALTER TABLE "users" ALTER COLUMN "age" SET DEFAULT 0;',
)
})
it('ALTER COLUMN — drop default + drop NOT NULL', () => {
const cs: ChangeSet = [
{
kind: 'alterColumn',
table: 'users',
column: 'age',
before: { name: 'age', type: 'integer', nullable: false, default: '0', primaryKey: false },
after: { name: 'age', type: 'integer', nullable: true, default: null, primaryKey: false },
},
]
expect(emitPg(cs)).toBe(
'ALTER TABLE "users" ALTER COLUMN "age" DROP DEFAULT;\n' +
'ALTER TABLE "users" ALTER COLUMN "age" DROP NOT NULL;',
)
})
})[x] Step 16.2: Run — fails (the M0 stub returns
-- unsupported).[x] Step 16.3: Extend
emitPg'semitChangeswitch
Replace the default branch and add new cases. Full updated emitChange:
function emitChange(change: Change): string {
switch (change.kind) {
case 'createTable':
return emitCreateTable(change.table)
case 'dropTable':
return `DROP TABLE ${quoteIdent(change.table.name)};`
case 'renameTable':
return `ALTER TABLE ${quoteIdent(change.from)} RENAME TO ${quoteIdent(change.to)};`
case 'addColumn':
return emitAddColumn(change.table, change.column)
case 'dropColumn':
return `ALTER TABLE ${quoteIdent(change.table)} DROP COLUMN ${quoteIdent(change.column.name)};`
case 'renameColumn':
return `ALTER TABLE ${quoteIdent(change.table)} RENAME COLUMN ${quoteIdent(change.from)} TO ${quoteIdent(change.to)};`
case 'alterColumn':
return emitAlterColumn(change.table, change.before, change.after)
case 'addIndex':
return emitAddIndex(change.table, change.index)
case 'dropIndex':
return `DROP INDEX ${quoteIdent(change.index.name)};`
case 'addForeignKey':
return emitAddFk(change.table, change.fk)
case 'dropForeignKey':
return `ALTER TABLE ${quoteIdent(change.table)} DROP CONSTRAINT ${quoteIdent(change.fk.name)};`
}
}Add the helpers at the bottom of the file:
function emitAddColumn(table: string, c: ColumnSnapshot): string {
return `ALTER TABLE ${quoteIdent(table)} ADD COLUMN ${emitColumnDecl(c)};`
}
function emitAlterColumn(table: string, before: ColumnSnapshot, after: ColumnSnapshot): string {
const stmts: string[] = []
const t = quoteIdent(table)
const c = quoteIdent(after.name)
if (before.type !== after.type) {
stmts.push(`ALTER TABLE ${t} ALTER COLUMN ${c} TYPE ${after.type} USING ${c}::${after.type};`)
}
if (before.nullable !== after.nullable) {
stmts.push(
`ALTER TABLE ${t} ALTER COLUMN ${c} ${after.nullable ? 'DROP NOT NULL' : 'SET NOT NULL'};`,
)
}
if (before.default !== after.default) {
stmts.push(
after.default === null
? `ALTER TABLE ${t} ALTER COLUMN ${c} DROP DEFAULT;`
: `ALTER TABLE ${t} ALTER COLUMN ${c} SET DEFAULT ${formatDefault(after.default)};`,
)
}
return stmts.join('\n')
}(The addIndex and addFk helpers are stubbed for now; Task 17 fills them with real bodies. The case 'addIndex' line above will route to the helper added next task. Add temporary stubs at the bottom of the file so the file compiles after this step:)
function emitAddIndex(_table: string, _i: import('../snapshot/types').IndexSnapshot): string {
return '-- index emit: filled in Task 17'
}
function emitAddFk(_table: string, _fk: import('../snapshot/types').ForeignKeySnapshot): string {
return '-- fk emit: filled in Task 17'
}[x] Step 16.4: Run — passes (column tests).
[x] Step 16.5: Commit
git add packages/db/src/emit/pg.ts packages/db/__tests__/unit/emit-pg-columns.test.ts
git commit -m "$(cat <<'EOF'
feat(db): pg emitter — ADD/DROP/ALTER/RENAME COLUMN (M0-S4)
ALTER COLUMN emits a sequence (TYPE / NOT NULL / DEFAULT) so each clause
is independently reviewable. Index and FK emit stubbed until Task 17.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 17: PG emit — indexes + foreign keys + serial desugar
Story: M0-S4. Files:
Modify:
packages/db/src/emit/pg.tsCreate:
packages/db/__tests__/unit/emit-pg-indexes-fks.test.ts[x] Step 17.1: Write the failing test
Create packages/db/__tests__/unit/emit-pg-indexes-fks.test.ts:
import { describe, it, expect } from 'vitest'
import { emitPg } from '@forinda/kickjs-db'
import type { ChangeSet } from '@forinda/kickjs-db'
describe('emitPg() — indexes & FKs', () => {
it('CREATE INDEX (non-unique)', () => {
const cs: ChangeSet = [
{
kind: 'addIndex',
table: 'users',
index: { name: 'users_email_idx', columns: ['email'], unique: false },
},
]
expect(emitPg(cs)).toBe('CREATE INDEX "users_email_idx" ON "users" ("email");')
})
it('CREATE UNIQUE INDEX', () => {
const cs: ChangeSet = [
{
kind: 'addIndex',
table: 'users',
index: { name: 'users_email_unique', columns: ['email'], unique: true },
},
]
expect(emitPg(cs)).toBe('CREATE UNIQUE INDEX "users_email_unique" ON "users" ("email");')
})
it('multi-column unique', () => {
const cs: ChangeSet = [
{
kind: 'addIndex',
table: 'posts',
index: { name: 'posts_slug', columns: ['title', 'authorId'], unique: true },
},
]
expect(emitPg(cs)).toBe('CREATE UNIQUE INDEX "posts_slug" ON "posts" ("title", "authorId");')
})
it('ADD FOREIGN KEY with cascade', () => {
const cs: ChangeSet = [
{
kind: 'addForeignKey',
table: 'posts',
fk: {
name: 'posts_author_fk',
columns: ['authorId'],
refTable: 'users',
refColumns: ['id'],
onDelete: 'cascade',
onUpdate: 'no_action',
},
},
]
expect(emitPg(cs)).toBe(
'ALTER TABLE "posts" ADD CONSTRAINT "posts_author_fk" ' +
'FOREIGN KEY ("authorId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE NO ACTION;',
)
})
})[x] Step 17.2: Run — fails.
[x] Step 17.3: Replace stubs at the bottom of
packages/db/src/emit/pg.ts
function emitAddIndex(table: string, i: import('../snapshot/types').IndexSnapshot): string {
const cols = i.columns.map(quoteIdent).join(', ')
return `CREATE${i.unique ? ' UNIQUE' : ''} INDEX ${quoteIdent(i.name)} ON ${quoteIdent(table)} (${cols});`
}
const FK_ACTIONS: Record<string, string> = {
cascade: 'CASCADE',
restrict: 'RESTRICT',
set_null: 'SET NULL',
set_default: 'SET DEFAULT',
no_action: 'NO ACTION',
}
function emitAddFk(table: string, fk: import('../snapshot/types').ForeignKeySnapshot): string {
const cols = fk.columns.map(quoteIdent).join(', ')
const refCols = fk.refColumns.map(quoteIdent).join(', ')
return (
`ALTER TABLE ${quoteIdent(table)} ADD CONSTRAINT ${quoteIdent(fk.name)} ` +
`FOREIGN KEY (${cols}) REFERENCES ${quoteIdent(fk.refTable)} (${refCols}) ` +
`ON DELETE ${FK_ACTIONS[fk.onDelete]} ON UPDATE ${FK_ACTIONS[fk.onUpdate]};`
)
}[x] Step 17.4: Run — passes.
[x] Step 17.5: Commit
git add packages/db/src/emit/pg.ts packages/db/__tests__/unit/emit-pg-indexes-fks.test.ts
git commit -m "$(cat <<'EOF'
feat(db): pg emitter — indexes, FKs, FK actions (M0-S4)
CREATE [UNIQUE] INDEX and ALTER TABLE ADD CONSTRAINT FOREIGN KEY,
mapping snapshot FkAction enum to PG SQL keywords.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 18: Testcontainer integration test (M0-S5)
Story: M0-S5. Apply emitted SQL against real Postgres; introspect; assert parity with target snapshot. Files:
Create:
packages/db/__tests__/integration/spike.test.ts[x] Step 18.1: Write the integration test
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql'
import pg from 'pg'
import {
table,
relations,
serial,
integer,
varchar,
index,
unique,
extractSnapshot,
diff,
emitPg,
} from '@forinda/kickjs-db'
import type { SchemaSnapshot } from '@forinda/kickjs-db'
const users = table(
'users',
{
id: serial().primaryKey(),
email: varchar(255).notNull().unique(),
},
(t) => ({
emailIdx: index('users_email_idx').on(t.email),
}),
)
const posts = table(
'posts',
{
id: serial().primaryKey(),
authorId: integer()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
title: varchar(200).notNull(),
},
(t) => ({
uniqTitle: unique('posts_title_author_unique').on(t.title, t.authorId),
}),
)
const usersRelations = relations(users, ({ many }) => ({ posts: many(posts) }))
let container: StartedPostgreSqlContainer
let client: pg.Client
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16-alpine').start()
client = new pg.Client({
host: container.getHost(),
port: container.getMappedPort(5432),
user: container.getUsername(),
password: container.getPassword(),
database: container.getDatabase(),
})
await client.connect()
}, 90_000)
afterAll(async () => {
await client?.end()
await container?.stop()
})
describe('spike — full pipeline (PG)', () => {
it('extract → diff → emit → apply → introspect produces target schema', async () => {
const target = extractSnapshot({ users, posts, usersRelations }, 'postgres')
const empty: SchemaSnapshot = { version: 1, dialect: 'postgres', tables: {} }
const sql = emitPg(diff(empty, target))
await client.query(sql)
// Verify users + posts exist
const tables = await client.query<{ table_name: string }>(`
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' ORDER BY table_name
`)
expect(tables.rows.map((r) => r.table_name)).toEqual(['posts', 'users'])
// Verify users.email is varchar(255) NOT NULL
const cols = await client.query<{
column_name: string
data_type: string
is_nullable: string
character_maximum_length: number | null
}>(`
SELECT column_name, data_type, is_nullable, character_maximum_length
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'users'
ORDER BY ordinal_position
`)
const email = cols.rows.find((r) => r.column_name === 'email')
expect(email).toBeDefined()
expect(email!.data_type).toBe('character varying')
expect(email!.character_maximum_length).toBe(255)
expect(email!.is_nullable).toBe('NO')
// Verify FK posts.authorId -> users.id
const fks = await client.query<{ constraint_name: string; on_delete: string }>(`
SELECT tc.constraint_name, rc.delete_rule AS on_delete
FROM information_schema.table_constraints tc
JOIN information_schema.referential_constraints rc USING (constraint_name)
WHERE tc.table_name = 'posts' AND tc.constraint_type = 'FOREIGN KEY'
`)
expect(fks.rows).toHaveLength(1)
expect(fks.rows[0].constraint_name).toBe('posts_authorId_fk')
expect(fks.rows[0].on_delete).toBe('CASCADE')
// Verify index users_email_idx exists
const idxs = await client.query<{ indexname: string }>(`
SELECT indexname FROM pg_indexes WHERE tablename = 'users' AND indexname = 'users_email_idx'
`)
expect(idxs.rows).toHaveLength(1)
}, 60_000)
})- [x] Step 18.2: Run — should pass
pnpm --filter @forinda/kickjs-db testExpected: PASS. Container start ~30s, test runs in <5s.
Note: if Docker isn't running, the test fails at container start. Document in README that integration tests require Docker.
- [x] Step 18.3: Commit
git add packages/db/__tests__/integration/spike.test.ts
git commit -m "$(cat <<'EOF'
test(db): full-pipeline integration test on real PG (M0-S5)
Builds the canonical 2-table schema, runs extract → diff → emit → apply
against a Testcontainers Postgres 16 instance, introspects with
information_schema/pg_indexes, and asserts table/column/FK/index parity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 19: kick.config.ts loader
Story: M0-S6 — minimal config reader for the CLI. Files:
Create:
packages/db/src/cli/config.tsModify:
packages/db/src/index.tsCreate:
packages/db/__tests__/unit/cli-config.test.tsCreate:
packages/db/__tests__/fixtures/kick.config.demo.ts[x] Step 19.1: Create fixture
packages/db/__tests__/fixtures/kick.config.demo.ts
export default {
db: {
schemaPath: './packages/db/__tests__/fixtures/schema.demo.ts',
migrationsDir: './packages/db/__tests__/fixtures/migrations',
dialect: 'postgres' as const,
},
}- [x] Step 19.2: Write the failing test
Create packages/db/__tests__/unit/cli-config.test.ts:
import { describe, it, expect } from 'vitest'
import { resolveDbConfig } from '../../src/cli/config'
describe('resolveDbConfig', () => {
it('reads schemaPath/migrationsDir/dialect from config', async () => {
const cfg = await resolveDbConfig({
configPath: './packages/db/__tests__/fixtures/kick.config.demo.ts',
})
expect(cfg).toEqual({
schemaPath: './packages/db/__tests__/fixtures/schema.demo.ts',
migrationsDir: './packages/db/__tests__/fixtures/migrations',
dialect: 'postgres',
})
})
it('returns sensible defaults when absent', async () => {
const cfg = await resolveDbConfig({
configPath: './packages/db/__tests__/fixtures/kick.config.empty.ts',
})
expect(cfg.dialect).toBe('postgres')
expect(cfg.schemaPath).toBe('src/db/schema.ts')
expect(cfg.migrationsDir).toBe('db/migrations')
})
})Also create packages/db/__tests__/fixtures/kick.config.empty.ts:
export default {}[x] Step 19.3: Run — fails.
[x] Step 19.4: Create
packages/db/src/cli/config.ts
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import type { Dialect } from '../snapshot/types'
export interface DbConfig {
schemaPath: string
migrationsDir: string
dialect: Dialect
}
export async function resolveDbConfig(opts: { configPath: string }): Promise<DbConfig> {
const abs = path.resolve(opts.configPath)
const mod = await import(pathToFileURL(abs).href)
const cfg = mod.default ?? mod
const db = cfg?.db ?? {}
return {
schemaPath: db.schemaPath ?? 'src/db/schema.ts',
migrationsDir: db.migrationsDir ?? 'db/migrations',
dialect: db.dialect ?? 'postgres',
}
}- [x] Step 19.5: Re-export — append to
packages/db/src/index.ts:
export { resolveDbConfig, type DbConfig } from './cli/config'[x] Step 19.6: Run — passes.
[x] Step 19.7: Commit
git add packages/db/src/cli packages/db/src/index.ts packages/db/__tests__/unit/cli-config.test.ts packages/db/__tests__/fixtures
git commit -m "$(cat <<'EOF'
feat(db): add resolveDbConfig() loading kick.config.ts (M0-S6)
Reads db.schemaPath / db.migrationsDir / db.dialect with defaults.
Uses dynamic import + pathToFileURL for ESM compat.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 20: generate command core — schema load + diff + emit + write files
Story: M0-S6. Files:
Create:
packages/db/src/cli/generate.tsModify:
packages/db/src/index.tsCreate:
packages/db/__tests__/fixtures/schema.demo.ts[x] Step 20.1: Create demo schema
packages/db/__tests__/fixtures/schema.demo.ts
import { table, serial, varchar } from '../../src/index'
export const users = table('users', {
id: serial().primaryKey(),
email: varchar(255).notNull().unique(),
})- [x] Step 20.2: Create
packages/db/src/cli/generate.ts
import path from 'node:path'
import { mkdir, readFile, writeFile, readdir } from 'node:fs/promises'
import { pathToFileURL } from 'node:url'
import { existsSync } from 'node:fs'
import { extractSnapshot } from '../snapshot/extract'
import { diff } from '../diff/engine'
import { emitPg } from '../emit/pg'
import type { DbConfig } from './config'
import type { SchemaSnapshot } from '../snapshot/types'
export interface GenerateOptions {
name: string
config: DbConfig
cwd: string
now?: () => Date
}
export interface GenerateResult {
status: 'created' | 'no-changes'
migrationDir?: string
changeCount: number
}
export async function generate(opts: GenerateOptions): Promise<GenerateResult> {
const schemaAbs = path.resolve(opts.cwd, opts.config.schemaPath)
const migrationsAbs = path.resolve(opts.cwd, opts.config.migrationsDir)
const schemaModule = await import(pathToFileURL(schemaAbs).href)
const target = extractSnapshot(schemaModule, opts.config.dialect)
const prev = await readLatestSnapshot(migrationsAbs)
const changes = diff(prev, target)
if (changes.length === 0) {
return { status: 'no-changes', changeCount: 0 }
}
const id = formatId(opts.now?.() ?? new Date(), opts.name)
const dir = path.join(migrationsAbs, id)
await mkdir(dir, { recursive: true })
const upSql = '-- REVIEWED: false\n' + emitPg(changes) + '\n'
await writeFile(path.join(dir, 'up.sql'), upSql, 'utf8')
await writeFile(path.join(dir, 'snapshot.json'), JSON.stringify(target, null, 2) + '\n', 'utf8')
await writeFile(
path.join(dir, 'meta.json'),
JSON.stringify(
{
id,
name: opts.name,
createdAt: (opts.now?.() ?? new Date()).toISOString(),
reviewed: false,
dialect: opts.config.dialect,
},
null,
2,
) + '\n',
'utf8',
)
return { status: 'created', migrationDir: dir, changeCount: changes.length }
}
async function readLatestSnapshot(migrationsDir: string): Promise<SchemaSnapshot> {
if (!existsSync(migrationsDir)) {
return { version: 1, dialect: 'postgres', tables: {} }
}
const entries = await readdir(migrationsDir)
const dirs = entries.filter((e) => /^\d{8}_\d{6}_/.test(e)).sort()
if (dirs.length === 0) {
return { version: 1, dialect: 'postgres', tables: {} }
}
const latest = dirs[dirs.length - 1]
const file = path.join(migrationsDir, latest, 'snapshot.json')
return JSON.parse(await readFile(file, 'utf8')) as SchemaSnapshot
}
function formatId(date: Date, name: string): string {
const pad = (n: number) => n.toString().padStart(2, '0')
const ts =
date.getUTCFullYear().toString() +
pad(date.getUTCMonth() + 1) +
pad(date.getUTCDate()) +
'_' +
pad(date.getUTCHours()) +
pad(date.getUTCMinutes()) +
pad(date.getUTCSeconds())
const slug = name.replace(/[^a-z0-9]+/gi, '_').toLowerCase()
return `${ts}_${slug}`
}- [x] Step 20.3: Re-export — append to
packages/db/src/index.ts:
export { generate } from './cli/generate'
export type { GenerateOptions, GenerateResult } from './cli/generate'- [x] Step 20.4: Verify typecheck
pnpm --filter @forinda/kickjs-db typecheckExpected: exit 0.
- [x] Step 20.5: Commit
git add packages/db/src/cli/generate.ts packages/db/src/index.ts packages/db/__tests__/fixtures/schema.demo.ts
git commit -m "$(cat <<'EOF'
feat(db): add generate() — schema load → diff → emit → write (M0-S6)
Reads previous snapshot from latest migration dir, diffs against the
extracted target, emits up.sql/snapshot.json/meta.json into a
timestamped folder. up.sql header is -- REVIEWED: false.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 21: generate end-to-end test
Story: M0-S6. Round-trip: empty migrations dir → run generate → verify files → run again → "no changes". Files:
Create:
packages/db/__tests__/unit/cli-generate.test.ts[x] Step 21.1: Write the test
Create packages/db/__tests__/unit/cli-generate.test.ts:
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, rm, readFile, readdir } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { generate } from '@forinda/kickjs-db'
let dir: string
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), 'kickdb-gen-'))
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
})
describe('generate()', () => {
it('creates a migration on first run', async () => {
const cfg = {
schemaPath: path.resolve('packages/db/__tests__/fixtures/schema.demo.ts'),
migrationsDir: path.join(dir, 'migrations'),
dialect: 'postgres' as const,
}
const fixed = new Date(Date.UTC(2026, 3, 27, 15, 30, 12))
const r = await generate({ name: 'init', config: cfg, cwd: process.cwd(), now: () => fixed })
expect(r.status).toBe('created')
expect(r.changeCount).toBe(1)
const subdirs = await readdir(cfg.migrationsDir)
expect(subdirs).toEqual(['20260427_153012_init'])
const upSql = await readFile(path.join(cfg.migrationsDir, subdirs[0], 'up.sql'), 'utf8')
expect(upSql.startsWith('-- REVIEWED: false\n')).toBe(true)
expect(upSql).toContain('CREATE TABLE "users"')
expect(upSql).toContain('CREATE UNIQUE INDEX "users_email_unique"')
const meta = JSON.parse(
await readFile(path.join(cfg.migrationsDir, subdirs[0], 'meta.json'), 'utf8'),
)
expect(meta).toMatchObject({ id: '20260427_153012_init', reviewed: false, dialect: 'postgres' })
})
it('returns no-changes when re-run against the same schema', async () => {
const cfg = {
schemaPath: path.resolve('packages/db/__tests__/fixtures/schema.demo.ts'),
migrationsDir: path.join(dir, 'migrations'),
dialect: 'postgres' as const,
}
const t1 = new Date(Date.UTC(2026, 3, 27, 15, 30, 12))
const t2 = new Date(Date.UTC(2026, 3, 27, 16, 0, 0))
await generate({ name: 'init', config: cfg, cwd: process.cwd(), now: () => t1 })
const r2 = await generate({ name: 'init2', config: cfg, cwd: process.cwd(), now: () => t2 })
expect(r2.status).toBe('no-changes')
expect(r2.changeCount).toBe(0)
})
})- [x] Step 21.2: Run — passes.
pnpm --filter @forinda/kickjs-db testExpected: PASS.
- [x] Step 21.3: Commit
git add packages/db/__tests__/unit/cli-generate.test.ts
git commit -m "$(cat <<'EOF'
test(db): generate() end-to-end (M0-S6)
First run creates a timestamped migration dir with up.sql + snapshot.json
+ meta.json. Re-run returns no-changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 22: Wire kick db generate into @forinda/kickjs-cli
Story: M0-S6 — surface the command on the existing CLI. Files:
- Modify:
packages/cli/package.json(add@forinda/kickjs-dbworkspace dep) - Create:
packages/cli/src/commands/db.ts - Modify:
packages/cli/src/cli.ts(register the command)
Read first: the existing
packages/cli/src/cli.tsand one existing command (e.g.generate.ts) to confirm the registration pattern (Commander.js subcommand, command builder, etc). The pattern below is plausible but verify against the file before pasting.
- [x] Step 22.1: Read the existing CLI registration shape
sed -n '1,40p' packages/cli/src/cli.ts
ls packages/cli/src/commands/Note the pattern — file may use commander, cac, or a custom parser. The snippet below assumes commander; adjust to match.
- [x] Step 22.2: Add the workspace dep to
packages/cli/package.json
In the dependencies block, add:
"@forinda/kickjs-db": "workspace:*"Then run:
pnpm install- [x] Step 22.3: Create
packages/cli/src/commands/db.ts
import path from 'node:path'
import { Command } from 'commander'
import { generate, resolveDbConfig } from '@forinda/kickjs-db'
export function registerDbCommands(program: Command) {
const db = program.command('db').description('Database commands (kickjs-db)')
db.command('generate <name>')
.description('Generate a new migration from schema diff')
.option('-c, --config <path>', 'Path to kick.config.ts', 'kick.config.ts')
.action(async (name: string, opts: { config: string }) => {
const cwd = process.cwd()
const config = await resolveDbConfig({ configPath: path.resolve(cwd, opts.config) })
const result = await generate({ name, config, cwd })
if (result.status === 'no-changes') {
console.log('No schema changes detected.')
return
}
console.log(
`Created migration ${result.migrationDir} (${result.changeCount} change${result.changeCount === 1 ? '' : 's'}).`,
)
})
}- [x] Step 22.4: Wire into
packages/cli/src/cli.ts
Open packages/cli/src/cli.ts and add (near other command registrations):
import { registerDbCommands } from './commands/db'
// ... after `program` is created and other commands registered:
registerDbCommands(program)If the file uses a different framework, adapt the registration to match. The semantic — register a
dbsubcommand withgenerate— is what matters.
- [x] Step 22.5: Build the CLI
pnpm --filter @forinda/kickjs-cli build
pnpm --filter @forinda/kickjs-db buildExpected: exit 0.
- [x] Step 22.6: Smoke-test the CLI in a temp dir
Create a one-off scratch script (do not commit):
mkdir -p /tmp/kickdb-spike && cd /tmp/kickdb-spike
cat > kick.config.ts <<'EOF'
export default {
db: {
schemaPath: 'src/db/schema.ts',
migrationsDir: 'db/migrations',
dialect: 'postgres' as const,
},
}
EOF
mkdir -p src/db
cat > src/db/schema.ts <<'EOF'
import { table, serial, varchar } from '@forinda/kickjs-db'
export const users = table('users', {
id: serial().primaryKey(),
email: varchar(255).notNull(),
})
EOF
node /home/forinda/dev/open-source/kick-js/packages/cli/bin.js db generate init
ls -la db/migrations/*/
cat db/migrations/*/up.sqlExpected: db/migrations/<timestamp>_init/up.sql exists, starts with -- REVIEWED: false, contains CREATE TABLE "users".
- [x] Step 22.7: Clean up the scratch dir
rm -rf /tmp/kickdb-spike- [x] Step 22.8: Commit
git add packages/cli/package.json packages/cli/src/commands/db.ts packages/cli/src/cli.ts
git commit -m "$(cat <<'EOF'
feat(cli): register kick db generate command (M0-S6)
Wires @forinda/kickjs-db's generate() into the existing CLI shell.
First end-to-end usable surface for the spike.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"M0 exit gate
After Task 22, run the full verification suite from repo root:
pnpm build
pnpm test
pnpm format:checkExpected: all green. The pipeline is proven:
- Write a TS schema →
extractSnapshot→ JSON IR. - Compare to last snapshot →
diff→ ChangeSet. - Compile →
emitPg→ SQL. - Apply via
pg.Client→ real PG state matches the schema. - CLI:
kick db generate <name>writes the migration files with-- REVIEWED: falseheader.
Ready for M1 (down emit + journal + lock + runner + Kysely client).
Plan self-review notes
Spec coverage check (against ./architecture.md and ./stories.md):
- M0-S1 — covered by Task 2.
- M0-S2 — covered by Tasks 3, 4, 5, 6, 7.
- M0-S3 — covered by Tasks 8, 9, 10, 11, 12, 13.
- M0-S4 — covered by Tasks 14, 15, 16, 17.
- M0-S5 — covered by Task 18.
- M0-S6 — covered by Tasks 19, 20, 21, 22.
Type consistency: SchemaSnapshot, ColumnSnapshot, IndexSnapshot, ForeignKeySnapshot, Change, ChangeSet defined in Task 2 / Task 8; consumed identically in Tasks 7, 9–13, 15–17, 18, 20. No naming drift.
Placeholders: none — every code block is complete.
Out of scope for M0 (deferred to M1):
- Down emission (
-- REVIEWED: falseheader onup.sqlonly;down.sqllands in M1-S2). - Journal (
_journal.json) — M1-S3. - Lock + tracking tables — M1-S4.
- Runner (
migrate latest|up|down|rollback|status) — M1-S5. - Drift detection — M1-S6.
- Kysely client — M1-S7, M1-S8.
- DI tokens — M1-S9.
- Example app port — M1-S10.
Plan complete and saved to docs/db/m0-spike-plan.md. Two execution options:
1. Subagent-Driven (recommended) — fresh subagent per task, review between tasks, fast iteration.
2. Inline Execution — execute tasks in this session, batch with checkpoints for review.
Which approach?