From 23083aa1817386ef597a3a18938c465f3284df0c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 21:45:41 +0000 Subject: [PATCH 1/4] fix(objectql)!: an option patch is judged against the owner's declared options in the registry fold, as at the install door and os validate (#28809) Co-Authored-By: Claude --- ...8809-field-patch-owner-declared-options.md | 19 +++ .../docs/data-modeling/object-extensions.mdx | 6 + .../cli/src/utils/field-patch-targets.test.ts | 16 +++ ...-install-local-field-patch-targets.test.ts | 21 ++- .../src/extension-field-patch-targets.ts | 9 +- .../registry-extension-field-patches.test.ts | 121 ++++++++++++++++++ packages/objectql/src/registry.ts | 112 ++++++++++++---- 7 files changed, 273 insertions(+), 31 deletions(-) create mode 100644 .changeset/28809-field-patch-owner-declared-options.md diff --git a/.changeset/28809-field-patch-owner-declared-options.md b/.changeset/28809-field-patch-owner-declared-options.md new file mode 100644 index 00000000000..0925676146c --- /dev/null +++ b/.changeset/28809-field-patch-owner-declared-options.md @@ -0,0 +1,19 @@ +--- +'@objectstack/objectql': minor +--- + +fix(objectql)!: the registry fold judges an option patch against the options the object's owner declares, as the install door and `os validate` do (#28809) + +Clause-②: yes (narrowing) — the object fold every read serves no longer applies a `fieldPatches` option patch whose option only an extension's `fields` redeclaration of the owner field declares; on `main` it applied such a patch when the redeclaring extension folded first. Nothing is widened, and no door's accept set moves. +Metadata-protocol: narrowed + + + +**BREAKING** narrowing in the object fold every read serves (`@objectstack/objectql`). + +- **Before.** An extension can take an owner field over through `fields` and add an option the owner does not declare. A `fieldPatches` option patch of that added option was judged by the fold against the field as merged so far, so it applied whenever the redeclaring extension folded first (a lower `priority`, or the same extension). The install door and `os validate` judge the owner's own declaration and refused the same patch. +- **Now.** The fold asks the one judge (`fieldPatchTargetMisses`, `@objectstack/metadata-core`) of the owner's fields as the owner declares them, and applies only what it admits. A patch of an option the owner does not declare is not applied and is named once in the log, whatever the priorities, as any other unmatched patch is. Which fields a patch reaches is unchanged: the owner's own. +- **Residual, unchanged rule.** When an extension's redeclaration leaves out an option the owner declares, a patch of that option is admitted at all three readers, and the fold has no such option to relabel, so nothing of it applies and nothing is named while that extension is installed. +- **Who sees it.** A package whose extension patches an option that only an extension's `fields` redeclaration adds. Measured in this repository: none (`fieldPatches` appears in no package or example outside the implementing code and tests). + +**The remedy.** Set that option's label where the option is declared: in the `fields` entry of the extension that redeclares the field. diff --git a/content/docs/data-modeling/object-extensions.mdx b/content/docs/data-modeling/object-extensions.mdx index f737ffb00d3..dc2a00bbe91 100644 --- a/content/docs/data-modeling/object-extensions.mdx +++ b/content/docs/data-modeling/object-extensions.mdx @@ -141,6 +141,12 @@ reason. `os validate` when the target is in the same stack. A field whose options come from a shared picklist has no option labels of its own to patch. At boot, an unmatched patch is not applied and is named once in the log; the boot carries on. +- **Options are the owner's too.** A patch is judged against the options the owner + declares, everywhere: an option another extension (or your own) adds by replacing the + field through `fields` is not one a patch can name, so the install door and + `os validate` refuse it and at boot it is not applied and is named once. If an + extension that replaces the field leaves out an option the owner declares, a patch of + that option is admitted but has nothing to relabel while that extension is installed. --- diff --git a/packages/cli/src/utils/field-patch-targets.test.ts b/packages/cli/src/utils/field-patch-targets.test.ts index 2d5dfc00ea5..fbbd5c32c32 100644 --- a/packages/cli/src/utils/field-patch-targets.test.ts +++ b/packages/cli/src/utils/field-patch-targets.test.ts @@ -35,6 +35,22 @@ describe('findFieldPatchTargetRefusals', () => { })).toEqual([]); }); + it('[#28809] judges an option against the declared object, never against an extension that redeclares the field through `fields`', () => { + const takeover = { industry: { ...account.fields.industry, options: [...account.fields.industry.options, { value: 'finance', label: 'Finance' }] } }; + const refusals = findFieldPatchTargetRefusals({ + objects: [account], + objectExtensions: [ + { extend: 'fpt_account', fields: takeover }, + { extend: 'fpt_account', fields: takeover, fieldPatches: { industry: { options: { finance: { label: 'Banking' } } } } }, + { extend: 'fpt_account', fieldPatches: { industry: { options: { finance: { label: 'Banking' } } } } }, + ], + }); + expect(refusals.map((r) => r.path)).toEqual([ + 'objectExtensions[1].fieldPatches.industry.options.finance', + 'objectExtensions[2].fieldPatches.industry.options.finance', + ]); + }); + it('leaves a target outside the composed stack to the install door', () => { expect(findFieldPatchTargetRefusals({ objectExtensions: [{ extend: 'crm_account', fieldPatches: { anything: { label: 'X' } } }], diff --git a/packages/cloud-connection/src/marketplace-install-local-field-patch-targets.test.ts b/packages/cloud-connection/src/marketplace-install-local-field-patch-targets.test.ts index f05b6897aa5..63ad1977bad 100644 --- a/packages/cloud-connection/src/marketplace-install-local-field-patch-targets.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-field-patch-targets.test.ts @@ -75,10 +75,18 @@ function makeC(body: any) { * A real registry holding the installed vendor object, and — when `region` is * set — an installed extension that adds `crm_account.region`. */ -function installedObjects({ region = false } = {}): SchemaRegistry { +function installedObjects({ region = false, takeover = false } = {}): SchemaRegistry { const reg = new SchemaRegistry({ multiTenant: false, searchCompanion: false } as never); reg.logLevel = 'silent'; reg.registerObject(structuredClone(ACCOUNT) as any, BASE, undefined, 'own'); + if (takeover) { + // [#28809] An installed extension that takes `industry` over through + // `fields`, adding the option `finance` the owner does not declare. + reg.registerObject( + { name: ACCOUNT.name, fields: { industry: { ...ACCOUNT.fields.industry, options: [...ACCOUNT.fields.industry.options, { value: 'finance', label: 'Finance' }] } } } as any, + 'com.example.crm-industry', undefined, 'extend', 100, + ); + } if (region) { reg.registerObject( { name: ACCOUNT.name, fields: { region: { type: 'text', label: 'Region' } } } as any, @@ -192,6 +200,17 @@ describe('install-local 2e — the field-patch target check (ADR-0146 D10)', () expect(h.syncs()).toBe(0); }); + it('[#28809] refuses a patch of an option only another installed extension adds by redeclaring the owner field, as the fold leaves it unapplied', async () => { + const objects = installedObjects({ takeover: true }); + // The resolved object does carry the option; the owner does not declare it. + expect((objects.getObject(ACCOUNT.name) as any).fields.industry.options.map((o: any) => o.value)).toContain('finance'); + const h = await mount(dir, objects); + const res = await h.install({ manifest: extensionPackage({ industry: { options: { finance: { label: 'Banking' } } } }) }); + expectRefusal(res, ['objectExtensions[0].fieldPatches.industry.options.finance']); + expect(res.payload.error.details.refusals[0]).toMatchObject({ object: 'crm_account', field: 'industry', option: 'finance', reason: 'option' }); + expect(h.installRegistrations()).toEqual([]); + }); + it('…while a patch of the owner\'s own field installs beside that extension', async () => { const h = await mount(dir, installedObjects({ region: true })); const res = await h.install({ manifest: extensionPackage({ industry: { label: 'Sector' } }) }); diff --git a/packages/metadata-core/src/extension-field-patch-targets.ts b/packages/metadata-core/src/extension-field-patch-targets.ts index bcd9d554148..ae6aea8aad7 100644 --- a/packages/metadata-core/src/extension-field-patch-targets.ts +++ b/packages/metadata-core/src/extension-field-patch-targets.ts @@ -84,10 +84,11 @@ function optionValuesOf(field: Record): string[] { * matched. * * `target` is the extended object's OWN declaration as the reader sees it — - * a patch reaches the owner's own fields only, never one an extension adds: - * the registry's owner declaration at the install door, the owner's fields as - * the fold has merged them in the fold, the composed stack's declaration at - * `os validate`. + * a patch reaches the owner's own fields only, never one an extension adds, + * and an option the owner's field declares, never one an extension adds by + * redeclaring the field through `fields` (#28809): the registry's owner + * declaration at the install door and in the fold, the composed stack's + * declaration at `os validate`. */ export function fieldPatchTargetMisses(object: string, fieldPatches: unknown, target: unknown): FieldPatchTargetMiss[] { if (!isRecord(fieldPatches)) return []; diff --git a/packages/objectql/src/registry-extension-field-patches.test.ts b/packages/objectql/src/registry-extension-field-patches.test.ts index 93c08e7940a..bec01700851 100644 --- a/packages/objectql/src/registry-extension-field-patches.test.ts +++ b/packages/objectql/src/registry-extension-field-patches.test.ts @@ -20,6 +20,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { SchemaRegistry } from './registry.js'; import { ObjectQL } from './engine.js'; +import { fieldPatchTargetRefusals } from '@objectstack/metadata-core'; const OWNER_PKG = 'com.vendor.crm'; const EXT_PKG = 'com.acme.crm-display'; @@ -243,6 +244,126 @@ describe('ADR-0146 D10 — fieldPatches is applied by mergeObjectDefinitions on }); }); +describe('#28809 — an option patch is judged against the owner\'s declared options, at the fold as at the doors', () => { + const TAKEOVER_PKG = 'com.acme.industry-takeover'; + + /** + * `crm_account.industry` taken over through `fields`: the owner's two + * options, the second relabelled, plus `finance`, which the owner does not + * declare — or, with `dropRetail`, the owner's `tech` alone. + */ + const takenOverIndustry = ({ dropRetail = false } = {}) => ({ + ...vendorAccount().fields.industry, + options: dropRetail + ? [{ value: 'tech', label: 'Tech' }] + : [ + { value: 'tech', label: 'Tech' }, + { value: 'retail', label: 'Retail stores' }, + { value: 'finance', label: 'Finance' }, + ], + }); + + function takeOver(reg: SchemaRegistry, priority: number, opts: { dropRetail?: boolean } = {}): void { + reg.registerObject( + { name: OBJ, fields: { industry: takenOverIndustry(opts) } } as any, + TAKEOVER_PKG, undefined, 'extend', priority, + ); + } + + /** + * What the install door (`getObjectOwner(name).definition`) and `os validate` + * (the composed stack's declared object) both ask: the one judge, against the + * owner's declaration. The fold must answer the same. + */ + const doorRefusals = (reg: SchemaRegistry, objectExtensions: unknown[]) => + fieldPatchTargetRefusals(objectExtensions, (name) => reg.getObjectOwner(name)?.definition).map((r) => r.path); + + const optionLabel = (body: any, value: string) => + industryOf(body).options.find((o: any) => o.value === value)?.label; + + const patchLines = (warn: ReturnType, needle: string) => + warn.mock.calls.map((c) => String(c[0])).filter((l) => l.includes(needle)); + + it('another extension adds an option by redeclaring the owner field: a patch of that option is not applied, and is named once — as the door and os validate refuse it', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const reg = withVendor(); + takeOver(reg, 100); + const patch = { industry: { options: { finance: { label: 'Banking' } } } }; + extend(reg, patch, EXT_PKG, 200); + + expect(doorRefusals(reg, [{ extend: OBJ, fieldPatches: patch }])) + .toEqual(['objectExtensions[0].fieldPatches.industry.options.finance']); + const served = reg.getObject(OBJ) as any; + // The takeover itself still serves: the option exists, with its own label. + expect(optionLabel(served, 'finance')).toBe('Finance'); + reg.invalidateAll(); + reg.getObject(OBJ); + const lines = patchLines(warn, 'fieldPatches.industry.options.finance'); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain(EXT_PKG); + }); + + it('…and the same extension redeclaring the field and patching its added option is not applied either, and is named once', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const reg = withVendor(); + const entry = { + fields: { industry: takenOverIndustry() }, + fieldPatches: { industry: { options: { finance: { label: 'Banking' } } } }, + }; + reg.registerObject({ name: OBJ, ...entry } as any, EXT_PKG, undefined, 'extend', 200); + + expect(doorRefusals(reg, [{ extend: OBJ, ...entry }])) + .toEqual(['objectExtensions[0].fieldPatches.industry.options.finance']); + expect(optionLabel(reg.getObject(OBJ), 'finance')).toBe('Finance'); + expect(patchLines(warn, 'fieldPatches.industry.options.finance')).toHaveLength(1); + }); + + it('control: the patcher folded BEFORE the redeclaring extension (lower priority) — not applied and named, as on main', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const reg = withVendor(); + takeOver(reg, 200); + extend(reg, { industry: { options: { finance: { label: 'Banking' } } } }, EXT_PKG, 100); + + expect(optionLabel(reg.getObject(OBJ), 'finance')).toBe('Finance'); + expect(patchLines(warn, 'fieldPatches.industry.options.finance')).toHaveLength(1); + }); + + it('control: a patch of an option the owner declares still applies onto the redeclared field, and nothing is named', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const reg = withVendor(); + takeOver(reg, 100); + const patch = { industry: { label: 'Sector', options: { tech: { label: 'Technology' }, retail: { label: 'Shops' } } } }; + extend(reg, patch, EXT_PKG, 200); + + expect(doorRefusals(reg, [{ extend: OBJ, fieldPatches: patch }])).toEqual([]); + const served = reg.getObject(OBJ) as any; + expect(industryOf(served).label).toBe('Sector'); + expect(optionLabel(served, 'tech')).toBe('Technology'); + expect(optionLabel(served, 'retail')).toBe('Shops'); + expect(optionLabel(served, 'finance')).toBe('Finance'); + expect(patchLines(warn, 'fieldPatches')).toEqual([]); + }); + + it('residual, pinned as measured: an owner option an earlier extension\'s redeclaration dropped — the judge admits the patch, the fold has no such option to relabel, nothing is applied and nothing is named', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const reg = withVendor(); + takeOver(reg, 100, { dropRetail: true }); + const patch = { industry: { label: 'Sector', options: { retail: { label: 'Shops' } } } }; + extend(reg, patch, EXT_PKG, 200); + + // The door and os validate admit it: `retail` is in the owner's declaration. + expect(doorRefusals(reg, [{ extend: OBJ, fieldPatches: patch }])).toEqual([]); + const served = reg.getObject(OBJ) as any; + // The takeover's options are served as the takeover declares them; the + // patch's field key still applies. + expect(industryOf(served).options).toEqual([{ value: 'tech', label: 'Tech' }]); + expect(industryOf(served).label).toBe('Sector'); + reg.invalidateAll(); + reg.getObject(OBJ); + expect(patchLines(warn, 'fieldPatches')).toEqual([]); + }); +}); + describe('ADR-0146 D10 — the manifest loop carries fieldPatches onto the extension def', () => { it('a package\'s objectExtensions entry patches the target through ObjectQL.registerApp', () => { const ql = new ObjectQL(); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 2f4a5f86b05..82dbf17264e 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -41,6 +41,7 @@ import { // door's and `os validate`'s judge, asked here so the fold names a patch it // cannot apply by the same verdict those doors refuse it by. fieldPatchTargetMisses, + type FieldPatchTargetMiss, } from '@objectstack/metadata-core'; // [ADR-0029 D9.2a] `scalarOverridesPackagedBase` is the #8284 comparison, imported rather // than re-spelled: the object FOLD asks the same question one layer down (has @@ -554,18 +555,28 @@ function ownFieldNamesOf(owner: unknown): ReadonlySet | undefined { } /** - * [ADR-0146 D10, second rule] What a field patch is judged against in the fold: - * `object` with only the fields of `ownFields`, in the record spelling, as they - * stand at that point of the fold. The fold applies a patch to exactly these - * fields ({@link applyFieldPatches}), so what it names is what it skipped. + * [ADR-0146 D10, second rule; #28809] What a field patch is judged against in + * the fold: the owner's declaration (`owner`) with only the fields of + * `ownFields` the fold carries at that point in the record spelling + * (`carried`) — which fields a patch reaches is the owner's own, unchanged — + * each field AS THE OWNER DECLARES IT, never as an extension's `fields` + * redeclared it. The install door (`getObjectOwner(name).definition`) and + * `os validate` (the declared object) cannot see another package's extension, + * so the owner's own field and options are the one answer all three readers + * share: an option an extension adds by taking the field over through `fields` + * is not one a patch can name. */ -function ownFieldsTarget(object: ServiceObject, ownFields: ReadonlySet): Record { +function ownFieldsTarget( + owner: unknown, + ownFields: ReadonlySet, + carried: (name: string) => boolean, +): Record { const fields: Record = {}; for (const name of ownFields) { - const field = recordFieldOf(object, name); - if (field) fields[name] = field; + const field = recordFieldOf(owner, name); + if (field && carried(name)) fields[name] = field; } - return { ...object, fields }; + return { ...(isPlainRecord(owner) ? owner : {}), fields }; } /** The option of a field's own `options` whose stored `value` is `value`. */ @@ -642,6 +653,12 @@ function patchedField( * or one only an extension adds — is skipped here, and named by the fold * ({@link SchemaRegistry.reportFieldPatchMisses}). Nothing applies when no * owner is registered. Returns `fields` BY REFERENCE when nothing applies. + * + * [#28809] The fold hands this only the patches the judge admitted against the + * owner's declaration ({@link admittedFieldPatches}). An admitted option patch + * whose option an earlier extension's `fields` redeclaration dropped finds no + * option here, so nothing of it applies; the judge admitted it, so nothing is + * named either. */ function applyFieldPatches( fields: ServiceObject['fields'], @@ -661,6 +678,33 @@ function applyFieldPatches( return (out ?? fields) as ServiceObject['fields']; } +/** + * [ADR-0146 D10, second rule; #28809] `patches` without what the judge missed + * against the owner's declaration: a missed field's whole patch, a missed + * option's entry under `options`. The fold applies exactly what the install + * door and `os validate` admit, and names exactly what they refuse. Returns + * `patches` BY REFERENCE when nothing was missed. + */ +function admittedFieldPatches( + patches: ObjectFieldPatches, + misses: readonly FieldPatchTargetMiss[], +): ObjectFieldPatches { + if (misses.length === 0) return patches; + const out: Record = { ...patches }; + for (const miss of misses) { + const patch = out[miss.field]; + if (!patch) continue; + if (miss.option === undefined) { + delete out[miss.field]; + continue; + } + if (!patch.options) continue; + const { [miss.option]: _missed, ...options } = patch.options; + out[miss.field] = { ...patch, options }; + } + return out as ObjectFieldPatches; +} + /** * [#28356] Which layer an object fold serves: `'served'`, what every read * answers (the bound actions the registry serves, runtime-authored included), @@ -4352,7 +4396,8 @@ export class SchemaRegistry { // base, for the scalars' reason above. const tenantPatched = this.tenantPatchedDisplayKeys(contributors, baseDefinition); // [ADR-0146 D10, second rule] The fields a patch reaches: the owner's own. - const ownFields = ownFieldNamesOf(contributors.find((c) => c.ownership === 'own')?.definition); + const owner = contributors.find((c) => c.ownership === 'own')?.definition; + const ownFields = ownFieldNamesOf(owner); let merged = tenantRules ? this.withPackagedOwnerRules(contributors, baseDefinition, tenantRules) : { ...baseDefinition }; @@ -4360,8 +4405,25 @@ export class SchemaRegistry { if (contrib.ownership === 'extend') { let extension = governAudit ? applyAuditFieldGovernance(contrib.definition) : contrib.definition; if (governQueue) extension = this.withoutExtensionQueueField(extension, contrib.packageId); - merged = mergeObjectDefinitions(merged, extension, tenantAuthored, tenantRules, tenantPatched, ownFields); - this.reportFieldPatchMisses(merged, extension, contrib.packageId, ownFields); + // [#28809] The judge, asked of the owner's fields as the OWNER declares + // them (what the install door and `os validate` read), never as an + // extension's `fields` redeclared them; the merge applies only what it + // admits. A field counts as carried as the merge below will carry it: + // in the running body's record spelling, or redeclared by this body. + const patches = (extension as ObjectExtensionBody).fieldPatches; + const running = merged; + const misses = patches && owner && ownFields + ? fieldPatchTargetMisses( + String(baseDefinition.name ?? extension.name ?? ''), + patches, + ownFieldsTarget(owner, ownFields, (name) => !!(recordFieldOf(running, name) ?? recordFieldOf(extension, name))), + ) + : []; + const admitted = misses.length > 0 + ? { ...extension, fieldPatches: admittedFieldPatches(patches!, misses) } as ObjectExtensionBody + : extension; + merged = mergeObjectDefinitions(merged, admitted, tenantAuthored, tenantRules, tenantPatched, ownFields); + this.reportFieldPatchMisses(misses, contrib.packageId); } } if (tenantRules) this.reportChangedRuleCopies(contributors, baseDefinition); @@ -4379,15 +4441,16 @@ export class SchemaRegistry { private fieldPatchMissesWarned = new Set(); /** - * [ADR-0146 D10, second rule] Name, once, each patch of `extension` the fold - * could not apply: a field that is not one of the owner's own (absent, or - * added only by an extension), an option value its field does not have, or - * an option of a picklist-bound field. The verdict is the install door's and - * `os validate`'s own judge (`fieldPatchTargetMisses`), asked of the owner's - * fields ({@link ownFieldsTarget}) as the install door asks it of the - * owner's declaration, so the boot names exactly what those doors refuse. - * With no owner registered there is nothing to judge against, and nothing - * is named. + * [ADR-0146 D10, second rule] Name, once, each patch the fold did not apply + * because the judge missed it: a field that is not one of the owner's own + * (absent, or added only by an extension), an option value the owner's field + * does not declare (one an extension adds by redeclaring the field through + * `fields` included, #28809), or an option of a picklist-bound field. The + * verdict (`misses`) is the install door's and `os validate`'s own judge + * (`fieldPatchTargetMisses`), asked by the fold of the owner's declaration + * ({@link ownFieldsTarget}) as those doors ask it, so the boot names exactly + * what they refuse. With no owner registered there is nothing to judge + * against, and nothing is named. * * ⛔ Never a refusal, and worded as the state it observes: the fold runs on * every read while a boot is still registering packages, and an owner that @@ -4397,14 +4460,11 @@ export class SchemaRegistry { * is lost. */ private reportFieldPatchMisses( - target: ServiceObject, - extension: ObjectExtensionBody, + misses: readonly FieldPatchTargetMiss[], packageId: string | undefined, - ownFields: ReadonlySet | undefined, ): void { - if (!extension.fieldPatches || !ownFields) return; - const object = String(target.name ?? extension.name ?? ''); - for (const miss of fieldPatchTargetMisses(object, extension.fieldPatches, ownFieldsTarget(target, ownFields))) { + for (const miss of misses) { + const object = miss.object; const key = `${object}|${packageId ?? ''}|${miss.field}|${miss.option ?? ''}`; if (this.fieldPatchMissesWarned.has(key)) continue; this.fieldPatchMissesWarned.add(key); From da4a27a4e3a6009cf6190581edf7cb9616a5a566 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 23:31:24 +0000 Subject: [PATCH 2/4] test(objectql): type the warn-line helper in the field-patch option cases for the test typecheck (#28809) Co-Authored-By: Claude --- packages/objectql/src/registry-extension-field-patches.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/objectql/src/registry-extension-field-patches.test.ts b/packages/objectql/src/registry-extension-field-patches.test.ts index bec01700851..bb4a390f032 100644 --- a/packages/objectql/src/registry-extension-field-patches.test.ts +++ b/packages/objectql/src/registry-extension-field-patches.test.ts @@ -281,7 +281,7 @@ describe('#28809 — an option patch is judged against the owner\'s declared opt const optionLabel = (body: any, value: string) => industryOf(body).options.find((o: any) => o.value === value)?.label; - const patchLines = (warn: ReturnType, needle: string) => + const patchLines = (warn: { mock: { calls: unknown[][] } }, needle: string): string[] => warn.mock.calls.map((c) => String(c[0])).filter((l) => l.includes(needle)); it('another extension adds an option by redeclaring the owner field: a patch of that option is not applied, and is named once — as the door and os validate refuse it', () => { From f6206b538ec272225501a96bec3830fa51ec09dd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 00:03:22 +0000 Subject: [PATCH 3/4] fix(objectql): the fold names an admitted option patch an earlier layer left out, by the same judge asked of the merged fields (#28809) Co-Authored-By: Claude --- .../registry-extension-field-patches.test.ts | 28 ++++++++++++++++-- packages/objectql/src/registry.ts | 29 +++++++++++++++---- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/packages/objectql/src/registry-extension-field-patches.test.ts b/packages/objectql/src/registry-extension-field-patches.test.ts index bb4a390f032..e79fd4113d5 100644 --- a/packages/objectql/src/registry-extension-field-patches.test.ts +++ b/packages/objectql/src/registry-extension-field-patches.test.ts @@ -344,7 +344,7 @@ describe('#28809 — an option patch is judged against the owner\'s declared opt expect(patchLines(warn, 'fieldPatches')).toEqual([]); }); - it('residual, pinned as measured: an owner option an earlier extension\'s redeclaration dropped — the judge admits the patch, the fold has no such option to relabel, nothing is applied and nothing is named', () => { + it('residual: an owner option an earlier extension\'s redeclaration left out — the judge admits the patch, the fold has no such option to relabel, nothing of it is applied, and it is named once', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const reg = withVendor(); takeOver(reg, 100, { dropRetail: true }); @@ -360,7 +360,31 @@ describe('#28809 — an option patch is judged against the owner\'s declared opt expect(industryOf(served).label).toBe('Sector'); reg.invalidateAll(); reg.getObject(OBJ); - expect(patchLines(warn, 'fieldPatches')).toEqual([]); + // Named once (what the fold skipped), by the same judge asked of the merged field. + const lines = patchLines(warn, 'fieldPatches'); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('fieldPatches.industry.options.retail'); + expect(lines[0]).toContain(EXT_PKG); + }); + + it('residual: a tenant overlay base that leaves an owner option out — admitted, nothing to relabel, named once; an option the tenant adds is not patched', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const reg = withVendor(); + const patch = { industry: { options: { retail: { label: 'Shops' }, wholesale: { label: 'Bulk' } } } }; + extend(reg, patch, EXT_PKG, 200); + const overlay = vendorAccount(); + overlay.fields.industry.options = [{ value: 'tech', label: 'Tech' }, { value: 'wholesale', label: 'Wholesale' }]; + reg.registerObject({ ...overlay, _provenance: 'org' }, 'sys_metadata', undefined, 'overlay'); + + // The doors read the owner: `retail` admitted, `wholesale` (the tenant's) refused. + expect(doorRefusals(reg, [{ extend: OBJ, fieldPatches: patch }])) + .toEqual(['objectExtensions[0].fieldPatches.industry.options.wholesale']); + const served = reg.getObject(OBJ) as any; + expect(industryOf(served).options).toEqual([{ value: 'tech', label: 'Tech' }, { value: 'wholesale', label: 'Wholesale' }]); + reg.invalidateAll(); + reg.getObject(OBJ); + expect(patchLines(warn, 'fieldPatches.industry.options.retail')).toHaveLength(1); + expect(patchLines(warn, 'fieldPatches.industry.options.wholesale')).toHaveLength(1); }); }); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 82dbf17264e..750806eaf62 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -656,9 +656,10 @@ function patchedField( * * [#28809] The fold hands this only the patches the judge admitted against the * owner's declaration ({@link admittedFieldPatches}). An admitted option patch - * whose option an earlier extension's `fields` redeclaration dropped finds no - * option here, so nothing of it applies; the judge admitted it, so nothing is - * named either. + * whose option an earlier layer left out (an extension's `fields` + * redeclaration, a tenant overlay base) finds no option here, so nothing of it + * applies; the fold names it after the merge, by the same judge asked of the + * merged fields. */ function applyFieldPatches( fields: ServiceObject['fields'], @@ -4423,7 +4424,21 @@ export class SchemaRegistry { ? { ...extension, fieldPatches: admittedFieldPatches(patches!, misses) } as ObjectExtensionBody : extension; merged = mergeObjectDefinitions(merged, admitted, tenantAuthored, tenantRules, tenantPatched, ownFields); - this.reportFieldPatchMisses(misses, contrib.packageId); + // [#28809] …and the same judge once more, over the admitted patches + // against the owner's fields as MERGED, which only names: an admitted + // option an earlier layer left out (an extension's `fields` + // redeclaration, a tenant overlay base) had nothing to relabel, so the + // fold names it as it names any patch it skipped. Never drops, never + // refuses; admission above stays the owner's. + const admittedPatches = (admitted as ObjectExtensionBody).fieldPatches; + const residual = admittedPatches && owner && ownFields + ? fieldPatchTargetMisses( + String(baseDefinition.name ?? extension.name ?? ''), + admittedPatches, + ownFieldsTarget(merged, ownFields, (name) => !!recordFieldOf(merged, name)), + ) + : []; + this.reportFieldPatchMisses([...misses, ...residual], contrib.packageId); } } if (tenantRules) this.reportChangedRuleCopies(contributors, baseDefinition); @@ -4449,8 +4464,10 @@ export class SchemaRegistry { * verdict (`misses`) is the install door's and `os validate`'s own judge * (`fieldPatchTargetMisses`), asked by the fold of the owner's declaration * ({@link ownFieldsTarget}) as those doors ask it, so the boot names exactly - * what they refuse. With no owner registered there is nothing to judge - * against, and nothing is named. + * what they refuse — plus, asked again of the merged fields, an admitted + * option an earlier layer left out, which had nothing to relabel. What the + * fold names is what it skipped. With no owner registered there is nothing + * to judge against, and nothing is named. * * ⛔ Never a refusal, and worded as the state it observes: the fold runs on * every read while a boot is still registering packages, and an owner that From 301b4e3d8f13bae733c2cc5d3c9e4a516c0ef55a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 00:04:03 +0000 Subject: [PATCH 4/4] docs(objectql): changeset and docs name the tenant overlay base beside the extension redeclaration, and the residual named once (#28809) Co-Authored-By: Claude --- .changeset/28809-field-patch-owner-declared-options.md | 10 +++++----- content/docs/data-modeling/object-extensions.mdx | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.changeset/28809-field-patch-owner-declared-options.md b/.changeset/28809-field-patch-owner-declared-options.md index 0925676146c..35e1f148928 100644 --- a/.changeset/28809-field-patch-owner-declared-options.md +++ b/.changeset/28809-field-patch-owner-declared-options.md @@ -4,16 +4,16 @@ fix(objectql)!: the registry fold judges an option patch against the options the object's owner declares, as the install door and `os validate` do (#28809) -Clause-②: yes (narrowing) — the object fold every read serves no longer applies a `fieldPatches` option patch whose option only an extension's `fields` redeclaration of the owner field declares; on `main` it applied such a patch when the redeclaring extension folded first. Nothing is widened, and no door's accept set moves. +Clause-②: yes (narrowing) — the object fold every read serves no longer applies a `fieldPatches` option patch whose option the owner's field does not declare: one an extension's `fields` redeclaration adds (applied on `main` when the redeclaring extension folded first), or one a tenant overlay base adds (applied on `main`). Nothing is widened, and no door's accept set moves. Metadata-protocol: narrowed **BREAKING** narrowing in the object fold every read serves (`@objectstack/objectql`). -- **Before.** An extension can take an owner field over through `fields` and add an option the owner does not declare. A `fieldPatches` option patch of that added option was judged by the fold against the field as merged so far, so it applied whenever the redeclaring extension folded first (a lower `priority`, or the same extension). The install door and `os validate` judge the owner's own declaration and refused the same patch. +- **Before.** An extension can take an owner field over through `fields` and add an option the owner does not declare, and a tenant overlay base can add one too. A `fieldPatches` option patch of such an added option was judged by the fold against the field as merged so far, so it applied whenever the added option was already there: a tenant overlay base's option, or a redeclaring extension that folded first (a lower `priority`, or the same extension). The install door and `os validate` judge the owner's own declaration and refused the same patch. - **Now.** The fold asks the one judge (`fieldPatchTargetMisses`, `@objectstack/metadata-core`) of the owner's fields as the owner declares them, and applies only what it admits. A patch of an option the owner does not declare is not applied and is named once in the log, whatever the priorities, as any other unmatched patch is. Which fields a patch reaches is unchanged: the owner's own. -- **Residual, unchanged rule.** When an extension's redeclaration leaves out an option the owner declares, a patch of that option is admitted at all three readers, and the fold has no such option to relabel, so nothing of it applies and nothing is named while that extension is installed. -- **Who sees it.** A package whose extension patches an option that only an extension's `fields` redeclaration adds. Measured in this repository: none (`fieldPatches` appears in no package or example outside the implementing code and tests). +- **Residual, unchanged rule.** When an earlier layer leaves out an option the owner declares (an extension's `fields` redeclaration, or a tenant overlay base), a patch of that option is admitted at all three readers, and the fold has no such option to relabel, so nothing of it applies. The fold asks the same judge once more of the merged fields, which only names: the patch is named once, as on `main`. Nothing is dropped or refused by that second asking. +- **Who sees it.** A package whose extension patches an option the owner does not declare, which only an extension's `fields` redeclaration or a tenant overlay base adds. Both doors already refused such a package. Measured in this repository: none (`fieldPatches` appears in no package or example outside the implementing code and tests). -**The remedy.** Set that option's label where the option is declared: in the `fields` entry of the extension that redeclares the field. +**The remedy.** Set that option's label where the option is declared: in the `fields` entry of the extension that redeclares the field, or in the tenant's own customization of the field. diff --git a/content/docs/data-modeling/object-extensions.mdx b/content/docs/data-modeling/object-extensions.mdx index dc2a00bbe91..bc8450dd3bd 100644 --- a/content/docs/data-modeling/object-extensions.mdx +++ b/content/docs/data-modeling/object-extensions.mdx @@ -143,10 +143,10 @@ reason. is not applied and is named once in the log; the boot carries on. - **Options are the owner's too.** A patch is judged against the options the owner declares, everywhere: an option another extension (or your own) adds by replacing the - field through `fields` is not one a patch can name, so the install door and - `os validate` refuse it and at boot it is not applied and is named once. If an - extension that replaces the field leaves out an option the owner declares, a patch of - that option is admitted but has nothing to relabel while that extension is installed. + field through `fields`, or one a tenant's customization adds, is not one a patch can + name, so the install door and `os validate` refuse it and at boot it is not applied + and is named once. If an earlier layer leaves out an option the owner declares, a patch + of that option is admitted but has nothing to relabel, and the boot names it once. ---