From bcf5158bcdb8bdabebcce48f992e3951e6135385 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 04:26:00 +0000 Subject: [PATCH 1/8] fix(runtime): a package export refuses when a read of a type the runtime serves fails assemblePackageManifest skipped every per-type read failure as an unknown type, so GET /packages/:id/export answered 200 without the collection. Only a type absent from the runtime's live vocabulary (getMetaTypes) is skipped now; a failed read of a served type refuses the export, naming the package and the type, with the read's declared status and code carried. Claude-Session: https://claude.ai/code/session_01JQwxbXpF6XoEdQQbPsGPBQ Co-authored-by: Claude --- .../packages-export-type-read-failure.test.ts | 131 ++++++++++++++++++ packages/runtime/src/domains/packages.ts | 70 +++++++++- 2 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 packages/runtime/src/domains/packages-export-type-read-failure.test.ts diff --git a/packages/runtime/src/domains/packages-export-type-read-failure.test.ts b/packages/runtime/src/domains/packages-export-type-read-failure.test.ts new file mode 100644 index 00000000000..f53c903f23a --- /dev/null +++ b/packages/runtime/src/domains/packages-export-type-read-failure.test.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29760] `GET /packages/:id/export` is the package's whole content or no + * answer: a per-type read that fails for a type this runtime serves refuses the + * export, naming the package and the type, instead of answering 200 without that + * collection. Only a type the runtime does not serve (absent from its live type + * vocabulary, `getMetaTypes`) is still skipped. + * + * The protocol is a double and the door is the real dispatcher. The same throw + * through the real protocol, on a real boot, is the dogfood file + * `package-export-type-read-failure.dogfood.test.ts`. + * + * - a failed read of a served type: refused, naming the package and the type, + * with the read's declared status and code carried and an undeclared throw + * answered `500`; + * - the read's sentence is quoted only when it is a declared client refusal; + * - CONTROL: a type the runtime does not serve is still skipped, the rest of + * the export answered; + * - fail closed: a protocol with no `getMetaTypes`, or whose listing throws, + * has not shown the type unknown, so the export is refused. + */ +import { describe, expect, it, vi } from 'vitest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const PKG = 'com.example.ledger'; +const DASHBOARD = { name: 'ledger_pulse', label: 'Pulse', _packageId: PKG }; +const OBJECT = { name: 'ledger_entry', label: 'Entry', fields: {}, _packageId: PKG }; + +const system = (): any => ({ request: {}, environmentId: 'platform', executionContext: { isSystem: true } }); + +function make(protocol: any) { + const registry = { + getPackage: vi.fn((id: string) => ({ id, manifest: { id, name: id, version: '1.0.0' } })), + getAllPackages: vi.fn().mockReturnValue([]), + }; + const kernel: any = { + context: { + getService: (name: string) => + name === 'objectql' ? { registry } : name === 'protocol' ? protocol : null, + }, + }; + return new HttpDispatcher(kernel); +} + +/** + * A protocol whose per-package read of `failing` throws `thrown` and serves one + * object and one dashboard otherwise; its live vocabulary is `served`. + */ +function protocolWith(failing: string, thrown: unknown, served: string[] | null = ['object', 'dashboard']) { + return { + getMetaItemsPerPackage: vi.fn(async (req: { type: string; packageId?: string }) => { + if (req.type === failing) throw thrown; + if (req.type === 'object') return { items: [OBJECT] }; + if (req.type === 'dashboard') return { items: [DASHBOARD] }; + return { items: [] }; + }), + getMetaItems: vi.fn(async () => ({ items: [] })), + ...(served === null ? {} : { getMetaTypes: vi.fn(async () => ({ types: served, entries: [] })) }), + }; +} + +const exportOf = async (protocol: any) => + (await make(protocol).handlePackages(`/${encodeURIComponent(PKG)}/export`, 'GET', undefined, {}, system())).response as any; + +const refusalOf = (response: any) => ({ + status: response?.status, + code: response?.body?.error?.code, + message: String(response?.body?.error?.message ?? ''), +}); + +describe('[#29760] GET /packages/:id/export refuses on a failed read of a type the runtime serves', () => { + it('an undeclared throw reading a served type answers 500, naming the package and the type, never 200 without the collection', async () => { + const response = await exportOf(protocolWith('dashboard', new Error('overlay read failed'))); + expect(response?.status, JSON.stringify(response?.body)).not.toBe(200); + const refusal = refusalOf(response); + expect({ status: refusal.status, code: refusal.code }).toEqual({ status: 500, code: 'INTERNAL_ERROR' }); + expect(refusal.message).toContain(`'${PKG}'`); + expect(refusal.message).toContain(`'dashboard'`); + // An undeclared throw is a fault: its sentence is not quoted. + expect(refusal.message).not.toContain('overlay read failed'); + }); + + it('the read\'s declared status and code ride on: a 503 stays a 503', async () => { + const thrown = Object.assign(new Error('metadata service unavailable'), { status: 503, code: 'SERVICE_UNAVAILABLE' }); + const refusal = refusalOf(await exportOf(protocolWith('dashboard', thrown))); + expect({ status: refusal.status, code: refusal.code }).toEqual({ status: 503, code: 'SERVICE_UNAVAILABLE' }); + expect(refusal.message).toContain(`'${PKG}'`); + expect(refusal.message).toContain(`'dashboard'`); + }); + + it('a declared client refusal\'s sentence is quoted after the package and the type', async () => { + const thrown = Object.assign(new Error('the type is refused for this caller'), { status: 400, code: 'INVALID_REQUEST' }); + const refusal = refusalOf(await exportOf(protocolWith('dashboard', thrown))); + expect({ status: refusal.status, code: refusal.code }).toEqual({ status: 400, code: 'INVALID_REQUEST' }); + expect(refusal.message.startsWith(`Reading the 'dashboard' items of package '${PKG}' failed`), refusal.message).toBe(true); + expect(refusal.message).toContain('the type is refused for this caller'); + }); + + it('CONTROL: a throwing read of a type the runtime does not serve is still skipped, the rest of the export answered', async () => { + const protocol = protocolWith('dashboard', new Error('no such type here'), ['object']); + const response = await exportOf(protocol); + expect(response?.status, JSON.stringify(response?.body)).toBe(200); + const manifest = response.body.data; + expect(manifest.objects.map((o: any) => o.name)).toEqual(['ledger_entry']); + expect(manifest.dashboards).toBeUndefined(); + expect(protocol.getMetaTypes).toHaveBeenCalled(); + }); + + it('fail closed: a protocol that brings no `getMetaTypes` has not shown the type unknown, so the export is refused', async () => { + const refusal = refusalOf(await exportOf(protocolWith('dashboard', new Error('overlay read failed'), null))); + expect(refusal.status).toBe(500); + expect(refusal.message).toContain(`'dashboard'`); + }); + + it('fail closed: a type listing that throws has not shown the type unknown, so the export is refused', async () => { + const protocol = protocolWith('dashboard', new Error('overlay read failed')); + protocol.getMetaTypes = vi.fn(async () => { throw new Error('listing failed'); }); + const refusal = refusalOf(await exportOf(protocol)); + expect(refusal.status).toBe(500); + expect(refusal.message).toContain(`'${PKG}'`); + expect(refusal.message).toContain(`'dashboard'`); + }); + + it('CONTROL: the vocabulary is asked only after a read threw', async () => { + const protocol = protocolWith('none', new Error('unused')); + const response = await exportOf(protocol); + expect(response?.status, JSON.stringify(response?.body)).toBe(200); + expect(protocol.getMetaTypes).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 1a91dc116ce..da2e6988e66 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -217,7 +217,7 @@ import { * handle took any spelling at all. */ export type PackagesDomainProtocol = - Partial> + Partial> & Partial> & { /** Declared by the producer (`@objectstack/metadata-protocol`), #9960. */ @@ -3378,6 +3378,64 @@ function ownDocumentsOf(items: any[], type: string, packageId: string, registry: return out; } +/** + * [#29760] Whether this runtime serves metadata type `type`: whether its live + * type vocabulary lists it (`getMetaTypes`, the one accessor that answers + * "which types exist here" for the `/meta` listing too). The question + * {@link assemblePackageManifest} asks only after a per-type read threw, to + * tell the one throw it may skip, a type this runtime does not serve, from a + * failed read of a type it does. + * + * Fail closed: a protocol that brings no `getMetaTypes`, or whose vocabulary + * cannot be read, has not shown the type unknown, so the type counts as served + * and its failed read refuses the export. ⛔ The listing's own failure is not + * reported: the refusal carries the read's failure, which is the one asked + * about. + */ +async function runtimeServesMetadataType(protocol: PackagesDomainProtocol, type: string): Promise { + if (typeof protocol.getMetaTypes !== 'function') return true; + let types: unknown; + try { + types = (await protocol.getMetaTypes())?.types; + } catch { + return true; + } + return !Array.isArray(types) || types.includes(type); +} + +/** + * [#29760] The refusal of a package export one of whose per-type reads failed, + * for a type this runtime serves ({@link runtimeServesMetadataType}). The + * export is the package's whole content or no answer (the exit right, and the + * whole-or-absent rule of `PackageExportBundleSchema`, whose entries this + * function's caller produces): an export without the type's collection reads + * complete, and a reinstall from it lands without the type, telling nobody. + * + * The route's refusal family is kept, not a new code minted: the read's own + * declared `status` / `statusCode` and string `code` ride on unchanged (a `503` + * stays a `503`), so the door's `errorFromThrown(e, 500)` answers what it + * answered for the read's throw everywhere else, and an undeclared throw is + * its `500`. The sentence names the package and the type, and quotes the + * read's own message only where ADR-0112's disclosure rule lets a caught + * sentence be quoted ({@link clientFacingFailureText}); the read's error stays + * on `cause` for the server-side record. + */ +function packageExportTypeReadRefusal(packageId: string, type: string, cause: unknown): Error { + const quoted = clientFacingFailureText(cause, ''); + const err: Error & { status?: number; code?: string; cause?: unknown } = new Error( + `Reading the '${type}' items of package '${packageId}' failed, so its export is refused ` + + `rather than answered without them${quoted ? `: ${quoted}` : '.'}`, + ); + err.cause = cause; + const thrown = cause as { status?: unknown; statusCode?: unknown; code?: unknown } | null | undefined; + const status = typeof thrown?.status === 'number' ? thrown.status + : typeof thrown?.statusCode === 'number' ? thrown.statusCode + : undefined; + if (status !== undefined) err.status = status; + if (typeof thrown?.code === 'string' && thrown.code !== '') err.code = thrown.code; + return err; +} + async function assemblePackageManifest( deps: DomainHandlerDeps, packageId: string, @@ -3442,9 +3500,13 @@ context: HttpProtocolContext, ? await protocol.getMetaItemsPerPackage({ type: singular, packageId }) : await protocol.getMetaItems({ type: singular, packageId }); items = Array.isArray(res?.items) ? res.items : []; - } catch { - // Unknown/unsupported type for this runtime — skip. - continue; + } catch (e) { + // [#29760] Only a type this runtime does not serve is skipped. A + // failed read of a type it serves refuses the export, naming the + // package and the type: an export without that collection reads + // complete and is not (see {@link packageExportTypeReadRefusal}). + if (!(await runtimeServesMetadataType(protocol, singular))) continue; + throw packageExportTypeReadRefusal(packageId, singular, e); } // [#29563, ADR-0146 D8 item 3] …and, from that layer, never an item // that declares it overrides THIS package: that is the pair's From ddf5b466cff9c1493d3f05b95a255698e656ee57 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 04:27:34 +0000 Subject: [PATCH 2/8] test(dogfood): a package export refuses on a failed read of a served type Claude-Session: https://claude.ai/code/session_01JQwxbXpF6XoEdQQbPsGPBQ Co-authored-by: Claude --- ...e-export-type-read-failure.dogfood.test.ts | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 packages/qa/dogfood/test/package-export-type-read-failure.dogfood.test.ts diff --git a/packages/qa/dogfood/test/package-export-type-read-failure.dogfood.test.ts b/packages/qa/dogfood/test/package-export-type-read-failure.dogfood.test.ts new file mode 100644 index 00000000000..5022c9d6759 --- /dev/null +++ b/packages/qa/dogfood/test/package-export-type-read-failure.dogfood.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#29760] A package export is the package's whole content or no answer: when +// the read of one metadata type the runtime serves fails, `GET +// /packages/:id/export` refuses, naming the package and the type, instead of +// answering 200 without that type's collection. +// +// ## What was measured before the fix +// +// `assemblePackageManifest` caught every per-type read failure as an +// "unknown/unsupported type" and skipped it. The read below fails the way a +// storage fault does on the real protocol: the overlay rows of `dashboard` +// cannot be read, which the protocol rethrows (only an unprovisioned +// `sys_metadata` licenses it to answer without them). The export then answered +// 200 with the showcase's objects and no `dashboards` key at all, so a +// reinstall from it would land without a dashboard and tell nobody. +// +// ## What this file pins, on one showcase boot +// +// 1. CONTROL: the showcase's export answers 200 and carries its dashboards; +// 2. the dashboard overlay read failing: the export answers 500, naming the +// package and `dashboard`, and serves no manifest; +// 3. CONTROL: the read restored, the export answers 200 with the dashboards +// again (the failure was the read's, not the boot's). +// +// The fault is injected on the protocol's own overlay read +// (`readActiveOverlayRows`, which the moved list body reaches through the +// host's call-time closure), never on the export door, so the whole real read +// path runs. It boots a stack of its own, so it stays out of `SHARED_SHOWCASE`. + +import { describe, it, expect, beforeAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const SHOWCASE = 'com.example.showcase'; +const FAILING = 'dashboard'; + +type Answer = { status: number; body: any }; + +async function call(stack: VerifyStack, token: string, method: string, path: string): Promise { + const res = await stack.apiAs(token, method, path); + return { status: res.status, body: await res.json().catch(() => null) }; +} + +describe('dogfood: a package export refuses when the read of a type the runtime serves fails', () => { + let before: Answer; + let failed: Answer; + let after: Answer; + let overlayReadsRefused = 0; + + beforeAll(async () => { + const stack = await bootShowcase(); + try { + const token = await stack.signIn(); + before = await call(stack, token, 'GET', `/packages/${SHOWCASE}/export`); + + const protocol = stack.kernel.getService('protocol') as { + readActiveOverlayRows(request: { type: string }): Promise; + }; + const original = protocol.readActiveOverlayRows; + protocol.readActiveOverlayRows = async function (this: unknown, request: { type: string }) { + if (request.type === FAILING) { + overlayReadsRefused += 1; + throw new Error(`dogfood: the ${FAILING} overlay rows could not be read`); + } + return original.call(protocol, request); + }; + try { + failed = await call(stack, token, 'GET', `/packages/${SHOWCASE}/export`); + } finally { + protocol.readActiveOverlayRows = original; + } + + after = await call(stack, token, 'GET', `/packages/${SHOWCASE}/export`); + } finally { + await stack.stop(); + } + }, 600_000); + + it('CONTROL: the showcase\'s export answers 200 and carries its dashboards', () => { + expect(before.status, JSON.stringify(before.body)).toBe(200); + expect((before.body?.data?.dashboards ?? []).length).toBeGreaterThan(0); + }); + + it('a failed dashboard read refuses the export, naming the package and the type, and serves no manifest', () => { + // The injected fault was reached: the export read the type through it. + expect(overlayReadsRefused).toBeGreaterThan(0); + expect(failed.status, JSON.stringify(failed.body)).toBe(500); + expect(failed.body?.success).toBe(false); + expect(failed.body?.data).toBeUndefined(); + expect(failed.body?.error?.code).toBe('INTERNAL_ERROR'); + const message = String(failed.body?.error?.message ?? ''); + expect(message).toContain(`'${SHOWCASE}'`); + expect(message).toContain(`'${FAILING}'`); + }); + + it('CONTROL: the read restored, the export answers 200 with the dashboards again', () => { + expect(after.status, JSON.stringify(after.body)).toBe(200); + expect((after.body?.data?.dashboards ?? []).map((d: any) => d.name)) + .toEqual((before.body?.data?.dashboards ?? []).map((d: any) => d.name)); + }); +}); From c688bc4e60803eae7c495b7db2f366168c4e21bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 04:36:12 +0000 Subject: [PATCH 3/8] test(dogfood): the export refusal carries the read's declared 503 Claude-Session: https://claude.ai/code/session_01JQwxbXpF6XoEdQQbPsGPBQ Co-authored-by: Claude --- ...kage-export-type-read-failure.dogfood.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/qa/dogfood/test/package-export-type-read-failure.dogfood.test.ts b/packages/qa/dogfood/test/package-export-type-read-failure.dogfood.test.ts index 5022c9d6759..db189ea11e4 100644 --- a/packages/qa/dogfood/test/package-export-type-read-failure.dogfood.test.ts +++ b/packages/qa/dogfood/test/package-export-type-read-failure.dogfood.test.ts @@ -10,16 +10,18 @@ // `assemblePackageManifest` caught every per-type read failure as an // "unknown/unsupported type" and skipped it. The read below fails the way a // storage fault does on the real protocol: the overlay rows of `dashboard` -// cannot be read, which the protocol rethrows (only an unprovisioned -// `sys_metadata` licenses it to answer without them). The export then answered +// cannot be read, which the protocol rethrows as its declared `503 +// SERVICE_UNAVAILABLE` (only an unprovisioned `sys_metadata` licenses it to +// answer without them). The export then answered // 200 with the showcase's objects and no `dashboards` key at all, so a // reinstall from it would land without a dashboard and tell nobody. // // ## What this file pins, on one showcase boot // // 1. CONTROL: the showcase's export answers 200 and carries its dashboards; -// 2. the dashboard overlay read failing: the export answers 500, naming the -// package and `dashboard`, and serves no manifest; +// 2. the dashboard overlay read failing: the export answers the read's own +// `503 SERVICE_UNAVAILABLE`, naming the package and `dashboard`, and +// serves no manifest; // 3. CONTROL: the read restored, the export answers 200 with the dashboards // again (the failure was the read's, not the boot's). // @@ -85,10 +87,12 @@ describe('dogfood: a package export refuses when the read of a type the runtime it('a failed dashboard read refuses the export, naming the package and the type, and serves no manifest', () => { // The injected fault was reached: the export read the type through it. expect(overlayReadsRefused).toBeGreaterThan(0); - expect(failed.status, JSON.stringify(failed.body)).toBe(500); + // The read's declared refusal rides on: the protocol answers a failed + // overlay read as a 503, and so does the export it failed. + expect(failed.status, JSON.stringify(failed.body)).toBe(503); expect(failed.body?.success).toBe(false); expect(failed.body?.data).toBeUndefined(); - expect(failed.body?.error?.code).toBe('INTERNAL_ERROR'); + expect(failed.body?.error?.code).toBe('SERVICE_UNAVAILABLE'); const message = String(failed.body?.error?.message ?? ''); expect(message).toContain(`'${SHOWCASE}'`); expect(message).toContain(`'${FAILING}'`); From 2cf84ec961d2f0f41c30972344d5aa1e526fdc4a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 04:38:59 +0000 Subject: [PATCH 4/8] test(dogfood): a type the runtime does not serve is still skipped by the export Claude-Session: https://claude.ai/code/session_01JQwxbXpF6XoEdQQbPsGPBQ Co-authored-by: Claude --- ...e-export-type-read-failure.dogfood.test.ts | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/qa/dogfood/test/package-export-type-read-failure.dogfood.test.ts b/packages/qa/dogfood/test/package-export-type-read-failure.dogfood.test.ts index db189ea11e4..c9d84812096 100644 --- a/packages/qa/dogfood/test/package-export-type-read-failure.dogfood.test.ts +++ b/packages/qa/dogfood/test/package-export-type-read-failure.dogfood.test.ts @@ -23,7 +23,11 @@ // `503 SERVICE_UNAVAILABLE`, naming the package and `dashboard`, and // serves no manifest; // 3. CONTROL: the read restored, the export answers 200 with the dashboards -// again (the failure was the read's, not the boot's). +// again (the failure was the read's, not the boot's); +// 4. a type this runtime does not serve is still skipped: `rag_pipeline` is +// an exportable collection the showcase's live type vocabulary +// (`getMetaTypes`) does not list, and its read failing leaves the export +// 200 with the same collections. // // The fault is injected on the protocol's own overlay read // (`readActiveOverlayRows`, which the moved list body reaches through the @@ -36,6 +40,8 @@ import { bootShowcase } from './showcase-boot.js'; const SHOWCASE = 'com.example.showcase'; const FAILING = 'dashboard'; +/** An exportable type the showcase's live vocabulary does not list (asserted below, not assumed). */ +const UNSERVED = 'rag_pipeline'; type Answer = { status: number; body: any }; @@ -48,7 +54,10 @@ describe('dogfood: a package export refuses when the read of a type the runtime let before: Answer; let failed: Answer; let after: Answer; + let unserved: Answer; + let liveTypes: string[] = []; let overlayReadsRefused = 0; + let unservedReadsRefused = 0; beforeAll(async () => { const stack = await bootShowcase(); @@ -58,15 +67,19 @@ describe('dogfood: a package export refuses when the read of a type the runtime const protocol = stack.kernel.getService('protocol') as { readActiveOverlayRows(request: { type: string }): Promise; + getMetaTypes(): Promise<{ types: string[] }>; }; + liveTypes = (await protocol.getMetaTypes()).types; const original = protocol.readActiveOverlayRows; - protocol.readActiveOverlayRows = async function (this: unknown, request: { type: string }) { - if (request.type === FAILING) { - overlayReadsRefused += 1; - throw new Error(`dogfood: the ${FAILING} overlay rows could not be read`); - } - return original.call(protocol, request); - }; + const failing = (type: string, onRefuse: () => void) => + async function (this: unknown, request: { type: string }) { + if (request.type === type) { + onRefuse(); + throw new Error(`dogfood: the ${type} overlay rows could not be read`); + } + return original.call(protocol, request); + }; + protocol.readActiveOverlayRows = failing(FAILING, () => { overlayReadsRefused += 1; }); try { failed = await call(stack, token, 'GET', `/packages/${SHOWCASE}/export`); } finally { @@ -74,6 +87,13 @@ describe('dogfood: a package export refuses when the read of a type the runtime } after = await call(stack, token, 'GET', `/packages/${SHOWCASE}/export`); + + protocol.readActiveOverlayRows = failing(UNSERVED, () => { unservedReadsRefused += 1; }); + try { + unserved = await call(stack, token, 'GET', `/packages/${SHOWCASE}/export`); + } finally { + protocol.readActiveOverlayRows = original; + } } finally { await stack.stop(); } @@ -103,4 +123,17 @@ describe('dogfood: a package export refuses when the read of a type the runtime expect((after.body?.data?.dashboards ?? []).map((d: any) => d.name)) .toEqual((before.body?.data?.dashboards ?? []).map((d: any) => d.name)); }); + + it('a type this runtime does not serve is still skipped: its failed read leaves the export 200, the same collections', () => { + // The precondition, measured on this boot: the type is exportable and the + // live vocabulary does not list it. Registering it makes this case a + // served type, whose failed read refuses (the case above). + expect(liveTypes).toContain(FAILING); + expect(liveTypes).not.toContain(UNSERVED); + expect(unservedReadsRefused).toBeGreaterThan(0); + expect(unserved.status, JSON.stringify(unserved.body)).toBe(200); + const collections = (answer: Answer) => + Object.keys(answer.body?.data ?? {}).filter((k) => Array.isArray(answer.body.data[k])).sort(); + expect(collections(unserved)).toEqual(collections(before)); + }); }); From cac7e0107e761087dce7cd51049f402ee495f6d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 04:39:42 +0000 Subject: [PATCH 5/8] chore(changeset): the package export refuses on a failed read of a served type Claude-Session: https://claude.ai/code/session_01JQwxbXpF6XoEdQQbPsGPBQ Co-authored-by: Claude --- .changeset/29760-export-type-read-failure.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/29760-export-type-read-failure.md diff --git a/.changeset/29760-export-type-read-failure.md b/.changeset/29760-export-type-read-failure.md new file mode 100644 index 00000000000..5900e59bb0a --- /dev/null +++ b/.changeset/29760-export-type-read-failure.md @@ -0,0 +1,19 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime)!: a package export refuses when the read of a metadata type the runtime serves fails, naming the package and the type, instead of answering without that type (#29760) + +Clause-②: no (narrowing) — `GET /packages/:id/export` answered `200` with a type's collection missing when that type's per-package read threw; it now refuses that request, so the door answers fewer requests with a body. Nothing is widened. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing at the package export door (`@objectstack/runtime`). + +- **Before.** `assemblePackageManifest` caught every per-type read failure as an unknown type and skipped it. A failed read of `dashboard` (an unreadable overlay row set, a store fault) left the export `200` with every other collection and no `dashboards` key, so a reinstall from it landed without the package's dashboards and told nobody. +- **Now.** Only a type this runtime does not serve, one its live type vocabulary (`getMetaTypes`) does not list, is skipped, as before. A failed read of a type it serves refuses the export: the message names the package and the type, and the read's own declared status and code are carried (a store fault the protocol declares `503 SERVICE_UNAVAILABLE` answers `503`; an undeclared throw answers `500`). A protocol that brings no `getMetaTypes`, or whose listing cannot be read, has not shown the type unknown, so its failed read refuses too. +- **Unchanged.** An export whose reads succeed is byte-for-byte the same; a package that is not found is still `404`. The whole-deployment bundle composes this export, so a bundle entry whose read fails refuses the bundle the same way. +- **Who sees it.** A caller exporting a package while the read of one of its types fails: it was handed a thinner export with `200`, and now gets the refusal, which it can retry once the read recovers. + +**The remedy.** Retry the export once the failing read recovers; the refusal names the type to look at. From eb5743e83bec4063e0e71af18ba345fc248c85ea Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 05:04:05 +0000 Subject: [PATCH 6/8] test(runtime): the whole-deployment bundle refuses through the per-package export on a failed read of a served type Claude-Session: https://claude.ai/code/session_01JQwxbXpF6XoEdQQbPsGPBQ Co-authored-by: Claude --- .../packages-export-type-read-failure.test.ts | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/domains/packages-export-type-read-failure.test.ts b/packages/runtime/src/domains/packages-export-type-read-failure.test.ts index f53c903f23a..e0249899a82 100644 --- a/packages/runtime/src/domains/packages-export-type-read-failure.test.ts +++ b/packages/runtime/src/domains/packages-export-type-read-failure.test.ts @@ -5,7 +5,9 @@ * answer: a per-type read that fails for a type this runtime serves refuses the * export, naming the package and the type, instead of answering 200 without that * collection. Only a type the runtime does not serve (absent from its live type - * vocabulary, `getMetaTypes`) is still skipped. + * vocabulary, `getMetaTypes`) is still skipped. The whole-deployment bundle, + * `GET /packages/export`, composes that export, so it refuses through the same + * producer, naming both, with no second check of its own. * * The protocol is a double and the door is the real dispatcher. The same throw * through the real protocol, on a real boot, is the dogfood file @@ -32,7 +34,9 @@ const system = (): any => ({ request: {}, environmentId: 'platform', executionCo function make(protocol: any) { const registry = { getPackage: vi.fn((id: string) => ({ id, manifest: { id, name: id, version: '1.0.0' } })), - getAllPackages: vi.fn().mockReturnValue([]), + getAllPackages: vi.fn().mockReturnValue([ + { id: PKG, source: 'authored', enabled: true, manifest: { id: PKG, name: PKG, version: '1.0.0' } }, + ]), }; const kernel: any = { context: { @@ -63,6 +67,9 @@ function protocolWith(failing: string, thrown: unknown, served: string[] | null const exportOf = async (protocol: any) => (await make(protocol).handlePackages(`/${encodeURIComponent(PKG)}/export`, 'GET', undefined, {}, system())).response as any; +const bundleOf = async (protocol: any) => + (await make(protocol).handlePackages('/export', 'GET', undefined, {}, system())).response as any; + const refusalOf = (response: any) => ({ status: response?.status, code: response?.body?.error?.code, @@ -129,3 +136,32 @@ describe('[#29760] GET /packages/:id/export refuses on a failed read of a type t expect(protocol.getMetaTypes).not.toHaveBeenCalled(); }); }); + +describe('[#29760] GET /packages/export refuses through the per-package export it composes', () => { + it('a failed read of a served type refuses the bundle with the read\'s status and code, naming the package and the type', async () => { + const thrown = Object.assign(new Error('metadata service unavailable'), { status: 503, code: 'SERVICE_UNAVAILABLE' }); + const response = await bundleOf(protocolWith('dashboard', thrown)); + expect(response?.status, JSON.stringify(response?.body)).not.toBe(200); + expect(response?.body?.data).toBeUndefined(); + const refusal = refusalOf(response); + expect({ status: refusal.status, code: refusal.code }).toEqual({ status: 503, code: 'SERVICE_UNAVAILABLE' }); + expect(refusal.message).toContain(`'${PKG}'`); + expect(refusal.message).toContain(`'dashboard'`); + }); + + it('an undeclared throw refuses the bundle 500, naming the package and the type', async () => { + const refusal = refusalOf(await bundleOf(protocolWith('dashboard', new Error('overlay read failed')))); + expect({ status: refusal.status, code: refusal.code }).toEqual({ status: 500, code: 'INTERNAL_ERROR' }); + expect(refusal.message).toContain(`'${PKG}'`); + expect(refusal.message).toContain(`'dashboard'`); + }); + + it('CONTROL: a throwing read of a type the runtime does not serve leaves the bundle 200, the entry without it', async () => { + const response = await bundleOf(protocolWith('dashboard', new Error('no such type here'), ['object'])); + expect(response?.status, JSON.stringify(response?.body)).toBe(200); + const [entry] = response.body.data.packages; + expect(entry.id).toBe(PKG); + expect(entry.objects.map((o: any) => o.name)).toEqual(['ledger_entry']); + expect(entry.dashboards).toBeUndefined(); + }); +}); From 4378aebe312c4b5673c3c3963424706f07f62aee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 05:05:14 +0000 Subject: [PATCH 7/8] test(dogfood): the bundle refuses with the per-package export on a failed read of a served type Claude-Session: https://claude.ai/code/session_01JQwxbXpF6XoEdQQbPsGPBQ Co-authored-by: Claude --- ...e-export-type-read-failure.dogfood.test.ts | 151 ++++++++++++------ 1 file changed, 106 insertions(+), 45 deletions(-) diff --git a/packages/qa/dogfood/test/package-export-type-read-failure.dogfood.test.ts b/packages/qa/dogfood/test/package-export-type-read-failure.dogfood.test.ts index c9d84812096..947e60ef8b2 100644 --- a/packages/qa/dogfood/test/package-export-type-read-failure.dogfood.test.ts +++ b/packages/qa/dogfood/test/package-export-type-read-failure.dogfood.test.ts @@ -3,7 +3,8 @@ // [#29760] A package export is the package's whole content or no answer: when // the read of one metadata type the runtime serves fails, `GET // /packages/:id/export` refuses, naming the package and the type, instead of -// answering 200 without that type's collection. +// answering 200 without that type's collection, and the whole-deployment +// bundle (`GET /packages/export`), which composes that export, refuses with it. // // ## What was measured before the fix // @@ -12,58 +13,98 @@ // storage fault does on the real protocol: the overlay rows of `dashboard` // cannot be read, which the protocol rethrows as its declared `503 // SERVICE_UNAVAILABLE` (only an unprovisioned `sys_metadata` licenses it to -// answer without them). The export then answered -// 200 with the showcase's objects and no `dashboards` key at all, so a -// reinstall from it would land without a dashboard and tell nobody. +// answer without them). The showcase's export then answered 200 with its +// objects and no `dashboards` key at all, so a reinstall from it would land +// without a dashboard and tell nobody. // -// ## What this file pins, on one showcase boot +// ## What this file pins, on one showcase boot with one installed package // -// 1. CONTROL: the showcase's export answers 200 and carries its dashboards; -// 2. the dashboard overlay read failing: the export answers the read's own -// `503 SERVICE_UNAVAILABLE`, naming the package and `dashboard`, and -// serves no manifest; -// 3. CONTROL: the read restored, the export answers 200 with the dashboards -// again (the failure was the read's, not the boot's); +// 1. CONTROL: the showcase's export and the bundle answer 200 and carry their +// dashboards (the bundle holds the installed package, never the +// showcase's own `code` package); +// 2. the dashboard overlay read failing: both doors answer the read's own +// `503 SERVICE_UNAVAILABLE`, naming the package and `dashboard`, and serve +// no body; +// 3. CONTROL: the read restored, both answer 200 with the dashboards again +// (the failure was the read's, not the boot's); // 4. a type this runtime does not serve is still skipped: `rag_pipeline` is // an exportable collection the showcase's live type vocabulary -// (`getMetaTypes`) does not list, and its read failing leaves the export +// (`getMetaTypes`) does not list, and its read failing leaves both doors // 200 with the same collections. // // The fault is injected on the protocol's own overlay read // (`readActiveOverlayRows`, which the moved list body reaches through the -// host's call-time closure), never on the export door, so the whole real read -// path runs. It boots a stack of its own, so it stays out of `SHARED_SHOWCASE`. +// host's call-time closure), never on either door, so the whole real read path +// runs. It boots a stack of its own (the install-local plugin mounted), so it +// stays out of `SHARED_SHOWCASE`. -import { describe, it, expect, beforeAll } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; +import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; import { bootShowcase } from './showcase-boot.js'; const SHOWCASE = 'com.example.showcase'; +const INSTALLED = 'com.dogfood.exportread'; const FAILING = 'dashboard'; /** An exportable type the showcase's live vocabulary does not list (asserted below, not assumed). */ const UNSERVED = 'rag_pipeline'; +const ROUTE = '/marketplace/install-local'; + +/** The install body `os package install` sends for an inline manifest. */ +const INSTALL = { + manifest: { + id: INSTALLED, + namespace: 'exportread', + name: 'Export read failure', + version: '1.0.0', + type: 'app', + objects: [{ name: 'exportread_entry', label: 'Entry', fields: { title: { type: 'text', label: 'Title' } } }], + dashboards: [{ name: 'exportread_pulse', label: 'Pulse', columns: 12, widgets: [] }], + }, +}; type Answer = { status: number; body: any }; +type Reading = { one: Answer; bundle: Answer }; -async function call(stack: VerifyStack, token: string, method: string, path: string): Promise { - const res = await stack.apiAs(token, method, path); +async function call(stack: VerifyStack, token: string, method: string, path: string, body?: unknown): Promise { + const res = await stack.apiAs(token, method, path, body); return { status: res.status, body: await res.json().catch(() => null) }; } +const collections = (manifest: Record | undefined): string[] => + Object.keys(manifest ?? {}).filter((k) => Array.isArray(manifest?.[k])).sort(); +const dashboardNames = (manifest: Record | undefined): string[] => + (manifest?.dashboards ?? []).map((d: any) => d.name); +const entryOf = (bundle: Answer) => (bundle.body?.data?.packages ?? []).find((p: any) => p.id === INSTALLED); + describe('dogfood: a package export refuses when the read of a type the runtime serves fails', () => { - let before: Answer; - let failed: Answer; - let after: Answer; - let unserved: Answer; + let storageDir: string; + let installed: Answer; + let before: Reading; + let failed: Reading; + let after: Reading; + let unserved: Reading; let liveTypes: string[] = []; let overlayReadsRefused = 0; let unservedReadsRefused = 0; beforeAll(async () => { - const stack = await bootShowcase(); + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-export-type-read-')); + const stack = await bootShowcase({ + extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], + }); try { const token = await stack.signIn(); - before = await call(stack, token, 'GET', `/packages/${SHOWCASE}/export`); + installed = await call(stack, token, 'POST', ROUTE, INSTALL); + const read = async (): Promise => ({ + one: await call(stack, token, 'GET', `/packages/${SHOWCASE}/export`), + bundle: await call(stack, token, 'GET', '/packages/export'), + }); + before = await read(); const protocol = stack.kernel.getService('protocol') as { readActiveOverlayRows(request: { type: string }): Promise; @@ -79,18 +120,19 @@ describe('dogfood: a package export refuses when the read of a type the runtime } return original.call(protocol, request); }; + protocol.readActiveOverlayRows = failing(FAILING, () => { overlayReadsRefused += 1; }); try { - failed = await call(stack, token, 'GET', `/packages/${SHOWCASE}/export`); + failed = await read(); } finally { protocol.readActiveOverlayRows = original; } - after = await call(stack, token, 'GET', `/packages/${SHOWCASE}/export`); + after = await read(); protocol.readActiveOverlayRows = failing(UNSERVED, () => { unservedReadsRefused += 1; }); try { - unserved = await call(stack, token, 'GET', `/packages/${SHOWCASE}/export`); + unserved = await read(); } finally { protocol.readActiveOverlayRows = original; } @@ -99,41 +141,60 @@ describe('dogfood: a package export refuses when the read of a type the runtime } }, 600_000); - it('CONTROL: the showcase\'s export answers 200 and carries its dashboards', () => { - expect(before.status, JSON.stringify(before.body)).toBe(200); - expect((before.body?.data?.dashboards ?? []).length).toBeGreaterThan(0); + afterAll(() => { + if (storageDir) rmSync(storageDir, { recursive: true, force: true }); }); - it('a failed dashboard read refuses the export, naming the package and the type, and serves no manifest', () => { + it('CONTROL: the package installs, and both doors answer 200 carrying the dashboards', () => { + expect(installed.status, JSON.stringify(installed.body)).toBe(200); + expect(before.one.status, JSON.stringify(before.one.body)).toBe(200); + expect(dashboardNames(before.one.body?.data).length).toBeGreaterThan(0); + expect(before.bundle.status, JSON.stringify(before.bundle.body)).toBe(200); + expect((before.bundle.body?.data?.packages ?? []).map((p: any) => p.id)).not.toContain(SHOWCASE); + expect(dashboardNames(entryOf(before.bundle))).toEqual(['exportread_pulse']); + }); + + it('a failed dashboard read refuses the package export, naming the package and the type, and serves no manifest', () => { // The injected fault was reached: the export read the type through it. expect(overlayReadsRefused).toBeGreaterThan(0); // The read's declared refusal rides on: the protocol answers a failed // overlay read as a 503, and so does the export it failed. - expect(failed.status, JSON.stringify(failed.body)).toBe(503); - expect(failed.body?.success).toBe(false); - expect(failed.body?.data).toBeUndefined(); - expect(failed.body?.error?.code).toBe('SERVICE_UNAVAILABLE'); - const message = String(failed.body?.error?.message ?? ''); + expect(failed.one.status, JSON.stringify(failed.one.body)).toBe(503); + expect(failed.one.body?.success).toBe(false); + expect(failed.one.body?.data).toBeUndefined(); + expect(failed.one.body?.error?.code).toBe('SERVICE_UNAVAILABLE'); + const message = String(failed.one.body?.error?.message ?? ''); expect(message).toContain(`'${SHOWCASE}'`); expect(message).toContain(`'${FAILING}'`); }); - it('CONTROL: the read restored, the export answers 200 with the dashboards again', () => { - expect(after.status, JSON.stringify(after.body)).toBe(200); - expect((after.body?.data?.dashboards ?? []).map((d: any) => d.name)) - .toEqual((before.body?.data?.dashboards ?? []).map((d: any) => d.name)); + it('the same failure refuses the whole-deployment bundle, naming the package and the type, and serves no bundle', () => { + expect(failed.bundle.status, JSON.stringify(failed.bundle.body)).toBe(503); + expect(failed.bundle.body?.success).toBe(false); + expect(failed.bundle.body?.data).toBeUndefined(); + expect(failed.bundle.body?.error?.code).toBe('SERVICE_UNAVAILABLE'); + const message = String(failed.bundle.body?.error?.message ?? ''); + expect(message).toContain(`'${INSTALLED}'`); + expect(message).toContain(`'${FAILING}'`); + }); + + it('CONTROL: the read restored, both doors answer 200 with the dashboards again', () => { + expect(after.one.status, JSON.stringify(after.one.body)).toBe(200); + expect(dashboardNames(after.one.body?.data)).toEqual(dashboardNames(before.one.body?.data)); + expect(after.bundle.status, JSON.stringify(after.bundle.body)).toBe(200); + expect(dashboardNames(entryOf(after.bundle))).toEqual(['exportread_pulse']); }); - it('a type this runtime does not serve is still skipped: its failed read leaves the export 200, the same collections', () => { + it('a type this runtime does not serve is still skipped: its failed read leaves both doors 200, the same collections', () => { // The precondition, measured on this boot: the type is exportable and the // live vocabulary does not list it. Registering it makes this case a - // served type, whose failed read refuses (the case above). + // served type, whose failed read refuses (the cases above). expect(liveTypes).toContain(FAILING); expect(liveTypes).not.toContain(UNSERVED); expect(unservedReadsRefused).toBeGreaterThan(0); - expect(unserved.status, JSON.stringify(unserved.body)).toBe(200); - const collections = (answer: Answer) => - Object.keys(answer.body?.data ?? {}).filter((k) => Array.isArray(answer.body.data[k])).sort(); - expect(collections(unserved)).toEqual(collections(before)); + expect(unserved.one.status, JSON.stringify(unserved.one.body)).toBe(200); + expect(collections(unserved.one.body?.data)).toEqual(collections(before.one.body?.data)); + expect(unserved.bundle.status, JSON.stringify(unserved.bundle.body)).toBe(200); + expect(collections(entryOf(unserved.bundle))).toEqual(collections(entryOf(before.bundle))); }); }); From 2fd8f6f7ed00fe27294d47d6184187f3bc782ae2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 06:36:06 +0000 Subject: [PATCH 8/8] fix(runtime): fold the listed type spellings to the singular before the served check getMetaTypes lists each type as its source stores it, singular or plural; the export loop asks with the canonical singular, so a plural-only listing read as unserved and a failed read of that type was skipped. Claude-Session: https://claude.ai/code/session_01JQwxbXpF6XoEdQQbPsGPBQ Co-authored-by: Claude --- .../packages-export-type-read-failure.test.ts | 7 +++++++ packages/runtime/src/domains/packages.ts | 13 +++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/domains/packages-export-type-read-failure.test.ts b/packages/runtime/src/domains/packages-export-type-read-failure.test.ts index e0249899a82..815dd42f0ed 100644 --- a/packages/runtime/src/domains/packages-export-type-read-failure.test.ts +++ b/packages/runtime/src/domains/packages-export-type-read-failure.test.ts @@ -114,6 +114,13 @@ describe('[#29760] GET /packages/:id/export refuses on a failed read of a type t expect(protocol.getMetaTypes).toHaveBeenCalled(); }); + it('a vocabulary that lists the type only by its plural spelling still serves it: the failed read refuses', async () => { + const refusal = refusalOf(await exportOf(protocolWith('dashboard', new Error('overlay read failed'), ['objects', 'dashboards']))); + expect({ status: refusal.status, code: refusal.code }).toEqual({ status: 500, code: 'INTERNAL_ERROR' }); + expect(refusal.message).toContain(`'${PKG}'`); + expect(refusal.message).toContain(`'dashboard'`); + }); + it('fail closed: a protocol that brings no `getMetaTypes` has not shown the type unknown, so the export is refused', async () => { const refusal = refusalOf(await exportOf(protocolWith('dashboard', new Error('overlay read failed'), null))); expect(refusal.status).toBe(500); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 110e61fec31..45723e790f7 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -3442,9 +3442,11 @@ function ownDocumentsOf(items: any[], type: string, packageId: string, registry: } /** - * [#29760] Whether this runtime serves metadata type `type`: whether its live - * type vocabulary lists it (`getMetaTypes`, the one accessor that answers - * "which types exist here" for the `/meta` listing too). The question + * [#29760] Whether this runtime serves metadata type `type` (the canonical + * singular): whether its live type vocabulary lists it under either spelling + * (`getMetaTypes`, the one accessor that answers "which types exist here" for + * the `/meta` listing too; each listed name folded through + * `PLURAL_TO_SINGULAR`, as its other callers fold it). The question * {@link assemblePackageManifest} asks only after a per-type read threw, to * tell the one throw it may skip, a type this runtime does not serve, from a * failed read of a type it does. @@ -3463,7 +3465,10 @@ async function runtimeServesMetadataType(protocol: PackagesDomainProtocol, type: } catch { return true; } - return !Array.isArray(types) || types.includes(type); + if (!Array.isArray(types)) return true; + // The listing spells each type as its source stores it, singular or + // plural; folded to the singular the export loop asks with. + return types.some((listed) => typeof listed === 'string' && (PLURAL_TO_SINGULAR[listed] ?? listed) === type); } /**