Skip to content

Commit 7fbfef4

Browse files
test(objectql): pin the environment overlay of a packaged item for the five overlay-enabled types (#22363)
Part of #22340 Clause-②: no This is stage S1 of #22340, claimed in the amendment comment 6069745527. It pins the environment overlay of the five overlay-enabled metadata types under the flag's current name, `allowOrgOverride`, and changes tests only. The rename is stage S2 and is not in this PR. #22340 stays open. ## What this adds This PR adds one new test file, `packages/objectql/src/environment-overlay-five-types.test.ts`. It runs on the real `ObjectQL` engine and `SchemaRegistry` and the real `ObjectStackProtocolImplementation`, on both topologies: an environment kernel (`environmentId` set) and a host-config kernel (none). The harness follows `engine-security-catalog-package-door.test.ts`. No source file changes. Each of `view`, `dashboard`, `report`, `translation` and `email_template` gets three checks, and the first two run on both topologies: 1. **Write.** `saveMetaItem` over the packaged item is accepted (`success: true`) and stores exactly one `sys_metadata` row. That row names no organization (`organization_id` is NULL), and the item's read then serves the body that was written. 2. **Read of a stored overlay.** An environment-wide row goes straight into the store, with no write door in front of it, and `getMetaItem` serves it. As a control, the same read serves the package's item before the row exists. 3. **Wire.** `getMetaTypes` advertises `allowOrgOverride: true` with `overrideSource: 'registry'` for the type. The `OS_METADATA_WRITABLE` hatch is cleared for every case, so it cannot stand in for the registry. One more case pins the identity: the registry types whose flag is `true` are exactly these five. The file writes the five out instead of reading them from the registry, so a type that leaves the flag fails its own cases by name. No `organizationId` reaches any call, and the protocol has no organization of its own. When #15206 S3 to S5 delete the per-organization path, these cases still stand. There are 26 cases: 1 identity case, 20 write and read cases (5 types, 2 topologies, 2 legs) and 5 wire cases. ## Measured on `origin/main` (`1cb0edb82`) before writing - All five environment-wide overlays are accepted on both topologies today. No stop condition fired: none of the five is refused, and none needs an `organizationId`. - Four of the packaged items come in through the manifest's own stack collections (`views`, `dashboards`, `reports`, `emailTemplates`), the seam that stamps package provenance. `translation` has no such collection (see Acceptance notes). Its packaged item is registered through the registry's package seam, `registerItem(type, item, 'name', packageId)`, which is the entry `isArtifactBacked` reads. - The single-item read, `getMetaItem`, serves a stored active row without consulting the flag or `supportsOverlay` (see Acceptance notes). ## Pins that already exist, not repeated here | type | already pinned elsewhere | added here | |---|---|---| | view | dispatcher PUT (`runtime/src/meta-field-overlay-lock.test.ts`); package-door verdict (`metadata-protocol/src/protocol.packaged-base-refusal.test.ts`); repository write (`metadata-protocol/src/sys-metadata-repository.package-writability.test.ts`); wire flag on a mock engine (`objectql/src/protocol-meta-types-rich.test.ts`) | the stored row's organization, the served body, the wire flag on the real engine | | dashboard | dispatcher PUT (`runtime/src/meta-field-overlay-lock.test.ts`) | all three checks on the protocol | | report | none | all three checks | | translation | none | all three checks | | email_template | the save over a packaged template on both topologies (the control in `objectql/src/engine-security-catalog-package-door.test.ts`) | the stored row's organization, the served body, the wire flag | Each existing pin reads one layer of one type, and none of them reads the stored row's organization, the served body and the wire flag together. The `email_template` save is the one overlap. Here it is only the first step of a round trip that goes on to read those three things. **No dispatcher case in `packages/runtime`.** For an environment-wide write (a `manage_metadata` session with no active organization), the dispatcher reads the flag nowhere: - `metaWriteCapabilityVerdict` admits `manage_metadata` before it reads the flag. - `organizationIdForMetaWrite` returns `undefined` before it reads the flag. Both read the flag only for a session with an active organization, which is the per-organization path. The dispatcher hands this same request to `saveMetaItem`, and the existing view and dashboard dispatcher pins already cover that transport. ## Reverse verification (ablation) - **Mutation.** `report`'s flag was flipped to `false` in `DEFAULT_METADATA_TYPE_REGISTRY` (`packages/spec/src/kernel/metadata-plugin.zod.ts`) through `scripts/ablation-replace.mjs`. Its evidence: anchor x1 → x0, replacement x0 → x1, blob `47f68e76cb5c` → `01bfde907a81`. - **Rebuild.** `@objectstack/spec` was rebuilt, because the test and `metadata-protocol`'s dist both import the registry from spec's `dist/`. `scripts/ablation-dist-preflight.mjs` found the marker in 4 built files: `dist/kernel/index.js`, `dist/kernel/index.mjs`, `dist/browser/kernel/index.js` and `dist/browser/kernel/index.mjs`. - **Prediction, written before the run.** 4 red and 22 green. The red cases would be the identity, `report`'s write on both topologies and `report`'s wire flag. `report`'s two read cases would stay green, because the read consults no flag. - **Result.** **4 failed | 22 passed (26)**, and the four were exactly the predicted ones: - identity: received `["dashboard","email_template","translation","view"]`; - write, on both topologies: received `{ refused: { code: 'NOT_OVERRIDABLE', status: 403 } }`; - wire: received `allowOrgOverride: false`, `overrideSource: 'registry'`. - **Restore.** The tool restored the file with `git checkout HEAD --`: the blob equals HEAD's (`47f68e76cb5c`) and `git diff HEAD` is empty. Spec was rebuilt, and `ablation-dist-preflight --absent` passed: the marker is absent from all 232 built files and the whole tree is clean. The re-run passed **26 of 26**. ## Verification (head `db89d66f0`) - **New file.** `pnpm --filter @objectstack/objectql exec vitest run --project local src/environment-overlay-five-types.test.ts`: 26 passed. - **Package suite.** `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2`: 388 files and 7633 tests passed. - **Typecheck.** `pnpm --filter @objectstack/objectql run typecheck` (tsc plus the test-layer program) is OK. The new file has no ledger entry in `test-typecheck-debt.json`, so it compiles with zero errors. - **Gates.** `dispatch-gates --commands` was re-derived from the branch's change set and gave the same 56 commands as the dispatch. 55 exit 0. `check:dual-build-cjs-loads` exits 3, PREREQUISITE NOT MET: it needs every workspace package built. It is NOT MEASURED here, and CI builds the workspace. The `--ran` verdict reads: `✓ dispatch-gates --ran: 56 derived famil(ies) accounted for — 55 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3).` - **Lint, narrowed to the one file.** `eslint --no-inline-config --format json` reports 1 file with 0 errors and 0 warnings. The file is in eslint's own population: `--print-config` returns a config with 5 rules for it. Type-aware linting is not enabled (no `parserOptions.project`, no typed rules), so adding this file cannot change another file's result. - **Runtime or integration tiers.** Not touched. ## Changeset There is no changeset, because this diff publishes nothing: - objectql's `tsconfig.json` excludes `**/*.test.ts` from the build. - After `pnpm --filter @objectstack/objectql run build`, the file's fixture strings appear 0 times in every path of `files[]` (`dist`, `README.md`, `CHANGELOG.md`). As a positive control, `registerMetadataCollections` appears in 6 files under `dist`. The repo's opt-out for a diff that publishes nothing is the `skip-changeset` label, and it is applied. ## Acceptance notes - **No in-repo producer puts a packaged `translation` into the `SchemaRegistry`.** This is a reading of four seams at `1cb0edb82`, not a boot measurement: - `METADATA_ARRAY_KEYS` (`objectql/src/engine.ts`) has no `translations` collection. - The artifact door's `ARTIFACT_FIELD_TO_TYPE` (`metadata/src/plugin.ts`) has none either. - The filesystem scanner (`MetadataPlugin._loadFromFileSystem` with its `packageId` option) stamps `_packageId`, but it registers into `MetadataManager`'s own map. - `ObjectQLPlugin.loadMetadataFromService` copies only `object`, `view`, `app`, `flow` and `hook` into the `SchemaRegistry`. So in today's composition, `isArtifactBacked` is false for every `translation`. An environment-wide translation write is then admitted by `allowRuntimeCreate`, not by the flag. The `translation` cases here pin the protocol's verdict for a packaged translation if one is ever registered. Today the flag's live effect on `translation` is the per-organization path and the Studio wire gate. This is noted for #22340's S2 contract review and not filed. - **The single-item read keys on neither the flag nor `supportsOverlay`.** `getMetaItem` serves a stored active row unless `declinesStoredRow` holds, which covers shipped flows and code-defined datasources only. `supportsOverlay` has one protocol reader, `mergesOverlayAtRead`, and only the delete door (`refusePackagedBaseRemoval`) calls it. So stage 0's reading, "the read keys on `supportsOverlay`", does not hold for this read. What holds, and what the ablation shows, is that the read consults no flag. This is noted for #22340 S2 and not filed. - **Cross-lane.** The file sits in a `domain:engine` package. The lane was told in the cross-lane declaration on #6367 (comment 6069783273). --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6a53564 commit 7fbfef4

1 file changed

Lines changed: 359 additions & 0 deletions

File tree

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The five overlay-enabled metadata types keep their ENVIRONMENT overlay: a
5+
* packaged item of each can be overlaid environment-wide, the overlay is stored
6+
* with no organization, it is what the item's read then serves, and
7+
* `GET /meta/types` tells Studio the type is writable.
8+
*
9+
* ## Why this file exists (#22340, stage S1)
10+
*
11+
* The registry flag that opens these writes, `allowOrgOverride`, is about to
12+
* change twice: #15206 rewrites the write doors and deletes the per-organization
13+
* overlay path that also reads it, and #22340 then renames the flag to say what
14+
* it will mean, "may an environment overlay this packaged item". Neither change
15+
* may lose an environment overlay, and before this file that was checkable for
16+
* part of the population only. So every case here is the ENVIRONMENT path and
17+
* nothing else: no `organizationId` reaches any call, the protocol has no
18+
* organization of its own, and the item is one a code package ships. The
19+
* per-organization path can be deleted under these cases without moving one.
20+
*
21+
* ## What is pinned, per type and on both topologies
22+
*
23+
* 1. the write — `saveMetaItem` over the packaged item is accepted, stores
24+
* exactly one row, the row names no organization, and the item's read
25+
* serves the body that was written;
26+
* 2. the read of a stored overlay — a stored environment-wide row is served
27+
* by `getMetaItem`. The row is put in the store directly, so this leg asks
28+
* the read alone, with no write door in front of it: a row stored before a
29+
* flag change keeps being served whatever the flag then says;
30+
* 3. the wire — `getMetaTypes` advertises the flag `true` for the type, from
31+
* the registry and not from the `OS_METADATA_WRITABLE` hatch.
32+
*
33+
* And the identity: the registry types whose flag is `true` are exactly these
34+
* five. The population below is written out, NOT read from the registry, so
35+
* that a type leaving the flag fails its own cases by name instead of silently
36+
* dropping out of them.
37+
*
38+
* ## The harness
39+
*
40+
* The real `ObjectQL` engine and its `SchemaRegistry`, the real
41+
* `ObjectStackProtocolImplementation` over it, and an in-memory driver (the
42+
* shape of `engine-security-catalog-package-door.test.ts`, which this file
43+
* follows). Two topologies, because the write is refused at different layers
44+
* on each: an environment kernel (`environmentId` set) and a host-config
45+
* kernel (none). Four of the packaged items arrive through the manifest's own
46+
* stack collections (`views`, `dashboards`, `reports`, `emailTemplates`), the
47+
* seam that stamps package provenance. `translation` has no such collection:
48+
* a stack's `translations:` are i18n bundles, and neither the engine's
49+
* collection list nor the metadata artifact door carries the type. Its
50+
* packaged item is registered through the registry's own package seam,
51+
* `registerItem(type, item, 'name', packageId)`, which is the one
52+
* `isArtifactBacked` reads.
53+
*
54+
* ## Pins that already exist elsewhere, and are not restated here
55+
*
56+
* - view: the dispatcher PUT (`runtime/src/meta-field-overlay-lock.test.ts`),
57+
* the package-door verdict
58+
* (`metadata-protocol/src/protocol.packaged-base-refusal.test.ts`), the
59+
* repository write
60+
* (`metadata-protocol/src/sys-metadata-repository.package-writability.test.ts`),
61+
* and the wire flag on a mock engine (`protocol-meta-types-rich.test.ts`).
62+
* - dashboard: the dispatcher PUT (`runtime/src/meta-field-overlay-lock.test.ts`).
63+
* - email_template: the save over a packaged template on both topologies,
64+
* as the control of `engine-security-catalog-package-door.test.ts`.
65+
* - report, translation: none.
66+
*
67+
* Each of those pins one layer of one type. What this file adds is the
68+
* environment round trip for all five on one harness: the row's organization,
69+
* the served body and the wire flag, none of which those pins read.
70+
*
71+
* No dispatcher case is added in `packages/runtime`: for an environment-wide
72+
* write (a `manage_metadata` session with no active organization) the
73+
* dispatcher reads the flag nowhere — its two readers, the
74+
* `manage_org_presentation` capability verdict and `organizationIdForMetaWrite`,
75+
* answer only for a session with an active organization, which is the
76+
* per-organization path — and it hands this same request to `saveMetaItem`.
77+
*/
78+
79+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
80+
import type { ServiceObject } from '@objectstack/spec/data';
81+
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
82+
import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from '@objectstack/metadata-protocol';
83+
import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core';
84+
import { ObjectQL } from './engine.js';
85+
86+
const PKG = 'com.acme.overlay_catalog';
87+
const PACKAGED_LABEL = 'Packaged';
88+
const OVERLAY_LABEL = 'Environment overlay';
89+
90+
/** The population, written out: the five types whose registry flag opens an environment overlay. */
91+
const OVERLAY_ENABLED = ['view', 'dashboard', 'report', 'translation', 'email_template'] as const;
92+
type OverlayType = (typeof OVERLAY_ENABLED)[number];
93+
94+
/**
95+
* Per type: the packaged item's name, and its body with a given label. The
96+
* view is the expanded item of the manifest's container below
97+
* (`<object>.<listViews key>`), which is the item Studio edits.
98+
*/
99+
const ITEMS: Record<OverlayType, { name: string; body: (label: string) => Record<string, unknown> }> = {
100+
view: {
101+
name: 'probe_obj.probe_list',
102+
body: (label) => ({
103+
name: 'probe_obj.probe_list',
104+
object: 'probe_obj',
105+
viewKind: 'list',
106+
label,
107+
config: { type: 'grid', data: { provider: 'object', object: 'probe_obj' }, columns: [{ field: 'name' }] },
108+
}),
109+
},
110+
dashboard: {
111+
name: 'probe_dashboard',
112+
body: (label) => ({ name: 'probe_dashboard', label, widgets: [] }),
113+
},
114+
report: {
115+
name: 'probe_report',
116+
// A report binds a dataset that resolves; the manifest ships it.
117+
body: (label) => ({
118+
name: 'probe_report', label, type: 'summary', dataset: 'probe_metrics', rows: ['month'], values: ['amount_sum'],
119+
}),
120+
},
121+
translation: {
122+
name: 'probe_translation',
123+
body: (label) => ({ name: 'probe_translation', label, locale: 'zh-CN', messages: { probeSave: '保存' } }),
124+
},
125+
email_template: {
126+
name: 'probe_email',
127+
body: (label) => ({ name: 'probe_email', label, subject: 'Probe', bodyHtml: '<p>Probe</p>' }),
128+
},
129+
};
130+
131+
/** The package: every type but `translation` through its own stack collection. */
132+
function overlayCatalogManifest(): Record<string, unknown> {
133+
return {
134+
id: PKG,
135+
name: 'overlay_catalog',
136+
views: [{
137+
object: 'probe_obj',
138+
listViews: {
139+
probe_list: {
140+
label: PACKAGED_LABEL,
141+
type: 'grid',
142+
data: { provider: 'object', object: 'probe_obj' },
143+
columns: [{ field: 'name' }],
144+
},
145+
},
146+
}],
147+
dashboards: [ITEMS.dashboard.body(PACKAGED_LABEL)],
148+
datasets: [{
149+
name: 'probe_metrics',
150+
label: 'Probe Metrics',
151+
object: 'probe_obj',
152+
dimensions: [{ name: 'month', label: 'Month', field: 'created_at', type: 'date' }],
153+
measures: [{ name: 'amount_sum', label: 'Amount', aggregate: 'sum', field: 'amount' }],
154+
}],
155+
reports: [ITEMS.report.body(PACKAGED_LABEL)],
156+
emailTemplates: [ITEMS.email_template.body(PACKAGED_LABEL)],
157+
};
158+
}
159+
160+
const sysMetadataObject: ServiceObject = {
161+
name: 'sys_metadata',
162+
label: 'System Metadata',
163+
fields: {
164+
id: { name: 'id', label: 'ID', type: 'text' as const },
165+
type: { name: 'type', label: 'Type', type: 'text' as const, required: true },
166+
name: { name: 'name', label: 'Name', type: 'text' as const, required: true },
167+
organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const },
168+
package_id: { name: 'package_id', label: 'Package', type: 'text' as const },
169+
metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const },
170+
checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 },
171+
state: { name: 'state', label: 'State', type: 'text' as const },
172+
version: { name: 'version', label: 'Version', type: 'number' as const },
173+
created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const },
174+
updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const },
175+
},
176+
};
177+
178+
/** In-memory driver, copied from `engine-security-catalog-package-door.test.ts`; equality-only WHERE. */
179+
function makeMemoryDriver() {
180+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
181+
const storeFor = (obj: string) => {
182+
let s = stores.get(obj);
183+
if (!s) { s = new Map(); stores.set(obj, s); }
184+
return s;
185+
};
186+
let nextId = 0;
187+
// `$and` / `$or` are conjoined WITH their sibling keys, the way a real
188+
// driver ANDs them.
189+
const matchesWhere = (row: Record<string, unknown>, where: any): boolean => {
190+
if (!where || typeof where !== 'object') return true;
191+
for (const [k, v] of Object.entries(where)) {
192+
if (k === '$and' && Array.isArray(v)) {
193+
if (!v.every((w: any) => matchesWhere(row, w))) return false;
194+
continue;
195+
}
196+
if (k === '$or' && Array.isArray(v)) {
197+
if (!v.some((w: any) => matchesWhere(row, w))) return false;
198+
continue;
199+
}
200+
if (k.startsWith('$')) continue;
201+
const rowVal = row[k];
202+
const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
203+
const a = rowVal === undefined ? null : rowVal;
204+
const b = expected === undefined ? null : expected;
205+
if (a !== b) return false;
206+
}
207+
return true;
208+
};
209+
const driver: any = {
210+
name: 'memory', version: '0.0.0', supports: {} as any,
211+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
212+
async execute() { return null; },
213+
async find(object: string, ast: any) {
214+
const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where));
215+
// The caller's bound, after the filter, by presence (`check:objectql-double-limit`).
216+
return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows;
217+
},
218+
async findOne(object: string, ast: any) {
219+
for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r;
220+
return null;
221+
},
222+
async create(object: string, data: Record<string, unknown>) {
223+
nextId += 1;
224+
const id = (data.id as string) ?? `r_${nextId}`;
225+
const row = { ...data, id };
226+
storeFor(object).set(id, row);
227+
return row;
228+
},
229+
async update(object: string, id: string, data: Record<string, unknown>) {
230+
const s = storeFor(object);
231+
const cur = s.get(id);
232+
if (!cur) throw new Error(`not found: ${object}/${id}`);
233+
const updated = { ...cur, ...data, id };
234+
s.set(id, updated);
235+
return updated;
236+
},
237+
async upsert(object: string, data: Record<string, unknown>) {
238+
const id = data.id as string | undefined;
239+
if (id && storeFor(object).has(id)) return this.update(object, id, data);
240+
return this.create(object, data);
241+
},
242+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
243+
async count(object: string, ast: any) { return (await this.find(object, ast)).length; },
244+
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
245+
return Promise.all(rows.map((r) => this.create(object, r)));
246+
},
247+
async bulkUpdate() { return []; }, async bulkDelete() {},
248+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
249+
async commit() {}, async rollback() {},
250+
};
251+
return { driver, stores };
252+
}
253+
254+
type Topology = 'environment-scoped' | 'host-config';
255+
const TOPOLOGIES: readonly Topology[] = ['environment-scoped', 'host-config'];
256+
257+
async function boot(topology: Topology) {
258+
const engine = new ObjectQL();
259+
const { driver, stores } = makeMemoryDriver();
260+
engine.registerDriver(driver, true);
261+
await engine.init();
262+
engine.registry.registerObject(sysMetadataObject, 'test-package');
263+
for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) {
264+
engine.registry.registerObject(o as any, 'test-package');
265+
}
266+
engine.registerApp(overlayCatalogManifest());
267+
engine.registry.registerItem('translation', ITEMS.translation.body(PACKAGED_LABEL), 'name', PKG);
268+
// No organization anywhere: the protocol is built without one, and no
269+
// call below passes one.
270+
const protocol = new ObjectStackProtocolImplementation(
271+
engine,
272+
() => new Map(),
273+
topology === 'environment-scoped' ? 'env_prod' : undefined,
274+
);
275+
const rowsOf = (type: string, name: string) => Array.from(stores.get('sys_metadata')?.values() ?? [])
276+
.filter((r) => r.type === type && r.name === name);
277+
/**
278+
* A stored ACTIVE environment-wide overlay row, written into the store the
279+
* way the repository's write lays one down (no organization, no package
280+
* binding, the body as JSON), with no write door in front of it.
281+
*/
282+
const storeEnvironmentRow = (type: string, name: string, body: Record<string, unknown>) =>
283+
driver.create('sys_metadata', {
284+
type, name, organization_id: null, package_id: null,
285+
metadata: JSON.stringify(body), state: 'active', version: 1,
286+
});
287+
return { engine, protocol, rowsOf, storeEnvironmentRow };
288+
}
289+
290+
// The operator hatch also opens a type's overlay, and a value left in the
291+
// environment would answer for a registry flag this file pins. Cleared for
292+
// every case and restored after it.
293+
const savedWritable = process.env.OS_METADATA_WRITABLE;
294+
beforeEach(() => {
295+
delete process.env.OS_METADATA_WRITABLE;
296+
ObjectStackProtocolImplementation.resetEnvWritableCache();
297+
resetEnvWritableMetadataTypes();
298+
});
299+
afterEach(() => {
300+
if (savedWritable === undefined) delete process.env.OS_METADATA_WRITABLE;
301+
else process.env.OS_METADATA_WRITABLE = savedWritable;
302+
ObjectStackProtocolImplementation.resetEnvWritableCache();
303+
resetEnvWritableMetadataTypes();
304+
});
305+
306+
describe('the five overlay-enabled types keep their environment overlay of a packaged item', () => {
307+
it('the registry opens an overlay on exactly these five types', () => {
308+
const flagged = DEFAULT_METADATA_TYPE_REGISTRY
309+
.filter((entry) => entry.allowOrgOverride === true)
310+
.map((entry) => entry.type)
311+
.sort();
312+
expect(flagged).toEqual([...OVERLAY_ENABLED].sort());
313+
});
314+
315+
for (const topology of TOPOLOGIES) {
316+
it.each(OVERLAY_ENABLED)(`${topology}: a save over the packaged %s is accepted, stored with no organization, and served back`, async (type) => {
317+
const { engine, protocol, rowsOf } = await boot(topology);
318+
const { name, body } = ITEMS[type];
319+
// Only an overlay if a package ships the item it lands on.
320+
expect(engine.registry.getArtifactItem<any>(type, name)?._packageId).toBe(PKG);
321+
322+
const result: any = await protocol
323+
.saveMetaItem({ type, name, item: body(OVERLAY_LABEL) })
324+
.catch((e: any) => ({ refused: { code: e?.code, status: e?.status } }));
325+
326+
expect(result).toMatchObject({ success: true });
327+
const rows = rowsOf(type, name);
328+
expect(rows).toHaveLength(1);
329+
// NULL is how the repository writes "no organization" today; a
330+
// tenant-less row with no such column reads the same here.
331+
expect(rows[0].organization_id ?? null).toBeNull();
332+
const read: any = await protocol.getMetaItem({ type, name });
333+
expect(read?.item?.label).toBe(OVERLAY_LABEL);
334+
});
335+
336+
it.each(OVERLAY_ENABLED)(`${topology}: a stored environment-wide overlay of the packaged %s is what its read serves`, async (type) => {
337+
const { protocol, storeEnvironmentRow } = await boot(topology);
338+
const { name, body } = ITEMS[type];
339+
// Control: before the row exists the read serves the package's item,
340+
// so the label below can only come from the stored overlay.
341+
const before: any = await protocol.getMetaItem({ type, name });
342+
expect(before?.item?.label).toBe(PACKAGED_LABEL);
343+
344+
await storeEnvironmentRow(type, name, body(OVERLAY_LABEL));
345+
346+
const read: any = await protocol.getMetaItem({ type, name });
347+
expect(read?.item?.label).toBe(OVERLAY_LABEL);
348+
});
349+
}
350+
351+
it.each(OVERLAY_ENABLED)('GET /meta/types advertises %s as overlay-writable, from the registry', async (type) => {
352+
const { protocol } = await boot('environment-scoped');
353+
354+
const result: any = await protocol.getMetaTypes();
355+
const entry = result?.entries?.find((e: any) => e.type === type);
356+
357+
expect(entry).toMatchObject({ allowOrgOverride: true, overrideSource: 'registry' });
358+
});
359+
});

0 commit comments

Comments
 (0)