From 5c7330e64b529ff9265b99b1b9eb016219463ef6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 03:39:33 +0000 Subject: [PATCH 1/2] fix(metadata-protocol): a package's list slot serves the stored row getMetaItem naming that package serves, in every row order The list built a package's slot from the latest of the package's row and the package-less row in row order. It now takes the served-row resolution the by-name read takes (servedOverlayRowCandidates: organization scope before env-wide, canonical spelling before the other, the package's own row before the package-less row), for the active merge and the draft-preview merge. findServedOverlayRow reads the same order from the store. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .changeset/21804-list-slot-prefer-local.md | 13 + .../protocol.list-slot-prefer-local.test.ts | 319 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 212 +++++++++--- 3 files changed, 489 insertions(+), 55 deletions(-) create mode 100644 .changeset/21804-list-slot-prefer-local.md create mode 100644 packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts diff --git a/.changeset/21804-list-slot-prefer-local.md b/.changeset/21804-list-slot-prefer-local.md new file mode 100644 index 00000000000..15f34c8a00f --- /dev/null +++ b/.changeset/21804-list-slot-prefer-local.md @@ -0,0 +1,13 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +fix(metadata-protocol): a package's slot in the metadata list serves the package's own stored row in every row order, as `getMetaItem` naming that package does (#21804) + +Clause-②: no + +ADR-0048 lets one item (type, name, organization scope) hold a package's stored `sys_metadata` row beside a package-less one. `getMetaItem` naming the package has always served the package's own row (prefer-local), and the organization's rows before the env-wide rows (ADR-0005). The list (`getMetaItems`, and the `getMetaItemsForExecution` view of it) built each package's slot from the LATEST of the package's row and the package-less row in the order the store returned them. So with both rows stored, the list served the package-less body for the package in one row order, and an env-wide row of the package could beat the organization's package-less row. + +Now the list and the by-name read take one resolution: the organization's rows, then the env-wide rows; within each, the type's canonical spelling, then the other; within each, the package's own row, then the package-less row, never another package's. A package's list slot serves the row `getMetaItem` naming that package serves, whatever the row order, and the `previewDrafts` list previews the draft the by-name read previews. A package with no row of its own still falls back to the package-less row, as before. The lock the list item reports is unchanged: it is still the strictest lock among the item's rows in scope. + +No key, export, status or error code changes. A list request scoped to one package (`packageId`) reads only that package's rows and is unchanged. diff --git a/packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts b/packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts new file mode 100644 index 00000000000..5fe4f92f2a1 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts @@ -0,0 +1,319 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21804, ADR-0048, ADR-0005] A package's slot in the metadata list serves the + * stored row `getMetaItem` naming that package serves, whatever order the + * store returns the rows in. + * + * The list builds each package's slot in `mergePackageAwareOverlay`. Its + * docblock promised `getMetaItem(name, packageId=P)`'s resolution, but it took + * the LATEST of the package's row and the package-less row in row order. So + * with both rows stored for one `(type, name, scope)`, the list served the + * package-less body in one order while the by-name read served the package's + * own row. The organization axis had the same shape: the list could serve an + * env-wide row of the package over the organization's package-less row, which + * the by-name read (organization first, ADR-0005) never does. + * + * Both now take one order, `servedOverlayRowCandidates` in `protocol.ts`: + * scope (the organization's rows, then env-wide), then spelling, then the + * package's own row before the package-less row. `findServedOverlayRow` reads + * it from the store; the list reads it over the rows it already holds. + * + * 1. The generated pin: every subset of five stored rows (env-wide and + * org-scoped, package-less and package A's, plus a third package B's + * env-wide row), in every order the store can return them, for a request + * with and without an organization. Every package slot in the list serves + * the row an oracle written from the rule names, and `getMetaItem` naming + * that package serves the same row. + * 2. Named: package A's row and the package-less row, both env-wide, both + * orders, with and without A's packaged artifact. The slot is A's row. + * 3. Named, organization scope: the organization's package-less row beats an + * env-wide row of A; the organization's row of A beats the env-wide + * package-less row. Both orders. + * 4. Control: only the package-less row is stored, under A's artifact. The + * slot still falls back to it (stamped as A's), as the by-name read does. + * 5. The draft preview: A's draft and a package-less draft, both orders. The + * previewed slot is A's draft, as `getMetaItem` with `previewDrafts`. + * + * Out of this card: a list scoped to a package (`packageId` on the list + * request) reads only that package's rows, so a package-less row never + * reaches its merge. That is the row read, not the merge, and these pins do + * not cover it. + */ +import { describe, expect, it } from 'vitest'; +import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const ORG = 'org_a'; +/** + * The type the tables run on: per-organization overridable (ADR-0005), so the + * organization axis is live, and with no expansion of its own in the list. + * (`view` upserts the items its stored containers expand by bare name, which + * folds two packages' same-name slots into one; that is the view expansion, + * not this merge, so pin 2 also runs on `view` with one package.) + */ +const TYPE = 'dashboard'; +const PKG_A = 'com.example.a'; +const PKG_B = 'com.example.b'; +const NAME = 'v_slot'; + +type Scope = 'env-wide' | 'org-scoped'; +interface RowSpec { scope: Scope; packageId: string | null; label: string } + +/** The stored rows the tables draw from. */ +const ROWS = { + envPackageless: { scope: 'env-wide', packageId: null, label: 'env-wide package-less row' }, + envA: { scope: 'env-wide', packageId: PKG_A, label: 'env-wide row of A' }, + envB: { scope: 'env-wide', packageId: PKG_B, label: 'env-wide row of B' }, + orgPackageless: { scope: 'org-scoped', packageId: null, label: 'org-scoped package-less row' }, + orgA: { scope: 'org-scoped', packageId: PKG_A, label: 'org-scoped row of A' }, +} as const satisfies Record; +type RowKey = keyof typeof ROWS; +const ROW_KEYS = Object.keys(ROWS) as RowKey[]; + +interface StoredRow { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; +} + +function stored(key: RowKey, state: 'active' | 'draft' = 'active', type: string = TYPE): StoredRow { + const spec: RowSpec = ROWS[key]; + const label = state === 'draft' ? `${spec.label} (draft)` : spec.label; + return { + id: `r_${key}_${state}`, + type, + name: NAME, + organization_id: spec.scope === 'org-scoped' ? ORG : null, + package_id: spec.packageId, + state, + metadata: JSON.stringify({ name: NAME, label, ...(type === 'view' ? { object: 'account' } : {}), _provenance: 'org' }), + }; +} + +/** What package A's loader registered for the name, when the row asks for it. */ +const ARTIFACT_A = { name: NAME, label: 'packaged by A', _packageId: PKG_A, _provenance: 'package' }; + +/** + * The engine double: `find` / `findOne` over `sys_metadata` rows, and a + * registry that holds at most package A's artifact, answering a + * package-scoped lookup with that package's items only. + */ +function harness(rows: StoredRow[], artifact = false, type: string = TYPE) { + const items: Array> = artifact ? [ARTIFACT_A] : []; + const ofPackage = (packageId?: string) => items.filter((i) => !packageId || i._packageId === packageId); + const registry = { + getArtifactItem(asked: string, name: string, packageId?: string) { + const hit = asked === type ? ofPackage(packageId).find((i) => i.name === name) : undefined; + return hit && isCodeArtifactBody(hit) ? hit : undefined; + }, + getItem(asked: string, name: string, packageId?: string) { + return asked === type ? ofPackage(packageId).find((i) => i.name === name) : undefined; + }, + listItems(asked: string, packageId?: string) { + return asked === type ? ofPackage(packageId) : []; + }, + getObject: () => undefined, + registerObject: () => undefined, + getPackage: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }; + const matching = (where: Record) => { + for (const k of Object.keys(where)) { + if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`); + } + return rows.filter((r) => + Object.entries(where).every(([k, v]) => v === undefined || (r as unknown as Record)[k] === v), + ); + }; + const engine: any = { + async find(table: string, opts?: { where?: Record; limit?: number }) { + if (table !== 'sys_metadata') return []; + const matched = matching(opts?.where ?? {}); + // `check:objectql-double-limit` — the caller's bound, applied after the filter. + return opts?.limit === undefined ? matched : matched.slice(0, opts.limit); + }, + async findOne(table: string, opts?: { where?: Record }) { + // `check:engine-double-contract` — refuses what the real engine refuses. + assertEngineFindOnePredicate(table, opts); + if (table !== 'sys_metadata') return null; + return matching(opts?.where ?? {})[0] ?? null; + }, + async insert() { + return {}; + }, + registry, + }; + return new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_1'); +} + +/** Every order the store can return `xs` in. */ +function orders(xs: readonly T[]): T[][] { + if (xs.length <= 1) return [[...xs]]; + return xs.flatMap((x, i) => orders([...xs.slice(0, i), ...xs.slice(i + 1)]).map((rest) => [x, ...rest])); +} + +/** Every subset of `xs`, the empty one included. */ +function subsets(xs: readonly T[]): T[][] { + return xs.reduce((acc, x) => [...acc, ...acc.map((s) => [...s, x])], [[]]); +} + +/** + * The oracle, written from the rule: the scopes the request reaches + * (ADR-0005: the organization's, then env-wide), and within one scope the + * package's own row before the package-less row (ADR-0048), never another + * package's. The first row found is the served row. + */ +function servedLabel(rows: readonly RowKey[], packageId: string, organizationId: string | undefined): string | undefined { + const scopes: Scope[] = organizationId ? ['org-scoped', 'env-wide'] : ['env-wide']; + for (const scope of scopes) { + for (const candidate of [packageId, null]) { + const hit = rows.find((key) => ROWS[key].scope === scope && ROWS[key].packageId === candidate); + if (hit) return ROWS[hit].label; + } + } + return undefined; +} + +/** The list's slot for `packageId` (the items of the name stamped with it). */ +async function listSlots( + protocol: ObjectStackProtocolImplementation, packageId: string, + request: { organizationId?: string; previewDrafts?: boolean } = {}, type: string = TYPE, +): Promise { + const listed: any = await protocol.getMetaItems({ type, ...request }); + return (listed.items as any[]).filter((item) => item?.name === NAME && item?._packageId === packageId); +} + +/** The body `getMetaItem` naming `packageId` serves. */ +async function byName( + protocol: ObjectStackProtocolImplementation, packageId: string, + request: { organizationId?: string; previewDrafts?: boolean } = {}, type: string = TYPE, +): Promise { + const read: any = await protocol.getMetaItem({ type, name: NAME, packageId, ...request }); + return read.item; +} + +// ── 1. The generated pin ───────────────────────────────────────────────────── + +describe('pin 1 (generated) — every package slot in the list serves the row getMetaItem naming that package serves, in every row order', () => { + const TABLE = subsets(ROW_KEYS).flatMap((rows) => + ([undefined, ORG] as const).map((organizationId) => ({ rows, organizationId }))); + + it('completeness: every subset of the five rows, with and without an organization, and both packages are reachable', () => { + expect(TABLE).toHaveLength(2 ** ROW_KEYS.length * 2); + expect(new Set(ROW_KEYS.map((key) => ROWS[key].packageId))).toEqual(new Set([null, PKG_A, PKG_B])); + expect(new Set(ROW_KEYS.map((key) => ROWS[key].scope))).toEqual(new Set(['env-wide', 'org-scoped'])); + // The arrangements this card is about are in the table: the package's + // row beside the package-less row of one scope, in more than one order. + const both = TABLE.filter(({ rows }) => + (rows.includes('envA') && rows.includes('envPackageless')) + || (rows.includes('orgA') && rows.includes('orgPackageless'))); + expect(both.length).toBeGreaterThan(0); + expect(both.every(({ rows }) => orders(rows).length >= 2)).toBe(true); + }); + + for (const { rows, organizationId } of TABLE) { + const title = `rows: ${rows.length === 0 ? 'none' : rows.map((key) => ROWS[key].label).join(' + ')}` + + ` · request: ${organizationId ? `organization ${organizationId}` : 'no organization'}`; + it(title, async () => { + for (const order of orders(rows)) { + const at = `${title} · row order ${order.join(', ') || '-'}`; + const protocol = harness(order.map((key) => stored(key))); + const scope = organizationId ? { organizationId } : {}; + for (const packageId of [PKG_A, PKG_B]) { + const expected = servedLabel(order, packageId, organizationId); + const inScope = order.some((key) => ROWS[key].packageId === packageId + && (ROWS[key].scope === 'env-wide' || organizationId !== undefined)); + const slots = await listSlots(protocol, packageId, scope); + if (!inScope) { + // No row of the package in scope and no artifact: the + // list has no slot for it. + expect(slots, `${at}: a slot for ${packageId}`).toEqual([]); + continue; + } + expect(slots.map((s) => s.label), `${at}: the list's slot for ${packageId}`).toEqual([expected]); + expect((await byName(protocol, packageId, scope))?.label, `${at}: getMetaItem naming ${packageId}`) + .toBe(expected); + } + } + }); + } +}); + +// ── 2–4. Named ─────────────────────────────────────────────────────────────── + +describe('pin 2 — the package\'s row beside the package-less row (one scope, env-wide): the slot is the package\'s row in both orders', () => { + for (const type of [TYPE, 'view']) { + for (const artifact of [false, true]) { + for (const order of orders(['envPackageless', 'envA'])) { + it(`/meta/${type} · ${artifact ? 'A\'s artifact registered' : 'no artifact'} · row order ${order.join(', ')}`, async () => { + const protocol = harness(order.map((key) => stored(key, 'active', type)), artifact, type); + const slots = await listSlots(protocol, PKG_A, {}, type); + expect(slots.map((s) => s.label)).toEqual([ROWS.envA.label]); + expect((await byName(protocol, PKG_A, {}, type))?.label).toBe(ROWS.envA.label); + // The package-less row is not listed beside it: it stands + // in only for a package with no row of its own. + const listed: any = await protocol.getMetaItems({ type }); + expect((listed.items as any[]).filter((item) => item?.name === NAME)).toHaveLength(1); + }); + } + } + } +}); + +describe('pin 3 — organization scope (ADR-0005): the organization\'s rows before the env-wide rows, then the package\'s own row', () => { + for (const order of orders(['orgPackageless', 'envA', 'envPackageless'])) { + it(`the organization's package-less row over an env-wide row of A · row order ${order.join(', ')}`, async () => { + const protocol = harness(order.map((key) => stored(key))); + const slots = await listSlots(protocol, PKG_A, { organizationId: ORG }); + expect(slots.map((s) => s.label)).toEqual([ROWS.orgPackageless.label]); + expect((await byName(protocol, PKG_A, { organizationId: ORG }))?.label).toBe(ROWS.orgPackageless.label); + }); + } + for (const order of orders(['orgA', 'envPackageless'])) { + it(`the organization's row of A over the env-wide package-less row · row order ${order.join(', ')}`, async () => { + const protocol = harness(order.map((key) => stored(key))); + const slots = await listSlots(protocol, PKG_A, { organizationId: ORG }); + expect(slots.map((s) => s.label)).toEqual([ROWS.orgA.label]); + expect((await byName(protocol, PKG_A, { organizationId: ORG }))?.label).toBe(ROWS.orgA.label); + }); + } +}); + +describe('pin 4 — control: with only the package-less row stored, the package\'s slot still falls back to it', () => { + it('A\'s artifact and the env-wide package-less row: the slot serves the row, stamped as A\'s', async () => { + const protocol = harness([stored('envPackageless')], true); + const slots = await listSlots(protocol, PKG_A); + expect(slots.map((s) => ({ label: s.label, packageId: s._packageId }))) + .toEqual([{ label: ROWS.envPackageless.label, packageId: PKG_A }]); + expect((await byName(protocol, PKG_A))?.label).toBe(ROWS.envPackageless.label); + }); + + it('lit control: A\'s artifact alone is served as itself', async () => { + const protocol = harness([], true); + const slots = await listSlots(protocol, PKG_A); + expect(slots.map((s) => s.label)).toEqual([ARTIFACT_A.label]); + expect((await byName(protocol, PKG_A))?.label).toBe(ARTIFACT_A.label); + }); +}); + +// ── 5. The draft preview ───────────────────────────────────────────────────── + +describe('pin 5 — the draft preview: the package\'s previewed slot is the draft getMetaItem previews, in both orders', () => { + for (const order of orders(['envPackageless', 'envA'])) { + it(`A's draft and the package-less draft · row order ${order.join(', ')}`, async () => { + const protocol = harness(order.map((key) => stored(key, 'draft')), true); + const slots = await listSlots(protocol, PKG_A, { previewDrafts: true }); + expect(slots.map((s) => ({ label: s.label, draft: s._draft }))) + .toEqual([{ label: `${ROWS.envA.label} (draft)`, draft: true }]); + const previewed = await byName(protocol, PKG_A, { previewDrafts: true }); + expect({ label: previewed?.label, draft: previewed?._draft }) + .toEqual({ label: `${ROWS.envA.label} (draft)`, draft: true }); + }); + } +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index c6bb8e6f428..c3958342651 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1921,6 +1921,69 @@ function storedRowDiscriminator(type: string, record: unknown): string | undefin return itemDiscriminator(type, body); } +/** + * One place a stored `sys_metadata` row of an item can sit, as + * {@link servedOverlayRowCandidates} orders them: a scope (the organization's, + * or env-wide with `organizationId: null`), a spelling of the type + * (`'canonical'`, or `'other'`, the type's singular/plural twin, pre-#4432 + * residue), and the package the row is bound to: the address's package, `null` + * for the package-less row, or `undefined` for any row (an address that names + * no package). + */ +interface ServedOverlayRowCandidate { + readonly scope: 'org' | 'env'; + readonly organizationId: string | null; + readonly spelling: 'canonical' | 'other'; + readonly packageId: string | null | undefined; +} + +/** + * [#21804] Where a stored row the list read sits, beside the package it is + * bound to: the scope it was read from (`null` for env-wide) and the type + * spelling it is stored under (the row's own `type` column). + */ +interface StoredRowPlace { + readonly organizationId: string | null; + readonly type: string; +} + +/** + * [#21804, ADR-0005, ADR-0048] THE served-row resolution: the order in which + * the stored rows of one item are tried for the CONTENT a read serves. The + * first candidate that holds a row is the served row. + * + * - Scope first (ADR-0005): the organization's rows, then the env-wide rows. + * Precedence, never a merge. + * - Then the spelling: the canonical type, then the other spelling (the + * at-rest tolerance the reads have always had, #4432). + * - Then the package (ADR-0048 prefer-local): with a `packageId`, that + * package's own row, then the package-less row, never another package's; + * with none, any row. + * + * Both readers of stored content take this order, so they cannot pick + * differently: {@link ObjectStackProtocolImplementation.findServedOverlayRow} + * (`getMetaItem`, its draft-preview arm, `getMetaItemLayered`) asks the store + * candidate by candidate, and {@link mergePackageAwareOverlay} (the list's + * active and draft-preview merges) asks the rows it already read. Before + * #21804 the list took the LATEST of a package's row and the package-less row + * in row order, so in one order the list served the package-less body for a + * package whose by-name read served the package's own row. + * + * It decides content only. The lock is selected separately, from every row in + * scope (`resolveOverlayLockLayer` in `item-lock.ts`, #21761). + */ +function servedOverlayRowCandidates(address: { + readonly organizationId: string | undefined; + readonly packageId: string | undefined; +}): ServedOverlayRowCandidate[] { + const scopes: Array> = address.organizationId + ? [{ scope: 'org', organizationId: address.organizationId }, { scope: 'env', organizationId: null }] + : [{ scope: 'env', organizationId: null }]; + const packages: Array = address.packageId ? [address.packageId, null] : [undefined]; + return scopes.flatMap((scope) => (['canonical', 'other'] as const).flatMap((spelling) => + packages.map((packageId) => ({ ...scope, spelling, packageId })))); +} + /** * ADR-0048 (#1828) — package-aware overlay merge for the unscoped metadata list. * @@ -1931,14 +1994,22 @@ function storedRowDiscriminator(type: string, record: unknown): string | undefin * • Two installed packages shipping the same `type/name` stay TWO rows — * resolution is per `(package, name)`, not bare `name`, so a higher-layer * row no longer collapses a same-name row from a different package. - * • For each package `P` that owns a row of a given name, the winner is the - * LATEST contribution that is either `P`'s own row or a package-less - * ("global", `package_id IS NULL`) row — mirroring - * `getMetaItem(name, packageId=P)`'s "scoped-then-global-fallback" - * resolution, so the list and single-item paths agree. This is also why a - * legacy row whose active/draft layers disagree on package attribution - * still collapses (a package-less active row + its `package_id`-bearing - * draft resolve to the one package slot, draft winning). + * • For each package `P` that owns a row of a given name, the winner is + * `P`'s own row or a package-less ("global", `package_id IS NULL`) row, + * the higher layer winning — `getMetaItem(name, packageId=P)`'s + * "scoped-then-global-fallback" resolution, so the list and single-item + * paths agree. This is also why a legacy row whose active/draft layers + * disagree on package attribution still collapses (a package-less active + * row + its `package_id`-bearing draft resolve to the one package slot, + * draft winning). + * • [#21804] When `records` are stored rows the caller read (`rowsRead`, + * each record's `stored` place), the winner among them is the one + * {@link servedOverlayRowCandidates} picks for `P`: the organization's row + * before the env-wide one, then `P`'s own before the package-less one. It + * is the resolution `getMetaItem` takes, so it does not depend on the + * order the store returned the rows in. Without `rowsRead` (the + * MetadataService merge, whose `records` are already-resolved items), + * the LATEST contribution wins, as before. * • A name with NO package-owned row resolves to its latest package-less * contribution — the pre-existing env-wide behaviour, unchanged. * @@ -1966,20 +2037,36 @@ function storedRowDiscriminator(type: string, record: unknown): string | undefin function mergePackageAwareOverlay( type: string, baseItems: unknown[], - records: Array<{ data: unknown; packageId: string | undefined }>, + records: Array<{ data: unknown; packageId: string | undefined; stored?: StoredRowPlace }>, transform?: (data: any, prev: any) => any, + rowsRead?: { readonly organizationId: string | undefined }, ): unknown[] { // Per-SLOT, layer-ordered contributions; `pkg: undefined` = package-less. - const buckets = new Map>(); + // `stored` marks a contribution that is a stored row the caller read. + type Contribution = { pkg: string | undefined; item: any; stored?: StoredRowPlace }; + const buckets = new Map(); const order: string[] = []; // first-seen slot order → stable output const slotOf = (item: unknown, name: unknown): string => { const disc = itemDiscriminator(type, item); return disc === undefined ? String(name) : `${String(name)}\u0000${disc}`; }; - const push = (slot: string, pkg: string | undefined, item: any) => { + const push = (slot: string, pkg: string | undefined, item: any, stored?: StoredRowPlace) => { let list = buckets.get(slot); if (!list) { buckets.set(slot, (list = [])); order.push(slot); } - list.push({ pkg, item }); + list.push(stored ? { pkg, item, stored } : { pkg, item }); + }; + // [#21804] The stored row the served-row resolution picks for package + // `real` among a slot's contributions: the first candidate of + // {@link servedOverlayRowCandidates} that one of the rows sits at. + const servedStoredRow = (list: readonly Contribution[], real: string | undefined): any => { + for (const candidate of servedOverlayRowCandidates({ organizationId: rowsRead?.organizationId, packageId: real })) { + const hit = list.find((c) => c.stored !== undefined + && c.stored.organizationId === candidate.organizationId + && (c.stored.type === type ? 'canonical' : 'other') === candidate.spelling + && (candidate.packageId === undefined || (c.pkg ?? null) === candidate.packageId)); + if (hit) return hit.item; + } + return undefined; }; for (const raw of baseItems) { @@ -1988,7 +2075,7 @@ function mergePackageAwareOverlay( push(slotOf(item, item.name), (item._packageId ?? undefined) as string | undefined, item); } } - for (const { data, packageId } of records) { + for (const { data, packageId, stored } of records) { const body = data as any; if (!(body && typeof body === 'object' && 'name' in body)) continue; // The base row this record shadows at its own slot (for view-identity @@ -2001,7 +2088,7 @@ function mergePackageAwareOverlay( ?? list.find((c) => c.pkg === undefined)?.item ?? list[0]?.item) : undefined; - push(slot, packageId, transform ? transform(body, prev) : body); + push(slot, packageId, transform ? transform(body, prev) : body, rowsRead ? stored : undefined); } const out: unknown[] = []; @@ -2013,11 +2100,15 @@ function mergePackageAwareOverlay( continue; } for (const real of reals) { - // getMetaItem(name, real) resolution: latest row that is `real`'s - // own or package-less (global fallback). - let chosen: any; - for (const c of list) { - if (c.pkg === real || c.pkg === undefined) chosen = c.item; + // getMetaItem(name, real) resolution. [#21804] A stored row is + // chosen by the served-row resolution, whatever order the rows + // came back in; with no stored row of `real`'s own or package-less, + // the latest lower-layer contribution of either stands, as before. + let chosen: any = rowsRead ? servedStoredRow(list, real) : undefined; + if (chosen === undefined) { + for (const c of list) { + if ((c.pkg === real || c.pkg === undefined) && c.stored === undefined) chosen = c.item; + } } if (chosen === undefined) continue; // A package-less body standing in for package `real` must carry @@ -5245,13 +5336,15 @@ function isNonCanonicalStoredType(type: string): boolean { * [#21442] A stored `sys_metadata` row as the list read parses it: its own * name, its body (stored-row conversions replayed), and the package and * organization it is bound to (`organizationId: null` for an environment-wide - * row). + * row). [#21804] `type` is the spelling the row is stored under (its own + * `type` column), which the served-row resolution reads. */ interface StoredOverlayEntry { name: string; data: any; packageId: string | undefined; organizationId: string | null; + type: string; } /** @@ -8779,7 +8872,14 @@ export class ObjectStackProtocolImplementation implements const mergeable = overlays.filter( ({ data }) => !this.isShippedFlowName(request.type, (data as { name?: unknown } | null)?.name), ); - items = mergePackageAwareOverlay(request.type, items, mergeable, (data, prev) => { + // [#21804] Each row travels with its place (scope and stored + // spelling), so the merge picks a package's slot by the + // served-row resolution the by-name read takes + // ({@link servedOverlayRowCandidates}), not by row order. + const placed = mergeable.map(({ data, packageId: recPkg, organizationId: recOrg, type: recType }) => ({ + data, packageId: recPkg, stored: { organizationId: recOrg, type: recType }, + })); + items = mergePackageAwareOverlay(request.type, items, placed, (data, prev) => { if (isView && data && typeof data === 'object') { const patch = viewIdentityPatch(data as Record, prev); if (patch) Object.assign(data as Record, patch); @@ -8797,7 +8897,7 @@ export class ObjectStackProtocolImplementation implements return this.foldObjectExtendersFromRegistry( request.type, (data as { name?: unknown } | null)?.name, data, ); - }); + }, { organizationId: orgId }); // [#13407] Expand any aggregated `defineView` container this // READ just merged in, INLINE into this response's own `items` @@ -8924,7 +9024,14 @@ export class ObjectStackProtocolImplementation implements if (recPkg && data && typeof data === 'object' && (data as any)._packageId === undefined) { (data as any)._packageId = recPkg; } - return { data, packageId: recPkg }; + // [#21804] The draft's place, as for the active rows: + // a package's previewed slot is the draft the + // by-name read's preview arm serves, in any row order. + const stored = { + organizationId: (record as { organization_id?: string | null }).organization_id ?? null, + type: String(record.type ?? request.type), + }; + return { data, packageId: recPkg, stored }; }); // [#7774] Same bundle slot as the active merge above — a // draft of one locale must preview over that locale, not @@ -8932,7 +9039,7 @@ export class ObjectStackProtocolImplementation implements items = mergePackageAwareOverlay(request.type, items, drafts, (data) => { if (data && typeof data === 'object') (data as any)._draft = true; return data; - }); + }, { organizationId: orgId }); } } catch (error) { // [#5532] Same rule as the active-overlay read above. Serving @@ -9307,7 +9414,10 @@ export class ObjectStackProtocolImplementation implements // merged row set is env-wide rows PLUS this org's rows, and // the two are only distinguishable here, at the row. const recOrg = (record as { organization_id?: string | null }).organization_id ?? null; - return { name: String(record.name), data, packageId: recPkg, organizationId: recOrg }; + return { + name: String(record.name), data, packageId: recPkg, organizationId: recOrg, + type: String(record.type ?? request.type), + }; }); } @@ -9436,6 +9546,12 @@ export class ObjectStackProtocolImplementation implements * row first and then the package-less row, never another package's; with * none, any row. * + * [#21804] That order is {@link servedOverlayRowCandidates}, and this + * method is its store reader: one `findOne` per candidate, first hit + * served. The list's merge ({@link mergePackageAwareOverlay}) is its other + * reader, over the rows the list already read, so a package's list slot + * serves the row this method serves for that package. + * * `orgId` arrives already gated ({@link organizationIdForMetaRead}): an * organization only selects a row on a type the registry declares per-org * overridable, so a pre-#6190 phantom org row is never the served one. @@ -9454,38 +9570,24 @@ export class ObjectStackProtocolImplementation implements state: 'active' | 'draft'; packageId?: string; }): Promise<{ row: any; scope: 'org' | 'env' } | undefined> { - const inScope = async (oid: string | null): Promise => { - const lookup = async (t: string): Promise => { - const base: Record = { - type: t, name: args.name, state: args.state, organization_id: oid, - }; - if (args.packageId) { - const scoped = await this.engine.findOne('sys_metadata', { - where: { ...base, package_id: args.packageId }, - }); - if (scoped) return scoped; - // ADR-0048 — no package-owned row; fall back to the GLOBAL - // (package-less) row only. Must NOT match a different - // package's row, or a collision would serve package B's - // customization for a package A read. - return await this.engine.findOne('sys_metadata', { - where: { ...base, package_id: null }, - }); - } - // No package context (legacy/runtime reader) — match any. - return await this.engine.findOne('sys_metadata', { where: base }); + // [#21804] The order is {@link servedOverlayRowCandidates}, the one + // the list's merge takes over the rows it read: per scope, the + // canonical spelling then the other, and within each, the package's + // own row then the package-less row (never another package's, or a + // collision would serve package B's customization for a package A + // read); with no package context (legacy/runtime reader), any row. + const other = PLURAL_TO_SINGULAR[args.type] ?? SINGULAR_TO_PLURAL[args.type]; + for (const candidate of servedOverlayRowCandidates({ organizationId: args.orgId, packageId: args.packageId })) { + const type = candidate.spelling === 'canonical' ? args.type : other; + if (type === undefined) continue; + const where: Record = { + type, name: args.name, state: args.state, organization_id: candidate.organizationId, }; - const rec = await lookup(args.type); - if (rec) return rec; - const alt = PLURAL_TO_SINGULAR[args.type] ?? SINGULAR_TO_PLURAL[args.type]; - return alt ? await lookup(alt) : undefined; - }; - if (args.orgId) { - const row = await inScope(args.orgId); - if (row) return { row, scope: 'org' }; + if (candidate.packageId !== undefined) where.package_id = candidate.packageId; + const row = await this.engine.findOne('sys_metadata', { where }); + if (row) return { row, scope: candidate.scope }; } - const row = await inScope(null); - return row ? { row, scope: 'env' } : undefined; + return undefined; } /** From 740c063f1fdac9f56ef3bc14c3fbc6cf576bb525 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 03:57:42 +0000 Subject: [PATCH 2/2] test(metadata-protocol): record the list-slot pin's engine double in the engine-double-contract ledger Written by `node scripts/check-engine-double-contract.mjs --write`: one new pinned findOne row for protocol.list-slot-prefer-local.test.ts, 0 lost. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- scripts/engine-double-contract.pinned.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 3434c995839..7d1597cb51e 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -806,6 +806,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.lock-door-read-agree.test.ts", "verb": "findOne",