fix(cli): generated migrations give the table's own id the driver's column shape - #15518
Conversation
…olumn shape
Both migration generators hardcoded the primary key as a UUID:
generateMigrationSql ' "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),'
generateMigrationTs " table.uuid('id').primary().defaultTo(db.fn.uuid());"
driver-sql emits `table.string('id').primary()` for that column — knex's
varchar(255), SqlDriver.DEFAULT_STRING_VARCHAR_CHARS. A platform id is a
string, not a uuid, so on Postgres the generated table refused the platform's
first insert with `22P02 invalid input syntax for type uuid`.
The DEFAULT is the quieter half and the reason this is worth correcting rather
than working around: the driver emits no database-side default, because its
insert path always supplies the id itself. `gen_random_uuid()` therefore only
ever fired for an out-of-band insert, handing that row a 36-character uuid the
platform's generator would never mint — one table holding two incompatible id
shapes, silently.
Both generators now emit the driver's own answer. The correction also closes a
contradiction inside generate.ts, whose prose already stated that a reference
column takes the width of the target's id column *because* the driver emits
`table.string('id').primary()`, a few hundred lines above the two lines that
emitted uuid.
generate-builtin-id-column.pin.test.ts reads the width from the driver's own
DEFAULT_STRING_VARCHAR_CHARS rather than transcribing 255, so the generators
cannot drift away from the driver again without a named failure. Its last case
records — deliberately without correcting — a third disagreement measured in
the same pass: the audit-stamp columns. driver-sql emits them nullable
(`table.timestamp(name).defaultTo(knex.fn.now())`); both generators emit them
NOT NULL. Which side moves is not this change's to decide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9bd8390135850dab1fea015291d61be6d34bc78f && git checkout 9bd8390135850dab1fea015291d61be6d34bc78f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 20032594f4a9ac8c78e2d6a3eedbe8e5aeae4911 bffde48fc9982cb92dfd04d5f8ba96de94af5e6e && git checkout -B drift-repro 20032594f4a9ac8c78e2d6a3eedbe8e5aeae4911 && git merge --no-ff bffde48fc9982cb92dfd04d5f8ba96de94af5e6e
node scripts/docs-audit/affected-docs.mjs --json 20032594f4a9ac8c78e2d6a3eedbe8e5aeae4911
|
Fixes #15040
Both migration generators in
packages/cli/src/commands/generate.tshardcoded the table's own primary key as a UUID. The platform's SQL driver emitstable.string('id').primary()for that column — knex'svarchar(255),SqlDriver.DEFAULT_STRING_VARCHAR_CHARS. A platform id is a string, not a uuid, so on Postgres the generated table refuses the platform's first insert with22P02 invalid input syntax for type uuid.Two literal lines, one per generator. No restructuring.
What changed
generateMigrationSql"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),"id" VARCHAR(255) PRIMARY KEY,generateMigrationTstable.uuid('id').primary().defaultTo(db.fn.uuid());table.string('id').primary();The
DEFAULThalf is the quiet one, and it is what the fix is really forThe loud failure announces itself:
22P02on the first insert, so nobody ships on top of the generated table silently. The default does not.Measured:
SqlDriver.create()always supplies the id itself —_id, else a caller-suppliedid, else a minted one — soDEFAULT gen_random_uuid()never fires for a platform write. It fires only for an out-of-band insert, and hands that row a 36-character uuid this platform's id generator would never mint. One table would then hold two incompatible id shapes, with nothing anywhere saying so. The driver emits no database-side default foridat all, and neither generator does now.On Postgres
knex.fn.uuid()compiles to(gen_random_uuid()), so the two generators were emitting one and the same wrong default in two spellings.The file already contradicted itself
generate.tsstates, a few hundred lines above the two generators, that a reference column takes the width of the target'sidcolumn because the driver emitstable.string('id').primary()— the derivation #14828 applied to thelookup/master_detail/user/treerows. Those comments were already right. Only the code was wrong, and this change makes the code agree with prose that had been correct all along; no comment needed correcting.Measured, not quoted
The
22P02refusal itself is quoted fromsql-driver.ts's own prose, not driven against a live Postgres — there is no database in this container. What is driven here is the emitted shape, on both sides, compiled offline through knex'spgdialect (knex.schema.createTable(...).toSQL(), a pure compile with no connection):The
afterrow is byte-identical to the driver's own id column.Third disagreement, measured and NOT folded in: the audit-stamp columns
Triage asked for
created_at/updated_atto be decided in the same pass rather than left half-done. Measured; it is a third disagreement, and this PR deliberately does not touch it.table.timestamps(true, true)compiles to.notNullable().defaultTo(CURRENT_TIMESTAMP)on both columns (knex 3.3.0,knex/lib/schema/tablebuilder.js). Compiled through the same offline pg probe:So the disagreement is nullability, not type — unlike the
idcolumn, where the type itself was wrong. It is not obviously a wrong value on either side: the driver stamps both columns on every write, which makesNOT NULLarguably the truer constraint, and the driver's DDL is dialect-branched (datetime(3)on MySQL, a canonical ISO default on SQLite, percreateAuditTimestampColumn) in a way a Postgres-flavoured generated migration does not try to reproduce. Which side moves is a decision, not a correction, so nothing here changes those lines. The measurement is recorded as the last case of the new pin, explicitly as a record rather than a ruling, so it cannot change shape unnoticed.The pin reads the authority instead of transcribing it
packages/cli/src/commands/generate-builtin-id-column.pin.test.ts. A pin assertingvarchar(255)would re-create this very defect one layer up — the whole shape of this card is "the generator disagrees with the driver". So the width is read fromDEFAULT_STRING_VARCHAR_CHARSwhere it is declared, and the typescript generator's emitted line is compared against the driver's own call byte for byte. Both extractions carry non-vacuity controls, and every "must not" assertion carries an anti-vacuity control proving the predicate fires on the shape that was there before.It needs no new declared inputs:
packages/drivers/driver-sql/src/sql-driver.tsis already listed under@objectstack/cli#testinturbo.json, andpnpm check:cross-package-test-inputspasses unchanged (26 package(s) read outside themselves, all declared).Two legs of ablation, one per generator, because the two lines live in different functions and a pin covering only one would look identical from the outside.
Clause-②: no, re-declared from the delivered diffA scaffolding generator's emitted DDL is corrected toward what the platform's driver already emits. No exported symbol, no accepted key or value, no schema and no wire shape moves. Changeset is
patchon that basis: perCheck Changeset's WHICH LEVEL prose,minoris for a purely additive widening of a published package's public surface, and afix(that changes no public surface stayspatch.Verification
All readings at
bffde48fc99, on a clean tree, in the worktreeobjectstack-issue-15040. Heavy runs went throughscripts/pm/os-verify-lock.sh; every verdict below is quoted from the gate's or the lock's own verdict line, never from a bare$?.@objectstack/clifull suite —pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2.Test Files 244 passed,Tests 2872 passed | 6 expected fail | 11 skipped, plus the five*.e2e.test.tssuites re-run afterturbo run buildsatisfied theirrequireBuiltCliprerequisite:Test Files 5 passed (5),Tests 11 passed (11). The first run reported those five as failed suites for one reason —packages/cli is not built: .../dist/commands/serve.js does not exist— which is a prerequisite, not a finding, so it was satisfied and re-measured rather than reported.pnpm --filter @objectstack/cli typecheck—VERDICT command-exit 0;check:test-typecheck: OK — @objectstack/cli's test layer compiles under packages/cli/tsconfig.test.json. The new pin really is in the program:tsc --noEmit --listFilesnamesgenerate-builtin-id-column.pin.test.tsandcommands/generate.ts, so "typecheck is clean" covers the file this PR adds rather than merely being true.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsfrom the change set the script takes itself (3 paths). 49 commands, all exit 0. Four first came backPREREQUISITE NOT MET/NOTHING was measured(check:dual-build-cjs-loads,check:i18n,check:i18n-coverage,check:i18n-walk-parity) — read as NOT MEASURED, satisfied with a workspace build, and re-run green. The five roster families the derivation flags as scoringsilentfor structural reasons with a roster under one of these paths were run too, not read as clearance:check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity,check:swallow-census-controls— all exit 0.STALE TREE— this branch is behindorigin/main, and four files the answer derives from changed across that range (check-dispatcher-error-vocabulary.mjs,check-type-check-coverage.mjs,engine-double-contract.pinned.json,measure-self-test-floor.mjs). The family list itself was byte-identical when re-derived after a freshgit fetch origin main, and three of those four gates ran green here from this tree's copies — but the reading is from this tree, not fromorigin/main. CI runs the farm against the merge and is the stronger reading.Ablation — two legs, one per generator, proved on disk
Predicted before running: restoring
uuidin one generator reds that generator's cases while the other generator's stay green. Both legs behaved that way, and distinctly.No rebuild is involved and none is owed: the pin imports
./generate.jsrelatively, so vitest readssrc/commands/generate.tsitself rather than anything indist/. That is not an assumption here — each leg mutated only the source and the pin went red without a build, which is what proves the resolution path.1 -> 0, injected marker1the sql generator emits the driver's width,neither generator gives the id a uuid type,neither generator gives the id a database-side defaultthe typescript generator emits the driver's own line, byte for byte1 -> 0, injected marker1the typescript generator emits the driver's own line, byte for byte, and the same two shared casesthe sql generator emits the driver's widthBoth legs:
Tests 3 failed | 48 passed (51), and in bothTest Files 1 failed | 2 passed—generate-field-type-vocabulary.pin.test.tsandgenerate-multiple-json-column.pin.test.tsstayed green through both mutations. That is the case for the new pin existing: the two pins already driving these generators do not cover the builtinidcolumn, so either regression could have landed under a green suite.Restore was
git checkout HEAD -- ABSOLUTE_PATHfrom a trap, and is proved rather than assumed: blob hash06db8cc9cc53cccc3e5aa27ac50417e0717655b1equal to theHEADblob andgit diff HEADempty, checked after each leg. An empty hash is treated as failure, not as "nothing to compare".Generated by Claude Code