Skip to content

fix(cli): a generated migration carries the column DEFAULT driver-sql puts on the same field - #17230

Merged
os-project-manager merged 5 commits into
mainfrom
claude/issue-16294-generator-notnull-and-default
Sep 9, 2026
Merged

fix(cli): a generated migration carries the column DEFAULT driver-sql puts on the same field#17230
os-project-manager merged 5 commits into
mainfrom
claude/issue-16294-generator-notnull-and-default

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #16294

Clause-②: no

Cause 3 of the card: neither os generate migration format read a field's defaultValue, so a table built from a generated migration carried no column DEFAULT where the platform's own table has one. A row inserted out of band — by a database client, a seed script, anything that does not go through the engine — got NULL where the declared value belonged.

⚠️ Read this first: two of the card's three causes are ALREADY FIXED at origin/main

I re-ran the card's six-column probe against generate.ts as #16887 and #17208 leave it, before touching anything. The baseline is diverged: 2 of 6, not the card's 4 of 6:

BASELINE at origin/main@3c5f3c5991 — live PostgreSQL 16.13, three schemas, one producer each

field                driver                          sqlgen                sqlgen==tsgen   verdict
f_plain              null=YES default=-              null=YES default=-    yes             agree
f_required           null=YES default=-              null=YES default=-    yes             agree     (card said DIVERGED)
f_storage_notnull    null=NO  default=-              null=NO  default=-    yes             agree     (card said DIVERGED)
f_required_and_st    null=NO  default=-              null=NO  default=-    yes             agree
f_default            null=YES default='hello'::text  null=YES default=-    yes             DIVERGED
f_default_required   null=YES default='hello'::text  null=YES default=-    yes             DIVERGED

diverged: 2 of 6

#16887 already landed the repair for cause 2, and for cause 1 with it — as one substitution, not two. Its diff introduced declaredNotNull() and moved both generators off required:

-      const notNull = fieldDef.required ? ' NOT NULL' : '';
+      const notNull = declaredNotNull(fieldDef) ? ' NOT NULL' : '';
-      const required = fieldDef.required ? '.notNullable()' : '.nullable()';
+      const required = declaredNotNull(fieldDef) ? '.notNullable()' : '.nullable()';

Reading storage.notNull is cause 2. So:

Re-derived cause 2's justification from the withdrawal rather than from triage, as instructed: packages/spec/src/conversions/registry.ts carries ⛔ WITHDRAWN — there is deliberately NO 'field-required-notnull-explicit', and docs/protocol-upgrade-guide.md tells upgraders to add storage: { notNull: true } "yourself — deliberately". That reading is what made cause 2 worth re-asserting in the new pin even though the emitter already satisfied it.

The fix

Both formats now render one shared verdict, declaredColumnDefault(), taken from SqlDriver.applyDeclaredColumnDefault — the single place a defaultValue becomes DDL on the platform side. Its four cases, and the two shapes the driver skips before reaching it:

authored driver both generators, after
a literal ('hello', 42, 0, true, '') defaultTo(value) the same value, quoted as knex binds it
'NOW()' on datetime CURRENT_TIMESTAMP CURRENT_TIMESTAMP / db.fn.now()
'NOW()' on date / time a UTC-pinned expression the driver's PostgreSQL arm, via db.raw
'current_user' nothing (engine-resolved) nothing
{ dialect, source } nothing (evaluated app-side) nothing
an option-level default: true nothing nothing
any field with multiple: true nothing (createColumn returns first) nothing

The two token predicates are imported from @objectstack/spec/data — the same isNowDefaultToken / isRuntimeDefaultToken the driver itself imports — so the token vocabulary is not transcribed and a token added later degrades to "no column default" on both sides at once.

One row is a trap a "just emit the literal" repair gets wrong and never notices. DEFAULT 42 and DEFAULT '42' are the same default, and PostgreSQL keeps them textually apart forever in information_schema.column_default (42 vs '42'::numeric). knex quotes every bound default, so the driver's column carries the quoted form. Measured, mid-change:

d_number   42   driver '42'::numeric   tsgen '42'::numeric   sqlgen 42     DIVERGED

That is exactly the cost #15521 already paid once for the audit pair, where a schema differ comparing default text reported them forever. Every literal is therefore emitted quoted, booleans as knex's '1' / '0' (PostgreSQL normalises both spellings to true / false, and a SQLite table built by the driver carries the quoted form verbatim, so one rule agrees with the driver on both dialects where two rules agree on one).

