From 04bd05314f26ac515c4d97aea70e23c20f98551a Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Fri, 24 Jul 2026 12:45:54 +0900 Subject: [PATCH 1/5] fix(plugin-stack-persistence): report metadata parse failures --- .changeset/fep-2612-parse-metadata.md | 2 +- .../src/StackSnapshotMetadataDefinition.ts | 1 + .../src/composeStrategies.ts | 43 ++++++++++++++++--- .../plugin-stack-persistence/src/errors.ts | 10 +++++ .../plugin-stack-persistence/src/index.ts | 1 + .../src/stackPersistencePlugin.ts | 5 ++- 6 files changed, 54 insertions(+), 8 deletions(-) diff --git a/.changeset/fep-2612-parse-metadata.md b/.changeset/fep-2612-parse-metadata.md index e19b2329b..40efc0e86 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 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..bda067d96 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: "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: "invalid composed strategy metadata schema", + }; + } + + if ( !Object.hasOwn(metadata, "version") || + metadata.version !== COMPOSED_METADATA_VERSION + ) { + return { + ok: false, + detail: "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: "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: "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..1dd907bbd 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"; @@ -53,7 +54,9 @@ export function stackPersistencePlugin({ const parsedMetadata = strategy.metadata.parse(record.metadata); - if (!parsedMetadata.ok) return null; + if (!parsedMetadata.ok) { + throw new StackSnapshotMetadataParseError(parsedMetadata.detail); + } const parsedRecord = { ...record, From 8b2380193dd0b4f2940920868685b18e62e25653 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Fri, 24 Jul 2026 12:50:06 +0900 Subject: [PATCH 2/5] refactor(plugin-stack-persistence): separate metadata parse errors --- .changeset/fep-2612-parse-metadata.md | 2 +- .../src/stackPersistencePlugin.ts | 42 ++++++++++++++----- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/.changeset/fep-2612-parse-metadata.md b/.changeset/fep-2612-parse-metadata.md index 40efc0e86..5b436b171 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. Metadata parse failures can include details and are reported through `onRecordLoadError`. +`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 through `onMetadataParseError`. diff --git a/extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts b/extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts index 1dd907bbd..6c8c8565a 100644 --- a/extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts +++ b/extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts @@ -10,6 +10,7 @@ import type { StackSnapshotStrategy } from "./StackSnapshotStrategy"; export type StackPersistencePluginOptions = { storage: StackSnapshotStorage; strategy: StackSnapshotStrategy; + onMetadataParseError?: (error: StackSnapshotMetadataParseError) => void; onRecordLoadError?: (error: StackSnapshotRecordLoadError) => void; onRecordSaveError?: (error: StackSnapshotRecordSaveError) => void; onLoadError?: NonNullable["onLoadError"]>; @@ -18,6 +19,7 @@ export type StackPersistencePluginOptions = { export function stackPersistencePlugin({ storage, strategy, + onMetadataParseError, onRecordLoadError, onRecordSaveError, onLoadError, @@ -47,22 +49,42 @@ 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)); + + return null; + } - if (!record) return null; + if (!record) return null; - const parsedMetadata = strategy.metadata.parse(record.metadata); + let parsedMetadata: ReturnType; - if (!parsedMetadata.ok) { - throw new StackSnapshotMetadataParseError(parsedMetadata.detail); - } + try { + parsedMetadata = strategy.metadata.parse(record.metadata); + } catch (detail) { + onMetadataParseError?.(new StackSnapshotMetadataParseError(detail)); - const parsedRecord = { - ...record, - metadata: parsedMetadata.value, - }; + return null; + } + + if (!parsedMetadata.ok) { + onMetadataParseError?.( + new StackSnapshotMetadataParseError(parsedMetadata.detail), + ); + + return null; + } + const parsedRecord = { + ...record, + metadata: parsedMetadata.value, + }; + + try { if (!strategy.shouldReuse({ record: parsedRecord, initialContext })) { return null; } From 7625dd424f969cfb19a395b2340e595ad3de2d9c Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Fri, 24 Jul 2026 12:51:12 +0900 Subject: [PATCH 3/5] refactor(plugin-stack-persistence): expose distinct load errors --- .changeset/fep-2612-parse-metadata.md | 2 +- .../src/stackPersistencePlugin.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.changeset/fep-2612-parse-metadata.md b/.changeset/fep-2612-parse-metadata.md index 5b436b171..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. Metadata parse failures can include details and are reported through `onMetadataParseError`. +`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/stackPersistencePlugin.ts b/extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts index 6c8c8565a..23876da9a 100644 --- a/extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts +++ b/extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts @@ -10,8 +10,9 @@ import type { StackSnapshotStrategy } from "./StackSnapshotStrategy"; export type StackPersistencePluginOptions = { storage: StackSnapshotStorage; strategy: StackSnapshotStrategy; - onMetadataParseError?: (error: StackSnapshotMetadataParseError) => void; - onRecordLoadError?: (error: StackSnapshotRecordLoadError) => void; + onRecordLoadError?: ( + error: StackSnapshotRecordLoadError | StackSnapshotMetadataParseError, + ) => void; onRecordSaveError?: (error: StackSnapshotRecordSaveError) => void; onLoadError?: NonNullable["onLoadError"]>; }; @@ -19,7 +20,6 @@ export type StackPersistencePluginOptions = { export function stackPersistencePlugin({ storage, strategy, - onMetadataParseError, onRecordLoadError, onRecordSaveError, onLoadError, @@ -66,13 +66,13 @@ export function stackPersistencePlugin({ try { parsedMetadata = strategy.metadata.parse(record.metadata); } catch (detail) { - onMetadataParseError?.(new StackSnapshotMetadataParseError(detail)); + onRecordLoadError?.(new StackSnapshotMetadataParseError(detail)); return null; } if (!parsedMetadata.ok) { - onMetadataParseError?.( + onRecordLoadError?.( new StackSnapshotMetadataParseError(parsedMetadata.detail), ); From 63063e74ea4d01bbd63a075b51a361c54a621502 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Fri, 24 Jul 2026 15:58:05 +0900 Subject: [PATCH 4/5] ignore runtime errors --- .../src/stackPersistencePlugin.ts | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts b/extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts index 23876da9a..46a06ec25 100644 --- a/extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts +++ b/extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts @@ -61,15 +61,7 @@ export function stackPersistencePlugin({ if (!record) return null; - let parsedMetadata: ReturnType; - - try { - parsedMetadata = strategy.metadata.parse(record.metadata); - } catch (detail) { - onRecordLoadError?.(new StackSnapshotMetadataParseError(detail)); - - return null; - } + const parsedMetadata = strategy.metadata.parse(record.metadata); if (!parsedMetadata.ok) { onRecordLoadError?.( @@ -84,17 +76,11 @@ export function stackPersistencePlugin({ metadata: parsedMetadata.value, }; - try { - if (!strategy.shouldReuse({ record: parsedRecord, initialContext })) { - return null; - } - - return parsedRecord.snapshot; - } catch (error) { - onRecordLoadError?.(new StackSnapshotRecordLoadError(error)); - + if (!strategy.shouldReuse({ record: parsedRecord, initialContext })) { return null; } + + return parsedRecord.snapshot; }, onLoadError(...args) { return onLoadError?.(...args) ?? { policy: "recover" }; From af3604c6fb0e36fb5b1d2f8eb19efd38987218c4 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Fri, 24 Jul 2026 16:01:57 +0900 Subject: [PATCH 5/5] use error objects --- .../plugin-stack-persistence/src/composeStrategies.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/plugin-stack-persistence/src/composeStrategies.ts b/extensions/plugin-stack-persistence/src/composeStrategies.ts index bda067d96..5d0c629a7 100644 --- a/extensions/plugin-stack-persistence/src/composeStrategies.ts +++ b/extensions/plugin-stack-persistence/src/composeStrategies.ts @@ -39,7 +39,7 @@ export function composeStrategies< if (data === null || typeof data !== "object") { return { ok: false, - detail: "composed strategy metadata must be an object", + detail: new Error("composed strategy metadata must be an object"), }; } @@ -51,7 +51,7 @@ export function composeStrategies< ) { return { ok: false, - detail: "invalid composed strategy metadata schema", + detail: new Error("invalid composed strategy metadata schema"), }; } @@ -61,7 +61,7 @@ export function composeStrategies< ) { return { ok: false, - detail: "unsupported composed strategy metadata version", + detail: new Error("unsupported composed strategy metadata version"), }; } @@ -72,7 +72,7 @@ export function composeStrategies< ) { return { ok: false, - detail: "composed strategy metadata data must be an object", + detail: new Error("composed strategy metadata data must be an object"), }; } @@ -84,7 +84,7 @@ export function composeStrategies< ) { return { ok: false, - detail: "composed strategy metadata keys do not match", + detail: new Error("composed strategy metadata keys do not match"), }; }