Skip to content

Commit 3237b4a

Browse files
fix(metadata-protocol): a package's list slot serves the stored row getMetaItem naming that package serves, in every row order (#21804) (#21815)
Fixes #21804 Clause-②: no ## What changes A package's slot in the metadata list now serves the stored row that `getMetaItem` naming that package serves, whatever order the store returns the rows in. - **One resolution, two readers.** The order is one function, `servedOverlayRowCandidates(address)` in `packages/metadata-protocol/src/protocol.ts`. Its input is the address `{ organizationId, packageId }`. It returns the ordered candidates `{ scope, organizationId, spelling, packageId }`: the organization's scope, then env-wide (ADR-0005); within each, the canonical spelling, then the other (#4432); within each, the package's own row, then the package-less row, never another package's (ADR-0048). With no package, any row. - `findServedOverlayRow` (`getMetaItem`, its draft-preview arm, `getMetaItemLayered`) reads it from the store: one `findOne` per candidate, first hit served. This is the same `findOne` sequence as before, now spelled once. - `mergePackageAwareOverlay` reads it over the rows the list already holds (`servedStoredRow`). Each stored row travels into the merge with its place (`stored: { organizationId, type }`), and the caller passes `rowsRead: { organizationId }`. The list's active-overlay merge and its `previewDrafts` merge both pass them. - No second resolver: the list iterates the same candidate list; it does not restate the order. - **What stays.** The lower layer (registry artifact) still loses to any stored row of the package or package-less. A package with no row of its own still falls back to the package-less row, stamped as the package's. The MetadataService merge, whose records are already-resolved items, keeps its latest-wins rule. The list item's lock still comes from `resolveOverlayLockLayer` (#21761); that block is untouched. - **Landing point**, as the claim said: `mergePackageAwareOverlay` in `readFlattenedMetaItems`. `StoredOverlayEntry` gains `type` (the row's stored spelling). No `packages/spec` change. ## H1: the two resolutions at the merge base (`18c2ddc1ec`) - `findServedOverlayRow`: per scope (org, then env), per spelling, `findOne` with `package_id` = the package, then `package_id` = null. First hit served. - `mergePackageAwareOverlay`: per slot and per package P, the LATEST contribution (registry items first, then the stored rows in row order) whose package is P or none. The stored rows come from `readActiveOverlayRows`: env rows, then the org's, merged by (package, name) with an org row replacing an env row in place. So "latest" depended on the store's order and on that map's insertion order. - Slot body for package A, engine double, unscoped list, env-wide rows: - A's row and the package-less row, order [package-less, A]: A's row. Order [A, package-less]: the package-less row, stamped A. `getMetaItem` naming A: A's row in both orders. - Only A's row: A's row. Only the package-less row (under A's artifact): the package-less row, stamped A. Both agree with `getMetaItem` naming A. ## Census: list slot vs getMetaItem, base vs this head Engine double, `view` rows. 16 arrangements over five rows (env package-less, env A, env B, org package-less, org A, including no row, one row, both, and a third package), every row order, with and without A's artifact. Each case compares the list's slot for A with `getMetaItem` naming A. | list request | disagreements at `18c2ddc1ec` | at this head | |---|---|---| | no package, no organization | 39 / 120 | 0 / 120 | | organization | 41 / 120 | 0 / 120 | | package A | 5 / 120 | 5 / 120 | | organization + package A | 20 / 120 | 20 / 120 | The 25 that remain are all in a list scoped to one package. That is the row read, not this merge (Acceptance notes, first item). ## H3: everything else in the list is unchanged - **Content dumps.** Over those arrangements, orders and artifacts, with five list requests (no package, package A, package B, organization, organization + package A) and two `getMetaItem` requests per case: 840 dumps. Base vs head: 748 byte-identical, 92 differ. - All 92 are in the two unscoped lists (no package: 45, organization: 47). - Every one is a package slot moving to the row `getMetaItem` naming that package serves: - A: package-less → A's row, 42; - B: package-less → B's row, 12; - org package-less → org A, 26; - env package-less → org A, 6; - env A → org package-less, 6. - Outside package slots: 0 differences. Slot count changes: 0. `getMetaItem` dumps: 0 differences. - **Lock family.** Same arrangements with every lock level on two of the rows: 4032 dumps of each list item's `_lock` family and each `getMetaItem` envelope. 4014 identical, 18 differ. All 18 are the organization list over [org package-less, env package-less, env A]. There the base list item stated env A's `_lock` (`no-overlay`, `no-delete` or `full`, 6 each) while the item's envelope said `none`, because the org's package-less row is the scope. At this head the list item says `none`. The envelopes are identical at base and head. - Across all 992 slot comparisons at this head, the list item's `_lock` equals `getMetaItem`'s envelope lock (base: 18 disagree). - The lock is still selected by `resolveOverlayLockLayer`. What moved is the body it is laid over when no overlay row binds. ## H4: organization scope The organization's rows come before the env-wide rows, then the package's own row. The org list and `getMetaItem` with an organization agree in every order: 0 / 120 above, and pin 3. ## Pins (`protocol.list-slot-prefer-local.test.ts`, 85 tests) 1. Generated: every subset of the five rows, every row order, with and without an organization, on `dashboard` (per-organization overridable, no list expansion of its own). For package A and package B, the list's slot serves the row an oracle written from the rule names, and `getMetaItem` naming the package serves the same row. A package with no row in scope has no slot. 2. A's row beside the package-less row, both env-wide, both orders, with and without A's artifact, on `dashboard` and `view`: the slot is A's row, listed once. 3. Organization scope, every order: the org's package-less row beats an env-wide row of A; the org's row of A beats the env-wide package-less row. 4. Control: only the package-less row, under A's artifact. The slot falls back to it, stamped A, as `getMetaItem` does. Lit control: the artifact alone is served as itself. 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` serves. ## Reverse verification - Committed first (`5c7330e64b`). The row-order pick was restored through `scripts/ablation-replace.mjs` (wrap mode, trap armed): the latest-wins loop back in place of the served-row call. Anchor 1 → 0, replacement 0 → 1, blob `c3958342651e` → `5261196669fb`. - The tests resolve `./protocol.js` from `src`, so no `dist` was involved. - Predicted, then measured on the pin file: **36 failed / 49 passed** of 85. - Pin 2: red when A's row comes back first, green when the package-less row does, on both types and both artifact states (4 red / 4 green). - Pin 3, org package-less over env A: 3 red / 3 green. Pin 3, org A over env package-less: green in both orders (the org's rows are read after the env rows, so "latest" happened to coincide). - Pin 4: green. Pin 5: 1 red / 1 green. Pin 1: 28 red / 37 green. - Restored with `git checkout HEAD -- ABS_PATH` (the tool, and the trap): blob after = blob at HEAD = `c3958342651e`; `git diff HEAD` empty. ## Tests At `978945aecd` (after merging `origin/main` at `75ddcd1b41`, which touches `cloud-connection`, `metadata-core`'s protocol handshake and `runtime`; install refreshed and the workspace rebuilt): - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: 214 files passed, 3 skipped; 20 037 tests passed, 19 skipped. - `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. `tsc --listFiles` includes the new test file. - objectql, against the rebuilt `metadata-protocol` dist: the 20 test files that call `getMetaItems`, 343 tests passed. ## Gates All at `978945aecd`, after the final commit. - `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived **72** commands. All 72 ran, plus the **54**-row artifact-roster block (53 beyond the derived set; `check:engine-double-contract` is in both) and the four symbol-anchor sweeps. **129 commands, 126 exit 0.** - `dispatch-gates --ran`: "72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED (a DERIVED zero — all 72 recorded an exit code and none of them is 3)". - The three non-zero results are roster gates that read a pull request, which a local run has none of (exit 2, not wired): - `check-partof-closing-keyword`: re-run with this body as `PR_BODY`; - `check-closing-target-claim` and `check-single-claim-paths`: **NOT MEASURED** locally (each needs a PR number and a token); their guard workflows run on this PR. - Named readings: - `check:engine-double-contract`: OK, after recording the new pinned row through its own `--write` (`740c063f1f`). - `check:durability-log-level`, `check:nul-bytes`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:adr-0087-registration`: green. The last three ran after the full build; the first pass at `5c7330e64b` read PREREQUISITE NOT MET (exit 3) on the two build readers, and that pass is not counted. - The four symbol-anchor sweeps (`check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors`, `check:adr-anchors`): green. - **Lint, a proven narrowing.** - Command: `eslint --no-inline-config --format json` over the 2 touched `.ts` files, at `978945aecd`: 2 files, 0 errors, 0 warnings. - Population, read from eslint's own `calculateConfigForFile` / `isPathIgnored`: both are linted (neither ignored). - Invariance: no type-aware linting (`parserOptions.project` and `projectService` read null for both), so this diff cannot move any untouched file's verdict. - The repo-wide `pnpm lint` is CI's. - **NOT MEASURED here, CI's:** Test Core shards beyond the two packages above; Temporal Conformance, Dogfood, Build Core; the workspace type-check lane. ## Acceptance notes - **A list scoped to one package never falls back to the package-less row.** `getMetaItems({ type, packageId: A })` (`GET /meta/:type?package=A`) reads only A's rows (`readActiveOverlayRows`), so a package-less row never reaches the merge. - Under A's artifact with only the package-less row stored, that list serves A's artifact while `getMetaItem` naming A serves the package-less row. - With an org: the org's package-less row against an env row of A, the scoped org list serves env A while `getMetaItem` serves the org's package-less row. - These are the 25 census rows left. `mergePackageAwareOverlay`'s docblock scopes its promise to the unscoped list, and ADR-0048 states no package-scoped list rule, so whether a package-filtered list should show package-less stand-ins is a semantics question. Unchanged here. - **`view` folds two packages' same-name slots into one.** The list's view-container expansion (#13407) upserts every item by bare name, after the merge. With env rows of A and B for one view name, the unscoped list holds only B's. That is why pin 1 runs on `dashboard`. Measured on the engine double; not changed here. - **A served body can state a lock its envelope does not report.** Engine double, at `18c2ddc1ec` and at this head alike. The organization holds only package B's row; an env-wide row of A declares `full`. `getMetaItem` naming A in that organization answers `lock: none, editable: true`, and the served body (env A's row) carries `_lock: full`. The content scope and the lock scope (#21761) differ there, and `withOverlayLockFamily` leaves the body unchanged when no overlay row binds. Reported to the seat; not changed here. - **Spelling.** The list's other-spelling fallback is per type and scope (`readActiveOverlayRows`), the by-name read's per item. The candidate order is shared; the list holds one spelling per scope, so the spelling step never splits a list slot. Unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9059082 commit 3237b4a

