Repository navigation
fix(metadata-protocol): diagnostics stop flagging picklist and options on a field that declares only picklist - #26953
Conversation
…out the options the runtime resolved onto it Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5nFWpcGdLrSDAf6vS9
… changeset Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5nFWpcGdLrSDAf6vS9
…outside the lean objectql/core boundary Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5nFWpcGdLrSDAf6vS9
📓 Docs Drift Check7 anchor(s) derived from 3 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 27 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 0c84b1a6db0d659f86558360c7ad6945a11c7547 && git checkout 0c84b1a6db0d659f86558360c7ad6945a11c7547
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e4ba846fa3cdaf067c177f31837a33c2206341a6 a6f469a31ff5891be6d07c692e81e4b5629e5469 && git checkout -B drift-repro e4ba846fa3cdaf067c177f31837a33c2206341a6 && git merge --no-ff a6f469a31ff5891be6d07c692e81e4b5629e5469
node scripts/docs-audit/affected-docs.mjs --json e4ba846fa3cdaf067c177f31837a33c2206341a6 |
席位核对ACCEPT · The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.
Generated by Claude Code |
Requested by Leehom · project thread
Fixes #26886
Clause-②: no
Metadata-protocol: none
维护者速读
picklist(共享选项列表)时,元数据诊断(Setup › 所有元数据类型的问题数、条目_diagnostics、GET /meta/diagnostics)不再误报「picklist和options不能同时声明」。真正同时写了两个键的字段照报,文案和级别不变。/meta/object/:name下发内容不变(仍是picklist+ 解析后的options),规则本身不动。新增的只是 picklist 解析在它生成的 options 数组上打一个不可枚举的标记,诊断见标记才略过;标记丢失时退回旧行为(误报),不会漏报。回滚即 revert。packages/metadata-protocol/src/metadata-diagnostics.ts(withoutResolvedPicklistOptions);packages/objectql/src/picklist-resolution.ts(resolveField打标记的条件);packages/metadata-core/src/resolved-picklist-options.ts。pnpm --filter @objectstack/objectql exec vitest run src/protocol-picklist-served-roundtrip.test.ts,预期 4/4 通过,其中三入口用例只报fields.both.options一条。What changes
The global-picklist resolution in
@objectstack/objectql(resolveFieldinpicklist-resolution.ts) now marks theoptionsarray it derives for a field that names a picklist, and the load-time diagnostics in@objectstack/metadata-protocol(computeMetadataDiagnostics) leave a marked array out of the document before the schema parse. A field is therefore judged as its author wrote it:GET /meta/object/:namepicklist: 'industry'picklist+ resolvedoptionspicklistandoptionscannot both be declared"picklistand its ownoptionspicklist+ resolvedoptionsfields.NAME.optionsoptionsonlyoptions, or carried options that are themselves marked (a served copy re-folded in memory). A body that brought unmarkedoptionsbesidepicklistis an authored pair and its resolved options stay unmarked, so the rule still fires.markResolvedPicklistOptions/isResolvedPicklistOptions, a new module in@objectstack/metadata-core(resolved-picklist-options.ts): a non-enumerable property keyed withSymbol.for, on the array itself. It is not in any response, stored row, JSON or memo key; every loaded copy of the package reads the one key. A copy of the array (.map, spread, JSON round trip) does not carry it, so a reader of a copy reports the diagnostic: the failure direction is the old false error, never a missed one.metadata-core. Both sides already declare it. The resolution sits in the lean@objectstack/objectql/coreclosure, which must not import@objectstack/metadata-protocol(ADR-0076 D2;core-boundary.ratchet.test.tsandcheck:lean-entry-closureboth refused the first version of this PR, which put the mark inmetadata-protocol).packages/spec/src/data/field.zod.ts, untouched); what/metaserves; the save door (a PUT of the served body is still refused 422INVALID_METADATA, pinned by the existing test inprotocol-picklist-served-roundtrip.test.ts). No second copy of the rule.Doors that show the diagnostic, all through the one seam
computeMetadataDiagnosticsis the only place the read side parses an item, so every door below is fixed by the one change:_diagnostics(getMetaItem→decorateMetadataItem)._diagnostics(getMetaItems→decorateMetadataItems).GET /api/v1/meta/diagnostics(protocol-meta-diagnostics.tsreads the list's_diagnostics, or computes it), which is what Setup › All Metadata Types counts._diagnosticson the effective item (protocol.ts,getMetaItemLayers).The boot-time scan of stored
sys_metadatarows (protocol.ts,convertStoredItem→computeMetadataDiagnostics) parses the stored body, which is authored and never resolved, so it is unmarked and keeps flagging a stored row that carries both keys.Why
Seat ruling on the card: judge what the author wrote, never the resolved copy; "If the resolved shape is the only thing that pass can see, the resolution may mark what it derived." Measured which applies: the authored item is not visible at the decoration seam.
finishServedObject(foldObjectExtendersOnto/resolveObject) before decoration, and that fold resolves the picklist.registry.getAllObjects()(resolved bodies) into the service.resolveFieldoverwrites a field'soptionsunconditionally, so even an authored pair reaches the decoration seam with the picklist's options: the served copy alone cannot tell the two apart.So the resolution marks what it derived. Stripping every
optionsbesidepicklistin the diagnostics instead would silence an authored pair on every door: a consumer-side tolerance the ruling excludes.Tests
metadata-coreresolved-picklist-options.test.ts(new, 4): the mark is in place and only on the marked array, invisible to JSON and key enumeration, gone from any copy of the array, kept through a copy of the field holding it.metadata-protocolmetadata-diagnostics.resolved-picklist-options.test.ts(new, 6): picklist-only field served with marked options is clean; an authored pair is flagged atfields.industry.optionswith the rule's message; inline-only clean; a marked array on a field with nopicklistis judged as written; a marked and an unmarked document with the same JSON are two verdicts in either order (the verdict memo); the decorated item keeps both keys and no trace of the mark.objectqlprotocol-picklist-served-roundtrip.test.ts(+2): a realObjectQLregistry with a code-registered object (picklist-onlyindustry, inlinestage, authored pairboth) throughObjectStackProtocolImplementation: the item read, the list read andgetMetaDiagnosticseach report exactlyfields.both.options; the item read still servesindustrywithpicklistand the resolved options.Red first, ablation
main(57820085), same harness as the new objectql test, before any edit: the item read, the list read andgetMetaDiagnosticseach reportedfields.industry.options"picklistandoptionscannot both be declared" for a field declaring onlypicklist.picklist-resolution.ts,markResolvedPicklistOptions(resolved);replaced byvoid 0;(on disk: 0 of the first, 1 of the second) turned both new objectql tests red (2 failed, 2 passed: the authored-pair control and the existing round-trip tests stay green). Restored from HEAD;git diff HEADempty afterwards.Live check (os dev)
examples/app-todowith itscategoryfield switched topicklist: 'todo_category'and a matchingpicklistsentry (a scratch edit, not committed; restored), booted withos dev --fresh, signed in as the seeded admin:mainmetadata-protocol + objectql (base blobs rebuilt)GET /meta/diagnostics?type=objecttodo_taskfields.category.options"picklistandoptionscannot both be declared"GET /meta/diagnostics(all types, 177 items)GET /meta/object/todo_taskfields.categorypicklist: "todo_category"+ 5 resolvedoptionstodo_task._diagnosticsvalid: false(that one error)valid: trueThe count drops by exactly the false row. Base blobs were restored and both packages rebuilt from HEAD afterwards.
Acceptance notes
app-todowith the same field shape (a code package's object naming a picklist); booting HotCRM against this branch's packages was not done.optionsarray (rather than the field) would bring the false error back for that door, loudly and in the safe direction. Today no step does: the item, list and sweep doors and the live boot all read clean.Verification
All at head
a6f469a3, after the final commit:@objectstack/objectqltest: Test Files 475 passed (475), Tests 8851 passed;@objectstack/metadata-protocoltest: Test Files 274 passed, 4 skipped (278), Tests 21699 passed, 26 skipped;@objectstack/metadata-coretest: Test Files 21 passed, Tests 840 passed.typecheckof all three: exit 0.eslint --no-inline-configon the seven touched.tsfiles: exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack): 62 derived, 62 run, every one exit 0;--ranreconciliation: "62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED".check:dual-build-cjs-loadswas read after a fullpnpm build(it reads every package'sdist/): 112 require entry points across 69 packages load.check:lean-entry-closureandcore-boundary.ratchet.test.tsare green on this head (both were red on the first version, which importedmetadata-protocolinto the lean core closure).test:repo, the repo-widepnpm lint, and the families outside the derived list.Human Minimum
packages/specholds no runtime logic. (Nopackages/specchange.)pnpm lintpasses. (eslint --no-inline-configon the touched files, exit 0.)main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed. (Not touched.)content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.🤖 Generated with Claude Code
https://claude.ai/code/session_01FEkQ5nFWpcGdLrSDAf6vS9
Generated by Claude Code