Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/fep-2612-parse-metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type Result<Value> =
}
| {
ok: false;
detail?: unknown;
};

export interface StackSnapshotMetadataDefinition<Metadata> {
Expand Down
43 changes: 37 additions & 6 deletions extensions/plugin-stack-persistence/src/composeStrategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PropertyKey, unknown>;

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<PropertyKey, unknown>;
Expand All @@ -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]> = [];
Expand All @@ -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]);
Expand Down
10 changes: 10 additions & 0 deletions extensions/plugin-stack-persistence/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
1 change: 1 addition & 0 deletions extensions/plugin-stack-persistence/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export {
type StrategiesMetadata,
} from "./composeStrategies";
export {
StackSnapshotMetadataParseError,
StackSnapshotRecordLoadError,
StackSnapshotRecordSaveError,
} from "./errors";
Expand Down
41 changes: 26 additions & 15 deletions extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { StackflowActions, StackflowPlugin } from "@stackflow/core";
import {
StackSnapshotMetadataParseError,
StackSnapshotRecordLoadError,
StackSnapshotRecordSaveError,
} from "./errors";
Expand All @@ -9,7 +10,9 @@ import type { StackSnapshotStrategy } from "./StackSnapshotStrategy";
export type StackPersistencePluginOptions<Metadata> = {
storage: StackSnapshotStorage<Metadata>;
strategy: StackSnapshotStrategy<Metadata>;
onRecordLoadError?: (error: StackSnapshotRecordLoadError) => void;
onRecordLoadError?: (
error: StackSnapshotRecordLoadError | StackSnapshotMetadataParseError,
) => void;
onRecordSaveError?: (error: StackSnapshotRecordSaveError) => void;
onLoadError?: NonNullable<ReturnType<StackflowPlugin>["onLoadError"]>;
};
Expand Down Expand Up @@ -46,30 +49,38 @@ export function stackPersistencePlugin<Metadata>({
return {
key: "@stackflow/plugin-stack-persistence",
provideSnapshot({ initialContext }) {
let record: ReturnType<typeof storage.load>;

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" };
Expand Down
Loading