Skip to content

Commit b97074f

Browse files
feat(plugin-stack-persistence): parse persisted metadata at the storage boundary (#735)
1 parent 42639c0 commit b97074f

7 files changed

Lines changed: 145 additions & 47 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@stackflow/plugin-stack-persistence": major
3+
---
4+
5+
`StackSnapshotStrategy` now exposes `metadata.create` and `metadata.parse`, storage loads unknown metadata, and composed strategies persist and validate schema/version envelopes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Stack, StackSnapshot } from "@stackflow/core";
2+
3+
export type Result<Value> =
4+
| {
5+
ok: true;
6+
value: Value;
7+
}
8+
| {
9+
ok: false;
10+
};
11+
12+
export interface StackSnapshotMetadataDefinition<Metadata> {
13+
create(args: { stack: Stack; snapshot: StackSnapshot }): Metadata;
14+
parse(data: unknown): Result<Metadata>;
15+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { StackSnapshotRecord } from "./StackSnapshotRecord";
22

33
export interface StackSnapshotStorage<Metadata> {
4-
load(): StackSnapshotRecord<Metadata> | null;
4+
load(): StackSnapshotRecord<unknown> | null;
55
save(record: StackSnapshotRecord<Metadata>): Promise<void>;
66
}

extensions/plugin-stack-persistence/src/StackSnapshotStrategy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import type { Stack, StackSnapshot } from "@stackflow/core";
1+
import type { StackSnapshotMetadataDefinition } from "./StackSnapshotMetadataDefinition";
22
import type { StackSnapshotRecord } from "./StackSnapshotRecord";
33

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

77
shouldReuse(args: {
88
record: StackSnapshotRecord<Metadata>;

extensions/plugin-stack-persistence/src/composeStrategies.ts

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import type { StackSnapshotStrategy } from "./StackSnapshotStrategy";
22

3+
const COMPOSED_METADATA_SCHEMA = "stackflow.compose-strategies";
4+
const COMPOSED_METADATA_VERSION = 1;
5+
6+
type StrategiesMetadataData<
7+
Strategies extends Record<string, StackSnapshotStrategy<any>>,
8+
> = {
9+
[Key in keyof Strategies]: ReturnType<Strategies[Key]["metadata"]["create"]>;
10+
};
11+
312
export type StrategiesMetadata<
413
Strategies extends Record<string, StackSnapshotStrategy<any>>,
514
> = {
6-
[Key in keyof Strategies]: Strategies[Key] extends StackSnapshotStrategy<
7-
infer Metadata
8-
>
9-
? Metadata
10-
: never;
15+
readonly schema: typeof COMPOSED_METADATA_SCHEMA;
16+
readonly version: typeof COMPOSED_METADATA_VERSION;
17+
readonly data: StrategiesMetadataData<Strategies>;
1118
};
1219

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

2027
return {
21-
createMetadata(args) {
22-
return Object.fromEntries(
23-
keys.map((key) => [key, strategies[key].createMetadata(args)]),
24-
) as StrategiesMetadata<Strategies>
28+
metadata: {
29+
create(args) {
30+
return {
31+
schema: COMPOSED_METADATA_SCHEMA,
32+
version: COMPOSED_METADATA_VERSION,
33+
data: Object.fromEntries(
34+
keys.map((key) => [key, strategies[key].metadata.create(args)]),
35+
),
36+
} as StrategiesMetadata<Strategies>;
37+
},
38+
parse(data) {
39+
if (data === null || typeof data !== "object") {
40+
return { ok: false };
41+
}
42+
43+
const metadata = data as Record<PropertyKey, unknown>;
44+
45+
if (
46+
!Object.hasOwn(metadata, "schema") ||
47+
!Object.hasOwn(metadata, "version") ||
48+
!Object.hasOwn(metadata, "data") ||
49+
metadata.schema !== COMPOSED_METADATA_SCHEMA ||
50+
metadata.version !== COMPOSED_METADATA_VERSION ||
51+
metadata.data === null ||
52+
typeof metadata.data !== "object"
53+
) {
54+
return { ok: false };
55+
}
56+
57+
const metadataData = metadata.data as Record<PropertyKey, unknown>;
58+
59+
if (
60+
Object.keys(metadataData).length !== keys.length ||
61+
!keys.every((key) => Object.hasOwn(metadataData, key))
62+
) {
63+
return { ok: false };
64+
}
65+
66+
const parsedEntries: Array<[PropertyKey, unknown]> = [];
67+
68+
for (const key of keys) {
69+
const result = strategies[key].metadata.parse(metadataData[key]);
70+
71+
if (!result.ok) {
72+
return { ok: false };
73+
}
74+
75+
parsedEntries.push([key, result.value]);
76+
}
77+
78+
return {
79+
ok: true,
80+
value: {
81+
schema: COMPOSED_METADATA_SCHEMA,
82+
version: COMPOSED_METADATA_VERSION,
83+
data: Object.fromEntries(parsedEntries),
84+
} as StrategiesMetadata<Strategies>,
85+
};
86+
},
2587
},
2688
shouldReuse({ record, initialContext }) {
27-
if (!keys.every((key) => Object.hasOwn(record.metadata, key))) {
28-
return false;
29-
}
30-
3189
return keys.every((key) => {
3290
return strategies[key].shouldReuse({
3391
record: {
3492
...record,
35-
metadata: record.metadata[key],
93+
metadata: record.metadata.data[key],
3694
},
3795
initialContext,
3896
});
Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
export {
22
composeStrategies,
3-
type StrategiesMetadata
3+
type StrategiesMetadata,
44
} from "./composeStrategies";
55
export {
66
StackSnapshotRecordLoadError,
77
StackSnapshotRecordSaveError,
88
} from "./errors";
9-
export { StackSnapshotRecord } from "./StackSnapshotRecord";
10-
export { StackSnapshotStorage } from "./StackSnapshotStorage";
11-
export { StackSnapshotStrategy } from "./StackSnapshotStrategy";
9+
export type {
10+
Result,
11+
StackSnapshotMetadataDefinition,
12+
} from "./StackSnapshotMetadataDefinition";
13+
export type { StackSnapshotRecord } from "./StackSnapshotRecord";
14+
export type { StackSnapshotStorage } from "./StackSnapshotStorage";
15+
export type { StackSnapshotStrategy } from "./StackSnapshotStrategy";
1216
export {
13-
StackPersistencePluginOptions,
17+
type StackPersistencePluginOptions,
1418
stackPersistencePlugin,
1519
} from "./stackPersistencePlugin";

extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import type {
2-
StackflowActions,
3-
StackflowPlugin,
4-
} from "@stackflow/core";
5-
import { StackSnapshotRecordSaveError, StackSnapshotRecordLoadError } from "./errors";
1+
import type { StackflowActions, StackflowPlugin } from "@stackflow/core";
2+
import {
3+
StackSnapshotRecordLoadError,
4+
StackSnapshotRecordSaveError,
5+
} from "./errors";
66
import type { StackSnapshotStorage } from "./StackSnapshotStorage";
77
import type { StackSnapshotStrategy } from "./StackSnapshotStrategy";
88

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

17-
export function stackPersistencePlugin<Metadata>(
18-
{ storage, strategy, onRecordLoadError, onRecordSaveError, onLoadError }: StackPersistencePluginOptions<Metadata>,
19-
): StackflowPlugin {
17+
export function stackPersistencePlugin<Metadata>({
18+
storage,
19+
strategy,
20+
onRecordLoadError,
21+
onRecordSaveError,
22+
onLoadError,
23+
}: StackPersistencePluginOptions<Metadata>): StackflowPlugin {
2024
return () => {
2125
const saveIfIdle = (actions: StackflowActions) => {
2226
const stack = actions.getStack();
2327

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

2630
const snapshot = actions.captureSnapshot();
27-
const metadata = strategy.createMetadata({ stack, snapshot });
31+
const metadata = strategy.metadata.create({ stack, snapshot });
2832

29-
storage.save({
30-
snapshot,
31-
metadata
32-
}).catch(error => {
33-
const saveError = new StackSnapshotRecordSaveError(error);
33+
storage
34+
.save({
35+
snapshot,
36+
metadata,
37+
})
38+
.catch((error) => {
39+
const saveError = new StackSnapshotRecordSaveError(error);
3440

35-
if (onRecordSaveError) return onRecordSaveError(saveError);
36-
else throw saveError;
37-
});
38-
}
41+
if (onRecordSaveError) return onRecordSaveError(saveError);
42+
else throw saveError;
43+
});
44+
};
3945

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

46-
if (!record)
47-
return null;
48-
if (strategy?.shouldReuse({ record, initialContext }) === false)
52+
if (!record) return null;
53+
54+
const parsedMetadata = strategy.metadata.parse(record.metadata);
55+
56+
if (!parsedMetadata.ok) return null;
57+
58+
const parsedRecord = {
59+
...record,
60+
metadata: parsedMetadata.value,
61+
};
62+
63+
if (!strategy.shouldReuse({ record: parsedRecord, initialContext })) {
4964
return null;
65+
}
5066

51-
return record.snapshot;
67+
return parsedRecord.snapshot;
5268
} catch (error) {
5369
onRecordLoadError?.(new StackSnapshotRecordLoadError(error));
5470

5571
return null;
5672
}
5773
},
5874
onLoadError(...args) {
59-
return onLoadError?.(...args) ?? { policy: "recover" }
75+
return onLoadError?.(...args) ?? { policy: "recover" };
6076
},
6177
onInit({ actions }) {
6278
saveIfIdle(actions);

0 commit comments

Comments
 (0)