Skip to content

Commit 5bd3d2f

Browse files
fix(metadata-protocol): diagnostics stop flagging picklist and options on a field that declares only picklist (#26953)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XQiacasSoJzxf6CYy6p4JTT)_ Fixes #26886 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:字段只写了 `picklist`(共享选项列表)时,元数据诊断(Setup › 所有元数据类型的问题数、条目 `_diagnostics`、`GET /meta/diagnostics`)不再误报「`picklist` 和 `options` 不能同时声明」。真正同时写了两个键的字段照报,文案和级别不变。 - **为什么改**:运行时把共享列表的选项解析到字段上再下发,诊断却校验了这份解析后的副本,于是每个用共享选项列表的应用(HotCRM 有 11 个字段)都给管理员亮红色错误,说应用坏了,实际选择框工作正常。 - **风险与回滚**:低。`/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` (`resolveField` in `picklist-resolution.ts`) now marks the `options` array 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: | Field as authored | Served by `GET /meta/object/:name` | Diagnostic before | Diagnostic after | |---|---|---|---| | `picklist: 'industry'` | `picklist` + resolved `options` | "`picklist` and `options` cannot both be declared" | clean | | `picklist` and its own `options` | `picklist` + resolved `options` | flagged | flagged, same message and severity, at `fields.NAME.options` | | inline `options` only | unchanged | clean | clean | - **When the resolution marks.** Only when the body it resolves carried no `options`, or carried options that are themselves marked (a served copy re-folded in memory). A body that brought unmarked `options` beside `picklist` is an authored pair and its resolved options stay unmarked, so the rule still fires. - **The mark.** `markResolvedPicklistOptions` / `isResolvedPicklistOptions`, a new module in `@objectstack/metadata-core` (`resolved-picklist-options.ts`): a non-enumerable property keyed with `Symbol.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. - **Why `metadata-core`.** Both sides already declare it. The resolution sits in the lean `@objectstack/objectql/core` closure, which must not import `@objectstack/metadata-protocol` (ADR-0076 D2; `core-boundary.ratchet.test.ts` and `check:lean-entry-closure` both refused the first version of this PR, which put the mark in `metadata-protocol`). - **Unchanged.** The rule itself (`packages/spec/src/data/field.zod.ts`, untouched); what `/meta` serves; the save door (a PUT of the served body is still refused 422 `INVALID_METADATA`, pinned by the existing test in `protocol-picklist-served-roundtrip.test.ts`). No second copy of the rule. ### Doors that show the diagnostic, all through the one seam `computeMetadataDiagnostics` is the only place the read side parses an item, so every door below is fixed by the one change: 1. An item read's `_diagnostics` (`getMetaItem` → `decorateMetadataItem`). 2. A list read's per-item `_diagnostics` (`getMetaItems` → `decorateMetadataItems`). 3. `GET /api/v1/meta/diagnostics` (`protocol-meta-diagnostics.ts` reads the list's `_diagnostics`, or computes it), which is what Setup › All Metadata Types counts. 4. The layered read's `_diagnostics` on the effective item (`protocol.ts`, `getMetaItemLayers`). The boot-time scan of stored `sys_metadata` rows (`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. - Every object read folds through the registry's `finishServedObject` (`foldObjectExtendersOnto` / `resolveObject`) before decoration, and that fold resolves the picklist. - The MetadataService body the item read prefers can itself be a served copy: the boot bridge copies `registry.getAllObjects()` (resolved bodies) into the service. - `resolveField` overwrites a field's `options` unconditionally, 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 `options` beside `picklist` in the diagnostics instead would silence an authored pair on every door: a consumer-side tolerance the ruling excludes. ## Tests - `metadata-core` `resolved-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-protocol` `metadata-diagnostics.resolved-picklist-options.test.ts` (new, 6): picklist-only field served with marked options is clean; an authored pair is flagged at `fields.industry.options` with the rule's message; inline-only clean; a marked array on a field with no `picklist` is 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. - `objectql` `protocol-picklist-served-roundtrip.test.ts` (+2): a real `ObjectQL` registry with a code-registered object (picklist-only `industry`, inline `stage`, authored pair `both`) through `ObjectStackProtocolImplementation`: the item read, the list read and `getMetaDiagnostics` each report exactly `fields.both.options`; the item read still serves `industry` with `picklist` and the resolved options. <details><summary>Red first, ablation</summary> - Red on `main` (`57820085`), same harness as the new objectql test, before any edit: the item read, the list read and `getMetaDiagnostics` each reported `fields.industry.options` "`picklist` and `options` cannot both be declared" for a field declaring only `picklist`. - Ablation (committed fix): in `picklist-resolution.ts`, `markResolvedPicklistOptions(resolved);` replaced by `void 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 HEAD` empty afterwards. </details> <details><summary>Live check (os dev)</summary> `examples/app-todo` with its `category` field switched to `picklist: 'todo_category'` and a matching `picklists` entry (a scratch edit, not committed; restored), booted with `os dev --fresh`, signed in as the seeded admin: | | `main` metadata-protocol + objectql (base blobs rebuilt) | this branch | |---|---|---| | `GET /meta/diagnostics?type=object` | 1 entry: `todo_task` `fields.category.options` "`picklist` and `options` cannot both be declared" | 0 entries | | `GET /meta/diagnostics` (all types, 177 items) | total 1 | total 0 | | `GET /meta/object/todo_task` `fields.category` | `picklist: "todo_category"` + 5 resolved `options` | identical | | `todo_task._diagnostics` | `valid: false` (that one error) | `valid: true` | The count drops by exactly the false row. Base blobs were restored and both packages rebuilt from HEAD afterwards. </details> ## Acceptance notes - **HotCRM path not booted.** The card's own reproduction is HotCRM on published 18.2.0. The live check above uses the in-repo `app-todo` with the same field shape (a code package's object naming a picklist); booting HotCRM against this branch's packages was not done. - **A copy loses the mark.** Any future step between the fold and the decoration that copies a field's `options` array (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: - Package suites (under the verify lock): `@objectstack/objectql` test: Test Files 475 passed (475), Tests 8851 passed; `@objectstack/metadata-protocol` test: Test Files 274 passed, 4 skipped (278), Tests 21699 passed, 26 skipped; `@objectstack/metadata-core` test: Test Files 21 passed, Tests 840 passed. `typecheck` of all three: exit 0. - `eslint --no-inline-config` on the seven touched `.ts` files: exit 0. - Derived gate families (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`): 62 derived, 62 run, every one exit 0; `--ran` reconciliation: "62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED". `check:dual-build-cjs-loads` was read after a full `pnpm build` (it reads every package's `dist/`): 112 require entry points across 69 packages load. `check:lean-entry-closure` and `core-boundary.ratchet.test.ts` are green on this head (both were red on the first version, which imported `metadata-protocol` into the lean core closure). - Not run locally, owned by CI: `test:repo`, the repo-wide `pnpm lint`, and the families outside the derived list. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (`eslint --no-inline-config` on the touched files, exit 0.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5nFWpcGdLrSDAf6vS9 --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5nFWpcGdLrSDAf6vS9)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 49b5059 commit 5bd3d2f

8 files changed

Lines changed: 302 additions & 2 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/metadata-core': patch
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/objectql': patch
5+
---
6+
7+
fix(metadata-protocol): load-time diagnostics no longer report "`picklist` and `options` cannot both be declared" on a field that declares only `picklist`
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
A select field that names a shared list (`picklist: 'industry'`) is served with the list's options written beside `picklist`, and the diagnostics judged that served copy, so every shared-picklist field of every app was reported as declaring both keys: in an item's `_diagnostics`, in the list read's, and in `GET /meta/diagnostics` (Setup › All Metadata Types). They now judge the field as its author wrote it.
13+
14+
- **The rule stands.** A field that authors both `picklist` and `options` is still reported, with the same message and severity, at `fields.NAME.options`.
15+
- **What `/meta` serves is unchanged.** A picklist-bound field is still served with `picklist` and the resolved `options`.
16+
- **How the two are told apart.** The picklist resolution marks the options array it derives; `@objectstack/metadata-core` exports `markResolvedPicklistOptions` and `isResolvedPicklistOptions`, and the diagnostics leave a marked array out of the judgement. The mark is not part of any response or stored row.

‎packages/metadata-core/src/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,9 @@ export * from './object-field-type.js';
133133
// `view` write in `@objectstack/metadata-protocol`; both read this one rule, so
134134
// a form withdrawn by either declared switch is withdrawn everywhere.
135135
export * from './anonymous-form-intake.js';
136+
137+
// [#26886] The mark on an `options` array resolved from a shared picklist,
138+
// shared by the resolution (`@objectstack/objectql`'s lean core closure) and the
139+
// load-time diagnostics (`@objectstack/metadata-protocol`), so the diagnostics
140+
// judge a field as its author wrote it.
141+
export * from './resolved-picklist-options.js';
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #26886 — the mark on options resolved from a shared picklist rides the array
5+
* itself and nothing else: never a JSON key, never on a copy. A copy reads as
6+
* authored, so a reader of it reports a diagnostic rather than missing one.
7+
*/
8+
import { describe, expect, it } from 'vitest';
9+
import { isResolvedPicklistOptions, markResolvedPicklistOptions } from './resolved-picklist-options.js';
10+
11+
describe('resolved picklist options mark', () => {
12+
it('marks the array it is given, in place, and only that array', () => {
13+
const options = [{ label: 'Tech', value: 'tech' }];
14+
expect(markResolvedPicklistOptions(options)).toBe(options);
15+
expect(isResolvedPicklistOptions(options)).toBe(true);
16+
expect(isResolvedPicklistOptions(markResolvedPicklistOptions(options))).toBe(true);
17+
expect(isResolvedPicklistOptions([{ label: 'Tech', value: 'tech' }])).toBe(false);
18+
expect(isResolvedPicklistOptions(undefined)).toBe(false);
19+
});
20+
21+
it('is invisible to JSON and to key enumeration', () => {
22+
const options = markResolvedPicklistOptions([{ label: 'Tech', value: 'tech' }]);
23+
expect(JSON.stringify({ options })).toBe('{"options":[{"label":"Tech","value":"tech"}]}');
24+
expect(Object.keys(options)).toEqual(['0']);
25+
expect(Object.getOwnPropertySymbols(options).map((s) => Object.getOwnPropertyDescriptor(options, s)?.enumerable)).toEqual([false]);
26+
});
27+
28+
it('does not survive a copy of the array', () => {
29+
const options = markResolvedPicklistOptions([{ label: 'Tech', value: 'tech' }]);
30+
expect(isResolvedPicklistOptions(options.map((o) => ({ ...o })))).toBe(false);
31+
expect(isResolvedPicklistOptions([...options])).toBe(false);
32+
expect(isResolvedPicklistOptions(JSON.parse(JSON.stringify(options)))).toBe(false);
33+
});
34+
35+
it('survives a copy of the field that holds it', () => {
36+
const field = { picklist: 'industry', options: markResolvedPicklistOptions([{ label: 'Tech', value: 'tech' }]) };
37+
expect(isResolvedPicklistOptions({ ...field }.options)).toBe(true);
38+
});
39+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#26886] The mark on an `options` array the runtime RESOLVED from a shared
5+
* picklist, as opposed to one an author wrote on the field.
6+
*
7+
* A field authors `picklist: '<name>'` and no `options`; every object read
8+
* serves it with the list's options written beside `picklist` (the registry's
9+
* picklist resolution in `@objectstack/objectql`). The load-time diagnostics in
10+
* `@objectstack/metadata-protocol` judge what the author wrote, so the field
11+
* schema's rule refusing `picklist` and `options` together must not read the
12+
* copy the runtime wrote. The resolution marks the array it derived and the
13+
* diagnostics leave only a marked array out.
14+
*
15+
* Here because both sides need the one spelling: the diagnostics live in
16+
* `@objectstack/metadata-protocol`, which `@objectstack/objectql` depends on,
17+
* and the resolution sits in the lean `@objectstack/objectql/core` closure,
18+
* which must not import `@objectstack/metadata-protocol` (ADR-0076 D2). Both
19+
* already declare this package.
20+
*
21+
* The mark is a non-enumerable property, so no response, stored row or JSON
22+
* key ever carries it, and it is keyed with `Symbol.for` so every loaded copy
23+
* of a package reads the one mark. A copy of the array (`.map`, a spread, a
24+
* JSON round trip) does not carry it: whoever reads the copy sees authored
25+
* options, so the failure direction is a reported diagnostic, never a missed one.
26+
*/
27+
28+
const RESOLVED_PICKLIST_OPTIONS = Symbol.for('@objectstack/metadata-core:resolved-picklist-options');
29+
30+
/**
31+
* Mark an `options` array as resolved from a shared picklist rather than
32+
* authored on the field. Returns the same array.
33+
*/
34+
export function markResolvedPicklistOptions<T extends object>(options: T): T {
35+
if (!Object.prototype.hasOwnProperty.call(options, RESOLVED_PICKLIST_OPTIONS)) {
36+
Object.defineProperty(options, RESOLVED_PICKLIST_OPTIONS, { value: true });
37+
}
38+
return options;
39+
}
40+
41+
/** `true` when `options` is an array {@link markResolvedPicklistOptions} marked. */
42+
export function isResolvedPicklistOptions(options: unknown): boolean {
43+
return Array.isArray(options)
44+
&& (options as unknown as Record<symbol, unknown>)[RESOLVED_PICKLIST_OPTIONS] === true;
45+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #26886 — load-time diagnostics judge a picklist-bound field as its author
5+
* wrote it, never with the `options` the runtime resolved onto it.
6+
*
7+
* Every object read serves a field that authors `picklist: '<name>'` with the
8+
* list's options written beside it, and the field schema refuses `picklist`
9+
* and `options` together. Judging the served copy flagged every shared-picklist
10+
* field of every app. The resolution marks the array it derived
11+
* (`markResolvedPicklistOptions`), and only a marked array is left out of the
12+
* judgement:
13+
*
14+
* - a field naming only a picklist, served with resolved options: clean;
15+
* - a field that AUTHORS both keys (an unmarked array): still flagged, with
16+
* the rule's own message, at `fields.NAME.options`;
17+
* - a field with inline `options` only: clean, before and after.
18+
*
19+
* The mark never reaches a response or a memo key: a marked document and an
20+
* unmarked one with the same JSON are two verdicts.
21+
*/
22+
import { describe, expect, it } from 'vitest';
23+
import { markResolvedPicklistOptions } from '@objectstack/metadata-core';
24+
import { computeMetadataDiagnostics, decorateMetadataItem } from './metadata-diagnostics.js';
25+
26+
const BOTH_KEYS = '`picklist` and `options` cannot both be declared';
27+
28+
const resolved = () => markResolvedPicklistOptions([{ label: 'Tech', value: 'tech' }]);
29+
const authored = () => [{ label: 'Tech', value: 'tech' }];
30+
31+
function objectWith(fields: Record<string, unknown>) {
32+
return { name: 'pk_probe', label: 'Probe', fields: { name: { name: 'name', type: 'text', label: 'Name' }, ...fields } };
33+
}
34+
35+
const industry = (options: unknown[]) =>
36+
({ name: 'industry', type: 'select', label: 'Industry', picklist: 'industry', options });
37+
38+
function bothKeysPaths(diagnostics: ReturnType<typeof computeMetadataDiagnostics>): string[] {
39+
return (diagnostics?.errors ?? []).filter((e) => e.message.startsWith(BOTH_KEYS)).map((e) => e.path);
40+
}
41+
42+
describe('diagnostics — options resolved from a shared picklist', () => {
43+
it('a field naming only a picklist, served with its resolved options, is clean', () => {
44+
expect(computeMetadataDiagnostics('object', objectWith({ industry: industry(resolved()) }))).toEqual({ valid: true });
45+
});
46+
47+
it('a field that authors `options` beside `picklist` is still flagged, at its options path', () => {
48+
const diagnostics = computeMetadataDiagnostics('object', objectWith({ industry: industry(authored()) }));
49+
expect(diagnostics?.valid).toBe(false);
50+
expect(bothKeysPaths(diagnostics)).toEqual(['fields.industry.options']);
51+
});
52+
53+
it('a field with inline `options` only is clean', () => {
54+
const inline = { name: 'stage', type: 'select', label: 'Stage', options: [{ label: 'Open', value: 'open' }] };
55+
expect(computeMetadataDiagnostics('object', objectWith({ stage: inline }))).toEqual({ valid: true });
56+
});
57+
58+
it('a marked array on a field with no `picklist` is judged as written', () => {
59+
// A one-character option value is refused; left out, nothing would be.
60+
const stray = { name: 'stage', type: 'select', label: 'Stage', options: markResolvedPicklistOptions([{ label: 'X', value: 'x' }]) };
61+
const diagnostics = computeMetadataDiagnostics('object', objectWith({ stage: stray }));
62+
expect(diagnostics?.errors?.map((e) => e.path)).toEqual(['fields.stage.options.0.value']);
63+
});
64+
65+
it('a marked and an unmarked document with the same JSON are two verdicts, in either order', () => {
66+
const marked = objectWith({ industry: industry(resolved()) });
67+
const unmarked = objectWith({ industry: industry(authored()) });
68+
expect(JSON.stringify(marked)).toBe(JSON.stringify(unmarked));
69+
expect(bothKeysPaths(computeMetadataDiagnostics('object', unmarked))).toEqual(['fields.industry.options']);
70+
expect(computeMetadataDiagnostics('object', marked)).toEqual({ valid: true });
71+
expect(bothKeysPaths(computeMetadataDiagnostics('object', unmarked))).toEqual(['fields.industry.options']);
72+
});
73+
74+
it('the served item keeps both keys and carries no trace of the mark', () => {
75+
const served = decorateMetadataItem('object', objectWith({ industry: industry(resolved()) })) as any;
76+
expect(served._diagnostics).toEqual({ valid: true });
77+
expect(served.fields.industry).toMatchObject({ picklist: 'industry', options: [{ label: 'Tech', value: 'tech' }] });
78+
expect(JSON.parse(JSON.stringify(served.fields.industry))).toEqual(industry(authored()));
79+
expect(Object.keys(served.fields.industry.options)).toEqual(['0']);
80+
});
81+
});

‎packages/metadata-protocol/src/metadata-diagnostics.ts‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import { redactMetadataItem } from './metadata-redaction.js';
4141
// runtime gate's own answer, so the publish verdict below is re-spelled
4242
// against the same prefix the gate wrote it with.
4343
import { stackKeyForType } from '@objectstack/lint/runtime';
44+
// [#26886] The resolution's mark on options it derived from a shared picklist.
45+
import { isResolvedPicklistOptions } from '@objectstack/metadata-core';
4446
import type { RuntimeAuthoringIssue } from '@objectstack/spec/api';
4547

4648
/**
@@ -175,6 +177,52 @@ function copyDiagnostics(verdict: MetadataDiagnostics): MetadataDiagnostics {
175177
return copy;
176178
}
177179

180+
// ---------------------------------------------------------------------------
181+
// [#26886] Options the runtime resolved from a shared picklist
182+
// ---------------------------------------------------------------------------
183+
//
184+
// A field authors `picklist: '<name>'` and no `options`; every object read
185+
// serves it with the list's options written beside `picklist` (the registry's
186+
// picklist resolution, `PicklistServedFieldSchema`). Diagnostics judge what the
187+
// author wrote, so the field schema's rule that refuses `picklist` and
188+
// `options` together must not read the copy the runtime wrote: it flagged
189+
// every picklist-bound field of every app.
190+
//
191+
// The resolution therefore MARKS the options array it derived
192+
// (`markResolvedPicklistOptions`, `@objectstack/metadata-core`), and only a
193+
// marked array is left out here. A field that authored its own `options`
194+
// beside `picklist` arrives unmarked and is still flagged, with the same
195+
// message and severity. The mark is a non-enumerable property, so it is no
196+
// part of the memo key either: the key is the content of the candidate the
197+
// mark has already shaped.
198+
199+
function withoutResolvedOptions(field: unknown): unknown {
200+
if (!field || typeof field !== 'object') return field;
201+
const { picklist, options } = field as { picklist?: unknown; options?: unknown };
202+
if (typeof picklist !== 'string' || !isResolvedPicklistOptions(options)) return field;
203+
const { options: _resolved, ...authored } = field as Record<string, unknown>;
204+
return authored;
205+
}
206+
207+
/**
208+
* A document with the resolved `options` left out of every picklist-bound
209+
* field of its name-keyed `fields` (the only spelling the object schema
210+
* accepts). By reference when no field carries resolved options.
211+
*/
212+
function withoutResolvedPicklistOptions(document: unknown): unknown {
213+
if (!document || typeof document !== 'object') return document;
214+
const fields = (document as { fields?: unknown }).fields;
215+
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return document;
216+
let next: Record<string, unknown> | undefined;
217+
for (const [name, field] of Object.entries(fields as Record<string, unknown>)) {
218+
const judged = withoutResolvedOptions(field);
219+
if (judged === field) continue;
220+
next ??= { ...(fields as Record<string, unknown>) };
221+
next[name] = judged;
222+
}
223+
return next ? { ...(document as object), fields: next } : document;
224+
}
225+
178226
/**
179227
* Compute spec diagnostics for a single metadata document.
180228
*
@@ -224,7 +272,10 @@ export function computeMetadataDiagnostics(
224272
// says. Same class as #6810 (`indexed`), different remedy — that key did
225273
// not belong on the served body at all and left at its injection site,
226274
// whereas this one is read by the UI and belongs on the response.
227-
const candidate = stripReadDecorations(item);
275+
// [#26886] …and judge each picklist-bound field as its author wrote it:
276+
// the `options` the runtime resolved onto it are not part of that shape
277+
// ({@link withoutResolvedPicklistOptions}).
278+
const candidate = withoutResolvedPicklistOptions(stripReadDecorations(item));
228279

229280
// [#25783] A document parsed before against this same schema is answered
230281
// from the memo — see "The verdict memo" above.

‎packages/objectql/src/picklist-resolution.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@
3131
* validator refuses every value of the field instead of accepting any.
3232
*/
3333

34+
// The mark that tells the load-time diagnostics these options were resolved
35+
// here, not authored: they judge what the author wrote, and the field schema
36+
// refuses `picklist` and `options` together.
37+
import { isResolvedPicklistOptions, markResolvedPicklistOptions } from '@objectstack/metadata-core';
38+
3439
/** One option, as `SelectOptionSchema` declares it (only `value` is read here). */
3540
export interface PicklistOptionLike {
3641
value: string | number | boolean;
@@ -133,7 +138,15 @@ function resolveField(field: unknown, resolve: (picklist: string) => readonly Pi
133138
const picklist = (field as { picklist?: unknown }).picklist;
134139
if (typeof picklist !== 'string' || picklist.length === 0) return field;
135140
const options = resolve(picklist);
136-
if (options) return { ...(field as object), options: options.map((o) => ({ ...o })) };
141+
if (options) {
142+
const resolved = options.map((o) => ({ ...o }));
143+
// Marked as derived unless the body brought `options` of its own: a field
144+
// that AUTHORS both keys is still told so. A body that is itself a served
145+
// copy (marked) stays marked.
146+
const carried = (field as { options?: unknown }).options;
147+
if (carried === undefined || isResolvedPicklistOptions(carried)) markResolvedPicklistOptions(resolved);
148+
return { ...(field as object), options: resolved };
149+
}
137150
// Unresolved: drop any options the body carried, so a stale served copy
138151
// cannot stand in for the list. The validator refuses every value of a
139152
// picklist-bound field that has none.

‎packages/objectql/src/protocol-picklist-served-roundtrip.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,52 @@ describe('picklist — the protocol object read and the authoring door', () => {
184184
expect(err.message).toContain('`picklist` and `options` cannot both be declared');
185185
});
186186
});
187+
188+
// [#26886] Every door that shows a load-time diagnostic judges a
189+
// picklist-bound field as its author wrote it: the item's `_diagnostics`, the
190+
// list's, and `GET /meta/diagnostics`. The read itself keeps serving both keys.
191+
describe('picklist — the load-time diagnostics judge the authored field, not the served copy', () => {
192+
const BOTH_KEYS = '`picklist` and `options` cannot both be declared';
193+
let protocol: ObjectStackProtocolImplementation;
194+
195+
beforeEach(async () => {
196+
const engine = new ObjectQL();
197+
engine.registerDriver(makeStubDriver().driver, true);
198+
await engine.init();
199+
engine.registry.registerObject(sysMetadataObject);
200+
for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) engine.registry.registerObject(o as any);
201+
engine.registerApp({
202+
id: 'com.test.lists', name: 'lists',
203+
picklists: [{ name: 'industry', label: 'Industry', options: [{ label: 'Tech', value: 'tech' }] }],
204+
objects: [{
205+
name: 'pd_obj', label: 'Probe', sharingModel: 'public_read_write',
206+
fields: {
207+
name: { name: 'name', type: 'text', label: 'Name' },
208+
industry: { name: 'industry', type: 'select', label: 'Industry', picklist: 'industry' },
209+
stage: { name: 'stage', type: 'select', label: 'Stage', options: [{ label: 'Open', value: 'open' }] },
210+
both: { name: 'both', type: 'select', label: 'Both', picklist: 'industry', options: [{ label: 'Own', value: 'own' }] },
211+
},
212+
}],
213+
} as any);
214+
protocol = new ObjectStackProtocolImplementation(engine);
215+
});
216+
217+
const bothKeysPaths = (diagnostics: any): string[] =>
218+
(diagnostics?.errors ?? []).filter((e: any) => String(e.message).startsWith(BOTH_KEYS)).map((e: any) => e.path);
219+
220+
it('the item read: only the field that authored both keys is flagged, and both keys are still served', async () => {
221+
const served: any = await protocol.getMetaItem({ type: 'object', name: 'pd_obj' } as any);
222+
expect(served.item.fields.industry).toMatchObject({ picklist: 'industry', options: [{ label: 'Tech', value: 'tech' }] });
223+
expect(bothKeysPaths(served.item._diagnostics)).toEqual(['fields.both.options']);
224+
expect(served.item._diagnostics.errors.map((e: any) => e.path)).toEqual(['fields.both.options']);
225+
});
226+
227+
it('the list read and `GET /meta/diagnostics` agree with the item read', async () => {
228+
const listed: any = await protocol.getMetaItems({ type: 'object' } as any);
229+
const item = (listed.items ?? listed).find((i: any) => i.name === 'pd_obj');
230+
expect(bothKeysPaths(item._diagnostics)).toEqual(['fields.both.options']);
231+
const sweep: any = await (protocol as any).getMetaDiagnostics({ type: 'object' });
232+
const entry = sweep.entries.find((e: any) => e.name === 'pd_obj');
233+
expect(entry.diagnostics.errors.map((e: any) => e.path)).toEqual(['fields.both.options']);
234+
});
235+
});

0 commit comments

Comments
 (0)