Skip to content

Commit 8692a08

Browse files
fix(cloud-connection)!: the marketplace reseed door converts the ledger body forward once, as install and rehydrate do (#24169)
Fixes #24040 Clause-②: no (narrowing) The marketplace `reseed-sample-data` door now converts the ledger body forward once, through the one entry point the install and the rehydrate use, before its side effects read it. It reloads the translations those two doors load, and no longer a retired shape they strip. **BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention. Disposition in the changeset: ADR-0087 `not-required (already-registered …)`, naming the four translation conversions. ## Premise check, on `origin/main` (base `7cda377c1`, merged up to `697301491`) - **Assumption 1 holds.** `handleReseed` called `this.applySideEffects(ctx, entry.manifest, { seedNow: true, c, door: 'reseed' })` with the raw ledger body. Install (`:1572` / `:1651`) and rehydrate (`:783` / `:806`) hand `applySideEffects` the body converted through `convertPackageBodyForward` (PR #24028). - **Assumption 2 holds, measured.** The new pin, run against the unfixed door, went red with the i18n service receiving `+ "settings": { "mail": … }` beside `apps`. The reseed loaded the per-app `settings` group that install and rehydrate strip. A census of the 142 entries in `ALL_CONVERSIONS` (each entry's own fixture, before against after, with every entry touching `translations`, `data`, `i18n`, `engines`, jobs, hooks or mappings then replayed through the real `ObjectQL.convertPackageBodyForward` at floor 17.0.0) finds four that rewrite `translations`, all replayed at this door: `translation-per-app-settings-removed` (the one the pin exercises), `translation-validation-messages-removed`, `translation-component-submit-label-removed` and `translation-widget-sub-caption-removed`. None touches `data`, `i18n` or `engines`. - **Assumption 3: the two other doors answer a converter throw differently, each by its own existing failure path.** Measured with an injected throwing converter: - **install:** the handler rejects (the host renders the error), nothing is registered and no ledger entry is written; - **rehydrate:** the per-entry catch logs `rehydrate failed for ID` at `error` with the thrown error attached, the entry is not loaded, and the boot continues. - The reseed is an HTTP door like the install, so it now does what the install does: the conversion call is not wrapped, the throw propagates, and nothing is loaded. Before, it answered and reloaded the raw body. No new answer is invented, and the install/rehydrate difference is reported here, not changed. - Can a real body throw? The engine's converter is documented as never throwing, but it can. A body whose top-level range is admitted (`>=17.0.0`) while a nested `manifest.engines.protocol` is a number passes the D1 handshake (which reads the top level) and throws `TypeError … trim is not a function` in the converter (which reads the nested `manifest` first). A non-string top-level range throws at the D1 handshake first, at every door. The install never writes the first shape to the ledger, because it throws before the ledger write. ## Change `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, `handleReseed` only: - After `refuseProtocolIncompatibleEntry` (unchanged, still FIRST, still judging the entry as stored), the door calls the plugin's existing `convertPackageBodyForward(ctx, entry.manifest)` once and hands the result to `applySideEffects`. - The ledger keeps the body as installed. The entry written back for `withSampleData` still carries its raw `manifest`, and the pin asserts the `settings` group is still in the ledger after the reseed. - `convertPackageBodyForward`, the install and rehydrate doors, the conversion registry and every other reader are untouched. ## Pin: `marketplace-install-local-reseed-convert-forward.test.ts` The pin goes through the real reseed route, with a real `ObjectQL` engine's `convertPackageBodyForward` (counted at the seam), a recording i18n service and a recording `runInlineSeed`: - **declared body** (`>=17.0.0`, translations carrying a per-app `settings` group beside `apps`): `200`, i18n receives `zh-CN` with `apps` only, the seed run gets the declared `data` unchanged, the converter runs exactly once and the named conversion warning prints once, and the ledger still holds `settings`; - **CONTROL, range-less body:** `200`, the converter hands the body back by reference, no warning, and i18n receives `settings` and `apps` as declared; - **D1 first:** an entry declaring `^17.0.0` is answered `422 OS_PROTOCOL_INCOMPATIBLE` with the converter never called and nothing loaded or seeded. ### Ablation (once, `scripts/ablation-replace.mjs` under a shell trap) - Mutation: the one call reverted to `this.applySideEffects(ctx, entry.manifest, …)`. Anchor x1 to x0, blob `86ebbc183687` to `3b914c584c12`. - Mutant leg: 1 red (the declared-body case, i18n received `+ "settings"`), 2 green (CONTROL, D1 first). - Restore leg: blob after restore equals HEAD (`86ebbc183687`), and `git diff HEAD` is empty. The pin imports the plugin from `src/`, so no `dist/` leg applies. ## Acceptance 3: the other body readers in this file | line | reader | reads | verdict | |---|---|---|---| | `:766` | rehydrate `checkProtocolCompat(entry.manifest)` | `engines` / `engine` | judged as stored by ruling; no conversion touches it | | `:2786` | `refuseProtocolIncompatibleEntry` (reseed, purge) | `engines` / `engine` | same | | `:1884` | `sampleDataInScope` (listing `withSampleData`), `seedDatasetsOf(entry.manifest)` | `data` | no conversion touches `data` | | `:2693` | `handlePurge`, `seedDatasetsOf(entry.manifest)` | `data` | same | | `:1435` | install `unrunnableCode(ctx, manifest, …)` (raw install body, before `:1572`) | jobs `enabled`/`pull`/`body`/`handler`/`name`, mappings `name`/`connectorSource`, hooks `body` presence/`name`/`handler` | disjoint: job conversions touch `id`, `timeout`, `retryPolicy`; mapping conversions touch `extractQuery`/`errorPolicy`/`batchSize` and `fieldMapping[].params`; hook conversions touch `timeout` and `body.capabilities` (not `body` presence). No verdict changes. | `:783`, `:1572` and `:2564` are the three conversion calls; `:2565` is the reseed's `applySideEffects` call this PR moves off the raw body. ## Verification, on head `e601aad52` (after merging `origin/main` at `697301491`) - `@objectstack/cloud-connection`: `pnpm test`, 49 files / 598 tests passed. `pnpm typecheck` exit 0. `tsc --listFiles` puts the new test in both programs (count 1 each). - `test:repo`: core 6 files / 84 tests (exit 0), objectql 1 / 5, types 1 / 11, rest 5 files / 254 passed, 12 skipped, runtime 3 / 808, all exit 0 on `e601aad52`. spec ran 66 files / 1105 tests, exit 0, on `dcb51cd45`. The merge after it touched no `packages/spec` path and none of this PR's files. - `eslint --no-inline-config` on both changed `.ts` files: exit 0. - `dispatch-gates --commands --repo objectstack-ai/objectstack` on `e601aad52`: 64 derived, 63 exit 0, and 1 NOT MEASURED: `pnpm check:dual-build-cjs-loads` exit 3, `PREREQUISITE NOT MET` (it needs every package's `dist/`; CI runs it). `--ran` exit 0: 64 accounted, 63 run, 1 NOT-MEASURED derived from the recorded code. - `check-adr-0087-registration` exit 0 (`not-required (already-registered)`), and `check-changeset-no-major` exit 0. ## Clause-② judgement `no (narrowing)`, not the claim's bare `no`. No accept set widens. The reseed now serves less (a retired translation group no longer reloads into i18n on reseed), and it now raises on a body whose conversion throws, where it answered before. Both are the dispatch's stated narrowing tests. A `CONTRACT_REVIEW_TIER` review is owed. ## Patch round 1 (`ec9f15c6b`, comment only) - The JSDoc on `convertPackageBodyForward` said "this plugin's two loads of a ledger body — the install and the rehydrate". This PR makes the reseed a third load, so the block now names three loads, and says the reseed hands only `applySideEffects` because it neither registers nor binds. The seat widened the claim's hold-off by that JSDoc block for this fix. No code moved. ## Acceptance notes (not fixed here) - The install's ADR-0120 D5e gate (`:1522`, `collectGlobalUniques(manifest.objects)`) reads the raw install body, while `declared-index-unique-scope` rewrites `indexes[].unique: true` to `'global'`. The gate treats both spellings as global with the same finding id, so the verdict is unchanged; only the `spelling` field in the refusal's details reads `true`. This is an observation, not a defect. --- _Generated by [Claude Code](https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bd4276a commit 8692a08

3 files changed

Lines changed: 269 additions & 15 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/cloud-connection': minor
3+
---
4+
5+
fix(cloud-connection)!: the marketplace `reseed-sample-data` door converts the installed package body forward once, as the install and the rehydrate do, so it reloads the translations those two doors load
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (already-registered translation-per-app-settings-removed, translation-validation-messages-removed, translation-component-submit-label-removed, translation-widget-sub-caption-removed) the reseed door now replays the conversion chain the install and the rehydrate already replay on the same stored body; the translation shapes it stops reloading are exactly the ones these four existing entries retire, and no spec key, export, option or stored shape is added, removed or renamed -->
10+
11+
**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
`POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data` handed its side effects (the translations it reloads into the i18n service, and the seed datasets it runs) the package body exactly as the install ledger stores it. The install and the boot rehydrate hand the same side effects that body converted forward, through the ADR-0087 window the body's own `engines.protocol` opens (`ObjectQL.convertPackageBodyForward`). So for a package built before a translation retirement, a reseed reloaded a retired translation shape that the other two doors strip. The reseed now converts the body once, through the same entry point, after the protocol handshake has judged the entry as stored.
14+
15+
- **Served less now:** for a package whose body declares a range this runtime admits, from a floor that predates a retirement, a reseed no longer reloads the translation shapes that the four translation conversions remove (`translation-per-app-settings-removed`, `translation-validation-messages-removed`, `translation-component-submit-label-removed` and `translation-widget-sub-caption-removed`). For example, a pre-17.4 per-app `settings` group is no longer loaded into the i18n service on reseed. After a reseed the i18n service holds what it holds after the install or a restart.
16+
- **Refused now:** a stored body the converter throws on is no longer reloaded. The reseed raises the converter's error, as the install does, and loads nothing. Before, it reloaded the raw body. The protocol handshake that runs first throws on the same malformed range at every door, so this arises only for a body whose range reads differently to the handshake and to the converter.
17+
- **Unchanged:** a body that declares no range is handed back as stored, so it reloads exactly as before. The handshake still runs first: a range that excludes this runtime is still answered `422 OS_PROTOCOL_INCOMPATIBLE`, before anything is converted or loaded. No conversion touches seed `data`, so a reseed seeds the same rows and answers the same counts. The ledger keeps the body as installed.
18+
19+
What changes for you: nothing to rewrite. For such a package the install and every restart already log the conversion warning, once per conversion per process, and it names the remedy: rebuild the package with `os build` and republish it.

‎packages/cloud-connection/src/marketplace-install-local-plugin.ts‎

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2525,6 +2525,12 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
25252525
* refused first, with the install route's `422 OS_PROTOCOL_INCOMPATIBLE`
25262526
* ({@link refuseProtocolIncompatibleEntry}): ahead of the side effects,
25272527
* because both of those answers are decided after them.
2528+
*
2529+
* [#24040] An entry the handshake admits is then converted forward once
2530+
* (ADR-0087 D2, {@link convertPackageBodyForward}), as the install and the
2531+
* rehydrate convert it, so the side effects reload the same translations
2532+
* and seed the same datasets the other two loads of that body do. A body
2533+
* with no declared range comes back unchanged.
25282534
*/
25292535
private handleReseed = async (c: any, ctx: PluginContext): Promise<Response> => {
25302536
const admission = await this.requireInstallCapability(c, ctx, 'Reseeding sample data');
@@ -2548,7 +2554,15 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
25482554
const incompatible = this.refuseProtocolIncompatibleEntry(c, entry);
25492555
if (incompatible) return incompatible;
25502556

2551-
const summary = await this.applySideEffects(ctx, entry.manifest, { seedNow: true, c, door: 'reseed' });
2557+
// [#24040] ADR-0087 D2 — a reseed is a load of the ledger body too: the
2558+
// body is converted forward ONCE here ({@link convertPackageBodyForward}),
2559+
// after the handshake above judged the entry as stored, exactly as the
2560+
// install and the rehydrate convert it, and that converted body is what
2561+
// the side effects reload (its translations) and seed (its datasets).
2562+
// The ledger keeps the body as installed: `entry` is written back below
2563+
// with its `manifest` untouched.
2564+
const converted = this.convertPackageBodyForward(ctx, entry.manifest);
2565+
const summary = await this.applySideEffects(ctx, converted, { seedNow: true, c, door: 'reseed' });
25522566
// [ADR-0123 D2 / D4] A walled session with no active organization: the
25532567
// reseed is a tenant-scoped write with no organization to write under.
25542568
if (summary.seeded.mode === 'refused') {
@@ -2779,20 +2793,23 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
27792793
};
27802794

27812795
/**
2782-
* ADR-0087 D2 for this plugin's two loads of a ledger body — the install
2783-
* and the rehydrate. The body is data at rest (a published artifact), so it
2784-
* replays the versioned forward conversion inside the window its own
2785-
* `engines.protocol` opens, through the engine's ONE registration-door entry
2786-
* point (`ObjectQL.convertPackageBodyForward`), reused here, never
2787-
* re-derived: a body with no declared range comes back by reference, and the
2788-
* named warning is the engine's own.
2789-
*
2790-
* Converted once per load and handed to EVERY reader of that load —
2791-
* `manifest.register`, {@link bindArtifactHandlers}, {@link applySideEffects}
2792-
* and the sample-data heal — so none of them reads the raw bytes while the
2793-
* registry holds the converted copy. `register` meets the same entry point
2794-
* again, which hands an already-converted body back by reference with no
2795-
* second warning.
2796+
* ADR-0087 D2 for this plugin's three loads of a ledger body — the install,
2797+
* the rehydrate and the reseed ({@link handleReseed}, #24040). The body is
2798+
* data at rest (a published artifact), so it replays the versioned forward
2799+
* conversion inside the window its own `engines.protocol` opens, through the
2800+
* engine's ONE registration-door entry point
2801+
* (`ObjectQL.convertPackageBodyForward`), reused here, never re-derived: a
2802+
* body with no declared range comes back by reference, and the named warning
2803+
* is the engine's own.
2804+
*
2805+
* Converted once per load and handed to EVERY reader of that load — on the
2806+
* install and the rehydrate `manifest.register`, {@link bindArtifactHandlers},
2807+
* {@link applySideEffects} and the rehydrate's sample-data heal; on the
2808+
* reseed {@link applySideEffects} alone, since it neither registers nor
2809+
* binds — so none of them reads the raw bytes while the registry holds the
2810+
* converted copy. On the two loads that register, `register` meets the same
2811+
* entry point again, which hands an already-converted body back by reference
2812+
* with no second warning.
27962813
*
27972814
* An engine without that door (not ObjectQL) is handed the body as before.
27982815
*/
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The `reseed-sample-data` door is a load of a ledger body too, so it converts
5+
* the body forward ONCE (ADR-0087 D2), through the entry point the install and
6+
* the boot rehydrate use (`ObjectQL.convertPackageBodyForward`, reached through
7+
* the plugin's own `convertPackageBodyForward`), and hands that converted body
8+
* to `applySideEffects`: the translations it reloads into i18n and the seed
9+
* datasets it runs. Before, the reseed handed `applySideEffects` the raw ledger
10+
* entry, so a body carrying a shape a conversion rewrites reloaded the raw
11+
* shape on reseed while the install and the rehydrate loaded it converted.
12+
*
13+
* The shape here is a pre-17.4 per-app `settings` translation group
14+
* (`translation-per-app-settings-removed`, retired after 17.4.0). The body
15+
* declares `>=17.0.0`: a range this runtime's D1 handshake admits, with a floor
16+
* that predates the retirement, so the door's window replays the entry and the
17+
* group is stripped. The neighbouring `apps` group rides through untouched.
18+
*
19+
* The converter is REAL: an `ObjectQL` engine's own `convertPackageBodyForward`,
20+
* counted at the seam the plugin calls. The i18n service records what it is
21+
* handed, and the seed run (`runInlineSeed`) records the datasets it is handed
22+
* and reports one row, so the route answers its ordinary success. What is
23+
* pinned is WHICH body the reseed reloads.
24+
*
25+
* The entry is written to the ledger after the boot: the reseed reads it from
26+
* the ledger itself, at the door, so the boot's rehydrate (which converts and
27+
* loads the same body) stays out of what is recorded.
28+
*/
29+
30+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
31+
import { mkdtempSync, rmSync } from 'node:fs';
32+
import { join } from 'node:path';
33+
import { tmpdir } from 'node:os';
34+
// The reseed merges its seed datasets through `@objectstack/runtime` (a lazy
35+
// `import()` inside the plugin). Its first load is paid here, at module top,
36+
// never inside a clocked `it`.
37+
import '@objectstack/runtime';
38+
import { ObjectQL } from '@objectstack/objectql';
39+
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
40+
import { LocalManifestSource } from './local-manifest-source.js';
41+
import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js';
42+
43+
type Bag = Record<string, any>;
44+
type Handler = (c: any) => Promise<any>;
45+
46+
const ROUTE = '/api/v1/marketplace/install-local';
47+
const RESEED = `POST ${ROUTE}/:manifestId/reseed-sample-data`;
48+
const PKG_ID = 'com.example.reseed24040';
49+
const SETTINGS_CONVERSION = 'translation-per-app-settings-removed';
50+
51+
/** The group the conversion strips: keyed by a platform settings namespace. */
52+
const SETTINGS_GROUP = { mail: { title: '邮件投递', keys: { host: { label: '主机' } } } };
53+
/** A neighbouring group on the same bundle entry, which no conversion touches. */
54+
const APPS_GROUP = { reseed_app: { label: '示例数据' } };
55+
const SEED_DATA = [{ object: 'qa_reseed_account', externalId: 'name', records: [{ name: 'Acme' }] }];
56+
57+
/** Built under a range this runtime admits, from a floor that predates 17.4.0. */
58+
const declaredBody = (): Bag => ({
59+
id: PKG_ID,
60+
name: 'Reseed',
61+
version: '1.0.0',
62+
type: 'app',
63+
scope: 'project',
64+
engines: { protocol: '>=17.0.0' },
65+
objects: [{ name: 'qa_reseed_account', fields: { name: { type: 'text' } } }],
66+
translations: [{ 'zh-CN': { settings: SETTINGS_GROUP, apps: APPS_GROUP } }],
67+
data: SEED_DATA,
68+
});
69+
/** The same body with NO declared range: the door replays nothing for it. */
70+
const rangeLessBody = (): Bag => {
71+
const { engines: _engines, ...rest } = declaredBody();
72+
return rest;
73+
};
74+
/** Installed for the previous protocol major: the D1 handshake refuses it. */
75+
const incompatibleBody = (): Bag => ({ ...declaredBody(), engines: { protocol: '^17.0.0' } });
76+
77+
let dir: string;
78+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-reseed-convert-')); });
79+
afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); });
80+
81+
function writeLedger(manifest: Bag): void {
82+
new LocalManifestSource(dir).write({
83+
packageId: PKG_ID,
84+
versionId: 'local',
85+
manifestId: PKG_ID,
86+
version: manifest.version,
87+
manifest,
88+
installedAt: '2026-01-01T00:00:00.000Z',
89+
installedBy: 'admin',
90+
withSampleData: false,
91+
});
92+
}
93+
94+
/** The plugin over a REAL engine's converter, booted to `kernel:ready` over an empty ledger. */
95+
async function bootPlugin() {
96+
const conversionWarnings: string[] = [];
97+
const ql = new ObjectQL({
98+
logger: {
99+
debug: () => undefined,
100+
info: () => undefined,
101+
error: () => undefined,
102+
warn: (line: unknown) => {
103+
if (String(line).includes('ADR-0087 conversion')) conversionWarnings.push(String(line));
104+
},
105+
},
106+
});
107+
const converted: Array<{ received: Bag; returned: Bag }> = [];
108+
const loaded: Array<[string, Bag]> = [];
109+
const services: Record<string, unknown> = {
110+
manifest: { register: vi.fn() },
111+
auth: installerAuthService(),
112+
objectql: withInstallerGrants({
113+
// The engine's own door, bound to the real engine and counted.
114+
convertPackageBodyForward: (body: Bag) => {
115+
const returned = ql.convertPackageBodyForward(body);
116+
converted.push({ received: body, returned });
117+
return returned;
118+
},
119+
syncSchemas: async () => undefined,
120+
}),
121+
metadata: {},
122+
i18n: { loadTranslations: (locale: string, data: Bag) => { loaded.push([locale, data]); } },
123+
};
124+
const routes = new Map<string, Handler>();
125+
const rawApp = {
126+
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
127+
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
128+
delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h),
129+
};
130+
const hooks = new Map<string, () => Promise<void>>();
131+
const ctx = {
132+
hook: (e: string, h: () => Promise<void>) => hooks.set(e, h),
133+
getService: (name: string) => {
134+
if (name === 'http-server') return { getRawApp: () => rawApp };
135+
if (!(name in services)) throw new Error(`no ${name}`);
136+
return services[name];
137+
},
138+
registerService: (name: string, svc: unknown) => { services[name] = svc; },
139+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
140+
};
141+
142+
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
143+
const seeded: Bag[][] = [];
144+
(plugin as unknown as Record<string, unknown>).runInlineSeed = async (_ctx: unknown, datasets: Bag[]) => {
145+
seeded.push(datasets);
146+
return { inserted: 1, updated: 0, skipped: 0, errors: 0, errorList: [], droppedRefs: 0 };
147+
};
148+
await plugin.start(ctx as never);
149+
await hooks.get('kernel:ready')?.();
150+
151+
const reseed = async () => {
152+
const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 }));
153+
return routes.get(RESEED)!({
154+
req: {
155+
url: `http://localhost:3000${ROUTE}`,
156+
raw: new Request(`http://localhost:3000${ROUTE}`),
157+
json: async () => ({}),
158+
param: (k: string) => (k === 'manifestId' ? PKG_ID : undefined),
159+
header: () => undefined,
160+
},
161+
json,
162+
});
163+
};
164+
return { converted, loaded, seeded, conversionWarnings, reseed };
165+
}
166+
167+
const ledgerBody = () => new LocalManifestSource(dir).read(PKG_ID).entry!.manifest as Bag;
168+
169+
describe('the reseed door reloads the body converted forward once, as install and rehydrate load it', () => {
170+
it('a declared body: the stripped `settings` group is not reloaded, through one conversion', async () => {
171+
const { converted, loaded, seeded, conversionWarnings, reseed } = await bootPlugin();
172+
writeLedger(declaredBody());
173+
174+
const res = await reseed();
175+
176+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
177+
expect(res.payload.data).toMatchObject({ manifestId: PKG_ID, inserted: 1, withSampleData: true });
178+
// i18n is handed the converted bundle: `settings` gone, `apps` intact.
179+
expect(loaded).toEqual([['zh-CN', { apps: APPS_GROUP }]]);
180+
// No conversion touches seed `data`: the run seeds the datasets as declared.
181+
expect(seeded).toEqual([SEED_DATA]);
182+
// Converted ONCE, from the body as the ledger holds it.
183+
expect(converted).toHaveLength(1);
184+
expect(converted[0].received).toEqual(declaredBody());
185+
expect(conversionWarnings.filter((w) => w.includes(`conversion '${SETTINGS_CONVERSION}'`))).toHaveLength(1);
186+
// The ledger keeps the body as installed, while the reseed rewrites the entry's flags.
187+
expect(ledgerBody().translations[0]['zh-CN'].settings).toEqual(SETTINGS_GROUP);
188+
expect(new LocalManifestSource(dir).read(PKG_ID).entry!.withSampleData).toBe(true);
189+
});
190+
191+
it('CONTROL — a range-less body comes back by reference, unchanged, and reloads as declared', async () => {
192+
const { converted, loaded, seeded, conversionWarnings, reseed } = await bootPlugin();
193+
writeLedger(rangeLessBody());
194+
195+
const res = await reseed();
196+
197+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
198+
expect(loaded).toEqual([['zh-CN', { settings: SETTINGS_GROUP, apps: APPS_GROUP }]]);
199+
expect(seeded).toEqual([SEED_DATA]);
200+
expect(converted).toHaveLength(1);
201+
expect(converted[0].returned).toBe(converted[0].received);
202+
expect(conversionWarnings).toEqual([]);
203+
});
204+
205+
it('the D1 handshake still judges the entry as stored, FIRST: a refused entry is never converted or reloaded', async () => {
206+
const { converted, loaded, seeded, conversionWarnings, reseed } = await bootPlugin();
207+
writeLedger(incompatibleBody());
208+
209+
const res = await reseed();
210+
211+
expect(res.status).toBe(422);
212+
expect(res.payload.error.code).toBe('OS_PROTOCOL_INCOMPATIBLE');
213+
expect(converted).toEqual([]);
214+
expect(conversionWarnings).toEqual([]);
215+
expect(loaded).toEqual([]);
216+
expect(seeded).toEqual([]);
217+
});
218+
});

0 commit comments

Comments
 (0)