fix(cli): os migrate meta converts objects built with ObjectSchema.create and the other strict authoring factories - #20801
Conversation
…nly define* The authored-source shim wrapped only `define*` exports, so an `ObjectSchema.create(...)` carrying a retired key threw at the call, inside the config load, before the migration chain could convert it. The shim now also wraps every strict authoring factory from one written list (`STRICT_AUTHORING_FACTORIES`: ObjectSchema/App/Dashboard/Report/Action `.create`), in place on the owner through a Proxy, and renders a swallowed raw ZodError through the project's `formatZodError` instead of its JSON array. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
The card's repro migrates, the plain-literal control is unchanged, an unrelated strict error surfaces as a refusal and never as a raw ZodError array, every listed factory is tolerated through the shim only, and the list is held to the spec surface in both directions. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
…dently of the list Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
…ict factories Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
…alues, 31 names, 5 strict, 13 identity) Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check9 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 25 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 94c4f829f64960e3869ab2c67bed93f038c9905f && git checkout 94c4f829f64960e3869ab2c67bed93f038c9905f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 96e724475c476d018c2b6d13dcd117ade14d0aa0 3d877c6d452377a6e0c69092285a0adf8fcfaf04 && git checkout -B drift-repro 96e724475c476d018c2b6d13dcd117ade14d0aa0 && git merge --no-ff 3d877c6d452377a6e0c69092285a0adf8fcfaf04
node scripts/docs-audit/affected-docs.mjs --json 96e724475c476d018c2b6d13dcd117ade14d0aa0 |
Contract reviewServed-tier: Inputs read:
Seat spot checks on adoption:
① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #20696
Clause-②: no
What was wrong
os migrate metaloads the config through the authored-source shim inpackages/cli/src/utils/config.ts. The shim swaps each@objectstack/specentrypoint for a module that wraps its helpers as try-real-then-authored, so a retired key reaches the conversion chain instead of stopping the load. It wrapped only the exports matchingDEFINE_HELPER_RE(thedefine*helpers).ObjectSchema.create(...)parses when it is called, insidedefineStack's argument, so it threw before the wrappeddefineStackever ran.Reproduced on
mainat91e8fa194before any edit:defineStack({ objects: [ObjectSchema.create({ ..., tenancy: { enabled: true, organizationField: 'organization_id' } })] }), withObjectSchemaimported from@objectstack/spec/data.os migrate meta --from 17 --jsonexits 1 with{"error":"[\n {\n \"code\": \"unrecognized_keys\", ...(the rawZodErrorarray). The tombstone inside that array saysRun os migrate meta --from 17.appliedholdsobject-tenancy-organization-field-removedatobjects[0].tenancy.organizationField, andschemaValidistrue.What changed
Everything below is in
packages/cli/src/utils/config.ts, as the triage direction asks.packages/specis untouched:ObjectSchema.createstays strict everywhere, and the shim is installed only byos migrate meta.One written list.
STRICT_AUTHORING_FACTORIESnames the five factories below as owner export → member, plus the entrypoint each was measured on.ObjectSchema.create@objectstack/spec/dataApp.create@objectstack/spec/uiDashboard.create@objectstack/spec/uiReport.create@objectstack/spec/uiAction.create@objectstack/spec/uiThe shim wraps an entry on every entrypoint whose owner export carries that member. Unlisted factories are not wrapped.
DEFINE_HELPER_REis unchanged, and no second pattern is added.How a factory is wrapped. The wrap happens in place, on a
Proxyover the real owner. Only the listed member gets the try-real-then-authored wrap; every other member (parse,safeParse,shape, ...) passes through untouched. It has to be aProxyrather than a copy becauseObjectSchemais a lazy-schemaProxywhoseownKeystrap throws.Reflect.ownKeys(ObjectSchema)answersTypeError: 'ownKeys' on proxy: trap result did not include 'prototype', soObject.keys(ObjectSchema)cannot seecreate. For the same reason, the shim reads the owner by property and never enumerates it.The refused-artifact line on stderr is rendered. While the config loads, the shim prints one stderr line per artifact the current schema refused. A raw
ZodErroron that line (itsmessageis the issues as a JSON array) is now printed through the project's ownformatZodError, as aObjectSchema.create validation failed (1 issue):block with one✗ path: messageline per issue. Errors that already carry prose, such asdefineStack'sStackSchemaInvalidErrororObjectSchema.create's own unknown-key andsystem-datarefusals, keep their message. The template is shared, sodefine*helpers that throw a rawZodError, such asdefineAgent, get the same rendering. This is the same defect class inside the same generated template: the card's third pin forbids a rawZodErrorarray, and without this change the fixed repro would still print one, only on stderr instead of in the exit payload.Where the list lives, and how it was measured
The list lives on the cli side. It does not have to be exported by
packages/spec: the shim is its only reader, and the pin holds it to the spec surface frompackages/cli. So there is noneeds_decision.Measured at
5f3c0f648over all 19 JS entrypoints in@objectstack/spec'sexports(the two.jsonentries excluded), reading each export'screateby property:createmember, under 31 export names (each identity factory is exported a second time as its*Schema).ObjectSchema.createis found at@objectstack/spec/data.(config) => config):ApiDocumentationConfig,ApiEndpoint,ApiTestCollection,BatchTask,MiddlewareConfig,OpenApiSpec,QueueConfig,RestApiConfig,RestApiPluginConfig,RestApiRouteRegistration,RestServerConfig,Task,WorkerConfig. They refuse nothing, so there is nothing to tolerate.OS_EAGER_SCHEMAS=1) sweep of every function member of every exported value checked the other members:Field.*(32 builders),SCIM.*,RLS.*,OData.*,StorageNameMapping.resolveTableName. None of them validates.define*top-level functions whose own source calls.parse(areconnectorFetchOptions,describeHighPrivilegeBitsandutcInstantMs. They are runtime helpers, not authoring factories.git grepon this tree counts 35ObjectSchema.create(sites and 3App.create(sites underexamples/.Dashboard.create,Report.createandAction.createhave 0 sites inexamples/, but they are wrapped anyway because the triage asked for every strict factory from one list.Pins
packages/cli/test/migrate-meta-strict-factories.test.tsisunittier: in-process overMigrateMeta.runandloadConfig, against a temp project that links the real@objectstack/spec. It spawns no process and boots no kernel. It has five cases:appliedholds the tenancy conversion atobjects[0].tenancy.organizationField, andschemaValidistrue. The stderr line namesObjectSchema.create()and carries no rawZodErrorarray.appliedlist as the literal.schemaValidisfalse. The human report readsdoes not yet pass schema validation — 1 refusal left after the chainand lists✗ objects.0.fields.stage.type: .... No stream (stdout or stderr,--jsonor human) contains a rawZodErrorarray.ObjectSchema.safeParsestill works through theProxy. Loading the same source withoutauthoredSourcestill rejects.homeand throws on a refused input. Everycreatethat spec exports and the list does not name returns its argument by identity. A new strict factory in spec therefore turns this case red and names itself.Reverse verification
I committed the fix first, then ran each mutation with
scripts/ablation-replace.mjs. It checks that the anchor hits exactly once, that the file's hash on disk changes, and that it is restored afterwards (hash equal toHEAD, emptygit diff HEAD). The subject issrcthrough relative imports, so nodistrebuild was involved. Predicted direction for both: red.ObjectSchemaentry deleted from the list (at5f3c0f648). Cases 1, 2, 3 and 5 are red; case 4 is green.expected 1 to be undefined, meaning exit 1 at load.no `applied` in the --json payload: {"error":"[\n {\n \"code\": \"unrecognized_keys\", ..., which is the original defect.add a strict `create` to STRICT_AUTHORING_FACTORIES ..., naming the unlisted strict factory.error.name === 'ZodError'changed to'AblatedZodError', at888e0c78d). Cases 1 and 3 are red onexpected '[authored-source] ObjectSchema.create…' not to match /"code":\s*"/. Cases 2, 4 and 5 are green, as predicted.Verification
pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2) at5f3c0f648:Test Files 237 passed (237),Tests 3370 passed (3370). The integration tier is declared to CI: this diff touches no integration-tier file and no spawn or boot entry point.pnpm --filter @objectstack/cli typecheck, which istsc --noEmitpluscheck:test-typecheck) at5f3c0f648: exit 0. The test layer passes (OK ... 3 file(s) / 28 error(s) / 6 pinned signature(s) held, all pre-existing debt), andtsconfig.test.json --listFilesOnlyincludes the new pin file.pnpm lint(full repo,eslint . --no-inline-config) at5f3c0f648: exit 0 in 183s. The last commit,3d877c6d4, is a docblock-only change (it corrects the measured counts). At that head, a targetedeslint --no-inline-config --format jsonover the two touched TS files reports both linted with 0 errors and 0 warnings. The changeset.mdis outside eslint's population.dispatch-gates --commandsderives 63 families from this diff: the 56 in the dispatch list, plus 7 that the changeset adds (check-adr-0087-registration×2,check-empty-changeset×2,release-rehearsal-clone --self-test,check:objectui-changeset,check:pm-changeset-deadline-census). All 63 exited 0 at5f3c0f648.--ranreconciliation reports:63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN(a derived zero, since every entry recorded an exit code). They were re-run at the final head3d877c6d4; see the report comment on the card.origin/main(085ca6bc1). After the merge I rebuilt@objectstack/specand the six closure packages that the merge changed, plus@objectstack/cli.Acceptance notes
schemaValid. Some checks run only when a factory is called and are not part of the stack schema:ObjectSchema.create's refusal of amanagedBy: 'system-data'object that grants no create, edit or delete, itsreferenceViasibling check, and its refusal of an explicitrequired: falseon acontrolled_by_parentmaster-detail reference. A source that trips one of them now migrates with that refusal on the stderr line andschemaValid: true.os validatestill refuses it (exit 1).system-datawith every write closed, plus the retired tenancy key, givesapplied: [object-tenancy-organization-field-removed],schemaValid: true, the refusal prose on stderr, andos validateexit 1.defineStackhas behaved this way under the existing shim. Its call-time namespace-prefix check throwsStackNamespacePrefixInvalidErrorwhileObjectStackDefinitionSchema.safeParseof the same stack succeeds.schemaValidis documented as whether the migrated stack parses under the installed schema, and that is what it reports. The changeset states this boundary.Reflect.ownKeyson a lazy-schema proxy (ObjectSchema, for one) throws, because the trap omits the target function's non-configurableprototype. I found no public door that reaches it, so this is an observation, not a filed finding. Carrier: none.os migrate meta --from 17buries a project's real findings under 240 generic protocol-18 notices, and marks default-flip conversions as "Applied" when the right action is usually no edit #20620 (the verdict-first report) has a changeset already onmain, and this diff does not touch that report code. For [finding] the conversions defineStack applies still miss two door paths after PR #20579: os lint --json never reads the record, and a defineStack that converts then refuses drops them from --json #20583 (os lint --jsonreads the conversion record), nothing here changes the record that tool reads. The only premise this moves for either card is thatos migrate metanow opens stacks whose objects are built with a strict factory.Generated by Claude Code