fix(cli): multiple: true takes a JSON column in both migration generators - #14990

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-14829-migration-multiple-field-type
Sep 3, 2026
Merged

fix(cli): multiple: true takes a JSON column in both migration generators#14990
os-trump merged 2 commits into
mainfrom
claude/issue-14829-migration-multiple-field-type

Conversation

@os-trump

@os-trumpos-trump commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14829

os generate types honoured multiple: true and neither migration generator did, so one authored field produced two incompatible answers from one config in one run — an array TS type and a scalar column. This threads the flag through both migration generators, answering it where the platform answers it: before the type is consulted at all.

Re-measured on origin/main (5bc2f27) — every number with the command that produced it

AnchorCommandResult
multiple occurrences in generate.tsgit grep -n multiple origin/main -- packages/cli/src/commands/generate.ts4, at :562 :564 :607 :831, all TypeScript-side. Card's numbers hold; the triage comment's :502/:504/:547/:771 are the stale pair
fieldTypeToSql aritysed -n '1030,1033p'took (fieldType: string) only — the flag could not reach it
The driver's rule, statement 1sed -n '15342,15370p' packages/drivers/driver-sql/src/sql-driver.tscreateColumn opens with if (field.multiple) { this.jsonColumn(table, name); return; }, above its switch (type)
statement 2sed -n '15867p' same fileisJsonField is JSON_COLUMN_TYPES.has(type) || !!field.multiple
statement 3sed -n '541,544p' packages/drivers/driver-sql/src/schema-drift.tsfieldHasColumn opens if (field?.multiple) return true;, documented as "Mirrors SqlDriver.createColumn exactly … including multiple (a JSON column)"
The spec's value predicatesed -n '213,236p' packages/spec/src/data/field-value.zod.tsisMultiValueField = MULTI_OPTION_TYPES.has(type) OR (MULTI_CAPABLE_TYPES.has(type) AND multiple === true) — type-gated
Is the combination authorable off that roster?grep -n multiple packages/spec/src/data/field.zod.tsmultiple is a plain z.boolean().default(false) on every field; only radio + multiple is refused by name (:1818)

One correction worth recording: the card and the triage comment both spell the driver path driver-sql; it is packages/drivers/driver-sql. A git grep pathspec of packages/**/src matches zero there (git pathspec ** is not recursive without :(glob)), which is a silent zero rather than an error.

Which surface is authoritative — the driver, and not the spec predicate

The three surfaces answer two different questions and only one of them is about a column:

  • isMultiValueField is the ADR-0104 D1 value contract — "is the persisted value an array". Gated on MULTI_CAPABLE_TYPES.
  • createColumn / isJsonField / fieldHasColumn decide which column exists. Gated on the flag alone, three times over.

A migration generator's subject is the column, so the column authority governs. There is a fourth statement of the same rule, found while bounding a residue note, and it is the one that shows what the defect COST. schema-drift.ts's base-type check opens its emission site with const declaresJsonColumn = field.multiple === true; and reports expected: 'json' — flag alone again — gated on acceptsStringifiedJson(col.type), which is /char|text/i. Run that against what each generator emitted for a flagged lookup before this PR:

Emitted byColumnacceptsStringifiedJsonConsequence on Postgres
--format sql (pre-fix)VARCHAR(36)truean error-severity needs_confirm drift finding against a table the CLI itself generated — "arrays are being written as the STRINGIFIED literal"
typescript (pre-fix)table.uuidfalseno finding; the database refuses the write instead
either (this PR)JSONBfalsenone — the column is what the platform wants

So the two pre-fix answers were wrong in two different ways, and neither was silent for the platform — only for the author. The triage comment's steer — derive from isMultiValueField — would have answered VARCHAR(255) for a text field flagged multiple: true while the driver gives that same field a JSON column: the same drift one notch narrower. And the combination is reachable, because FieldSchema does not refuse it and the CLI generators sit downstream of validation (their own default: arm is documented as serving the unvalidated authoring door). So the implementation gates on the flag, with no list at all — the "fourth hand-copied vocabulary" the card warned about is avoided by not needing one. MULTI_CAPABLE_TYPES is still imported by the pin, as the roster it sweeps.

Red-first, and which arm fired

The pin was written and committed before the fix (a59b393f20), and run against the unfixed generators:

Tests 9 failed | 6 passed (15)
AssertionError: os generate migration --format sql gave a flagged lookup a scalar column …
expected 'VARCHAR(36)' to be 'JSONB' pin.test.ts:177

The 9 failures are the discriminating arms; all 6 controls passed — the roster control, the emission control, the "same type without the flag keeps its scalar column" control, the two driver-authority reads and the scope fence. The red is the negative arm at line 177, not the harness.

Re-proved as a full ablation on the finished tree (os-verify-lock.sh, one hold):

green baseline Tests 15 passed (15)
mutation on disk both branch markers 0/0 · blob a3fd631b… != HEAD 6cc1e93a… · 57593 -> 57420 bytes
ablated Tests 9 failed | 6 passed (15) — the same 9 arms, same 6 controls green
restore hash-object == HEAD blob 6cc1e93a… · git diff HEAD -> 0 files
re-run Tests 15 passed (15)

Mutation and restore are both proved by bytes, never by an editor's exit code, under trap … EXIT INT TERM on absolute paths. No build/dist leg: the pin imports its subject by relative specifier (./generate.js), which vitest resolves to the sibling source, so there is no exports hop that a stale dist could answer for.

Verification

CheckResult
pnpm lint (whole repo, eslint . --no-inline-config)exit 0 at ba72049628 — the final commit, no narrowing claimed
the pin + the neighbouring vocabulary pin + generate-file-name-registry-parity61 passed
check:cross-package-test-inputs, check:ci-filter-parity, check:turbo-task-graph, check:entry-guard, check:nul-bytes, check:test-source-alias, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:published-files, check:undeclared-dep-imports, check:doc-authoring, check:type-source-resolution, check:slot-lookup, check:parse-guard, check:pnpm-filter-targets, check:cli-command-ids, check:objectui-changeset, check:empty-changeset, check:changeset-no-major, check:adr-0087-registration, check:changeset-gate-self-tests, check:pm-half-states, check:self-test-wired, check:ratchet-remedy-authority, check:agent-test-spellingall exit 0
packages/clitsc --noEmit and check:test-typecheckNOT MEASURED — 194 of 234 errors are TS2307 for workspace modules, the unbuilt-closure signature, and the three check:test-typecheck files named are ones this diff does not touch

For that last row the question was narrowed rather than dropped, and the narrowing is measured: an A/B on the same unbuilt tree — census at HEAD vs census with generate.ts reverted to 5bc2f2727a and the pin file removed — is 234 error lines both ways with an empty diff, and zero errors name either of my files. So this diff adds no type error; what is unmeasured locally is the pre-existing state, which CI measures on a built workspace. Non-vacuity for the coverage claim itself: tsc --noEmit --listFiles lists 988 files and the new pin is one of them (grep -c = 1), so packages/cli's include: ["src"] really does typecheck it — the new test is deliberately under src/, not test/, for that reason.

File face

FileWhy
packages/cli/src/commands/generate.tsthe fix: fieldTypeToSql takes the flag and answers it first; generateMigrationTs emits the JSON column before its per-type switch; three functions gain a named export so the pin can drive them
packages/cli/src/commands/generate-multiple-json-column.pin.test.tsnew — the cross-surface pin
turbo.json + scripts/cross-package-test-inputs.mjsthe pin reads two driver-sql sources, so check:cross-package-test-inputs demanded both declarations. This is the pin's point, not paperwork: if the driver moves the rule and cli's suite does not re-run, the two sides drift again by the cache
.changeset/generate-migration-multiple-json-column.mdpatch for @objectstack/cli

Changeset fork, measured:packages/cli's files is ["dist","README.md","CHANGELOG.md"], so a src/** comment publishes nothing — but this card changes emitted output (os generate migration's DDL), which compiles into dist/commands/generate.js and ships. patch, not skip-changeset.

The three named exports do not widen the published surface: @objectstack/cli's exports map is . and ./console only, so src/commands/generate.ts is not an entrypoint. The file already carried a named export used by a test (GENERATOR_SCAFFOLD_TARGETS).

What I did NOT do

Residue

  • The generators spell a JSON column JSONB / table.jsonb(…) while the driver emits json (or text on SQLite). That predates this card and lives in the existing json vocabulary entry, so it is left alone; the new branch reuses each generator's own existing spelling rather than introducing a third. Bounded before deciding not to file it: the only check that could care is schema-drift.ts's base-type finding, and it is gated on acceptsStringifiedJson = /char|text/i, which jsonb does not match. So the divergence raises nothing and is cosmetic — a measurement, not an assumption, and the reason there is no drive-by card for it.
  • A previously generated migration file is not rewritten by a generator change. An app that already ran one has a scalar column where the platform wants JSON; the schema sync is additive and will not retype it. That drift pre-exists this PR — it is what the defect was — and closing it would be a migration story, not a generator one.

Authored in Claude Code session https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza (recorded here in prose because editing a PR body rewrites the footer below back to its bare form).

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

…urfaces
Red-first. The pin drives `os generate types`, `os generate migration
--format sql` and `os generate migration` (typescript) on one config and
asserts they give a flagged field ONE answer — an array TS type and a JSON
column — mirroring `driver-sql`'s `createColumn`, which decides `multiple`
before its per-type switch.
The three generator functions gain a named export so the pin can drive them
directly; `src/commands/generate.ts` is not a package entrypoint
(`@objectstack/cli` exports only `.` and `./console`), so nothing is added to
the published surface, and no emitted output changes in this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…rators
`os generate types` honoured the flag and neither migration generator did, so
one authored `Field.lookup({ multiple: true })` produced an array TS type and a
scalar `VARCHAR(36)` / `table.uuid` column from the same config in the same run.
The column authority is `driver-sql`, and its answer is the flag alone:
`createColumn` short-circuits on `field.multiple` above its own per-type switch,
`isJsonField` is `JSON_COLUMN_TYPES.has(type) || !!field.multiple`, and
`fieldHasColumn` opens with the same check. Both generators now answer it in the
same place — before the type is consulted — so the element type gets no vote.
Deliberately NOT the spec's `isMultiValueField`: that is the ADR-0104 D1 value
contract, gated on `MULTI_CAPABLE_TYPES`, and it would answer VARCHAR for a
`text` field the driver gives a JSON column — the same drift one notch narrower.
The per-type vocabularies are untouched; the disputed scalar answers stay
byte-for-byte and are pinned as a scope fence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

3 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))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 22 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 5bc2f2727aef5d697530c63d64dd5592df445776packageMentionDocs.

Which tree this was computed on

This run read content/docs from d8e8e896abd2eadc963ba94a11c58d937310ead6 — the merge of head ba7204962888e968b98bee7b3d47ead13015eb94 into base 5bc2f2727aef5d697530c63d64dd5592df445776, 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 d8e8e896abd2eadc963ba94a11c58d937310ead6 && git checkout d8e8e896abd2eadc963ba94a11c58d937310ead6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5bc2f2727aef5d697530c63d64dd5592df445776 ba7204962888e968b98bee7b3d47ead13015eb94 && git checkout -B drift-repro 5bc2f2727aef5d697530c63d64dd5592df445776 && git merge --no-ff ba7204962888e968b98bee7b3d47ead13015eb94
node scripts/docs-audit/affected-docs.mjs --json 5bc2f2727aef5d697530c63d64dd5592df445776

