Skip to content

Commit 48297ad

Browse files
fix(cloud-connection): the install-local listing marks a package the rehydrate refused as not loaded (#21822) (#21833)
Fixes #21822 Clause-②: no ## What changed After a restart whose `kernel:ready` rehydrate refused a protocol-incompatible package (ADR-0087 D1), `GET /api/v1/marketplace/install-local` listed it like any loaded package, and each GET logged a `warn` that it could not read the package's seed rows. Now the rehydrate records each entry it refuses, and the listing serves that entry with a `notLoaded` marker carrying the refusal's code and the declared range. The listing reads no seed rows for it, so the per-request warn is gone. This is the direction triage ruled (`5988934231`): **a marker, not omission**. `packages/cloud-connection/src/marketplace-install-local-plugin.ts` only. ⛔ No change to the refusal itself (its line, level and wording), to DELETE, to the install route or to `handleReseed`. No `packages/spec` path. ## The wire shape (for objectstack-ai/objectui#11645 to render) A refused entry, as an operator (`manage_metadata`) reads it: ```json { "packageId": "com.example.crm", "versionId": "…", "manifestId": "com.example.crm", "version": "…", "installedAt": "…", "notLoaded": { "code": "OS_PROTOCOL_INCOMPATIBLE", "requiredRange": "^16" }, "installedBy": "…" } ``` - `notLoaded` is CLOSED: exactly `code` and `requiredRange`, the handshake diagnostic's own member names (the same `requiredRange` the install door's 422 carries in `error.details`). It is not a spread of the diagnostic. - `notLoaded` stands **in place of** `withSampleData`. The listing does not read the package's rows, so it makes no claim about them: the key is omitted, not `false`. This follows the same rule as `installedBy` for a narrowed caller. - A narrowed caller (authenticated, no `manage_metadata`) gets the same item without `installedBy`. The marker is served to every authenticated caller. - A loaded entry is byte-identical to before: `withSampleData`, no `notLoaded` key. `total` counts marked entries. - After `DELETE` the entry is gone from the ledger and the listing. Once a compatible version is installed over it, the entry is listed as loaded, with no marker. ## Where "not loaded" comes from (H3, measured on the code) I compared two sources: - **A**, the rehydrate's own record of what it refused. - **B**, re-running `checkProtocolCompat` on each ledger entry per request. I chose **A**. It records what the rehydrate did, so it agrees with it by construction. B is a second judgement, and it disagrees with what happened in reachable states: - `rehydrate` returns before the handshake loop when there is no `manifest` service ("no `manifest` service — rehydrate skipped"). B would mark entries whose refusal never happened. - The listing re-reads the ledger directory on every request. The rehydrate's own comment names a ledger shared with "a runtime of another protocol", so an entry written there after this boot was never judged here, and B would report a refusal this runtime never made. The record is keyed by manifest id and holds the refused entry's `installedAt`, which says which entry was refused. The install door is the only writer of a new entry, it always stamps a fresh `installedAt`, and it runs the same handshake first, so it can never write an incompatible entry. So a compatible re-install stops the marker without any write to the record from the install door or from DELETE. `rehydrate` clears the record when it starts. ## Pins (the ruling's, verbatim, plus the order's addition) - "after a restart whose rehydrate refused an entry, `GET /install-local` lists it with the marker and the code." - "A loadable entry is unchanged, and DELETE on the marked entry still works." - No seed-row read and no `withSampleData` warn for the marked entry. Unit (`src/marketplace-install-local-listing-not-loaded.test.ts`, 7 cases): a fresh plugin over a ledger with one refused and one loadable entry. The engine double answers only for registered objects, the way the real one answers `Object '…' not found`. The cases cover: - the marker, on the operator's item and the narrowed caller's; - the closed member set; - zero reads of the marked package's object and zero seed-row warnings, with a lit control that the loadable entry's object was read; - the loadable item, byte-equal to the one a ledger without the refused entry serves; - DELETE; - a compatible re-install clears the marker. Door (`packages/qa/dogfood/test/install-local-listing-not-loaded.dogfood.test.ts`, 7 cases): real showcase boots over one `databaseFile` and one ledger. - Boot 1 installs the CRM package (28 seed rows) and a small loadable package. - Between the boots, the CRM ledger entry is rewritten to declare `^(major-1)`, an install made for an older runtime. - Boot 2's rehydrate refuses it; the capture holds the `error` line. - The GET serves the marker. The loadable item is byte-identical to its boot-1 item. DELETE answers 200, and the listing then holds the loadable package alone. - The no-warning assertion has a lit control: an unreadable ledger file planted before boot 2 makes the same GET log its own `warn` through the same logger, in the same capture window. ## Reverse verification (one-off, not kept) The plugin file was restored to the merge base (`c4d57131`, blob `50a71c2f`) with a trap that restores `HEAD`. Each leg was proven on disk: the `refusedAtRehydrate` count went 6 → 0 → 6, the hash equalled the BASE blob, then the `HEAD` blob `411ad7a9`, and `git diff HEAD` came back empty. Both suites import the plugin from `src`: the unit test by a relative path, and the dogfood `isolated` project aliases `@objectstack/cloud-connection` to `../../cloud-connection/src/index.ts`. So no `dist` leg applies. - Unit, pre-fix: **3 failed | 4 passed (7)**. The marker cases fail, and the read/warn case shows both facts: `readsOfMarkedEntry: ["qa_old_account"]` and the warning `com.example.qaold21822: the installed-apps listing could not read this package's seed rows (qa_old_account: Object 'qa_old_account' not found), so it answers withSampleData: false for it`. With the fix: 7 passed. - Door, pre-fix: **2 failed | 5 passed (7)**. `notLoaded` is `undefined`, and the GET logs `WARN [MarketplaceInstallLocal] com.example.crm: the installed-apps listing could not read this package's seed rows (crm_account: Object 'crm_account' not found; crm_contact: …; crm_opportunity: …; crm_lead: …; crm_activity: …)`. This is the card's own reading, reproduced. With the fix: 7 passed. The preservation pins (the loadable entry, DELETE, the compatible re-install, the preconditions and the capture control) pass on both sides, as preservation pins should. ## Tests and gates All readings are from `claude/issue-21822-listing-not-loaded-marker` after one merge of `origin/main` (`07bf21ff`). Final head: `36817931`. - **Build:** the `@objectstack/cloud-connection` and `@objectstack/dogfood` dependency closures, rebuilt after the merge: `turbo run build`, 63/63 tasks. - **`pnpm --filter @objectstack/cloud-connection test`:** 40 files / 492 tests passed (at `6bb9f960`). The one later commit, `36817931`, changes only the new test's engine double, and that file was re-run at `36817931`: 7/7. - **`pnpm --filter @objectstack/cloud-connection typecheck`:** exit 0. `tsc --listFiles` includes the new test file. - **`pnpm --filter @objectstack/dogfood typecheck`:** exit 0. `tsc --listFiles` includes the new dogfood file. - **Dogfood, narrowed:** `vitest run --project isolated` over `install-local-listing-not-loaded`, `install-local-listing-sample-data`, `install-local-purge-sample-data` and `install-local-no-active-organization`: 4 files / 27 tests passed. This narrowing is proven, not assumed: `git grep -l -E 'MarketplaceInstallLocalPlugin|marketplace/install-local' -- packages/qa/dogfood/test` at HEAD names exactly these four files. The rest of the suite belongs to CI's Dogfood Regression Gate. - **Gate battery:** the 67 commands `dispatch-gates --commands` derives for this change set (the dispatch's 49 plus 18 more), all exit 0 at `36817931`. `dispatch-gates --ran`: "67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN". - `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET`: eight packages outside this closure had no `dist`. They were built and the gate re-ran green: "106 published require entry point(s) across 66 package(s) load". - `check:where-matcher` caught the new engine double reading a combinator as a field name. The double now refuses what it does not implement: 461/461 conforming. - **`pnpm lint`** (`eslint . --no-inline-config`, the whole repository): exit 0 at `36817931`. - **Stale-tree note:** after the one merge, `origin/main` gained `088428fb` (#21823: the runtime dispatcher and `scripts/check-route-envelope.mjs`). So `check:route-envelope` ran on its pre-#21823 copy. CI runs on the merge ref. ## Acceptance notes - **Other not-loaded states stay unmarked; they are outside this ruling, which names the protocol refusal.** None was measured through a public door, and none is filed. - A rehydrate whose `register` throws ("rehydrate failed for …", at `error`) is still listed with `withSampleData`, and gets the per-request seed-row warning. - A cloud-snapshot install whose hot-register failed ("will load on next restart") answers 200 and is listed as installed until the restart. - No `manifest` service at `kernel:ready` ("rehydrate skipped") lists every entry as installed. - If a second not-loaded cause is ever marked, `notLoaded.code` is its slot. Today the shape is closed to the one code. - **Not measured here:** reseed and purge on a marked entry. objectstack-ai/objectui#11645 already says Details offers no action that needs a loaded package. - **Release text:** the pending #21775 changeset's "(for example, a package the runtime did not load)" stays true, for the first case above. - **The refusal line is unchanged:** same level (`error`), same wording. Only its docblock now says the listing marks the entry. - **Concurrency:** #21776's dev edits `handleReseed` in the same file. This PR touches only the rehydrate's refusal branch, the record field, `handleList` and a helper after it, so there is no overlapping region. --- _Generated by [Claude Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2799155 commit 48297ad

4 files changed

Lines changed: 608 additions & 17 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/cloud-connection": patch
3+
---
4+
5+
`GET /api/v1/marketplace/install-local` now marks an installed package that this runtime refused to load. Before, after a restart whose rehydrate refused a package built for another protocol major, the listing served it like any loaded package, and the console's Installed Apps showed it as installed.
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** On a restart, a ledger entry whose `engines.protocol` range excludes this runtime is not loaded, and the boot logs `OS_PROTOCOL_INCOMPATIBLE` at `error`. The entry stays in the ledger, so `DELETE` and a compatible re-install still act on it. The listing served it with the same fields as a loaded package. Each request also tried to read its seed rows from objects that were never registered, and logged a `warn` saying it could not.
10+
- **What it does now.** That entry is listed with `"notLoaded": { "code": "OS_PROTOCOL_INCOMPATIBLE", "requiredRange": "^16" }` (the range the package declares) in place of `withSampleData`. No seed row is read for it, so the per-request `warn` is gone. `notLoaded` has exactly these two members, and every authenticated caller sees it.
11+
- **Unchanged.** A loaded package's entry is exactly as before, with no `notLoaded` key. `DELETE /api/v1/marketplace/install-local/:manifestId` removes a marked entry as before, and once a compatible version is installed over it, the entry is listed as loaded.
12+
- **Where the marker comes from.** The rehydrate records each entry it refuses, and the listing reads that record. The listing does not run the protocol check again.
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21822] `GET /api/v1/marketplace/install-local` lists a ledger entry the
5+
* `kernel:ready` rehydrate refused to load with a not-loaded marker.
6+
*
7+
* ## The defect this file pins shut
8+
*
9+
* The rehydrate keeps an entry whose `engines.protocol` excludes this runtime
10+
* in the ledger and loads none of it (ADR-0087 D1, #21762). The listing then
11+
* served that entry exactly like a loaded one, `withSampleData` included, so
12+
* the console's Installed Apps said "installed"; and since #21775 each GET also
13+
* tried to read the package's seed rows from objects nobody registered, and
14+
* logged one `warn` per request saying it could not.
15+
*
16+
* ## What these cases pin (triage ruling `5988934231`)
17+
*
18+
* - after a restart whose rehydrate refused an entry, the listing serves it
19+
* with `notLoaded: { code, requiredRange }`, a CLOSED pair, in place of
20+
* `withSampleData`, to the operator and the narrowed caller alike;
21+
* - no seed row is read for the marked entry and the listing's
22+
* "could not read this package's seed rows" warning does not fire;
23+
* - a loadable entry is served unchanged: its rows are read and its item is
24+
* the one a ledger without the refused entry serves;
25+
* - DELETE on the marked entry still works, and a compatible version
26+
* installed over it is listed as loaded.
27+
*
28+
* A "restart" here is a fresh plugin mounted over a ledger that already holds
29+
* the entries, through its real `start()` + `kernel:ready`. The real-boot pin
30+
* across a restart is `packages/qa/dogfood/test/install-local-listing-not-loaded.dogfood.test.ts`.
31+
*
32+
* Ranges derive from the running protocol, never literals.
33+
*/
34+
35+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
36+
import { mkdtempSync, rmSync } from 'node:fs';
37+
import { join } from 'node:path';
38+
import { tmpdir } from 'node:os';
39+
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
40+
// The rehydrate binds handlers and the listing builds its seed graph through
41+
// `@objectstack/runtime` (lazy `import()`s inside the plugin). Its first load
42+
// is paid here, at module top, never inside a clocked `it`.
43+
import '@objectstack/runtime';
44+
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
45+
import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js';
46+
import { installerGrantRows, INSTALLER_USER_ID } from './install-local-principal.fixtures.js';
47+
48+
type Handler = (c: any) => Promise<any>;
49+
type Row = Record<string, unknown> & { id: string };
50+
51+
const ROUTE = '/api/v1/marketplace/install-local';
52+
const OLD_RANGE = `^${PROTOCOL_MAJOR - 1}`;
53+
const CURRENT_RANGE = `^${PROTOCOL_MAJOR}`;
54+
const SEED_WARNING = 'the installed-apps listing could not read this package\'s seed rows';
55+
56+
/** Installed for the previous protocol major: the rehydrate refuses it. */
57+
const REFUSED = {
58+
id: 'com.example.qaold21822', name: 'Old', version: '1.0.0', type: 'app', scope: 'project',
59+
engines: { protocol: OLD_RANGE },
60+
objects: [{ name: 'qa_old_account', fields: { name: { type: 'text' } } }],
61+
data: [{ object: 'qa_old_account', externalId: 'name', records: [{ name: 'Acme' }] }],
62+
};
63+
/** Built for this protocol: the rehydrate loads it. */
64+
const LOADED = {
65+
id: 'com.example.qacurrent21822', name: 'Current', version: '1.0.0', type: 'app', scope: 'project',
66+
engines: { protocol: CURRENT_RANGE },
67+
objects: [{ name: 'qa_cur_account', fields: { name: { type: 'text' } } }],
68+
data: [{ object: 'qa_cur_account', externalId: 'name', records: [{ name: 'Beta' }] }],
69+
};
70+
71+
function ledgerEntry(manifest: { id: string; version: string }): InstalledManifestEntry {
72+
return {
73+
packageId: manifest.id,
74+
versionId: manifest.version,
75+
manifestId: manifest.id,
76+
version: manifest.version,
77+
manifest,
78+
installedAt: '2026-01-01T00:00:00.000Z',
79+
installedBy: INSTALLER_USER_ID,
80+
withSampleData: true,
81+
};
82+
}
83+
84+
/** A tenant administrator that does NOT hold `manage_metadata`: the narrowed caller. */
85+
const MEMBER_ID = 'usr_member';
86+
const MEMBER_GRANTS: Record<string, unknown[]> = {
87+
sys_user: [{ id: MEMBER_ID, email: 'member@objectstack.test' }],
88+
sys_member: [],
89+
sys_user_position: [],
90+
sys_position: [],
91+
sys_position_permission_set: [],
92+
sys_user_permission_set: [{ id: 'ups_member', user_id: MEMBER_ID, permission_set_id: 'ps_member', organization_id: null }],
93+
sys_permission_set: [{ id: 'ps_member', name: 'organization_admin', system_permissions: ['setup.access', 'manage_org_users'] }],
94+
};
95+
96+
/**
97+
* Mount the plugin over a ledger that already holds `manifests`, the way a
98+
* restarted runtime meets it. The engine answers only for objects a package
99+
* REGISTERED, the way the real one answers `Object '…' not found` for a
100+
* refused package's objects, and it records every read.
101+
*/
102+
async function restartWith(manifests: Array<{ id: string; version: string }>, dir: string) {
103+
for (const m of manifests) new LocalManifestSource(dir).write(ledgerEntry(m));
104+
// Both packages' seed rows are in the database: the refused package's
105+
// were written while an earlier runtime still loaded it.
106+
const tables: Record<string, Row[]> = {
107+
qa_old_account: [{ id: 'o1', name: 'Acme' }],
108+
qa_cur_account: [{ id: 'c1', name: 'Beta' }],
109+
};
110+
const objects = new Map<string, any>();
111+
const reads: string[] = [];
112+
const caller = { as: 'operator' as 'operator' | 'member' };
113+
const grants = () => (caller.as === 'operator' ? installerGrantRows() : MEMBER_GRANTS);
114+
const engine = {
115+
syncSchemas: vi.fn(async () => undefined),
116+
registry: { getAllPackages: () => [] },
117+
async find(object: string, query?: any): Promise<unknown[]> {
118+
const granted = grants();
119+
if (Object.prototype.hasOwnProperty.call(granted, object)) return granted[object]!;
120+
reads.push(object);
121+
if (!objects.has(object)) throw new Error(`Object '${object}' not found`);
122+
const where: Record<string, unknown> = query?.where ?? {};
123+
const rows = (tables[object] ?? []).filter((row) => Object.entries(where).every(([k, v]) => {
124+
// Scalar equality only; anything else is refused, never guessed.
125+
if (k.startsWith('$') || (v !== null && typeof v === 'object')) {
126+
throw new Error(`only scalar equality is implemented here (got '${k}')`);
127+
}
128+
return row[k] === v;
129+
}));
130+
return (typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows).map((row) => ({ ...row }));
131+
},
132+
};
133+
const register = vi.fn((m: any) => { for (const o of m?.objects ?? []) objects.set(o.name, o); });
134+
const services: Record<string, unknown> = {
135+
manifest: { register },
136+
auth: { api: { getSession: async () => ({ user: { id: caller.as === 'operator' ? INSTALLER_USER_ID : MEMBER_ID }, session: {} }) } },
137+
objectql: engine,
138+
metadata: { getObject: async (name: string) => objects.get(name) },
139+
};
140+
const routes = new Map<string, Handler>();
141+
const rawApp = {
142+
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
143+
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
144+
delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h),
145+
};
146+
const hooks = new Map<string, any>();
147+
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
148+
const ctx = {
149+
hook: (e: string, h: any) => hooks.set(e, h),
150+
getService: (name: string) => {
151+
if (name === 'http-server') return { getRawApp: () => rawApp };
152+
const svc = services[name];
153+
if (svc === undefined) throw new Error(`no ${name}`);
154+
return svc;
155+
},
156+
logger,
157+
};
158+
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
159+
await plugin.start(ctx as any);
160+
await hooks.get('kernel:ready')?.();
161+
return {
162+
caller,
163+
reads,
164+
logger,
165+
registered: () => register.mock.calls.map(([m]) => m?.id),
166+
list: async () => {
167+
reads.length = 0;
168+
logger.warn.mockClear();
169+
const res = await routes.get(`GET ${ROUTE}`)!(makeC());
170+
return { status: res.status, data: res.payload?.data, byId: new Map<string, any>((res.payload?.data?.items ?? []).map((i: any) => [i.manifestId, i])) };
171+
},
172+
uninstall: (manifestId: string) => routes.get(`DELETE ${ROUTE}/:manifestId`)!(makeC(undefined, { manifestId })),
173+
install: (body: unknown) => routes.get(`POST ${ROUTE}`)!(makeC(body)),
174+
};
175+
}
176+
177+
function makeC(body?: unknown, params: Record<string, string> = {}) {
178+
return {
179+
req: {
180+
url: `http://localhost:3000${ROUTE}`,
181+
raw: new Request(`http://localhost:3000${ROUTE}`),
182+
json: async () => body,
183+
param: (k: string) => params[k],
184+
header: () => undefined,
185+
},
186+
json: vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })),
187+
};
188+
}
189+
190+
const seedWarnings = (logger: { warn: { mock: { calls: unknown[][] } } }) =>
191+
logger.warn.mock.calls.map(([m]) => String(m)).filter((m) => m.includes(SEED_WARNING));
192+
193+
let dir: string;
194+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-not-loaded-')); });
195+
afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); });
196+
197+
describe('GET install-local after a restart whose rehydrate refused an entry', () => {
198+
it('PRECONDITION: the rehydrate refused the old entry and loaded the current one', async () => {
199+
const h = await restartWith([REFUSED, LOADED], dir);
200+
expect(h.registered()).toContain(LOADED.id);
201+
expect(h.registered()).not.toContain(REFUSED.id);
202+
const said = h.logger.error.mock.calls.map(([m]) => String(m));
203+
expect(said).toHaveLength(1);
204+
expect(said[0]).toContain(`OS_PROTOCOL_INCOMPATIBLE: ${REFUSED.id}@1.0.0 is NOT loaded`);
205+
});
206+
207+
it('lists the refused entry with the marker and the code: notLoaded { code, requiredRange }, in place of withSampleData', async () => {
208+
const h = await restartWith([REFUSED, LOADED], dir);
209+
const listing = await h.list();
210+
expect(listing.status).toBe(200);
211+
expect(listing.data.total).toBe(2);
212+
expect(listing.byId.get(REFUSED.id)).toEqual({
213+
packageId: REFUSED.id,
214+
versionId: '1.0.0',
215+
manifestId: REFUSED.id,
216+
version: '1.0.0',
217+
installedAt: '2026-01-01T00:00:00.000Z',
218+
notLoaded: { code: 'OS_PROTOCOL_INCOMPATIBLE', requiredRange: OLD_RANGE },
219+
installedBy: INSTALLER_USER_ID,
220+
});
221+
// CLOSED: exactly the two members, nothing else of the diagnostic.
222+
expect(Object.keys(listing.byId.get(REFUSED.id).notLoaded).sort()).toEqual(['code', 'requiredRange']);
223+
});
224+
225+
it('reads no seed row for the marked entry, and the listing\'s seed-row warning does not fire for it', async () => {
226+
const h = await restartWith([REFUSED, LOADED], dir);
227+
const listing = await h.list();
228+
expect(listing.status).toBe(200);
229+
// Control: the double sees the listing's reads; the loadable entry's rows were read.
230+
expect(h.reads).toContain('qa_cur_account');
231+
// One expectation, so a regression shows both facts at once.
232+
expect({
233+
readsOfMarkedEntry: h.reads.filter((object) => object === 'qa_old_account'),
234+
seedWarnings: seedWarnings(h.logger),
235+
}).toEqual({ readsOfMarkedEntry: [], seedWarnings: [] });
236+
});
237+
238+
it('a loadable entry is unchanged: its rows answer withSampleData, and its item is the one served without the refused entry', async () => {
239+
const h = await restartWith([REFUSED, LOADED], dir);
240+
const beside = (await h.list()).byId.get(LOADED.id);
241+
expect(beside).toEqual({
242+
packageId: LOADED.id,
243+
versionId: '1.0.0',
244+
manifestId: LOADED.id,
245+
version: '1.0.0',
246+
installedAt: '2026-01-01T00:00:00.000Z',
247+
withSampleData: true,
248+
installedBy: INSTALLER_USER_ID,
249+
});
250+
expect(beside).not.toHaveProperty('notLoaded');
251+
252+
const alone = mkdtempSync(join(tmpdir(), 'mil-not-loaded-alone-'));
253+
try {
254+
const solo = await restartWith([LOADED], alone);
255+
expect(JSON.stringify((await solo.list()).byId.get(LOADED.id))).toBe(JSON.stringify(beside));
256+
} finally {
257+
rmSync(alone, { recursive: true, force: true });
258+
}
259+
});
260+
261+
it('the narrowed caller sees the marker too, without the two operator fields', async () => {
262+
const h = await restartWith([REFUSED, LOADED], dir);
263+
h.caller.as = 'member';
264+
const listing = await h.list();
265+
expect(listing.status).toBe(200);
266+
expect(listing.data).not.toHaveProperty('storageDir');
267+
expect(listing.byId.get(REFUSED.id)).toEqual({
268+
packageId: REFUSED.id,
269+
versionId: '1.0.0',
270+
manifestId: REFUSED.id,
271+
version: '1.0.0',
272+
installedAt: '2026-01-01T00:00:00.000Z',
273+
notLoaded: { code: 'OS_PROTOCOL_INCOMPATIBLE', requiredRange: OLD_RANGE },
274+
});
275+
});
276+
277+
it('DELETE on the marked entry still works, and the listing then serves the loadable entry alone', async () => {
278+
const h = await restartWith([REFUSED, LOADED], dir);
279+
const res = await h.uninstall(REFUSED.id);
280+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
281+
expect(res.payload.data.manifestId).toBe(REFUSED.id);
282+
expect(new LocalManifestSource(dir).has(REFUSED.id)).toBe(false);
283+
const listing = await h.list();
284+
expect(listing.data.total).toBe(1);
285+
expect([...listing.byId.keys()]).toEqual([LOADED.id]);
286+
});
287+
288+
it('a compatible version installed over the marked entry is listed as loaded, with no marker', async () => {
289+
const h = await restartWith([REFUSED, LOADED], dir);
290+
const { data: _seed, ...withoutSeed } = REFUSED;
291+
const res = await h.install({ manifest: { ...withoutSeed, version: '2.0.0', engines: { protocol: CURRENT_RANGE } } });
292+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
293+
expect(h.registered()).toContain(REFUSED.id);
294+
const item = (await h.list()).byId.get(REFUSED.id);
295+
expect(item).toMatchObject({ manifestId: REFUSED.id, version: '2.0.0', withSampleData: false });
296+
expect(item).not.toHaveProperty('notLoaded');
297+
});
298+
});

0 commit comments

Comments
 (0)