fix(cli): multiple: true takes a JSON column in both migration generators - #14990
Conversation
…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
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
Fixes #14829
os generate typeshonouredmultiple: trueand 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 itmultipleoccurrences ingenerate.tsgit grep -n multiple origin/main -- packages/cli/src/commands/generate.ts:562 :564 :607 :831, all TypeScript-side. Card's numbers hold; the triage comment's:502/:504/:547/:771are the stale pairfieldTypeToSqlaritysed -n '1030,1033p'(fieldType: string)only — the flag could not reach itsed -n '15342,15370p' packages/drivers/driver-sql/src/sql-driver.tscreateColumnopens withif (field.multiple) { this.jsonColumn(table, name); return; }, above itsswitch (type)sed -n '15867p'same fileisJsonFieldisJSON_COLUMN_TYPES.has(type) || !!field.multiplesed -n '541,544p' packages/drivers/driver-sql/src/schema-drift.tsfieldHasColumnopensif (field?.multiple) return true;, documented as "MirrorsSqlDriver.createColumnexactly … includingmultiple(a JSON column)"sed -n '213,236p' packages/spec/src/data/field-value.zod.tsisMultiValueField=MULTI_OPTION_TYPES.has(type)OR (MULTI_CAPABLE_TYPES.has(type)ANDmultiple === true) — type-gatedgrep -n multiple packages/spec/src/data/field.zod.tsmultipleis a plainz.boolean().default(false)on every field; onlyradio+multipleis refused by name (:1818)One correction worth recording: the card and the triage comment both spell the driver path
driver-sql; it ispackages/drivers/driver-sql. Agit greppathspec ofpackages/**/srcmatches 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:
isMultiValueFieldis the ADR-0104 D1 value contract — "is the persisted value an array". Gated onMULTI_CAPABLE_TYPES.createColumn/isJsonField/fieldHasColumndecide 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 withconst declaresJsonColumn = field.multiple === true;and reportsexpected: 'json'— flag alone again — gated onacceptsStringifiedJson(col.type), which is/char|text/i. Run that against what each generator emitted for a flaggedlookupbefore this PR:acceptsStringifiedJson--format sql(pre-fix)VARCHAR(36)needs_confirmdrift finding against a table the CLI itself generated — "arrays are being written as the STRINGIFIED literal"table.uuidJSONBSo 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 answeredVARCHAR(255)for atextfield flaggedmultiple: truewhile the driver gives that same field a JSON column: the same drift one notch narrower. And the combination is reachable, becauseFieldSchemadoes not refuse it and the CLI generators sit downstream of validation (their owndefault: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_TYPESis 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: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):Mutation and restore are both proved by bytes, never by an editor's exit code, under
trap … EXIT INT TERMon absolute paths. No build/distleg: the pin imports its subject by relative specifier (./generate.js), which vitest resolves to the sibling source, so there is noexportshop that a staledistcould answer for.Verification
pnpm lint(whole repo,eslint . --no-inline-config)ba72049628— the final commit, no narrowing claimedgenerate-file-name-registry-paritycheck: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-spellingpackages/clitsc --noEmitandcheck:test-typecheckTS2307for workspace modules, the unbuilt-closure signature, and the threecheck:test-typecheckfiles named are ones this diff does not touchFor 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.tsreverted to5bc2f2727aand 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 --listFileslists 988 files and the new pin is one of them (grep -c= 1), sopackages/cli'sinclude: ["src"]really does typecheck it — the new test is deliberately undersrc/, nottest/, for that reason.File face
packages/cli/src/commands/generate.tsfieldTypeToSqltakes the flag and answers it first;generateMigrationTsemits the JSON column before its per-type switch; three functions gain a named export so the pin can drive thempackages/cli/src/commands/generate-multiple-json-column.pin.test.tsturbo.json+scripts/cross-package-test-inputs.mjsdriver-sqlsources, socheck:cross-package-test-inputsdemanded 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.mdpatchfor@objectstack/cliChangeset fork, measured:
packages/cli'sfilesis["dist","README.md","CHANGELOG.md"], so asrc/**comment publishes nothing — but this card changes emitted output (os generate migration's DDL), which compiles intodist/commands/generate.jsand ships.patch, notskip-changeset.The three named exports do not widen the published surface:
@objectstack/cli'sexportsmap is.and./consoleonly, sosrc/commands/generate.tsis not an entrypoint. The file already carried a named export used by a test (GENERATOR_SCAFFOLD_TARGETS).What I did NOT do
autonumber,formula,multiselect,vectorandlookup/master_detailkeep the answers they had. A test block asserts exactly that, labelled as a scope fence rather than an endorsement — whoever takes [finding] packages/cli generate.ts: five field-type vocabulary entries that DO exist disagree with what the platform stores (autonumber SERIAL, formula's column, multiselect TEXT, vector VECTOR, lookup uuid) #14828 must update it deliberately.Worth stating precisely, because the fix does change the answer for
formula/autonumberwhen flagged (TEXT/SERIAL->JSONB). That is not the fenced decision: the driver gives a flagged field of any type a JSON column (fieldHasColumnreturnstruefor a flaggedformula, which is otherwise virtual), so those shapes move toward the platform, while the scalar entries — the ones [finding] packages/cli generate.ts: five field-type vocabulary entries that DO exist disagree with what the platform stores (autonumber SERIAL, formula's column, multiselect TEXT, vector VECTOR, lookup uuid) #14828 owns — do not move at all.packages/specread, never edited.packages/rest/src/error-response.tsuntouched.Residue
JSONB/table.jsonb(…)while the driver emitsjson(ortexton SQLite). That predates this card and lives in the existingjsonvocabulary 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 isschema-drift.ts's base-type finding, and it is gated onacceptsStringifiedJson=/char|text/i, whichjsonbdoes 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.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