⚠️ 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 5bc2f2727aef5d697530c63d64dd5592df445776 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(cli): multiple: true takes a JSON column in both migration generators - #14990

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-14829-migration-multiple-field-type
Sep 3, 2026
Merged

fix(cli): multiple: true takes a JSON column in both migration generators#14990
os-trump merged 2 commits into
mainfrom
claude/issue-14829-migration-multiple-field-type

Conversation

@os-trump

@os-trumpos-trump commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14829

os generate types honoured multiple: true and neither migration generator did, so one authored field produced two incompatible answers from one config in one run — an array TS type and a scalar column. This threads the flag through both migration generators, answering it where the platform answers it: before the type is consulted at all.

Re-measured on origin/main (5bc2f27) — every number with the command that produced it

AnchorCommandResult
multiple occurrences in generate.tsgit grep -n multiple origin/main -- packages/cli/src/commands/generate.ts4, at :562 :564 :607 :831, all TypeScript-side. Card's numbers hold; the triage comment's :502/:504/:547/:771 are the stale pair
fieldTypeToSql aritysed -n '1030,1033p'took (fieldType: string) only — the flag could not reach it
The driver's rule, statement 1sed -n '15342,15370p' packages/drivers/driver-sql/src/sql-driver.tscreateColumn opens with if (field.multiple) { this.jsonColumn(table, name); return; }, above its switch (type)
statement 2sed -n '15867p' same fileisJsonField is JSON_COLUMN_TYPES.has(type) || !!field.multiple
statement 3sed -n '541,544p' packages/drivers/driver-sql/src/schema-drift.tsfieldHasColumn opens if (field?.multiple) return true;, documented as "Mirrors SqlDriver.createColumn exactly … including multiple (a JSON column)"
The spec's value predicatesed -n '213,236p' packages/spec/src/data/field-value.zod.tsisMultiValueField = MULTI_OPTION_TYPES.has(type) OR (MULTI_CAPABLE_TYPES.has(type) AND multiple === true) — type-gated
Is the combination authorable off that roster?grep -n multiple packages/spec/src/data/field.zod.tsmultiple is a plain z.boolean().default(false) on every field; only radio + multiple is refused by name (:1818)

One correction worth recording: the card and the triage comment both spell the driver path driver-sql; it is packages/drivers/driver-sql. A git grep pathspec of packages/**/src matches zero there (git pathspec ** is not recursive without :(glob)), which is a silent zero rather than an error.

Which surface is authoritative — the driver, and not the spec predicate

The three surfaces answer two different questions and only one of them is about a column:

  • isMultiValueField is the ADR-0104 D1 value contract — "is the persisted value an array". Gated on MULTI_CAPABLE_TYPES.
  • createColumn / isJsonField / fieldHasColumn decide which column exists. Gated on the flag alone, three times over.

A migration generator's subject is the column, so the column authority governs. There is a fourth statement of the same rule, found while bounding a residue note, and it is the one that shows what the defect COST. schema-drift.ts's base-type check opens its emission site with const declaresJsonColumn = field.multiple === true; and reports expected: 'json' — flag alone again — gated on acceptsStringifiedJson(col.type), which is /char|text/i. Run that against what each generator emitted for a flagged lookup before this PR:

Emitted byColumnacceptsStringifiedJsonConsequence on Postgres
--format sql (pre-fix)VARCHAR(36)truean error-severity needs_confirm drift finding against a table the CLI itself generated — "arrays are being written as the STRINGIFIED literal"
typescript (pre-fix)table.uuidfalseno finding; the database refuses the write instead
either (this PR)JSONBfalsenone — the column is what the platform wants

So the two pre-fix answers were wrong in two different ways, and neither was silent for the platform — only for the author. The triage comment's steer — derive from isMultiValueField — would have answered VARCHAR(255) for a text field flagged multiple: true while the driver gives that same field a JSON column: the same drift one notch narrower. And the combination is reachable, because FieldSchema does not refuse it and the CLI generators sit downstream of validation (their own default: arm is documented as serving the unvalidated authoring door). So the implementation gates on the flag, with no list at all — the "fourth hand-copied vocabulary" the card warned about is avoided by not needing one. MULTI_CAPABLE_TYPES is still imported by the pin, as the roster it sweeps.

Red-first, and which arm fired

The pin was written and committed before the fix (a59b393f20), and run against the unfixed generators:

Tests 9 failed | 6 passed (15)
AssertionError: os generate migration --format sql gave a flagged lookup a scalar column …
expected 'VARCHAR(36)' to be 'JSONB' pin.test.ts:177

The 9 failures are the discriminating arms; all 6 controls passed — the roster control, the emission control, the "same type without the flag keeps its scalar column" control, the two driver-authority reads and the scope fence. The red is the negative arm at line 177, not the harness.

Re-proved as a full ablation on the finished tree (os-verify-lock.sh, one hold):

green baseline Tests 15 passed (15)
mutation on disk both branch markers 0/0 · blob a3fd631b… != HEAD 6cc1e93a… · 57593 -> 57420 bytes
ablated Tests 9 failed | 6 passed (15) — the same 9 arms, same 6 controls green
restore hash-object == HEAD blob 6cc1e93a… · git diff HEAD -> 0 files
re-run Tests 15 passed (15)

Mutation and restore are both proved by bytes, never by an editor's exit code, under trap … EXIT INT TERM on absolute paths. No build/dist leg: the pin imports its subject by relative specifier (./generate.js), which vitest resolves to the sibling source, so there is no exports hop that a stale dist could answer for.

Verification

CheckResult
pnpm lint (whole repo, eslint . --no-inline-config)exit 0 at ba72049628 — the final commit, no narrowing claimed
the pin + the neighbouring vocabulary pin + generate-file-name-registry-parity61 passed
check:cross-package-test-inputs, check:ci-filter-parity, check:turbo-task-graph, check:entry-guard, check:nul-bytes, check:test-source-alias, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:published-files, check:undeclared-dep-imports, check:doc-authoring, check:type-source-resolution, check:slot-lookup, check:parse-guard, check:pnpm-filter-targets, check:cli-command-ids, check:objectui-changeset, check:empty-changeset, check:changeset-no-major, check:adr-0087-registration, check:changeset-gate-self-tests, check:pm-half-states, check:self-test-wired, check:ratchet-remedy-authority, check:agent-test-spellingall exit 0
packages/clitsc --noEmit and check:test-typecheckNOT MEASURED — 194 of 234 errors are TS2307 for workspace modules, the unbuilt-closure signature, and the three check:test-typecheck files named are ones this diff does not touch

For that last row the question was narrowed rather than dropped, and the narrowing is measured: an A/B on the same unbuilt tree — census at HEAD vs census with generate.ts reverted to 5bc2f2727a and the pin file removed — is 234 error lines both ways with an empty diff, and zero errors name either of my files. So this diff adds no type error; what is unmeasured locally is the pre-existing state, which CI measures on a built workspace. Non-vacuity for the coverage claim itself: tsc --noEmit --listFiles lists 988 files and the new pin is one of them (grep -c = 1), so packages/cli's include: ["src"] really does typecheck it — the new test is deliberately under src/, not test/, for that reason.

File face

FileWhy
packages/cli/src/commands/generate.tsthe fix: fieldTypeToSql takes the flag and answers it first; generateMigrationTs emits the JSON column before its per-type switch; three functions gain a named export so the pin can drive them
packages/cli/src/commands/generate-multiple-json-column.pin.test.tsnew — the cross-surface pin
turbo.json + scripts/cross-package-test-inputs.mjsthe pin reads two driver-sql sources, so check:cross-package-test-inputs demanded both declarations. This is the pin's point, not paperwork: if the driver moves the rule and cli's suite does not re-run, the two sides drift again by the cache
.changeset/generate-migration-multiple-json-column.mdpatch for @objectstack/cli

Changeset fork, measured:packages/cli's files is ["dist","README.md","CHANGELOG.md"], so a src/** comment publishes nothing — but this card changes emitted output (os generate migration's DDL), which compiles into dist/commands/generate.js and ships. patch, not skip-changeset.

The three named exports do not widen the published surface: @objectstack/cli's exports map is . and ./console only, so src/commands/generate.ts is not an entrypoint. The file already carried a named export used by a test (GENERATOR_SCAFFOLD_TARGETS).

What I did NOT do

Residue

  • The generators spell a JSON column JSONB / table.jsonb(…) while the driver emits json (or text on SQLite). That predates this card and lives in the existing json vocabulary entry, so it is left alone; the new branch reuses each generator's own existing spelling rather than introducing a third. Bounded before deciding not to file it: the only check that could care is schema-drift.ts's base-type finding, and it is gated on acceptsStringifiedJson = /char|text/i, which jsonb does not match. So the divergence raises nothing and is cosmetic — a measurement, not an assumption, and the reason there is no drive-by card for it.
  • A previously generated migration file is not rewritten by a generator change. An app that already ran one has a scalar column where the platform wants JSON; the schema sync is additive and will not retype it. That drift pre-exists this PR — it is what the defect was — and closing it would be a migration story, not a generator one.

Authored in Claude Code session https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza (recorded here in prose because editing a PR body rewrites the footer below back to its bare form).

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

…urfaces
Red-first. The pin drives `os generate types`, `os generate migration
--format sql` and `os generate migration` (typescript) on one config and
asserts they give a flagged field ONE answer — an array TS type and a JSON
column — mirroring `driver-sql`'s `createColumn`, which decides `multiple`
before its per-type switch.
The three generator functions gain a named export so the pin can drive them
directly; `src/commands/generate.ts` is not a package entrypoint
(`@objectstack/cli` exports only `.` and `./console`), so nothing is added to
the published surface, and no emitted output changes in this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…rators
`os generate types` honoured the flag and neither migration generator did, so
one authored `Field.lookup({ multiple: true })` produced an array TS type and a
scalar `VARCHAR(36)` / `table.uuid` column from the same config in the same run.
The column authority is `driver-sql`, and its answer is the flag alone:
`createColumn` short-circuits on `field.multiple` above its own per-type switch,
`isJsonField` is `JSON_COLUMN_TYPES.has(type) || !!field.multiple`, and
`fieldHasColumn` opens with the same check. Both generators now answer it in the
same place — before the type is consulted — so the element type gets no vote.
Deliberately NOT the spec's `isMultiValueField`: that is the ADR-0104 D1 value
contract, gated on `MULTI_CAPABLE_TYPES`, and it would answer VARCHAR for a
`text` field the driver gives a JSON column — the same drift one notch narrower.
The per-type vocabularies are untouched; the disputed scalar answers stay
byte-for-byte and are pinned as a scope fence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

3 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))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 22 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 5bc2f2727aef5d697530c63d64dd5592df445776packageMentionDocs.

Which tree this was computed on

This run read content/docs from d8e8e896abd2eadc963ba94a11c58d937310ead6 — the merge of head ba7204962888e968b98bee7b3d47ead13015eb94 into base 5bc2f2727aef5d697530c63d64dd5592df445776, 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 d8e8e896abd2eadc963ba94a11c58d937310ead6 && git checkout d8e8e896abd2eadc963ba94a11c58d937310ead6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5bc2f2727aef5d697530c63d64dd5592df445776 ba7204962888e968b98bee7b3d47ead13015eb94 && git checkout -B drift-repro 5bc2f2727aef5d697530c63d64dd5592df445776 && git merge --no-ff ba7204962888e968b98bee7b3d47ead13015eb94
node scripts/docs-audit/affected-docs.mjs --json 5bc2f2727aef5d697530c63d64dd5592df445776

