From e0589b0eb8fbad95885021dab164de3b82d7adf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 10:08:08 +0000 Subject: [PATCH 1/3] fix(metadata-protocol): a package-scoped list slot serves the package-less row getMetaItem naming the package serves The package-less rows in scope enter each merge of a package-scoped list as stand-ins: they serve a slot the package seats, by the shared candidate order, and never seat one, so the list's membership is unchanged. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 129 +++++++++++++++++---- 1 file changed, 109 insertions(+), 20 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 182c66778c..a42b6a439c 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2026,6 +2026,18 @@ function servedOverlayRowCandidates(address: { * 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. + * • [#21817] A list scoped to one package passes the package-less rows in + * its scope as STAND-INS (`standIn` on a record). A stand-in serves a + * slot the package seats, by the same served-row resolution, so the + * scoped list's slot is what `getMetaItem` naming the package serves: + * the package's own row, else the package-less row. A stand-in never + * seats a slot itself, so the scoped list still lists only the items the + * package ships. A slot that only stand-ins reach is emitted with its + * latest stand-in and recorded in `unseated`, and an item recorded there + * that arrives as a base item counts as a stand-in again: a later layer + * (the draft preview, the MetadataService listing) may still seat that + * slot, and the caller drops what is still recorded once the last layer + * has merged. * * `transform(data, prev)` runs on each `records` body before it enters the * merge (view-identity healing, draft tagging); `prev` is the base row it @@ -2047,27 +2059,33 @@ function servedOverlayRowCandidates(address: { * its buckets are unchanged. * * @param type Canonical (singular) metadata type of every row being merged. + * @param unseated [#21817] The scoped list's record of stand-ins no + * contribution has seated yet (see the stand-in bullet above). */ function mergePackageAwareOverlay( type: string, baseItems: unknown[], - records: Array<{ data: unknown; packageId: string | undefined; stored?: StoredRowPlace }>, + records: Array<{ data: unknown; packageId: string | undefined; stored?: StoredRowPlace; standIn?: boolean }>, transform?: (data: any, prev: any) => any, rowsRead?: { readonly organizationId: string | undefined }, + unseated?: WeakSet, ): unknown[] { // Per-SLOT, layer-ordered contributions; `pkg: undefined` = package-less. - // `stored` marks a contribution that is a stored row the caller read. - type Contribution = { pkg: string | undefined; item: any; stored?: StoredRowPlace }; + // `stored` marks a contribution that is a stored row the caller read; + // `standIn` one that serves a slot without seating it ([#21817]). + type Contribution = { pkg: string | undefined; item: any; stored?: StoredRowPlace; standIn?: true }; 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, stored?: StoredRowPlace) => { + const push = (slot: string, pkg: string | undefined, item: any, stored?: StoredRowPlace, standIn?: boolean) => { let list = buckets.get(slot); if (!list) { buckets.set(slot, (list = [])); order.push(slot); } - list.push(stored ? { pkg, item, stored } : { pkg, item }); + const c: Contribution = stored ? { pkg, item, stored } : { pkg, item }; + if (standIn) c.standIn = true; + list.push(c); }; // [#21804] The stored row the served-row resolution picks for package // `real` among a slot's contributions: the first candidate of @@ -2086,10 +2104,11 @@ function mergePackageAwareOverlay( for (const raw of baseItems) { const item = raw as any; if (item && typeof item === 'object' && 'name' in item) { - push(slotOf(item, item.name), (item._packageId ?? undefined) as string | undefined, item); + push(slotOf(item, item.name), (item._packageId ?? undefined) as string | undefined, item, + undefined, unseated?.has(item)); } } - for (const { data, packageId, stored } of records) { + for (const { data, packageId, stored, standIn } 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 @@ -2102,12 +2121,24 @@ function mergePackageAwareOverlay( ?? list.find((c) => c.pkg === undefined)?.item ?? list[0]?.item) : undefined; - push(slot, packageId, transform ? transform(body, prev) : body, rowsRead ? stored : undefined); + push(slot, packageId, transform ? transform(body, prev) : body, rowsRead ? stored : undefined, standIn); } const out: unknown[] = []; for (const slot of order) { const list = buckets.get(slot)!; + // [#21817] Only stand-ins reach this slot: no item of the package is + // here (yet), so the stand-in is held back, not served as one. + if (list.every((c) => c.standIn)) { + // The stand-in the one candidate order picks among the stored + // rows here, else (no stored row: a stand-in held back by an + // earlier merge) the latest. + let held = rowsRead ? servedStoredRow(list, undefined) : undefined; + if (held === undefined) held = list[list.length - 1].item; + unseated?.add(held); + out.push(held); + continue; + } const reals = Array.from(new Set(list.filter((c) => c.pkg !== undefined).map((c) => c.pkg))); if (reals.length === 0) { out.push(list[list.length - 1].item); // latest package-less row wins @@ -8863,6 +8894,17 @@ export class ObjectStackProtocolImplementation implements // scoped to a package reads its rows with that package only, so it // reads the package-agnostic set too (the overlay cache answers it). let lockRows: readonly any[] = []; + // [#21817] A list scoped to a package serves, in each slot the package + // seats, the row `getMetaItem` naming that package serves: the + // package's own row, else the package-less row (ADR-0048), by the one + // candidate order ({@link servedOverlayRowCandidates}). Its row read + // names the package, so the package-less rows come from the + // package-agnostic read above, filtered back to the package-less ones. + // They enter each merge as stand-ins ({@link mergePackageAwareOverlay}): + // a stand-in serves a slot the package seats and never seats one, so + // the list's membership stays the items the package ships. A stand-in + // no layer seated is recorded here and dropped after the last merge. + const unseated = packageId ? new WeakSet() : undefined; try { // [#21442] The row set this read consults — the overlay cache // included — is {@link readActiveOverlayRows}, and each row is @@ -8872,7 +8914,8 @@ export class ObjectStackProtocolImplementation implements // scope for one caller. const records = await this.readActiveOverlayRows(request, orgId); lockRows = packageId ? await this.readActiveOverlayRows({ type: request.type }, orgId) : records; - if (records && records.length > 0) { + const standInRows = packageId ? lockRows.filter((row) => (row?.package_id ?? null) === null) : []; + if ((records && records.length > 0) || standInRows.length > 0) { const isView = (PLURAL_TO_SINGULAR[request.type] ?? request.type) === 'view'; const overlays = this.storedOverlayEntries(request, records); @@ -8899,7 +8942,14 @@ export class ObjectStackProtocolImplementation implements 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) => { + // [#21817] The package-less rows, placed the same way, as + // stand-ins: the same parse and the same shipped-flow rule. + const standIns = this.storedOverlayEntries(request, standInRows) + .filter(({ data }) => !this.isShippedFlowName(request.type, (data as { name?: unknown } | null)?.name)) + .map(({ data, organizationId: recOrg, type: recType }) => ({ + data, packageId: undefined, stored: { organizationId: recOrg, type: recType }, standIn: true, + })); + items = mergePackageAwareOverlay(request.type, items, [...placed, ...standIns], (data, prev) => { if (isView && data && typeof data === 'object') { const patch = viewIdentityPatch(data as Record, prev); if (patch) Object.assign(data as Record, patch); @@ -8917,7 +8967,7 @@ export class ObjectStackProtocolImplementation implements return this.foldObjectExtendersFromRegistry( request.type, (data as { name?: unknown } | null)?.name, data, ); - }, { organizationId: orgId }); + }, { organizationId: orgId }, unseated); // [#13407] Expand any aggregated `defineView` container this // READ just merged in, INLINE into this response's own `items` @@ -8957,14 +9007,30 @@ export class ObjectStackProtocolImplementation implements // `records`, the one the by-name read asks, so the two doors // answer the same row for the name. An item the registry or a // package supplies under the name is still replaced, as before. - if (isView) { + // + // [#21817] In a list scoped to a package, a package-less row + // of the name stands in ahead of the expansion too: the by-name + // read naming the package serves that row before it asks any + // expansion. The expansion still seats the slot, so a stand-in + // held back by the merge is served there, as the package's. + if (isView && records.length > 0) { const byName = new Map(); for (const it of items as any[]) { if (it && typeof it === 'object' && typeof it.name === 'string') byName.set(it.name, it); } const ownRowNames = this.namesWithOwnStoredRow(records); + const standInNames = this.namesWithOwnStoredRow(standInRows); for (const { item: vi } of this.expandStoredViewContainers(request.type, overlays)) { if (ownRowNames.has(vi.name as string)) continue; + const held = byName.get(vi.name as string) as Record | undefined; + if (held !== undefined && standInNames.has(vi.name as string)) { + // Seated: a copy the `unseated` record does not hold, + // stamped as the merge stamps a stand-in. + if (unseated?.has(held)) { + byName.set(vi.name as string, held._packageId === undefined ? { ...held, _packageId: packageId } : { ...held }); + } + continue; + } byName.set(vi.name as string, vi); } items = Array.from(byName.values()); @@ -9015,27 +9081,36 @@ export class ObjectStackProtocolImplementation implements // process-wide registry or to non-preview reads. if (request.previewDrafts) { try { - const queryDrafts = async (oid: string | null): Promise => { + const queryDrafts = async (oid: string | null, pkg: string | undefined): Promise => { const whereClause: Record = { type: request.type, state: 'draft', organization_id: oid }; - if (packageId) whereClause.package_id = packageId; + if (pkg) whereClause.package_id = pkg; let rs = await this.engine.find('sys_metadata', { where: whereClause }); if (!rs || rs.length === 0) { const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type]; if (alt) { const altWhere: Record = { type: alt, state: 'draft', organization_id: oid }; - if (packageId) altWhere.package_id = packageId; + if (pkg) altWhere.package_id = pkg; rs = await this.engine.find('sys_metadata', { where: altWhere }); } } return rs ?? []; }; - const draftRecords = [...(await queryDrafts(null)), ...(orgId ? await queryDrafts(orgId) : [])]; - if (draftRecords.length > 0) { + const draftRecords = [...(await queryDrafts(null, packageId)), ...(orgId ? await queryDrafts(orgId, packageId) : [])]; + // [#21817] A list scoped to a package previews the package-less + // drafts as stand-ins, as its active arm does: the + // package-agnostic draft read, filtered back to the + // package-less rows. The by-name preview arm naming the + // package serves that draft when the package has none. + const standInDraftRecords = packageId + ? [...(await queryDrafts(null, undefined)), ...(orgId ? await queryDrafts(orgId, undefined) : [])] + .filter((record) => (record?.package_id ?? null) === null) + : []; + if (draftRecords.length > 0 || standInDraftRecords.length > 0) { // ADR-0048 (#1828) — package-aware draft overlay (parity with // the active-overlay merge above): a package-scoped draft // previews only its own package's entry, so two packages' // same-name drafts stay distinct. Draft rows win over active. - const drafts = draftRecords.map((record) => { + const placeDraft = (record: any) => { const data = this.convertStoredItem( String(record.type ?? request.type), typeof record.metadata === 'string' ? JSON.parse(record.metadata) : record.metadata, @@ -9052,14 +9127,18 @@ export class ObjectStackProtocolImplementation implements type: String(record.type ?? request.type), }; return { data, packageId: recPkg, stored }; - }); + }; + const drafts = [ + ...draftRecords.map(placeDraft), + ...standInDraftRecords.map((record) => ({ ...placeDraft(record), standIn: true })), + ]; // [#7774] Same bundle slot as the active merge above — a // draft of one locale must preview over that locale, not // over the whole bundle. items = mergePackageAwareOverlay(request.type, items, drafts, (data) => { if (data && typeof data === 'object') (data as any)._draft = true; return data; - }, { organizationId: orgId }); + }, { organizationId: orgId }, unseated); } } catch (error) { // [#5532] Same rule as the active-overlay read above. Serving @@ -9144,12 +9223,22 @@ export class ObjectStackProtocolImplementation implements packageId: ((it as any)?._packageId ?? undefined) as string | undefined, })), ); + // [#21817] A stand-in still held back here takes the + // package's runtime item of its slot as the item it serves + // (the latest contribution, stamped as the package's): a + // seated copy, which `unseated` does not hold. } } } catch { // MetadataService not available or doesn't support this type } + // [#21817] A stand-in no layer seated is a package-less row of an item + // the package does not ship: it is not in the package's list. + if (unseated) { + items = (items as any[]).filter((it) => !(it && typeof it === 'object' && unseated.has(it))); + } + // Hide metadata owned by a disabled package. `listItems` already drops // disabled-package items from the SchemaRegistry, but the DB overlay and // MetadataService merges above can re-introduce them (e.g. an app/view From ab41401bb0f077acf4ee28b6881d17894d3e2715 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 10:11:14 +0000 Subject: [PATCH 2/3] test(metadata-protocol): pin the package-scoped list slot to getMetaItem naming the package, and its membership Seven pins: the generated table over every subset and order of five rows, with and without an organization and the package's artifact; named row orders; membership; the MetadataService layer; the draft preview; the view container expansion; the lock family. Plus the patch changeset. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .changeset/21817-scoped-list-fallback.md | 13 + .../protocol.list-slot-prefer-local.test.ts | 7 +- .../src/protocol.scoped-list-fallback.test.ts | 449 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 3 +- 4 files changed, 467 insertions(+), 5 deletions(-) create mode 100644 .changeset/21817-scoped-list-fallback.md create mode 100644 packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts diff --git a/.changeset/21817-scoped-list-fallback.md b/.changeset/21817-scoped-list-fallback.md new file mode 100644 index 0000000000..cedda56fcf --- /dev/null +++ b/.changeset/21817-scoped-list-fallback.md @@ -0,0 +1,13 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +fix(metadata-protocol): a package-scoped metadata list serves a package-less customization of an item the package ships, as `getMetaItem` naming the package does (#21817) + +Clause-②: no + +ADR-0048 lets one item hold a package-less stored `sys_metadata` row, an ordinary customization, beside the package's own artifact or row. `getMetaItem` naming the package serves the package's own row, else the package-less row, the organization's rows before the env-wide rows (ADR-0005). A list scoped to the package (`getMetaItems({ type, packageId })`, `GET /api/v1/meta/:type?package=`, and the `getMetaItemsForExecution` view of it) read only the package's own rows. So it served the package's artifact over a package-less customization of it, and an env-wide row of the package over the organization's package-less row, while `getMetaItem` naming the package served the customization. + +Now each slot of a package-scoped list resolves the way `getMetaItem` naming the package does: the package's own row, else the package-less row, by the same order the unscoped list uses. This holds over a registry item, a MetadataService item and a view the package's stored container expands, and in the `previewDrafts` list, where a package-less draft stands in when the package has none. The list's membership is unchanged: it still lists only the items the package ships, and a package-less row of an item the package does not ship adds no item to it. The lock each item reports is unchanged. + +No key, export, status or error code changes. 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 index 5fe4f92f2a..ff1b352865 100644 --- a/packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts +++ b/packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts @@ -35,10 +35,9 @@ * 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. + * A list scoped to a package (`packageId` on the list request) is pinned in + * `protocol.scoped-list-fallback.test.ts` (#21817): its package-less rows + * reach the merge as stand-ins, so its slot takes the same order. */ import { describe, expect, it } from 'vitest'; import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; diff --git a/packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts b/packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts new file mode 100644 index 0000000000..6c3ce3fbf7 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts @@ -0,0 +1,449 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21817, ADR-0048, ADR-0005] A list scoped to one package + * (`getMetaItems({ type, packageId })`, `GET /api/v1/meta/:type?package=`) + * serves, in each slot the package ships, the row `getMetaItem` naming that + * package serves: the package's own row, else the package-less row, the + * organization's rows before the env-wide ones. + * + * The scoped list read its stored rows with the package only, so a + * package-less row never reached its merge. With the package's artifact and a + * package-less customization of it stored, the list served the artifact while + * `getMetaItem` naming the package served the customization. With an + * organization, the list served an env-wide row of the package over the + * organization's package-less row, which `getMetaItem` never does. + * + * Now the package-less rows in scope (the package-agnostic read the list + * already makes for the lock, filtered back to the package-less rows) enter + * each of the list's merges as stand-ins, and the merge picks a slot's row by + * the one candidate order `servedOverlayRowCandidates` in `protocol.ts`. A + * stand-in serves a slot the package seats and never seats one, so the + * scoped list still lists only the items the package ships. + * + * 1. The generated pin: every subset of five stored rows (env-wide and + * org-scoped, package-less and package A's, plus package B's env-wide + * row), every row order, with and without an organization, with and + * without A's artifact. For packages A and B: when the package ships the + * name (its artifact, or a row of its own in scope), the scoped list's + * one slot serves the row an oracle written from the rule names, and + * `getMetaItem` naming the package serves the same; when it ships + * nothing, the scoped list has no slot for the name. + * 2. Named, both row orders: A's artifact beside the package-less row; with + * an organization, the organization's package-less row beside an env-wide + * row of A. + * 3. Membership: a package-less row of a name the package does not ship + * adds no slot to the scoped list, which lists the same names with and + * without it, while the unscoped list still serves that row. + * 4. The MetadataService layer: a package's runtime item (no registry item, + * no row of the package) and a package-less row of its name. The scoped + * slot serves the row, as `getMetaItem` naming the package does. + * 5. The draft preview: a package-less draft stands in for the package's + * slot; the package's own draft wins over it in both orders. + * 6. The view container expansion: a package-less row of a name the + * package's stored container expands is served ahead of the expansion, + * as `getMetaItem` naming the package serves it. + * 7. The lock: where the served row moves to the package-less row, the + * scoped slot's lock family still equals `getMetaItem`'s envelope. + */ +import { describe, expect, it } from 'vitest'; +import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const ORG = 'org_a'; +/** Per-organization overridable (ADR-0005), so the organization axis is live. */ +const TYPE = 'dashboard'; +const PKG_A = 'com.example.a'; +const PKG_B = 'com.example.b'; +const NAME = 'd_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, + options: { state?: 'active' | 'draft'; type?: string; name?: string; body?: Record } = {}, +): StoredRow { + const { state = 'active', type = TYPE, name = NAME, body = {} } = options; + const spec: RowSpec = ROWS[key]; + const label = state === 'draft' ? `${spec.label} (draft)` : spec.label; + return { + id: `r_${key}_${state}_${name}`, + type, + name, + organization_id: spec.scope === 'org-scoped' ? ORG : null, + package_id: spec.packageId, + state, + metadata: JSON.stringify({ name, label, _provenance: 'org', ...body }), + }; +} + +/** What package A's loader registered for the name, when a case 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, a registry + * that holds the given items (package-scoped lookups answer that package's + * only), and optionally a MetadataService listing runtime items. + */ +function harness( + rows: StoredRow[], + options: { items?: Array>; runtime?: Array>; type?: string } = {}, +) { + const { items = [], runtime, type = TYPE } = options; + 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, + }; + const services = new Map(); + if (runtime) { + services.set('metadata', { + async list(asked: string) { + return asked === type ? runtime.map((i) => ({ ...i })) : []; + }, + async get(asked: string, name: string, packageId?: string) { + return asked === type + ? runtime.find((i) => i.name === name && (!packageId || i._packageId === packageId)) + : undefined; + }, + }); + } + return new ObjectStackProtocolImplementation(engine, () => services as any, '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. With no row, the package's artifact. + */ +function servedLabel(rows: readonly RowKey[], packageId: string, organizationId: string | undefined, artifact: boolean): 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 artifact && packageId === PKG_A ? ARTIFACT_A.label : undefined; +} + +/** Whether the package ships the name: its artifact, or a row of its own in scope. */ +function ships(rows: readonly RowKey[], packageId: string, organizationId: string | undefined, artifact: boolean): boolean { + if (artifact && packageId === PKG_A) return true; + return rows.some((key) => ROWS[key].packageId === packageId + && (ROWS[key].scope === 'env-wide' || organizationId !== undefined)); +} + +/** The items of the scoped list, by name. */ +async function scopedList( + protocol: ObjectStackProtocolImplementation, packageId: string, + request: { organizationId?: string; previewDrafts?: boolean } = {}, type: string = TYPE, +): Promise { + const listed: any = await protocol.getMetaItems({ type, packageId, ...request }); + return listed.items as any[]; +} + +/** The scoped list's slot for the name. */ +async function scopedSlots( + protocol: ObjectStackProtocolImplementation, packageId: string, + request: { organizationId?: string; previewDrafts?: boolean } = {}, type: string = TYPE, name: string = NAME, +): Promise { + return (await scopedList(protocol, packageId, request, type)).filter((item) => item?.name === name); +} + +/** What `getMetaItem` naming `packageId` answers. */ +async function byName( + protocol: ObjectStackProtocolImplementation, packageId: string, + request: { organizationId?: string; previewDrafts?: boolean } = {}, type: string = TYPE, name: string = NAME, +): Promise { + return protocol.getMetaItem({ type, name, packageId, ...request }); +} + +// ── 1. The generated pin ───────────────────────────────────────────────────── + +describe('pin 1 (generated) — the scoped list\'s slot serves the row getMetaItem naming the package serves, in every row order', () => { + const TABLE = subsets(ROW_KEYS).flatMap((rows) => + ([undefined, ORG] as const).flatMap((organizationId) => + [false, true].map((artifact) => ({ rows, organizationId, artifact })))); + + it('completeness: every subset of the five rows, with and without an organization and A\'s artifact; the fallback arrangements are in it', () => { + expect(TABLE).toHaveLength(2 ** ROW_KEYS.length * 2 * 2); + expect(new Set(ROW_KEYS.map((key) => ROWS[key].packageId))).toEqual(new Set([null, PKG_A, PKG_B])); + // The arrangements this card is about: a package that ships the name + // and a package-less row the oracle serves for it, in more than one order. + const fallback = TABLE.filter(({ rows, organizationId, artifact }) => + [PKG_A, PKG_B].some((packageId) => ships(rows, packageId, organizationId, artifact) + && !ROW_KEYS.some((key) => ROWS[key].label === servedLabel(rows, packageId, organizationId, artifact) + && ROWS[key].packageId === packageId) + && servedLabel(rows, packageId, organizationId, artifact) !== ARTIFACT_A.label)); + expect(fallback.length).toBeGreaterThan(0); + expect(fallback.some(({ rows }) => orders(rows).length >= 2)).toBe(true); + expect(fallback.some(({ organizationId }) => organizationId === undefined)).toBe(true); + expect(fallback.some(({ organizationId }) => organizationId === ORG)).toBe(true); + }); + + for (const { rows, organizationId, artifact } of TABLE) { + const title = `rows: ${rows.length === 0 ? 'none' : rows.map((key) => ROWS[key].label).join(' + ')}` + + ` · ${artifact ? 'A\'s artifact' : 'no artifact'}` + + ` · 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)), { items: artifact ? [ARTIFACT_A] : [] }); + const scope = organizationId ? { organizationId } : {}; + for (const packageId of [PKG_A, PKG_B]) { + const slots = await scopedSlots(protocol, packageId, scope); + if (!ships(order, packageId, organizationId, artifact)) { + // The package ships nothing of the name: a package-less + // row adds no slot to its list. + expect(slots, `${at}: a slot in the list scoped to ${packageId}`).toEqual([]); + continue; + } + const expected = servedLabel(order, packageId, organizationId, artifact); + expect(slots.map((s) => ({ label: s.label, packageId: s._packageId })), `${at}: the list scoped to ${packageId}`) + .toEqual([{ label: expected, packageId }]); + expect((await byName(protocol, packageId, scope)).item?.label, `${at}: getMetaItem naming ${packageId}`) + .toBe(expected); + } + } + }); + } +}); + +// ── 2. Named, both row orders ──────────────────────────────────────────────── + +describe('pin 2 — named arrangements, both row orders: the scoped slot is getMetaItem\'s row', () => { + for (const type of [TYPE, 'view']) { + for (const order of orders(['envPackageless', 'envB'])) { + it(`/meta/${type}?package=A · A's artifact + the env-wide package-less row · row order ${order.join(', ')}`, async () => { + const artifact = { ...ARTIFACT_A, ...(type === 'view' ? { object: 'account' } : {}) }; + const protocol = harness(order.map((key) => stored(key, { type })), { items: [artifact], type }); + const slots = await scopedSlots(protocol, PKG_A, {}, type); + expect(slots.map((s) => ({ label: s.label, packageId: s._packageId }))) + .toEqual([{ label: ROWS.envPackageless.label, packageId: PKG_A }]); + expect((await byName(protocol, PKG_A, {}, type)).item?.label).toBe(ROWS.envPackageless.label); + }); + } + } + for (const order of orders(['orgPackageless', 'envA'])) { + it(`organization ${ORG} · 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 scopedSlots(protocol, PKG_A, { organizationId: ORG }); + expect(slots.map((s) => s.label)).toEqual([ROWS.orgPackageless.label]); + expect((await byName(protocol, PKG_A, { organizationId: ORG })).item?.label).toBe(ROWS.orgPackageless.label); + }); + } + for (const order of orders(['orgA', 'envPackageless'])) { + it(`organization ${ORG} · 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 scopedSlots(protocol, PKG_A, { organizationId: ORG }); + expect(slots.map((s) => s.label)).toEqual([ROWS.orgA.label]); + expect((await byName(protocol, PKG_A, { organizationId: ORG })).item?.label).toBe(ROWS.orgA.label); + }); + } +}); + +// ── 3. Membership ──────────────────────────────────────────────────────────── + +describe('pin 3 — membership: a package-less row of a name the package does not ship adds no slot', () => { + const OTHER = 'd_other'; + for (const organizationId of [undefined, ORG]) { + for (const scopeKey of ['envPackageless', 'orgPackageless'] as const) { + if (scopeKey === 'orgPackageless' && organizationId === undefined) continue; + it(`${organizationId ? `organization ${organizationId}` : 'no organization'} · a ${ROWS[scopeKey].label} of a name A does not ship`, async () => { + const shipped = [stored('envA', { name: 'd_rowed' })]; + const items = [ARTIFACT_A]; + const scope = organizationId ? { organizationId } : {}; + const without = harness(shipped, { items }); + const withRow = harness([...shipped, stored(scopeKey, { name: OTHER })], { items }); + const names = (list: any[]) => list.map((i) => `${i.name}@${i._packageId}`).sort(); + const listedWithout = names(await scopedList(without, PKG_A, scope)); + expect(listedWithout).toEqual([`d_rowed@${PKG_A}`, `${NAME}@${PKG_A}`]); + expect(names(await scopedList(withRow, PKG_A, scope))).toEqual(listedWithout); + // The row is still served where it belongs: the unscoped list. + const unscoped: any = await withRow.getMetaItems({ type: TYPE, ...scope }); + expect((unscoped.items as any[]).filter((i) => i.name === OTHER).map((i) => i.label)) + .toEqual([ROWS[scopeKey].label]); + }); + } + } +}); + +// ── 4. The MetadataService layer ───────────────────────────────────────────── + +describe('pin 4 — the MetadataService layer: a package-less row stands in for the package\'s runtime item', () => { + const RUNTIME_A = { name: NAME, label: 'runtime item of A', _packageId: PKG_A }; + for (const scopeKey of ['envPackageless', 'orgPackageless'] as const) { + it(`a ${ROWS[scopeKey].label} of the name: the scoped slot serves it, as getMetaItem naming A does`, async () => { + const protocol = harness([stored(scopeKey)], { runtime: [RUNTIME_A] }); + const scope = { organizationId: ORG }; + const slots = await scopedSlots(protocol, PKG_A, scope); + expect(slots.map((s) => ({ label: s.label, packageId: s._packageId }))) + .toEqual([{ label: ROWS[scopeKey].label, packageId: PKG_A }]); + expect((await byName(protocol, PKG_A, scope)).item?.label).toBe(ROWS[scopeKey].label); + }); + } + it('control: no runtime item of A, the package-less row adds no slot; lit control: the runtime item alone is served', async () => { + const rowOnly = harness([stored('envPackageless')], { runtime: [] }); + expect(await scopedSlots(rowOnly, PKG_A)).toEqual([]); + const runtimeOnly = harness([], { runtime: [RUNTIME_A] }); + expect((await scopedSlots(runtimeOnly, PKG_A)).map((s) => s.label)).toEqual([RUNTIME_A.label]); + expect((await byName(runtimeOnly, PKG_A)).item?.label).toBe(RUNTIME_A.label); + }); +}); + +// ── 5. The draft preview ───────────────────────────────────────────────────── + +describe('pin 5 — the draft preview: the scoped previewed slot is the draft getMetaItem previews', () => { + it('A\'s artifact and a package-less draft: the previewed slot is the draft', async () => { + const protocol = harness([stored('envPackageless', { state: 'draft' })], { items: [ARTIFACT_A] }); + const slots = await scopedSlots(protocol, PKG_A, { previewDrafts: true }); + expect(slots.map((s) => ({ label: s.label, draft: s._draft, packageId: s._packageId }))) + .toEqual([{ label: `${ROWS.envPackageless.label} (draft)`, draft: true, packageId: PKG_A }]); + const previewed = (await byName(protocol, PKG_A, { previewDrafts: true })).item; + expect({ label: previewed?.label, draft: previewed?._draft }) + .toEqual({ label: `${ROWS.envPackageless.label} (draft)`, draft: true }); + }); + for (const order of orders(['envPackageless', 'envA'])) { + it(`A's draft and a package-less draft · row order ${order.join(', ')}: A's draft`, async () => { + const protocol = harness(order.map((key) => stored(key, { state: 'draft' })), { items: [ARTIFACT_A] }); + const slots = await scopedSlots(protocol, PKG_A, { previewDrafts: true }); + expect(slots.map((s) => s.label)).toEqual([`${ROWS.envA.label} (draft)`]); + expect((await byName(protocol, PKG_A, { previewDrafts: true })).item?.label).toBe(`${ROWS.envA.label} (draft)`); + }); + } + it('membership: a package-less draft of a name A does not ship previews no slot', async () => { + const protocol = harness([stored('envPackageless', { state: 'draft', name: 'd_other' })], { items: [ARTIFACT_A] }); + const listed = await scopedList(protocol, PKG_A, { previewDrafts: true }); + expect(listed.map((i) => i.name)).toEqual([NAME]); + }); +}); + +// ── 6. The view container expansion ────────────────────────────────────────── + +describe('pin 6 — a stored view container of A: a package-less row of a name it expands is served ahead of the expansion', () => { + const OBJECT = 'crm_lead'; + const container = { + name: OBJECT, + list: { label: 'All Leads', type: 'grid', data: { provider: 'object', object: OBJECT }, columns: [{ field: 'name' }] }, + listViews: { + pipeline: { label: 'Lead Pipeline', type: 'grid', data: { provider: 'object', object: OBJECT }, columns: [{ field: 'name' }] }, + }, + }; + const containerRow = { ...stored('envA', { type: 'view', name: OBJECT }), metadata: JSON.stringify(container) }; + const pipeline = `${OBJECT}.pipeline`; + const customized = stored('envPackageless', { + type: 'view', name: pipeline, + body: { object: OBJECT, viewKind: 'list', config: { type: 'grid', columns: [{ field: 'name' }] } }, + }); + + it('the scoped list serves the package-less row for the name, stamped A, and still lists every name the container expands', async () => { + const protocol = harness([containerRow, customized], { type: 'view' }); + const listed = await scopedList(protocol, PKG_A, {}, 'view'); + expect(listed.map((i) => i.name).sort()).toEqual([`${OBJECT}.default`, pipeline]); + const slot = listed.find((i) => i.name === pipeline); + expect({ label: slot?.label, packageId: slot?._packageId }).toEqual({ label: ROWS.envPackageless.label, packageId: PKG_A }); + expect((await byName(protocol, PKG_A, {}, 'view', pipeline)).item?.label).toBe(ROWS.envPackageless.label); + }); + + it('lit control: without the package-less row the expansion is served', async () => { + const protocol = harness([containerRow], { type: 'view' }); + const listed = await scopedList(protocol, PKG_A, {}, 'view'); + expect(listed.map((i) => i.name).sort()).toEqual([`${OBJECT}.default`, pipeline]); + expect(listed.find((i) => i.name === pipeline)?.label).toBe('Lead Pipeline'); + }); +}); + +// ── 7. The lock ────────────────────────────────────────────────────────────── + +describe('pin 7 — the lock: the scoped slot\'s lock family equals getMetaItem\'s envelope where the served row moved', () => { + for (const lock of ['no-overlay', 'no-delete', 'full'] as const) { + for (const declaredOn of ['orgPackageless', 'envA'] as const) { + it(`organization ${ORG} · the organization's package-less row served over env A · ${ROWS[declaredOn].label} declares ${lock}`, async () => { + const rows = (['orgPackageless', 'envA'] as const).map((key) => + stored(key, { body: key === declaredOn ? { _lock: lock, _lockReason: `declared on ${key}` } : {} })); + const protocol = harness(rows, { items: [ARTIFACT_A] }); + const scope = { organizationId: ORG }; + const [slot] = await scopedSlots(protocol, PKG_A, scope); + const read = await byName(protocol, PKG_A, scope); + expect(slot?.label).toBe(ROWS.orgPackageless.label); + expect(read.item?.label).toBe(ROWS.orgPackageless.label); + expect({ lock: slot?._lock ?? 'none', reason: slot?._lockReason }) + .toEqual({ lock: read.lock, reason: read.item?._lockReason }); + }); + } + } +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a42b6a439c..c96ce0d942 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1978,7 +1978,8 @@ interface StoredRowPlace { * 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 + * active and draft-preview merges, a list scoped to a package included, whose + * package-less rows enter as stand-ins, #21817) 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. From 1d226f180e16f741cc284b0563db625958af9da6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 10:41:23 +0000 Subject: [PATCH 3/3] chore(scripts): record the scoped-list pin file's engine double in the engine-double ledger Written by `node scripts/check-engine-double-contract.mjs --write` (1 row added, 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 59ec541b32..418daa2bab 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1381,6 +1381,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.served-content-hash.test.ts", "verb": "delete",