diff --git a/.changeset/19518-picklist-kind.md b/.changeset/19518-picklist-kind.md index 01dae8d8f13..d5990040929 100644 --- a/.changeset/19518-picklist-kind.md +++ b/.changeset/19518-picklist-kind.md @@ -11,7 +11,7 @@ Clause-②: yes (widening) - **The kind.** `PicklistSchema` — `{ name, label, description?, options }`, where `options` is the field option shape (`SelectOptionSchema`) reused as is. Authored in a package as `*.picklist.ts` (`definePicklist`) or `defineStack({ picklists })`. It is a registered kind (`MetadataTypeSchema`, `DEFAULT_METADATA_TYPE_REGISTRY`, `getMetadataTypeSchema('picklist')`) that loads before `object`. It is package-owned, so a runtime create or a per-organization overlay is refused. - **The reference.** `Field.select({ picklist: 'industry' })` adds a `picklist` key to `FieldSchema`. It is valid on the option types only (select, radio, multiselect, checkboxes, tags). A field that declares both `picklist` and `options` is refused at `options`, with a prescription. The functional-completeness predicate counts a `picklist` reference as the field's option source. -- **The served shape.** `PicklistServedFieldSchema` declares what a client reads for a picklist-bound field: the resolved `options` next to the `picklist` that names the list. This release does not resolve the reference. Until the runtime does, a picklist-bound field is served without options, and the liveness ledger grades the key `planned` and warns an author who writes it. +- **The served shape.** `PicklistServedFieldSchema` declares what a client reads for a picklist-bound field: the resolved `options` next to the `picklist` that names the list. The runtime resolves the reference onto that served field; see the picklist runtime entry of this release. - **Extensions.** `defineStack({ picklistExtensions: [{ extend, options }] })` adds options to a picklist that another package owns. It can only add; removing or renaming a value stays with the owning package. - **Translation.** `TranslationData` gains `picklists..{ label?, options: { value: label } }`. `translatePicklist` translates a served picklist item. `translateObject` gives a picklist-bound field the list's option labels, and a field-level `options` entry still wins over them. - **Studio type label.** `@objectstack/platform-objects` carries the `picklist` type's label and description in its metadata-forms translation bundles (en, zh-CN, ja-JP, es-ES). diff --git a/.changeset/19519-picklist-runtime.md b/.changeset/19519-picklist-runtime.md new file mode 100644 index 00000000000..2cb5af66447 --- /dev/null +++ b/.changeset/19519-picklist-runtime.md @@ -0,0 +1,17 @@ +--- +'@objectstack/objectql': minor +'@objectstack/metadata': patch +'@objectstack/spec': patch +--- + +feat(objectql): the runtime resolves a field's `picklist` onto its served options, validates writes against the resolved list, and merges `picklistExtensions` additively + +Clause-②: no + +- **Load.** `defineStack({ picklists })` and `defineStack({ picklistExtensions })` now register, from a manifest and from a nested plugin, through the same registration seam as every other collection. The compiled-artifact door registers `picklists` as `picklist` items, so `GET /meta/picklist` serves them on an artifact boot. +- **Merge.** A picklist's options are its own, followed by the options every `picklistExtensions` entry adds. A value the list already carries is refused with `422 INVALID_METADATA`, which names both declarations, whichever of the two registered first. The later declaration never replaces the earlier one. A package that registers again replaces its own extension. Uninstalling a package removes the values it added. +- **Serve.** A field with `picklist: 'NAME'` is served with the resolved options written onto it and `picklist` kept (`PicklistServedFieldSchema`), on every object read, including objects stored in `sys_metadata`. The list's translations (`picklists.NAME.options.VALUE`) relabel those options per request locale. An option marked `default: true` in the list fills an omitted field on insert, as an inline option does, and the import template reads it the same way. +- **Unknown name.** A packaged field that names a picklist no loaded package declares fails the boot at `kernel:ready` with `INVALID_METADATA`, and so does a `picklistExtensions` entry that extends such a list. The error names every such field or extension and the package that declared it. After boot, an artifact registered through the `manifest` service is checked before any of it registers. A field whose list does not resolve is served with no options and accepts no value. +- **Write validation.** The write door judges a picklist-bound field against the resolved options, and its refusal names the picklist. The wire code stays `invalid_option`. The validation message catalog gains three message keys for this (`invalid_option_picklist`, `invalid_option_value_picklist`, `invalid_option_picklist_unresolved`) in en, zh-CN, ja-JP and es-ES. They change the message text only, never the wire. +- **Writing the served body back.** The served body carries `picklist` and `options` together. Writing it back through the metadata door is still refused, with the prescription to drop `options`, as `FieldSchema` declares. Nothing strips it on the write side. +- **Ledger.** `field.picklist`, the `picklist` kind's rows and `translation.picklists` are `live`. `field.picklist` no longer carries `authorWarn`, so `os lint` / `os validate` stop warning an author who writes it. diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 5cbbb941ad7..500efa2e14a 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -1432,10 +1432,11 @@ describe('the object/field walk, against a synthetic ledger directory (#19268)', // If `field.json` ever warns again these two flip, and the block above // ("field walk: a malformed `fields` array …") can take its real subject back // — but this block keeps working either way, which is the point. - it('the SHIPPED field ledger warns on `picklist` alone — which is why the walk needs a subject of its own', () => { - // `picklist` is `planned` + `authorWarn` until the server resolves picklist - // references; the synthetic slot below is still warned by nothing shipped. - expect([...authorWarnedProperties('field')]).toEqual(['picklist']); + it('the SHIPPED field ledger warns on nothing — which is why the walk needs a subject of its own', () => { + // `picklist` was the last warned row (`planned` + `authorWarn`) and left + // when the server began resolving picklist references (`live`); the + // synthetic slot below is warned by nothing shipped either. + expect([...authorWarnedProperties('field')]).toEqual([]); expect( lintLivenessProperties({ objects: [{ name: 'widget', fields: [{ name: 'a', synthWarnedSlot: true }] }], diff --git a/packages/metadata-protocol/src/reference-sites.derivation.test.ts b/packages/metadata-protocol/src/reference-sites.derivation.test.ts index 809b5746b31..ab6c554a284 100644 --- a/packages/metadata-protocol/src/reference-sites.derivation.test.ts +++ b/packages/metadata-protocol/src/reference-sites.derivation.test.ts @@ -211,3 +211,14 @@ describe('[#9190] derived reference sites — derivation is a pure function of t expect(flatten(a)).toEqual(flatten(REFERENCE_SITES)); }); }); + +describe('a field\'s `picklist` is a reference site of the `picklist` kind', () => { + // The admin "Used by" panel and the delete-safety check read this index. A + // select field that names a shared list (`Field.select({ picklist })`) + // must count as a use of that list, or the list reads as safe to delete + // while objects take their options from it. Derived by the naming rule — + // the property spells the target — so no row exists to forget. + it('object.fields{}.picklist points at the picklist it names', () => { + expect(sitesFor('picklist')).toContainEqual({ fromType: 'object', property: 'picklist' }); + }); +}); diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index ba323d99081..55d8f93dd94 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -178,6 +178,12 @@ const ARTIFACT_FIELD_TO_TYPE: Record = { ragPipelines: 'rag_pipeline', hooks: 'hook', mappings: 'mapping', + // Shared option lists. Registered as items here so the artifact boot + // serves `GET /meta/picklist` like every other kind; what a FIELD is + // served with is resolved by the ObjectQL registry, which also merges + // `picklistExtensions` into the list they name — that collection is not a + // kind of its own and has no entry here (see check:stack-collection-maps). + picklists: 'picklist', analyticsCubes: 'analytics_cube', connectors: 'connector', emailTemplates: 'email_template', diff --git a/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts b/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts index 49ba8a96152..9982d657443 100644 --- a/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts +++ b/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts @@ -46,6 +46,7 @@ const REPRESENTATIVE: Record> = { hook: { name: 'account_audit', object: 'account', events: ['beforeInsert'], handler: 'audit_account' }, seed: { object: 'account', records: [{ name: 'Acme' }] }, mapping: { name: 'account_import', targetObject: 'account', fieldMapping: [] }, + picklist: { name: 'industry', label: 'Industry', options: [{ label: 'Technology', value: 'technology' }] }, datasource: { name: 'warehouse', driver: 'sqlite', config: {} }, analytics_cube: { name: 'account_cube', sql: 'account', measures: {}, dimensions: {} }, page: { name: 'account_home', label: 'Account Home', regions: [] }, @@ -81,7 +82,7 @@ const REPRESENTATIVE: Record> = { */ const BOUND_SCHEMA: Record = { object: 'ObjectSchema', field: 'FieldSchema', hook: 'HookSchema', seed: 'SeedSchema', - mapping: 'MappingSchema', datasource: 'DatasourceSchema', analytics_cube: 'CubeSchema', + mapping: 'MappingSchema', picklist: 'PicklistSchema', datasource: 'DatasourceSchema', analytics_cube: 'CubeSchema', page: 'PageSchema', dashboard: 'DashboardSchema', app: 'AppSchema', action: 'ActionSchema', report: 'ReportSchema', dataset: 'DatasetSchema', flow: 'FlowSchema', webhook: 'WebhookSchema', job: 'JobSchema', translation: 'TranslationItemSchema', email_template: 'EmailTemplateDefinitionSchema', diff --git a/packages/metadata/src/serializers/typescript-serializer.ts b/packages/metadata/src/serializers/typescript-serializer.ts index d381046351f..fed221fe193 100644 --- a/packages/metadata/src/serializers/typescript-serializer.ts +++ b/packages/metadata/src/serializers/typescript-serializer.ts @@ -53,6 +53,7 @@ const ANNOTATION_BY_METADATA_TYPE: ReadonlyMap'` instead of `options`; the + * registry resolves the list — its own options plus every + * `picklistExtensions` entry's — onto the served field, and the write door + * judges against that same field. Pinned here, one block per obligation: + * + * - the served shape (`options` resolved, `picklist` kept), on every read + * path the fold reaches, independent of the order things registered in; + * - the additive merge, and the refusal of a repeated value in either + * registration order (never last-wins); + * - the unknown-name audit (the boot refusal lives in + * `plugin-picklist-boot-audit.test.ts`); + * - write validation against the resolved set, the refusal naming the list, + * and a list that did not resolve accepting nothing; + * - a stale served copy never standing in for the list. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from './engine.js'; +import { ValidationError } from './validation/record-validator.js'; +import { describeUnresolvedPicklistReferences } from './picklist-resolution.js'; + +function makeStubDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string) { return Array.from(storeFor(object).values()); }, + async findOne(object: string) { return storeFor(object).values().next().value ?? null; }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update() { return null; }, + async upsert(object: string, data: Record) { return this.create(object, data); }, + async delete() { return true; }, + async count(object: string) { return storeFor(object).size; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async updateMany() { return 0; }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, stores }; +} + +const sys = { context: { isSystem: true } } as any; + +const INDUSTRY = { + name: 'industry', + label: 'Industry', + options: [ + { label: 'Technology', value: 'technology', default: true }, + { label: 'Finance', value: 'finance' }, + ], +}; + +const HEALTHCARE_EXTENSION = { extend: 'industry', options: [{ label: 'Healthcare', value: 'healthcare' }] }; + +const account = { + name: 'pk_account', + label: 'Account', + fields: { + name: { name: 'name', label: 'Name', type: 'text' as const }, + industry: { name: 'industry', label: 'Industry', type: 'select' as const, picklist: 'industry' }, + }, +}; + +const lead = { + name: 'pk_lead', + label: 'Lead', + fields: { + name: { name: 'name', label: 'Name', type: 'text' as const }, + industries: { name: 'industries', label: 'Industries', type: 'multiselect' as const, picklist: 'industry' }, + }, +}; + +/** The manifest of the package that owns the list and the first object. */ +const CORE = { + id: 'com.test.picklist.core', + name: 'core', + picklists: [INDUSTRY], + objects: [account], +}; + +/** A second package: adds a value to the list and binds a second object to it. */ +const HEALTH = { + id: 'com.test.picklist.health', + name: 'health', + picklistExtensions: [HEALTHCARE_EXTENSION], + objects: [lead], +}; + +async function bootEngine(): Promise { + const engine = new ObjectQL(); + engine.registerDriver(makeStubDriver().driver, true); + await engine.init(); + return engine; +} + +const values = (field: any) => (field?.options ?? []).map((o: any) => o.value); + +async function refusal(promise: Promise): Promise { + const err = await promise.then( + () => { throw new Error('expected the write to be refused'); }, + (e) => e, + ); + expect(err).toBeInstanceOf(ValidationError); + return err as ValidationError; +} + +describe('picklist — the served shape', () => { + let engine: ObjectQL; + beforeEach(async () => { + engine = await bootEngine(); + engine.registerApp(CORE); + engine.registerApp(HEALTH); + }); + + it('serves the field with the merged options and keeps `picklist`', () => { + const field: any = engine.registry.getObject('pk_account')!.fields.industry; + expect(field.picklist).toBe('industry'); + expect(values(field)).toEqual(['technology', 'finance', 'healthcare']); + // The option shape is carried verbatim, `default` included. + expect(field.options[0]).toEqual({ label: 'Technology', value: 'technology', default: true }); + }); + + it('resolves the SAME set onto a second object bound to the list', () => { + expect(values(engine.registry.getObject('pk_lead')!.fields.industries)).toEqual(['technology', 'finance', 'healthcare']); + }); + + it('serves the registry list through resolvePicklistOptions, and nothing for an unknown name', () => { + expect(engine.registry.resolvePicklistOptions('industry')!.map((o) => o.value)).toEqual(['technology', 'finance', 'healthcare']); + expect(engine.registry.resolvePicklistOptions('nope')).toBeUndefined(); + }); + + it('a body the registry never saw is resolved by the fold too — by reference when nothing is bound', () => { + const body = { name: 'loose', fields: { f: { name: 'f', type: 'select', picklist: 'industry' } } }; + const folded: any = engine.registry.foldObjectExtendersOnto('loose', body); + expect(values(folded.fields.f)).toEqual(['technology', 'finance', 'healthcare']); + const plain = { name: 'plain', fields: { f: { name: 'f', type: 'text' } } }; + expect(engine.registry.foldObjectExtendersOnto('plain', plain)).toBe(plain); + }); + + it('an object that binds no picklist is served exactly as before', () => { + engine.registry.registerObject({ name: 'pk_plain', fields: { s: { name: 's', type: 'select', options: [{ label: 'A', value: 'a' }] } } } as any, 'p'); + expect(values(engine.registry.getObject('pk_plain')!.fields.s)).toEqual(['a']); + expect((engine.registry.getObject('pk_plain')!.fields.s as any).picklist).toBeUndefined(); + }); +}); + +describe('picklist — load order', () => { + it('an object registered BEFORE its list still resolves once the list arrives', async () => { + const engine = await bootEngine(); + engine.registry.registerObject(account as any, 'com.test.early'); + // Read before the list exists: nothing to serve yet … + expect((engine.registry.getObject('pk_account')!.fields.industry as any).options).toBeUndefined(); + // … then the list and an extension land, in the "wrong" order. + engine.registry.registerPicklistExtension(HEALTHCARE_EXTENSION, 'com.test.health'); + engine.registry.registerItem('picklist', { ...INDUSTRY }, 'name', 'com.test.core'); + expect(values(engine.registry.getObject('pk_account')!.fields.industry)).toEqual(['technology', 'finance', 'healthcare']); + }); + + it('a nested plugin\'s lists register under the parent package, through the same seam', async () => { + const engine = await bootEngine(); + engine.registerApp({ id: 'com.test.nested', name: 'nested', objects: [account], plugins: [{ name: 'inner', picklists: [INDUSTRY], picklistExtensions: [HEALTHCARE_EXTENSION] }] }); + expect(values(engine.registry.getObject('pk_account')!.fields.industry)).toEqual(['technology', 'finance', 'healthcare']); + }); +}); + +describe('picklist — the additive merge refuses a repeated value', () => { + it('an extension repeating one of the list\'s own values', async () => { + const engine = await bootEngine(); + engine.registerApp(CORE); + const err: any = (() => { + try { engine.registerApp({ id: 'com.test.dup', name: 'dup', picklistExtensions: [{ extend: 'industry', options: [{ label: 'Fintech', value: 'finance' }] }] }); } + catch (e) { return e; } + return undefined; + })(); + expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 }); + expect(err.message).toContain("Picklist 'industry' would carry the value 'finance' twice"); + expect(err.message).toContain("package 'com.test.picklist.core'"); + expect(err.message).toContain("package 'com.test.dup'"); + // Not last-wins: the owner's option is untouched. + expect(engine.registry.resolvePicklistOptions('industry')).toEqual([ + { label: 'Technology', value: 'technology', default: true }, + { label: 'Finance', value: 'finance' }, + ]); + }); + + it('the list registering AFTER an extension that already added one of its values', async () => { + const engine = await bootEngine(); + engine.registry.registerPicklistExtension({ extend: 'industry', options: [{ label: 'Tech', value: 'technology' }] }, 'com.test.early'); + expect(() => engine.registry.registerItem('picklist', { ...INDUSTRY }, 'name', 'com.test.core')) + .toThrow(expect.objectContaining({ code: 'INVALID_METADATA', status: 422 })); + expect(engine.registry.resolvePicklistOptions('industry')).toBeUndefined(); + }); + + it('two packages\' extensions adding the same value', async () => { + const engine = await bootEngine(); + engine.registerApp(CORE); + engine.registerApp(HEALTH); + expect(() => engine.registry.registerPicklistExtension(HEALTHCARE_EXTENSION, 'com.test.other')) + .toThrow(/Picklist 'industry' would carry the value 'healthcare' twice/); + }); + + it('a value repeated inside one extension', async () => { + const engine = await bootEngine(); + engine.registerApp(CORE); + expect(() => engine.registry.registerPicklistExtension({ extend: 'industry', options: [{ label: 'X', value: 'x' }, { label: 'X2', value: 'x' }] }, 'p')) + .toThrow(expect.objectContaining({ code: 'INVALID_METADATA' })); + }); + + it('a package re-registering its own extension is a replay, not a duplicate', async () => { + const engine = await bootEngine(); + engine.registerApp(CORE); + engine.registerApp(HEALTH); + engine.registerApp(HEALTH); + expect(engine.registry.resolvePicklistOptions('industry')!.map((o) => o.value)).toEqual(['technology', 'finance', 'healthcare']); + }); + + it('uninstalling the extending package takes its values out of every bound field', async () => { + const engine = await bootEngine(); + engine.registerApp(CORE); + engine.registry.registerPicklistExtension(HEALTHCARE_EXTENSION, 'com.test.health'); + engine.registry.unregisterItemsByPackage('com.test.health'); + expect(values(engine.registry.getObject('pk_account')!.fields.industry)).toEqual(['technology', 'finance']); + }); +}); + +describe('picklist — an unknown name', () => { + it('names every unresolved field with the package that declared it', async () => { + const engine = await bootEngine(); + engine.registerApp({ id: 'com.test.broken', name: 'broken', objects: [account, lead] }); + const unresolved = engine.registry.findUnresolvedPicklistReferences(); + expect(unresolved).toEqual([ + { object: 'pk_account', field: 'industry', picklist: 'industry', packageId: 'com.test.broken' }, + { object: 'pk_lead', field: 'industries', picklist: 'industry', packageId: 'com.test.broken' }, + ]); + const err = describeUnresolvedPicklistReferences(unresolved)!; + expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 }); + expect(err.message).toContain("field 'pk_account.industry' (package 'com.test.broken') references picklist 'industry'"); + // Never served as a select with an empty list. + expect((engine.registry.getObject('pk_account')!.fields.industry as any).options).toBeUndefined(); + }); + + it('an extension field from another package is attributed to THAT package', async () => { + const engine = await bootEngine(); + engine.registerApp({ id: 'com.test.owner', name: 'owner', objects: [{ name: 'pk_x', fields: { a: { name: 'a', type: 'text' } } }] }); + engine.registerApp({ + id: 'com.test.ext', name: 'ext', + objectExtensions: [{ extend: 'pk_x', fields: { tier: { name: 'tier', type: 'select', picklist: 'tier' } } }], + }); + expect(engine.registry.findUnresolvedPicklistReferences()).toEqual([ + { object: 'pk_x', field: 'tier', picklist: 'tier', packageId: 'com.test.ext' }, + ]); + }); + + it('a tenant overlay never fails the audit — the write door refuses it instead', async () => { + const engine = await bootEngine(); + engine.registry.registerObject({ name: 'pk_tenant', fields: { f: { name: 'f', type: 'select', picklist: 'gone' } } } as any, undefined, undefined, 'overlay'); + expect(engine.registry.findUnresolvedPicklistReferences()).toEqual([]); + }); + + it('an extension of a list nothing declares is reported with its package — its values would go nowhere', async () => { + const engine = await bootEngine(); + engine.registerApp({ id: 'com.test.orphan', name: 'orphan', picklistExtensions: [{ extend: 'industy', options: [{ label: 'X', value: 'x' }] }] }); + const orphans = engine.registry.findOrphanPicklistExtensions(); + expect(orphans).toEqual([{ picklist: 'industy', packageId: 'com.test.orphan' }]); + expect(describeUnresolvedPicklistReferences([], orphans)!.message) + .toContain("a `picklistExtensions` entry (package 'com.test.orphan') extends picklist 'industy'"); + }); + + it('nothing is reported once every list resolves', async () => { + const engine = await bootEngine(); + engine.registerApp(CORE); + engine.registerApp(HEALTH); + expect(engine.registry.findUnresolvedPicklistReferences()).toEqual([]); + expect(engine.registry.findOrphanPicklistExtensions()).toEqual([]); + }); +}); + +describe('picklist — the write door judges the resolved set', () => { + let engine: ObjectQL; + beforeEach(async () => { + engine = await bootEngine(); + engine.registerApp(CORE); + engine.registerApp(HEALTH); + }); + + it('accepts the list\'s own value and an extension\'s value', async () => { + expect(((await engine.insert('pk_account', { name: 'A', industry: 'finance' }, sys)) as any).industry).toBe('finance'); + expect(((await engine.insert('pk_account', { name: 'B', industry: 'healthcare' }, sys)) as any).industry).toBe('healthcare'); + expect(((await engine.insert('pk_lead', { name: 'L', industries: ['healthcare', 'technology'] }, sys)) as any).industries).toEqual(['healthcare', 'technology']); + }); + + it('refuses a value outside the set, and the refusal names the picklist', async () => { + const err = await refusal(engine.insert('pk_lead', { name: 'L', industries: ['retail'] }, sys)); + expect(err.code).toBe('VALIDATION_FAILED'); + expect(err.fields[0]).toMatchObject({ field: 'industries', code: 'invalid_option', options: ['technology', 'finance', 'healthcare'] }); + expect(err.fields[0].message).toBe('Industries: "retail" is not a value of picklist "industry": technology, finance, healthcare'); + const single = await refusal(engine.insert('pk_account', { name: 'A', industry: 'retail' }, sys)); + expect(single.fields[0]).toMatchObject({ field: 'industry', code: 'invalid_option' }); + expect(single.fields[0].message).toBe('Industry must be one of the values of picklist "industry": technology, finance, healthcare'); + }); + + it('fills an omitted field from the list\'s option marked `default: true`', async () => { + expect(((await engine.insert('pk_account', { name: 'D' }, sys)) as any).industry).toBe('technology'); + }); + + it('a field whose list does not resolve accepts NO value — never read as free-form', async () => { + // Outside a kernel nothing audits the boot, so the engine is the last door. + const bare = await bootEngine(); + bare.registry.registerObject({ name: 'pk_orphan', fields: { f: { name: 'f', label: 'F', type: 'select', picklist: 'gone' } } } as any, 'p'); + const err = await refusal(bare.insert('pk_orphan', { f: 'anything' }, sys)); + expect(err.fields[0]).toMatchObject({ field: 'f', code: 'invalid_option' }); + expect(err.fields[0].message).toBe('F takes its values from picklist "gone", which no loaded package declares, so no value can be accepted'); + }); +}); + +describe('picklist — a stale served copy never stands in for the list', () => { + it('a body already carrying options beside `picklist` gets the CURRENT list', async () => { + const engine = await bootEngine(); + engine.registerApp(CORE); + const stale = { name: 'pk_account', fields: { industry: { name: 'industry', type: 'select', picklist: 'industry', options: [{ label: 'Old', value: 'old' }] } } }; + expect(values((engine.registry.foldObjectExtendersOnto('pk_account', stale) as any).fields.industry)).toEqual(['technology', 'finance']); + }); + + it('…and loses them when the list is gone, so the write door refuses instead of accepting a stale value', async () => { + const engine = await bootEngine(); + const stale = { name: 'pk_s', fields: { f: { name: 'f', type: 'select', picklist: 'gone', options: [{ label: 'Old', value: 'old' }] } } }; + const folded: any = engine.registry.foldObjectExtendersOnto('pk_s', stale); + expect('options' in folded.fields.f).toBe(false); + expect(folded.fields.f.picklist).toBe('gone'); + }); + + it('handles the array spelling of `fields` a served body may carry', async () => { + const engine = await bootEngine(); + engine.registerApp(CORE); + const body = { name: 'pk_arr', fields: [{ name: 'f', type: 'select', picklist: 'industry' }, { name: 'g', type: 'text' }] }; + const folded: any = engine.registry.foldObjectExtendersOnto('pk_arr', body); + expect(values(folded.fields[0])).toEqual(['technology', 'finance']); + expect(folded.fields[1]).toBe(body.fields[1]); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 743ff917c84..76deb410bd8 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -2887,6 +2887,14 @@ export type HeldFileResolver = ( * That row is gone with the divergence. */ const METADATA_ARRAY_KEYS = [ + // Data Protocol — shared option lists. FIRST, so a source's lists are in the + // registry before anything later in this loop reads them; objects do not + // depend on the order (they resolve a list lazily, on the next fold), so this + // is the declared loading order (`picklist` loads before `object`), not a + // precondition. Both are dispatched to their own registry verbs in + // `registerMetadataCollections`: the extensions merge into the list they + // name instead of registering as items of their own. + 'picklists', 'picklistExtensions', // UI Protocol 'actions', 'views', 'pages', 'dashboards', 'reports', 'datasets', 'themes', // Automation Protocol @@ -6783,6 +6791,13 @@ export class ObjectQL implements IObjectQLEngine { const items = (source as any)?.[key]; if (!Array.isArray(items) || items.length === 0) continue; this.logger.debug(`Registering ${key} from ${sourceLabel}`, { id: ownerId, count: items.length }); + // A `picklistExtensions` entry is not an item: it has no name, only + // the list it adds to, and the registry merges it there — additive + // only, a repeated value refused loudly (`registerPicklistExtension`). + if (key === 'picklistExtensions') { + for (const extension of items) this._registry.registerPicklistExtension(extension, ownerId); + continue; + } for (const item of items) { const itemName = resolveMetadataItemName(key, item); if (!itemName) { diff --git a/packages/objectql/src/picklist-resolution.ts b/packages/objectql/src/picklist-resolution.ts new file mode 100644 index 00000000000..5f627c2c282 --- /dev/null +++ b/packages/objectql/src/picklist-resolution.ts @@ -0,0 +1,280 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Shared picklists at runtime — the judgments the registry and the boot audit + * share, kept pure so each has ONE spelling. + * + * A select field may author `picklist: ''` instead of `options` + * (`FieldSchema.picklist`, `data/picklist.zod.ts`). The runtime owes three + * things for it, and every one of them is answered from the ONE resolved list + * this module builds: + * + * 1. **Merge.** A picklist's options are its own, followed by what every + * `picklistExtensions` entry adds. Additive only: a value the list already + * carries is REFUSED, never overwritten — last-wins would let a second + * package silently relabel or recolour a value the owner declared + * ({@link findDuplicatePicklistValue}). + * 2. **Serve.** A field bound to a picklist is served with the resolved + * options written onto it and `picklist` kept, the + * `PicklistServedFieldSchema` shape ({@link resolvePicklistFieldsOnto}). + * The write door reads the same served field, so the set a client is + * offered and the set a write is judged by cannot diverge. + * 3. **Refuse an unknown name.** A field naming a list no loaded package + * declares is a load-time error naming the field and the package, never a + * field served with no options ({@link describeUnresolvedPicklistReferences}). + * + * The resolved field is RE-DERIVED on every fold, never trusted: a body that + * already carries `options` beside `picklist` (a served copy written back + * into the metadata service by the boot bridge) has them replaced by the + * current list, and has them REMOVED when the list is gone. A stale copy can + * therefore never widen what a write accepts — with no list, the record + * validator refuses every value of the field instead of accepting any. + */ + +/** One option, as `SelectOptionSchema` declares it (only `value` is read here). */ +export interface PicklistOptionLike { + value: string | number | boolean; + label?: string; + [key: string]: unknown; +} + +/** One contribution to a picklist's options: the owning list's own, or one extension's. */ +export interface PicklistContribution { + /** The package that declared these options; `undefined` for a bare registration. */ + packageId: string | undefined; + /** `'picklist'` for the owning list, `'extension'` for a `picklistExtensions` entry. */ + kind: 'picklist' | 'extension'; + options: readonly PicklistOptionLike[]; +} + +/** One field that names a picklist, with the package that declared the field. */ +export interface PicklistReference { + object: string; + field: string; + picklist: string; + packageId: string | undefined; +} + +/** The ADR-0112 envelope every refusal here is thrown in — the code the registration seams already use. */ +export interface PicklistMetadataError extends Error { + code: 'INVALID_METADATA'; + status: 422; + httpStatus: 422; +} + +export function picklistMetadataError(message: string): PicklistMetadataError { + const err = new Error(message) as PicklistMetadataError; + err.code = 'INVALID_METADATA'; + err.status = 422; + err.httpStatus = 422; + return err; +} + +function describePackage(packageId: string | undefined): string { + return packageId ? `package '${packageId}'` : 'a registration with no package'; +} + +function describeContribution(c: PicklistContribution): string { + return c.kind === 'picklist' + ? `the picklist itself (${describePackage(c.packageId)})` + : `an extension from ${describePackage(c.packageId)}`; +} + +/** + * The first value two contributions both declare, in merge order — or + * `undefined` when every value is distinct. Values compare by their string + * form, the form the record validator matches a written value against. + * + * A value repeated INSIDE one contribution counts too: two entries for one + * value in a single list are the same ambiguity (which label, which colour) + * whoever declared them. + */ +export function findDuplicatePicklistValue( + contributions: readonly PicklistContribution[], +): { value: string; first: PicklistContribution; second: PicklistContribution } | undefined { + const seen = new Map(); + for (const contribution of contributions) { + for (const option of contribution.options) { + if (!option || typeof option !== 'object' || option.value === undefined) continue; + const value = String(option.value); + const first = seen.get(value); + if (first) return { value, first, second: contribution }; + seen.set(value, contribution); + } + } + return undefined; +} + +/** The refusal for a value the list already carries, naming both declarations. */ +export function duplicatePicklistValueError( + picklist: string, + duplicate: { value: string; first: PicklistContribution; second: PicklistContribution }, +): PicklistMetadataError { + return picklistMetadataError( + `Picklist '${picklist}' would carry the value '${duplicate.value}' twice: it is declared by ` + + `${describeContribution(duplicate.first)} and again by ${describeContribution(duplicate.second)}. ` + + 'A picklist merges its extensions additively, so a value already in the list cannot be added ' + + 'again — the later declaration does not replace the earlier one. Remove the repeated value, or ' + + 'give the new option a value of its own.', + ); +} + +/** The merged options of a picklist: every contribution's, in order. */ +export function mergePicklistOptions(contributions: readonly PicklistContribution[]): PicklistOptionLike[] { + const merged: PicklistOptionLike[] = []; + for (const contribution of contributions) { + for (const option of contribution.options) merged.push({ ...option }); + } + return merged; +} + +function resolveField(field: unknown, resolve: (picklist: string) => readonly PicklistOptionLike[] | undefined): unknown { + if (!field || typeof field !== 'object') return field; + const picklist = (field as { picklist?: unknown }).picklist; + if (typeof picklist !== 'string' || picklist.length === 0) return field; + const options = resolve(picklist); + if (options) return { ...(field as object), options: options.map((o) => ({ ...o })) }; + // Unresolved: drop any options the body carried, so a stale served copy + // cannot stand in for the list. The validator refuses every value of a + // picklist-bound field that has none. + if (!('options' in (field as object))) return field; + const { options: _stale, ...rest } = field as Record; + return rest; +} + +/** + * Write each picklist-bound field's resolved options onto an object body. + * + * Returns the body BY REFERENCE when it has no picklist-bound field, so every + * object that does not use the feature is served byte-identically. Handles + * both field spellings a served body carries (a name-keyed record, an array). + */ +export function resolvePicklistFieldsOnto( + body: T, + resolve: (picklist: string) => readonly PicklistOptionLike[] | undefined, +): T { + if (!body || typeof body !== 'object') return body; + const fields = (body as { fields?: unknown }).fields; + if (!fields || typeof fields !== 'object') return body; + const bound = (f: unknown): boolean => + !!f && typeof f === 'object' && typeof (f as { picklist?: unknown }).picklist === 'string'; + if (Array.isArray(fields)) { + if (!fields.some(bound)) return body; + return { ...(body as object), fields: fields.map((f) => resolveField(f, resolve)) } as T; + } + const entries = Object.entries(fields as Record); + if (!entries.some(([, f]) => bound(f))) return body; + const next: Record = {}; + for (const [name, f] of entries) next[name] = resolveField(f, resolve); + return { ...(body as object), fields: next } as T; +} + +/** Every field of an object body that names a picklist. */ +export function collectPicklistReferences( + object: string, + body: unknown, + packageId: string | undefined, +): PicklistReference[] { + const refs: PicklistReference[] = []; + const fields = (body as { fields?: unknown } | undefined)?.fields; + if (!fields || typeof fields !== 'object') return refs; + const entries: Array<[string, unknown]> = Array.isArray(fields) + ? fields.map((f) => [String((f as { name?: unknown })?.name ?? ''), f]) + : Object.entries(fields as Record); + for (const [field, def] of entries) { + const picklist = (def as { picklist?: unknown } | undefined)?.picklist; + if (typeof picklist === 'string' && picklist.length > 0) refs.push({ object, field, picklist, packageId }); + } + return refs; +} + +/** A `picklistExtensions` entry whose `extend` names a list no loaded package declares. */ +export interface OrphanPicklistExtension { + picklist: string; + packageId: string | undefined; +} + +/** + * The load-time refusal for picklist names nothing declares — every field + * that references one, and every `picklistExtensions` entry that extends one, + * so an author fixes the whole set in one pass. `undefined` when there are + * none. + * + * An extension of an undeclared list is refused for the same reason the + * field is: its options would otherwise be held for a list that never + * arrives, and the values an author added would silently go nowhere. + */ +export function describeUnresolvedPicklistReferences( + unresolved: readonly PicklistReference[], + orphanExtensions: readonly OrphanPicklistExtension[] = [], +): PicklistMetadataError | undefined { + if (unresolved.length === 0 && orphanExtensions.length === 0) return undefined; + const lines = [ + ...unresolved.map( + (r) => `field '${r.object}.${r.field}' (${describePackage(r.packageId)}) references picklist '${r.picklist}'`, + ), + ...orphanExtensions.map( + (e) => `a \`picklistExtensions\` entry (${describePackage(e.packageId)}) extends picklist '${e.picklist}'`, + ), + ]; + return picklistMetadataError( + `${lines.length === 1 ? 'A picklist is named' : `${lines.length} picklist names are used`} ` + + `that no loaded package declares: ${lines.join('; ')}. A field bound to a picklist takes its options ` + + 'from that list and has none of its own, and an extension adds options to a list that must exist, so ' + + 'neither can take effect until the list does. Declare the picklist (a `*.picklist.ts` file, or ' + + '`defineStack({ picklists })`) in the package or in one it depends on, or correct the name.', + ); +} + +/** A manifest's `objects` in either authored spelling, as `[name, body]` pairs. */ +function manifestObjects(source: any): Array<[string, unknown]> { + const objects = source?.objects; + if (Array.isArray(objects)) return objects.map((o: any) => [String(o?.name ?? ''), o]); + if (objects && typeof objects === 'object') return Object.entries(objects); + return []; +} + +/** The manifest itself and its nested `plugins[]`, which register under its package (`registerPlugin`). */ +function manifestSources(manifest: any): any[] { + const plugins = Array.isArray(manifest?.plugins) ? manifest.plugins.filter((p: unknown) => p && typeof p === 'object') : []; + return [manifest, ...plugins]; +} + +/** + * The picklist references one manifest brings — its objects', its + * `objectExtensions`' and its nested plugins' fields — attributed to the + * package that registers them. Read before the manifest is registered, by the + * post-boot install door. + */ +export function collectManifestPicklistReferences(manifest: any, packageId: string | undefined): PicklistReference[] { + const refs: PicklistReference[] = []; + for (const source of manifestSources(manifest)) { + for (const [name, body] of manifestObjects(source)) refs.push(...collectPicklistReferences(name, body, packageId)); + for (const ext of Array.isArray(source?.objectExtensions) ? source.objectExtensions : []) { + refs.push(...collectPicklistReferences(String(ext?.extend ?? ''), ext, packageId)); + } + } + return refs; +} + +/** The picklist names one manifest declares, nested plugins included. */ +export function collectManifestPicklistNames(manifest: any): Set { + const names = new Set(); + for (const source of manifestSources(manifest)) { + for (const p of Array.isArray(source?.picklists) ? source.picklists : []) { + if (typeof p?.name === 'string') names.add(p.name); + } + } + return names; +} + +/** The `picklistExtensions` targets one manifest brings, nested plugins included. */ +export function collectManifestPicklistExtensions(manifest: any, packageId: string | undefined): OrphanPicklistExtension[] { + const out: OrphanPicklistExtension[] = []; + for (const source of manifestSources(manifest)) { + for (const ext of Array.isArray(source?.picklistExtensions) ? source.picklistExtensions : []) { + if (typeof ext?.extend === 'string') out.push({ picklist: ext.extend, packageId }); + } + } + return out; +} diff --git a/packages/objectql/src/plugin-picklist-boot-audit.test.ts b/packages/objectql/src/plugin-picklist-boot-audit.test.ts new file mode 100644 index 00000000000..937a65b1798 --- /dev/null +++ b/packages/objectql/src/plugin-picklist-boot-audit.test.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A field naming a picklist no package declares is a LOAD-TIME error that + * names the field and the package — never a select served with no options. + * + * Judged at `kernel:ready`, when every package has registered: a list another + * package declares later in the same boot must not read as missing (AGENTS.md, + * startup registry reads). After that the vocabulary is sealed, and an + * artifact registered through the `manifest` service is judged before any of + * it registers. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectKernel, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin } from './plugin.js'; +import type { ObjectQL } from './engine.js'; + +/** The slice of the `manifest` service these cases call. */ +interface ManifestService { + register(artifact: unknown): Promise | void; +} + +const kernels: ObjectKernel[] = []; +afterEach(async () => { + while (kernels.length) { + const kernel = kernels.pop()!; + try { if (kernel.getState() === 'running') await kernel.shutdown(); } catch { /* noop */ } + } +}); + +/** A plugin that registers each artifact through the `manifest` service during init. */ +function registering(name: string, artifacts: unknown[]) { + return { + name, + dependencies: ['com.objectstack.engine.objectql'], + init: async (ctx: PluginContext) => { + const manifest = ctx.getService('manifest'); + for (const artifact of artifacts) await manifest.register(artifact); + }, + }; +} + +function kernelWith(...plugins: any[]) { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + kernels.push(kernel); + return { kernel, use: async () => { for (const p of plugins) await kernel.use(p); } }; +} + +/** The option values a registered object's field is served with. */ +const servedValues = (ql: ObjectQL, object: string, field: string): unknown[] => + ((ql.registry.getObject(object)?.fields?.[field] as { options?: Array<{ value: unknown }> } | undefined)?.options ?? []) + .map((o) => o.value); + +const INDUSTRY = { name: 'industry', label: 'Industry', options: [{ label: 'Technology', value: 'technology' }] }; +const ACCOUNT = { + name: 'pb_account', + fields: { industry: { name: 'industry', type: 'select', picklist: 'industry' } }, +}; + +describe('the picklist boot audit', () => { + it('fails the boot naming the field and the package', async () => { + const { kernel, use } = kernelWith(new ObjectQLPlugin(), registering('app', [{ id: 'com.test.boot.app', name: 'app', objects: [ACCOUNT] }])); + await use(); + const err: any = await kernel.bootstrap().then(() => undefined, (e) => e); + expect(err).toBeDefined(); + expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 }); + expect(err.message).toContain("field 'pb_account.industry' (package 'com.test.boot.app') references picklist 'industry'"); + }); + + it('fails the boot on an extension of a list nothing declares', async () => { + const { kernel, use } = kernelWith( + new ObjectQLPlugin(), + registering('ext', [{ id: 'com.test.boot.ext', name: 'ext', picklistExtensions: [{ extend: 'industy', options: [{ label: 'X', value: 'x' }] }] }]), + ); + await use(); + const err: any = await kernel.bootstrap().then(() => undefined, (e) => e); + expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 }); + expect(err.message).toContain("a `picklistExtensions` entry (package 'com.test.boot.ext') extends picklist 'industy'"); + }); + + it('boots when the list arrives from a package registered LATER in the same boot', async () => { + const { kernel, use } = kernelWith( + new ObjectQLPlugin(), + registering('app', [{ id: 'com.test.boot.app', name: 'app', objects: [ACCOUNT] }]), + registering('lists', [{ id: 'com.test.boot.lists', name: 'lists', picklists: [INDUSTRY] }]), + ); + await use(); + await kernel.bootstrap(); + const ql = kernel.getService('objectql'); + expect(servedValues(ql, 'pb_account', 'industry')).toEqual(['technology']); + }); + + it('after the boot, an artifact naming an unknown list is refused before ANY of it registers', async () => { + const { kernel, use } = kernelWith(new ObjectQLPlugin()); + await use(); + await kernel.bootstrap(); + const manifest = kernel.getService('manifest'); + expect(() => manifest.register({ id: 'com.test.late', name: 'late', objects: [ACCOUNT] })) + .toThrow(/field 'pb_account\.industry' \(package 'com\.test\.late'\) references picklist 'industry'/); + const ql = kernel.getService('objectql'); + expect(ql.registry.getObject('pb_account')).toBeUndefined(); + }); + + it('after the boot, an artifact extending an unknown list is refused before ANY of it registers', async () => { + const { kernel, use } = kernelWith(new ObjectQLPlugin()); + await use(); + await kernel.bootstrap(); + const manifest = kernel.getService('manifest'); + expect(() => manifest.register({ id: 'com.test.late', name: 'late', picklistExtensions: [{ extend: 'industy', options: [{ label: 'X', value: 'x' }] }] })) + .toThrow(/extends picklist 'industy'/); + const ql = kernel.getService('objectql'); + expect(ql.registry.findOrphanPicklistExtensions()).toEqual([]); + }); + + it('after the boot, an artifact that brings its own list — or names a registered one — registers', async () => { + const { kernel, use } = kernelWith(new ObjectQLPlugin(), registering('lists', [{ id: 'com.test.boot.lists', name: 'lists', picklists: [INDUSTRY] }])); + await use(); + await kernel.bootstrap(); + const manifest = kernel.getService('manifest'); + await manifest.register({ id: 'com.test.late', name: 'late', objects: [ACCOUNT] }); + await manifest.register({ + id: 'com.test.late2', name: 'late2', + picklists: [{ name: 'tier', label: 'Tier', options: [{ label: 'Gold', value: 'gold' }] }], + objects: [{ name: 'pb_member', fields: { tier: { name: 'tier', type: 'select', picklist: 'tier' } } }], + }); + const ql = kernel.getService('objectql'); + expect(servedValues(ql, 'pb_account', 'industry')).toEqual(['technology']); + expect(servedValues(ql, 'pb_member', 'tier')).toEqual(['gold']); + }); +}); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 6530671021e..b5e39e9cd0a 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -25,6 +25,12 @@ import { } from './action-activation.js'; import type { IMetadataService } from '@objectstack/spec/contracts'; import type { ServiceObject } from '@objectstack/spec/data'; +import { + collectManifestPicklistExtensions, + collectManifestPicklistNames, + collectManifestPicklistReferences, + describeUnresolvedPicklistReferences, +} from './picklist-resolution.js'; export type { Plugin, PluginContext }; @@ -262,6 +268,14 @@ export class ObjectQLPlugin implements Plugin { * the fix off in marketplace install-local's primary home. */ private bridgeLateManifests = false; + + /** + * Set at `kernel:ready`, once the boot audit has found every picklist a + * field names: from then on an artifact registered through the `manifest` + * service is judged before it registers (AGENTS.md, startup registry reads: + * seal the vocabulary, then judge). + */ + private picklistVocabularySealed = false; /** Unsubscribe handles for metadata-event subscriptions (ADR-0008 PR-7). */ private metadataUnsubscribes: Array<() => void> = []; /** ADR-0057 lifecycle enforcement (Reaper/Rotator/Archiver). */ @@ -454,6 +468,25 @@ export class ObjectQLPlugin implements Plugin { .filter((id): id is string => id !== undefined), }; + // Once the boot has sealed the picklist vocabulary (`kernel:ready`, + // below), an artifact arriving later is judged BEFORE anything of it + // registers: every field it brings that names a picklist must resolve + // against the registry or against a list the artifact itself declares. + // A refused install registers nothing. + if (this.picklistVocabularySealed) { + const declared = new Set(); + for (const manifest of ordered) for (const name of collectManifestPicklistNames(manifest)) declared.add(name); + const known = (name: string) => declared.has(name) || ql.registry.resolvePicklistOptions(name) !== undefined; + const unresolved = ordered + .flatMap((manifest) => collectManifestPicklistReferences(manifest, artifactPackageId(manifest))) + .filter((ref) => !known(ref.picklist)); + const orphans = ordered + .flatMap((manifest) => collectManifestPicklistExtensions(manifest, artifactPackageId(manifest))) + .filter((ext) => !known(ext.picklist)); + const refusal = describeUnresolvedPicklistReferences(unresolved, orphans); + if (refusal) throw refusal; + } + for (const manifest of ordered) { ql.registerApp(manifest, scope); ctx.logger.debug('Manifest registered via manifest service', { @@ -565,6 +598,20 @@ export class ObjectQLPlugin implements Plugin { // Idempotent: the bind fully replaces the 'metadata-service' package // set, so edited hooks re-bind and deleted hooks tear down. ctx.hook('kernel:ready', async () => { + // A field naming a picklist no package declares fails the boot, naming + // the field and the package — never served as a select with nothing to + // choose — and so does an extension of such a list, whose values would + // otherwise go nowhere. Judged HERE because every package has registered by now, so + // "not declared" is final; a list a later package declares would + // otherwise read as missing (AGENTS.md, startup registry reads). From + // here on the vocabulary is sealed and each late artifact is judged as + // it arrives (the `manifest` service above). + const unresolvedPicklists = describeUnresolvedPicklistReferences( + this.ql?.registry.findUnresolvedPicklistReferences() ?? [], + this.ql?.registry.findOrphanPicklistExtensions() ?? [], + ); + if (unresolvedPicklists) throw unresolvedPicklists; + this.picklistVocabularySealed = true; // #7737 — FIRST, before anything that might read data: bind every // declared federated object to its remote table now that every // plugin's `start()` (including the declared-datasource auto-connect diff --git a/packages/objectql/src/protocol-picklist-served-roundtrip.test.ts b/packages/objectql/src/protocol-picklist-served-roundtrip.test.ts new file mode 100644 index 00000000000..33a341f2931 --- /dev/null +++ b/packages/objectql/src/protocol-picklist-served-roundtrip.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The served shape of a picklist-bound field at the protocol's object read, + * and what the authoring door does when that served body is written back. + * + * The read serves `picklist` AND the resolved `options` + * (`PicklistServedFieldSchema`). The write door is an AUTHORING door, and + * `FieldSchema` refuses the two keys together — so a PUT of the served body is + * refused, loudly, with the prescription to drop `options`. That is the + * behaviour the spec declares for the served shape; this file pins that the + * runtime keeps it (no write-side strip), and that the refusal is the + * ADR-0112 `INVALID_METADATA` envelope rather than a silent rewrite. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { ObjectQL } from './engine.js'; + +const sysMetadataObject = { + name: 'sys_metadata', + label: 'System Metadata', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + type: { name: 'type', label: 'Type', type: 'text' as const, required: true }, + name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, + // [#8682] The real `sys_metadata` carries this — it is part of the + // row's uniqueness key `(type, name, organization_id, package_id)` and + // `SysMetadataRepository` writes it — but this minimal stub had omitted + // it. Nothing noticed while an undeclared write key simply travelled to + // the driver; the declared-field door judges the payload against this + // map, so the omission now shows up as the fixture defect it always was. + package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, + metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, + state: { name: 'state', label: 'State', type: 'text' as const }, + version: { name: 'version', label: 'Version', type: 'number' as const }, + created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const }, + updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const }, + }, +}; + +/** + * Minimal stub driver covering only what `SysMetadataRepository` + * exercises. Equality-only WHERE evaluation; one record store per object. + */ +function makeStubDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them. The short-circuiting shape this stub used to carry + // (`if ($or) return $or.some(...)`) discarded every sibling equality key in + // the same object, so a query like + // `{ state:'draft', package_id, $or:[{organization_id:ORG},{organization_id:null}] }` + // was silently answered on the `$or` alone — a different query than the one + // written, with the suite still green. See #7620. + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) + ? (v as any).$eq + : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + + const driver: any = { + name: 'memory', + version: '0.0.0', + supports: {} as any, + async connect() {}, + async disconnect() {}, + async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { + return storeFor(object).delete(id); + }, + async count(object: string, ast: any) { + return (await this.find(object, ast)).length; + }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, + async rollback() {}, + }; + return { driver, stores }; +} + + +describe('picklist — the protocol object read and the authoring door', () => { + let engine: ObjectQL; + let protocol: ObjectStackProtocolImplementation; + const authored = { + name: 'pp_obj', label: 'Probe', sharingModel: 'public_read_write', + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + industry: { name: 'industry', type: 'select', label: 'Industry', picklist: 'industry' }, + }, + }; + + beforeEach(async () => { + engine = new ObjectQL(); + engine.registerDriver(makeStubDriver().driver, true); + await engine.init(); + engine.registry.registerObject(sysMetadataObject); + engine.registerApp({ + id: 'com.test.lists', name: 'lists', + picklists: [{ name: 'industry', label: 'Industry', options: [{ label: 'Tech', value: 'tech' }] }], + }); + engine.registerApp({ id: 'com.test.more', name: 'more', picklistExtensions: [{ extend: 'industry', options: [{ label: 'Health', value: 'health' }] }] }); + protocol = new ObjectStackProtocolImplementation(engine); + }); + + it('an authored body (`picklist`, no `options`) saves, and the read serves the resolved options beside `picklist`', async () => { + await expect(protocol.saveMetaItem({ type: 'object', name: 'pp_obj', item: authored } as any)).resolves.toMatchObject({ success: true }); + const served: any = await protocol.getMetaItem({ type: 'object', name: 'pp_obj' } as any); + expect(served.item.fields.industry).toMatchObject({ + picklist: 'industry', + options: [{ label: 'Tech', value: 'tech' }, { label: 'Health', value: 'health' }], + }); + }); + + it('writing the SERVED body back is refused 422 INVALID_METADATA, naming the two keys — never stripped silently', async () => { + await protocol.saveMetaItem({ type: 'object', name: 'pp_obj', item: authored } as any); + const served: any = await protocol.getMetaItem({ type: 'object', name: 'pp_obj' } as any); + const err: any = await protocol.saveMetaItem({ type: 'object', name: 'pp_obj', item: served.item } as any).then(() => undefined, (e) => e); + expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 }); + expect(err.message).toContain('`picklist` and `options` cannot both be declared'); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 9ea529d0526..bbf2a1b3773 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -64,6 +64,19 @@ import { formatNavContributionGroupDiagnostic, type NavContributionGroupDiagnostic, } from './nav-contribution-diagnostics.js'; +// The shared-picklist judgments (merge, served shape, unknown-name refusal) — +// pure, so the registry and the engine plugin's load-time audit read one +// spelling of each. See the module header. +import { + collectPicklistReferences, + duplicatePicklistValueError, + findDuplicatePicklistValue, + mergePicklistOptions, + resolvePicklistFieldsOnto, + type PicklistContribution, + type PicklistOptionLike, + type PicklistReference, +} from './picklist-resolution.js'; /** * Reserved namespaces that do not get FQN prefix applied. @@ -2049,6 +2062,18 @@ export class SchemaRegistry { */ private appNavContributions = new Map>(); + /** + * `picklistExtensions` — the options other packages ADD to a picklist, + * keyed `target picklist → declaring package → value → option`. + * + * Keyed by package so a re-registration of the same package (a manifest + * replay, an HMR rebuild) replaces its own contribution instead of + * colliding with it, while a value two DIFFERENT contributors declare is + * refused ({@link registerPicklistExtension}). The owning list itself lives + * in the generic item store under type `picklist`, like every other kind. + */ + private picklistExtensionContributions = new Map }>>(); + /** * Package ids that must be installed in a DISABLED state **when they have no * row yet**. Seeded once at boot (from persisted state) BEFORE any package @@ -2434,7 +2459,10 @@ export class SchemaRegistry { merged = mergeObjectDefinitions(merged, contrib.definition, tenantAuthored); } } - return merged; + // A picklist-bound field is served with its list's options resolved onto + // it — here, in the fold every object read and the write door share, so + // the set a client is offered and the set a write is judged by are one. + return this.resolvePicklistFields(merged); } /** @@ -2562,9 +2590,13 @@ export class SchemaRegistry { foldObjectExtendersOnto(name: string, base: T): T { if (base === null || typeof base !== 'object') return base; const fqn = this.resolveObjectKey(name); - if (fqn === undefined) return base; + // With nothing to fold, the body still gets its picklist-bound fields + // resolved — the same step the fold below ends with — so a body this + // registry never saw is served in the one shape every other read uses. + // By reference when no field names a picklist. + if (fqn === undefined) return this.resolvePicklistFields(base); const contributors = this.objectContributors.get(fqn); - if (!contributors || !contributors.some((c) => c.ownership === 'extend')) return base; + if (!contributors || !contributors.some((c) => c.ownership === 'extend')) return this.resolvePicklistFields(base); return this.foldExtendersOntoDefinition( contributors, this.subtractExtenderContributions(contributors, base as unknown as ServiceObject), @@ -3484,6 +3516,12 @@ export class SchemaRegistry { // so both load paths produce identical lock state. applyProtection(item as any, { packageId }); + // A picklist's own values must not repeat any value its extensions + // already add — refused before anything is stored, like the extension + // side ({@link registerPicklistExtension}). Every resolved object depends + // on the list, so the merged-object cache is dropped once it lands (below). + if (type === 'picklist') this.assertPicklistValuesDistinct(baseName, item as any, packageId); + // Spec-conformance DIAGNOSTIC — deliberately not a gate (#3903). // // Registration proceeds on failure because refusing here would unhook the @@ -3653,6 +3691,7 @@ export class SchemaRegistry { } collection.set(storageKey, item); + if (type === 'picklist') this.invalidateAll(); this.log(`[Registry] Registered ${type}: ${storageKey}`); } @@ -3679,6 +3718,9 @@ export class SchemaRegistry { * Universal Unregister Method */ unregisterItem(type: string, name: string) { + // Every resolved object may carry this list's options; the removal below + // is synchronous, so the next fold re-resolves against the store without it. + if (type === 'picklist') this.invalidateAll(); const collection = this.metadata.get(type); if (!collection) { console.warn(`[Registry] Attempted to unregister non-existent ${type}: ${name}`); @@ -3836,6 +3878,10 @@ export class SchemaRegistry { if (removed.length > 0) { this.log(`[Registry] Unregistered ${removed.length} item(s) from package: ${packageId}`); } + // The package's `picklistExtensions` leave with it, and every resolved + // object is re-derived without its lists and its added values. + const droppedExtensions = this.unregisterPicklistExtensionsByPackage(packageId); + if (droppedExtensions || removed.some((r) => r.startsWith('picklist/'))) this.invalidateAll(); return { removed, orphanedOverlays }; } @@ -4697,6 +4743,155 @@ export class SchemaRegistry { return this.listItems('kind'); } + // ========================================== + // Shared picklists + // ========================================== + + /** + * Add a `picklistExtensions` entry's options to the picklist it names. + * + * ADDITIVE ONLY, and refused rather than resolved when it is not: a value + * the list already carries — from its owner or from another package's + * extension — throws `INVALID_METADATA` naming both declarations, and + * nothing is stored. Last-wins would let a second package silently replace + * an option the owner declared. + * + * The target need not be registered yet: packages register in dependency + * order, not picklist order, and an object resolves its options lazily on + * the next fold, so the extension is held until the list arrives. A target + * that never arrives is not this method's to judge — the boot audit + * ({@link findUnresolvedPicklistReferences}, {@link findOrphanPicklistExtensions}) + * refuses the fields that name it and the extensions that extend it. + * + * Re-registration by the same package replaces that package's own + * contribution value by value, so a manifest replay is idempotent. + */ + registerPicklistExtension( + extension: { extend: string; options: readonly PicklistOptionLike[] }, + packageId?: string, + ): void { + const target = extension.extend; + const packageKey = packageId ?? ''; + const own = this.picklistExtensionContributions.get(target)?.get(packageKey); + const merged = new Map(own?.options); + for (const option of extension.options ?? []) { + if (option && typeof option === 'object' && option.value !== undefined) merged.set(String(option.value), option); + } + const candidate: PicklistContribution = { packageId, kind: 'extension', options: [...merged.values()] }; + const others = this.picklistContributions(target).filter( + (c) => !(c.kind === 'extension' && (c.packageId ?? '') === packageKey), + ); + // The candidate's own list first: a value repeated INSIDE this one entry + // is refused too, and against the owner's values it then reads in merge + // order (the list first, this extension second). + const repeated = findDuplicatePicklistValue([{ ...candidate, options: extension.options ?? [] }]); + const duplicate = repeated ?? findDuplicatePicklistValue([...others, candidate]); + if (duplicate) throw duplicatePicklistValueError(target, duplicate); + + let byPackage = this.picklistExtensionContributions.get(target); + if (!byPackage) this.picklistExtensionContributions.set(target, byPackage = new Map()); + byPackage.set(packageKey, { packageId, options: merged }); + this.invalidateAll(); + this.log(`[Registry] Registered picklist extension: ${target} (+${extension.options?.length ?? 0}) from ${packageId}`); + } + + /** + * The resolved options of a picklist — its own, then every extension's, in + * registration order — or `undefined` when no picklist of that name is + * registered. Extensions held for an unregistered list resolve nothing on + * their own: a list exists only once its owner declares it. + */ + resolvePicklistOptions(name: string): PicklistOptionLike[] | undefined { + const contributions = this.picklistContributions(name); + if (!contributions.some((c) => c.kind === 'picklist')) return undefined; + return mergePicklistOptions(contributions); + } + + /** + * Every field of every packaged object that names a picklist no registered + * package declares — the load-time audit's input. + * + * Judged over the PACKAGED contributors only (`own` and `extend`), never an + * `overlay` and never a tenant-authored body: those come out of + * `sys_metadata`, and a stored row must not be able to fail a boot. A + * tenant field naming an unknown list is still never served options and + * never accepts a value — the fold drops whatever options it carried and + * the record validator refuses the field. + */ + findUnresolvedPicklistReferences(): PicklistReference[] { + const unresolved: PicklistReference[] = []; + for (const [fqn, contributors] of this.objectContributors) { + for (const contributor of contributors) { + if (contributor.ownership === 'overlay' || isTenantAuthored(contributor.definition)) continue; + for (const ref of collectPicklistReferences(fqn, contributor.definition, contributor.packageId)) { + if (this.resolvePicklistOptions(ref.picklist) === undefined) unresolved.push(ref); + } + } + } + return unresolved; + } + + /** + * Every `picklistExtensions` entry whose target list no registered package + * declares — the second half of the load-time audit. Held extensions are + * legal while the boot fills; once it is sealed, one still held adds its + * options to nothing. + */ + findOrphanPicklistExtensions(): Array<{ picklist: string; packageId: string | undefined }> { + const orphans: Array<{ picklist: string; packageId: string | undefined }> = []; + for (const [target, byPackage] of this.picklistExtensionContributions) { + if (this.resolvePicklistOptions(target) !== undefined) continue; + for (const entry of byPackage.values()) orphans.push({ picklist: target, packageId: entry.packageId }); + } + return orphans; + } + + /** The owning list's options (when registered) followed by every extension's. */ + private picklistContributions(name: string): PicklistContribution[] { + const contributions: PicklistContribution[] = []; + const base = this.getItem<{ options?: PicklistOptionLike[]; _packageId?: string }>('picklist', name); + if (base) { + contributions.push({ packageId: base._packageId, kind: 'picklist', options: Array.isArray(base.options) ? base.options : [] }); + } + for (const entry of this.picklistExtensionContributions.get(name)?.values() ?? []) { + contributions.push({ packageId: entry.packageId, kind: 'extension', options: [...entry.options.values()] }); + } + return contributions; + } + + /** + * The list half of the additive rule: a picklist registered AFTER an + * extension already added one of its values is refused the same way, so the + * verdict does not depend on which package registered first. + */ + private assertPicklistValuesDistinct( + name: string, + picklist: { options?: PicklistOptionLike[] }, + packageId: string | undefined, + ): void { + const extensions = this.picklistContributions(name).filter((c) => c.kind === 'extension'); + const duplicate = findDuplicatePicklistValue([ + { packageId, kind: 'picklist', options: Array.isArray(picklist?.options) ? picklist.options : [] }, + ...extensions, + ]); + if (duplicate) throw duplicatePicklistValueError(name, duplicate); + } + + /** Resolve every picklist-bound field of a body (by reference when there is none). */ + private resolvePicklistFields(body: T): T { + return resolvePicklistFieldsOnto(body, (picklist) => this.resolvePicklistOptions(picklist)); + } + + /** Drop a package's `picklistExtensions`; `true` when it had any. */ + private unregisterPicklistExtensionsByPackage(packageId: string): boolean { + let dropped = false; + for (const [target, byPackage] of this.picklistExtensionContributions) { + if (byPackage.delete(packageId)) dropped = true; + if (byPackage.size === 0) this.picklistExtensionContributions.delete(target); + } + return dropped; + } + // ========================================== // Reset (for testing) // ========================================== @@ -4747,6 +4942,7 @@ export class SchemaRegistry { this.namespaceRegistry.clear(); this.metadata.clear(); this.appNavContributions.clear(); + this.picklistExtensionContributions.clear(); this._objectRevision += 1; this.log('[Registry] Reset complete'); } diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 21c8b07b279..7b4fcc46e05 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -281,6 +281,14 @@ interface FieldDef { */ valueDomain?: ValueDomain; options?: Array<{ value: string | number; label?: string } | string | number>; + /** + * The shared picklist the field takes its options from. The registry's fold + * writes that list's resolved options onto the field before it reaches this + * door (`SchemaRegistry.resolvePicklistOptions`), so `options` above IS the + * picklist's set; this key only lets a refusal name the list, and marks a + * field whose list did not resolve as one that accepts no value at all. + */ + picklist?: string; } function isMissing(v: unknown): boolean { @@ -901,6 +909,14 @@ function validateOne( messageParams?: Record, ) => buildFieldError({ field: name, code, def, constraint, messageKey, options, value, messageParams }, ctx); + // A field bound to a shared picklist is judged against the list's resolved + // options like any inline list; the refusal names the list. When the list + // did not resolve, the field has no options and accepts no value — refused + // rather than read as free-form, which would accept anything. + const picklist = typeof def.picklist === 'string' && def.picklist.length > 0 ? def.picklist : undefined; + const picklistUnresolved = () => + fail('invalid_option', undefined, 'invalid_option_picklist_unresolved', undefined, undefined, { picklist }); + // ── required ──────────────────────────────────────────────────── // `autonumber` is runtime-owned: the value is generated by the engine / // driver (the SQL driver assigns it from a persistent sequence AFTER this @@ -1379,8 +1395,16 @@ function validateOne( // drift the one-definition ruling (#17469) closes. if ((t === 'select' || t === 'radio') && !isMultiValueField(def)) { const allowed = optionValues(def.options); + if (picklist !== undefined && allowed.length === 0) return picklistUnresolved(); if (allowed.length > 0 && !allowed.includes(String(value))) { - return fail('invalid_option', { allowed: allowed.join(', ') }, 'invalid_option', allowed); + return fail( + 'invalid_option', + { allowed: allowed.join(', ') }, + picklist !== undefined ? 'invalid_option_picklist' : 'invalid_option', + allowed, + undefined, + picklist !== undefined ? { picklist } : undefined, + ); } return null; } @@ -1397,15 +1421,20 @@ function validateOne( // reference integrity is handled elsewhere. if (t === 'lookup' || t === 'user' || t === 'file' || t === 'image') return null; const allowed = optionValues(def.options); - if (allowed.length === 0) return null; // free-form (tags without options) + if (allowed.length === 0) { + // A picklist-bound field with no options is a list that did not + // resolve, never a free-form one: nothing it could hold has been offered. + return picklist !== undefined && value.length > 0 ? picklistUnresolved() : null; + } for (const v of value) { if (!allowed.includes(String(v))) { return fail( 'invalid_option', { allowed: allowed.join(', ') }, - 'invalid_option_value', + picklist !== undefined ? 'invalid_option_value_picklist' : 'invalid_option_value', allowed, String(v), + picklist !== undefined ? { picklist } : undefined, ); } } diff --git a/packages/qa/dogfood/test/picklist-shared-across-objects.dogfood.test.ts b/packages/qa/dogfood/test/picklist-shared-across-objects.dogfood.test.ts new file mode 100644 index 00000000000..56202789996 --- /dev/null +++ b/packages/qa/dogfood/test/picklist-shared-across-objects.dogfood.test.ts @@ -0,0 +1,192 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// GOLDEN REGRESSION — one shared picklist, two objects, two packages, booted for +// real and driven through the HTTP doors a client uses (the ADR-0054 runtime proof). +// +// The picklist kind lets several select fields name ONE option list +// (`Field.select({ picklist: 'industry' })`) instead of copying `options` into +// each, and lets another package ADD values to it (`picklistExtensions`). The +// runtime owes four things for that, and this file asserts them on one booted +// artifact rather than one unit at a time: +// +// 1. the served field carries the RESOLVED options — the owner's list plus +// the extension's value — with `picklist` kept, on both objects; +// 2. object A accepts a write of the value the EXTENSION added; +// 3. object B refuses a value outside the set, and the refusal names the +// picklist; +// 4. a locale switch relabels the options, through the list's own +// translations (`picklists..options.`), inherited by every +// field bound to it. +// +// Every one of those is green in a unit test with a hand-built registry. What +// only a boot sees is the assembly: `composeStacks(…, { manifest: 'preserve' })` +// emitting the two package bodies, the artifact load registering them in +// dependency order, the ObjectQL fold the `/meta` read and the write door +// share, and the REST localization boundary reading the resolved field. +// +// Boots a fixture stack of its own, so it stays out of `SHARED_SHOWCASE`. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { composeStacks, defineStack } from '@objectstack/spec'; + +const CORE = 'com.dogfood.picklist.core'; +const HEALTH = 'com.dogfood.picklist.health'; + +/** The owning package: the list, its translations, and object A. */ +const coreStack = defineStack({ + manifest: { + id: CORE, + namespace: 'dfp', + version: '0.0.0', + type: 'app', + name: 'Picklist Core', + description: 'Owns the shared industry picklist and the account object.', + }, + picklists: [ + { + name: 'industry', + label: 'Industry', + options: [ + { label: 'Technology', value: 'technology' }, + { label: 'Finance', value: 'finance' }, + ], + }, + ], + objects: [ + { + name: 'dfp_account', + label: 'Account', + sharingModel: 'public_read_write', + fields: { + name: { type: 'text', label: 'Name', required: true }, + industry: { type: 'select', label: 'Industry', picklist: 'industry' }, + }, + }, + ], + translations: [ + { + 'zh-CN': { + picklists: { + industry: { + label: '行业', + options: { technology: '科技', finance: '金融', healthcare: '医疗' }, + }, + }, + }, + }, + ], +}); + +/** A second package: adds a value to the list it does not own, and binds object B to it. */ +const healthStack = defineStack({ + manifest: { + id: HEALTH, + namespace: 'dfp', + version: '0.0.0', + type: 'module', + name: 'Picklist Health', + description: 'Extends the industry picklist and binds the lead object to it.', + dependencies: { [CORE]: '0.0.0' }, + }, + picklistExtensions: [{ extend: 'industry', options: [{ label: 'Healthcare', value: 'healthcare' }] }], + objects: [ + { + name: 'dfp_lead', + label: 'Lead', + sharingModel: 'public_read_write', + fields: { + name: { type: 'text', label: 'Name', required: true }, + industry: { type: 'select', label: 'Industry', picklist: 'industry' }, + }, + }, + ], +}); + +const artifact = composeStacks([healthStack, coreStack], { manifest: 'preserve' }); + +describe('dogfood: one shared picklist, two objects, a package extension', () => { + let stack: VerifyStack; + let token: string; + + /** A `/meta` read of one object, in a stated language. */ + const metaIn = async (locale: string | undefined, object: string): Promise => { + const res = await stack.api(`/meta/object/${object}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + ...(locale ? { 'Accept-Language': locale } : {}), + }, + }); + expect(res.status, `GET /meta/object/${object}`).toBe(200); + // The by-name read answers `{ type, name, item }`; the object is `item`. + const body: any = await res.json(); + expect(body?.item?.name, JSON.stringify(body).slice(0, 300)).toBe(object); + return body.item; + }; + + beforeAll(async () => { + stack = await bootStack(artifact); + token = await stack.signIn(); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('serves both objects with the resolved options — the owner\'s and the extension\'s — and keeps `picklist`', async () => { + for (const object of ['dfp_account', 'dfp_lead']) { + const field = (await metaIn(undefined, object))?.fields?.industry; + expect(field?.picklist, object).toBe('industry'); + expect(field?.options, object).toEqual([ + { label: 'Technology', value: 'technology' }, + { label: 'Finance', value: 'finance' }, + { label: 'Healthcare', value: 'healthcare' }, + ]); + } + }); + + it('object A accepts the value the extension added', async () => { + const res = await stack.apiAs(token, 'POST', '/data/dfp_account', { name: 'Clinic Co', industry: 'healthcare' }); + expect(res.status, await res.clone().text()).toBeLessThan(300); + const body: any = await res.json(); + const id = body.record?.id ?? body.id; + const read = await stack.apiAs(token, 'GET', `/data/dfp_account/${id}`); + const row: any = await read.json(); + expect(row.record?.industry ?? row.industry).toBe('healthcare'); + }); + + it('object B refuses a value outside the set, and the refusal names the picklist', async () => { + const res = await stack.apiAs(token, 'POST', '/data/dfp_lead', { name: 'Shop Co', industry: 'retail' }); + expect(res.status).toBe(400); + const body: any = await res.json(); + expect(body.code).toBe('VALIDATION_FAILED'); + expect(body.fields?.[0]).toMatchObject({ field: 'industry', code: 'invalid_option' }); + expect(body.fields?.[0]?.message).toBe('Industry must be one of the values of picklist "industry": technology, finance, healthcare'); + }); + + it('a locale switch relabels the options on both objects, through the list\'s own translations', async () => { + for (const object of ['dfp_account', 'dfp_lead']) { + const field = (await metaIn('zh-CN', object))?.fields?.industry; + expect(field?.options?.map((o: any) => o.label), object).toEqual(['科技', '金融', '医疗']); + expect(field?.options?.map((o: any) => o.value), object).toEqual(['technology', 'finance', 'healthcare']); + } + // …and the default-language read is untouched by the other locale's catalog. + expect((await metaIn(undefined, 'dfp_lead'))?.fields?.industry?.options?.[2]?.label).toBe('Healthcare'); + }); + + it('the list itself is served as a `picklist` item, its label translated per locale', async () => { + const read = async (locale: string | undefined) => { + const res = await stack.api('/meta/picklist/industry', { + method: 'GET', + headers: { Authorization: `Bearer ${token}`, ...(locale ? { 'Accept-Language': locale } : {}) }, + }); + expect(res.status, 'GET /meta/picklist/industry').toBe(200); + const body: any = await res.json(); + expect(body?.item?.name, JSON.stringify(body).slice(0, 300)).toBe('industry'); + return body.item; + }; + expect((await read(undefined)).label).toBe('Industry'); + expect((await read('zh-CN')).label).toBe('行业'); + }); +}); diff --git a/packages/rest/src/import-template-route.test.ts b/packages/rest/src/import-template-route.test.ts index ecc28c30740..9412aad70e0 100644 --- a/packages/rest/src/import-template-route.test.ts +++ b/packages/rest/src/import-template-route.test.ts @@ -471,7 +471,17 @@ describe('?template=true — a required field the engine fills from its option d const OPTS = [{ label: 'A', value: 'a' }, { label: 'B', value: 'b' }, { label: 'C', value: 'c' }]; const mark = (...values: string[]) => OPTS.map((o) => (values.includes(o.value) ? { ...o, default: true } : o)); -const PARITY: Record; blank: 'refused' | 'accepted'; stored?: unknown }> = { +/** + * A shared picklist whose option is marked `default: true`. A field bound to it + * authors NO options of its own — the registry resolves the list onto the + * served field — so the template's object read and the engine's registry + * schema must BOTH see the resolved options, or the template would unstar a + * column the engine still refuses blank. `served` is that resolved field, for + * the direct mirror check below (which reads a field def, not the registry). + */ +const PARITY_PICKLIST = { name: 'pty_list', label: 'List', options: mark('b') }; + +const PARITY: Record; blank: 'refused' | 'accepted'; stored?: unknown; served?: Record }> = { plain: { def: { type: 'text' }, blank: 'refused' }, default_value: { def: { type: 'text', defaultValue: 'x' }, blank: 'accepted', stored: 'x' }, default_value_false: { def: { type: 'boolean', defaultValue: false }, blank: 'accepted', stored: false }, @@ -491,12 +501,17 @@ const PARITY: Record; blank: 'refused' | readonly: { def: { type: 'text', readonly: true }, blank: 'accepted' }, system: { def: { type: 'text', system: true }, blank: 'accepted' }, autonumber: { def: { type: 'autonumber', autonumberFormat: 'N-{0000}' }, blank: 'accepted' }, + picklist_option_default: { + def: { type: 'select', picklist: 'pty_list' }, blank: 'accepted', stored: 'b', + served: { type: 'select', picklist: 'pty_list', options: mark('b') }, + }, }; describe('the `*` agrees with the engine: starred exactly when the import door refuses a blank', () => { it('for every shape of default the engine reads', async () => { const { get, importRoute, engine } = await boot(); const objectOf = (key: string) => `pty_${key}`; + engine.registry.registerItem('picklist', PARITY_PICKLIST, 'name', 'com.test.parity'); for (const [key, { def }] of Object.entries(PARITY)) { engine.registry.registerObject({ name: objectOf(key), label: key, @@ -506,7 +521,9 @@ describe('the `*` agrees with the engine: starred exactly when the import door r await engine.syncSchemas(); const disagreements: string[] = []; - for (const [key, { def, blank, stored }] of Object.entries(PARITY)) { + for (const [key, { def: authored, blank, stored, served }] of Object.entries(PARITY)) { + // The mirror reads a field def; a picklist-bound one is judged as the registry serves it. + const def = served ?? authored; const object = objectOf(key); // The template's own verdict — its header, via `?fields=` so every shape is a column. const wb = await loadXlsxWorkbook((await get({ template: 'true', fields: 'f' }, object)).body()); diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index e9b3b329f66..0611828490c 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -930,7 +930,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | email_template | this row read 8/–/13/– for one day (seeded 2026-08-01, #4488: "every authorable property is dead", the webhook shape on AUTH mail) and #4509 CLOSED it by ENFORCING — the second worked example, after `webhook`, that a dead verdict is a worklist entry rather than a tombstone. `bootstrapDeclaredEmailTemplates` materializes declared items into the `sys_email_template` rows `sendTemplate` reads, sharing `mapTemplateToRow` with the built-in seeder so the two doors cannot drift, and re-materializes on live metadata writes (`email_template` is `allowRuntimeCreate: true`, so boot-only would have left Studio saves inert). Three breaks had to close, not one: the engine never registered `emailTemplates:` into the registry, built-in seeds masqueraded as `managed_by: admin` and outranked declared templates, and nothing materialized. ADR-0054 proof bound on `subject` (`email-template-materialization`) | | job | seeded 2026-08-01 (#4488). The file-authored path is fully enforced: all three schedule shapes honored by the adapters, `retryPolicy`/`timeout` enforced since #3494 (this is the retryPolicy the datasource ledger warns about confusing with its dead namesake), `enabled: false` skips scheduling. Dead 3 = `id` (authorWarn — `name` is the identity everywhere) + label/description (docs-kept). The type-level gap CLOSED 2026-08-02 (#4509) by closing the door rather than bridging it: `handler` names a function in the compiled bundle's function table, which a runtime writer cannot name, so `allowRuntimeCreate` **and** `allowOrgOverride` are now false and `*.job.ts` / `defineStack({ jobs })` are the supported doors. The kind stays registered — its file loader is genuinely consumed (ADR-0088 admission test) **#4667**: `id` REMOVED (row deleted, strict removal) — nothing read it and its own describe() ("defaults to `name` when omitted") advertised an identity override that never existed; `name` is the scheduling key, the sys_job row key and the JobExecution.jobId stamp, so two jobs differing only in `id` were one job. **#7131** (PR #7425) takes the remaining two: `label` and `description` re-grade `dead` → `live` under the 2026-08-10 maintainer ruling that **designer previews count as consumers** — objectui's `JobPreview` had been reading `d.label` and `d.description` and rendering them as the preview card's title and subtitle the whole time, so the old "no runtime consumer" was a true statement about the *scheduler* and a false one about the system. **This row now has zero dead and the ADR-0033 exemption is still in force**, which is worth saying out loud because it is the first row in this table where those two facts hold together: the keys are still docs-shaped, still deliberately KEPT, still not `authorWarn`'d, and enforce-or-remove still has nothing to chase here. What changed is only that the exemption no longer has to carry the verdict — the measurement does. | | mapping | seeded 2026-08-01 (#4488) at 8/11 live; **0 dead since #4509** retired the three that were not. The import half (#2611) is loudly enforced — unsupported transforms/formats are 400s, `mode`/`upsertKey` default the request, the wizard picker renders `label`. RETIRED 17.0.0: `extractQuery` (authorWarn — "for export only" promised an export path no exporter implements) + `errorPolicy`/`batchSize`, which were dead AND **unwarnable** (schema defaults materialize at parse, so presence ≠ authored — `_authorWarnSkipped`, the non-boolean instance of the default(true) rule). That unwarnability is why they went out in the 17.0.0 window rather than after a deprecation cycle: removal was the only channel that could ever reach the author. Rows DELETED, not tombstoned — MappingSchema is strict, so the keys left the walked shape. `connectorSource` (2026-09-30) is this type's first `planned` row: the pull binding the connector-attached sync family MOVED here when the connector's `syncConfig` / `fieldMappings` retired to tombstones, seeded contract-first with `authorWarn` (its seven drilled rows — five children and `watermark`'s two — `planned` with it) because the pull executor is the next stage | -| picklist | seeded 2026-09-30 (#19518) — `PicklistSchema`, the shared option list select fields reference by name. Every key `planned`: the kind is registered with the spec layer ahead of its runtime reader by ruling, and the runtime layer (#19519 — load before `object`, additive `picklistExtensions` merge, resolve onto the served field) has not landed. `field.picklist` carries the same verdict with `authorWarn`, because until then a picklist-bound field is served with no options. | +| picklist | seeded 2026-09-30 (#19518) with every key `planned`, ahead of its runtime reader by ruling; **live since the runtime layer (#19519)**: the ObjectQL registry merges `picklistExtensions` into the list (additive only, a repeated value refused) and resolves it onto every field that names it, the write door judges against that set, and a field naming an undeclared list fails the boot. `field.picklist` left `authorWarn` with it. Display keys (`label`, `description`, `options.color`) are graded by their served form. | | seed | seeded 2026-08-01 (#4488). Fully live via SeedLoaderService on both doors (boot/per-org replay + runtime-draft publish). `records` is the z.record walk boundary: the keys an author writes are the target object's fields, governed by that object's own definitions — recorded in the entry, not silently skipped | | translation | seeded 2026-08-01 (#4488) — after fixing the walker: the registered schema is a z.preprocess pipe (#3778 retired-dialect guard) whose transform side the unwrap always took, so the type was literally unwalkable. 11 of 12 groups live across spec resolvers, REST localization, objectui client resolvers and plugin-audit (whose composed-key `t()` calls make `messages` easy to mis-verify as dead) — `flows` was the one that was not, and was `planned` at seeding. **#14253** added the twelfth, `datasets`, seeded LIVE and DRILLED (label / description / dimensions / measures) with its reader in the same change: `translateDataset` in the dispatch table, which is what `TRANSLATABLE_METADATA_TYPES` is derived from, so the REST boundary followed with nothing else to remember. The same change gave `objects.._views..bulkActions` and `objects.._validations..message` their first keys — both beneath the walk boundary, so neither adds a row here. Dead 1 = `validationMessages` (authorWarn) at seeding: nothing resolved it, and #3778's own legacy-key migration table steered `errors:` authors into it — a shipped false signpost, the capabilities.readOnly shape. **#4667**: `validationMessages` REMOVED (row deleted) — removed from the shared translationDataShape(), so it retired at BOTH doors at once, closing the item-only asymmetry #3778's original guard had. #3778's own `errors` guidance was rewritten in the same change: it had been steering authors INTO this dead group. ⚠️ **What that left behind is this table's own worked example of the defect it warns about** (#7377): the same commit that deleted the `validationMessages` row wrote a count column of `dead 2` beside a sentence that named exactly one dead key — and that one was the key it had just removed. The real two were `name` and `label`, which the cell never mentioned. Measured at that commit, not inferred: the ledger's dead set there is `{name, label}` and `validationMessages` is absent from `props`. The number was right and the prose was false, in the same cell, on the day it was written — which is why the counts are now generated and this cell holds prose only. **#7131** (PR #7425) resolves it: `name` and `label` re-grade `dead` → `live` under the designer-previews-count-as-consumers ruling (objectui `TranslationPreview.tsx:67` reads `label` first and falls back to `name`, both rendering at `:100`), so the dead set is empty and there is no dead-set sentence left to keep true. As on `job`, the ADR-0033 docs-shaped exemption is untouched — nothing about enforce-or-remove moved. **#19620** (ruling batch #210 item 2 letter B): `settings` row DELETED — the strict-delete route, because `TranslationItemSchema` no longer declares the key and refuses it by name (the item door now takes the per-app face, as the file door has since #15178). ⚠️ The deleted row read `live`, and that verdict was TRUE and stays true of the platform: its evidence read the SERVED tree, which the platform bundle feeds, so the deletion retires the key from the application-authored item and nothing else — the capability lives on `PlatformTranslationDataSchema`, outside this ledger. **#20296**: `flows` is now half-read. `flows.screens` re-graded `planned` → `live`: objectui's FlowRunner reads each screen's `title` and each field's `label` / `placeholder` at the `.objectui-sha` pin f8a9d0fb. `flows.label` stays `planned` because nothing reads it yet (#20318), and so does the container's `authorWarn`, whose `authorHint` now names the read half and the unread one. | | qa | seeded 2026-08-10 (#6247) — **not a metadata type**: `TestSuiteSchema` is the FILE surface of the shipped `os test` command (`qa/*.test.json`), governed through the same `SPEC_ONLY_SCHEMAS` override as `query`/`webhook`/`validation`. It is in the table as the clearest worked example of a **false `dead` measurement**: #6247 reported the whole domain declared-but-inert on a grep that scanned only `*Schema` identifiers, and every consumer here reads the **type** names (`QA.TestSuite`, `QA.TestStep`, `QA.TestAction`) — so an entire execution chain (core's `TestRunner` + `HttpTestAdapter`, published via `export * as QA`, driven by a documented CLI command) read as zero consumers, and a retire ruling was issued on it before being withdrawn. The `evidenceScope` table one section up says no amount of specifier matching is sufficient for a negative claim; this is the same lesson for **identifier** matching. What was really wrong was narrower and real: the type was the contract and the schema had no `parse` site, so the CLI's `JSON.parse(content) as QA.TestSuite` cast admitted anything — ENFORCED in the same change (`TestSuiteSchema.safeParse` at the load site, pinned). The seeding recorded Dead 5 — `name`, `scenarios.name`, `scenarios.description`, `scenarios.tags` and `scenarios.requires` — and #20289 (family `qa-runner`, verdict ENFORCE) made all FIVE live: the suite `name` heads the suite in `os test`'s report and is stamped as `suiteName` on every TestResult the suite produces; `scenarios.name` is printed beside the id and carried as `scenarioName`; `scenarios.description` is carried and printed under a failed scenario; `scenarios.tags` is read by `os test --tags` (any-of, exact; deselected scenarios are counted and never passed); and `scenarios.requires` is judged by the TestRunner before a scenario's first step — `params` against the environment of the process running `os test`, `services` against the target's discovery `services` (enabled and `available`) — so an unmet entry SKIPS the scenario with its reason, counted apart and never passed. `requires` was put to the maintainer first rather than guessed (ruled B): over HTTP no server surface lists the loaded plugins, so `requires.plugins` is a `retiredKey()` tombstone inside the block whose prescription names `requires.services`. It does not carry `authorWarn` and the omission is deliberate (`_authorWarnSkipped`): the lint walks stack **collections**, a QA suite is a loose file in no stack, so a warn flag here would emit nothing — a silent no-op inside the mechanism built to catch silent no-ops | diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index ec2da038770..012fb2453ce 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -57,11 +57,10 @@ "note": "select options {label,value,description,color,default} — renderers + validation. `description` joined 2026-08-31 (objectui#6153, inheriting the objectui#6140 ruling frame): objectui LookupField searches it on authored static options (LookupField.tsx:526) and recordToOption produces it for fetched options." }, "picklist": { - "status": "planned", - "verifiedAt": "2026-09-30", - "authorWarn": true, - "authorHint": "A field that names a picklist is not yet resolved by the server: until picklist resolution ships, the field is served with no options, so its control offers nothing and a written value is not checked against the list. Keep inline `options` on fields that must work today.", - "note": "Declared with the spec layer (#19518); mutually exclusive with `options` at the schema door (FieldSchema superRefine). The reader is the runtime layer (#19519), which resolves the reference onto the served field's `options` — not landed, so `planned`. `authorWarn` because the interim is the misleading shape: the key parses and publishes, and the served field carries no options until #19519. Flip to `live` against its resolver." + "status": "live", + "evidence": "packages/objectql/src/registry.ts#resolvePicklistOptions", + "verifiedAt": "2026-10-01", + "note": "The ObjectQL registry resolves the reference onto the served field (`PicklistServedFieldSchema`: `options` resolved, `picklist` kept) in the object fold every read and the write door share (`foldExtendersOntoDefinition` / `foldObjectExtendersOnto`); the record validator judges a write against that set and names the picklist in the refusal (`packages/objectql/src/validation/record-validator.ts`); a field naming a list no package declares fails the boot at `kernel:ready` naming the field and the package (`packages/objectql/src/plugin.ts`). End to end: `packages/qa/dogfood/test/picklist-shared-across-objects.dogfood.test.ts`." }, "deleteBehavior": { "status": "live", diff --git a/packages/spec/liveness/picklist.json b/packages/spec/liveness/picklist.json index 099b17d352d..e7764e0d08a 100644 --- a/packages/spec/liveness/picklist.json +++ b/packages/spec/liveness/picklist.json @@ -1,58 +1,65 @@ { "type": "picklist", - "_note": "PicklistSchema (`data/picklist.zod.ts`) — a shared option list that select fields reference by name (`Field.select({ picklist })`). Seeded 2026-09-30 with the spec layer (#19518), every key `planned`: the kind is registered ahead of its runtime reader by ruling (spec layer first, runtime layer #19519 Blocked-by it). The ADR-0010 envelope keys carry the same `null` verdict as `capability`: loader-stamped, not authored.", + "_note": "PicklistSchema (`data/picklist.zod.ts`) — a shared option list that select fields reference by name (`Field.select({ picklist })`). Read at runtime by the ObjectQL registry, which merges `picklistExtensions` into the list (additive only) and resolves it onto every field that names it. The ADR-0010 envelope keys carry the same `null` verdict as `capability`: loader-stamped, not authored.", "props": { "name": { - "status": "planned", - "verifiedAt": "2026-09-30", - "note": "The handle a field's `picklist` names. Declared with the spec layer of the picklist kind (#19518); its runtime reader is the runtime layer (#19519: load before `object`, additive merge of `picklistExtensions`, resolve onto the served field, write validation against the resolved set), which has NOT landed. No code outside `packages/spec` reads a picklist yet, so this is `planned`, not `live`. Re-verify to `live` against #19519's resolver." + "status": "live", + "evidence": "packages/objectql/src/registry.ts#resolvePicklistOptions", + "verifiedAt": "2026-10-01", + "note": "The handle a field's `picklist` names: the registry looks the list up by it when it resolves a field, and the boot audit refuses a field whose name matches no registered list." }, "label": { - "status": "planned", - "verifiedAt": "2026-09-30", - "note": "Display label of the list (the Studio picklist page is a later phase). Declared with the spec layer of the picklist kind (#19518); its runtime reader is the runtime layer (#19519: load before `object`, additive merge of `picklistExtensions`, resolve onto the served field, write validation against the resolved set), which has NOT landed. No code outside `packages/spec` reads a picklist yet, so this is `planned`, not `live`. Re-verify to `live` against #19519's resolver." + "status": "live", + "verifiedAt": "2026-10-01", + "note": "display: the list's own label, served with the kind (`GET /meta/picklist`; the artifact door registers `picklists:` as items) and translated under `picklists..label` (`translatePicklist`). A Studio picklist page is a later phase." }, "description": { - "status": "planned", - "verifiedAt": "2026-09-30", - "note": "Author-facing summary of what the list enumerates. Declared with the spec layer of the picklist kind (#19518); its runtime reader is the runtime layer (#19519: load before `object`, additive merge of `picklistExtensions`, resolve onto the served field, write validation against the resolved set), which has NOT landed. No code outside `packages/spec` reads a picklist yet, so this is `planned`, not `live`. Re-verify to `live` against #19519's resolver." + "status": "live", + "verifiedAt": "2026-10-01", + "note": "display: served with the kind (`GET /meta/picklist`) for authors choosing a list." }, "options": { - "status": "planned", - "verifiedAt": "2026-09-30", - "note": "The options every referencing field is served, resolved onto the field (`PicklistServedFieldSchema`). Declared with the spec layer of the picklist kind (#19518); its runtime reader is the runtime layer (#19519: load before `object`, additive merge of `picklistExtensions`, resolve onto the served field, write validation against the resolved set), which has NOT landed. No code outside `packages/spec` reads a picklist yet, so this is `planned`, not `live`. Re-verify to `live` against #19519's resolver.", + "status": "live", + "verifiedAt": "2026-10-01", + "note": "Merged with every `picklistExtensions` entry's options (additive only — a repeated value is refused, `registerPicklistExtension`) and written onto each referencing field's served `options`, which the record validator judges writes against.", "children": { "label": { - "status": "planned", - "verifiedAt": "2026-09-30", - "note": "A picklist option — the field option shape reused verbatim (`SelectOptionSchema`). Read, like the list itself, by the #19519 resolver once it lands; it reaches every consumer through the referencing field's resolved `options`, where the same key is `live` on `field.json`." + "status": "live", + "evidence": "packages/objectql/src/registry.ts#resolvePicklistOptions", + "verifiedAt": "2026-10-01", + "note": "Reaches every consumer as the referencing field's resolved option label, translated under `picklists..options.` (`translateObject`)." }, "value": { - "status": "planned", - "verifiedAt": "2026-09-30", - "note": "A picklist option — the field option shape reused verbatim (`SelectOptionSchema`). Read, like the list itself, by the #19519 resolver once it lands; it reaches every consumer through the referencing field's resolved `options`, where the same key is `live` on `field.json`." + "status": "live", + "evidence": "packages/objectql/src/validation/record-validator.ts", + "verifiedAt": "2026-10-01", + "note": "The value a write to a referencing field is judged against, through the field's resolved `options`." }, "description": { - "status": "planned", - "verifiedAt": "2026-09-30", - "note": "A picklist option — the field option shape reused verbatim (`SelectOptionSchema`). Read, like the list itself, by the #19519 resolver once it lands; it reaches every consumer through the referencing field's resolved `options`, where the same key is `live` on `field.json`." + "status": "live", + "evidence": "packages/objectql/src/registry.ts#resolvePicklistOptions", + "verifiedAt": "2026-10-01", + "note": "Carried onto the referencing field's resolved option, where the same key is `live` on `field.json`." }, "color": { - "status": "planned", - "verifiedAt": "2026-09-30", - "note": "A picklist option — the field option shape reused verbatim (`SelectOptionSchema`). Read, like the list itself, by the #19519 resolver once it lands; it reaches every consumer through the referencing field's resolved `options`, where the same key is `live` on `field.json`." + "status": "live", + "verifiedAt": "2026-10-01", + "note": "display: carried onto the referencing field's resolved option, rendered like an inline option's (`field.options`)." }, "default": { - "status": "planned", - "verifiedAt": "2026-09-30", - "note": "A picklist option — the field option shape reused verbatim (`SelectOptionSchema`). Read, like the list itself, by the #19519 resolver once it lands; it reaches every consumer through the referencing field's resolved `options`, where the same key is `live` on `field.json`." + "status": "live", + "evidence": "packages/objectql/src/registry.ts#resolvePicklistOptions", + "verifiedAt": "2026-10-01", + "note": "Carried onto the referencing field's resolved option, so the engine's insert-time option default and the import template's required star read it like an inline option's (`packages/rest/src/import-template-route.test.ts`)." }, "visibleWhen": { - "status": "planned", - "verifiedAt": "2026-09-30", - "note": "A picklist option — the field option shape reused verbatim (`SelectOptionSchema`). Read, like the list itself, by the #19519 resolver once it lands; it reaches every consumer through the referencing field's resolved `options`, where the same key is `live` on `field.json`." + "status": "live", + "evidence": "packages/objectql/src/validation/rule-validator.ts", + "verifiedAt": "2026-10-01", + "note": "Carried onto the referencing field's resolved option, where the option-visibility predicate reads it like an inline option's." } - } + }, + "evidence": "packages/objectql/src/registry.ts#resolvePicklistOptions" }, "_lock": null, "_lockReason": null, diff --git a/packages/spec/liveness/state-counts/field.md b/packages/spec/liveness/state-counts/field.md index 0df888579da..4beaad39889 100644 --- a/packages/spec/liveness/state-counts/field.md +++ b/packages/spec/liveness/state-counts/field.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `field` | 92 | 0 | 0 | 1 | 1 | 94 | +| `field` | 93 | 0 | 0 | 1 | 0 | 94 | diff --git a/packages/spec/liveness/state-counts/picklist.md b/packages/spec/liveness/state-counts/picklist.md index b9b5325e9d0..cf41d72030d 100644 --- a/packages/spec/liveness/state-counts/picklist.md +++ b/packages/spec/liveness/state-counts/picklist.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `picklist` | 7 | 0 | 0 | 0 | 9 | 16 | +| `picklist` | 16 | 0 | 0 | 0 | 0 | 16 | diff --git a/packages/spec/liveness/state-counts/translation.md b/packages/spec/liveness/state-counts/translation.md index d28b11bf5f8..d260eb70f72 100644 --- a/packages/spec/liveness/state-counts/translation.md +++ b/packages/spec/liveness/state-counts/translation.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `translation` | 23 | 0 | 0 | 0 | 3 | 26 | +| `translation` | 25 | 0 | 0 | 0 | 1 | 26 | diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index 65d8598005a..77b00358259 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -31,22 +31,22 @@ "note": "the largest group, fully live: label/pluralLabel/description (translateObject), fields.{label,help,placeholder,options}, _views (resolveViewLabel + empty-state copy), _actions (label/confirmText/successMessage/params/resultDialog — object-scoped first, then globalActions fallback), _sections (objectui record:details section labels). Served through REST translateMetaItem(s) and the /api/v1/i18n endpoints; objectui re-resolves client-side via the spec-translations transform. 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED — this entry named SIX positions in i18n-resolver.ts and only the first, `:735`, was parsable as a citation at all; it had rotted onto `widgets?: WidgetLike[]` in the `DashboardLike` INTERFACE. The other five (`:751`, `:159`, `:197`, `:873`, `:900`) were bare line suffixes with no path in front of them, so they were never resolved, bounded or key-checked by anything — the largest instance in this batch of the class `flow.status` and `dashboard.widgets.suppressWarnings` also carried. The five anchors above are the five distinct group readers, named. Re-closed by hand against 8cb96ec41." }, "picklists": { - "status": "planned", - "verifiedAt": "2026-09-30", + "status": "live", + "verifiedAt": "2026-10-01", "evidence": "packages/spec/src/system/i18n-resolver.ts#translatePicklist (a served `picklist` item: `picklists..label` and `.options.`, registered in METADATA_DOCUMENT_TRANSLATORS); packages/spec/src/system/i18n-resolver.ts#translateObject (a picklist-bound field inherits `picklists..options.` after its own field-level entry)", - "note": "Declared with the spec layer of the picklist kind (#19518), resolvers in the same change. `planned`, not `live`, because the PRODUCER half is missing: nothing serves a picklist item or a picklist-bound field with its options resolved until the runtime layer (#19519) lands, so both resolvers are reachable only from tests today. Flip to `live` when #19519's resolved serve reaches them.", + "note": "Both resolvers are reached on the real /meta read path: the ObjectQL registry serves a picklist-bound field with the list's resolved options (`packages/objectql/src/registry.ts#resolvePicklistOptions`), which `translateObject` relabels per request locale, and the artifact door registers `picklists:` as items, which `translatePicklist` relabels. Driven end to end by `packages/qa/dogfood/test/picklist-shared-across-objects.dogfood.test.ts`.", "children": { "label": { - "status": "planned", - "verifiedAt": "2026-09-30", + "status": "live", + "verifiedAt": "2026-10-01", "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupPicklistLabel (`picklists..label`, read by translatePicklist)", - "note": "The list's own display name — the served picklist item, which nothing serves until #19519." + "note": "The list's own display name on the served picklist item (`GET /meta/picklist/`)." }, "options": { - "status": "planned", - "verifiedAt": "2026-09-30", + "status": "live", + "verifiedAt": "2026-10-01", "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupPicklistOption (`picklists..options.`, read by translatePicklist and by translateObject for a field whose `picklist` names the list)", - "note": "Option labels, translated once and inherited by every referencing field; a field-level `objects..fields..options` entry stays the more specific and wins. Reached once #19519 serves the resolved options." + "note": "Option labels, translated once and inherited by every referencing field; a field-level `objects..fields..options` entry stays the more specific and wins." } } }, diff --git a/packages/spec/src/system/validation-message.ts b/packages/spec/src/system/validation-message.ts index a3301b2948b..ce00a927525 100644 --- a/packages/spec/src/system/validation-message.ts +++ b/packages/spec/src/system/validation-message.ts @@ -128,6 +128,13 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_option: '{{label}} must be one of: {{allowed}}', reference_not_found: '{{label}}: no {{target}} record has id "{{value}}"', invalid_option_value: '{{label}}: "{{value}}" is not one of: {{allowed}}', + // `invalid_option`'s picklist sentences: the field takes its options from a + // shared picklist, so the refusal names the list (`{{picklist}}`, a + // message-only parameter). `_unresolved` is the field whose list no loaded + // package declares, which accepts no value at all. + invalid_option_picklist: '{{label}} must be one of the values of picklist "{{picklist}}": {{allowed}}', + invalid_option_value_picklist: '{{label}}: "{{value}}" is not a value of picklist "{{picklist}}": {{allowed}}', + invalid_option_picklist_unresolved: '{{label}} takes its values from picklist "{{picklist}}", which no loaded package declares, so no value can be accepted', // `value_domain` (ADR-0114 member; the field-level `valueDomain` card's // spec half) — the code-named default names the domain by its machine // word; the three finer variants (one per vocabulary member, rendering @@ -177,6 +184,9 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_option: '{{label}}必须是以下值之一:{{allowed}}', reference_not_found: '{{label}}:不存在 id 为“{{value}}”的{{target}}记录', invalid_option_value: '{{label}}:“{{value}}”不在允许的取值范围内:{{allowed}}', + invalid_option_picklist: '{{label}}必须是选项列表“{{picklist}}”中的值之一:{{allowed}}', + invalid_option_value_picklist: '{{label}}:“{{value}}”不是选项列表“{{picklist}}”中的值:{{allowed}}', + invalid_option_picklist_unresolved: '{{label}}的取值来自选项列表“{{picklist}}”,但没有已加载的包声明该列表,因此无法接受任何值', value_domain: '{{label}}必须是 {{valueDomain}} 值域的成员(当前 “{{value}}”)', value_domain_iana_time_zone: '{{label}}必须是有效的 IANA 时区标识符,例如 Europe/Zurich(当前 “{{value}}”)', value_domain_iso_4217_currency: '{{label}}必须是有效的 ISO 4217 货币代码,例如 CHF(当前 “{{value}}”)', @@ -219,6 +229,9 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_option: '{{label}}は次のいずれかを指定してください:{{allowed}}', reference_not_found: '{{label}}:id が「{{value}}」の{{target}}レコードは存在しません', invalid_option_value: '{{label}}:「{{value}}」は指定できません(指定可能:{{allowed}})', + invalid_option_picklist: '{{label}}は選択リスト「{{picklist}}」の値のいずれかを指定してください:{{allowed}}', + invalid_option_value_picklist: '{{label}}:「{{value}}」は選択リスト「{{picklist}}」の値ではありません(指定可能:{{allowed}})', + invalid_option_picklist_unresolved: '{{label}}の値は選択リスト「{{picklist}}」から取りますが、読み込まれたパッケージにこのリストがないため、値を受け付けられません', value_domain: '{{label}}は {{valueDomain}} 値ドメインのメンバーでなければなりません(現在「{{value}}」)', value_domain_iana_time_zone: '{{label}}は有効な IANA タイムゾーン識別子でなければなりません(例: Europe/Zurich、現在「{{value}}」)', value_domain_iso_4217_currency: '{{label}}は有効な ISO 4217 通貨コードでなければなりません(例: CHF、現在「{{value}}」)', @@ -261,6 +274,9 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_option: '{{label}} debe ser uno de: {{allowed}}', reference_not_found: '{{label}}: ningún registro de {{target}} tiene el id «{{value}}»', invalid_option_value: '{{label}}: «{{value}}» no es uno de: {{allowed}}', + invalid_option_picklist: '{{label}} debe ser uno de los valores de la lista de selección «{{picklist}}»: {{allowed}}', + invalid_option_value_picklist: '{{label}}: «{{value}}» no es un valor de la lista de selección «{{picklist}}»: {{allowed}}', + invalid_option_picklist_unresolved: '{{label}} toma sus valores de la lista de selección «{{picklist}}», que ningún paquete cargado declara, así que no se puede aceptar ningún valor', value_domain: '{{label}} debe pertenecer al dominio de valores {{valueDomain}} (actual: «{{value}}»)', value_domain_iana_time_zone: '{{label}} debe ser un identificador de zona horaria IANA válido, p. ej. Europe/Zurich (actual: «{{value}}»)', value_domain_iso_4217_currency: '{{label}} debe ser un código de moneda ISO 4217 válido, p. ej. CHF (actual: «{{value}}»)', diff --git a/scripts/check-stack-collection-maps.mjs b/scripts/check-stack-collection-maps.mjs index 75c50a96102..b14df4578f1 100644 --- a/scripts/check-stack-collection-maps.mjs +++ b/scripts/check-stack-collection-maps.mjs @@ -619,15 +619,6 @@ const SITES = [ 'ADR-0090 D3 positions reach the registry through the security bootstrap, which reads them off the ' + 'stack directly; the loop\'s sibling `permissions` entry is what makes the absence look like a gap.', }, - { - direction: 'missing', - keys: ['picklists', 'picklistExtensions'], - reason: - 'PENDING the picklist runtime layer (#19519): the spec declares `picklists` and ' - + '`picklistExtensions` ahead of their reader, by ruling (the spec layer lands first). Registering ' - + 'them — and merging the extensions into their target list — is that layer\'s work, so this row ' - + 'goes STALE, and fails, the day it lands. The liveness ledger grades the same keys `planned`.', - }, ], }, { @@ -687,12 +678,12 @@ const SITES = [ }, { direction: 'missing', - keys: ['picklists', 'picklistExtensions'], + keys: ['picklistExtensions'], reason: - 'PENDING the picklist runtime layer (#19519): the spec declares `picklists` and ' - + '`picklistExtensions` ahead of their reader, by ruling (the spec layer lands first). Registering ' - + 'them — and merging the extensions into their target list — is that layer\'s work, so this row ' - + 'goes STALE, and fails, the day it lands. The liveness ledger grades the same keys `planned`.', + 'registered by a DIFFERENT seam rather than dropped: an extension is not an item of its own — it ' + + 'has no name, only the list it adds options to — so it never becomes a metadata item. The ' + + 'ObjectQL registry merges it into that list (additive only, a repeated value refused), which is ' + + 'where every field bound to the list reads its options; `picklists` itself IS mapped here.', }, ], },