⚠️ 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 5bc2f2727aef5d697530c63d64dd5592df445776 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): multiple: true takes a JSON column in both migration generators - #14990

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-14829-migration-multiple-field-type
Sep 3, 2026
Merged

fix(cli): multiple: true takes a JSON column in both migration generators#14990
os-trump merged 2 commits into
mainfrom
claude/issue-14829-migration-multiple-field-type

Conversation

@os-trump

@os-trumpos-trump commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14829

os generate types honoured multiple: true and neither migration generator did, so one authored field produced two incompatible answers from one config in one run — an array TS type and a scalar column. This threads the flag through both migration generators, answering it where the platform answers it: before the type is consulted at all.

Re-measured on origin/main (5bc2f27) — every number with the command that produced it

AnchorCommandResult
multiple occurrences in generate.tsgit grep -n multiple origin/main -- packages/cli/src/commands/generate.ts4, at :562 :564 :607 :831, all TypeScript-side. Card's numbers hold; the triage comment's :502/:504/:547/:771 are the stale pair
fieldTypeToSql aritysed -n '1030,1033p'took (fieldType: string) only — the flag could not reach it
The driver's rule, statement 1sed -n '15342,15370p' packages/drivers/driver-sql/src/sql-driver.tscreateColumn opens with if (field.multiple) { this.jsonColumn(table, name); return; }, above its switch (type)
statement 2sed -n '15867p' same fileisJsonField is JSON_COLUMN_TYPES.has(type) || !!field.multiple
statement 3sed -n '541,544p' packages/drivers/driver-sql/src/schema-drift.tsfieldHasColumn opens if (field?.multiple) return true;, documented as "Mirrors SqlDriver.createColumn exactly … including multiple (a JSON column)"
The spec's value predicatesed -n '213,236p' packages/spec/src/data/field-value.zod.tsisMultiValueField = MULTI_OPTION_TYPES.has(type) OR (MULTI_CAPABLE_TYPES.has(type) AND multiple === true) — type-gated
Is the combination authorable off that roster?grep -n multiple packages/spec/src/data/field.zod.tsmultiple is a plain z.boolean().default(false) on every field; only radio + multiple is refused by name (:1818)

One correction worth recording: the card and the triage comment both spell the driver path driver-sql; it is packages/drivers/driver-sql. A git grep pathspec of packages/**/src matches zero there (git pathspec ** is not recursive without :(glob)), which is a silent zero rather than an error.

Which surface is authoritative — the driver, and not the spec predicate

The three surfaces answer two different questions and only one of them is about a column:

  • isMultiValueField is the ADR-0104 D1 value contract — "is the persisted value an array". Gated on MULTI_CAPABLE_TYPES.
  • createColumn / isJsonField / fieldHasColumn decide which column exists. Gated on the flag alone, three times over.

A migration generator's subject is the column, so the column authority governs. There is a fourth statement of the same rule, found while bounding a residue note, and it is the one that shows what the defect COST. schema-drift.ts's base-type check opens its emission site with const declaresJsonColumn = field.multiple === true; and reports expected: 'json' — flag alone again — gated on acceptsStringifiedJson(col.type), which is /char|text/i. Run that against what each generator emitted for a flagged lookup before this PR:

Emitted byColumnacceptsStringifiedJsonConsequence on Postgres
--format sql (pre-fix)VARCHAR(36)truean error-severity needs_confirm drift finding against a table the CLI itself generated — "arrays are being written as the STRINGIFIED literal"
typescript (pre-fix)table.uuidfalseno finding; the database refuses the write instead
either (this PR)JSONBfalsenone — the column is what the platform wants

So the two pre-fix answers were wrong in two different ways, and neither was silent for the platform — only for the author. The triage comment's steer — derive from isMultiValueField — would have answered VARCHAR(255) for a text field flagged multiple: true while the driver gives that same field a JSON column: the same drift one notch narrower. And the combination is reachable, because FieldSchema does not refuse it and the CLI generators sit downstream of validation (their own default: arm is documented as serving the unvalidated authoring door). So the implementation gates on the flag, with no list at all — the "fourth hand-copied vocabulary" the card warned about is avoided by not needing one. MULTI_CAPABLE_TYPES is still imported by the pin, as the roster it sweeps.

Red-first, and which arm fired

The pin was written and committed before the fix (a59b393f20), and run against the unfixed generators:

Tests 9 failed | 6 passed (15)
AssertionError: os generate migration --format sql gave a flagged lookup a scalar column …
expected 'VARCHAR(36)' to be 'JSONB' pin.test.ts:177

The 9 failures are the discriminating arms; all 6 controls passed — the roster control, the emission control, the "same type without the flag keeps its scalar column" control, the two driver-authority reads and the scope fence. The red is the negative arm at line 177, not the harness.

Re-proved as a full ablation on the finished tree (os-verify-lock.sh, one hold):

green baseline Tests 15 passed (15)
mutation on disk both branch markers 0/0 · blob a3fd631b… != HEAD 6cc1e93a… · 57593 -> 57420 bytes
ablated Tests 9 failed | 6 passed (15) — the same 9 arms, same 6 controls green
restore hash-object == HEAD blob 6cc1e93a… · git diff HEAD -> 0 files
re-run Tests 15 passed (15)

Mutation and restore are both proved by bytes, never by an editor's exit code, under trap … EXIT INT TERM on absolute paths. No build/dist leg: the pin imports its subject by relative specifier (./generate.js), which vitest resolves to the sibling source, so there is no exports hop that a stale dist could answer for.

Verification

CheckResult
pnpm lint (whole repo, eslint . --no-inline-config)exit 0 at ba72049628 — the final commit, no narrowing claimed
the pin + the neighbouring vocabulary pin + generate-file-name-registry-parity61 passed
check:cross-package-test-inputs, check:ci-filter-parity, check:turbo-task-graph, check:entry-guard, check:nul-bytes, check:test-source-alias, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:published-files, check:undeclared-dep-imports, check:doc-authoring, check:type-source-resolution, check:slot-lookup, check:parse-guard, check:pnpm-filter-targets, check:cli-command-ids, check:objectui-changeset, check:empty-changeset, check:changeset-no-major, check:adr-0087-registration, check:changeset-gate-self-tests, check:pm-half-states, check:self-test-wired, check:ratchet-remedy-authority, check:agent-test-spellingall exit 0
packages/clitsc --noEmit and check:test-typecheckNOT MEASURED — 194 of 234 errors are TS2307 for workspace modules, the unbuilt-closure signature, and the three check:test-typecheck files named are ones this diff does not touch

For that last row the question was narrowed rather than dropped, and the narrowing is measured: an A/B on the same unbuilt tree — census at HEAD vs census with generate.ts reverted to 5bc2f2727a and the pin file removed — is 234 error lines both ways with an empty diff, and zero errors name either of my files. So this diff adds no type error; what is unmeasured locally is the pre-existing state, which CI measures on a built workspace. Non-vacuity for the coverage claim itself: tsc --noEmit --listFiles lists 988 files and the new pin is one of them (grep -c = 1), so packages/cli's include: ["src"] really does typecheck it — the new test is deliberately under src/, not test/, for that reason.

File face

FileWhy
packages/cli/src/commands/generate.tsthe fix: fieldTypeToSql takes the flag and answers it first; generateMigrationTs emits the JSON column before its per-type switch; three functions gain a named export so the pin can drive them
packages/cli/src/commands/generate-multiple-json-column.pin.test.tsnew — the cross-surface pin
turbo.json + scripts/cross-package-test-inputs.mjsthe pin reads two driver-sql sources, so check:cross-package-test-inputs demanded both declarations. This is the pin's point, not paperwork: if the driver moves the rule and cli's suite does not re-run, the two sides drift again by the cache
.changeset/generate-migration-multiple-json-column.mdpatch for @objectstack/cli

Changeset fork, measured:packages/cli's files is ["dist","README.md","CHANGELOG.md"], so a src/** comment publishes nothing — but this card changes emitted output (os generate migration's DDL), which compiles into dist/commands/generate.js and ships. patch, not skip-changeset.

The three named exports do not widen the published surface: @objectstack/cli's exports map is . and ./console only, so src/commands/generate.ts is not an entrypoint. The file already carried a named export used by a test (GENERATOR_SCAFFOLD_TARGETS).

What I did NOT do

Residue

  • The generators spell a JSON column JSONB / table.jsonb(…) while the driver emits json (or text on SQLite). That predates this card and lives in the existing json vocabulary entry, so it is left alone; the new branch reuses each generator's own existing spelling rather than introducing a third. Bounded before deciding not to file it: the only check that could care is schema-drift.ts's base-type finding, and it is gated on acceptsStringifiedJson = /char|text/i, which jsonb does not match. So the divergence raises nothing and is cosmetic — a measurement, not an assumption, and the reason there is no drive-by card for it.
  • A previously generated migration file is not rewritten by a generator change. An app that already ran one has a scalar column where the platform wants JSON; the schema sync is additive and will not retype it. That drift pre-exists this PR — it is what the defect was — and closing it would be a migration story, not a generator one.

Authored in Claude Code session https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza (recorded here in prose because editing a PR body rewrites the footer below back to its bare form).

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

…urfaces
Red-first. The pin drives `os generate types`, `os generate migration
--format sql` and `os generate migration` (typescript) on one config and
asserts they give a flagged field ONE answer — an array TS type and a JSON
column — mirroring `driver-sql`'s `createColumn`, which decides `multiple`
before its per-type switch.
The three generator functions gain a named export so the pin can drive them
directly; `src/commands/generate.ts` is not a package entrypoint
(`@objectstack/cli` exports only `.` and `./console`), so nothing is added to
the published surface, and no emitted output changes in this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…rators
`os generate types` honoured the flag and neither migration generator did, so
one authored `Field.lookup({ multiple: true })` produced an array TS type and a
scalar `VARCHAR(36)` / `table.uuid` column from the same config in the same run.
The column authority is `driver-sql`, and its answer is the flag alone:
`createColumn` short-circuits on `field.multiple` above its own per-type switch,
`isJsonField` is `JSON_COLUMN_TYPES.has(type) || !!field.multiple`, and
`fieldHasColumn` opens with the same check. Both generators now answer it in the
same place — before the type is consulted — so the element type gets no vote.
Deliberately NOT the spec's `isMultiValueField`: that is the ADR-0104 D1 value
contract, gated on `MULTI_CAPABLE_TYPES`, and it would answer VARCHAR for a
`text` field the driver gives a JSON column — the same drift one notch narrower.
The per-type vocabularies are untouched; the disputed scalar answers stay
byte-for-byte and are pinned as a scope fence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

3 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))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 22 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 5bc2f2727aef5d697530c63d64dd5592df445776packageMentionDocs.

Which tree this was computed on

This run read content/docs from d8e8e896abd2eadc963ba94a11c58d937310ead6 — the merge of head ba7204962888e968b98bee7b3d47ead13015eb94 into base 5bc2f2727aef5d697530c63d64dd5592df445776, 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 d8e8e896abd2eadc963ba94a11c58d937310ead6 && git checkout d8e8e896abd2eadc963ba94a11c58d937310ead6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5bc2f2727aef5d697530c63d64dd5592df445776 ba7204962888e968b98bee7b3d47ead13015eb94 && git checkout -B drift-repro 5bc2f2727aef5d697530c63d64dd5592df445776 && git merge --no-ff ba7204962888e968b98bee7b3d47ead13015eb94
node scripts/docs-audit/affected-docs.mjs --json 5bc2f2727aef5d697530c63d64dd5592df445776

