Skip to content

Commit c7168ac

Browse files
fix(service-package,cloud-connection): convert stored package bodies forward at the sys_packages and marketplace doors (#24028)
Fixes #23974 Clause-②: yes (widening) Three package doors called the registry without going through the ADR-0087 versioned window that PR #23962 opened at the registration door. This PR routes each door through that window, or records why the door stays strict. The conversion entry point `ObjectQL.convertPackageBodyForward` is reused as it stands. Nothing changes in `packages/objectql/**`, `packages/metadata-core/**` or `packages/spec/**`, and `SchemaRegistry.installPackage` gets no second judge. ## Premise check, on `origin/main` (base `92fd343`, merged up to `fd9664ffa`) - **Door 1 holds.** `service-package/src/index.ts:715` called `registry.installPackage(rec.manifest)`, and one `try/catch` wrapped the whole loop. That catch logged anything other than a seam failure at **`debug`**. So the first record the registry refused also dropped every record after it, and nothing above `debug` said so. - **Door 2 holds** (`protocol.ts:31393`). Its premise as a conversion door does not, see door 2 below. - **Door 3 holds, on both paths.** Rehydrate (`:780`/`:790`/`:795`/`:827`) and the install path (`:1554`/`:1618`/`:1625`) both hand `register` the raw body and hand the same raw body to `bindArtifactHandlers` / `applySideEffects`. - **Correction to the card's reach.** HotCRM 4.0.1 exactly as published declares `engines.protocol: ^17.7.0`. This runtime is protocol 18, so the ADR-0087 **D1 handshake** at doors 1 and 3 (and `assertProtocolCompat` at door 2) refuses that body by name **before** the registry or any conversion sees it. Door 1 `continue`s past it, so a HotCRM-4.0.1 row alone does not stop hydration on v18 (pinned below). The window at these doors opens for a stored body whose range **admits** major 18 and whose floor predates a retirement, for example `>=17.7.0`. The converting pins restamp the fixture's body that way: same 17.7.0 floor, same pages. The loop defect is real for any registry refusal and is fixed below. ## Door 1: `sys_packages` boot hydration (`@objectstack/service-package`) - Each stored body goes through `objectql.convertPackageBodyForward` (reached through the engine the plugin already holds, as `registry` is) before `registry.installPackage` judges it. The stored row is not rewritten, so it converts again at every boot. This follows the ADR-0087 addendum: metadata at rest replays the chain at every load. - The loop survives one bad record. A record the registry still refuses after conversion is logged at **`error`**, naming the package id, carrying the refusal's message, and with the refusal attached. The line also states the consequence (absent from the registry for this process while its row remains) and the fix. The records after it keep hydrating. - The `isSeamUnreadable` WARN is untouched. - `Hydrated N package(s) from sys_packages into registry` now counts exactly the records the registry **accepted** in that pass. It excludes records the D1 handshake refused, records already registered, and records the registry refused (each of those is reported by name). ## Door 2: `protocol.installPackage` / `POST /api/v1/packages` stays STRICT (authoring-side) The decision comes from what each caller actually sends. It is recorded at the call site in `protocol.ts`, and the code is unchanged. - **`POST /api/v1/packages`** (`packages/runtime/src/domains/packages.ts`) parses the whole body through `PackageInstallBodySchema` / the closed `ManifestSchema` before calling the primitive. Measured on this tree: the HotCRM body is refused there with `Unrecognized key(s) on this package manifest: pages` in all three cases: as published, with only the convertible shapes, and with the conversion applied by hand. No package body that carries metadata collections reaches the primitive over HTTP, so a conversion inside it would never run for this door. - **`duplicatePackage`** (`:27496`) copies `srcPkg.manifest`, the registry's own record. The registry holds only what it accepted (already converted at whichever door installed it), so the duplicate sees the converted body. It behaves the same either way. - **The unmanaged install's base** (`:27870`): `plan.baseManifest` is identity keys plus `id` / `name` / `namespace` (`package-unmanaged-install.ts:473`), with no collections. The window has nothing to act on. - **AI Studio's blueprint tools** (triage's cloud reading, `6219354007`) call the primitive in-process with a manifest they are authoring now. A retired declaration there is the author's to fix, refused loudly at the moment of writing. - **What `sys_packages` stores:** unchanged. The primitive refuses before it persists, so a refused body writes no row. A body that lands in `sys_packages` by any road is data at rest from then on, and door 1 converts it at every boot. - Pinned over the real registry: the same converting body through `protocol.installPackage` is refused with `MisplacedDefaultPageError` (`INVALID_METADATA`, 422), with no registry record, no `sys_packages` row and no conversion warning. ## Door 3: the marketplace install's side readers (`@objectstack/cloud-connection`) - Install and rehydrate each convert the body **once** (a new `convertPackageBodyForward(ctx, manifest)` that delegates to the engine's entry point). That one object is what `manifest.register`, `bindArtifactHandlers`, `applySideEffects` and the rehydrate's seed read (`maybeHealSampleData`, which now takes the converted body) receive. The ledger keeps the body as installed. - `register` meets the entry point a second time. The already-converted body comes back with no second warning, and the pin asserts exactly one warning per conversion on both paths. ## Pins (real `SchemaRegistry` throughout; a stub cannot refuse) - `service-package/src/hydration-convert-forward.test.ts`: **real stack** (`ObjectKernel` + `ObjectQLPlugin` + `SqlDriver` better-sqlite3 on disk + `PackageServicePlugin`). One process publishes through the `package` service; a second process on the same file hydrates, as a restart does. I chose the real stack because the defect sits between the store, the engine's converter and the registry's refusal, and a unit double can show none of the three. Cases: - the body hydrates converted with the named warning, and the row stays as published; - a refused record (named at `error`, with `code`/`status` asserted on the attached refusal) followed by a record that still hydrates; - CONTROL: a range-less body is refused exactly as before, with no conversion; - HotCRM 4.0.1 as published is refused by the D1 handshake and the next record hydrates; - door 2's strict refusal. - `cloud-connection/src/marketplace-install-local-convert-forward.test.ts`: a real `ObjectKernel` + `ObjectQLPlugin` supplies the converter and the `manifest` service. On rehydrate and install, all readers receive the **same** converted object. CONTROL on both paths: a range-less body is refused at `register` (install: `422 PLUGIN_REGISTER_FAILED`; rehydrate: the refusal is logged for that entry with `code`/`status`), and no side reader runs. ### Ablations (`scripts/ablation-replace.mjs`; each restore proven blob == HEAD and `git diff HEAD` empty) | # | mutation | result | |---|---|---| | A1 | door 1 hands `rec.manifest` unconverted | 2 red (`hydrates … converted`, `keeps hydrating`) | | A2 | door 1's per-record catch rethrows | 2 red (`keeps hydrating`, CONTROL: the refusal falls to the old `debug` line) | | A3 | rehydrate binder reads `entry.manifest` | 1 red (`rehydrate — … same converted body`) | | A4 | install `applySideEffects` reads the raw `manifest` | 1 red (`install — … same converted body`) | The tests import the touched files from `src/` (and `@objectstack/objectql` / `core` / `driver-sql` / `metadata-protocol` through anchored vitest aliases to `src/`), so no `dist/` leg applies. ## Verification, on head `fe7b944bb` The later heads' readings are in the addenda and the dev's hand-backs: `f1443d9bca` (the gate fix) and `cbec0eac83` (patch round 1, addendum `6224822850`). At `cbec0eac83`, `dispatch-gates --commands` derives 102, 101 exit 0, and 1 is NOT MEASURED (`check:dual-build-cjs-loads`, exit 3). The name-fold gate's `--self-test` passes 30/30 with a matcher-battery floor of 20. Full `pnpm lint` exits 0. The readings below are the first report's. - `@objectstack/service-package`: test 118/118 (8 files), typecheck exit 0. `@objectstack/cloud-connection`: test 559/559 (47 files), typecheck exit 0. `@objectstack/metadata-protocol` (a comment-only change): typecheck exit 0, plus `durable-package.test.ts` 10/10. Its full suite is left to CI, a declared narrowing. `--listFiles` confirms that both new test files are in their package's tsc program. - `pnpm lint` (full, `eslint . --no-inline-config`): exit 0. - `dispatch-gates --commands`: 98 derived, 97 run with exit 0. One is NOT MEASURED: `pnpm check:dual-build-cjs-loads` exit 3 (`PREREQUISITE NOT MET`, it needs every package's `dist/`), and CI runs it. `check-plugin-teardown-shape.mjs --self-test` first exited 3 on the shallow clone; after fetching its pinned fixture commit it exited 0. `--ran` reconciliation: 98 accounted, 97 run, 1 NOT-MEASURED derived from the recorded exit 3. ## Wiring the pins needed - `service-package` devDependencies `@objectstack/objectql`, `@objectstack/driver-sql`, `@objectstack/metadata-protocol` (no cycle; `pnpm-lock.yaml` +9). Its `vitest.config.ts` gains anchored aliases to those packages' `src/` (`check:test-source-alias` green, no ledger widening). - Both new tests read the one HotCRM fixture under `packages/objectql/src/__fixtures__/`, so it is declared in `scripts/cross-package-test-inputs.mjs` and `turbo.json` (`check:cross-package-test-inputs`, `check:ci-filter-parity` green; `core` already covers the path). - **A surface supplement outside the claim: `scripts/check-position-name-fold-loaders.mjs`.** Its glob-shape rule 3 read the new `packages/objectql/src/__fixtures__/hotcrm-4.0.1-published-pages.artifact.json` line in `scripts/cross-package-test-inputs.mjs` as a reference to the metadata permissions artifact, a false positive. Rule 3 now judges whole path tokens. A token is set aside only when all of these hold: - it ends at a quote, backtick, whitespace, `,`, `]`, `)` or end of line; - it has no `..` segment; - its `path.posix.normalize`d form is outside `packages/metadata/src/__fixtures__/`; - it is one literal file path, with no glob character and no `${`. Everything else on the line is still judged. The self-test passes 30/30, with the matcher-battery floor at 20. Ablating the normalisation reds one case, and so does ablating the token boundary. The first carve-out (`f1443d9bca`) leaked `./` and partial-glob spellings (contract record `6224173098`, R1), and patch round 1 (`cbec0eac83`) closed that (record `6225074650`). ## Acceptance notes - **Other raw-ledger readers in `marketplace-install-local-plugin.ts`, outside the claimed region.** These belong to the same family as door 3. Conversions DO rewrite translations, jobs and hooks today (`packages/spec/src/conversions/registry.ts`), though none rewrites seed `data`. - The sample-data probes (`seedDatasetsOf(entry.manifest)`) and the install-time `unrunnableCode(ctx, manifest, …)` read nothing a conversion touches, so neither gives a wrong answer now. - The reseed door (`applySideEffects(ctx, entry.manifest, … door: 'reseed')`) reloads RAW translations, including a per-app `settings` group that `translation-per-app-settings-removed` strips. Carrier: #24040. - **`duplicatePackage`'s `catch {}`** around its `installPackage` call swallows a refusal (pre-existing, unchanged here). Carrier: #24041. - **`checkProtocolCompat` reads `^17.7.0 || ^18.0.0` as `incompatible`** on this runtime (measured with a direct call), although the range admits major 18 and the handshake's own comment says an unparsed range never causes a false rejection. Reach through a public door is not measured. Carrier: #24029. - The runtime's no-protocol fallback in `POST /api/v1/packages` (`registry.installPackage` after `assertProtocolCompat`) is the same authoring door as door 2 and stays strict too. _Seat edit · `domain:services` seat 2 · `session_018VqJPGA1NDNSCZsnecsChP` · 2026-10-16T21:32Z: the gate edit is declared under "Wiring the pins needed" (N2), the raw-ledger-reader note is corrected and its carriers named (N1, contract record `6224173098`), and a pointer to the later heads' readings is added under Verification._ --- _Generated by [Claude Code](https://claude.ai/code/session_013JuCowY7RrGXYbGEv3cWsQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b6805ac commit c7168ac

12 files changed

Lines changed: 800 additions & 19 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-package': minor
3+
'@objectstack/cloud-connection': minor
4+
---
5+
6+
Clause-②: yes (widening)
7+
8+
fix(service-package,cloud-connection): a stored or installed package body converts forward under ADR-0087 D2 at the `sys_packages` boot hydration and at the marketplace install, and one refused `sys_packages` record no longer stops the hydration of the rest
9+
10+
The registration door (`ObjectQL.convertPackageBodyForward`, `@objectstack/objectql`) already replays the versioned forward conversion for a package body that declares its `engines.protocol` range. Two more doors that load a body at rest now go through that same entry point, before the registry judges the body:
11+
12+
- **`sys_packages` boot hydration** (`PackageServicePlugin.start`). A stored record whose range admits this runtime, built before a retirement (for example a `type: 'home'` page with `isDefault: true`), now hydrates converted, with the engine's named warning, where the registry used to refuse it. The stored row is not rewritten, so it converts again at every boot.
13+
- A record the registry still refuses is reported **by name** at `error`, with the refusal attached, and the records after it keep hydrating. Before, the first refusal threw out of the whole loop to a `debug` line, and every durable package after it was absent from the registry with nothing saying so.
14+
- `Hydrated N package(s) from sys_packages into registry` counts the records the registry accepted in that pass.
15+
- The ADR-0087 D1 handshake still runs first: a record whose declared range excludes this runtime's major is refused by it, named, as before.
16+
- **The marketplace install** (`MarketplaceInstallLocalPlugin`, install and boot rehydrate). The body is converted once per load, and that one converted body is what `manifest.register`, the handler binder, the side effects (translations, seed datasets) and the rehydrate's sample-data heal read. Before, `register` converted its own copy while the side readers read the raw ledger entry. The ledger keeps the body as installed.
17+
18+
Nothing is narrowed. A body with no declared range (a manifest built in code) meets the registry's refusals exactly as before at both doors. The install primitive `protocol.installPackage` (`POST /api/v1/packages`) is authoring-side and stays strict: the registry judges the manifest as handed.
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The marketplace install's two loads of a package body — the install and the
5+
* boot rehydrate — convert the body forward ONCE (ADR-0087 D2) and hand that
6+
* one converted body to every reader of the load: `manifest.register`, the
7+
* handler binder (`bindArtifactHandlers`), the side effects
8+
* (`applySideEffects`: translations and seed datasets) and the rehydrate's
9+
* sample-data heal. Before, `register` converted its own copy while the three
10+
* side readers read the raw ledger entry.
11+
*
12+
* The converter and the judge are REAL: an `ObjectKernel` with `ObjectQLPlugin`
13+
* supplies the engine's `convertPackageBodyForward` and the `manifest` service
14+
* over the real `SchemaRegistry` (a stubbed registry cannot refuse, so it could
15+
* not tell the CONTROL below from the converting case). The side readers are
16+
* replaced by recorders, because what is pinned is WHICH body they receive.
17+
*
18+
* The body is HotCRM 4.0.1's (`__fixtures__/hotcrm-4.0.1-published-pages.artifact.json`
19+
* in `@objectstack/objectql`): a `type: 'home'` page with `isDefault: true`,
20+
* declared floor 17.7.0. As published it declares `^17.7.0`, which this
21+
* runtime's ADR-0087 D1 handshake refuses before any load (the protocol
22+
* handshake suite pins that), so the converting cases restamp the same body
23+
* `>=17.7.0`: same floor, same pages, a range this runtime admits.
24+
*/
25+
26+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
27+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
28+
import { tmpdir } from 'node:os';
29+
import { dirname, join, resolve } from 'node:path';
30+
import { fileURLToPath } from 'node:url';
31+
// The plugin reaches `@objectstack/runtime` through a lazy `import()`; its first
32+
// load is paid here, at module top, never inside a clocked `it`.
33+
import '@objectstack/runtime';
34+
import { ObjectKernel } from '@objectstack/core';
35+
import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql';
36+
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
37+
import { LocalManifestSource } from './local-manifest-source.js';
38+
import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js';
39+
40+
const HERE = dirname(fileURLToPath(import.meta.url));
41+
const FIXTURE_PATH = resolve(HERE, '../../objectql/src/__fixtures__/hotcrm-4.0.1-published-pages.artifact.json');
42+
43+
type Bag = Record<string, any>;
44+
type Handler = (c: any) => Promise<any>;
45+
46+
const HOTCRM_ID = 'app.objectstack.hotcrm';
47+
const IS_DEFAULT_CONVERSION = 'page-is-default-off-record-page-removed';
48+
const ROUTE = '/api/v1/marketplace/install-local';
49+
50+
/** HotCRM 4.0.1's body under a range this runtime admits, from the same 17.7.0 floor. */
51+
const hotcrmBody = (): Bag => {
52+
const artifact = JSON.parse(readFileSync(FIXTURE_PATH, 'utf8'));
53+
const body = artifact.packages.find((e: Bag) => e.manifest.id === HOTCRM_ID).manifest;
54+
return { ...body, engines: { protocol: '>=17.7.0' } };
55+
};
56+
/** The same body as a plugin builds it in code: NO declared range. */
57+
const codeBuiltBody = (): Bag => {
58+
const { engines: _engines, ...rest } = hotcrmBody();
59+
return rest;
60+
};
61+
const homePage = (body: Bag) => (body.pages as Bag[]).find((p) => p.name === 'sales_home_page');
62+
63+
let dir: string;
64+
const kernels: ObjectKernel[] = [];
65+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-convert-forward-')); });
66+
afterEach(async () => {
67+
while (kernels.length) {
68+
const k = kernels.pop()!;
69+
if (k.getState() === 'running') await k.shutdown();
70+
}
71+
rmSync(dir, { recursive: true, force: true });
72+
vi.restoreAllMocks();
73+
});
74+
75+
function seedLedger(manifest: Bag): void {
76+
new LocalManifestSource(dir).write({
77+
packageId: HOTCRM_ID,
78+
versionId: 'local',
79+
manifestId: HOTCRM_ID,
80+
version: manifest.version,
81+
manifest,
82+
installedAt: '2026-01-01T00:00:00.000Z',
83+
installedBy: 'admin',
84+
withSampleData: false,
85+
});
86+
}
87+
88+
/**
89+
* The plugin over a REAL engine's converter and `manifest` service, booted to
90+
* `kernel:ready` (which runs the rehydrate). Every reader of a load records the
91+
* body it was handed, in one ordered list.
92+
*/
93+
async function bootPlugin() {
94+
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false } as never);
95+
kernels.push(kernel);
96+
await kernel.use(new ObjectQLPlugin());
97+
await kernel.bootstrap();
98+
const ql = kernel.getService<ObjectQL>('objectql');
99+
ql.registry.logLevel = 'silent';
100+
const conversionWarnings: string[] = [];
101+
(ql as unknown as { logger: { warn: (line: unknown) => void } }).logger.warn = (line: unknown) => {
102+
if (String(line).includes('ADR-0087 conversion')) conversionWarnings.push(String(line));
103+
};
104+
105+
const handed: Array<{ reader: string; body: Bag }> = [];
106+
const realManifest = kernel.getService<{ register(m: unknown, options?: unknown): unknown }>('manifest');
107+
const manifest = {
108+
register: async (body: Bag, options?: unknown) => {
109+
// The plugin also registers its own Setup UI bundle at
110+
// `kernel:ready`; only the package under test is recorded.
111+
if (body?.id === HOTCRM_ID) handed.push({ reader: 'register', body });
112+
await realManifest.register(body, options);
113+
},
114+
};
115+
const routes = new Map<string, Handler>();
116+
const rawApp = {
117+
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
118+
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
119+
delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h),
120+
};
121+
const hooks = new Map<string, () => Promise<void>>();
122+
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
123+
const services: Record<string, unknown> = {
124+
manifest,
125+
auth: installerAuthService(),
126+
// The engine's own door, bound to the real engine; the rest of the
127+
// surface this plugin reads off `objectql` is the registry's.
128+
objectql: withInstallerGrants({
129+
convertPackageBodyForward: (body: unknown) => ql.convertPackageBodyForward(body),
130+
registry: ql.registry,
131+
syncSchemas: async () => undefined,
132+
}),
133+
};
134+
const ctx = {
135+
hook: (e: string, h: () => Promise<void>) => hooks.set(e, h),
136+
getService: (name: string) => {
137+
if (name === 'http-server') return { getRawApp: () => rawApp };
138+
if (!(name in services)) throw new Error(`no ${name}`);
139+
return services[name];
140+
},
141+
logger,
142+
};
143+
144+
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
145+
const recorder = (reader: string) => async (...args: unknown[]) => {
146+
handed.push({ reader, body: (reader === 'heal' ? args[2] : args[1]) as Bag });
147+
return { seeded: { mode: 'none' } };
148+
};
149+
const p = plugin as unknown as Record<string, unknown>;
150+
p.bindArtifactHandlers = recorder('bindArtifactHandlers');
151+
p.applySideEffects = recorder('applySideEffects');
152+
p.maybeHealSampleData = recorder('heal');
153+
154+
await plugin.start(ctx as never);
155+
await hooks.get('kernel:ready')?.();
156+
157+
const install = async (body: Bag) => {
158+
const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 }));
159+
return routes.get(`POST ${ROUTE}`)!({
160+
req: {
161+
url: `http://localhost:3000${ROUTE}`,
162+
raw: new Request('http://localhost:3000/x'),
163+
json: async () => ({ manifest: body }),
164+
param: () => undefined,
165+
},
166+
json,
167+
});
168+
};
169+
return { ql, handed, conversionWarnings, logger, install };
170+
}
171+
172+
/** Every reader received the SAME converted object, and the flag is gone from it. */
173+
function expectOneConvertedBody(handed: Array<{ reader: string; body: Bag }>, readers: string[]) {
174+
expect(handed.map((h) => h.reader)).toEqual(readers);
175+
const [first] = handed;
176+
for (const h of handed) expect(h.body, `${h.reader} read a different body`).toBe(first.body);
177+
expect(homePage(first.body)).toMatchObject({ type: 'home' });
178+
expect(homePage(first.body)).not.toHaveProperty('isDefault');
179+
}
180+
181+
describe('the marketplace install converts a body once and every reader of the load reads that body', () => {
182+
it('rehydrate — register, the binder, the side effects and the heal all receive the converted body', async () => {
183+
seedLedger(hotcrmBody());
184+
const { ql, handed, conversionWarnings } = await bootPlugin();
185+
186+
expectOneConvertedBody(handed, ['register', 'bindArtifactHandlers', 'applySideEffects', 'heal']);
187+
// The converted body still registers as a marketplace install.
188+
expect(ql.registry.getPackage(HOTCRM_ID)?.source).toBe('marketplace');
189+
// Converted once: `register` met the entry point again with the
190+
// converted body, and no second warning was printed.
191+
expect(conversionWarnings.filter((w) => w.includes(`conversion '${IS_DEFAULT_CONVERSION}'`))).toHaveLength(1);
192+
// The ledger keeps the body as installed: data at rest replays at every load.
193+
expect(homePage(new LocalManifestSource(dir).read(HOTCRM_ID).entry!.manifest as Bag)).toMatchObject({ isDefault: true });
194+
});
195+
196+
it('install — register, the binder and the side effects all receive the converted body', async () => {
197+
const { ql, handed, conversionWarnings, install } = await bootPlugin();
198+
199+
const res = await install(hotcrmBody());
200+
201+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
202+
expectOneConvertedBody(handed, ['register', 'bindArtifactHandlers', 'applySideEffects']);
203+
expect(ql.registry.getPackage(HOTCRM_ID)?.source).toBe('marketplace');
204+
expect(conversionWarnings.filter((w) => w.includes(`conversion '${IS_DEFAULT_CONVERSION}'`))).toHaveLength(1);
205+
expect(homePage(new LocalManifestSource(dir).read(HOTCRM_ID).entry!.manifest as Bag)).toMatchObject({ isDefault: true });
206+
});
207+
});
208+
209+
describe('CONTROL — a body with no declared range meets the registry\'s refusal exactly as before', () => {
210+
const expectMisplacedDefault = (message: string) =>
211+
expect(message).toContain(`Page "sales_home_page" of package "${HOTCRM_ID}": \`isDefault: true\` is refused on a \`type: 'home'\` page`);
212+
213+
it('install — refused at register (422 PLUGIN_REGISTER_FAILED), nothing converted, no side reader runs', async () => {
214+
const { ql, handed, conversionWarnings, install } = await bootPlugin();
215+
216+
const res = await install(codeBuiltBody());
217+
218+
expect(res.status).toBe(422);
219+
expect(res.payload.error.code).toBe('PLUGIN_REGISTER_FAILED');
220+
expectMisplacedDefault(res.payload.error.message);
221+
expect(handed.map((h) => h.reader)).toEqual(['register']);
222+
expect(homePage(handed[0].body)).toMatchObject({ isDefault: true });
223+
expect(conversionWarnings).toEqual([]);
224+
expect(ql.registry.getPackage(HOTCRM_ID)).toBeUndefined();
225+
});
226+
227+
it('rehydrate — refused at register and reported for that entry, no side reader runs', async () => {
228+
seedLedger(codeBuiltBody());
229+
const { ql, handed, conversionWarnings, logger } = await bootPlugin();
230+
231+
expect(handed.map((h) => h.reader)).toEqual(['register']);
232+
expect(homePage(handed[0].body)).toMatchObject({ isDefault: true });
233+
expect(conversionWarnings).toEqual([]);
234+
expect(ql.registry.getPackage(HOTCRM_ID)).toBeUndefined();
235+
const failed = logger.error.mock.calls.filter((c: unknown[]) => String(c[0]) === `[MarketplaceInstallLocal] rehydrate failed for ${HOTCRM_ID}`);
236+
expect(failed).toHaveLength(1);
237+
const refusal = failed[0][1] as Error & { code?: string; status?: number };
238+
expect({ name: refusal.name, code: refusal.code, status: refusal.status })
239+
.toEqual({ name: 'MisplacedDefaultPageError', code: 'INVALID_METADATA', status: 422 });
240+
expectMisplacedDefault(refusal.message);
241+
});
242+
});

0 commit comments

Comments
 (0)