From e36980475c27af40066d540061d267c623d88602 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:25:23 +0000 Subject: [PATCH 1/6] fix(cloud-connection): the install-local listing marks an entry the rehydrate refused as not loaded The kernel:ready rehydrate records each ledger entry it refuses under the ADR-0087 D1 handshake, by manifest id and installedAt. GET /install-local lists that entry with notLoaded { code, requiredRange } in place of withSampleData, and reads no seed rows for it. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-authored-by: Claude --- .../src/marketplace-install-local-plugin.ts | 116 +++++++++++++++--- 1 file changed, 99 insertions(+), 17 deletions(-) diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 50a71c2f32..411ad7a9d1 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -47,7 +47,10 @@ * outright (#8976). Each entry's `withSampleData` says whether the * caller's own scope — its active organization under a wall — * holds any of the package's seed rows, read from the rows, never - * from the install-wide ledger record (#21775). + * from the install-wide ledger record (#21775). An entry this boot's + * rehydrate refused to load is still listed, with a `notLoaded` + * marker in place of `withSampleData` carrying the refusal's code + * and the declared range, and no seed row is read for it (#21822). * * DELETE /api/v1/marketplace/install-local/:manifestId * → removes the cached manifest, withdraws the package from the @@ -66,7 +69,8 @@ * cached manifest so installs survive process restarts without further * cloud round-trips. An entry whose `engines.protocol` excludes this runtime's * major is not loaded: it is reported at `error`, naming the replay command, - * and the boot continues (ADR-0087 D1, #21762). + * and the boot continues (ADR-0087 D1, #21762). The GET listing marks it as + * not loaded for as long as that ledger entry stands (#21822). */ import type { Plugin, PluginContext } from '@objectstack/core'; @@ -313,6 +317,21 @@ function seedDatasetsOf(manifest: any): PurgeSeedDataset[] { : []; } +/** + * [#21822] The marker the GET listing puts on a ledger entry this boot's + * `kernel:ready` rehydrate refused to load: the refusal's `code` and the range + * the package declares. + * + * CLOSED: exactly these two members, named one by one from the handshake's own + * diagnostic, the way `protocolIncompatibleAnswer` names its `details`. ⛔ Not a + * spread of the diagnostic: a member added to it later does not reach the + * listing without a decision here. + */ +interface NotLoadedMarker { + code: ProtocolIncompatibleDiagnostic['code']; + requiredRange: ProtocolIncompatibleDiagnostic['requiredRange']; +} + /** * [ADR-0093 D4/D5, ADR-0105 D1 / #5262] Is an organization wall actually IN * FORCE for this boot? Both seeding decisions in this plugin key off it. @@ -417,6 +436,22 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * install whose ledger entry went missing. */ private readonly bootUserCodeIds = new Set(); + /** + * [#21822] What this boot's `kernel:ready` rehydrate refused to load under + * ADR-0087 D1's handshake, keyed by manifest id: the refused entry's + * `installedAt` and the marker the GET listing serves for it. + * + * The record of what the rehydrate DID, written by it alone, never a second + * judgement of the ledger: the listing does not re-run the handshake. + * + * `installedAt` names WHICH entry was refused. Only the install door writes + * a new entry for a manifest id, always with a fresh `installedAt`, and only + * after the same handshake admitted it. So once a compatible version + * replaces the refused entry, the record no longer matches the ledger and + * the listing stops marking it — with no write to this record from the + * install door or from DELETE. + */ + private readonly refusedAtRehydrate = new Map(); constructor(config: MarketplaceInstallLocalPluginConfig = {}) { this.cloudUrl = resolveCloudUrl(config.controlPlaneUrl); @@ -495,6 +530,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * a marketplace package). */ private rehydrate = async (ctx: PluginContext): Promise => { + // [#21822] The record describes THIS rehydrate, and nothing older. + this.refusedAtRehydrate.clear(); const { entries, skipped } = this.readAll(); // #5413 — BEFORE the early return, not after. A ledger whose entries @@ -530,6 +567,12 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // unrecognised range rehydrates exactly as it did before. const compat = checkProtocolCompat(entry.manifest); if (compat.status === 'incompatible') { + // [#21822] Recorded where the GET listing reads it, so the + // entry is listed as not loaded rather than as installed. + this.refusedAtRehydrate.set(entry.manifestId, { + installedAt: entry.installedAt, + marker: { code: compat.diagnostic.code, requiredRange: compat.diagnostic.requiredRange }, + }); this.reportProtocolIncompatibleEntry(ctx, entry, compat.diagnostic); continue; } @@ -1364,6 +1407,25 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * Each entry's `withSampleData` answers "does MY scope hold this package's * sample data?" — derived per request by {@link sampleDataInScope}, never * read from the ledger's install-wide record of the same name. + * + * ## [#21822] An entry the rehydrate refused is listed, marked not loaded + * + * The `kernel:ready` rehydrate keeps an entry whose declared protocol range + * excludes this runtime in the ledger, and loads none of it. The listing + * served it like any installed package, so the console's Installed Apps + * said "installed" while only a boot log line said otherwise. It is listed + * still — DELETE and a compatible re-install act on it, and omitting it + * would leave the operator nothing to uninstall from — with a marker: + * + * { …, "installedAt": "…", "notLoaded": { "code": "OS_PROTOCOL_INCOMPATIBLE", "requiredRange": "^16" } } + * + * read from what the rehydrate recorded ({@link refusedAtRehydrate}). + * `notLoaded` stands in place of `withSampleData`: none of the package's + * objects are registered, so no seed row is read for it and the listing + * makes no claim about its rows — omitted, not `false`, for the reason + * `installedBy` is omitted above. A loaded entry is served exactly as before, + * with no `notLoaded` key. The marker is served to every authenticated + * caller: it says what the runtime holds, not who installed it or where. */ private handleList = async (c: any, ctx: PluginContext): Promise => { // Before the ledger is touched, exactly as the mutating doors refuse @@ -1375,25 +1437,44 @@ export class MarketplaceInstallLocalPlugin implements Plugin { const { entries, skipped } = this.readAll(); this.warnSkippedLedgerEntries(ctx, skipped, 'it is MISSING from the installed-apps list served to the console'); - const withSampleData = await this.sampleDataInScope(c, ctx, entries); + const notLoaded = new Map(); + for (const e of entries) { + const marker = this.notLoadedMarker(e); + if (marker) notLoaded.set(e.manifestId, marker); + } + const withSampleData = await this.sampleDataInScope(c, ctx, entries.filter((e) => !notLoaded.has(e.manifestId))); return c.json({ success: true, data: { - items: entries.map(e => ({ - packageId: e.packageId, - versionId: e.versionId, - manifestId: e.manifestId, - version: e.version, - installedAt: e.installedAt, - withSampleData: withSampleData.has(e.manifestId), - ...(operator ? { installedBy: e.installedBy } : {}), - })), + items: entries.map(e => { + const marker = notLoaded.get(e.manifestId); + return { + packageId: e.packageId, + versionId: e.versionId, + manifestId: e.manifestId, + version: e.version, + installedAt: e.installedAt, + ...(marker ? { notLoaded: marker } : { withSampleData: withSampleData.has(e.manifestId) }), + ...(operator ? { installedBy: e.installedBy } : {}), + }; + }), total: entries.length, ...(operator ? { storageDir: this.storageDir } : {}), }, }, 200); }; + /** + * [#21822] The not-loaded marker for a listed ledger entry, or `undefined` + * for one this boot loaded. Marked only when the rehydrate refused THIS + * entry: same manifest id, same `installedAt` (see {@link refusedAtRehydrate}). + */ + private notLoadedMarker = (entry: InstalledEntry): NotLoadedMarker | undefined => { + const refused = this.refusedAtRehydrate.get(entry.manifestId); + if (!refused || refused.installedAt !== entry.installedAt) return undefined; + return { ...refused.marker }; + }; + /** * [#21775] The listed entries whose sample data is in the CALLER'S scope — * the set the listing answers each entry's `withSampleData` from. @@ -2740,11 +2821,12 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * [#21762] The one line a rehydrate prints for a ledger entry it refuses to * load under ADR-0087 D1's handshake. * - * `error`, not `warn`: the ledger says the package is installed (the GET - * listing still serves it) while the running kernel holds none of it, so - * persisted and runtime state disagree with nothing else saying so. The - * line owes the consequence and the fix, and carries the handshake's own - * message, which names the replay command (`objectstack migrate meta`). + * `error`, not `warn`: the ledger says the package is installed while the + * running kernel holds none of it, so persisted and runtime state disagree. + * The GET listing still serves the entry, marked not loaded (#21822), to + * whoever reads it; this line is what the boot itself says. The line owes + * the consequence and the fix, and carries the handshake's own message, + * which names the replay command (`objectstack migrate meta`). */ private reportProtocolIncompatibleEntry = ( ctx: PluginContext, From ccee756cc6951307d19d50dba75e139c7e0e1aa7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:27:06 +0000 Subject: [PATCH 2/6] test(cloud-connection): pin the install-local listing's not-loaded marker Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-authored-by: Claude --- ...e-install-local-listing-not-loaded.test.ts | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts diff --git a/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts b/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts new file mode 100644 index 0000000000..c4803f75e8 --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts @@ -0,0 +1,289 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21822] `GET /api/v1/marketplace/install-local` lists a ledger entry the + * `kernel:ready` rehydrate refused to load with a not-loaded marker. + * + * ## The defect this file pins shut + * + * The rehydrate keeps an entry whose `engines.protocol` excludes this runtime + * in the ledger and loads none of it (ADR-0087 D1, #21762). The listing then + * served that entry exactly like a loaded one, `withSampleData` included, so + * the console's Installed Apps said "installed"; and since #21775 each GET also + * tried to read the package's seed rows from objects nobody registered, and + * logged one `warn` per request saying it could not. + * + * ## What these cases pin (triage ruling `5988934231`) + * + * - after a restart whose rehydrate refused an entry, the listing serves it + * with `notLoaded: { code, requiredRange }`, a CLOSED pair, in place of + * `withSampleData`, to the operator and the narrowed caller alike; + * - no seed row is read for the marked entry and the listing's + * "could not read this package's seed rows" warning does not fire; + * - a loadable entry is served unchanged: its rows are read and its item is + * the one a ledger without the refused entry serves; + * - DELETE on the marked entry still works, and a compatible version + * installed over it is listed as loaded. + * + * A "restart" here is a fresh plugin mounted over a ledger that already holds + * the entries, through its real `start()` + `kernel:ready`. The real-boot pin + * across a restart is `packages/qa/dogfood/test/install-local-listing-not-loaded.dogfood.test.ts`. + * + * Ranges derive from the running protocol, never literals. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; +// The rehydrate binds handlers and the listing builds its seed graph through +// `@objectstack/runtime` (lazy `import()`s inside the plugin). Its first load +// is paid here, at module top, never inside a clocked `it`. +import '@objectstack/runtime'; +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js'; +import { installerGrantRows, INSTALLER_USER_ID } from './install-local-principal.fixtures.js'; + +type Handler = (c: any) => Promise; +type Row = Record & { id: string }; + +const ROUTE = '/api/v1/marketplace/install-local'; +const OLD_RANGE = `^${PROTOCOL_MAJOR - 1}`; +const CURRENT_RANGE = `^${PROTOCOL_MAJOR}`; +const SEED_WARNING = 'the installed-apps listing could not read this package\'s seed rows'; + +/** Installed for the previous protocol major: the rehydrate refuses it. */ +const REFUSED = { + id: 'com.example.qaold21822', name: 'Old', version: '1.0.0', type: 'app', scope: 'project', + engines: { protocol: OLD_RANGE }, + objects: [{ name: 'qa_old_account', fields: { name: { type: 'text' } } }], + data: [{ object: 'qa_old_account', externalId: 'name', records: [{ name: 'Acme' }] }], +}; +/** Built for this protocol: the rehydrate loads it. */ +const LOADED = { + id: 'com.example.qacurrent21822', name: 'Current', version: '1.0.0', type: 'app', scope: 'project', + engines: { protocol: CURRENT_RANGE }, + objects: [{ name: 'qa_cur_account', fields: { name: { type: 'text' } } }], + data: [{ object: 'qa_cur_account', externalId: 'name', records: [{ name: 'Beta' }] }], +}; + +function ledgerEntry(manifest: { id: string; version: string }): InstalledManifestEntry { + return { + packageId: manifest.id, + versionId: manifest.version, + manifestId: manifest.id, + version: manifest.version, + manifest, + installedAt: '2026-01-01T00:00:00.000Z', + installedBy: INSTALLER_USER_ID, + withSampleData: true, + }; +} + +/** A tenant administrator that does NOT hold `manage_metadata`: the narrowed caller. */ +const MEMBER_ID = 'usr_member'; +const MEMBER_GRANTS: Record = { + sys_user: [{ id: MEMBER_ID, email: 'member@objectstack.test' }], + sys_member: [], + sys_user_position: [], + sys_position: [], + sys_position_permission_set: [], + sys_user_permission_set: [{ id: 'ups_member', user_id: MEMBER_ID, permission_set_id: 'ps_member', organization_id: null }], + sys_permission_set: [{ id: 'ps_member', name: 'organization_admin', system_permissions: ['setup.access', 'manage_org_users'] }], +}; + +/** + * Mount the plugin over a ledger that already holds `manifests`, the way a + * restarted runtime meets it. The engine answers only for objects a package + * REGISTERED, the way the real one answers `Object '…' not found` for a + * refused package's objects, and it records every read. + */ +async function restartWith(manifests: Array<{ id: string; version: string }>, dir: string) { + for (const m of manifests) new LocalManifestSource(dir).write(ledgerEntry(m)); + // Both packages' seed rows are in the database: the refused package's + // were written while an earlier runtime still loaded it. + const tables: Record = { + qa_old_account: [{ id: 'o1', name: 'Acme' }], + qa_cur_account: [{ id: 'c1', name: 'Beta' }], + }; + const objects = new Map(); + const reads: string[] = []; + const caller = { as: 'operator' as 'operator' | 'member' }; + const grants = () => (caller.as === 'operator' ? installerGrantRows() : MEMBER_GRANTS); + const engine = { + syncSchemas: vi.fn(async () => undefined), + registry: { getAllPackages: () => [] }, + async find(object: string, query?: any): Promise { + const granted = grants(); + if (Object.prototype.hasOwnProperty.call(granted, object)) return granted[object]!; + reads.push(object); + if (!objects.has(object)) throw new Error(`Object '${object}' not found`); + const where: Record = query?.where ?? {}; + const rows = (tables[object] ?? []).filter((row) => Object.entries(where).every(([k, v]) => row[k] === v)); + return (typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows).map((row) => ({ ...row })); + }, + }; + const register = vi.fn((m: any) => { for (const o of m?.objects ?? []) objects.set(o.name, o); }); + const services: Record = { + manifest: { register }, + auth: { api: { getSession: async () => ({ user: { id: caller.as === 'operator' ? INSTALLER_USER_ID : MEMBER_ID }, session: {} }) } }, + objectql: engine, + metadata: { getObject: async (name: string) => objects.get(name) }, + }; + const routes = new Map(); + const rawApp = { + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; + const hooks = new Map(); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const ctx = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + logger, + }; + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + await plugin.start(ctx as any); + await hooks.get('kernel:ready')?.(); + return { + caller, + reads, + logger, + registered: () => register.mock.calls.map(([m]) => m?.id), + list: async () => { + reads.length = 0; + logger.warn.mockClear(); + const res = await routes.get(`GET ${ROUTE}`)!(makeC()); + return { status: res.status, data: res.payload?.data, byId: new Map((res.payload?.data?.items ?? []).map((i: any) => [i.manifestId, i])) }; + }, + uninstall: (manifestId: string) => routes.get(`DELETE ${ROUTE}/:manifestId`)!(makeC(undefined, { manifestId })), + install: (body: unknown) => routes.get(`POST ${ROUTE}`)!(makeC(body)), + }; +} + +function makeC(body?: unknown, params: Record = {}) { + return { + req: { + url: `http://localhost:3000${ROUTE}`, + raw: new Request(`http://localhost:3000${ROUTE}`), + json: async () => body, + param: (k: string) => params[k], + header: () => undefined, + }, + json: vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })), + }; +} + +const seedWarnings = (logger: { warn: { mock: { calls: unknown[][] } } }) => + logger.warn.mock.calls.map(([m]) => String(m)).filter((m) => m.includes(SEED_WARNING)); + +let dir: string; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-not-loaded-')); }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); }); + +describe('GET install-local after a restart whose rehydrate refused an entry', () => { + it('PRECONDITION: the rehydrate refused the old entry and loaded the current one', async () => { + const h = await restartWith([REFUSED, LOADED], dir); + expect(h.registered()).toContain(LOADED.id); + expect(h.registered()).not.toContain(REFUSED.id); + const said = h.logger.error.mock.calls.map(([m]) => String(m)); + expect(said).toHaveLength(1); + expect(said[0]).toContain(`OS_PROTOCOL_INCOMPATIBLE: ${REFUSED.id}@1.0.0 is NOT loaded`); + }); + + it('lists the refused entry with the marker and the code: notLoaded { code, requiredRange }, in place of withSampleData', async () => { + const h = await restartWith([REFUSED, LOADED], dir); + const listing = await h.list(); + expect(listing.status).toBe(200); + expect(listing.data.total).toBe(2); + expect(listing.byId.get(REFUSED.id)).toEqual({ + packageId: REFUSED.id, + versionId: '1.0.0', + manifestId: REFUSED.id, + version: '1.0.0', + installedAt: '2026-01-01T00:00:00.000Z', + notLoaded: { code: 'OS_PROTOCOL_INCOMPATIBLE', requiredRange: OLD_RANGE }, + installedBy: INSTALLER_USER_ID, + }); + // CLOSED: exactly the two members, nothing else of the diagnostic. + expect(Object.keys(listing.byId.get(REFUSED.id).notLoaded).sort()).toEqual(['code', 'requiredRange']); + }); + + it('reads no seed row for the marked entry, and the listing\'s seed-row warning does not fire for it', async () => { + const h = await restartWith([REFUSED, LOADED], dir); + const listing = await h.list(); + expect(listing.status).toBe(200); + // Control: the double sees the listing's reads; the loadable entry's rows were read. + expect(h.reads).toContain('qa_cur_account'); + expect(h.reads).not.toContain('qa_old_account'); + expect(seedWarnings(h.logger)).toEqual([]); + }); + + it('a loadable entry is unchanged: its rows answer withSampleData, and its item is the one served without the refused entry', async () => { + const h = await restartWith([REFUSED, LOADED], dir); + const beside = (await h.list()).byId.get(LOADED.id); + expect(beside).toEqual({ + packageId: LOADED.id, + versionId: '1.0.0', + manifestId: LOADED.id, + version: '1.0.0', + installedAt: '2026-01-01T00:00:00.000Z', + withSampleData: true, + installedBy: INSTALLER_USER_ID, + }); + expect(beside).not.toHaveProperty('notLoaded'); + + const alone = mkdtempSync(join(tmpdir(), 'mil-not-loaded-alone-')); + try { + const solo = await restartWith([LOADED], alone); + expect(JSON.stringify((await solo.list()).byId.get(LOADED.id))).toBe(JSON.stringify(beside)); + } finally { + rmSync(alone, { recursive: true, force: true }); + } + }); + + it('the narrowed caller sees the marker too, without the two operator fields', async () => { + const h = await restartWith([REFUSED, LOADED], dir); + h.caller.as = 'member'; + const listing = await h.list(); + expect(listing.status).toBe(200); + expect(listing.data).not.toHaveProperty('storageDir'); + expect(listing.byId.get(REFUSED.id)).toEqual({ + packageId: REFUSED.id, + versionId: '1.0.0', + manifestId: REFUSED.id, + version: '1.0.0', + installedAt: '2026-01-01T00:00:00.000Z', + notLoaded: { code: 'OS_PROTOCOL_INCOMPATIBLE', requiredRange: OLD_RANGE }, + }); + }); + + it('DELETE on the marked entry still works, and the listing then serves the loadable entry alone', async () => { + const h = await restartWith([REFUSED, LOADED], dir); + const res = await h.uninstall(REFUSED.id); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(res.payload.data.manifestId).toBe(REFUSED.id); + expect(new LocalManifestSource(dir).has(REFUSED.id)).toBe(false); + const listing = await h.list(); + expect(listing.data.total).toBe(1); + expect([...listing.byId.keys()]).toEqual([LOADED.id]); + }); + + it('a compatible version installed over the marked entry is listed as loaded, with no marker', async () => { + const h = await restartWith([REFUSED, LOADED], dir); + const { data: _seed, ...withoutSeed } = REFUSED; + const res = await h.install({ manifest: { ...withoutSeed, version: '2.0.0', engines: { protocol: CURRENT_RANGE } } }); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(h.registered()).toContain(REFUSED.id); + const item = (await h.list()).byId.get(REFUSED.id); + expect(item).toMatchObject({ manifestId: REFUSED.id, version: '2.0.0', withSampleData: false }); + expect(item).not.toHaveProperty('notLoaded'); + }); +}); From 4f2057dba6218d0f2e569e6ff9f581741663c87f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:32:18 +0000 Subject: [PATCH 3/6] test(cloud-connection): one expectation for the marked entry's reads and warnings Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-authored-by: Claude --- .../marketplace-install-local-listing-not-loaded.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts b/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts index c4803f75e8..09f37df30b 100644 --- a/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts @@ -222,8 +222,11 @@ describe('GET install-local after a restart whose rehydrate refused an entry', ( expect(listing.status).toBe(200); // Control: the double sees the listing's reads; the loadable entry's rows were read. expect(h.reads).toContain('qa_cur_account'); - expect(h.reads).not.toContain('qa_old_account'); - expect(seedWarnings(h.logger)).toEqual([]); + // One expectation, so a regression shows both facts at once. + expect({ + readsOfMarkedEntry: h.reads.filter((object) => object === 'qa_old_account'), + seedWarnings: seedWarnings(h.logger), + }).toEqual({ readsOfMarkedEntry: [], seedWarnings: [] }); }); it('a loadable entry is unchanged: its rows answer withSampleData, and its item is the one served without the refused entry', async () => { From 3fa8b7814941ddc5efc018d17a92682e4da1b311 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:33:53 +0000 Subject: [PATCH 4/6] test(dogfood): pin the install-local listing's not-loaded marker on a real boot across a restart Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-authored-by: Claude --- ...l-local-listing-not-loaded.dogfood.test.ts | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 packages/qa/dogfood/test/install-local-listing-not-loaded.dogfood.test.ts diff --git a/packages/qa/dogfood/test/install-local-listing-not-loaded.dogfood.test.ts b/packages/qa/dogfood/test/install-local-listing-not-loaded.dogfood.test.ts new file mode 100644 index 0000000000..11d96899db --- /dev/null +++ b/packages/qa/dogfood/test/install-local-listing-not-loaded.dogfood.test.ts @@ -0,0 +1,199 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// GOLDEN REGRESSION — after a restart whose `kernel:ready` rehydrate refused a +// protocol-incompatible package, `GET /api/v1/marketplace/install-local` lists +// it with a not-loaded marker carrying the refusal's code and the declared +// range, reads none of its seed rows, and DELETE still removes it (#21822). +// +// ## What was measured before the fix +// +// On an unwalled real boot (showcase + this plugin, `databaseFile`), after a +// restart whose rehydrate refused `com.example.crm` (its ledger entry declared +// the previous protocol major): `200`, the entry listed with the same fields as +// a loaded package, nothing saying it was not loaded — only the boot's `error` +// line did — and each GET logged one `warn` that the listing could not read the +// package's seed rows (`Object 'crm_account' not found`). +// +// ## What this file pins, on two real boots over one database file and one ledger +// +// 1. PRECONDITIONS: boot 1 installed the CRM package with its 28 seed rows +// and a small loadable package; between the boots the CRM ledger entry is +// made to declare the previous protocol major — an install made for an +// older runtime — and boot 2's rehydrate refuses it. +// 2. The listing on boot 2 serves the CRM entry with +// `notLoaded: { code: 'OS_PROTOCOL_INCOMPATIBLE', requiredRange }` in place +// of `withSampleData`; the loadable package's item is byte-identical to its +// boot-1 item. +// 3. That GET logs no seed-row warning. Control: an unreadable ledger file +// planted before boot 2 makes the same GET log its own `warn` through the +// same logger, so the capture is shown to see that level in that window. +// 4. DELETE on the marked entry answers 200, and the listing then serves the +// loadable package alone. +// +// Boots its own showcase stack, twice, so it stays out of `SHARED_SHOWCASE`. + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import crmStack from '@objectstack/example-crm'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { LocalManifestSource, MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; +import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; +import { buildShapedArtifact } from './build-shaped-artifact.js'; + +const CRM = 'com.example.crm'; +/** A package with nothing to refuse: built for this protocol, no objects, no seed data. */ +const LOADABLE = 'com.example.qa21822'; +const BASE = '/marketplace/install-local'; +const OLD_RANGE = `^${PROTOCOL_MAJOR - 1}`; +const SEED_WARNING = 'the installed-apps listing could not read this package\'s seed rows'; +const UNREADABLE_FILE = 'qa-21822-unreadable.json'; + +/** The install body `os package install .json` sends. */ +function crmInstallBody(): { manifest: Record } { + const { artifact } = buildShapedArtifact(crmStack as unknown as Record); + const manifest = artifact.manifest as { id?: string; version?: string }; + return { manifest: { ...artifact, id: manifest.id, version: manifest.version } }; +} + +const loadableInstallBody = { + manifest: { id: LOADABLE, name: 'qa_21822', version: '1.0.0', type: 'app', scope: 'project', engines: { protocol: `^${PROTOCOL_MAJOR}` } }, +}; + +async function json(res: Response): Promise<{ status: number; body: any }> { // eslint-disable-line @typescript-eslint/no-explicit-any + return { status: res.status, body: await res.json().catch(() => null) }; +} + +/** Every line the process writes while `run` is in flight — the kernel logger writes to the streams. */ +async function captureOutput(run: () => Promise): Promise<{ value: T; lines: string[] }> { + const lines: string[] = []; + const stdout = process.stdout.write.bind(process.stdout); + const stderr = process.stderr.write.bind(process.stderr); + const warn = console.warn; + const error = console.error; + (process.stdout as any).write = (chunk: unknown, ...rest: any[]) => { lines.push(String(chunk)); return stdout(chunk as any, ...rest); }; // eslint-disable-line @typescript-eslint/no-explicit-any + (process.stderr as any).write = (chunk: unknown, ...rest: any[]) => { lines.push(String(chunk)); return stderr(chunk as any, ...rest); }; // eslint-disable-line @typescript-eslint/no-explicit-any + console.warn = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); warn(...args); }; + console.error = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); error(...args); }; + try { + return { value: await run(), lines }; + } finally { + (process.stdout as any).write = stdout; // eslint-disable-line @typescript-eslint/no-explicit-any + (process.stderr as any).write = stderr; // eslint-disable-line @typescript-eslint/no-explicit-any + console.warn = warn; + console.error = error; + } +} + +type Item = Record & { manifestId: string }; +const itemsOf = (listing: { body: any }): Item[] => listing.body?.data?.items ?? []; // eslint-disable-line @typescript-eslint/no-explicit-any +const itemOf = (listing: { body: any }, manifestId: string): Item | undefined => itemsOf(listing).find((i) => i.manifestId === manifestId); // eslint-disable-line @typescript-eslint/no-explicit-any + +describe('dogfood: the install-local listing marks a package the restart refused to load as not loaded (#21822)', () => { + let stack: VerifyStack | undefined; + let storageDir: string; + let dbDir: string; + let crmInstall: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any + let loadableInstall: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any + let crmVersion: string; + let beforeRestart: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any + let bootLines: string[]; + let afterRestart: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any + let listLines: string[]; + let uninstall: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any + let afterUninstall: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any + + const boot = (databaseFile: string) => bootStack(showcaseStack, { + databaseFile, + extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], + }); + + beforeAll(async () => { + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-not-loaded-ledger-')); + dbDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-not-loaded-db-')); + const databaseFile = join(dbDir, 'verify.db'); + + // ── boot 1: install the CRM package and a loadable one ───────────────── + stack = await boot(databaseFile); + let token = await stack.signIn(); + crmInstall = await json(await stack.apiAs(token, 'POST', BASE, crmInstallBody())); + loadableInstall = await json(await stack.apiAs(token, 'POST', BASE, loadableInstallBody)); + beforeRestart = await json(await stack.apiAs(token, 'GET', BASE)); + await stack.stop(); + stack = undefined; + + // ── between the boots: the CRM install now declares the previous major ─ + // (an install made for an older runtime), and the ledger holds one file + // that does not parse — the capture control for the listing's warn line. + const ledger = new LocalManifestSource(storageDir); + const crm = ledger.read(CRM).entry!; + crmVersion = crm.version; + ledger.write({ ...crm, manifest: { ...crm.manifest, engines: { ...(crm.manifest?.engines ?? {}), protocol: OLD_RANGE } } }); + writeFileSync(join(storageDir, UNREADABLE_FILE), '{'); + + // ── boot 2: the same database file and ledger ────────────────────────── + const booted = await captureOutput(() => boot(databaseFile)); + stack = booted.value; + bootLines = booted.lines; + token = await stack.signIn(); + const listed = await captureOutput(async () => json(await stack!.apiAs(token, 'GET', BASE))); + afterRestart = listed.value; + listLines = listed.lines; + uninstall = await json(await stack.apiAs(token, 'DELETE', `${BASE}/${CRM}`)); + afterUninstall = await json(await stack.apiAs(token, 'GET', BASE)); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + if (storageDir) rmSync(storageDir, { recursive: true, force: true }); + if (dbDir) rmSync(dbDir, { recursive: true, force: true }); + }); + + it('PRECONDITION: boot 1 installed the CRM package with its seed rows, and the loadable package', () => { + expect(crmInstall.status, JSON.stringify(crmInstall.body)).toBe(200); + expect(crmInstall.body?.data?.seeded).toMatchObject({ mode: 'inline', inserted: 28 }); + expect(loadableInstall.status, JSON.stringify(loadableInstall.body)).toBe(200); + expect(beforeRestart.status).toBe(200); + expect(itemOf(beforeRestart, CRM)).toMatchObject({ withSampleData: true }); + expect(itemOf(beforeRestart, CRM)).not.toHaveProperty('notLoaded'); + }); + + it('PRECONDITION: boot 2\'s rehydrate refused the CRM package', () => { + const refused = bootLines.filter((l) => l.includes(`OS_PROTOCOL_INCOMPATIBLE: ${CRM}@${crmVersion} is NOT loaded`)); + expect(refused, 'the rehydrate\'s refusal line').toHaveLength(1); + }); + + it('lists the refused package with the marker and the code, in place of withSampleData', () => { + expect(afterRestart.status, JSON.stringify(afterRestart.body)).toBe(200); + expect(afterRestart.body?.data?.total).toBe(2); + const crm = itemOf(afterRestart, CRM); + expect(crm?.notLoaded).toEqual({ code: 'OS_PROTOCOL_INCOMPATIBLE', requiredRange: OLD_RANGE }); + expect(crm).not.toHaveProperty('withSampleData'); + const { withSampleData: _was, ...unchangedFields } = itemOf(beforeRestart, CRM)!; + expect(crm).toEqual({ ...unchangedFields, notLoaded: { code: 'OS_PROTOCOL_INCOMPATIBLE', requiredRange: OLD_RANGE } }); + }); + + it('a loadable entry is unchanged: its item is byte-identical to the one boot 1 served', () => { + expect(JSON.stringify(itemOf(afterRestart, LOADABLE))).toBe(JSON.stringify(itemOf(beforeRestart, LOADABLE))); + expect(itemOf(afterRestart, LOADABLE)).toMatchObject({ withSampleData: false }); + }); + + it('control: the GET\'s capture holds the listing\'s own warn for the unreadable ledger file', () => { + expect(listLines.some((l) => l.includes(`unreadable ledger entry ${UNREADABLE_FILE}`))).toBe(true); + }); + + it('the GET logs no seed-row warning for the refused package', () => { + expect(listLines.filter((l) => l.includes(SEED_WARNING))).toEqual([]); + }); + + it('DELETE on the marked entry still works, and the listing then serves the loadable package alone', () => { + expect(uninstall.status, JSON.stringify(uninstall.body)).toBe(200); + expect(uninstall.body?.data?.manifestId).toBe(CRM); + expect(afterUninstall.status).toBe(200); + expect(itemsOf(afterUninstall).map((i) => i.manifestId)).toEqual([LOADABLE]); + expect(new LocalManifestSource(storageDir).has(CRM)).toBe(false); + }); +}); From bb9cc933541efc95e158d93925176b9525fd7bd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:34:37 +0000 Subject: [PATCH 5/6] chore(changeset): cloud-connection patch for the listing's not-loaded marker Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-authored-by: Claude --- .../21822-install-local-listing-not-loaded-marker.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/21822-install-local-listing-not-loaded-marker.md diff --git a/.changeset/21822-install-local-listing-not-loaded-marker.md b/.changeset/21822-install-local-listing-not-loaded-marker.md new file mode 100644 index 0000000000..47566e2d24 --- /dev/null +++ b/.changeset/21822-install-local-listing-not-loaded-marker.md @@ -0,0 +1,12 @@ +--- +"@objectstack/cloud-connection": patch +--- + +`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. + +Clause-②: no + +- **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. +- **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. +- **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. +- **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. From 3681793122c73909ef9bdb707e78b44fd16f3ede Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:02:09 +0000 Subject: [PATCH 6/6] test(cloud-connection): the listing double refuses a where it does not implement Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-authored-by: Claude --- .../marketplace-install-local-listing-not-loaded.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts b/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts index 09f37df30b..d6fac67bce 100644 --- a/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts @@ -120,7 +120,13 @@ async function restartWith(manifests: Array<{ id: string; version: string }>, di reads.push(object); if (!objects.has(object)) throw new Error(`Object '${object}' not found`); const where: Record = query?.where ?? {}; - const rows = (tables[object] ?? []).filter((row) => Object.entries(where).every(([k, v]) => row[k] === v)); + const rows = (tables[object] ?? []).filter((row) => Object.entries(where).every(([k, v]) => { + // Scalar equality only; anything else is refused, never guessed. + if (k.startsWith('$') || (v !== null && typeof v === 'object')) { + throw new Error(`only scalar equality is implemented here (got '${k}')`); + } + return row[k] === v; + })); return (typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows).map((row) => ({ ...row })); }, };