⚠️ 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 5bc2f2727aef5d697530c63d64dd5592df445776 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): multiple: true takes a JSON column in both migration generators - #14990

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-14829-migration-multiple-field-type
Sep 3, 2026
Merged

fix(cli): multiple: true takes a JSON column in both migration generators#14990
os-trump merged 2 commits into
mainfrom
claude/issue-14829-migration-multiple-field-type

Conversation

@os-trump

@os-trumpos-trump commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14829

os generate types honoured multiple: true and neither migration generator did, so one authored field produced two incompatible answers from one config in one run — an array TS type and a scalar column. This threads the flag through both migration generators, answering it where the platform answers it: before the type is consulted at all.

Re-measured on origin/main (5bc2f27) — every number with the command that produced it

AnchorCommandResult
multiple occurrences in generate.tsgit grep -n multiple origin/main -- packages/cli/src/commands/generate.ts4, at :562 :564 :607 :831, all TypeScript-side. Card's numbers hold; the triage comment's :502/:504/:547/:771 are the stale pair
fieldTypeToSql aritysed -n '1030,1033p'took (fieldType: string) only — the flag could not reach it
The driver's rule, statement 1sed -n '15342,15370p' packages/drivers/driver-sql/src/sql-driver.tscreateColumn opens with if (field.multiple) { this.jsonColumn(table, name); return; }, above its switch (type)
statement 2sed -n '15867p' same fileisJsonField is JSON_COLUMN_TYPES.has(type) || !!field.multiple
statement 3sed -n '541,544p' packages/drivers/driver-sql/src/schema-drift.tsfieldHasColumn opens if (field?.multiple) return true;, documented as "Mirrors SqlDriver.createColumn exactly … including multiple (a JSON column)"
The spec's value predicatesed -n '213,236p' packages/spec/src/data/field-value.zod.tsisMultiValueField = MULTI_OPTION_TYPES.has(type) OR (MULTI_CAPABLE_TYPES.has(type) AND multiple === true) — type-gated
Is the combination authorable off that roster?grep -n multiple packages/spec/src/data/field.zod.tsmultiple is a plain z.boolean().default(false) on every field; only radio + multiple is refused by name (:1818)

One correction worth recording: the card and the triage comment both spell the driver path driver-sql; it is packages/drivers/driver-sql. A git grep pathspec of packages/**/src matches zero there (git pathspec ** is not recursive without :(glob)), which is a silent zero rather than an error.

Which surface is authoritative — the driver, and not the spec predicate

The three surfaces answer two different questions and only one of them is about a column:

  • isMultiValueField is the ADR-0104 D1 value contract — "is the persisted value an array". Gated on MULTI_CAPABLE_TYPES.
  • createColumn / isJsonField / fieldHasColumn decide which column exists. Gated on the flag alone, three times over.

A migration generator's subject is the column, so the column authority governs. There is a fourth statement of the same rule, found while bounding a residue note, and it is the one that shows what the defect COST. schema-drift.ts's base-type check opens its emission site with const declaresJsonColumn = field.multiple === true; and reports expected: 'json' — flag alone again — gated on acceptsStringifiedJson(col.type), which is /char|text/i. Run that against what each generator emitted for a flagged lookup before this PR:

Emitted byColumnacceptsStringifiedJsonConsequence on Postgres
--format sql (pre-fix)VARCHAR(36)truean error-severity needs_confirm drift finding against a table the CLI itself generated — "arrays are being written as the STRINGIFIED literal"
typescript (pre-fix)table.uuidfalseno finding; the database refuses the write instead
either (this PR)JSONBfalsenone — the column is what the platform wants

So the two pre-fix answers were wrong in two different ways, and neither was silent for the platform — only for the author. The triage comment's steer — derive from isMultiValueField — would have answered VARCHAR(255) for a text field flagged multiple: true while the driver gives that same field a JSON column: the same drift one notch narrower. And the combination is reachable, because FieldSchema does not refuse it and the CLI generators sit downstream of validation (their own default: arm is documented as serving the unvalidated authoring door). So the implementation gates on the flag, with no list at all — the "fourth hand-copied vocabulary" the card warned about is avoided by not needing one. MULTI_CAPABLE_TYPES is still imported by the pin, as the roster it sweeps.

Red-first, and which arm fired

The pin was written and committed before the fix (a59b393f20), and run against the unfixed generators:

Tests 9 failed | 6 passed (15)
AssertionError: os generate migration --format sql gave a flagged lookup a scalar column …
expected 'VARCHAR(36)' to be 'JSONB' pin.test.ts:177

The 9 failures are the discriminating arms; all 6 controls passed — the roster control, the emission control, the "same type without the flag keeps its scalar column" control, the two driver-authority reads and the scope fence. The red is the negative arm at line 177, not the harness.

Re-proved as a full ablation on the finished tree (os-verify-lock.sh, one hold):

green baseline Tests 15 passed (15)
mutation on disk both branch markers 0/0 · blob a3fd631b… != HEAD 6cc1e93a… · 57593 -> 57420 bytes
ablated Tests 9 failed | 6 passed (15) — the same 9 arms, same 6 controls green
restore hash-object == HEAD blob 6cc1e93a… · git diff HEAD -> 0 files
re-run Tests 15 passed (15)

Mutation and restore are both proved by bytes, never by an editor's exit code, under trap … EXIT INT TERM on absolute paths. No build/dist leg: the pin imports its subject by relative specifier (./generate.js), which vitest resolves to the sibling source, so there is no exports hop that a stale dist could answer for.

Verification

CheckResult
pnpm lint (whole repo, eslint . --no-inline-config)exit 0 at ba72049628 — the final commit, no narrowing claimed
the pin + the neighbouring vocabulary pin + generate-file-name-registry-parity61 passed
check:cross-package-test-inputs, check:ci-filter-parity, check:turbo-task-graph, check:entry-guard, check:nul-bytes, check:test-source-alias, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:published-files, check:undeclared-dep-imports, check:doc-authoring, check:type-source-resolution, check:slot-lookup, check:parse-guard, check:pnpm-filter-targets, check:cli-command-ids, check:objectui-changeset, check:empty-changeset, check:changeset-no-major, check:adr-0087-registration, check:changeset-gate-self-tests, check:pm-half-states, check:self-test-wired, check:ratchet-remedy-authority, check:agent-test-spellingall exit 0
packages/clitsc --noEmit and check:test-typecheckNOT MEASURED — 194 of 234 errors are TS2307 for workspace modules, the unbuilt-closure signature, and the three check:test-typecheck files named are ones this diff does not touch

For that last row the question was narrowed rather than dropped, and the narrowing is measured: an A/B on the same unbuilt tree — census at HEAD vs census with generate.ts reverted to 5bc2f2727a and the pin file removed — is 234 error lines both ways with an empty diff, and zero errors name either of my files. So this diff adds no type error; what is unmeasured locally is the pre-existing state, which CI measures on a built workspace. Non-vacuity for the coverage claim itself: tsc --noEmit --listFiles lists 988 files and the new pin is one of them (grep -c = 1), so packages/cli's include: ["src"] really does typecheck it — the new test is deliberately under src/, not test/, for that reason.

File face

FileWhy
packages/cli/src/commands/generate.tsthe fix: fieldTypeToSql takes the flag and answers it first; generateMigrationTs emits the JSON column before its per-type switch; three functions gain a named export so the pin can drive them
packages/cli/src/commands/generate-multiple-json-column.pin.test.tsnew — the cross-surface pin
turbo.json + scripts/cross-package-test-inputs.mjsthe pin reads two driver-sql sources, so check:cross-package-test-inputs demanded both declarations. This is the pin's point, not paperwork: if the driver moves the rule and cli's suite does not re-run, the two sides drift again by the cache
.changeset/generate-migration-multiple-json-column.mdpatch for @objectstack/cli

Changeset fork, measured:packages/cli's files is ["dist","README.md","CHANGELOG.md"], so a src/** comment publishes nothing — but this card changes emitted output (os generate migration's DDL), which compiles into dist/commands/generate.js and ships. patch, not skip-changeset.

The three named exports do not widen the published surface: @objectstack/cli's exports map is . and ./console only, so src/commands/generate.ts is not an entrypoint. The file already carried a named export used by a test (GENERATOR_SCAFFOLD_TARGETS).

What I did NOT do

Residue

  • The generators spell a JSON column JSONB / table.jsonb(…) while the driver emits json (or text on SQLite). That predates this card and lives in the existing json vocabulary entry, so it is left alone; the new branch reuses each generator's own existing spelling rather than introducing a third. Bounded before deciding not to file it: the only check that could care is schema-drift.ts's base-type finding, and it is gated on acceptsStringifiedJson = /char|text/i, which jsonb does not match. So the divergence raises nothing and is cosmetic — a measurement, not an assumption, and the reason there is no drive-by card for it.
  • A previously generated migration file is not rewritten by a generator change. An app that already ran one has a scalar column where the platform wants JSON; the schema sync is additive and will not retype it. That drift pre-exists this PR — it is what the defect was — and closing it would be a migration story, not a generator one.

Authored in Claude Code session https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza (recorded here in prose because editing a PR body rewrites the footer below back to its bare form).

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

…urfaces
Red-first. The pin drives `os generate types`, `os generate migration
--format sql` and `os generate migration` (typescript) on one config and
asserts they give a flagged field ONE answer — an array TS type and a JSON
column — mirroring `driver-sql`'s `createColumn`, which decides `multiple`
before its per-type switch.
The three generator functions gain a named export so the pin can drive them
directly; `src/commands/generate.ts` is not a package entrypoint
(`@objectstack/cli` exports only `.` and `./console`), so nothing is added to
the published surface, and no emitted output changes in this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…rators
`os generate types` honoured the flag and neither migration generator did, so
one authored `Field.lookup({ multiple: true })` produced an array TS type and a
scalar `VARCHAR(36)` / `table.uuid` column from the same config in the same run.
The column authority is `driver-sql`, and its answer is the flag alone:
`createColumn` short-circuits on `field.multiple` above its own per-type switch,
`isJsonField` is `JSON_COLUMN_TYPES.has(type) || !!field.multiple`, and
`fieldHasColumn` opens with the same check. Both generators now answer it in the
same place — before the type is consulted — so the element type gets no vote.
Deliberately NOT the spec's `isMultiValueField`: that is the ADR-0104 D1 value
contract, gated on `MULTI_CAPABLE_TYPES`, and it would answer VARCHAR for a
`text` field the driver gives a JSON column — the same drift one notch narrower.
The per-type vocabularies are untouched; the disputed scalar answers stay
byte-for-byte and are pinned as a scope fence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

3 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))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 22 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 5bc2f2727aef5d697530c63d64dd5592df445776packageMentionDocs.

Which tree this was computed on

This run read content/docs from d8e8e896abd2eadc963ba94a11c58d937310ead6 — the merge of head ba7204962888e968b98bee7b3d47ead13015eb94 into base 5bc2f2727aef5d697530c63d64dd5592df445776, 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 d8e8e896abd2eadc963ba94a11c58d937310ead6 && git checkout d8e8e896abd2eadc963ba94a11c58d937310ead6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5bc2f2727aef5d697530c63d64dd5592df445776 ba7204962888e968b98bee7b3d47ead13015eb94 && git checkout -B drift-repro 5bc2f2727aef5d697530c63d64dd5592df445776 && git merge --no-ff ba7204962888e968b98bee7b3d47ead13015eb94
node scripts/docs-audit/affected-docs.mjs --json 5bc2f2727aef5d697530c63d64dd5592df445776