4 files changed

Lines changed: 494 additions & 55 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
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)
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
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.
12+
13+
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.
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21804, ADR-0048, ADR-0005] A package's slot in the metadata list serves the
5+
* stored row `getMetaItem` naming that package serves, whatever order the
6+
* store returns the rows in.
7+
*
8+
* The list builds each package's slot in `mergePackageAwareOverlay`. Its
9+
* docblock promised `getMetaItem(name, packageId=P)`'s resolution, but it took
10+
* the LATEST of the package's row and the package-less row in row order. So
11+
* with both rows stored for one `(type, name, scope)`, the list served the
12+
* package-less body in one order while the by-name read served the package's
13+
* own row. The organization axis had the same shape: the list could serve an
14+
* env-wide row of the package over the organization's package-less row, which
15+
* the by-name read (organization first, ADR-0005) never does.
16+
*
17+
* Both now take one order, `servedOverlayRowCandidates` in `protocol.ts`:
18+
* scope (the organization's rows, then env-wide), then spelling, then the
19+
* package's own row before the package-less row. `findServedOverlayRow` reads
20+
* it from the store; the list reads it over the rows it already holds.
21+
*
22+
* 1. The generated pin: every subset of five stored rows (env-wide and
23+
* org-scoped, package-less and package A's, plus a third package B's
24+
* env-wide row), in every order the store can return them, for a request
25+
* with and without an organization. Every package slot in the list serves
26+
* the row an oracle written from the rule names, and `getMetaItem` naming
27+
* that package serves the same row.
28+
* 2. Named: package A's row and the package-less row, both env-wide, both
29+
* orders, with and without A's packaged artifact. The slot is A's row.
30+
* 3. Named, organization scope: the organization's package-less row beats an
31+
* env-wide row of A; the organization's row of A beats the env-wide
32+
* package-less row. Both orders.
33+
* 4. Control: only the package-less row is stored, under A's artifact. The
34+
* slot still falls back to it (stamped as A's), as the by-name read does.
35+
* 5. The draft preview: A's draft and a package-less draft, both orders. The
36+
* previewed slot is A's draft, as `getMetaItem` with `previewDrafts`.
37+
*
38+
* Out of this card: a list scoped to a package (`packageId` on the list
39+
* request) reads only that package's rows, so a package-less row never
40+
* reaches its merge. That is the row read, not the merge, and these pins do
41+
* not cover it.
42+
*/
43+
import { describe, expect, it } from 'vitest';
44+
import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core';
45+
import { ObjectStackProtocolImplementation } from './protocol.js';
46+
47+
const ORG = 'org_a';
48+
/**
49+
* The type the tables run on: per-organization overridable (ADR-0005), so the
50+
* organization axis is live, and with no expansion of its own in the list.
51+
* (`view` upserts the items its stored containers expand by bare name, which
52+
* folds two packages' same-name slots into one; that is the view expansion,
53+
* not this merge, so pin 2 also runs on `view` with one package.)
54+
*/
55+
const TYPE = 'dashboard';
56+
const PKG_A = 'com.example.a';
57+
const PKG_B = 'com.example.b';
58+
const NAME = 'v_slot';
59+
60+
type Scope = 'env-wide' | 'org-scoped';
61+
interface RowSpec { scope: Scope; packageId: string | null; label: string }
62+
63+
/** The stored rows the tables draw from. */
64+
const ROWS = {
65+
envPackageless: { scope: 'env-wide', packageId: null, label: 'env-wide package-less row' },
66+
envA: { scope: 'env-wide', packageId: PKG_A, label: 'env-wide row of A' },
67+
envB: { scope: 'env-wide', packageId: PKG_B, label: 'env-wide row of B' },
68+
orgPackageless: { scope: 'org-scoped', packageId: null, label: 'org-scoped package-less row' },
69+
orgA: { scope: 'org-scoped', packageId: PKG_A, label: 'org-scoped row of A' },
70+
} as const satisfies Record<string, RowSpec>;
71+
type RowKey = keyof typeof ROWS;
72+
const ROW_KEYS = Object.keys(ROWS) as RowKey[];
73+
74+
interface StoredRow {
75+
id: string;
76+
type: string;
77+
name: string;
78+
organization_id: string | null;
79+
package_id: string | null;
80+
state: string;
81+
metadata: string;
82+
}
83+
84+
function stored(key: RowKey, state: 'active' | 'draft' = 'active', type: string = TYPE): StoredRow {
85+
const spec: RowSpec = ROWS[key];
86+
const label = state === 'draft' ? `${spec.label} (draft)` : spec.label;
87+
return {
88+
id: `r_${key}_${state}`,
89+
type,
90+
name: NAME,
91+
organization_id: spec.scope === 'org-scoped' ? ORG : null,
92+
package_id: spec.packageId,
93+
state,
94+
metadata: JSON.stringify({ name: NAME, label, ...(type === 'view' ? { object: 'account' } : {}), _provenance: 'org' }),
95+
};
96+
}
97+
98+
/** What package A's loader registered for the name, when the row asks for it. */
99+
const ARTIFACT_A = { name: NAME, label: 'packaged by A', _packageId: PKG_A, _provenance: 'package' };
100+
101+
/**
102+
* The engine double: `find` / `findOne` over `sys_metadata` rows, and a
103+
* registry that holds at most package A's artifact, answering a
104+
* package-scoped lookup with that package's items only.
105+
*/
106+
function harness(rows: StoredRow[], artifact = false, type: string = TYPE) {
107+
const items: Array<Record<string, unknown>> = artifact ? [ARTIFACT_A] : [];
108+
const ofPackage = (packageId?: string) => items.filter((i) => !packageId || i._packageId === packageId);
109+
const registry = {
110+
getArtifactItem(asked: string, name: string, packageId?: string) {
111+
const hit = asked === type ? ofPackage(packageId).find((i) => i.name === name) : undefined;
112+
return hit && isCodeArtifactBody(hit) ? hit : undefined;
113+
},
114+
getItem(asked: string, name: string, packageId?: string) {
115+
return asked === type ? ofPackage(packageId).find((i) => i.name === name) : undefined;
116+
},
117+
listItems(asked: string, packageId?: string) {
118+
return asked === type ? ofPackage(packageId) : [];
119+
},
120+
getObject: () => undefined,
121+
registerObject: () => undefined,
122+
getPackage: () => undefined,
123+
isPackageDisabled: () => false,
124+
applyNavContributions: (app: unknown) => app,
125+
};
126+
const matching = (where: Record<string, unknown>) => {
127+
for (const k of Object.keys(where)) {
128+
if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`);
129+
}
130+
return rows.filter((r) =>
131+
Object.entries(where).every(([k, v]) => v === undefined || (r as unknown as Record<string, unknown>)[k] === v),
132+
);
133+
};
134+
const engine: any = {
135+
async find(table: string, opts?: { where?: Record<string, unknown>; limit?: number }) {
136+
if (table !== 'sys_metadata') return [];
137+
const matched = matching(opts?.where ?? {});
138+
// `check:objectql-double-limit` — the caller's bound, applied after the filter.
139+
return opts?.limit === undefined ? matched : matched.slice(0, opts.limit);
140+
},
141+
async findOne(table: string, opts?: { where?: Record<string, unknown> }) {
142+
// `check:engine-double-contract` — refuses what the real engine refuses.
143+
assertEngineFindOnePredicate(table, opts);
144+
if (table !== 'sys_metadata') return null;
145+
return matching(opts?.where ?? {})[0] ?? null;
146+
},
147+
async insert() {
148+
return {};
149+
},
150+
registry,
151+
};
152+
return new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_1');
153+
}
154+
155+
/** Every order the store can return `xs` in. */
156+
function orders<T>(xs: readonly T[]): T[][] {
157+
if (xs.length <= 1) return [[...xs]];
158+
return xs.flatMap((x, i) => orders([...xs.slice(0, i), ...xs.slice(i + 1)]).map((rest) => [x, ...rest]));
159+
}
160+
161+
/** Every subset of `xs`, the empty one included. */
162+
function subsets<T>(xs: readonly T[]): T[][] {
163+
return xs.reduce<T[][]>((acc, x) => [...acc, ...acc.map((s) => [...s, x])], [[]]);
164+
}
165+
166+
/**
167+
* The oracle, written from the rule: the scopes the request reaches
168+
* (ADR-0005: the organization's, then env-wide), and within one scope the
169+
* package's own row before the package-less row (ADR-0048), never another
170+
* package's. The first row found is the served row.
171+
*/
172+
function servedLabel(rows: readonly RowKey[], packageId: string, organizationId: string | undefined): string | undefined {
173+
const scopes: Scope[] = organizationId ? ['org-scoped', 'env-wide'] : ['env-wide'];
174+
for (const scope of scopes) {
175+
for (const candidate of [packageId, null]) {
176+
const hit = rows.find((key) => ROWS[key].scope === scope && ROWS[key].packageId === candidate);
177+
if (hit) return ROWS[hit].label;
178+
}
179+
}
180+
return undefined;
181+
}
182+
183+
/** The list's slot for `packageId` (the items of the name stamped with it). */
184+
async function listSlots(
185+
protocol: ObjectStackProtocolImplementation, packageId: string,
186+
request: { organizationId?: string; previewDrafts?: boolean } = {}, type: string = TYPE,
187+
): Promise<any[]> {
188+
const listed: any = await protocol.getMetaItems({ type, ...request });
189+
return (listed.items as any[]).filter((item) => item?.name === NAME && item?._packageId === packageId);
190+
}
191+
192+
/** The body `getMetaItem` naming `packageId` serves. */
193+
async function byName(
194+
protocol: ObjectStackProtocolImplementation, packageId: string,
195+
request: { organizationId?: string; previewDrafts?: boolean } = {}, type: string = TYPE,
196+
): Promise<any> {
197+
const read: any = await protocol.getMetaItem({ type, name: NAME, packageId, ...request });
198+
return read.item;
199+
}
200+
201+
// ── 1. The generated pin ─────────────────────────────────────────────────────
202+
203+
describe('pin 1 (generated) — every package slot in the list serves the row getMetaItem naming that package serves, in every row order', () => {
204+
const TABLE = subsets(ROW_KEYS).flatMap((rows) =>
205+
([undefined, ORG] as const).map((organizationId) => ({ rows, organizationId })));
206+
207+
it('completeness: every subset of the five rows, with and without an organization, and both packages are reachable', () => {
208+
expect(TABLE).toHaveLength(2 ** ROW_KEYS.length * 2);
209+
expect(new Set(ROW_KEYS.map((key) => ROWS[key].packageId))).toEqual(new Set([null, PKG_A, PKG_B]));
210+
expect(new Set(ROW_KEYS.map((key) => ROWS[key].scope))).toEqual(new Set(['env-wide', 'org-scoped']));
211+
// The arrangements this card is about are in the table: the package's
212+
// row beside the package-less row of one scope, in more than one order.
213+
const both = TABLE.filter(({ rows }) =>
214+
(rows.includes('envA') && rows.includes('envPackageless'))
215+
|| (rows.includes('orgA') && rows.includes('orgPackageless')));
216+
expect(both.length).toBeGreaterThan(0);
217+
expect(both.every(({ rows }) => orders(rows).length >= 2)).toBe(true);
218+
});
219+
220+
for (const { rows, organizationId } of TABLE) {
221+
const title = `rows: ${rows.length === 0 ? 'none' : rows.map((key) => ROWS[key].label).join(' + ')}`
222+
+ ` · request: ${organizationId ? `organization ${organizationId}` : 'no organization'}`;
223+
it(title, async () => {
224+
for (const order of orders(rows)) {
225+
const at = `${title} · row order ${order.join(', ') || '-'}`;
226+
const protocol = harness(order.map((key) => stored(key)));
227+
const scope = organizationId ? { organizationId } : {};
228+
for (const packageId of [PKG_A, PKG_B]) {
229+
const expected = servedLabel(order, packageId, organizationId);
230+
const inScope = order.some((key) => ROWS[key].packageId === packageId
231+
&& (ROWS[key].scope === 'env-wide' || organizationId !== undefined));
232+
const slots = await listSlots(protocol, packageId, scope);
233+
if (!inScope) {
234+
// No row of the package in scope and no artifact: the
235+
// list has no slot for it.
236+
expect(slots, `${at}: a slot for ${packageId}`).toEqual([]);
237+
continue;
238+
}
239+
expect(slots.map((s) => s.label), `${at}: the list's slot for ${packageId}`).toEqual([expected]);
240+
expect((await byName(protocol, packageId, scope))?.label, `${at}: getMetaItem naming ${packageId}`)
241+
.toBe(expected);
242+
}
243+
}
244+
});
245+
}
246+
});
247+
248+
// ── 2–4. Named ───────────────────────────────────────────────────────────────
249+
250+
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', () => {
251+
for (const type of [TYPE, 'view']) {
252+
for (const artifact of [false, true]) {
253+
for (const order of orders<RowKey>(['envPackageless', 'envA'])) {
254+
it(`/meta/${type} · ${artifact ? 'A\'s artifact registered' : 'no artifact'} · row order ${order.join(', ')}`, async () => {
255+
const protocol = harness(order.map((key) => stored(key, 'active', type)), artifact, type);
256+
const slots = await listSlots(protocol, PKG_A, {}, type);
257+
expect(slots.map((s) => s.label)).toEqual([ROWS.envA.label]);
258+
expect((await byName(protocol, PKG_A, {}, type))?.label).toBe(ROWS.envA.label);
259+
// The package-less row is not listed beside it: it stands
260+
// in only for a package with no row of its own.
261+
const listed: any = await protocol.getMetaItems({ type });
262+
expect((listed.items as any[]).filter((item) => item?.name === NAME)).toHaveLength(1);
263+
});
264+
}
265+
}
266+
}
267+
});
268+
269+
describe('pin 3 — organization scope (ADR-0005): the organization\'s rows before the env-wide rows, then the package\'s own row', () => {
270+
for (const order of orders<RowKey>(['orgPackageless', 'envA', 'envPackageless'])) {
271+
it(`the organization's package-less row over an env-wide row of A · row order ${order.join(', ')}`, async () => {
272+
const protocol = harness(order.map((key) => stored(key)));
273+
const slots = await listSlots(protocol, PKG_A, { organizationId: ORG });
274+
expect(slots.map((s) => s.label)).toEqual([ROWS.orgPackageless.label]);
275+
expect((await byName(protocol, PKG_A, { organizationId: ORG }))?.label).toBe(ROWS.orgPackageless.label);
276+
});
277+
}
278+
for (const order of orders<RowKey>(['orgA', 'envPackageless'])) {
279+
it(`the organization's row of A over the env-wide package-less row · row order ${order.join(', ')}`, async () => {
280+
const protocol = harness(order.map((key) => stored(key)));
281+
const slots = await listSlots(protocol, PKG_A, { organizationId: ORG });
282+
expect(slots.map((s) => s.label)).toEqual([ROWS.orgA.label]);
283+
expect((await byName(protocol, PKG_A, { organizationId: ORG }))?.label).toBe(ROWS.orgA.label);
284+
});
285+
}
286+
});
287+
288+
describe('pin 4 — control: with only the package-less row stored, the package\'s slot still falls back to it', () => {
289+
it('A\'s artifact and the env-wide package-less row: the slot serves the row, stamped as A\'s', async () => {
290+
const protocol = harness([stored('envPackageless')], true);
291+
const slots = await listSlots(protocol, PKG_A);
292+
expect(slots.map((s) => ({ label: s.label, packageId: s._packageId })))
293+
.toEqual([{ label: ROWS.envPackageless.label, packageId: PKG_A }]);
294+
expect((await byName(protocol, PKG_A))?.label).toBe(ROWS.envPackageless.label);
295+
});
296+
297+
it('lit control: A\'s artifact alone is served as itself', async () => {
298+
const protocol = harness([], true);
299+
const slots = await listSlots(protocol, PKG_A);
300+
expect(slots.map((s) => s.label)).toEqual([ARTIFACT_A.label]);
301+
expect((await byName(protocol, PKG_A))?.label).toBe(ARTIFACT_A.label);
302+
});
303+
});
304+
305+
// ── 5. The draft preview ─────────────────────────────────────────────────────
306+
307+
describe('pin 5 — the draft preview: the package\'s previewed slot is the draft getMetaItem previews, in both orders', () => {
308+
for (const order of orders<RowKey>(['envPackageless', 'envA'])) {
309+
it(`A's draft and the package-less draft · row order ${order.join(', ')}`, async () => {
310+
const protocol = harness(order.map((key) => stored(key, 'draft')), true);
311+
const slots = await listSlots(protocol, PKG_A, { previewDrafts: true });
312+
expect(slots.map((s) => ({ label: s.label, draft: s._draft })))
313+
.toEqual([{ label: `${ROWS.envA.label} (draft)`, draft: true }]);
314+
const previewed = await byName(protocol, PKG_A, { previewDrafts: true });
315+
expect({ label: previewed?.label, draft: previewed?._draft })
316+
.toEqual({ label: `${ROWS.envA.label} (draft)`, draft: true });
317+
});
318+
}
319+
});

0 commit comments

Comments
 (0)