From a0e9948820a4e9b30b207ca3a0a98b3204eb8493 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sat, 10 Oct 2026 10:54:13 -0500 Subject: [PATCH] fix(storage): recognize errors across module packages --- core/tests/module-storage.test.ts | 12 +++++ packages/contracts/src/module-storage.ts | 22 +++++++++ packages/module-sdk/src/index.ts | 1 + scripts/lib/deploy-smoke-display.mjs | 57 +++++++++++++++--------- 4 files changed, 72 insertions(+), 20 deletions(-) diff --git a/core/tests/module-storage.test.ts b/core/tests/module-storage.test.ts index b7b0f6d..ae3e9c0 100644 --- a/core/tests/module-storage.test.ts +++ b/core/tests/module-storage.test.ts @@ -1,5 +1,6 @@ import { EMPTY_MODULE_STORAGE, + isModuleStorageError, isValidModuleStorageKey, MODULE_STORAGE_KEY_MAX_BYTES, MODULE_STORAGE_READ_URL_DEFAULT_SECONDS, @@ -322,6 +323,17 @@ describe("module storage scoping", () => { } }); + it("recognizes a storage error from an installed module's separate package copy", () => { + const external = Object.assign(new Error("The storage service could not be reached."), { + code: "STORAGE_PROVIDER_UNAVAILABLE", + name: "ModuleStorageError", + }); + + expect(external).not.toBeInstanceOf(ModuleStorageError); + expect(isModuleStorageError(external)).toBe(true); + expect(isModuleStorageError(Object.assign(new Error("no"), { code: "UNKNOWN" }))).toBe(false); + }); + it("gives a module no way to reach another module's storage by knowing its name", async () => { const shared = fakeProvider(); const announcements = createModuleStorage({ diff --git a/packages/contracts/src/module-storage.ts b/packages/contracts/src/module-storage.ts index 9576522..e7efdd3 100644 --- a/packages/contracts/src/module-storage.ts +++ b/packages/contracts/src/module-storage.ts @@ -87,6 +87,28 @@ export class ModuleStorageError extends Error { } } +/** + * Whether `value` is a module-storage error, judged by its stable public shape rather than its class. + * + * An installed module bundles its own copy of the contracts package (ADR 0049), so an error created + * by the host's storage provider does not share class identity with the module's + * `ModuleStorageError`. `instanceof ModuleStorageError` is therefore not a valid check across the + * module boundary. + */ +export function isModuleStorageError( + value: unknown, +): value is Pick { + if (!(value instanceof Error) || value.name !== "ModuleStorageError") { + return false; + } + const { code, key } = value as { code?: unknown; key?: unknown }; + return ( + typeof code === "string" && + (MODULE_STORAGE_ERROR_CODES as readonly string[]).includes(code) && + (key === undefined || typeof key === "string") + ); +} + /** The S3 object-key ceiling, which every S3-compatible provider shares. Measured in UTF-8 bytes, * not characters, because that is what the wire format bounds. */ export const MODULE_STORAGE_KEY_MAX_BYTES = 1024; diff --git a/packages/module-sdk/src/index.ts b/packages/module-sdk/src/index.ts index e10363c..59c5a78 100644 --- a/packages/module-sdk/src/index.ts +++ b/packages/module-sdk/src/index.ts @@ -37,6 +37,7 @@ export { isDevicePrincipal, isModuleHttpMethod, isModuleRouteBodyMode, + isModuleStorageError, isValidModuleStorageKey, isValidModuleStorageReadUrlFilename, MODULE_HTTP_BODY_MODES, diff --git a/scripts/lib/deploy-smoke-display.mjs b/scripts/lib/deploy-smoke-display.mjs index f888afb..d155549 100644 --- a/scripts/lib/deploy-smoke-display.mjs +++ b/scripts/lib/deploy-smoke-display.mjs @@ -426,30 +426,47 @@ async function seedSignage(owner) { const base = "/api/v1/modules/signage"; const images = []; for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) { - const uploaded = await owner.evaluate( - `(async () => { - const canvas = document.createElement("canvas"); - canvas.width = 640; - canvas.height = 360; - const context = canvas.getContext("2d"); - context.fillStyle = ${JSON.stringify(colour)}; - context.fillRect(0, 0, 640, 360); - const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); - return fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, { - body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST", - signal: AbortSignal.timeout(${requestTimeoutMs}), - }).then( - async (response) => ({ status: response.status, body: await response.json().catch(() => null) }), - (error) => ({ status: error && error.name === "TimeoutError" ? "timeout" : "network-error" }), - ); - })()`, - `uploading slide ${index}`, - ); + let uploaded; + for (let attempt = 1; attempt <= 2; attempt += 1) { + uploaded = await owner.evaluate( + `(async () => { + const canvas = document.createElement("canvas"); + canvas.width = 640; + canvas.height = 360; + const context = canvas.getContext("2d"); + context.fillStyle = ${JSON.stringify(colour)}; + context.fillRect(0, 0, 640, 360); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); + return fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, { + body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST", + signal: AbortSignal.timeout(${requestTimeoutMs}), + }).then( + async (response) => ({ status: response.status, body: await response.json().catch(() => null) }), + (error) => ({ status: error && error.name === "TimeoutError" ? "timeout" : "network-error" }), + ); + })()`, + `uploading slide ${index}`, + ); + if ( + attempt === 1 && + uploaded.status === 503 && + uploaded.body?.code === "SIGNAGE_STORAGE_UNAVAILABLE" + ) { + note(`slide ${index} met one transient storage outage; retrying once`); + await sleep(1_000); + continue; + } + break; + } if (uploaded.status === "timeout") { throw new HangError(`HTTP POST ${base}/images from page "owner"`, requestTimeoutMs); } if (uploaded.status !== 200 && uploaded.status !== 201) { - throw new Error(`Uploading slide ${index} returned HTTP ${uploaded.status}.`); + const code = + typeof uploaded.body?.code === "string" && /^SIGNAGE_[A-Z_]+$/u.test(uploaded.body.code) + ? ` (${uploaded.body.code})` + : ""; + throw new Error(`Uploading slide ${index} returned HTTP ${uploaded.status}${code}.`); } images.push(uploaded.body?.image?.id); }