⚠️ 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 5bc2f2727aef5d697530c63d64dd5592df445776 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(cli): multiple: true takes a JSON column in both migration generators - #14990

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-14829-migration-multiple-field-type
Sep 3, 2026
Merged

fix(cli): multiple: true takes a JSON column in both migration generators#14990
os-trump merged 2 commits into
mainfrom
claude/issue-14829-migration-multiple-field-type

Conversation

@os-trump

@os-trumpos-trump commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14829

os generate types honoured multiple: true and neither migration generator did, so one authored field produced two incompatible answers from one config in one run — an array TS type and a scalar column. This threads the flag through both migration generators, answering it where the platform answers it: before the type is consulted at all.

Re-measured on origin/main (5bc2f27) — every number with the command that produced it

AnchorCommandResult
multiple occurrences in generate.tsgit grep -n multiple origin/main -- packages/cli/src/commands/generate.ts4, at :562 :564 :607 :831, all TypeScript-side. Card's numbers hold; the triage comment's :502/:504/:547/:771 are the stale pair
fieldTypeToSql aritysed -n '1030,1033p'took (fieldType: string) only — the flag could not reach it
The driver's rule, statement 1sed -n '15342,15370p' packages/drivers/driver-sql/src/sql-driver.tscreateColumn opens with if (field.multiple) { this.jsonColumn(table, name); return; }, above its switch (type)
statement 2sed -n '15867p' same fileisJsonField is JSON_COLUMN_TYPES.has(type) || !!field.multiple
statement 3sed -n '541,544p' packages/drivers/driver-sql/src/schema-drift.tsfieldHasColumn opens if (field?.multiple) return true;, documented as "Mirrors SqlDriver.createColumn exactly … including multiple (a JSON column)"
The spec's value predicatesed -n '213,236p' packages/spec/src/data/field-value.zod.tsisMultiValueField = MULTI_OPTION_TYPES.has(type) OR (MULTI_CAPABLE_TYPES.has(type) AND multiple === true) — type-gated
Is the combination authorable off that roster?grep -n multiple packages/spec/src/data/field.zod.tsmultiple is a plain z.boolean().default(false) on every field; only radio + multiple is refused by name (:1818)

One correction worth recording: the card and the triage comment both spell the driver path driver-sql; it is packages/drivers/driver-sql. A git grep pathspec of packages/**/src matches zero there (git pathspec ** is not recursive without :(glob)), which is a silent zero rather than an error.

Which surface is authoritative — the driver, and not the spec predicate

The three surfaces answer two different questions and only one of them is about a column:

  • isMultiValueField is the ADR-0104 D1 value contract — "is the persisted value an array". Gated on MULTI_CAPABLE_TYPES.
  • createColumn / isJsonField / fieldHasColumn decide which column exists. Gated on the flag alone, three times over.

A migration generator's subject is the column, so the column authority governs. There is a fourth statement of the same rule, found while bounding a residue note, and it is the one that shows what the defect COST. schema-drift.ts's base-type check opens its emission site with const declaresJsonColumn = field.multiple === true; and reports expected: 'json' — flag alone again — gated on acceptsStringifiedJson(col.type), which is /char|text/i. Run that against what each generator emitted for a flagged lookup before this PR:

Emitted byColumnacceptsStringifiedJsonConsequence on Postgres
--format sql (pre-fix)VARCHAR(36)truean error-severity needs_confirm drift finding against a table the CLI itself generated — "arrays are being written as the STRINGIFIED literal"
typescript (pre-fix)table.uuidfalseno finding; the database refuses the write instead
either (this PR)JSONBfalsenone — the column is what the platform wants

So the two pre-fix answers were wrong in two different ways, and neither was silent for the platform — only for the author. The triage comment's steer — derive from isMultiValueField — would have answered VARCHAR(255) for a text field flagged multiple: true while the driver gives that same field a JSON column: the same drift one notch narrower. And the combination is reachable, because FieldSchema does not refuse it and the CLI generators sit downstream of validation (their own default: arm is documented as serving the unvalidated authoring door). So the implementation gates on the flag, with no list at all — the "fourth hand-copied vocabulary" the card warned about is avoided by not needing one. MULTI_CAPABLE_TYPES is still imported by the pin, as the roster it sweeps.

Red-first, and which arm fired

The pin was written and committed before the fix (a59b393f20), and run against the unfixed generators:

Tests 9 failed | 6 passed (15)
AssertionError: os generate migration --format sql gave a flagged lookup a scalar column …
expected 'VARCHAR(36)' to be 'JSONB' pin.test.ts:177

The 9 failures are the discriminating arms; all 6 controls passed — the roster control, the emission control, the "same type without the flag keeps its scalar column" control, the two driver-authority reads and the scope fence. The red is the negative arm at line 177, not the harness.

Re-proved as a full ablation on the finished tree (os-verify-lock.sh, one hold):

green baseline Tests 15 passed (15)
mutation on disk both branch markers 0/0 · blob a3fd631b… != HEAD 6cc1e93a… · 57593 -> 57420 bytes
ablated Tests 9 failed | 6 passed (15) — the same 9 arms, same 6 controls green
restore hash-object == HEAD blob 6cc1e93a… · git diff HEAD -> 0 files
re-run Tests 15 passed (15)

Mutation and restore are both proved by bytes, never by an editor's exit code, under trap … EXIT INT TERM on absolute paths. No build/dist leg: the pin imports its subject by relative specifier (./generate.js), which vitest resolves to the sibling source, so there is no exports hop that a stale dist could answer for.

Verification

CheckResult
pnpm lint (whole repo, eslint . --no-inline-config)exit 0 at ba72049628 — the final commit, no narrowing claimed
the pin + the neighbouring vocabulary pin + generate-file-name-registry-parity61 passed
check:cross-package-test-inputs, check:ci-filter-parity, check:turbo-task-graph, check:entry-guard, check:nul-bytes, check:test-source-alias, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:published-files, check:undeclared-dep-imports, check:doc-authoring, check:type-source-resolution, check:slot-lookup, check:parse-guard, check:pnpm-filter-targets, check:cli-command-ids, check:objectui-changeset, check:empty-changeset, check:changeset-no-major, check:adr-0087-registration, check:changeset-gate-self-tests, check:pm-half-states, check:self-test-wired, check:ratchet-remedy-authority, check:agent-test-spellingall exit 0
packages/clitsc --noEmit and check:test-typecheckNOT MEASURED — 194 of 234 errors are TS2307 for workspace modules, the unbuilt-closure signature, and the three check:test-typecheck files named are ones this diff does not touch

For that last row the question was narrowed rather than dropped, and the narrowing is measured: an A/B on the same unbuilt tree — census at HEAD vs census with generate.ts reverted to 5bc2f2727a and the pin file removed — is 234 error lines both ways with an empty diff, and zero errors name either of my files. So this diff adds no type error; what is unmeasured locally is the pre-existing state, which CI measures on a built workspace. Non-vacuity for the coverage claim itself: tsc --noEmit --listFiles lists 988 files and the new pin is one of them (grep -c = 1), so packages/cli's include: ["src"] really does typecheck it — the new test is deliberately under src/, not test/, for that reason.

File face

FileWhy
packages/cli/src/commands/generate.tsthe fix: fieldTypeToSql takes the flag and answers it first; generateMigrationTs emits the JSON column before its per-type switch; three functions gain a named export so the pin can drive them
packages/cli/src/commands/generate-multiple-json-column.pin.test.tsnew — the cross-surface pin
turbo.json + scripts/cross-package-test-inputs.mjsthe pin reads two driver-sql sources, so check:cross-package-test-inputs demanded both declarations. This is the pin's point, not paperwork: if the driver moves the rule and cli's suite does not re-run, the two sides drift again by the cache
.changeset/generate-migration-multiple-json-column.mdpatch for @objectstack/cli

Changeset fork, measured:packages/cli's files is ["dist","README.md","CHANGELOG.md"], so a src/** comment publishes nothing — but this card changes emitted output (os generate migration's DDL), which compiles into dist/commands/generate.js and ships. patch, not skip-changeset.

The three named exports do not widen the published surface: @objectstack/cli's exports map is . and ./console only, so src/commands/generate.ts is not an entrypoint. The file already carried a named export used by a test (GENERATOR_SCAFFOLD_TARGETS).

What I did NOT do

Residue

  • The generators spell a JSON column JSONB / table.jsonb(…) while the driver emits json (or text on SQLite). That predates this card and lives in the existing json vocabulary entry, so it is left alone; the new branch reuses each generator's own existing spelling rather than introducing a third. Bounded before deciding not to file it: the only check that could care is schema-drift.ts's base-type finding, and it is gated on acceptsStringifiedJson = /char|text/i, which jsonb does not match. So the divergence raises nothing and is cosmetic — a measurement, not an assumption, and the reason there is no drive-by card for it.
  • A previously generated migration file is not rewritten by a generator change. An app that already ran one has a scalar column where the platform wants JSON; the schema sync is additive and will not retype it. That drift pre-exists this PR — it is what the defect was — and closing it would be a migration story, not a generator one.

Authored in Claude Code session https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza (recorded here in prose because editing a PR body rewrites the footer below back to its bare form).

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

…urfaces
Red-first. The pin drives `os generate types`, `os generate migration
--format sql` and `os generate migration` (typescript) on one config and
asserts they give a flagged field ONE answer — an array TS type and a JSON
column — mirroring `driver-sql`'s `createColumn`, which decides `multiple`
before its per-type switch.
The three generator functions gain a named export so the pin can drive them
directly; `src/commands/generate.ts` is not a package entrypoint
(`@objectstack/cli` exports only `.` and `./console`), so nothing is added to
the published surface, and no emitted output changes in this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…rators
`os generate types` honoured the flag and neither migration generator did, so
one authored `Field.lookup({ multiple: true })` produced an array TS type and a
scalar `VARCHAR(36)` / `table.uuid` column from the same config in the same run.
The column authority is `driver-sql`, and its answer is the flag alone:
`createColumn` short-circuits on `field.multiple` above its own per-type switch,
`isJsonField` is `JSON_COLUMN_TYPES.has(type) || !!field.multiple`, and
`fieldHasColumn` opens with the same check. Both generators now answer it in the
same place — before the type is consulted — so the element type gets no vote.
Deliberately NOT the spec's `isMultiValueField`: that is the ADR-0104 D1 value
contract, gated on `MULTI_CAPABLE_TYPES`, and it would answer VARCHAR for a
`text` field the driver gives a JSON column — the same drift one notch narrower.
The per-type vocabularies are untouched; the disputed scalar answers stay
byte-for-byte and are pinned as a scope fence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

3 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))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 22 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 5bc2f2727aef5d697530c63d64dd5592df445776packageMentionDocs.

Which tree this was computed on

This run read content/docs from d8e8e896abd2eadc963ba94a11c58d937310ead6 — the merge of head ba7204962888e968b98bee7b3d47ead13015eb94 into base 5bc2f2727aef5d697530c63d64dd5592df445776, 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 d8e8e896abd2eadc963ba94a11c58d937310ead6 && git checkout d8e8e896abd2eadc963ba94a11c58d937310ead6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5bc2f2727aef5d697530c63d64dd5592df445776 ba7204962888e968b98bee7b3d47ead13015eb94 && git checkout -B drift-repro 5bc2f2727aef5d697530c63d64dd5592df445776 && git merge --no-ff ba7204962888e968b98bee7b3d47ead13015eb94
node scripts/docs-audit/affected-docs.mjs --json 5bc2f2727aef5d697530c63d64dd5592df445776

