Uh oh!
There was an error while loading. Please reload this page.
fix(cli): give every FieldType member a generate.ts vocabulary entry, and make the next gap loud - #14834
Conversation
…t gap loud Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…guard is removed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
… vocabulary Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
📓 Docs Drift CheckThis PR changes 2 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 — 37 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 315a485d9fa97e66b48a10af2971e241a67c98ba && git checkout 315a485d9fa97e66b48a10af2971e241a67c98ba
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 5fe7ab8d815d1f506e2afff45ee38726256c1c3d && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 5fe7ab8d815d1f506e2afff45ee38726256c1c3d
node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#14657
Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
generate.tscarries three hand-authored field-type vocabularies. None was ever checked against theFieldTypeenum it describes, and a real member with no entry did not error — it took the default. The scaffold then emitted a plausible-looking wrong schema with nothing to tell the author.This PR closes both halves: every member now has an answer, and the next member that does not is loud.
The population, re-measured — not taken from the card
The card said "21+" and the triage comment could not reproduce it. Both are hand-carried lists, which is the artefact this card is about, so the set was derived programmatically at claim time: the
FieldTypeenum and all three vocabularies read in ONE script, using the same extractors the pin test uses.The card's numbers hold exactly: 49 members, 21 / 21 / 24 missing, 25 members affected in at least one vocabulary. After this PR the same script reports
49 / 49 / 49and every "missing" list empty.The three vocabularies, on the merge base (
5a5336b39)FIELD_TYPE_MAPos generate typesemitsgenerate.ts:471|| 'unknown'at:503FIELD_TYPE_SQL_MAPos generate migration --format sqlemitsgenerate.ts:880|| 'TEXT'at:912switch (fType)ingenerateMigrationTsos generate migrationemits (typescript, the DEFAULT format)generate.ts:1003, in the function at:962default:at:1042Three is the right number and they stay three. They answer three different questions and their answers legitimately differ (
textisstring/VARCHAR(255)/table.string;multiselectisstring[]/TEXT/table.jsonb). What did NOT need to stay three is the COMPLETENESS CHECK: one rule, one authority (FieldType), applied to all three. No merge, no behaviour change — see "what I did not do".The mechanism: totality derived from the enum, in two places
Type level, for the two lookup tables. Each is now closed by
} satisfies Record<FieldType, string>;. A field type added to the spec is a namedtscerror here —Property 'x' is missing in type … but required in type Record<FieldType, string>— and this is evaluated for real, not a phantom check:packages/clihas"typecheck": "tsc --noEmit", itstsconfig.jsonincludessrc, andtsc --listFilesputs 2 of 2 of this PR's edited files inside that program. It also enforces the #13871 direction for free: a key outside the enum is an excess-property error.Test level, for all three. The
switchcannot carry asatisfies— its scrutinee is a plainstringoff a possibly-unvalidated config — sogenerate-field-type-vocabulary.pin.test.tswalks the real enum and names any member a vocabulary does not cover. It already read all three out of the source, so this is the second half of an assertion it was built for.Deliberately NOT chosen: an annotated exclusion list of members allowed to stay on the fallback. A hand-kept list checked by another hand-kept list is the same defect one layer up. A member that genuinely wants the default gets an EXPLICIT entry spelling the default out, so the decision is written down instead of being an absence — the assertion message says exactly that.
The pin also pins the annotation itself: the extractor REQUIRES
} satisfies Record<FieldType, string>;as each table's terminator, so deleting the type-level half cannot quietly demote it to nothing.Red-first proof
Three mutations, each confirmed on disk before the run and each restore proven by blob-hash equality, under
trap '<restore>' EXIT INT TERMon absolute paths. The implementation was committed FIRST, soHEADis a real restore point.No rebuild leg applies to A and B: the pin reads
generate.tsas SOURCE TEXT (fs.readFileSyncofsrc/commands/generate.tsviaimport.meta.url), not through any packageexports; the onedistdependency isFieldTypefrom@objectstack/spec, which was built first and which no mutation touches. Mutation C is the type-level half and DID run against the fully built workspace closure (turbo run build --filter='./packages/*' --filter='./packages/*/*', exit 0).Mutation B's first draft failed only on a bare
toHaveLength(2)— "expected [ Array(1) ] to have a length of 2", which tells an author nothing. The extractor was rewritten to bound each table at its OWN closing line and name the cause; the message above is the second run. A guard you cannot show failing LEGIBLY is only half a guard.The values are measured, not invented
Each answer is the shape the platform actually implements: the spec's ADR-0104 D1 value classes (
packages/spec/src/data/field-value.zod.ts) decide the class, anddriver-sql's DDL emitter (packages/drivers/driver-sql/src/sql-driver.ts), which creates the real columns, decides the shape.STRING_VALUE_TYPESsecretstringVARCHAR(255)table.stringsys_secretref, not the credential (ADR-0100); the driver falls to its defaulttable.stringSTRING_VALUE_TYPES(text family)code,signature,qrcodestringTEXTtable.textsql-driver.tscases these three into its text family (#11794, #11875); any declaredmaxLengthis enforced at the write seam, not by the columnBOOLEAN_VALUE_TYPEStogglebooleanBOOLEANtable.booleanboolean/togglearmSINGLE_OPTION_TYPESradiostringVARCHAR(255)table.stringselectMULTI_OPTION_TYPEScheckboxes,tagsstring[]JSONBtable.jsonbdriver-sql'sJSON_COLUMN_TYPESNUMERIC_VALUE_TYPESslider,progress,summarynumberDECIMAL(18,2),DECIMAL(5,2),DECIMAL(18,2)table.decimalvalueSchemaForgives all threez.number(); the driver gives them float columns.progresstakespercent's narrower shape — same 0-100 quantityREFERENCE_VALUE_TYPESuser,treestringVARCHAR(36)table.stringtable.string, nottable.uuid: a platform id is a 26-char ULID and Postgres refuses one in auuidcolumn — the existinguserarm already says thisFILE_REFERENCE_TYPESavatar,video,audio(+image,filein the switch only)stringVARCHAR(2048)table.stringsys_fileid string (FileReferenceIdValueSchema), which is whyimage/filewere alreadystring/VARCHAR(2048). One class, one answerSTRUCTURED_JSON_TYPEScomposite,repeater,record,location,address(+vectorin the switch only)JSONBtable.jsonbCOMPUTED_VALUE_TYPESautonumber(switch only)string)SERIAL)table.stringtable.string. ASERIALcould not holdINV-0001Two details worth calling out, both from the triage comment's split conditions:
locationis JSON, notPOINT, so the per-dialect split condition never triggers. The spec's own value contract is{lat, lng, altitude?, accuracy?}andlocationis a member ofSTRUCTURED_JSON_TYPES, sodriver-sqlgives it a JSON column like the rest of that class.POINTwas the inventedgeo_pointghost [finding] packages/cli generate.ts 的迁移 codegen switch 携带自己的幽灵字段类型词表——'slug'/'ip_address'/'encrypted'/'integer' 是 case 标签但均非 FieldType 成员(#13716 同类,隔一个包) #13871 removed, and it is not portable to SQLite. No driver contract moves.secret's TS type is a measurement, not a policy statement, so that split condition does not trigger either. Every entry inFIELD_TYPE_MAPdescribes the STORED form (lookupisstring, not the related record), and a secret's stored form is the opaque ref the engine persists on the row.stringrestates ADR-0100; it does not decide anything.The embedded-structured family is answered ONCE, as triage asked — one rule for
composite/repeater/record/address, withlocationandjsonin the same class. Their TypeScript types point AT the spec's own exported types rather than transcribing them (location: 'Data.LocationValue',address: 'Data.AddressValue'— the generated file already imports* as Data), so the emitted interface cannot drift from the value contract.composite/repeater/recordtakeRecord<string, unknown>shapes matchingvalueSchemaFor's branches, because their sub-field shape is per-field and not nameable from the enum.Members I refused to map: none. Every one of the 25 had a measured answer in the spec or the driver, so "refuse loudly" was never the honest outcome for any of them. What I DID refuse is below.
What I did NOT do, and why
autonumber: 'SERIAL',formulagiven a column the driver never creates (case 'formula': return;),multiselect: 'TEXT'next tojson: 'JSONB',vector: 'VECTOR', andlookup/master_detailattable.uuid. Correcting any of them changes DDL that already-generated apps ran; this card's change is additive. Filed as [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 rather than mirrored — and the new entries deliberately do NOT copy them, which is whycheckboxesisJSONBbesidemultiselect: 'TEXT'andautonumbergetstable.stringbesideSERIAL. Both output formats already disagreed for all four of those members before this PR; this makes the default format match the platform.packages/specuntouched.FieldTypeis read, never edited. Nothing here needs a spec change.multiple: trueleft alone. Both migration generators ignore it whileos generate typeshonours it, so a multi-valued field gets an array TS type and a scalar column. Orthogonal to this card (it affects members that already had entries, through a flag no migration generator reads). Filed as [finding] packages/cli generate.ts: both migration generators ignoremultiple: true, so a multi-valued field gets a scalar column whileos generate typesgives it an array type #14829.packages/cli/test/run-dev-unbuilt-workspace.e2e.test.tsis the known repo-wide flake; its repair sits in the merge queue as PR test(cli): make the unread-reader ceiling a load-independent constant at RUN_TIMEOUT_MS #14715. Not touched here — and it passed in the full suite run below, so it is not a red on this branch.File face
packages/cli/src/commands/generate.ts·packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts·.changeset/generate-field-type-vocabulary-totality.md·packages/runtime/src/dispatcher-error-vocabulary.ts.None of the four
packages/cliPRs in flight from this seat is touched: #14809 (src/commands/explain.ts,test/commands.test.ts), #14812 (src/commands/serve.ts), #14705 (src/commands/create.ts), #14710 (tsconfig.json,package.json,tsconfig.test.json,test-typecheck-debt.json).The one file outside
packages/clineeds its own justification. Addingcode: 'TEXT'toFIELD_TYPE_SQL_MAPturnedcheck:dispatcher-error-vocabularyred: thecodeFIELD TYPE's key is spelledcodeand its value happens to be upper-case, so the scan reads it as an ADR-0112 error-code stamp.[unclassified-site] packages/cli/src/commands/generate.ts stamps unregistered code 'TEXT' (objlit). The gate's own designed repair is a classification row, and the fitting verdict already exists —foreign-vocabulary, "belongs to a different vocabulary that merely spells itselfcode". One row added,door: 'none'; the gate goes from1 finding(s)toOK — 61 unregistered code-stamping site(s), all classified. The alternative (quoting the key to dodge the scanner) would have hidden the site from a gate rather than answering it.packages/runtime's owntypecheckanderror-envelope.conformance.test.ts— the one test that importsUNREGISTERED_CODE_SITES— are both green below.Changeset:
patch, measuredpackages/cli'sfileswhitelist is["dist","README.md","CHANGELOG.md"], so asrc/**COMMENT would publish nothing — but this changes whatos generate typesandos generate migrationEMIT, and that behaviour reachesdist. A released package whose behaviour changes takes a patch.dispatch-gates.mjsprints the fork as "write one unless this card publishes nothing from any released package"; this card publishes.Local verification — all green at
8c3049a9The union below ran at
8c3049a9(git rev-parse --short HEADat the time of the run; nothing has been committed since, and the tree was clean). The gate family was derived from the real change set withnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack— 29 families by path and kind, 37 runnable commands once the changeset existed — not from a remembered list. Every exit code was captured BEFORE any pipe, and each verdict is read from the gate's own printed line.Build and package verdicts
All 37 derived gate commands: green. The 21
pnpm check:*families and the 16 directnode scripts/…families, including the five that need a built workspace —check:type-check-coverage,check:type-check-debt(OK — 21 ledger entr(ies) re-measured in 213.7s, 549 raw tsc error(s) total, none above its recorded number),check:dual-build-cjs-loads(this run 102/66/610/1 · floors 90/58/520/1),check:i18n(OK (9 package(s) — all bundles in sync, no undeclared authoring keys)) andcheck:i18n-coverage.Two of them had to be re-run to get a real reading, and both are worth recording:
check:dispatcher-error-vocabularywas genuinely RED on the first pass and is green after the classification row — see the file-face section.check:test-completenessandscripts/pm/check-half-states.mjsexit 3 when run without their inputs — their own documented "nothing was measured" code, not a finding. Both are green in the run above only in the sense that CI supplies what they need; locally they were re-read as NOT MEASURED rather than counted as passes.Getting here took three attempts at the shared verify lock — two full 540s budgets returned
VERDICT queue-timeout (exit 99) · never acquiredbehind four other seats (issue-14758,issue-14642,issue-14573, plus@objectstack/runtime^...and@objectstack/rest^...builds); the third acquired after 511s and held for 1950s.🤖 Generated with Claude Code
https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza