diff --git a/.changeset/28604-install-local-rehydrate-lifted-entry.md b/.changeset/28604-install-local-rehydrate-lifted-entry.md new file mode 100644 index 00000000000..9d08aea7190 --- /dev/null +++ b/.changeset/28604-install-local-rehydrate-lifted-entry.md @@ -0,0 +1,14 @@ +--- +'@objectstack/cloud-connection': patch +--- + +fix(cloud-connection)!: install-local judges a ledger entry stored in the CLI's lifted shape through the install door's flatten, at the boot rehydrate and on the sample-data doors (#28604) + +Clause-②: no (narrowing) — at `kernel:ready` the install-local rehydrate no longer loads, and `POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data` and `/purge-sample-data` answer 422 `OS_PROTOCOL_INCOMPATIBLE` for, a ledger entry written by an install before #28535 in the body `os package install ` sends (`id` and `version` lifted beside a nested `manifest`) whose nested `engines.protocol` excludes this runtime. Such an entry loaded and those doors acted on it before. A flat entry with the same range was already refused the same way. Nothing is widened. +Metadata-protocol: none + + + +**BREAKING** boot-load and accept-set narrowing for already-ledgered install-local entries (`@objectstack/cloud-connection`): the `kernel:ready` rehydrate and the two sample-data doors, reached by a deployment where `os package install ./dist/objectstack.json` ran before #28535. + +An install before #28535 ledgered the CLI's body unflattened, so the entry's `engines` and `dependencies` sat one level down, and the rehydrate and the sample-data doors judged `engines.protocol` at the top: an entry built for another protocol was re-registered at every boot with no error. They now read the entry through the install door's one flatten (`flattenPackageBody`), and the rehydrate orders entries by the dependencies they nest too. Such an entry gets exactly what a flat entry with the same range gets: it is not loaded, one error line names the code, the package and the remedy, the listing marks it `notLoaded`, and DELETE or a compatible reinstall still act on it. A compatible lifted entry rehydrates exactly as before, registering the body as it was ledgered. diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 934c5ee20e2..e535832bf39 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -609,9 +609,18 @@ function declaredPackageManifest(body: Record): Record * is the permission-set collection, its `objects` the definitions — the * flatten order `AssembledPackageBodySchema` records). A body already flat is * returned as it is. + * + * [#28604] Also how a LEDGER entry is read wherever a decision reads its + * manifest keys — the `kernel:ready` rehydrate's handshake and dependency + * order, and the sample-data doors' handshake: an install before #28535 + * ledgered the body `os package install ` sends unflattened (`id` and + * `version` lifted beside the nested `manifest`), so its `engines` and + * `dependencies` sit one level down. Total: a ledger value is not + * shape-checked ({@link LocalManifestSource}), and anything that is not a + * plain object is handed back as it is. */ function flattenPackageBody(body: Record): Record { - if (!isPlainObject(body.manifest)) return body; + if (!isPlainObject(body) || !isPlainObject(body.manifest)) return body; const { manifest: meta, ...sections } = body; return { ...meta, ...sections }; } @@ -1093,7 +1102,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // `checkProtocolCompat` is the handshake's own judge. Only a // positive incompatibility is acted on: an absent or // unrecognised range rehydrates exactly as it did before. - const compat = checkProtocolCompat(entry.manifest); + // [#28604] Judged on the entry as the install door reads a body + // ({@link flattenPackageBody}), so an entry ledgered in the + // CLI's lifted shape before #28535 is judged by the range it + // nests, exactly as a flat entry with that range is. + const compat = checkProtocolCompat(flattenPackageBody(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. @@ -1168,9 +1181,12 @@ export class MarketplaceInstallLocalPlugin implements Plugin { */ private inDependencyOrder(ctx: PluginContext, entries: InstalledManifestEntry[]): InstalledManifestEntry[] { const { ordered, cycles } = orderHydrationBatch(entries.map((entry) => { - const bodies: unknown[] = Array.isArray(entry.manifest?.packages) - ? entry.manifest.packages.map((p: { manifest?: unknown } | null) => p?.manifest) - : [entry.manifest]; + // [#28604] Read as the install door reads a body, so a lifted + // entry's nested `dependencies` order it as a flat entry's do. + const body = flattenPackageBody(entry.manifest); + const bodies: unknown[] = Array.isArray(body?.packages) + ? body.packages.map((p: { manifest?: unknown } | null) => p?.manifest) + : [body]; return { item: entry, label: entry.manifestId, @@ -3833,8 +3849,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * (`protocolIncompatibleAnswer`). Each door asks this right after it has * read the entry, before any side effect. * - * Judged with `checkProtocolCompat` on the entry itself, as the rehydrate - * judges it, and NOT read from {@link refusedAtRehydrate}: an entry another + * Judged with `checkProtocolCompat` on the entry itself, read through + * {@link flattenPackageBody} as the rehydrate judges it (#28604), and NOT + * read from {@link refusedAtRehydrate}: an entry another * runtime wrote to a shared ledger after this boot was never rehydrated * here, and the doors refuse it all the same. Only a positive * incompatibility is refused. An absent or unrecognised range is admitted, @@ -3843,7 +3860,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * operator gets out of the refused state. */ private refuseProtocolIncompatibleEntry = (c: any, entry: InstalledEntry): Response | undefined => { - const compat = checkProtocolCompat(entry.manifest); + const compat = checkProtocolCompat(flattenPackageBody(entry.manifest)); if (compat.status !== 'incompatible') return undefined; const refusal = protocolIncompatibleAnswer(new ProtocolIncompatibleError(compat.diagnostic)); return c.json({ diff --git a/packages/cloud-connection/src/marketplace-install-local-protocol-handshake.test.ts b/packages/cloud-connection/src/marketplace-install-local-protocol-handshake.test.ts index 72239c7adcd..72637978493 100644 --- a/packages/cloud-connection/src/marketplace-install-local-protocol-handshake.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-protocol-handshake.test.ts @@ -174,8 +174,8 @@ function expectNothingWritten(h: { installRegistrations: () => unknown[]; instal expect(readdirSync(dir).sort()).toEqual(files); } -function seedEntry(manifest: { id: string; version: string }) { - new LocalManifestSource(dir).write({ +function seedEntry(manifest: { id: string; version: string }, into: string = dir) { + new LocalManifestSource(into).write({ packageId: manifest.id, versionId: manifest.version, manifestId: manifest.id, @@ -432,6 +432,59 @@ describe('kernel:ready rehydrate of an already-ledgered incompatible entry', () }); }); +/** + * [#28604] A ledger entry an install wrote BEFORE #28535: the body + * `os package install ` sends, stored as it arrived (manifest nested, + * `id` and `version` lifted beside it). The rehydrate reads it through the + * install door's one flatten, so the outcome is the flat entry's, measured on + * the same harness: on `origin/main` before this change the `^${OLD_MAJOR}` lifted + * entry was registered and synced with no error line. + */ +describe('[#28604] kernel:ready rehydrate of an entry ledgered in the CLI\'s lifted shape', () => { + const errors = (h: { logger: { error: { mock: { calls: unknown[][] } } } }) => + h.logger.error.mock.calls.map(([m]) => String(m)); + const lifted = (manifest: Record) => cliInstallBody(compiledArtifact(manifest)).manifest; + + it(`an excluded nested range (${OLD_RANGE}) is NOT loaded: the error line a flat entry with that range gets, byte for byte`, async () => { + seedEntry(lifted(INCOMPATIBLE)); + const h = await mount(dir); + expect(h.bootRegistrations().map((m) => m.id)).not.toContain(OLD_ID); + expect(h.bootSyncs).toBe(0); + const said = errors(h); + expect(said).toHaveLength(1); + expect(said[0]).toContain('OS_PROTOCOL_INCOMPATIBLE'); + + // The flat entry with the same range, on a fresh ledger: the same line. + const flatDir = mkdtempSync(join(tmpdir(), 'mil-protocol-flat-')); + try { + seedEntry(INCOMPATIBLE, flatDir); + expect(said).toEqual(errors(await mount(flatDir))); + } finally { + rmSync(flatDir, { recursive: true, force: true }); + } + }); + + it('the entry is listed as not loaded, with the nested range', async () => { + seedEntry(lifted(INCOMPATIBLE)); + const h = await mount(dir); + const res = await h.routes.get(`GET ${ROUTE}`)!(makeC(undefined)); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + const item = res.payload.data.items.find((i: any) => i.manifestId === OLD_ID); + expect(item.notLoaded).toEqual({ code: 'OS_PROTOCOL_INCOMPATIBLE', requiredRange: OLD_RANGE }); + }); + + it(`lit control: a lifted entry with ${CURRENT_RANGE} rehydrates as before, registering the body as ledgered`, async () => { + const body = lifted(COMPATIBLE); + seedEntry(body); + const h = await mount(dir); + expect(errors(h)).toEqual([]); + const registered = h.bootRegistrations().filter((m) => m.id === COMPATIBLE.id); + expect(registered).toHaveLength(1); + expect(registered[0]).toEqual(body); + expect(h.bootSyncs).toBe(1); + }); +}); + describe('[#26480] a package AHEAD of this runtime is told to upgrade, never to migrate', () => { /** The card's reproduction: a floor major one above the running protocol. */ const NEW_MAJOR = PROTOCOL_MAJOR + 1; diff --git a/packages/cloud-connection/src/marketplace-install-local-rehydrate-order.test.ts b/packages/cloud-connection/src/marketplace-install-local-rehydrate-order.test.ts index 5efad971d7b..2962e2ab751 100644 --- a/packages/cloud-connection/src/marketplace-install-local-rehydrate-order.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-rehydrate-order.test.ts @@ -94,4 +94,16 @@ describe('kernel:ready rehydrate — ADR-0141 D5 dependency order', () => { expect(cycle).toContain('com.acme.a'); expect(cycle).toContain('com.acme.b'); }); + + it('[#28604] orders an entry ledgered in the CLI\'s lifted shape by the dependencies its nested manifest declares', async () => { + // What an install before #28535 ledgered for `os package install `: + // the manifest nested, `id` and `version` lifted beside it. + const lifted: InstalledManifestEntry = { + ...REVENUE, + manifest: { id: REVENUE.manifestId, version: '1.0.0', manifest: { ...REVENUE.manifest } }, + }; + const { order, errors } = await rehydrate(dir, [lifted, HOTCRM]); + expect(order).toEqual(['app.objectstack.hotcrm', 'app.objectstack.hotcrm.revenue']); + expect(errors.filter((e) => e.includes('cycle'))).toEqual([]); + }); }); diff --git a/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts b/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts index ee2173713a0..9048182fafa 100644 --- a/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts @@ -350,6 +350,67 @@ describe('reseed and purge on an entry the rehydrate refused to load', () => { }); }); +/** + * [#28604] An entry an install wrote before #28535, in the body + * `os package install ` sends: the manifest nested under `manifest:`, + * `id` and `version` lifted beside it, the collections at the top. The doors + * read it through the install door's one flatten, so it is answered as the + * flat entry with the same range is. + */ +function liftedEntryOf(flat: { id: string; version: string; [k: string]: unknown }) { + const { objects, translations, data, ...manifest } = flat; + return { + manifest, + objects, + ...(translations !== undefined ? { translations } : {}), + data, + id: flat.id, + version: flat.version, + }; +} + +describe('[#28604] reseed and purge on an entry ledgered in the CLI\'s lifted shape', () => { + const LIFTED_REFUSED = liftedEntryOf(REFUSED); + const LIFTED_LOADED = liftedEntryOf(LOADED); + + it('PRECONDITION: the rehydrate refused the lifted old entry and loaded the lifted current one', async () => { + const h = await restartWith([LIFTED_REFUSED, LIFTED_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('reseed and purge answer the flat entry\'s 422, byte for byte, and nothing moves', async () => { + const h = await restartWith([LIFTED_REFUSED, LIFTED_LOADED], dir); + const before = h.state(); + const reseed = await h.reseed(REFUSED.id); + const purge = await h.purge(REFUSED.id); + expect([reseed.status, reseed.payload.error.code]).toEqual([422, 'OS_PROTOCOL_INCOMPATIBLE']); + expect(h.state()).toEqual(before); + expect(h.tables.qa_old_account).toEqual([{ id: 'o1', name: 'Acme' }]); + + const flatDir = mkdtempSync(join(tmpdir(), 'mil-sample-not-loaded-flat-')); + try { + const flat = await restartWith([REFUSED, LOADED], flatDir); + expect(JSON.stringify(reseed.payload)).toBe(JSON.stringify((await flat.reseed(REFUSED.id)).payload)); + expect(JSON.stringify(purge.payload)).toBe(JSON.stringify((await flat.purge(REFUSED.id)).payload)); + } finally { + rmSync(flatDir, { recursive: true, force: true }); + } + }); + + it('lit control: on the lifted loadable entry both doors answer 200, as before', async () => { + const h = await restartWith([LIFTED_REFUSED, LIFTED_LOADED], dir); + const reseed = await h.reseed(LOADED.id); + expect(reseed.status, JSON.stringify(reseed.payload)).toBe(200); + const purge = await h.purge(LOADED.id); + expect(purge.status, JSON.stringify(purge.payload)).toBe(200); + expect(purge.payload.data).toMatchObject({ manifestId: LOADED.id, deleted: 1 }); + }); +}); + describe('reseed and purge on a loadable entry answer as before', () => { it('reseed: 200 with the loader\'s counts, and its side effects land (the control for every "unchanged" above)', async () => { const h = await restartWith([REFUSED, LOADED], dir);