fix(cli): generated migrations match driver-sql on audit-stamp nullability and default text - #16278
Conversation
…ility and default text `os generate migration` emitted `NOT NULL` on the builtin `created_at` / `updated_at` columns in both formats while `driver-sql`'s `createAuditTimestampColumn` creates them nullable, and the SQL format spelled their default `now()` while both knex producers emit `CURRENT_TIMESTAMP`. Nothing failed either way, but `information_schema.column_default` keeps the two spellings textually apart, so a schema diff between a generated table and a platform-created one reported the pair forever. Both generators now follow the driver, the same rule the `id` column already follows. `table.timestamps(true, true)` cannot express that shape — knex compiles its second argument to `.notNullable().defaultTo(...)` with no way to take the default alone — so the TypeScript format spells the two columns out as the driver's own line. `--format sql` now states in its help text and its docblock that it targets PostgreSQL only and makes no MySQL or SQLite claim. The recorded-divergence case in `generate-builtin-id-column.pin.test.ts` becomes an agreement pin, derived from the driver's builder rather than transcribing the expected strings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…dit-stamp-nullability-and-default
…dit-stamp-nullability-and-default
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
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 ce9b328bc6cb4162fbdcd8385046a68f92128c0b && git checkout ce9b328bc6cb4162fbdcd8385046a68f92128c0b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 331d3dc998b0c47f9b90470e5306c5d1fc5d46ff 2e586a127b609dfd75631ee4ba2d924d29994735 && git checkout -B drift-repro 331d3dc998b0c47f9b90470e5306c5d1fc5d46ff && git merge --no-ff 2e586a127b609dfd75631ee4ba2d924d29994735
node scripts/docs-audit/affected-docs.mjs --json 331d3dc998b0c47f9b90470e5306c5d1fc5d46ff
|
Landing provenance —
|
| ruled item | delivered | how it is evidenced |
|---|---|---|
1 · both generators stop emitting NOT NULL |
✅ | live catalog read + ablation |
2 · SQL format's DEFAULT now() → CURRENT_TIMESTAMP |
✅ | live catalog read + ablation |
3 · --format sql declares PostgreSQL-only (help text + docblock) |
✅ in code | |
4 · the recorded-divergence it edited, not added to or deleted |
✅ | case count 8 before, 8 after |
| 5 · option C refused — the driver does not move | ✅ | packages/drivers/** absent from the diff |
⭐ generateMigrationTs could not express the ruled shape through table.timestamps(true, true) — knex compiles that helper to .notNullable().defaultTo(CURRENT_TIMESTAMP) unconditionally — so it now emits the driver's own line per column. That is a mechanical consequence of the ruling, not a widening of it.
The evidence standard was recovered, not downgraded
The live PostgreSQL 16.13 cluster from the earlier round was found still running, and all three producers were driven into it in separate schemas so each emitted text ran unmodified: the driver's Postgres arm through knex, the TypeScript format by importing the generated migration and invoking its own up(), the SQL format by executing its DDL verbatim. Read back from information_schema.columns:
all six rows timestamp with time zone / null=YES / default=CURRENT_TIMESTAMP
distinct tuples: 1 VERDICT: ALL THREE PRODUCERS AGREE
Under ablation the same cluster reports distinct tuples: 3 — PRODUCERS DISAGREE, with p_sqlgen null=NO default=now() (falsifying rows 1 and 2), p_tsgen null=NO (row 3), and p_driver unmoved. ⇒ the agreement is a measurement of the catalog, not of the emitted string.
Ablation discipline: falsification conditions written down before the run, one per ruled row, directions predicted. Mutation proven on disk by blob hash — 195280715f4 replaced by 5c1d13b24d7, asserted equal to origin/main's blob and unequal to the fixed blob, with anchor-text counts alongside. Result exactly as predicted: Tests 2 failed | 118 passed (120), precisely the two named cases. Restore proven by observed state — blob back to 195280715f4 == HEAD blob, git diff HEAD empty, git status --porcelain empty — under a trap on EXIT INT TERM using absolute paths from git rev-parse --show-toplevel, ⛔ never by an exit code.
No rebuild leg, and that is a property of the resolution rather than an omission: the pin imports ./generate.js, a relative same-package specifier vitest resolves straight to src/generate.ts with no exports/dist hop, so no dist can go stale between mutation and measurement.
⚠️ One ruled row shipped with NO executable detector
Quoted from the implementer, who disclosed it rather than letting a green imply coverage:
the dialect-scope row (help text + docblock) has NO executable detector — it stayed green under the ablation because nothing asserts it, and is verified by reading only.
⇒ ruling item 3 is verified by reading, not by machine. If someone later deletes those two sentences, nothing reddens, and os generate migration --format sql silently resumes claiming dialect-neutrality — which is the very question ("which dialect does the generator claim to match") this card exists to settle.
This seat considered requiring a pin and decided against it: asserting help-text prose is brittle, and the ruling asked for a declaration rather than a guard. ⭐ That is a judgement call, recorded here so it can be overturned rather than discovered later.
⚠️ Clause ② — graded no, and a tension this seat will not paper over
Graded from the delivered diff, per limb:
- Mechanical floor: no. No newly exported symbol, no new key on any published payload, nothing under
packages/spec/src/**. The diff ispackages/cli/src/commands/generate.ts, one pin test edited in place, one changeset. - Conformance limb: no, per the ruling: "generated scaffold output is a developer-facing artifact, not a published contract; the type half that WAS clause-② already landed in PR fix(cli): generated SQL migrations give timestamp columns their time zone #16070."
yes on the conformance limb, with the reasoning "the emitted DDL of a shipped command changes, so this is graded yes rather than argued down." This half changes the same command's emitted DDL, on the same two columns, and is graded no.
The ruling's stated reason for no (scaffold output is developer-facing, not a published contract) would have applied equally to the type half. ⇒ the two grades rest on a distinction the ruling asserts rather than derives. ⛔ This seat is following the ruling — it is binding and explicitly covers this diff's shape, and it explicitly invited the seat to re-grade, which is what the paragraph above is. But two contradictory grades on one surface should not pass silently, and this is worth the maintainer's revisiting.
Because clause ② is no, needs:contract-review was never applied to either carrier and there is nothing to strip. ⛔ Correspondingly, check-clause2-carriers was not run — there is no pair to check.
CI — the full population
33 of 33 complete, every one success or skipped, none failed, at head 2e586a127b6; page 2 of the listing empty.
Test Core rollup job (101473704865) appeared and completed at 10:50:28Z. Had the earlier accounting been carried forward, this landing would have asserted a population that no longer existed. Re-read, not remembered. (The same thing happened on #16265 in the other direction, 37 → 36.)
⭐ Pre-squash commit-message check — clean
New this round, and applied here. git log <merge-base>..<head> --format='%B':
- one commit to be squashed:
52145d48e37; - card-relation trailers inside commit bodies: none;
- falsified-claim phrases: none;
- files: exactly
generate.ts, the pin test, and one changeset.
This check exists because the queue squashes, and squashing concatenates every commit message into main permanently — measured this morning on #16247, where two sentences already known false went into history that way. Recorded on #16158.
Gate union, and one declared narrowing
dispatch-gates.mjs run twice: the plain form printed Reconciliation — 57 famil(ies), the --commands form harvested exactly 57 invocations while printing no reconciliation line. All 57 run: 54 exit 0, zero red, 3 NOT MEASURED, each quoted from its own verdict text rather than scored off an exit code —
check:dual-build-cjs-loads— "PREREQUISITE NOT MET … This is NOT a pass: nothing was measured"check:i18n-coverage— "Nothing was compared … says NOTHING about whether any declared label went untranslated"check:type-check-debt— its self-test passed and its plain siblingcheck:type-check-coveragepassed, but its whole-tree--re-measuresweep was stopped deliberately after holding the shared verify lock ~19 minutes with two agents queued.
⭐ That stop is a declared narrowing, neither pass nor red, and it was done correctly: SIGTERM to an explicit PID (exit 143), ⛔ not a name-pattern kill — the process table is shared with parallel agents, which is #16182.
Provenance of the work itself
52145d48e37 already contained the whole implementation — but its measurements died with it and could not be audited, so every claim above was re-measured from scratch rather than inherited. That instruction was not ceremony: on the sibling card #15880 the same rule caught a predecessor's head that was actually red.
Filed from the same run: #16279 — no test ever executes the migration DDL os generate migration emits; both formats are pinned as text only.
Flipping ready and arming. ⛔ Card #15521's pm:dispatched comes off after the merge; the PR closes it with Fixes, both ruled rows being delivered.
Generated by Claude Code
Fixes #15521
Ruled on the card 2026-09-06: option B, both remaining rows in one PR, because they are one question about the same two columns. Both migration generators now follow
driver-sqlon the builtincreated_at/updated_atcolumns — the same rule #15040 applied to theidcolumn.packages/drivers/**is untouched. It is the authority on this card, never the subject; option C was refused in the ruling.What changed
--format sql"created_at" TIMESTAMPTZ NOT NULL DEFAULT now()"created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMPtable.timestamps(true, true)table.timestamp('created_at').defaultTo(db.fn.now())--format sqlnow()CURRENT_TIMESTAMP--formathelp text andgenerateMigrationSql's docblock both state PostgreSQL-only, and disclaim MySQL and SQLitetable.timestamps(true, true)cannot express the ruled shape: knex 3.3.0 compiles its second argument to.notNullable().defaultTo(...)on both columns, with no way to ask that helper for the DEFAULT without the NOT NULL. So dropping NOT NULL to match the driver means spelling the two columns out as the driver's own line, withthis.knexre-receivered to the generated migration'sdb.NOT NULL was never load-bearing:
stampInsertTimestampsfills both columns on every platform write, and where it does not (the documentedskipSchemaSyncposture) the column DEFAULT fires. What it bought was a permanent schema diff between a generated table and a platform-created one.Evidence — live PostgreSQL 16.13
All three producers driven into one live cluster, each into its own schema so the emitted text runs unmodified, then read back from
information_schema.columns:Ablation — main's
generate.tsrestored over the fixFalsification conditions were named in writing before the run, one per ruled row. The mutation was proven on disk by blob hash (
195280715f4fixed, replaced by5c1d13b24d7, which is byte-identical toorigin/mainand to the pre-fix blob), and the restore proven by observed state —git diff HEADempty plus blob equality — never by an exit code.Ablated, the same six test files:
Exactly the two predicted cases, and no others. Ablated, the same live cluster:
An admitted coverage gap, stated rather than papered over: the dialect-scope row (the help-text and docblock sentences) has no executable detector. It stayed green under ablation because nothing asserts it. It is verified by reading only.
The pin test
generate-builtin-id-column.pin.test.ts's recorded-divergence case was edited in place into an agreement pin, as the ruling directed — no second case added, none deleted (case count 8 before, 8 after). Its assertions are derived from the driver's own builder line rather than transcribed, and the driver source is required to still contain that line, so a driver that moves fails there rather than leaving the generators quietly wrong.Verification
Every measurement below was taken at this PR's head,
2e586a127b6. The predecessor run that wrote this code was killed by a container restart before it reported, so none of its measurements survived to be audited and none were inherited.pnpm --filter '@objectstack/cli^...' build,VERDICT command-exit 0.pnpm --filter @objectstack/cli exec vitest runover the six test files that importgenerate.js: Test Files 6 passed (6), Tests 120 passed (120).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, run twice. The plain form'sReconciliationline names 57 families; the--commandsform harvested exactly 57 invocations. Harvest asserted against the Reconciliation number, not against the matched-block count.Three of the 57 are NOT MEASURED rather than green, and their own verdict text says so:
check:dual-build-cjs-loads— "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/. ... This is NOT a pass: nothing was measured."check:i18n-coverage— "Nothing was compared: 12 config(s) did lint, but a partial round cannot judge the ratchet ... this result says NOTHING about whether any declared label went untranslated."check:type-check-debt— its--self-testpassed (55 semantic, 97 observation, 45 re-measure, 28 built-closure, 19 auto-lowering and 18 exit-code cases hold) and its plain siblingcheck:type-check-coveragepassed at gate 53, but its--re-measurearm is a whole-treetscsweep across 79 packages. This seat stopped that sweep deliberately (SIGTERM by explicit PID, henceexit=143) after it had held the shared verify lock for roughly nineteen minutes with two other agents queued behind it. This is a declared narrowing, not a red and not a pass: a repo-wide ratchet recount is a run CI owns, and CI performs it on this PR.The first two need a whole-tree build this worktree does not have. None of the three is a finding against this diff, and none is reported here as a pass.
Exit codes throughout were captured by redirect-then-capture, never after a pipe.
Coverage this PR does not have
--formathelp text andgenerateMigrationSql's docblock) have no executable detector, confirmed by their staying green under the ablation. They are verified by reading only.scaffold-emission-typechecks.test.tscoversGENERATOR_SCAFFOLD_TARGETS, not migration output. In place of that, this seat imported and executed the actual generated migration against live PostgreSQL, which is what the catalog rows above are read from.Generated by Claude Code