Skip to content

Commit 4267484

Browse files
fix(runtime): AppPlugin registers the forward-converted copy of every collection (#23024)
Fixes #22994 Clause-②: no Door 2 only, as triage graded it. Door 1 (the conversion window in `packages/metadata-core`) is #23001, untouched here. ## What changes `AppPlugin.init()` handed the `manifest` service the RAW bundle. That service decomposes it into the ObjectQL registry over `METADATA_ARRAY_KEYS`. The registry is the code layer of the flattened list that `GET /api/v1/meta/TYPE` serves and that `getMetaItemsForExecution` hands to the email template loader. On the artifact boot, the door (`MetadataPlugin`) registered the forward-converted copy into the metadata service. So the item read served the converted copy while the list served the raw one. Now the boot runs ONE ADR-0087 forward conversion (`forwardConverted`, memoized, `packages/runtime/src/app-plugin.ts`): - It runs after the D1 handshake (`assertProtocolCompat` is unmoved and still first). - Its input is `{ ...manifest, ...collections }`, the definition the security block has converted since the #12844 work. For a bundle without `packages[]`, that is byte for byte the payload `init()` always registered. - Both registrars take its result: `manifest.register(...)` in `init()`, and the ADR-0057 security block in `start()`, which no longer converts on its own. No reader converts again: `readFlattenedMetaItems` and the email loader are untouched. Why this input and not `this.bundle`: - **Converting before the spread would turn the #18034 pin red.** The `manifest-permissions-string-list-removed` entry would strip `manifest.permissions` before the registrar sees it. The "2 of 2" warning would then describe a value that never arrived. - **The #18034 source decision still reads the RAW inputs.** `fromManifest` reads `this.collections.manifest` and `this.collections[field]`, unchanged, because the conversion can rewrite a collection key (`roles` to `positions`). - **The artifact-boot branch of the security block is unchanged.** Under `securityMetadataRegistrar: 'artifact-door'` it registers nothing and logs the same debug line. - **A bundle with `packages[]` is handed over as authored, as before.** Resolving `packages[]` in `init()` is reserved to the `manifest` service by the posture the plugin-dev #15292 falsifier pins (4/4 green against the rebuilt `dist`). Also, no conversion reaches package bodies, at the artifact door either (measured, see Acceptance notes). ## Measured, before and after The card's door: `os dev -a dist/objectstack.json --fresh` on an undeclared-engines artifact (`objectstack-ai/objectstack` at BASE `6a7eca6868`, then at this branch), read over HTTP. **The second type is `dashboard`, converted by the major-18 retired entry `dashboard-refresh-interval-to-refresh-interval-seconds`** (retiredAfter 17.3.0). | read | before | after | |---|---|---| | `GET /api/v1/meta/dashboard`, ops_board | `refreshInterval: 300`, `_diagnostics.valid: false` (the tombstone's rename prescription) | `refreshIntervalSeconds: 300`, `valid: true` | | `GET /api/v1/meta/dashboard/ops_board` | `refreshIntervalSeconds: 300`, `valid: true` | `refreshIntervalSeconds: 300`, `valid: true` (the same document as the list) | | `GET /api/v1/meta/email_template`, probe.welcome | carries `category` / `isSystem`, `valid: true` | unchanged | | `GET /api/v1/meta/email_template/probe.welcome` | the door's parsed copy, `valid: true` | unchanged | On this tree `category` / `isSystem` are live keys: their retirement is #22990, which is parked on this card. So the email half was measured on #22990's head `e4e96505e0` with and without this patch, in a scratch tree, through the real `createRegistryEmailTemplateLoader` over the booted kernel's protocol service: - **Without the patch:** - the list carries `category: custom`, `isSystem: false` with `_diagnostics.valid: false`; - the item read is clean; - the loader refuses: "email template 'probe.welcome' (en-US) is not a valid email template, so it cannot be sent: `email_template.category` was removed…". - **With the patch:** - the list is valid with both keys gone; - the loader sends `{ name: probe.welcome, locale: en-US, subject: Welcome, body_html, active: true }`. The list and the item still differ on the PARSE axis: the door's copy carries the email schema's `variables: []` / `active: true` defaults. That is route ownership (#12892), not this conversion. The pins assert "every key the list serves equals the item's", never "one document", except for the dashboard, where the two really are one document. ## Tests `packages/runtime/src/app-plugin-converted-collections.test.ts`: - **A real `createStandaloneStack` boot over the card's artifact.** Four checks: - the dashboard list equals the item read, `valid: true`; - the dashboard execution face equals the conversion's own output; - every key of the email list equals the item read's, `valid: true`; - the email execution face equals the conversion's output and passes the loader's own predicate, `EmailTemplateDefinitionSchema.safeParse(stripReadDecorations(item))`. - **A control boot of the same app in current spellings:** the registry holds the authored items plus stamps, nothing rewritten. - **Three unit legs:** - one conversion: the security registrar registers the very object the manifest payload carries (identity, not equality); - a current bundle is handed over with its collection arrays by reference; - a `packages[]` bundle is handed over as authored. The email legs are positive controls on this tree. **Measured on #22990's head:** this file is 8/8 green with the patch and 5 red without it, both email legs included. So they become live pins the day that retirement lands, with no edit. Ablation of the one conversion line in `init()`, run through `scripts/ablation-replace.mjs`: - **Mutation landed:** anchor 1 to 0, blob `39d6bb3b6958` to `891daa76d2b3`. - **The predicted three went red:** - the dashboard list pin ("expected undefined to be 300"); - the dashboard execution-face pin; - the one-conversion unit leg. - **The other 18 stayed green,** including every #14491 pin (the BASE behaviour), at `86d2782f09`, before the patch round rewrote five of those pins. - **Restore proven:** blob equals HEAD, and `git diff HEAD` is empty. - **No rebuild was needed:** `app-plugin.ts` reaches the runtime suite by relative source import. The before and after probe differed without one. All on `86d2782f09`: - **`pnpm --filter @objectstack/runtime test`:** at `0f6556d1c5`, 357 of 357 files and 5141 tests pass, 19 skipped. - **`pnpm --filter @objectstack/runtime typecheck`:** exit 0; the test layer held its debt ledger. `tsc --listFiles` compiles the new file with 0 errors in it. - **Untouched pins, green:** - #12844 / #12892: `app-plugin-artifact-forward-conversion.test.ts`, `standalone-stack-security-registrar.test.ts`; - #18034: `app-plugin.manifest-security-collision.test.ts`. - **Gates:** all 63 derived by `dispatch-gates.mjs` exit 0. `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit 3) and was measured green after building the 10 missing `dist`s. `--ran` reconciliation: "63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero …)". - **Lint:** narrowed to the two touched sources, with the three proofs: - `--print-config` puts both in eslint's population; - `--format json` reports 2 files, 0 errors, 0 warnings; - no config object enables type-aware linting (every `parserOptions` is `{ ecmaVersion, sourceType }`), and the four custom rules are AST-local, so no untouched file's verdict can move. ## #14491's measurement: five conversion-axis pins rewritten (seat ruling A, `6122651929` → claim `6121395708` widened) `packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts` now pins the converged conversion axis. None of the five pins is skipped, relaxed or deleted; each keeps its subject and asserts the measured converged values (commit `0f6556d1c5`): 1. permission `probe_agent`: the registry grant map and RLS entry carry no `allowRestore` / `allowPurge` / `priority`. The three door defaults are still absent (the parse axis). 2. The stored `sys_permission_set` RLS equals the door's. 3. `share_legacy_deals` carries `sharedWith.type: position` and `accessLevel: edit` in the registry, equal to the door's. 4. `share_legacy_level` carries `accessLevel: edit` in the registry. 5. **An authorization outcome.** `share_legacy_deals` is now SEEDED, exactly once, as a position share: `recipient_type: position`, `recipient_id: sales_mgr`, `access_level: edit`. That gives three rows where there were two. Before this PR it was dropped at seeding (`role` is refused by `mapRecipientType`). The changeset says so. The file's header now records that #22994 closed the conversion axis, while the parse axis (string `condition`, and the missing `scope` / `isDefault` / `active` defaults) stays #14491's. Those pins are unchanged. Ablating the `init()` conversion line turns exactly the five rewritten pins red against the raw registry copy (`5 failed | 8 passed`). The restore is proven against HEAD. Open PR #22962 (#15204 stage 8) edits the same file and deletes pin 2; whichever lands second merges `main`. The seat declared this on #15204. ## Acceptance notes (not this PR) - **The conversion layer does not reach option-B package bodies.** Measured: the artifact door (`_parseAndRegisterArtifact`) refuses an artifact whose only dashboard sits in `packages[0].manifest.dashboards` with `refreshInterval`, with "invalid_type at packages.0.manifest.dashboards.0.refreshInterval". The conversion walks a definition's own collections. Same family as #23001, for the seat to route. - **Readers of the raw `this.bundle` / `this.collections` after the seam, answered for assumption 5.** Each reader takes one of three answers: it needs the converted copy, it keeps the raw one, or it does not care. - **Datasources** (`codeDefinedDatasourceOwners`, the in-memory `datasource` registration, `connectDeclared`): raw. The drivers read `config.timeoutMs` / `persistence.autoSaveIntervalMs`, so a datasource still carrying the retired `config.timeout` (turso) or `autoSaveInterval` (memory) loses the value at connect. - **Jobs** (`scheduleAppArtifactJobs(ctx, this.bundle)`): raw. The binder reads `job.timeoutMs`, so a retired `jobs[].timeout` schedules with no timeout. - These two are retired keys a consumer reads. Reported, not converted here, as the dispatch ruled. - **Does not care:** - `datasourceMapping`, objects passed to `connectDeclared` (read for `name` / `datasource` only), seed `data`: no conversion targets what they read; - hooks: the binder reads no `timeout` / `timeoutMs`; - `emitCatalogEvent`, `grantedPermissions`: no conversion touches them. - **Translations** (`loadTranslations`): NOT MEASURED. - **Residual of a multi-package config stack** (`registerUnclaimedTopLevel`): raw, same class. --- _Generated by [Claude Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0ede0f8 commit 4267484

4 files changed

Lines changed: 514 additions & 54 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
`GET /api/v1/meta/<type>` now lists an artifact's items in their converted shape, the shape `GET /api/v1/meta/<type>/<name>` serves
6+
7+
Clause-②: no
8+
9+
A compiled artifact built before a spec retirement still carries the retired key, and the ADR-0087 forward conversion rewrites it when the artifact boots. The artifact door converted it, so the item read served the converted copy. `AppPlugin` handed the RAW bundle to the engine registry, which is what the list read, `getMetaItemsForExecution` and the email template loader read. So the list served the retired key with `_diagnostics.valid: false`, and a reader of the list (the email loader is one) was handed an item the current schema refuses. Measured with `os dev -a dist/objectstack.json --fresh` on an artifact that declares no `engines.protocol` and carries a dashboard with `refreshInterval: 300`: the item read served `refreshIntervalSeconds: 300`, valid, and the list served `refreshInterval: 300`, invalid.
10+
11+
`AppPlugin` now runs one forward conversion per boot, after the protocol handshake, over the same definition its security registrar already converted. The registry and that registrar both register from its result. An artifact authored in the current spellings registers exactly what it did before, because a conversion that changes nothing returns its input.
12+
13+
The security seeders read the same registry copy first, so a boot with an engine now seeds what the conversion produced. Measured on `createStandaloneStack` with a `>=17.1.0` artifact: a sharing rule declared with the legacy recipient type `role` is now seeded as `position`, where it used to be skipped as an unmappable recipient. A seeded permission set no longer stores the retired `allowRestore` / `allowPurge` or the row-level-security `priority`.
14+
15+
One case is unchanged: a bundle that carries `packages[]` still hands its package bodies over as authored. No conversion reaches those bodies, at the artifact door either.
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22994: `AppPlugin` registers the ADR-0087 forward-converted copy of EVERY
5+
* collection, not only the security ones (#12844), and it converts once.
6+
*
7+
* The seam is `AppPlugin.init()`'s `manifest.register(...)`. It decomposes the
8+
* bundle into the ObjectQL registry over `METADATA_ARRAY_KEYS`, and that
9+
* registry is the code layer of the flattened list, the list
10+
* `GET /meta/<type>` serves and that `getMetaItemsForExecution` hands to the
11+
* email loader. It used to be handed the RAW bundle. On the artifact boot
12+
* (`createStandaloneStack`, the composition `os dev -a` runs) the door,
13+
* `MetadataPlugin`, registers the converted copy into the metadata service, so
14+
* `GET /meta/<type>/NAME` served that copy while the list served the raw one.
15+
* Measured before this change, on `origin/main`, for the artifact below:
16+
*
17+
* list dashboard/ops_board refreshInterval: 300 _diagnostics.valid: false
18+
* item dashboard/ops_board refreshIntervalSeconds: 300 _diagnostics.valid: true
19+
* execution face refreshInterval: 300
20+
*
21+
* The second type is `dashboard`, converted by the major-18 retired entry
22+
* `dashboard-refresh-interval-to-refresh-interval-seconds` (retiredAfter
23+
* 17.3.0). It sits beside `emailTemplates`, the card's own type. That type has
24+
* no conversion on this tree: its `category` / `isSystem` retirement is a
25+
* separate change. So its legs below assert the contract (the registry
26+
* holds the conversion's OUTPUT, and that output passes the email loader's own
27+
* parse). That contract has no retired key to bite on here, and it does the
28+
* day a retirement of that type lands.
29+
*
30+
* Out of scope, on purpose: the PARSE axis. The door strict-parses (schema
31+
* defaults, ADR-0122 transforms), and the registry copy does not. So the item
32+
* read can carry keys the listed copy lacks (an email template's
33+
* `variables: []` / `active: true`). The list pins below therefore assert
34+
* "every key the list serves is the key the item read serves", never "the two
35+
* are one document". Unifying them is a route-ownership question (#12892),
36+
* not this conversion.
37+
*/
38+
39+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
40+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
41+
import { tmpdir } from 'node:os';
42+
import { join } from 'node:path';
43+
import { applyArtifactForwardConversions } from '@objectstack/metadata-core';
44+
import { stripReadDecorations } from '@objectstack/spec/kernel';
45+
import { EmailTemplateDefinitionSchema } from '@objectstack/spec/system';
46+
import { Runtime } from './runtime.js';
47+
import { AppPlugin } from './app-plugin.js';
48+
import { createStandaloneStack } from './standalone-stack.js';
49+
50+
// [#10126] Pay the first transform of these dist-resolved workspace deps at
51+
// MODULE LOAD: the factory and the kernel boot below import them lazily, inside
52+
// a clocked hook (`scripts/check-test-source-alias.mjs`, the clocked-window rule).
53+
import '@objectstack/metadata';
54+
import '@objectstack/objectql';
55+
import '@objectstack/service-datasource';
56+
57+
const APP_ID = 'com.test.issue-22994';
58+
const STAMPS = { _packageId: APP_ID, _packageVersion: '1.0.0', _provenance: 'package' } as const;
59+
60+
/** A dashboard carrying the retired `refreshInterval` spelling. */
61+
const RETIRED_DASHBOARD = { name: 'ops_board', label: 'Ops Board', widgets: [], refreshInterval: 300 };
62+
/** What a 17.x parse emitted for an email template: both defaulted keys present. */
63+
const EMAIL_TEMPLATE = {
64+
name: 'probe.welcome',
65+
label: 'Welcome',
66+
locale: 'en-US',
67+
subject: 'Welcome',
68+
bodyHtml: '<p>Hi</p>',
69+
category: 'custom',
70+
isSystem: false,
71+
};
72+
73+
/**
74+
* The card's reproduction shape: an UNDECLARED-engines artifact, so the
75+
* window opens (`converted-undeclared`) at the door and here alike.
76+
*/
77+
const RETIRED_ARTIFACT = {
78+
manifest: { id: APP_ID, name: 'Converted Collections Probe', type: 'app', version: '1.0.0' },
79+
dashboards: [RETIRED_DASHBOARD],
80+
emailTemplates: [EMAIL_TEMPLATE],
81+
};
82+
83+
/** The CONTROL: the same app authored in the current spellings. Nothing to convert. */
84+
const CURRENT_DASHBOARD = { name: 'ops_board', label: 'Ops Board', widgets: [], refreshIntervalSeconds: 300 };
85+
const CURRENT_EMAIL_TEMPLATE = { name: 'probe.welcome', label: 'Welcome', locale: 'en-US', subject: 'Welcome', bodyHtml: '<p>Hi</p>' };
86+
const CURRENT_ARTIFACT = {
87+
manifest: { id: APP_ID, name: 'Converted Collections Probe', type: 'app', version: '1.0.0' },
88+
dashboards: [CURRENT_DASHBOARD],
89+
emailTemplates: [CURRENT_EMAIL_TEMPLATE],
90+
};
91+
92+
const BOOT_TIMEOUT = 120_000;
93+
94+
/** Fresh bytes per reader: readers stamp and normalize what they are handed. */
95+
const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value));
96+
97+
/** `item` minus the read-side `_diagnostics` badge, which only the served reads add. */
98+
function undiagnosed(item: Record<string, unknown>): Record<string, unknown> {
99+
const { _diagnostics: _ignored, ...rest } = item;
100+
return rest;
101+
}
102+
103+
/** The authored item as the conversion rewrites it: the policy function's own answer. */
104+
function convertedAuthored(collection: 'dashboards' | 'emailTemplates', name: string): Record<string, unknown> {
105+
const result = applyArtifactForwardConversions(copy(RETIRED_ARTIFACT) as Record<string, unknown>);
106+
const items = (result.definition as Record<string, Array<Record<string, unknown>>>)[collection];
107+
const item = items.find((i) => i.name === name);
108+
if (!item) throw new Error(`the conversion lost ${collection}/${name}`);
109+
return item;
110+
}
111+
112+
interface BootedArtifact {
113+
kernel: any;
114+
dir: string;
115+
}
116+
117+
/** Boot the card's composition over `artifact`, the way `os dev -a` composes it. */
118+
async function bootArtifact(tag: string, artifact: unknown): Promise<BootedArtifact> {
119+
const dir = mkdtempSync(join(tmpdir(), `os-22994-${tag}-`));
120+
const artifactPath = join(dir, 'objectstack.json');
121+
writeFileSync(artifactPath, JSON.stringify(artifact), 'utf-8');
122+
const stack = await createStandaloneStack({
123+
artifactPath,
124+
projectRoot: dir,
125+
databaseUrl: ':memory:',
126+
skipSeedData: true,
127+
runPlatformMigrations: false,
128+
});
129+
const runtime = new Runtime({ cluster: false });
130+
const kernel = runtime.getKernel();
131+
for (const p of stack.plugins) await kernel.use(p as any);
132+
await kernel.bootstrap();
133+
return { kernel, dir };
134+
}
135+
136+
async function teardown(booted: BootedArtifact | undefined): Promise<void> {
137+
if (!booted) return;
138+
try { await booted.kernel.shutdown(); } catch { /* noop */ }
139+
rmSync(booted.dir, { recursive: true, force: true });
140+
}
141+
142+
/** The one item named `name` in the list `getMetaItems({ type })` serves, the `GET /meta/<type>` read. */
143+
async function listed(kernel: any, type: string, name: string): Promise<Record<string, unknown>> {
144+
const answer = await kernel.getService('protocol').getMetaItems({ type });
145+
const hits = (answer?.items as Array<Record<string, unknown>> | undefined)?.filter((i) => i?.name === name) ?? [];
146+
expect(hits, `GET /meta/${type} must list exactly one ${name}`).toHaveLength(1);
147+
return hits[0]!;
148+
}
149+
150+
/** The item `getMetaItem({ type, name })` serves, the `GET /meta/<type>/NAME` read. */
151+
async function served(kernel: any, type: string, name: string): Promise<Record<string, unknown>> {
152+
const answer = await kernel.getService('protocol').getMetaItem({ type, name });
153+
expect(answer?.item, `GET /meta/${type}/${name} must serve an item`).toBeDefined();
154+
return answer.item as Record<string, unknown>;
155+
}
156+
157+
/** The one item named `name` on the execution face, the list the email loader reads. */
158+
async function executed(kernel: any, type: string, name: string): Promise<Record<string, unknown>> {
159+
const answer = await kernel.getService('protocol').getMetaItemsForExecution({ type });
160+
const hits = (answer?.items as Array<Record<string, unknown>> | undefined)?.filter((i) => i?.name === name) ?? [];
161+
expect(hits, `the execution face must carry exactly one ${type}/${name}`).toHaveLength(1);
162+
return hits[0]!;
163+
}
164+
165+
describe('#22994: the artifact boot lists the converted copy the item read serves', () => {
166+
let booted: BootedArtifact | undefined;
167+
168+
beforeAll(async () => {
169+
booted = await bootArtifact('retired', RETIRED_ARTIFACT);
170+
}, BOOT_TIMEOUT);
171+
172+
afterAll(async () => {
173+
await teardown(booted);
174+
});
175+
176+
it('dashboard: GET /meta/dashboard lists the converted copy, valid, the very document GET /meta/dashboard/NAME serves', async () => {
177+
const list = await listed(booted!.kernel, 'dashboard', 'ops_board');
178+
const item = await served(booted!.kernel, 'dashboard', 'ops_board');
179+
180+
expect(list.refreshIntervalSeconds).toBe(300);
181+
expect(list).not.toHaveProperty('refreshInterval');
182+
expect(list._diagnostics).toEqual({ valid: true });
183+
// The dashboard schema adds no default here, so the two reads are one
184+
// document. That is the card's whole defect, stated directly.
185+
expect(undiagnosed(list)).toEqual(undiagnosed(item));
186+
});
187+
188+
it('dashboard: the execution face carries the converted copy, the conversion\'s own output', async () => {
189+
const exec = await executed(booted!.kernel, 'dashboard', 'ops_board');
190+
expect(exec).toEqual({ ...convertedAuthored('dashboards', 'ops_board'), ...STAMPS });
191+
});
192+
193+
it('email_template: every key the list serves is the key the item read serves, and the list is valid', async () => {
194+
const list = await listed(booted!.kernel, 'email_template', 'probe.welcome');
195+
const item = await served(booted!.kernel, 'email_template', 'probe.welcome');
196+
197+
expect(list._diagnostics).toEqual({ valid: true });
198+
// Subset, not equality: the item read is the door's strict-parsed copy
199+
// and carries schema defaults the registry copy never had (the parse
200+
// axis, #12892). A retired key the conversion strips is in neither,
201+
// and one the registry kept would fail here.
202+
for (const [key, value] of Object.entries(undiagnosed(list))) {
203+
expect(item[key], `GET /meta/email_template/probe.welcome serves ${key}`).toEqual(value);
204+
}
205+
});
206+
207+
it('email_template: the email loader is handed the conversion\'s output, and it parses as the loader parses it', async () => {
208+
const exec = await executed(booted!.kernel, 'email_template', 'probe.welcome');
209+
expect(exec).toEqual({ ...convertedAuthored('emailTemplates', 'probe.welcome'), ...STAMPS });
210+
// `createRegistryEmailTemplateLoader`'s own refusal test, verbatim:
211+
// `EmailTemplateDefinitionSchema.safeParse(stripReadDecorations(item))`,
212+
// which refuses with "is not a valid email template, so it cannot be sent".
213+
const parsed = EmailTemplateDefinitionSchema.safeParse(stripReadDecorations(exec));
214+
expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true);
215+
});
216+
});
217+
218+
describe('#22994 control: an artifact authored in the current spellings registers exactly what it authored', () => {
219+
let booted: BootedArtifact | undefined;
220+
221+
beforeAll(async () => {
222+
booted = await bootArtifact('current', CURRENT_ARTIFACT);
223+
}, BOOT_TIMEOUT);
224+
225+
afterAll(async () => {
226+
await teardown(booted);
227+
});
228+
229+
it('the registry holds the authored items, stamped, and nothing rewritten', () => {
230+
const registry = booted!.kernel.getService('objectql').registry;
231+
const pick = (type: string, name: string) => registry.listItems(type).filter((i: any) => i?.name === name);
232+
expect(pick('dashboard', 'ops_board')).toEqual([{ ...CURRENT_DASHBOARD, ...STAMPS }]);
233+
expect(pick('email_template', 'probe.welcome')).toEqual([{ ...CURRENT_EMAIL_TEMPLATE, ...STAMPS }]);
234+
});
235+
});
236+
237+
/** A context whose `manifest` service captures the payload and whose metadata service captures writes. */
238+
function capturingCtx() {
239+
const payloads: any[] = [];
240+
const registrations: Array<{ type: string; name: string; item: any }> = [];
241+
const services: Record<string, unknown> = {
242+
manifest: { register: (payload: unknown) => { payloads.push(payload); } },
243+
metadata: {
244+
registerInMemory: (type: string, name: string, item: unknown) => {
245+
registrations.push({ type, name, item });
246+
},
247+
},
248+
objectql: {},
249+
};
250+
const ctx = {
251+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
252+
registerService: vi.fn(),
253+
// Unknown services answer `undefined`, as in the two-reader harness
254+
// (app-plugin-artifact-forward-conversion.test.ts): every other read
255+
// `start()` makes is an optional one it degrades past.
256+
getService: vi.fn((name: string) => services[name]),
257+
getServices: vi.fn(() => new Map()),
258+
hook: vi.fn(),
259+
trigger: vi.fn(),
260+
} as any;
261+
return { ctx, payloads, registrations };
262+
}
263+
264+
describe('#22994: one conversion per boot, and every registrar takes its result', () => {
265+
it('the config boot hands the manifest service the converted copy, and the security registrar registers the SAME converted objects', async () => {
266+
const bundle = {
267+
manifest: { id: APP_ID, name: 'Converted Collections Probe', type: 'app', version: '1.0.0' },
268+
dashboards: [RETIRED_DASHBOARD],
269+
// `allowRestore` is retired (`permission-allow-restore-purge-removed`),
270+
// so the security registrar's copy is a converted one too.
271+
permissions: [{ name: 'support_agent', label: 'Support Agent', objects: { crm_ticket: { allowRead: true, allowRestore: true } } }],
272+
};
273+
const { ctx, payloads, registrations } = capturingCtx();
274+
const plugin = new AppPlugin(copy(bundle));
275+
await plugin.init!(ctx);
276+
await plugin.start!(ctx);
277+
278+
expect(payloads).toHaveLength(1);
279+
const payload = payloads[0];
280+
expect(payload.dashboards[0]).toEqual({ name: 'ops_board', label: 'Ops Board', widgets: [], refreshIntervalSeconds: 300 });
281+
expect(payload.permissions[0].objects.crm_ticket).toEqual({ allowRead: true });
282+
283+
// ONE conversion: a second pass would hand the registrar a second,
284+
// equal-looking copy. Identity is what tells the two apart.
285+
const permission = registrations.find((r) => r.type === 'permission' && r.name === 'support_agent');
286+
expect(permission?.item).toBe(payload.permissions[0]);
287+
});
288+
289+
it('a current bundle is handed over unchanged: the authored collections, the same references', async () => {
290+
const bundle = copy(CURRENT_ARTIFACT);
291+
const { ctx, payloads } = capturingCtx();
292+
await new AppPlugin(bundle).init!(ctx);
293+
294+
expect(payloads).toHaveLength(1);
295+
expect(payloads[0]).toStrictEqual({ ...bundle.manifest, ...bundle });
296+
// Copy-on-write: a conversion that changed nothing returns what it was
297+
// given, so nothing the boot registers is a rewritten copy.
298+
expect(payloads[0].dashboards).toBe(bundle.dashboards);
299+
expect(payloads[0].emailTemplates).toBe(bundle.emailTemplates);
300+
});
301+
302+
it('a bundle carrying packages[] is handed over as authored: init() never resolves the package list', async () => {
303+
const bundle = {
304+
manifest: { id: APP_ID, name: 'Converted Collections Probe', type: 'app', version: '1.0.0' },
305+
packages: [{ manifest: { id: `${APP_ID}.core`, name: 'core', version: '1.0.0', type: 'app', dashboards: [RETIRED_DASHBOARD] } }],
306+
};
307+
const { ctx, payloads } = capturingCtx();
308+
await new AppPlugin(bundle).init!(ctx);
309+
310+
expect(payloads).toHaveLength(1);
311+
expect(payloads[0]).toStrictEqual({ ...bundle.manifest, ...bundle });
312+
expect(payloads[0].packages).toBe(bundle.packages);
313+
});
314+
});

0 commit comments

Comments
 (0)