⚠️ 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 5bc2f2727aef5d697530c63d64dd5592df445776 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): multiple: true takes a JSON column in both migration generators - #14990

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-14829-migration-multiple-field-type
Sep 3, 2026
Merged

fix(cli): multiple: true takes a JSON column in both migration generators#14990
os-trump merged 2 commits into
mainfrom
claude/issue-14829-migration-multiple-field-type

Conversation

@os-trump

@os-trumpos-trump commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14829

os generate types honoured multiple: true and neither migration generator did, so one authored field produced two incompatible answers from one config in one run — an array TS type and a scalar column. This threads the flag through both migration generators, answering it where the platform answers it: before the type is consulted at all.

Re-measured on origin/main (5bc2f27) — every number with the command that produced it

AnchorCommandResult
multiple occurrences in generate.tsgit grep -n multiple origin/main -- packages/cli/src/commands/generate.ts4, at :562 :564 :607 :831, all TypeScript-side. Card's numbers hold; the triage comment's :502/:504/:547/:771 are the stale pair
fieldTypeToSql aritysed -n '1030,1033p'took (fieldType: string) only — the flag could not reach it
The driver's rule, statement 1sed -n '15342,15370p' packages/drivers/driver-sql/src/sql-driver.tscreateColumn opens with if (field.multiple) { this.jsonColumn(table, name); return; }, above its switch (type)
statement 2sed -n '15867p' same fileisJsonField is JSON_COLUMN_TYPES.has(type) || !!field.multiple
statement 3sed -n '541,544p' packages/drivers/driver-sql/src/schema-drift.tsfieldHasColumn opens if (field?.multiple) return true;, documented as "Mirrors SqlDriver.createColumn exactly … including multiple (a JSON column)"
The spec's value predicatesed -n '213,236p' packages/spec/src/data/field-value.zod.tsisMultiValueField = MULTI_OPTION_TYPES.has(type) OR (MULTI_CAPABLE_TYPES.has(type) AND multiple === true) — type-gated
Is the combination authorable off that roster?grep -n multiple packages/spec/src/data/field.zod.tsmultiple is a plain z.boolean().default(false) on every field; only radio + multiple is refused by name (:1818)

One correction worth recording: the card and the triage comment both spell the driver path driver-sql; it is packages/drivers/driver-sql. A git grep pathspec of packages/**/src matches zero there (git pathspec ** is not recursive without :(glob)), which is a silent zero rather than an error.

Which surface is authoritative — the driver, and not the spec predicate

The three surfaces answer two different questions and only one of them is about a column:

  • isMultiValueField is the ADR-0104 D1 value contract — "is the persisted value an array". Gated on MULTI_CAPABLE_TYPES.
  • createColumn / isJsonField / fieldHasColumn decide which column exists. Gated on the flag alone, three times over.

A migration generator's subject is the column, so the column authority governs. There is a fourth statement of the same rule, found while bounding a residue note, and it is the one that shows what the defect COST. schema-drift.ts's base-type check opens its emission site with const declaresJsonColumn = field.multiple === true; and reports expected: 'json' — flag alone again — gated on acceptsStringifiedJson(col.type), which is /char|text/i. Run that against what each generator emitted for a flagged lookup before this PR:

Emitted byColumnacceptsStringifiedJsonConsequence on Postgres
--format sql (pre-fix)VARCHAR(36)truean error-severity needs_confirm drift finding against a table the CLI itself generated — "arrays are being written as the STRINGIFIED literal"
typescript (pre-fix)table.uuidfalseno finding; the database refuses the write instead
either (this PR)JSONBfalsenone — the column is what the platform wants

So the two pre-fix answers were wrong in two different ways, and neither was silent for the platform — only for the author. The triage comment's steer — derive from isMultiValueField — would have answered VARCHAR(255) for a text field flagged multiple: true while the driver gives that same field a JSON column: the same drift one notch narrower. And the combination is reachable, because FieldSchema does not refuse it and the CLI generators sit downstream of validation (their own default: arm is documented as serving the unvalidated authoring door). So the implementation gates on the flag, with no list at all — the "fourth hand-copied vocabulary" the card warned about is avoided by not needing one. MULTI_CAPABLE_TYPES is still imported by the pin, as the roster it sweeps.

Red-first, and which arm fired

The pin was written and committed before the fix (a59b393f20), and run against the unfixed generators:

Tests 9 failed | 6 passed (15)
AssertionError: os generate migration --format sql gave a flagged lookup a scalar column …
expected 'VARCHAR(36)' to be 'JSONB' pin.test.ts:177

The 9 failures are the discriminating arms; all 6 controls passed — the roster control, the emission control, the "same type without the flag keeps its scalar column" control, the two driver-authority reads and the scope fence. The red is the negative arm at line 177, not the harness.

Re-proved as a full ablation on the finished tree (os-verify-lock.sh, one hold):

green baseline Tests 15 passed (15)
mutation on disk both branch markers 0/0 · blob a3fd631b… != HEAD 6cc1e93a… · 57593 -> 57420 bytes
ablated Tests 9 failed | 6 passed (15) — the same 9 arms, same 6 controls green
restore hash-object == HEAD blob 6cc1e93a… · git diff HEAD -> 0 files
re-run Tests 15 passed (15)

Mutation and restore are both proved by bytes, never by an editor's exit code, under trap … EXIT INT TERM on absolute paths. No build/dist leg: the pin imports its subject by relative specifier (./generate.js), which vitest resolves to the sibling source, so there is no exports hop that a stale dist could answer for.

Verification

CheckResult
pnpm lint (whole repo, eslint . --no-inline-config)exit 0 at ba72049628 — the final commit, no narrowing claimed
the pin + the neighbouring vocabulary pin + generate-file-name-registry-parity61 passed
check:cross-package-test-inputs, check:ci-filter-parity, check:turbo-task-graph, check:entry-guard, check:nul-bytes, check:test-source-alias, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:published-files, check:undeclared-dep-imports, check:doc-authoring, check:type-source-resolution, check:slot-lookup, check:parse-guard, check:pnpm-filter-targets, check:cli-command-ids, check:objectui-changeset, check:empty-changeset, check:changeset-no-major, check:adr-0087-registration, check:changeset-gate-self-tests, check:pm-half-states, check:self-test-wired, check:ratchet-remedy-authority, check:agent-test-spellingall exit 0
packages/clitsc --noEmit and check:test-typecheckNOT MEASURED — 194 of 234 errors are TS2307 for workspace modules, the unbuilt-closure signature, and the three check:test-typecheck files named are ones this diff does not touch

For that last row the question was narrowed rather than dropped, and the narrowing is measured: an A/B on the same unbuilt tree — census at HEAD vs census with generate.ts reverted to 5bc2f2727a and the pin file removed — is 234 error lines both ways with an empty diff, and zero errors name either of my files. So this diff adds no type error; what is unmeasured locally is the pre-existing state, which CI measures on a built workspace. Non-vacuity for the coverage claim itself: tsc --noEmit --listFiles lists 988 files and the new pin is one of them (grep -c = 1), so packages/cli's include: ["src"] really does typecheck it — the new test is deliberately under src/, not test/, for that reason.

File face

FileWhy
packages/cli/src/commands/generate.tsthe fix: fieldTypeToSql takes the flag and answers it first; generateMigrationTs emits the JSON column before its per-type switch; three functions gain a named export so the pin can drive them
packages/cli/src/commands/generate-multiple-json-column.pin.test.tsnew — the cross-surface pin
turbo.json + scripts/cross-package-test-inputs.mjsthe pin reads two driver-sql sources, so check:cross-package-test-inputs demanded both declarations. This is the pin's point, not paperwork: if the driver moves the rule and cli's suite does not re-run, the two sides drift again by the cache
.changeset/generate-migration-multiple-json-column.mdpatch for @objectstack/cli

Changeset fork, measured:packages/cli's files is ["dist","README.md","CHANGELOG.md"], so a src/** comment publishes nothing — but this card changes emitted output (os generate migration's DDL), which compiles into dist/commands/generate.js and ships. patch, not skip-changeset.

The three named exports do not widen the published surface: @objectstack/cli's exports map is . and ./console only, so src/commands/generate.ts is not an entrypoint. The file already carried a named export used by a test (GENERATOR_SCAFFOLD_TARGETS).

What I did NOT do

Residue

  • The generators spell a JSON column JSONB / table.jsonb(…) while the driver emits json (or text on SQLite). That predates this card and lives in the existing json vocabulary entry, so it is left alone; the new branch reuses each generator's own existing spelling rather than introducing a third. Bounded before deciding not to file it: the only check that could care is schema-drift.ts's base-type finding, and it is gated on acceptsStringifiedJson = /char|text/i, which jsonb does not match. So the divergence raises nothing and is cosmetic — a measurement, not an assumption, and the reason there is no drive-by card for it.
  • A previously generated migration file is not rewritten by a generator change. An app that already ran one has a scalar column where the platform wants JSON; the schema sync is additive and will not retype it. That drift pre-exists this PR — it is what the defect was — and closing it would be a migration story, not a generator one.

Authored in Claude Code session https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza (recorded here in prose because editing a PR body rewrites the footer below back to its bare form).

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

…urfaces
Red-first. The pin drives `os generate types`, `os generate migration
--format sql` and `os generate migration` (typescript) on one config and
asserts they give a flagged field ONE answer — an array TS type and a JSON
column — mirroring `driver-sql`'s `createColumn`, which decides `multiple`
before its per-type switch.
The three generator functions gain a named export so the pin can drive them
directly; `src/commands/generate.ts` is not a package entrypoint
(`@objectstack/cli` exports only `.` and `./console`), so nothing is added to
the published surface, and no emitted output changes in this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…rators
`os generate types` honoured the flag and neither migration generator did, so
one authored `Field.lookup({ multiple: true })` produced an array TS type and a
scalar `VARCHAR(36)` / `table.uuid` column from the same config in the same run.
The column authority is `driver-sql`, and its answer is the flag alone:
`createColumn` short-circuits on `field.multiple` above its own per-type switch,
`isJsonField` is `JSON_COLUMN_TYPES.has(type) || !!field.multiple`, and
`fieldHasColumn` opens with the same check. Both generators now answer it in the
same place — before the type is consulted — so the element type gets no vote.
Deliberately NOT the spec's `isMultiValueField`: that is the ADR-0104 D1 value
contract, gated on `MULTI_CAPABLE_TYPES`, and it would answer VARCHAR for a
`text` field the driver gives a JSON column — the same drift one notch narrower.
The per-type vocabularies are untouched; the disputed scalar answers stay
byte-for-byte and are pinned as a scope fence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

3 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))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 22 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 5bc2f2727aef5d697530c63d64dd5592df445776packageMentionDocs.

Which tree this was computed on

This run read content/docs from d8e8e896abd2eadc963ba94a11c58d937310ead6 — the merge of head ba7204962888e968b98bee7b3d47ead13015eb94 into base 5bc2f2727aef5d697530c63d64dd5592df445776, 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 d8e8e896abd2eadc963ba94a11c58d937310ead6 && git checkout d8e8e896abd2eadc963ba94a11c58d937310ead6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5bc2f2727aef5d697530c63d64dd5592df445776 ba7204962888e968b98bee7b3d47ead13015eb94 && git checkout -B drift-repro 5bc2f2727aef5d697530c63d64dd5592df445776 && git merge --no-ff ba7204962888e968b98bee7b3d47ead13015eb94
node scripts/docs-audit/affected-docs.mjs --json 5bc2f2727aef5d697530c63d64dd5592df445776

