Skip to content

Commit e9dec3d

Browse files
fix(metadata-protocol): a view a stored container expands answers by name what the object door lists, on every kernel and container scope (#21442) (#21508)
Fixes #21442 Clause-②: no ## What this changes `GET /api/v1/meta/view?object=OBJECT` (the object door, `getMetaItems`) lists the views a stored view container expands. The by-name read (`GET /api/v1/meta/view/NAME`, `getMetaItem`) expanded no container. It answered such a name only on an unscoped kernel and only for an environment-wide container, where registry hydration had registered a copy. This PR implements triage's ruling A (comment 5957375321) as ruled: the by-name read expands the in-scope stored containers through `expandRuntimeViewContainer`, the function the list read uses. Nothing is persisted or registered, and there is no kernel-specific branch. - **By-name read (`getMetaItem`).** A new step 1b sits after the stored-row read and before the MetadataService and registry steps. When a view name has no stored row of its own and a stored view container in the caller's scope expands it, the read answers the item the list read serves under that name. The step sits before the MetadataService and registry steps because the list read's expansion also wins over items of the same name from those two sources. - **One selection and one expansion, shared with the list read.** Three parts of `readFlattenedMetaItems` move unchanged into private methods: its row selection (the two `queryByOrg` reads, the overlay cache and the org-over-env merge), its row parse, and its expansion loop. The new methods are `readActiveOverlayRows`, `storedOverlayEntries` and `expandStoredViewContainers`, and both doors call them. `resolveRowlessExpandedView` is the by-name use of the three. `expandRuntimeViewContainer` is called as is and is not edited. The list read's own behaviour is unchanged, and the package's full suite still passes over it. - **Layers (`getMetaItemLayered`).** For such a name the layers report the container's provenance in fields that already exist. `overlay` is the container's own stored row: its `name` is the container's, and its `_packageId` is the package the row is bound to. `overlayScope` is the scope that row was read from (`env` or `org`), and `effective` is the expanded view, which is what the by-name read answers. `code` keeps its own read: the item a package ships under the name, else `null`. - **History and diff (`historyMetaItem`, `diffMetaItem`).** They resolve to the container's own row. Each answers exactly what it answers under the container's row name for the same caller, and says so: every event's `ref.name` is the container's, and the diff's `name` is the container's (the item actually diffed, the same rule its echoed `type` already follows). No history is synthesized for a name that was never stored. - **Control.** The container's own name still answers its stored row on every read. ## Measured, in-process at the protocol (the #21334 showcase harness: `showcase_task`, every member kind) | | before, `env_local` | before, unscoped | after, both kernels | |---|---|---|---| | package-scoped container (saved into another writable package) | nothing | same item as the list | same item as the list | | package-less, environment-wide container | nothing | same item as the list | same item as the list | | package-less, organization-scoped container | nothing | nothing | same item as the list | | tenant overlay of the package's own container, `showcase_task.default` | the packaged view (`All Tasks`) | the overlay's view (env-wide) / the packaged view (org-scoped) | the overlay's view | | layers for `showcase_task.os_qa_probe.in_progress` (package-scoped container) | `overlay: null`, `effective: null` | `overlay: null`, `code` and `effective` = the hydrated copy | `overlay` = container `os_qa_probe`, `overlayScope` = its scope, `effective` = the item | | history for that name (package-scoped container) | `[]` | `[]` | the container's own events, `ref.name: os_qa_probe` (every scope) | | diff for that name (package-scoped container) | empty, `name` echoed | empty, `name` echoed | the container's own diff, `name: os_qa_probe` (every scope) | "Before" is `origin/main` at `5555047117`. "After" is this branch. Both were run with the same harness, and the item comparison excludes `_diagnostics`. ## Provenance needed no new field (PM mechanism assumption 3) The layered response already carries `overlay` (the stored row behind the name) and `overlayScope` (its scope). History events already carry `ref.name` and `ref.org`, and the diff already echoes `name`. So no published response shape gains or loses a key, and `Clause-②: no` stands as claimed. ## A write by an expanded name (out of scope, recorded, unchanged) Probe: `saveMetaItem` with type `view`, the name `showcase_task.os_qa_probe.in_progress` and a ViewItem body. On both kernels and for all three container scopes, the save door ACCEPTS it and stores a row under that name. Afterwards the by-name read answers the written row, and the object door still lists the container's expansion for that name. The probe gave byte-identical results with `protocol.ts` at `5555047117` and at this branch: `saveMetaItem` is not in this diff, and it does not call any read this PR changes for a view. The disagreement it leaves is filed as a finding in the dev report (the list read's upsert by name displaces a stored row of the same name), not fixed here. ## Reverse verification (each mutation committed-state, landed through `scripts/ablation-replace.mjs`, restore proven blob == HEAD and `git diff HEAD` empty) The subject resolves through relative source imports (`./index.js`), so no `dist/` leg applies. - **A1, by-name step 1b off** (`if (expanded !== undefined && false)`): `view-container-runtime-expansion.test.ts` went from 119/119 to **30 failed / 89 passed**. The failures were every `env_local` case (21); on the unscoped kernel, the organization-scoped cases, the isolation case and the org-scoped overlay case (8); and the cross-kernel case (1). The unscoped environment-wide cases stayed green, which is the pre-fix registry-hydration answer the card describes. - **A2, layers resolution off**: **10 failed** (every layers assertion, both kernels). - **A3, history delegation off**: **6 failed** (the history assertions, both kernels). - **A4, diff delegation off**: **6 failed** (the diff assertions, both kernels). ## Tests - New, in `packages/metadata-protocol/src/view-container-runtime-expansion.test.ts`, nested in the #21334 block so the faithful-registry harness is reused: 49 cases. They cover both kernels × all three container scopes × every member kind: every name the object door lists (the packaged names included) answers that same item by name, and the container's own name is the control. Further cases cover the layers, history and diff per scope; that the reads store no row and register nothing; that a name nothing expands answers nothing and gets no history; organization isolation; a tenant overlay of the package's own container (env-wide and org-scoped); and a cross-kernel item equality. - `get-meta-item-org-read-gate.test.ts` / `get-meta-item-layered-org-read-gate.test.ts`: their engine double declared only `findOne`, and a row-less view name now also issues `find` for the container selection. The double gains a `find` that records partitions. The `view` case now also asserts that the selection reads the same partitions (`[null, ORG]`). The `find` is assigned onto the existing double rather than declared in its literal, so the double's `check:engine-double-contract` accounting is unchanged and no ledger moves. - At the final head `3558ad6e70`: `pnpm --filter @objectstack/metadata-protocol typecheck` clean (`tsc --listFiles` includes all three edited test files). The full suite (`vitest run`) gave 205 files passed / 3 skipped, **3151 tests passed** / 19 skipped. - Downstream, against the rebuilt `dist/` (a narrowed, declared sample, chosen as the test files that read a view by name through the real protocol): `@objectstack/objectql` 10 files / 226 tests, `@objectstack/rest` 9 files / 123 tests, `@objectstack/runtime` 3 files / 43 tests, all green. The rest of those packages is CI's. - Gates: `dispatch-gates` derived 64 families for this tree (`3558ad6e70`). 63 ran green after the final commit. 1 is NOT MEASURED: `check:dual-build-cjs-loads` exit 3 PREREQUISITE NOT MET, because it reads every package's `dist/` and about 38 packages were not built locally. `--ran` reconciliation: 64 accounted, 63 run, 1 NOT-MEASURED, 0 UNRUN. - Lint, narrowed: `eslint --no-inline-config --format json` over the 4 changed TypeScript files gave 4 files, 0 errors, 0 warnings. The config does no type-aware linting (no `parserOptions.project`; see the note at `eslint.config.mjs` line 328), so this diff cannot move a verdict on an untouched file. A repo-wide `pnpm lint` is CI's. ## Acceptance notes - **Cost.** A by-name read of a view name with no stored row of its own (every packaged view, for instance) now also reads the container selection. That is the list read's two `find`s, served from the same overlay cache entry the object door uses (same key: type, package, gated organization) on an engine with a write epoch. It is the price of one expansion rule rather than two. - **Claim surface, declared.** Four edits fall outside `getMetaItem`, history and diff. The three-method extraction in `readFlattenedMetaItems` is a pure move, needed so that the by-name read reuses the list's selection rather than a second copy (PM mechanism assumption 2). The `getMetaItemLayered` edit is the ruling's Layers bullet. The two read-gate test doubles are described above. No edit touches the save door, `deletePackage` or `packages/rest`. - **Kernel-dependent residue this PR does not change.** The item every door answers is now the same on both kernels. On an unscoped kernel, the registry still holds the write-through-hydrated copies of an environment-wide container's expansions, without the tenant marker the container itself is given. A package-bound container's copies therefore pass for code artifacts there. So `resettable` reads `true` on the by-name envelope, the layers' `code` is the hydrated copy (on `env_local`: `false`, `null`), and `isArtifactBacked` is true for a write by that name. The producer-side fix would change the save door's intent for a write by an expanded name, which the ruling keeps unchanged, so it is reported as a finding and not fixed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2df621a commit e9dec3d

5 files changed

Lines changed: 594 additions & 167 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): a view a stored view container expands answers by name what the object door lists, on every kernel and for every container scope
6+
7+
Clause-②: no
8+
9+
- **What changed.** `GET /api/v1/meta/view?object=…` lists the views a stored view container expands, and the by-name read now answers each of those names with the same item. Before, `getMetaItem` expanded no container: it answered such a name only on an unscoped kernel and only for an environment-wide container, where the registry held a hydrated copy. On an environment-scoped kernel, and for an organization-scoped container on any kernel, it answered nothing. Where the name is one a package also ships, such as `<object>.default` under a tenant's overlay of that package's container, it answered the packaged view while the list served the overlay's.
10+
- **How.** The by-name read selects the stored containers in the caller's scope with the list read's own row selection, and expands them with the list read's own expansion. Nothing is persisted or registered, and a stored row of the name itself still answers first.
11+
- **Layers, history and diff for such a name.** `getMetaItemLayered` reports the container's own stored row as `overlay`, with the scope it was read from as `overlayScope`, and the expanded view as `effective`. `historyMetaItem` and `diffMetaItem` answer exactly what they answer under the container's own name, and say so: every event's `ref.name` and the diff's `name` are the container's. No history is made up for a name that was never stored.
12+
- **What does not change.** The container's own name still answers its stored row. The save door is unchanged, including a write by an expanded name. No response shape gains or loses a key.

