Repository navigation
fix(studio): object writers seeded from a served read drop a picklist-bound field's served options (objectui#11692) - #11706
Conversation
…-bound field's served options Move `dropServedPicklistOptions` from app-shell to `@object-ui/data-objectstack` (a new export) so the plugin-designer writers can share it, re-point app-shell's two callers, and apply it at the writers that seed an object PUT from a served read: the OWD overview, the Setup fields and objects pages, and `MetadataService.saveFields`. `saveObject` and `saveMetadataItem` read nothing and are left as they are, so an authored picklist + options pair stays the server's loud 422. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude <noreply@anthropic.com>
… for objectui#11692 Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude <noreply@anthropic.com>
✅ 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
|
…ed-options Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude <noreply@anthropic.com>
…'s served options before the parent PUT `EmbeddedItemEditor.doSave` re-reads the parent object's served `layered.effective`, splices the edited item in and PUTs the whole parent, so an object with a picklist-bound field was refused (422 at `fields.FIELD.options`) whatever item was saved. The object parent now goes through `dropServedPicklistOptions` before the PUT; other parent types are sent as before. Pinned with a 422-refusing transport; changeset prose names the fifth writer. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude <noreply@anthropic.com>
✅ 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
|
Contract reviewServed-tier: Inputs: card objectui#11692 (body and all six comments, 6011093735 through 6012902044), PR objectui#11706 (body, 17-file list, the API net diff against ① Derived judgmentsPublic surface.
Published write behaviour (each writer, read at the head sha).
Pins against triage's pin sentence ("each listed writer saves a picklist-bound object it read and did not touch, and the server receives no options on the bound field"): every new file drives a real README ( ② Semver level
Clause-②: yes One widening (the new ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #11692
Clause-②: yes
Object writers outside the two objectui#10202 designers seed their PUT from a served object read. The runtime serves a picklist-bound field with
picklistand theoptionsit resolved from the list, and the authoring door refuses that pair for the whole object (422 INVALID_METADATAatfields.FIELD.options). Each writer the card lists now either drops those served options before its PUT, or is shown here not to seed from a served read. A fifth writer of the same class,EmbeddedItemEditor, was found during the work. The seat added it to the claim's file surface (comment 6012529702), and it is fixed the same way.Dispatched work: claim 6011332561 on objectui#11692, PM session
https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju.What changes
The helper moves to
@object-ui/data-objectstack(new export, the reason forClause-②: yes).dropServedPicklistOptionsmoves fromapp-shell/src/views/metadata-admin/picklist-binding.tstodata-objectstack/src/picklist-binding.ts(agit mv, with its test) and is exported from the package barrel.@object-ui/plugin-designerdoes not depend on@object-ui/app-shell, andapp-shelllistsplugin-designeras a peer, so the two Setup pages could not import it where it lived. Both packages already depend ondata-objectstack. That package also owns the client that serves the read and carries the write, the same reasonextractDraftBodylives there.builtinComponents.tsx(the metadata-adminobjectresource'sfromDraft) andStudioDesignSurface.tsx, now import it from@object-ui/data-objectstack. No copy is left inapp-shell, which never exported it.packages/data-objectstack/dist/index.d.tsafterturbo run build:declare function dropServedPicklistOptionswith type parameterT extends Record of string to unknown, takingbody: Tand returningT. It appears in the barrel's export list besideextractDraftBody. The type parameter is spelled out in words here because GitHub strips angle brackets from PR bodies.MetadataClient.saveis unchanged, which leaves option B ruled out as triage directed. The barrel comment and the module docblock both say the export is for WRITERS. A door cannot tell a served copy from an authored pair, and the authored pair must stay the server's loud refusal.Per writer (card "Done when"):
PackageOwdOverviewPaneldoSavegetDraftbody, elselayered.effectivedropServedPicklistOptions(next)beforeclient.saveMetadataFieldsPagehandleFieldsChangeclient.get('object'), andcarryOver/carryPreservedFieldcarry every per-field key backdropServedPicklistOptions(mergedObject). The page has no options editor, so a bound field'soptionscan only be the served copyMetadataObjectsPagehandleObjectsChangeclient.list('object')spread as the merge basedropServedPicklistOptions(merged)EmbeddedItemEditordoSave(opened from an object's fields, indexes or validations)client.layered(parentType, parentName).effective, splices the edited item in, and PUTs the whole parentobjectparent goes throughdropServedPicklistOptions(updated). Other parent types are sent exactly as beforeMetadataService.saveFieldsclient.meta.getItem('object')and the per-field carry-overdropServedPicklistOptions(updatedObject).DesignerFieldDefinitiondeclares nopicklist, so this method can neither author nor remove a bindingMetadataService.saveObjectobjandexistingFieldsMetadataService.saveMetadataItemdatais the caller'sThe list read resolving the binding is a source reading of objectstack, not a live measurement. objectstack's
metadata-protocolfoldObjectExtendersFromRegistrydocblock says the list read "readsregistry.listItems, whose object branch resolves through the same fold", andSchemaRegistry.foldExtendersOntoDefinitionends inresolvePicklistFields.Triage's correction stands:
runtime-metadata-persistence.tssaves no object, so it is untouched.Pins (a 422-refusing transport, as objectui#10202's pins are)
Each new test drives a real
MetadataClient, or forMetadataServicea realObjectStackAdapterand SDK. The transport double answers a PUT whose fields carrypicklistbesideoptionswith the door's422 INVALID_METADATAenvelope, so every claim is read off the wire:PackageOwdOverviewPanel.picklistServedOptions-11692.test.tsx: an OWD change on an object with a bound field, seeded fromlayered.effectiveand from a served draft. The door accepts the body, the bound field goes out aspicklistonly, the inline select and the text field go out byte-identical, and the OWD edit lands.MetadataFieldsPage.picklistServedOptions-11692.test.tsx: relabel an unrelated field. Both the designable boundselectand a carried-through boundmultiselectleave withoutoptions, and the inline select keeps its options.MetadataObjectsPage.picklistServedOptions-11692.test.tsx: relabel the object. Same assertions.EmbeddedItemEditor.picklistServedOptions-11692.test.tsx: the editor is mounted over a served object with a boundtier. Two cases: saving another item (amount), and an untouched save of the bound field itself. In both, the door accepts the parent,tiergoes out aspicklistonly, and the inline select goes out byte-identical.MetadataService.picklistServedOptions-11692.test.ts:saveFieldswith a field list built from the served read is accepted, and the bound field has nooptions.saveObjectandsaveMetadataItemissue no read, and an authored pair still reaches the door and is refused, asserted on the SDK error'scode: 'INVALID_METADATA'andhttpStatus: 422.picklist-binding.test.tsis unchanged apart from its header. It still reads the contract off the installed@objectstack/spec.Ablation. Each fix was committed first (
51bb8bbfor the four writers,32b8c2eforEmbeddedItemEditor). Each writer's helper call was then removed through objectstack'sscripts/ablation-replace.mjs, which wraps the run, verifies that the mutation landed on disk, and restores fromHEAD. Every pin went red for the right reason, and every restore was proven by blob hash equal to HEAD and an emptygit diff HEAD.dropServedPicklistOptions(next)tonextrefused at fields.tier.options: expected 422 to be 200dropServedPicklistOptions(mergedObject)tomergedObjectrefused at fields.tier.options, fields.regions.optionsdropServedPicklistOptions(merged)tomergedrefused at fields.tier.optionssaveFieldsdropServedPicklistOptions(updatedObject)toupdatedObjectfailed spec validationthrowEmbeddedItemEditordropServedPicklistOptions(updated)toupdated(at32b8c2e)refused at fields.tier.options: expected 422 to be 200The
saveObjectandsaveMetadataItempins stay green under that last mutation, as they should. The first attempt at the last three ablations was REFUSED by the tool and ran no test, because the replacement was a substring of the anchor. They were re-anchored on the wholesave(...)line and re-run. The table shows the re-run.No
distbuild sits in the ablation path. The writers are imported fromsrcby relative path, and the root vitest config aliases@object-ui/data-objectstackto itssrc.Gates
Patch round (fifth writer), at branch head
32b8c2e. This head includes the merge ofmainat3409fe8, which is a merge commit, not a rebase.turbo run build --filter=@object-ui/app-shell^... --concurrency=2Tasks: 28 successful, 28 totalpnpm --filter @object-ui/app-shell run type-checkpnpm --filter @object-ui/app-shell run lintbc90d2f, and the new test has no hitvitest runof the 6 pin files plus the 3 existingEmbeddedItemEditorsuitesTest Files 9 passed (9),Tests 39 passed (39)check:new-line-citations/check:control-bytes/check:changeset-claims/check:pending-changeset-literals/check:metadata-write-doors0 new citation(s)and3 can carry an object document, 3 reach assertObjectMetadataWritablecheck-changeset-no-major/check-changeset-presence/check:self-import/check:phantom-deps/check-vi-mock-{specifiers,inherit,override-shape}/check:test-path-roots15 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)Round 1, at branch head
bc90d2f. These readings were not re-taken in the patch round. All atbc90d2funless stated. Heavy runs went throughos-verify-lock.sh. Every exit code was captured before any pipe.turbo run build --filter=@object-ui/app-shell^... --filter=@object-ui/plugin-designer^... --concurrency=2(at51bb8bb, whosesrcis identical tobc90d2f)Tasks: 28 successful, 28 totalpnpm --filter @object-ui/data-objectstack run type-checkpnpm --filter @object-ui/plugin-designer run type-check(tsc --noEmit && tsc -p tsconfig.test.json)--listFiles)pnpm --filter @object-ui/app-shell run type-check(same two legs)--listFiles)pnpm --filter @object-ui/{data-objectstack,plugin-designer,app-shell} run lintvitest runof the 5 new or moved test filesTest Files 5 passed (5),Tests 13 passed (13)vitest run packages/data-objectstack/ packages/plugin-designer/plus 126app-shellfiles (see below)Test Files 247 passed (247),Tests 2342 passed (2342)check:self-import/check:phantom-deps/check:unused-depscheck:readme-exportsreal(--list)--strictagainst the builtdistcheck:new-line-citations0 new citation(s)check:control-bytesOKcheck:metadata-write-doors3 can carry an object document, 3 reach assertObjectMetadataWritablecheck:changeset-claims/check:pending-changeset-literalsNo pending changeset names a file this change touches.check-changeset-no-major/check-changeset-fixed/check-changeset-presence13 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)check:unreferenced-sources/check:test-path-roots/check-vi-mock-{specifiers,inherit,override-shape}/check:esm-specifiers/check:doc-fences/check:designer-field-key-parity/check-type-check-coverage/check-lint-coverage/check:spec-symbols/check:component-surface-parity(report-only)Notes on the table:
app-shellhas 1025 test files. A full run of all three packages was started and held the shared lock for 29 minutes without finishing, with other seats queued behind it, so I stopped it (my own PID). I re-ran a narrowed set instead: all ofdata-objectstackandplugin-designer, and inapp-shellevery test undersrc/services/andsrc/views/studio-design/, every test that namesStudioDesignSurface,builtinComponents,PackageOwdOverviewPanel,MetadataService,picklist-binding,dropServedPicklistOptionsorregister-builtins, and the objectui#10202 suites. That set is derived from direct importers. It is blind to a test that reaches a touched module through a third module, so the fullapp-shellsuite is left to CI.app-shell's barrel surface is byte-unchanged.data-objectstackgains one export and loses none.check:readme-exportsexit 1 is not a measurement of this change. It is the gate's "type entry not on disk" refusal for 8 packages outside this build's closure (plugin-gantt,plugin-map,plugin-markdown,plugin-timelineand others). With--listit exits 2 (PRECONDITION NOT MET) and judgespackages/data-objectstack/README.md'sdropServedPicklistOptionsimportreal.check:doc-snippets, reason: its build closure is every package. The one new README block was compiled--strictagainst the builtdata-objectstackdist instead, with the negative control shown in the table.check:eager-closure, reason: it needs a full console build. See Acceptance notes.Acceptance notes
EmbeddedItemEditor, is fixed here after the claim amendment (comment 6012529702). Its wrap covers the edited item too, the same rule the four writers and the Studio data page apply: every field that names a picklist leaves withoutoptions. If the drawer's generic field form offersoptionson a bound field, an edit made there is dropped while the binding stays, because the editor cannot tell the served copy from an edit. Unbinding a field stays the job of the select-field editor's "Options from" picker (objectui#10202).MetadataServicehas no in-repo caller ofsaveObject,saveFieldsorsaveMetadataItem. It is public API throughuseMetadataService. ThesaveFieldsfix and the two pins of "no read, the pair still refused" are therefore measured on the method, not on a screen.check:eager-closurewas not run locally (it needs a full console build). The helper moved into a package thatapps/console/src/dataSource.tsalready imports statically, so no new module joins the eager graph. That claim is read from source, not measured.Generated by Claude Code