⚠️ 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 5bc2f2727aef5d697530c63d64dd5592df445776 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): multiple: true takes a JSON column in both migration generators - #14990

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-14829-migration-multiple-field-type
Sep 3, 2026
Merged

fix(cli): multiple: true takes a JSON column in both migration generators#14990
os-trump merged 2 commits into
mainfrom
claude/issue-14829-migration-multiple-field-type

Conversation

@os-trump

@os-trumpos-trump commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14829

os generate types honoured multiple: true and neither migration generator did, so one authored field produced two incompatible answers from one config in one run — an array TS type and a scalar column. This threads the flag through both migration generators, answering it where the platform answers it: before the type is consulted at all.

Re-measured on origin/main (5bc2f27) — every number with the command that produced it

AnchorCommandResult
multiple occurrences in generate.tsgit grep -n multiple origin/main -- packages/cli/src/commands/generate.ts4, at :562 :564 :607 :831, all TypeScript-side. Card's numbers hold; the triage comment's :502/:504/:547/:771 are the stale pair
fieldTypeToSql aritysed -n '1030,1033p'took (fieldType: string) only — the flag could not reach it
The driver's rule, statement 1sed -n '15342,15370p' packages/drivers/driver-sql/src/sql-driver.tscreateColumn opens with if (field.multiple) { this.jsonColumn(table, name); return; }, above its switch (type)
statement 2sed -n '15867p' same fileisJsonField is JSON_COLUMN_TYPES.has(type) || !!field.multiple
statement 3sed -n '541,544p' packages/drivers/driver-sql/src/schema-drift.tsfieldHasColumn opens if (field?.multiple) return true;, documented as "Mirrors SqlDriver.createColumn exactly … including multiple (a JSON column)"
The spec's value predicatesed -n '213,236p' packages/spec/src/data/field-value.zod.tsisMultiValueField = MULTI_OPTION_TYPES.has(type) OR (MULTI_CAPABLE_TYPES.has(type) AND multiple === true) — type-gated
Is the combination authorable off that roster?grep -n multiple packages/spec/src/data/field.zod.tsmultiple is a plain z.boolean().default(false) on every field; only radio + multiple is refused by name (:1818)

One correction worth recording: the card and the triage comment both spell the driver path driver-sql; it is packages/drivers/driver-sql. A git grep pathspec of packages/**/src matches zero there (git pathspec ** is not recursive without :(glob)), which is a silent zero rather than an error.

Which surface is authoritative — the driver, and not the spec predicate

The three surfaces answer two different questions and only one of them is about a column:

  • isMultiValueField is the ADR-0104 D1 value contract — "is the persisted value an array". Gated on MULTI_CAPABLE_TYPES.
  • createColumn / isJsonField / fieldHasColumn decide which column exists. Gated on the flag alone, three times over.

A migration generator's subject is the column, so the column authority governs. There is a fourth statement of the same rule, found while bounding a residue note, and it is the one that shows what the defect COST. schema-drift.ts's base-type check opens its emission site with const declaresJsonColumn = field.multiple === true; and reports expected: 'json' — flag alone again — gated on acceptsStringifiedJson(col.type), which is /char|text/i. Run that against what each generator emitted for a flagged lookup before this PR:

Emitted byColumnacceptsStringifiedJsonConsequence on Postgres
--format sql (pre-fix)VARCHAR(36)truean error-severity needs_confirm drift finding against a table the CLI itself generated — "arrays are being written as the STRINGIFIED literal"
typescript (pre-fix)table.uuidfalseno finding; the database refuses the write instead
either (this PR)JSONBfalsenone — the column is what the platform wants

So the two pre-fix answers were wrong in two different ways, and neither was silent for the platform — only for the author. The triage comment's steer — derive from isMultiValueField — would have answered VARCHAR(255) for a text field flagged multiple: true while the driver gives that same field a JSON column: the same drift one notch narrower. And the combination is reachable, because FieldSchema does not refuse it and the CLI generators sit downstream of validation (their own default: arm is documented as serving the unvalidated authoring door). So the implementation gates on the flag, with no list at all — the "fourth hand-copied vocabulary" the card warned about is avoided by not needing one. MULTI_CAPABLE_TYPES is still imported by the pin, as the roster it sweeps.

Red-first, and which arm fired

The pin was written and committed before the fix (a59b393f20), and run against the unfixed generators:

Tests 9 failed | 6 passed (15)
AssertionError: os generate migration --format sql gave a flagged lookup a scalar column …
expected 'VARCHAR(36)' to be 'JSONB' pin.test.ts:177

The 9 failures are the discriminating arms; all 6 controls passed — the roster control, the emission control, the "same type without the flag keeps its scalar column" control, the two driver-authority reads and the scope fence. The red is the negative arm at line 177, not the harness.

Re-proved as a full ablation on the finished tree (os-verify-lock.sh, one hold):

green baseline Tests 15 passed (15)
mutation on disk both branch markers 0/0 · blob a3fd631b… != HEAD 6cc1e93a… · 57593 -> 57420 bytes
ablated Tests 9 failed | 6 passed (15) — the same 9 arms, same 6 controls green
restore hash-object == HEAD blob 6cc1e93a… · git diff HEAD -> 0 files
re-run Tests 15 passed (15)

Mutation and restore are both proved by bytes, never by an editor's exit code, under trap … EXIT INT TERM on absolute paths. No build/dist leg: the pin imports its subject by relative specifier (./generate.js), which vitest resolves to the sibling source, so there is no exports hop that a stale dist could answer for.

Verification

CheckResult
pnpm lint (whole repo, eslint . --no-inline-config)exit 0 at ba72049628 — the final commit, no narrowing claimed
the pin + the neighbouring vocabulary pin + generate-file-name-registry-parity61 passed
check:cross-package-test-inputs, check:ci-filter-parity, check:turbo-task-graph, check:entry-guard, check:nul-bytes, check:test-source-alias, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:published-files, check:undeclared-dep-imports, check:doc-authoring, check:type-source-resolution, check:slot-lookup, check:parse-guard, check:pnpm-filter-targets, check:cli-command-ids, check:objectui-changeset, check:empty-changeset, check:changeset-no-major, check:adr-0087-registration, check:changeset-gate-self-tests, check:pm-half-states, check:self-test-wired, check:ratchet-remedy-authority, check:agent-test-spellingall exit 0
packages/clitsc --noEmit and check:test-typecheckNOT MEASURED — 194 of 234 errors are TS2307 for workspace modules, the unbuilt-closure signature, and the three check:test-typecheck files named are ones this diff does not touch

For that last row the question was narrowed rather than dropped, and the narrowing is measured: an A/B on the same unbuilt tree — census at HEAD vs census with generate.ts reverted to 5bc2f2727a and the pin file removed — is 234 error lines both ways with an empty diff, and zero errors name either of my files. So this diff adds no type error; what is unmeasured locally is the pre-existing state, which CI measures on a built workspace. Non-vacuity for the coverage claim itself: tsc --noEmit --listFiles lists 988 files and the new pin is one of them (grep -c = 1), so packages/cli's include: ["src"] really does typecheck it — the new test is deliberately under src/, not test/, for that reason.

File face

FileWhy
packages/cli/src/commands/generate.tsthe fix: fieldTypeToSql takes the flag and answers it first; generateMigrationTs emits the JSON column before its per-type switch; three functions gain a named export so the pin can drive them
packages/cli/src/commands/generate-multiple-json-column.pin.test.tsnew — the cross-surface pin
turbo.json + scripts/cross-package-test-inputs.mjsthe pin reads two driver-sql sources, so check:cross-package-test-inputs demanded both declarations. This is the pin's point, not paperwork: if the driver moves the rule and cli's suite does not re-run, the two sides drift again by the cache
.changeset/generate-migration-multiple-json-column.mdpatch for @objectstack/cli

Changeset fork, measured:packages/cli's files is ["dist","README.md","CHANGELOG.md"], so a src/** comment publishes nothing — but this card changes emitted output (os generate migration's DDL), which compiles into dist/commands/generate.js and ships. patch, not skip-changeset.

The three named exports do not widen the published surface: @objectstack/cli's exports map is . and ./console only, so src/commands/generate.ts is not an entrypoint. The file already carried a named export used by a test (GENERATOR_SCAFFOLD_TARGETS).

What I did NOT do

Residue

  • The generators spell a JSON column JSONB / table.jsonb(…) while the driver emits json (or text on SQLite). That predates this card and lives in the existing json vocabulary entry, so it is left alone; the new branch reuses each generator's own existing spelling rather than introducing a third. Bounded before deciding not to file it: the only check that could care is schema-drift.ts's base-type finding, and it is gated on acceptsStringifiedJson = /char|text/i, which jsonb does not match. So the divergence raises nothing and is cosmetic — a measurement, not an assumption, and the reason there is no drive-by card for it.
  • A previously generated migration file is not rewritten by a generator change. An app that already ran one has a scalar column where the platform wants JSON; the schema sync is additive and will not retype it. That drift pre-exists this PR — it is what the defect was — and closing it would be a migration story, not a generator one.

Authored in Claude Code session https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza (recorded here in prose because editing a PR body rewrites the footer below back to its bare form).

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

…urfaces
Red-first. The pin drives `os generate types`, `os generate migration
--format sql` and `os generate migration` (typescript) on one config and
asserts they give a flagged field ONE answer — an array TS type and a JSON
column — mirroring `driver-sql`'s `createColumn`, which decides `multiple`
before its per-type switch.
The three generator functions gain a named export so the pin can drive them
directly; `src/commands/generate.ts` is not a package entrypoint
(`@objectstack/cli` exports only `.` and `./console`), so nothing is added to
the published surface, and no emitted output changes in this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…rators
`os generate types` honoured the flag and neither migration generator did, so
one authored `Field.lookup({ multiple: true })` produced an array TS type and a
scalar `VARCHAR(36)` / `table.uuid` column from the same config in the same run.
The column authority is `driver-sql`, and its answer is the flag alone:
`createColumn` short-circuits on `field.multiple` above its own per-type switch,
`isJsonField` is `JSON_COLUMN_TYPES.has(type) || !!field.multiple`, and
`fieldHasColumn` opens with the same check. Both generators now answer it in the
same place — before the type is consulted — so the element type gets no vote.
Deliberately NOT the spec's `isMultiValueField`: that is the ADR-0104 D1 value
contract, gated on `MULTI_CAPABLE_TYPES`, and it would answer VARCHAR for a
`text` field the driver gives a JSON column — the same drift one notch narrower.
The per-type vocabularies are untouched; the disputed scalar answers stay
byte-for-byte and are pinned as a scope fence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

3 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))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 22 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 5bc2f2727aef5d697530c63d64dd5592df445776packageMentionDocs.

Which tree this was computed on

This run read content/docs from d8e8e896abd2eadc963ba94a11c58d937310ead6 — the merge of head ba7204962888e968b98bee7b3d47ead13015eb94 into base 5bc2f2727aef5d697530c63d64dd5592df445776, 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 d8e8e896abd2eadc963ba94a11c58d937310ead6 && git checkout d8e8e896abd2eadc963ba94a11c58d937310ead6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5bc2f2727aef5d697530c63d64dd5592df445776 ba7204962888e968b98bee7b3d47ead13015eb94 && git checkout -B drift-repro 5bc2f2727aef5d697530c63d64dd5592df445776 && git merge --no-ff ba7204962888e968b98bee7b3d47ead13015eb94
node scripts/docs-audit/affected-docs.mjs --json 5bc2f2727aef5d697530c63d64dd5592df445776