‎packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts‎

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -97,33 +97,40 @@ const storedRow = (
9797
* The engine double: `findOne` over a row table, plus the registry surface the
9898
* layered read touches on its way past the overlay.
9999
*
100-
* ⛔ No `find` / `insert` / `update` / `delete`, deliberately — the read path
101-
* under test issues exactly one verb, and a double declaring verbs no case
102-
* exercises would owe `check:engine-double-contract` a dispatch contract that
103-
* protects nothing. Same shape the two sibling read-gate pins drive.
100+
* `find` is the second verb the read path issues, and only for a `view` name
101+
* with no stored row of its own: the read then selects the stored view
102+
* containers that might expand that name, through the list read's own row
103+
* selection (#21442). It records into `finds` — the same partitions question
104+
* asked of that read. ⛔ No `insert` / `update` / `delete`, deliberately — a
105+
* double declaring verbs no case exercises would owe
106+
* `check:engine-double-contract` a dispatch contract that protects nothing. Same shape the two sibling read-gate pins drive.
104107
*/
105108
function makeHarness(rows: StoredRow[]) {
106109
const findOnes: Array<Record<string, unknown>> = [];
110+
const finds: Array<Record<string, unknown>> = [];
111+
const matching = (where: Record<string, unknown>) => {
112+
// `check:where-matcher` — a hand-written matcher with no combinator
113+
// branch reads `$and` as a field name and answers the wrong question
114+
// rather than failing. Refuse the shape this double does not
115+
// implement, matching the sibling doubles' convention.
116+
for (const k of Object.keys(where)) {
117+
if (k.startsWith('$')) {
118+
throw new Error(`[test double] unsupported WHERE combinator '${k}'`);
119+
}
120+
}
121+
return rows.filter((r) =>
122+
Object.entries(where).every(([k, v]) => {
123+
if (v === undefined) return true;
124+
return (r as unknown as Record<string, unknown>)[k] === v;
125+
}),
126+
);
127+
};
107128
const engine: any = {
108129
async findOne(table: string, opts?: { where?: Record<string, unknown> }) {
109130
if (table !== 'sys_metadata') return undefined;
110131
const where = opts?.where ?? {};
111132
findOnes.push({ ...where });
112-
// `check:where-matcher` — a hand-written matcher with no combinator
113-
// branch reads `$and` as a field name and answers the wrong
114-
// question rather than failing. Refuse the shape this double does
115-
// not implement, matching the sibling doubles' convention.
116-
for (const k of Object.keys(where)) {
117-
if (k.startsWith('$')) {
118-
throw new Error(`[test double] unsupported WHERE combinator '${k}'`);
119-
}
120-
}
121-
return rows.find((r) =>
122-
Object.entries(where).every(([k, v]) => {
123-
if (v === undefined) return true;
124-
return (r as unknown as Record<string, unknown>)[k] === v;
125-
}),
126-
);
133+
return matching(where)[0];
127134
},
128135
registry: {
129136
registerItem: () => undefined,
@@ -137,8 +144,14 @@ function makeHarness(rows: StoredRow[]) {
137144
applyNavContributions: (app: unknown) => app,
138145
},
139146
};
147+
engine.find = async (table: string, opts?: { where?: Record<string, unknown> }) => {
148+
if (table !== 'sys_metadata') return [];
149+
const where = opts?.where ?? {};
150+
finds.push({ ...where });
151+
return matching(where);
152+
};
140153
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any;
141-
return { protocol, findOnes };
154+
return { protocol, findOnes, finds };
142155
}
143156

144157
/** Every `organization_id` partition the engine was asked for, deduplicated. */
@@ -443,11 +456,14 @@ describe('§5 an already-gating caller receives the same scope it did before', (
443456
// for `view` gets the org partition read, exactly as before.
444457
const gated = organizationIdForMetaRead('view', ORG);
445458
expect(gated).toBe(ORG);
446-
const { protocol, findOnes } = makeHarness([]);
459+
const { protocol, findOnes, finds } = makeHarness([]);
447460
await protocol.getMetaItemLayered({
448461
type: 'view', name: 'probe', organizationId: gated,
449462
});
450463
expect(partitions(findOnes)).toEqual([null, ORG]);
464+
// [#21442] `probe` has no row of its own, so the read also selects the
465+
// stored containers that might expand it — from the same partitions.
466+
expect(partitions(finds)).toEqual([null, ORG]);
451467
});
452468
});
453469

‎packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts‎

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -109,33 +109,40 @@ const storedRow = (
109109
* The engine double: `findOne` over a row table, plus the registry surface the
110110
* single-item read path touches on its way past the overlay.
111111
*
112-
* ⛔ No `find` / `insert` / `update` / `delete`, deliberately — the read path
113-
* under test issues exactly one verb, and a double declaring verbs no case
114-
* exercises would owe `check:engine-double-contract` a dispatch contract that
115-
* protects nothing. Same shape the sibling plural-door pin drives.
112+
* `find` is the second verb the read path issues, and only for a `view` name
113+
* with no stored row of its own: the read then selects the stored view
114+
* containers that might expand that name, through the list read's own row
115+
* selection (#21442). It records into `finds` — the same partitions question
116+
* asked of that read. ⛔ No `insert` / `update` / `delete`, deliberately — a
117+
* double declaring verbs no case exercises would owe
118+
* `check:engine-double-contract` a dispatch contract that protects nothing. Same shape the sibling plural-door pin drives.
116119
*/
117120
function makeHarness(rows: StoredRow[]) {
118121
const findOnes: Array<Record<string, unknown>> = [];
122+
const finds: Array<Record<string, unknown>> = [];
123+
const matching = (where: Record<string, unknown>) => {
124+
// `check:where-matcher` — a hand-written matcher with no combinator
125+
// branch reads `$and` as a field name and answers the wrong question
126+
// rather than failing. Refuse the shape this double does not
127+
// implement, matching the sibling doubles' convention.
128+
for (const k of Object.keys(where)) {
129+
if (k.startsWith('$')) {
130+
throw new Error(`[test double] unsupported WHERE combinator '${k}'`);
131+
}
132+
}
133+
return rows.filter((r) =>
134+
Object.entries(where).every(([k, v]) => {
135+
if (v === undefined) return true;
136+
return (r as unknown as Record<string, unknown>)[k] === v;
137+
}),
138+
);
139+
};
119140
const engine: any = {
120141
async findOne(table: string, opts?: { where?: Record<string, unknown> }) {
121142
if (table !== 'sys_metadata') return undefined;
122143
const where = opts?.where ?? {};
123144
findOnes.push({ ...where });
124-
// `check:where-matcher` — a hand-written matcher with no combinator
125-
// branch reads `$and` as a field name and answers the wrong
126-
// question rather than failing. Refuse the shape this double does
127-
// not implement, matching the sibling doubles' convention.
128-
for (const k of Object.keys(where)) {
129-
if (k.startsWith('$')) {
130-
throw new Error(`[test double] unsupported WHERE combinator '${k}'`);
131-
}
132-
}
133-
return rows.find((r) =>
134-
Object.entries(where).every(([k, v]) => {
135-
if (v === undefined) return true;
136-
return (r as unknown as Record<string, unknown>)[k] === v;
137-
}),
138-
);
145+
return matching(where)[0];
139146
},
140147
registry: {
141148
registerItem: () => undefined,
@@ -149,8 +156,14 @@ function makeHarness(rows: StoredRow[]) {
149156
applyNavContributions: (app: unknown) => app,
150157
},
151158
};
159+
engine.find = async (table: string, opts?: { where?: Record<string, unknown> }) => {
160+
if (table !== 'sys_metadata') return [];
161+
const where = opts?.where ?? {};
162+
finds.push({ ...where });
163+
return matching(where);
164+
};
152165
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any;
153-
return { protocol, findOnes };
166+
return { protocol, findOnes, finds };
154167
}
155168

156169
/** Every `organization_id` partition the engine was asked for, deduplicated. */
@@ -389,9 +402,12 @@ describe('§4 an already-gating caller receives the same scope it did before', (
389402
// the org partition read, exactly as before this change.
390403
const gated = organizationIdForMetaRead('view', ORG);
391404
expect(gated).toBe(ORG);
392-
const { protocol, findOnes } = makeHarness([]);
405+
const { protocol, findOnes, finds } = makeHarness([]);
393406
await protocol.getMetaItem({ type: 'view', name: 'probe', organizationId: gated });
394407
expect(partitions(findOnes)).toEqual([null, ORG]);
408+
// [#21442] `probe` has no row of its own, so the read also selects the
409+
// stored containers that might expand it — from the same partitions.
410+
expect(partitions(finds)).toEqual([null, ORG]);
395411
});
396412
});
397413

0 commit comments

Comments
 (0)