The knex DEFAULT / NOT NULL coupling — read, and it does not constrain this

The comment already in generate.ts records that table.timestamps(true, true) compiles its second argument to .notNullable().defaultTo(...) on both columns with no helper for a DEFAULT without the NOT NULL. That coupling belongs to the timestamps HELPER, not to knex columns in general — an ordinary ColumnBuilder takes .nullable() / .notNullable() and .defaultTo() as independent modifiers. The audit-column block already pays for the helper's coupling by spelling those two columns out longhand; nothing about this change needed to work around it, and the new .defaultTo(...) link is simply appended after the nullability call, in createColumn's own order. Stated here because the card asked for it explicitly rather than left silent.

Acceptance — the card's own probe, re-run

Three producers driven into three PostgreSQL 16.13 schemas — driver-sql through initObjects, --format sql through db.raw of the emitted DDL, --format ts by importing the emitted module and calling up(db) — with information_schema.columns read back per schema.

AFTER, at 2134a6ebed

field                driver                          sqlgen                          verdict
f_plain              null=YES default=-              null=YES default=-              agree
f_required           null=YES default=-              null=YES default=-              agree
f_storage_notnull    null=NO  default=-              null=NO  default=-              agree
f_required_and_st    null=NO  default=-              null=NO  default=-              agree
f_default            null=YES default='hello'::text  null=YES default='hello'::text  agree
f_default_required   null=YES default='hello'::text  null=YES default='hello'::text  agree

diverged: 0 of 6

A wider 23-column probe on the same cluster covers every defaultValue shape (NOW() on all three temporal types and its lowercase spelling, current_user, an Expression envelope, an option-level default, a quote, a newline, a backslash, zero, false, an empty string, a negative, an integer-representation type, multiple with a default, and storage.notNull beside a default): diverged: 1 of 23 before and after. The one row is not this card's — see Acceptance notes.

Tests

