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. 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..947e60ef8b2 --- /dev/null +++ b/packages/qa/dogfood/test/package-export-type-read-failure.dogfood.test.ts @@ -0,0 +1,200 @@ +// 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, and the whole-deployment +// bundle (`GET /packages/export`), which composes that export, refuses with it. +// +// ## 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 as its declared `503 +// SERVICE_UNAVAILABLE` (only an unprovisioned `sys_metadata` licenses it to +// 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 with one installed package +// +// 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 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 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 { 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, 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 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 () => { + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-export-type-read-')); + const stack = await bootShowcase({ + extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], + }); + try { + const token = await stack.signIn(); + 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; + getMetaTypes(): Promise<{ types: string[] }>; + }; + liveTypes = (await protocol.getMetaTypes()).types; + const original = protocol.readActiveOverlayRows; + 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 read(); + } finally { + protocol.readActiveOverlayRows = original; + } + + after = await read(); + + protocol.readActiveOverlayRows = failing(UNSERVED, () => { unservedReadsRefused += 1; }); + try { + unserved = await read(); + } finally { + protocol.readActiveOverlayRows = original; + } + } finally { + await stack.stop(); + } + }, 600_000); + + afterAll(() => { + if (storageDir) rmSync(storageDir, { recursive: true, force: true }); + }); + + 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.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('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 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 cases above). + expect(liveTypes).toContain(FAILING); + expect(liveTypes).not.toContain(UNSERVED); + expect(unservedReadsRefused).toBeGreaterThan(0); + 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))); + }); +}); 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..815dd42f0ed --- /dev/null +++ b/packages/runtime/src/domains/packages-export-type-read-failure.test.ts @@ -0,0 +1,174 @@ +// 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 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 + * `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([ + { id: PKG, source: 'authored', enabled: true, manifest: { id: PKG, name: PKG, version: '1.0.0' } }, + ]), + }; + 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 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, + 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('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); + 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(); + }); +}); + +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(); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index ec2bd91c5d4..c644d7d7dbe 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -225,7 +225,7 @@ import { getMetadataTypeRedactor } from '@objectstack/spec/kernel'; * handle took any spelling at all. */ export type PackagesDomainProtocol = - Partial> + Partial> & Partial> & { /** Declared by the producer (`@objectstack/metadata-protocol`), #9960. */ @@ -3451,6 +3451,69 @@ function ownDocumentsOf(items: any[], type: string, packageId: string, registry: return out; } +/** + * [#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. + * + * 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; + } + 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); +} + +/** + * [#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, @@ -3515,9 +3578,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