⚠️ 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 5bc2f2727aef5d697530c63d64dd5592df445776 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(cli): multiple: true takes a JSON column in both migration generators - #14990

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-14829-migration-multiple-field-type
Sep 3, 2026
Merged

fix(cli): multiple: true takes a JSON column in both migration generators#14990
os-trump merged 2 commits into
mainfrom
claude/issue-14829-migration-multiple-field-type

Conversation

@os-trump

@os-trumpos-trump commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14829

os generate types honoured multiple: true and neither migration generator did, so one authored field produced two incompatible answers from one config in one run — an array TS type and a scalar column. This threads the flag through both migration generators, answering it where the platform answers it: before the type is consulted at all.

Re-measured on origin/main (5bc2f27) — every number with the command that produced it

AnchorCommandResult
multiple occurrences in generate.tsgit grep -n multiple origin/main -- packages/cli/src/commands/generate.ts4, at :562 :564 :607 :831, all TypeScript-side. Card's numbers hold; the triage comment's :502/:504/:547/:771 are the stale pair
fieldTypeToSql aritysed -n '1030,1033p'took (fieldType: string) only — the flag could not reach it
The driver's rule, statement 1sed -n '15342,15370p' packages/drivers/driver-sql/src/sql-driver.tscreateColumn opens with if (field.multiple) { this.jsonColumn(table, name); return; }, above its switch (type)
statement 2sed -n '15867p' same fileisJsonField is JSON_COLUMN_TYPES.has(type) || !!field.multiple
statement 3sed -n '541,544p' packages/drivers/driver-sql/src/schema-drift.tsfieldHasColumn opens if (field?.multiple) return true;, documented as "Mirrors SqlDriver.createColumn exactly … including multiple (a JSON column)"
The spec's value predicatesed -n '213,236p' packages/spec/src/data/field-value.zod.tsisMultiValueField = MULTI_OPTION_TYPES.has(type) OR (MULTI_CAPABLE_TYPES.has(type) AND multiple === true) — type-gated
Is the combination authorable off that roster?grep -n multiple packages/spec/src/data/field.zod.tsmultiple is a plain z.boolean().default(false) on every field; only radio + multiple is refused by name (:1818)

One correction worth recording: the card and the triage comment both spell the driver path driver-sql; it is packages/drivers/driver-sql. A git grep pathspec of packages/**/src matches zero there (git pathspec ** is not recursive without :(glob)), which is a silent zero rather than an error.

Which surface is authoritative — the driver, and not the spec predicate

The three surfaces answer two different questions and only one of them is about a column:

  • isMultiValueField is the ADR-0104 D1 value contract — "is the persisted value an array". Gated on MULTI_CAPABLE_TYPES.
  • createColumn / isJsonField / fieldHasColumn decide which column exists. Gated on the flag alone, three times over.

A migration generator's subject is the column, so the column authority governs. There is a fourth statement of the same rule, found while bounding a residue note, and it is the one that shows what the defect COST. schema-drift.ts's base-type check opens its emission site with const declaresJsonColumn = field.multiple === true; and reports expected: 'json' — flag alone again — gated on acceptsStringifiedJson(col.type), which is /char|text/i. Run that against what each generator emitted for a flagged lookup before this PR:

Emitted byColumnacceptsStringifiedJsonConsequence on Postgres
--format sql (pre-fix)VARCHAR(36)truean error-severity needs_confirm drift finding against a table the CLI itself generated — "arrays are being written as the STRINGIFIED literal"
typescript (pre-fix)table.uuidfalseno finding; the database refuses the write instead
either (this PR)JSONBfalsenone — the column is what the platform wants

So the two pre-fix answers were wrong in two different ways, and neither was silent for the platform — only for the author. The triage comment's steer — derive from isMultiValueField — would have answered VARCHAR(255) for a text field flagged multiple: true while the driver gives that same field a JSON column: the same drift one notch narrower. And the combination is reachable, because FieldSchema does not refuse it and the CLI generators sit downstream of validation (their own default: arm is documented as serving the unvalidated authoring door). So the implementation gates on the flag, with no list at all — the "fourth hand-copied vocabulary" the card warned about is avoided by not needing one. MULTI_CAPABLE_TYPES is still imported by the pin, as the roster it sweeps.

Red-first, and which arm fired

The pin was written and committed before the fix (a59b393f20), and run against the unfixed generators:

Tests 9 failed | 6 passed (15)
AssertionError: os generate migration --format sql gave a flagged lookup a scalar column …
expected 'VARCHAR(36)' to be 'JSONB' pin.test.ts:177

The 9 failures are the discriminating arms; all 6 controls passed — the roster control, the emission control, the "same type without the flag keeps its scalar column" control, the two driver-authority reads and the scope fence. The red is the negative arm at line 177, not the harness.

Re-proved as a full ablation on the finished tree (os-verify-lock.sh, one hold):

green baseline Tests 15 passed (15)
mutation on disk both branch markers 0/0 · blob a3fd631b… != HEAD 6cc1e93a… · 57593 -> 57420 bytes
ablated Tests 9 failed | 6 passed (15) — the same 9 arms, same 6 controls green
restore hash-object == HEAD blob 6cc1e93a… · git diff HEAD -> 0 files
re-run Tests 15 passed (15)

Mutation and restore are both proved by bytes, never by an editor's exit code, under trap … EXIT INT TERM on absolute paths. No build/dist leg: the pin imports its subject by relative specifier (./generate.js), which vitest resolves to the sibling source, so there is no exports hop that a stale dist could answer for.

Verification

CheckResult
pnpm lint (whole repo, eslint . --no-inline-config)exit 0 at ba72049628 — the final commit, no narrowing claimed
the pin + the neighbouring vocabulary pin + generate-file-name-registry-parity61 passed
check:cross-package-test-inputs, check:ci-filter-parity, check:turbo-task-graph, check:entry-guard, check:nul-bytes, check:test-source-alias, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:published-files, check:undeclared-dep-imports, check:doc-authoring, check:type-source-resolution, check:slot-lookup, check:parse-guard, check:pnpm-filter-targets, check:cli-command-ids, check:objectui-changeset, check:empty-changeset, check:changeset-no-major, check:adr-0087-registration, check:changeset-gate-self-tests, check:pm-half-states, check:self-test-wired, check:ratchet-remedy-authority, check:agent-test-spellingall exit 0
packages/clitsc --noEmit and check:test-typecheckNOT MEASURED — 194 of 234 errors are TS2307 for workspace modules, the unbuilt-closure signature, and the three check:test-typecheck files named are ones this diff does not touch

For that last row the question was narrowed rather than dropped, and the narrowing is measured: an A/B on the same unbuilt tree — census at HEAD vs census with generate.ts reverted to 5bc2f2727a and the pin file removed — is 234 error lines both ways with an empty diff, and zero errors name either of my files. So this diff adds no type error; what is unmeasured locally is the pre-existing state, which CI measures on a built workspace. Non-vacuity for the coverage claim itself: tsc --noEmit --listFiles lists 988 files and the new pin is one of them (grep -c = 1), so packages/cli's include: ["src"] really does typecheck it — the new test is deliberately under src/, not test/, for that reason.

File face

FileWhy
packages/cli/src/commands/generate.tsthe fix: fieldTypeToSql takes the flag and answers it first; generateMigrationTs emits the JSON column before its per-type switch; three functions gain a named export so the pin can drive them
packages/cli/src/commands/generate-multiple-json-column.pin.test.tsnew — the cross-surface pin
turbo.json + scripts/cross-package-test-inputs.mjsthe pin reads two driver-sql sources, so check:cross-package-test-inputs demanded both declarations. This is the pin's point, not paperwork: if the driver moves the rule and cli's suite does not re-run, the two sides drift again by the cache
.changeset/generate-migration-multiple-json-column.mdpatch for @objectstack/cli

Changeset fork, measured:packages/cli's files is ["dist","README.md","CHANGELOG.md"], so a src/** comment publishes nothing — but this card changes emitted output (os generate migration's DDL), which compiles into dist/commands/generate.js and ships. patch, not skip-changeset.

The three named exports do not widen the published surface: @objectstack/cli's exports map is . and ./console only, so src/commands/generate.ts is not an entrypoint. The file already carried a named export used by a test (GENERATOR_SCAFFOLD_TARGETS).

What I did NOT do

Residue

  • The generators spell a JSON column JSONB / table.jsonb(…) while the driver emits json (or text on SQLite). That predates this card and lives in the existing json vocabulary entry, so it is left alone; the new branch reuses each generator's own existing spelling rather than introducing a third. Bounded before deciding not to file it: the only check that could care is schema-drift.ts's base-type finding, and it is gated on acceptsStringifiedJson = /char|text/i, which jsonb does not match. So the divergence raises nothing and is cosmetic — a measurement, not an assumption, and the reason there is no drive-by card for it.
  • A previously generated migration file is not rewritten by a generator change. An app that already ran one has a scalar column where the platform wants JSON; the schema sync is additive and will not retype it. That drift pre-exists this PR — it is what the defect was — and closing it would be a migration story, not a generator one.

Authored in Claude Code session https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza (recorded here in prose because editing a PR body rewrites the footer below back to its bare form).

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

…urfaces
Red-first. The pin drives `os generate types`, `os generate migration
--format sql` and `os generate migration` (typescript) on one config and
asserts they give a flagged field ONE answer — an array TS type and a JSON
column — mirroring `driver-sql`'s `createColumn`, which decides `multiple`
before its per-type switch.
The three generator functions gain a named export so the pin can drive them
directly; `src/commands/generate.ts` is not a package entrypoint
(`@objectstack/cli` exports only `.` and `./console`), so nothing is added to
the published surface, and no emitted output changes in this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…rators
`os generate types` honoured the flag and neither migration generator did, so
one authored `Field.lookup({ multiple: true })` produced an array TS type and a
scalar `VARCHAR(36)` / `table.uuid` column from the same config in the same run.
The column authority is `driver-sql`, and its answer is the flag alone:
`createColumn` short-circuits on `field.multiple` above its own per-type switch,
`isJsonField` is `JSON_COLUMN_TYPES.has(type) || !!field.multiple`, and
`fieldHasColumn` opens with the same check. Both generators now answer it in the
same place — before the type is consulted — so the element type gets no vote.
Deliberately NOT the spec's `isMultiValueField`: that is the ADR-0104 D1 value
contract, gated on `MULTI_CAPABLE_TYPES`, and it would answer VARCHAR for a
`text` field the driver gives a JSON column — the same drift one notch narrower.
The per-type vocabularies are untouched; the disputed scalar answers stay
byte-for-byte and are pinned as a scope fence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

3 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))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 22 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 5bc2f2727aef5d697530c63d64dd5592df445776packageMentionDocs.

Which tree this was computed on

This run read content/docs from d8e8e896abd2eadc963ba94a11c58d937310ead6 — the merge of head ba7204962888e968b98bee7b3d47ead13015eb94 into base 5bc2f2727aef5d697530c63d64dd5592df445776, 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 d8e8e896abd2eadc963ba94a11c58d937310ead6 && git checkout d8e8e896abd2eadc963ba94a11c58d937310ead6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5bc2f2727aef5d697530c63d64dd5592df445776 ba7204962888e968b98bee7b3d47ead13015eb94 && git checkout -B drift-repro 5bc2f2727aef5d697530c63d64dd5592df445776 && git merge --no-ff ba7204962888e968b98bee7b3d47ead13015eb94
node scripts/docs-audit/affected-docs.mjs --json 5bc2f2727aef5d697530c63d64dd5592df445776

⚠️ 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 5bc2f2727aef5d697530c63d64dd5592df445776 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-trump@claude