fix(types): refuse a bare exportOptions array on the object-grid zod mirror - #8485
Conversation
…mirror
`ObjectGridSchema` declared no `exportOptions` member, and `BaseSchema` is
`.passthrough()`, so a bare format array validated green and came back verbatim
— then lost silently to the `['csv', 'json']` default, because `ObjectGrid`
reads `schema.exportOptions?.formats` and nothing else and the warning path
reads `.formats` too. The retired `'pdf'` value and a sixth key rode through the
same hole.
Declare the member as the spec's OBJECT arm, peeled out of
`ListViewSchema.shape.exportOptions` and bound by reference, so the bare array
is refused by name with a message pointing at the shape the renderer reads,
while `'pdf'` and a sixth key keep the spec's own messages. Not the spec union
itself: its first arm lifts a bare array to `{ formats }`, which is the opposite
of the refusal ruled here — the sibling `list-view` mirror keeps that binding
and keeps lifting.
The key leaves `UnmirroredDeclared` (87 -> 86 keys) and the spec reference
re-derives the pair into the split's SPEC-DERIVED half (2/3 -> 3/17
spec-derived, 12/84 -> 11/69 local); every pinned figure moved with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE
❌ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. Which half objected:
📦 Bundle Size Report
Size Limits
|
⛔ CI red —
|
…fusal Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE
…ork budget
The console's `framework` chunk is `packages/(core|react|types)`, and the
`exportOptions` refusal ships two string constants into it. Measured against a
clean `origin/main` build: `framework` was 70,651 gzip bytes with a 71,000 byte
ceiling (349 bytes of headroom), and this branch took it to 71,042 — over by 42.
Two reductions, each pure redundancy removal with no fact lost:
* the module-load `throw` message, 272 -> 144 raw bytes, worth 35 gzip bytes.
It still names the moved spec symbol, the member that depends on it and the
card; the rationale it used to restate is in the docblock above it, which
the production minifier strips while the string ships.
* two redundant phrases in the shared guidance string, 378 -> 363 raw bytes,
worth 8 gzip bytes ("grid toolbar export menu" -> "grid toolbar menu", after
a sentence that already says "Export configuration"; "would be dropped in
silence" -> "is silently dropped").
`framework` now measures 70,999 against the 71,000 ceiling. ⛔ No ceiling was
raised and no baseline moved. Every accept-set reading and every assertion of
the pin re-verified on this head: bare array still one invalid_type issue at
['exportOptions'] whose message still matches /formats/, /list-view/ and
/object-grid/ and is still identical to the member's description; the five-key
object still parses verbatim; 'pdf' and a sixth key still refused.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Fixes #7762
Lane
domain:spec.Clause-②: yes— this narrows a published accept set, soneeds:contract-reviewis hung on this PR and it stays draft: not enqueued, no auto-merge, no self-approval. Parking green at the gate is the sanctioned resting state.packages/plugin-grid/**is untouched. The routing ruling holds the renderer's read to be correct, and nothing in this change needed it to move.What was wrong, measured on
origin/mainc90395bObjectGridSchemainpackages/types/src/zod/objectql.zod.tsdeclared noexportOptionsmember, andBaseSchemais.passthrough(). Taken on a built tree:ObjectGrid.tsxreadsschema.exportOptions?.formatsand only that, and the effect that warns about dropped formats reads.formatstoo and returns early when it is absent, so the authored array then lost silently to the['csv','json']default with the export button still on screen. The card's premise re-measured green in full, against@objectstack/spec@17.3.0.What this does
Declares the member as the spec's object arm, peeled out of
SpecListViewSchema.shape.exportOptionsand bound by reference, re-wrapped so itsinvalid_typemessage names the shape the renderer reads.⛔ Not
SpecListViewSchema.shape.exportOptionsitself. That reference is a two-arm union whose first arm LIFTS a bare array to{ formats }at parse; binding it would make this mirror accept-and-lift, the opposite of the ruled refusal. The siblinglist-viewmirror keeps that binding and keeps lifting — pinned as a live control in the new test.Peeled rather than restated: a local copy of the five keys is a third copy of one contract, and the copy is what drifts. Every member schema is the spec's own object, asserted by
toBeidentity.One correction worth recording, because it is invisible in the diff: the shape must be typed by the same two steps at the type level (
unwrap, then the arm carrying ashape). Annotating itz.ZodRawShapecompiles and builds, but collapses every member of the authoring face tounknown— measured, the drift ledger reportedexportOptionsNARROWER than declared. Recorded in the source comment so it is not reintroduced.The re-derived ledger edit set, with its firing control
Every figure below was re-derived on current
origin/main, symbol-located. Nothing was carried in from the card or any comment, and the previous round's addresses are all stale — PR #8354 rewrote that file after them.origin/main)UnmirroredDeclaredentryobjectql.zod.ts#ObjectGridSchemaunion, :1624'exportOptions'among themSPEC_DERIVED_PAIRS, afterobjectql.zod.ts#ObjectGanttSchema14 entries/87 keysSPEC-DERIVED (2 entries, 3 keys)LOCAL (12 entries, 84 keys)14 entries / 87 keys — 2 / 3 spec-derived, 12 / 84 local14 / 86 — 3 / 17, 11 / 69UnmirroredDeclareddocstring, :130087 keysis what this ledger records todayRuntimeOnlyDeclareddocstring, :1695UnmirroredDeclaredrecords87 keystodayTwo figures the predecessor's enumeration did not have and one that changed shape: the pair also moves between the split's halves. Binding a
Spec…symbol inside this mirror's initializer makesspecReferencingExportsre-derive the pair, soSPEC_DERIVED_PAIRSmust gain it — the #7779 membership mechanism a second time. Measured, not reasoned: with the member added and the list untouched,SPEC_DERIVED_PAIRS matches what the mirror sources actually dofailed withexpected [ …(12) ] to deeply equal [ …(11) ]. The entry docblock records that the half is MEMBERSHIP and that the fourteen remaining keys are still ordinary local omissions, the reading #7279 kept onObjectViewSchemafor the same reason.The two historical figures pinned at named revisions (
beccf1c6b/ed7178bf3) are deliberately not touched — they cannot rot and must not chase the ledger.The firing control.
pnpm --filter @object-ui/types type-checkis the instrument;vitest run …zod-mirror-parity.test.tscannot see the type-level reconciliation at all, because the ledger is a TYPE map and vitest does not typecheck. Run TWICE, and both runs are reported because they measure different directions.Leg A — on the pre-fix tree
c90395b, the direction "a member appears". The mutation was the most generic member possible,exportOptions: z.any().optional()::2414is an address in the PRE-EDIT file, and it is not quoted as if it described this head: this PR's own ledger edits add lines above the assertion and move it. Leg B is the reading about the tree a reviewer checks out.Leg B — re-run on head
1ee7f72d, the direction that applies now. The key is mirrored here, so the firing mutation is DELETING the member while the ledger row stays shrunk:SPEC_DERIVED_PAIRS matches what the mirror sources actually do(1 failed | 30 passed). The type-level reconciliation stays invisible to vitest: it is not one of that file's 31 runtime tests. The false-green warning is unchanged by vitest happening to fail here for another reason.zzLedgerProbeleft type-check at exit 0. That is the probe testing the wrong direction — an UNDECLARED mirror key is not anUnmirroredDeclaredledger key — not evidence the instrument is asleep. Leg B is the valid control.The new pin, and its ablation
packages/types/src/__tests__/object-grid-export-options-refusal-7762.test.ts— 11 tests: a non-vacuity floor that the spec arm is found with its five keys, shape membership with a live and a negative control,toBeidentity per member, the "not the spec union" control (which also assertslist-viewstill lifts), the four authored readings, and three non-regression controls.Ablated on the committed tree by deleting the member and running the pin: 6 of 11 red, and the 5 that stayed green are exactly the ones that must — the floor, the "not the union" control, the five-key object still accepted, the empty node, and the neighbouring members. Restored by
git checkout HEAD --the path, proved by blob equality (37f6f962…, this head) and an emptygit diff HEAD.Gates, derived from the changed-file set
Exit codes captured by redirect before any pipe.
pnpm --filter @object-ui/types buildpnpm --filter @object-ui/types type-checkpnpm exec vitest run packages/types/pnpm exec vitest runon the new pinpnpm --filter @object-ui/types lintnode scripts/check-changeset-presence.mjscheck-changeset-fixed/check-changeset-no-majorpnpm check:control-bytescheck:esm-specifierscheck:spec-symbolscheck:self-importcheck:phantom-depscheck:unreferenced-sourcescheck:published-tsconfig-excludecheck-type-check-coveragecheck-governed-queue-guard --teston all four pathsexportOptions(the new pin, plugin-grid ×4, plugin-list, console parity)turbo run build --filter='...@object-ui/types' --filter='!@object-ui/site' --concurrency=2The downstream leg is the whole consumer closure, not a narrowing:
@object-ui/typesis the protocol package and this change moves its publishedz.inputface, so every consumer was rebuilt (each package'sbuildistsc). Provenance: every figure in the table above was re-measured on head1ee7f72d— the build, the type-check, the 145/2766 suite, the pin, the pin ablation, lint, all elevencheck:*gates, the governed-surface probe, the 7-file consumer set and the 42-package closure. ⛔ Nothing here is a29c0479number relabelled with a newer sha. The two readings that are NOT from this head are labelled where they appear, and are readings about a different tree by design: the pre-fix baseline (taken onorigin/main— that is the point of it) and firing-control Leg A (taken onc90395b, with Leg B re-run on this head). Heavy runs went throughscripts/pm/os-verify-lock.shwithOS_VERIFY_LOCK_SLOT=issue-7762-objectui; verdicts read from its ownVERDICT command-exitlines.Console
frameworkbudget — merged main, and the bytes recoveredorigin/main(61e649a) is merged in as3ab8e40(--no-ff, no conflicts, ⛔ no rebase / amend / force-push). Theframeworkchunk ispackages/(core|react|types), so this PR's published strings land in it.Two legs, both built and measured locally:
frameworkgzip bytesorigin/main61e649a29c0479(merged)1ee7f72d⇒ The ceiling is not saturated:
mainis healthy and the 391-byte delta is this PR's. Two reductions were taken, each measured before choosing, and each pure redundancy removal that loses no fact:throwmessage, 272 → 144 raw bytes — worth 35 gzip bytes. It still names the moved spec symbol, the member that depends on it and the card; the rationale it used to restate lives in the docblock above it, which the production minifier strips while the string ships.typeof cur.unwrap === 'function'predicate (an estimated 5–8 gzip bytes, deliberately not measured). A non-function truthyunwrapwould then raise aTypeErrorinstead of the diagnosticError, which weakens the module-load guard.⛔
PER_CHUNK_GZIP_CEILINGS.frameworkandPER_CHUNK_BASELINEare untouched —git diff origin/main -- scripts/check-eager-closure-budget.mjsis empty. Nothing was skipped, disabled or quarantined.mainitself only had 349 bytes of headroom on a chunk whose ceiling was last raised for an unrelated spec bump. The next card that adds any published string or schema member topackages/(core|react|types)hits this wall with nothing left to trim. Raising it is a maintainer-floor action and was not taken here; flagging it so the decision gets taken deliberately instead of discovered by the next lane.The accept set did not move with the bytes. Verdict sets compared across the two blobs, same probe, rebuilt
disteach time:Only the message LENGTH moved (376 → 361 characters). And the pin's own assertions re-verified on
1ee7f72d:/formats/✅,/list-view/✅,/object-grid/✅, anddescription === refusal message✅ (both 363 UTF-8 bytes).list-viewstill LIFTS a bare array to{ formats: ['csv','xlsx'] }and still refuses'pdf'— no sideways leak.ObjectViewSchema.table.exportOptionsmoves with the change (it isObjectGridSchema.omit(…).partial()): base accepted a bare array there, this head refuses it, the object form parses on both, and it has zero runtime readers — named in the changeset rather than left to be discovered.object-gridthe retired'pdf'value is reachable only through the OBJECT spelling. The ARRAY spelling['csv','pdf']is refused earlier, by the named refusal, before the format enum is consulted — correct and intended, since the array form is not admitted on this node at all.Fixture triage
Repo-wide sweep for an authored array spelling (
git grep -n "exportOptions['\"]\?: *\["): four hits, none on anobject-gridnode — one changeset prose line, and threelist-viewfixtures (ListView.test.tsx,export-options-spec-parity.test.ts,p1-spec-alignment.test.ts) whose spelling is still legal there and is asserted to keep lifting. No fixture is owed a change.验收备注
packages/plugin-grid/src/index.tsx's designer description forexportOptionsstill lists four keys and omitsstreaming. Noted, not filed: it is already recorded in the card body and routed by triage into its shard A table, andplugin-gridis read-only for this lane.🤖 Generated with Claude Code
https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE
Authored by Claude Code in session
session_01CZY49skxUBYyJcdnTcYPrE(durable attribution in prose, so an edit does not accrete footer blocks).Generated by Claude Code