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
5 changes: 5 additions & 0 deletions .changeset/fep-2612-parse-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Stack, StackSnapshot } from "@stackflow/core";

export type Result<Value> =
| {
ok: true;
value: Value;
}
| {
ok: false;
};

export interface StackSnapshotMetadataDefinition<Metadata> {
create(args: { stack: Stack; snapshot: StackSnapshot }): Metadata;
parse(data: unknown): Result<Metadata>;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { StackSnapshotRecord } from "./StackSnapshotRecord";

export interface StackSnapshotStorage<Metadata> {
load(): StackSnapshotRecord<Metadata> | null;
load(): StackSnapshotRecord<unknown> | null;
save(record: StackSnapshotRecord<Metadata>): Promise<void>;
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Stack, StackSnapshot } from "@stackflow/core";
import type { StackSnapshotMetadataDefinition } from "./StackSnapshotMetadataDefinition";
import type { StackSnapshotRecord } from "./StackSnapshotRecord";

export interface StackSnapshotStrategy<Metadata> {
createMetadata(args: { stack: Stack; snapshot: StackSnapshot }): Metadata;
metadata: StackSnapshotMetadataDefinition<Metadata>;

shouldReuse(args: {
record: StackSnapshotRecord<Metadata>;
Expand Down
86 changes: 72 additions & 14 deletions extensions/plugin-stack-persistence/src/composeStrategies.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import type { StackSnapshotStrategy } from "./StackSnapshotStrategy";

const COMPOSED_METADATA_SCHEMA = "stackflow.compose-strategies";
const COMPOSED_METADATA_VERSION = 1;

type StrategiesMetadataData<
Strategies extends Record<string, StackSnapshotStrategy<any>>,
> = {
[Key in keyof Strategies]: ReturnType<Strategies[Key]["metadata"]["create"]>;
};

export type StrategiesMetadata<
Strategies extends Record<string, StackSnapshotStrategy<any>>,
> = {
[Key in keyof Strategies]: Strategies[Key] extends StackSnapshotStrategy<
infer Metadata
>
? Metadata
: never;
readonly schema: typeof COMPOSED_METADATA_SCHEMA;
readonly version: typeof COMPOSED_METADATA_VERSION;
readonly data: StrategiesMetadataData<Strategies>;
};

export function composeStrategies<
Expand All @@ -18,21 +25,72 @@ export function composeStrategies<
const keys = Object.keys(strategies) as Array<keyof Strategies>;

return {
createMetadata(args) {
return Object.fromEntries(
keys.map((key) => [key, strategies[key].createMetadata(args)]),
) as StrategiesMetadata<Strategies>
metadata: {
create(args) {
return {
schema: COMPOSED_METADATA_SCHEMA,
version: COMPOSED_METADATA_VERSION,
data: Object.fromEntries(
keys.map((key) => [key, strategies[key].metadata.create(args)]),
),
} as StrategiesMetadata<Strategies>;
},
parse(data) {
if (data === null || typeof data !== "object") {
return { ok: false };
}

const metadata = data as Record<PropertyKey, unknown>;

if (
!Object.hasOwn(metadata, "schema") ||
!Object.hasOwn(metadata, "version") ||
!Object.hasOwn(metadata, "data") ||
metadata.schema !== COMPOSED_METADATA_SCHEMA ||
metadata.version !== COMPOSED_METADATA_VERSION ||
metadata.data === null ||
typeof metadata.data !== "object"
) {
return { ok: false };
}

const metadataData = metadata.data as Record<PropertyKey, unknown>;

if (
Object.keys(metadataData).length !== keys.length ||
!keys.every((key) => Object.hasOwn(metadataData, key))
) {
return { ok: false };
}

const parsedEntries: Array<[PropertyKey, unknown]> = [];

for (const key of keys) {
const result = strategies[key].metadata.parse(metadataData[key]);

if (!result.ok) {
return { ok: false };
}

parsedEntries.push([key, result.value]);
}

return {
ok: true,
value: {
schema: COMPOSED_METADATA_SCHEMA,
version: COMPOSED_METADATA_VERSION,
data: Object.fromEntries(parsedEntries),
} as StrategiesMetadata<Strategies>,
};
},
},
shouldReuse({ record, initialContext }) {
if (!keys.every((key) => Object.hasOwn(record.metadata, key))) {
return false;
}

return keys.every((key) => {
return strategies[key].shouldReuse({
record: {
...record,
metadata: record.metadata[key],
metadata: record.metadata.data[key],
},
initialContext,
});
Expand Down
14 changes: 9 additions & 5 deletions extensions/plugin-stack-persistence/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
export {
composeStrategies,
type StrategiesMetadata
type StrategiesMetadata,
} from "./composeStrategies";
export {
StackSnapshotRecordLoadError,
StackSnapshotRecordSaveError,
} from "./errors";
export { StackSnapshotRecord } from "./StackSnapshotRecord";
export { StackSnapshotStorage } from "./StackSnapshotStorage";
export { StackSnapshotStrategy } from "./StackSnapshotStrategy";
export type {
Result,
StackSnapshotMetadataDefinition,
} from "./StackSnapshotMetadataDefinition";
export type { StackSnapshotRecord } from "./StackSnapshotRecord";
export type { StackSnapshotStorage } from "./StackSnapshotStorage";
export type { StackSnapshotStrategy } from "./StackSnapshotStrategy";
export {
StackPersistencePluginOptions,
type StackPersistencePluginOptions,
stackPersistencePlugin,
} from "./stackPersistencePlugin";
66 changes: 41 additions & 25 deletions extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type {
StackflowActions,
StackflowPlugin,
} from "@stackflow/core";
import { StackSnapshotRecordSaveError, StackSnapshotRecordLoadError } from "./errors";
import type { StackflowActions, StackflowPlugin } from "@stackflow/core";
import {
StackSnapshotRecordLoadError,
StackSnapshotRecordSaveError,
} from "./errors";
import type { StackSnapshotStorage } from "./StackSnapshotStorage";
import type { StackSnapshotStrategy } from "./StackSnapshotStrategy";

Expand All @@ -11,52 +11,68 @@ export type StackPersistencePluginOptions<Metadata> = {
strategy: StackSnapshotStrategy<Metadata>;
onRecordLoadError?: (error: StackSnapshotRecordLoadError) => void;
onRecordSaveError?: (error: StackSnapshotRecordSaveError) => void;
onLoadError?: NonNullable<ReturnType<StackflowPlugin>['onLoadError']>;
}
onLoadError?: NonNullable<ReturnType<StackflowPlugin>["onLoadError"]>;
};

export function stackPersistencePlugin<Metadata>(
{ storage, strategy, onRecordLoadError, onRecordSaveError, onLoadError }: StackPersistencePluginOptions<Metadata>,
): StackflowPlugin {
export function stackPersistencePlugin<Metadata>({
storage,
strategy,
onRecordLoadError,
onRecordSaveError,
onLoadError,
}: StackPersistencePluginOptions<Metadata>): StackflowPlugin {
return () => {
const saveIfIdle = (actions: StackflowActions) => {
const stack = actions.getStack();

if (stack.globalTransitionState !== "idle") return;

const snapshot = actions.captureSnapshot();
const metadata = strategy.createMetadata({ stack, snapshot });
const metadata = strategy.metadata.create({ stack, snapshot });

storage.save({
snapshot,
metadata
}).catch(error => {
const saveError = new StackSnapshotRecordSaveError(error);
storage
.save({
snapshot,
metadata,
})
.catch((error) => {
const saveError = new StackSnapshotRecordSaveError(error);

if (onRecordSaveError) return onRecordSaveError(saveError);
else throw saveError;
});
}
if (onRecordSaveError) return onRecordSaveError(saveError);
else throw saveError;
});
};

return {
key: "@stackflow/plugin-stack-persistence",
provideSnapshot({ initialContext }) {
try {
const record = storage.load();

if (!record)
return null;
if (strategy?.shouldReuse({ record, initialContext }) === false)
if (!record) return null;

const parsedMetadata = strategy.metadata.parse(record.metadata);

if (!parsedMetadata.ok) return null;

const parsedRecord = {
...record,
metadata: parsedMetadata.value,
};

if (!strategy.shouldReuse({ record: parsedRecord, initialContext })) {
return null;
}

return record.snapshot;
return parsedRecord.snapshot;
} catch (error) {
onRecordLoadError?.(new StackSnapshotRecordLoadError(error));

return null;
}
},
onLoadError(...args) {
return onLoadError?.(...args) ?? { policy: "recover" }
return onLoadError?.(...args) ?? { policy: "recover" };
},
onInit({ actions }) {
saveIfIdle(actions);
Expand Down
Loading