diff --git a/.changeset/fep-2612-parse-metadata.md b/.changeset/fep-2612-parse-metadata.md index e19b2329b..a445ec2d4 100644 --- a/.changeset/fep-2612-parse-metadata.md +++ b/.changeset/fep-2612-parse-metadata.md @@ -2,4 +2,4 @@ "@stackflow/plugin-stack-persistence": major --- -`StackSnapshotStrategy` now exposes `metadata.create` and `metadata.parse`, storage loads unknown metadata, and composed strategies persist and validate schema/version envelopes. +`StackSnapshotStrategy` now exposes `metadata.create` and `metadata.parse`, storage loads unknown metadata, and composed strategies persist and validate schema/version envelopes. Metadata parse failures can include details and are reported directly through `onRecordLoadError`. diff --git a/extensions/plugin-stack-persistence/src/StackSnapshotMetadataDefinition.ts b/extensions/plugin-stack-persistence/src/StackSnapshotMetadataDefinition.ts index 3af1d4e24..007fb6f2f 100644 --- a/extensions/plugin-stack-persistence/src/StackSnapshotMetadataDefinition.ts +++ b/extensions/plugin-stack-persistence/src/StackSnapshotMetadataDefinition.ts @@ -7,6 +7,7 @@ export type Result = } | { ok: false; + detail?: unknown; }; export interface StackSnapshotMetadataDefinition { diff --git a/extensions/plugin-stack-persistence/src/composeStrategies.ts b/extensions/plugin-stack-persistence/src/composeStrategies.ts index 940c4e16e..5d0c629a7 100644 --- a/extensions/plugin-stack-persistence/src/composeStrategies.ts +++ b/extensions/plugin-stack-persistence/src/composeStrategies.ts @@ -37,21 +37,43 @@ export function composeStrategies< }, parse(data) { if (data === null || typeof data !== "object") { - return { ok: false }; + return { + ok: false, + detail: new Error("composed strategy metadata must be an object"), + }; } const metadata = data as Record; if ( !Object.hasOwn(metadata, "schema") || + metadata.schema !== COMPOSED_METADATA_SCHEMA + ) { + return { + ok: false, + detail: new Error("invalid composed strategy metadata schema"), + }; + } + + if ( !Object.hasOwn(metadata, "version") || + metadata.version !== COMPOSED_METADATA_VERSION + ) { + return { + ok: false, + detail: new Error("unsupported composed strategy metadata version"), + }; + } + + if ( !Object.hasOwn(metadata, "data") || - metadata.schema !== COMPOSED_METADATA_SCHEMA || - metadata.version !== COMPOSED_METADATA_VERSION || metadata.data === null || typeof metadata.data !== "object" ) { - return { ok: false }; + return { + ok: false, + detail: new Error("composed strategy metadata data must be an object"), + }; } const metadataData = metadata.data as Record; @@ -60,7 +82,10 @@ export function composeStrategies< Object.keys(metadataData).length !== keys.length || !keys.every((key) => Object.hasOwn(metadataData, key)) ) { - return { ok: false }; + return { + ok: false, + detail: new Error("composed strategy metadata keys do not match"), + }; } const parsedEntries: Array<[PropertyKey, unknown]> = []; @@ -69,7 +94,13 @@ export function composeStrategies< const result = strategies[key].metadata.parse(metadataData[key]); if (!result.ok) { - return { ok: false }; + return { + ok: false, + detail: { + strategy: String(key), + detail: result.detail, + }, + }; } parsedEntries.push([key, result.value]); diff --git a/extensions/plugin-stack-persistence/src/errors.ts b/extensions/plugin-stack-persistence/src/errors.ts index b155fb6ba..1bd2ede86 100644 --- a/extensions/plugin-stack-persistence/src/errors.ts +++ b/extensions/plugin-stack-persistence/src/errors.ts @@ -17,3 +17,13 @@ export class StackSnapshotRecordLoadError extends Error { this.cause = cause; } } + +export class StackSnapshotMetadataParseError extends Error { + detail?: unknown; + + constructor(detail?: unknown) { + super("failed to parse stack snapshot metadata"); + this.name = "StackSnapshotMetadataParseError"; + this.detail = detail; + } +} diff --git a/extensions/plugin-stack-persistence/src/index.ts b/extensions/plugin-stack-persistence/src/index.ts index 30e1b2adc..97b58c705 100644 --- a/extensions/plugin-stack-persistence/src/index.ts +++ b/extensions/plugin-stack-persistence/src/index.ts @@ -3,6 +3,7 @@ export { type StrategiesMetadata, } from "./composeStrategies"; export { + StackSnapshotMetadataParseError, StackSnapshotRecordLoadError, StackSnapshotRecordSaveError, } from "./errors"; diff --git a/extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts b/extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts index aa13b3f5c..46a06ec25 100644 --- a/extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts +++ b/extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts @@ -1,5 +1,6 @@ import type { StackflowActions, StackflowPlugin } from "@stackflow/core"; import { + StackSnapshotMetadataParseError, StackSnapshotRecordLoadError, StackSnapshotRecordSaveError, } from "./errors"; @@ -9,7 +10,9 @@ import type { StackSnapshotStrategy } from "./StackSnapshotStrategy"; export type StackPersistencePluginOptions = { storage: StackSnapshotStorage; strategy: StackSnapshotStrategy; - onRecordLoadError?: (error: StackSnapshotRecordLoadError) => void; + onRecordLoadError?: ( + error: StackSnapshotRecordLoadError | StackSnapshotMetadataParseError, + ) => void; onRecordSaveError?: (error: StackSnapshotRecordSaveError) => void; onLoadError?: NonNullable["onLoadError"]>; }; @@ -46,30 +49,38 @@ export function stackPersistencePlugin({ return { key: "@stackflow/plugin-stack-persistence", provideSnapshot({ initialContext }) { + let record: ReturnType; + try { - const record = storage.load(); + record = storage.load(); + } catch (error) { + onRecordLoadError?.(new StackSnapshotRecordLoadError(error)); - if (!record) return null; + return null; + } - const parsedMetadata = strategy.metadata.parse(record.metadata); + if (!record) return null; - if (!parsedMetadata.ok) return null; + const parsedMetadata = strategy.metadata.parse(record.metadata); - const parsedRecord = { - ...record, - metadata: parsedMetadata.value, - }; + if (!parsedMetadata.ok) { + onRecordLoadError?.( + new StackSnapshotMetadataParseError(parsedMetadata.detail), + ); - if (!strategy.shouldReuse({ record: parsedRecord, initialContext })) { - return null; - } + return null; + } - return parsedRecord.snapshot; - } catch (error) { - onRecordLoadError?.(new StackSnapshotRecordLoadError(error)); + const parsedRecord = { + ...record, + metadata: parsedMetadata.value, + }; + if (!strategy.shouldReuse({ record: parsedRecord, initialContext })) { return null; } + + return parsedRecord.snapshot; }, onLoadError(...args) { return onLoadError?.(...args) ?? { policy: "recover" };