New: packages/cli/src/commands/generate-declared-column-default.pin.test.ts, 10 tests, modelled on generate-declared-unique-index.pin.test.ts.

  • Section A, the real chain: all three producers driven into one in-memory better-sqlite3 database each, 18 corpus columns, and the columns read back out of the engine's own catalog (PRAGMA table_info) — the live-PostgreSQL acceptance above transplanted into a tier that runs everywhere. Plus the card's consequence as behaviour: an out-of-band INSERT that omits the column reads back the declared value from all three tables.
  • Section B: every NOW() spelling recomputed from SqlDriver.nowColumnDefault itself, through a protected-widening subclass on a PostgreSQL-configured driver that never connects. A pin that transcribed CURRENT_TIMESTAMP would re-create this very defect one layer up.
  • Non-vacuity in both sections, plus a firing control (a changed declaration must move the driver's column and both generators with it).

Commands and verdicts, all captured redirect-then-$?:

run verdict
pnpm --filter @objectstack/cli exec vitest run --project unit 190 files / 2635 tests pass ‡
pnpm --filter @objectstack/cli exec vitest run --project integration 41 files / 358 tests pass
pnpm --filter @objectstack/cli typecheck exit 0
pnpm --filter '@objectstack/cli^...' build + pnpm build exit 0 (73/73 tasks)
pnpm lint (repo-wide, eslint . --no-inline-config) exit 0 at 2134a6ebed — the whole tree, so no narrowing to justify
88 gate commands from dispatch-gates.mjs --commands --repo objectstack-ai/objectstack all exit 0

‡ two of those unit files first came back as a PREREQUISITE NOT MET (packages/cli is not built), not a failure; they pass after pnpm --filter @objectstack/cli build. Three gates first exited 3 for the same class of reason — check:dual-build-cjs-loads and check:i18n-coverage needed a full pnpm build, and check:type-check-debt OOMed under my own tighter NODE_OPTIONS heap cap; all three are exit 0 on the final head once given what they asked for. ⛔ None of the three was read as a pass.

Ablation — directions predicted in writing before any leg ran

Four legs, one script, whole thing under trap ... EXIT INT TERM with absolute paths; every mutation proved on disk by occurrence count and a blob hash differing from the HEAD blob, every restore by git checkout HEAD -- path (never bare) with the blob back to HEAD's, git diff HEAD empty and git status --porcelain empty at the end.

leg predicted observed
1 — columnDefaultSql's literal arm returns '' RED, localised to the sql emitter RED, 5 failed / 5 passed
2 — restore 10/10 green, tree clean 10/10 green, blob e1fa07b0 = HEAD, git diff HEAD empty
3 — the now-date branch collapses into now RED, exactly the date row of section B RED, 1 failed / 9 passed, exactly that row
4 — restore 10/10 green, tree clean 10/10 green, git status --porcelain empty

⚠️ Leg 1's prediction was partly wrong and is recorded as wrong. I predicted three named section-A rows and "section B unaffected". Four section-A rows went red (the out-of-band-insert row too, legibly), and one section-B row went red as wellthe token match is the spec's, because its closing assertion checks that a near-miss 'NOW' is emitted as an ordinary string literal, which routes through the literal arm the leg ablated. The direction was as predicted; the containment claim was not, for a legible reason.

Leg 3 is the one that matters for trust: it fails only where the pin recomputes from nowColumnDefault, which is the assertion a transcribing pin would have left permanently green.

Docs drift, on the final head

scripts/docs-audit/affected-docs.mjs reported 34 pages across 8 anchors. Judged: content/docs/references/** is auto-generated and content/docs/releases/** is release-owned (neither touched); the permissions / ui / concepts pages match only through the over-broad sys_user literal anchor and say nothing about DDL emission; content/docs/deployment/cli.mdx documents os generate TYPE NAME scaffolds and never generate migration.

One page is a real row, and it is a row the change makes TRUE rather than false: content/docs/protocol/objectql/types.mdx's defaultValue: 'NOW()' section attributed the translation to the driver alone. It now says both generator formats reproduce the PostgreSQL arm, and names the three shapes that deliberately get no column default — the same shape of sentence #16887 added to this page one property over.

⚠️ The tool walks content/docs only, so docs/ can never appear in any run. Checked by hand: docs/protocol-upgrade-guide.md speaks only to required / storage.notNull and is untouched by this change; docs/DX_ROADMAP.md lists objectstack generate migration as unimplemented, which was already stale before this diff and is not this card's row.

Single-writer

Measured from the open PR list (21 PRs, 316 files, each against its own merge base via GET /pulls/N/files), never from remote branches: no open PR touches packages/cli/src/commands/generate.ts. Positive control on the same exact-match predicate fires — scripts/pm/check-widening-tells.mjs is named by PR #17216. #16319, the next card queued against this file, has no open PR.

Acceptance notes

Authored by Claude Code in session session_015QE8qk46e5CHJxyQEUjbf8.


Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 7 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-flow.mdx (via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/deployment/cli.mdx (via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/protocol/kernel/lifecycle.mdx (via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/protocol/objectql/types.mdx (via os generate (command, read off packages/cli/src/commands/generate.ts))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-4.mdx (via os generate (command, read off packages/cli/src/commands/generate.ts))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 anchor(s) matched too much of the corpus to be a work list: current_user (literal, 30 pages), sys_user (literal, 33 pages)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json bccf311100cad7affccd6cbfcddbd81fe734d97dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from e97271494f8c0417d8be50f622c3feae005def0b — the merge of head 2134a6ebedbbbb0e77a9109b5780aafec4da718e into base bccf311100cad7affccd6cbfcddbd81fe734d97d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e97271494f8c0417d8be50f622c3feae005def0b && git checkout e97271494f8c0417d8be50f622c3feae005def0b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bccf311100cad7affccd6cbfcddbd81fe734d97d 2134a6ebedbbbb0e77a9109b5780aafec4da718e && git checkout -B drift-repro bccf311100cad7affccd6cbfcddbd81fe734d97d && git merge --no-ff 2134a6ebedbbbb0e77a9109b5780aafec4da718e

node scripts/docs-audit/affected-docs.mjs --json bccf311100cad7affccd6cbfcddbd81fe734d97d

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs bccf311100cad7affccd6cbfcddbd81fe734d97d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-project-manager
os-project-manager marked this pull request as ready for review September 9, 2026 20:18
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 9, 2026
Merged via the queue into main with commit 559e531 Sep 9, 2026
42 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-16294-generator-notnull-and-default branch September 9, 2026 20:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants