From 37053dd3c26112f5cd7a63e257690ee4dc5d1880 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Tue, 4 Aug 2026 12:56:49 +0900 Subject: [PATCH 1/7] docs(plugin-stack-persistence): add README (FEP-2672) --- extensions/plugin-stack-persistence/README.md | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 extensions/plugin-stack-persistence/README.md diff --git a/extensions/plugin-stack-persistence/README.md b/extensions/plugin-stack-persistence/README.md new file mode 100644 index 000000000..614a27531 --- /dev/null +++ b/extensions/plugin-stack-persistence/README.md @@ -0,0 +1,287 @@ +# @stackflow/plugin-stack-persistence + +Persist a Stackflow navigation snapshot beyond the lifetime of the JavaScript +runtime and restore it when the stack starts again. The package is +framework-neutral: it uses the `@stackflow/core` plugin contract and leaves the +storage medium, serialization, record lifetime, and reuse policy to your +application. + +## Installation + +```bash +yarn add @stackflow/plugin-stack-persistence +``` + +This package requires `@stackflow/core` 3.x. + +## Setup + +Create a synchronous loader, an asynchronous saver, and a strategy that +validates stored metadata and decides whether its snapshot can be reused. + +The following example stores snapshots in `localStorage`, rejects records from +another application version, and expires records after seven days: + +```typescript +import type { + StackSnapshotRecord, + StackSnapshotStorage, + StackSnapshotStrategy, +} from "@stackflow/plugin-stack-persistence"; +import { stackPersistencePlugin } from "@stackflow/plugin-stack-persistence"; + +const STORAGE_KEY = "stackflow.snapshot"; +const APP_VERSION = 1 as const; +const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000; + +type SnapshotMetadata = { + appVersion: typeof APP_VERSION; + savedAt: number; +}; + +const storage: StackSnapshotStorage = { + load() { + if (typeof window === "undefined") return null; + + const serialized = window.localStorage.getItem(STORAGE_KEY); + + return serialized === null + ? null + : (JSON.parse(serialized) as StackSnapshotRecord); + }, + async save(record) { + if (typeof window === "undefined") return; + + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(record)); + }, +}; + +const strategy: StackSnapshotStrategy = { + metadata: { + create() { + return { + appVersion: APP_VERSION, + savedAt: Date.now(), + }; + }, + parse(data) { + if ( + data === null || + typeof data !== "object" || + !("appVersion" in data) || + data.appVersion !== APP_VERSION || + !("savedAt" in data) || + typeof data.savedAt !== "number" + ) { + return { + ok: false, + detail: "invalid snapshot metadata", + }; + } + + return { + ok: true, + value: { + appVersion: APP_VERSION, + savedAt: data.savedAt, + }, + }; + }, + }, + shouldReuse({ record }) { + return Date.now() - record.metadata.savedAt < MAX_AGE_MS; + }, +}; + +export const persistencePlugin = stackPersistencePlugin({ + storage, + strategy, + onRecordLoadError(error) { + console.warn("Could not read the saved Stackflow snapshot", error); + }, + onRecordSaveError(error) { + console.error("Could not save the Stackflow snapshot", error); + }, +}); +``` + +Add the plugin to an existing Stackflow configuration: + +```typescript +import { stackflow } from "@stackflow/react"; +import { ArticleActivity } from "./ArticleActivity"; +import { HomeActivity } from "./HomeActivity"; +import { persistencePlugin } from "./persistence"; +import { config } from "./stackflow.config"; + +const { Stack } = stackflow({ + config, + components: { + HomeActivity, + ArticleActivity, + }, + plugins: [persistencePlugin], +}); +``` + +## Behavior + +### Restoring a snapshot + +Stackflow calls `storage.load()` synchronously while creating the stack. When a +record is present, the plugin: + +1. passes its untrusted `metadata` through `strategy.metadata.parse()`; +2. passes the parsed record and Stackflow's `initialContext` to + `strategy.shouldReuse()`; and +3. provides the snapshot to Stackflow when the strategy returns `true`. + +Returning `null` from `storage.load()`, returning `false` from `shouldReuse()`, +or returning `{ ok: false }` from `metadata.parse()` causes Stackflow to use its +normal initial stack. A thrown `storage.load()` error has the same fallback and +is reported as `StackSnapshotRecordLoadError` through `onRecordLoadError`. +Metadata parse failures are reported as `StackSnapshotMetadataParseError`. + +After the plugin accepts a record, core still validates and replays its +snapshot against the current Stackflow configuration. `onLoadError` controls +what happens when that step fails: + +```typescript +stackPersistencePlugin({ + storage, + strategy, + onLoadError({ error, initialContext }) { + reportSnapshotError(error, initialContext); + + return { policy: "propagate" }; + }, +}); +``` + +The default policy is `{ policy: "recover" }`, which discards the unusable +snapshot and creates the normal initial stack. Return `{ policy: "propagate" }` +to let the core `SnapshotLoadError` abort stack creation. + +`storage.load()`, metadata parsing, the reuse decision, and snapshot loading +are all synchronous. Prepare data before creating the stack when the backing +store has an asynchronous read API. In server environments, return `null` when +the chosen storage is unavailable, as in the example above. + +### Saving snapshots + +The plugin captures a record during stack initialization and after stack +changes, but calls `storage.save()` only when `globalTransitionState` is +`"idle"`. Each record contains the complete core snapshot and metadata created +from the same current `Stack` and `StackSnapshot`. The `metadata.create()` +callback receives both values. + +`storage.save()` runs asynchronously and does not block navigation. The plugin +does not wait for an earlier save before starting a later one, so storage backed +by asynchronous I/O must prevent an older request from overwriting a newer +record. A rejected save is wrapped in `StackSnapshotRecordSaveError` and sent +to `onRecordSaveError`. Without a handler, the wrapped error is rethrown from +the promise rejection. + +The storage owns serialization. Ensure that the selected codec can represent +the values carried by your application's snapshot events and metadata. + +### Composing reuse policies + +Use `composeStrategies()` when a record must satisfy several independent reuse +policies: + +```typescript +import { composeStrategies } from "@stackflow/plugin-stack-persistence"; + +const strategy = composeStrategies({ + appVersion: appVersionStrategy, + session: sessionStrategy, +}); +``` + +The composed strategy stores a versioned metadata envelope. On load, it +requires exactly the same strategy keys, parses each strategy's metadata, and +reuses the snapshot only when every `shouldReuse()` call returns `true`. + +## Error handling + +- `onRecordLoadError` receives `StackSnapshotRecordLoadError` when + `storage.load()` throws and `StackSnapshotMetadataParseError` when + `metadata.parse()` returns `{ ok: false }`. In both cases, startup falls back + to the normal initial stack. +- `onLoadError` receives core `SnapshotLoadError` values for snapshots that + cannot be loaded with the current configuration. It recovers by default. +- `onRecordSaveError` receives `StackSnapshotRecordSaveError` when the promise + returned by `storage.save()` rejects. + +The error wrappers expose the original value as `cause` for record load/save +errors and as `detail` for metadata parse errors. Exceptions thrown directly by +`metadata.parse()` or `shouldReuse()` are outside these recovery callbacks and +propagate during stack creation. Return `{ ok: false, detail }` or `false` for +expected rejection paths. + +## Public API + +### `stackPersistencePlugin(options)` + +Creates a Stackflow core plugin. `options` contains: + +- `storage` — required `StackSnapshotStorage` implementation; +- `strategy` — required `StackSnapshotStrategy` implementation; +- `onRecordLoadError` — optional storage-load and metadata-parse error handler; +- `onRecordSaveError` — optional save-rejection handler; and +- `onLoadError` — optional core snapshot-load policy handler. + +Only one Stackflow plugin can provide a non-null snapshot during stack +creation. If this plugin accepts a record while another plugin also provides a +snapshot, core rejects the conflicting configuration. + +### Storage and record types + +```typescript +interface StackSnapshotStorage { + load(): StackSnapshotRecord | null; + save(record: StackSnapshotRecord): Promise; +} + +type StackSnapshotRecord = { + snapshot: StackSnapshot; + metadata: Metadata; +}; +``` + +Loaded metadata is deliberately `unknown`; the strategy must validate it before +the plugin can use the record. + +### Strategy types + +```typescript +interface StackSnapshotMetadataDefinition { + create(args: { stack: Stack; snapshot: StackSnapshot }): Metadata; + parse(data: unknown): Result; +} + +interface StackSnapshotStrategy { + metadata: StackSnapshotMetadataDefinition; + shouldReuse(args: { + record: StackSnapshotRecord; + initialContext: unknown; + }): boolean; +} + +type Result = + | { ok: true; value: Value } + | { ok: false; detail?: unknown }; +``` + +`composeStrategies()` returns another `StackSnapshotStrategy`, so composed +strategies can be passed to `stackPersistencePlugin()` without special setup. +The inferred envelope type is exported as `StrategiesMetadata`. + +### Error classes + +- `StackSnapshotRecordLoadError` — exposes the thrown storage value as `cause`. +- `StackSnapshotMetadataParseError` — exposes the parser failure detail as + `detail`. +- `StackSnapshotRecordSaveError` — exposes the rejected storage value as + `cause`. From 390e757071c664021b39957c176fb50366ed9ffc Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Tue, 4 Aug 2026 15:41:39 +0900 Subject: [PATCH 2/7] docs(plugin-stack-persistence): align README structure (FEP-2672) --- extensions/plugin-stack-persistence/README.md | 178 ++++++++++-------- 1 file changed, 96 insertions(+), 82 deletions(-) diff --git a/extensions/plugin-stack-persistence/README.md b/extensions/plugin-stack-persistence/README.md index 614a27531..d6aa91c83 100644 --- a/extensions/plugin-stack-persistence/README.md +++ b/extensions/plugin-stack-persistence/README.md @@ -1,10 +1,13 @@ # @stackflow/plugin-stack-persistence -Persist a Stackflow navigation snapshot beyond the lifetime of the JavaScript -runtime and restore it when the stack starts again. The package is -framework-neutral: it uses the `@stackflow/core` plugin contract and leaves the -storage medium, serialization, record lifetime, and reuse policy to your -application. +Applications often need to preserve a user's navigation context across a page +reload or JavaScript runtime replacement. Reconstructing only the initial +Activity loses the navigation history and any Steps recorded in the stack. + +`@stackflow/plugin-stack-persistence` saves a complete Stackflow snapshot and +restores it when the stack starts again. The package is framework-neutral and +leaves the storage medium, serialization, record lifetime, and reuse policy to +your application. ## Installation @@ -12,12 +15,39 @@ application. yarn add @stackflow/plugin-stack-persistence ``` -This package requires `@stackflow/core` 3.x. - ## Setup -Create a synchronous loader, an asynchronous saver, and a strategy that -validates stored metadata and decides whether its snapshot can be reused. +Add `stackPersistencePlugin()` to your Stackflow configuration with a storage +and reuse strategy: + +```typescript +import { stackPersistencePlugin } from "@stackflow/plugin-stack-persistence"; +import { stackflow } from "@stackflow/react"; +import { ArticleActivity } from "./ArticleActivity"; +import { HomeActivity } from "./HomeActivity"; +import { snapshotStorage, snapshotStrategy } from "./persistence"; +import { config } from "./stackflow.config"; + +const { Stack } = stackflow({ + config, + components: { + HomeActivity, + ArticleActivity, + }, + plugins: [ + stackPersistencePlugin({ + storage: snapshotStorage, + strategy: snapshotStrategy, + }), + ], +}); +``` + +## Usage + +The storage must provide a synchronous loader and an asynchronous saver. The +strategy validates stored metadata and decides whether its snapshot can be +reused. The following example stores snapshots in `localStorage`, rejects records from another application version, and expires records after seven days: @@ -28,7 +58,6 @@ import type { StackSnapshotStorage, StackSnapshotStrategy, } from "@stackflow/plugin-stack-persistence"; -import { stackPersistencePlugin } from "@stackflow/plugin-stack-persistence"; const STORAGE_KEY = "stackflow.snapshot"; const APP_VERSION = 1 as const; @@ -39,7 +68,7 @@ type SnapshotMetadata = { savedAt: number; }; -const storage: StackSnapshotStorage = { +export const snapshotStorage: StackSnapshotStorage = { load() { if (typeof window === "undefined") return null; @@ -56,7 +85,7 @@ const storage: StackSnapshotStorage = { }, }; -const strategy: StackSnapshotStrategy = { +export const snapshotStrategy: StackSnapshotStrategy = { metadata: { create() { return { @@ -92,36 +121,6 @@ const strategy: StackSnapshotStrategy = { return Date.now() - record.metadata.savedAt < MAX_AGE_MS; }, }; - -export const persistencePlugin = stackPersistencePlugin({ - storage, - strategy, - onRecordLoadError(error) { - console.warn("Could not read the saved Stackflow snapshot", error); - }, - onRecordSaveError(error) { - console.error("Could not save the Stackflow snapshot", error); - }, -}); -``` - -Add the plugin to an existing Stackflow configuration: - -```typescript -import { stackflow } from "@stackflow/react"; -import { ArticleActivity } from "./ArticleActivity"; -import { HomeActivity } from "./HomeActivity"; -import { persistencePlugin } from "./persistence"; -import { config } from "./stackflow.config"; - -const { Stack } = stackflow({ - config, - components: { - HomeActivity, - ArticleActivity, - }, - plugins: [persistencePlugin], -}); ``` ## Behavior @@ -137,30 +136,11 @@ record is present, the plugin: 3. provides the snapshot to Stackflow when the strategy returns `true`. Returning `null` from `storage.load()`, returning `false` from `shouldReuse()`, -or returning `{ ok: false }` from `metadata.parse()` causes Stackflow to use its -normal initial stack. A thrown `storage.load()` error has the same fallback and -is reported as `StackSnapshotRecordLoadError` through `onRecordLoadError`. -Metadata parse failures are reported as `StackSnapshotMetadataParseError`. - -After the plugin accepts a record, core still validates and replays its -snapshot against the current Stackflow configuration. `onLoadError` controls -what happens when that step fails: - -```typescript -stackPersistencePlugin({ - storage, - strategy, - onLoadError({ error, initialContext }) { - reportSnapshotError(error, initialContext); - - return { policy: "propagate" }; - }, -}); -``` - -The default policy is `{ policy: "recover" }`, which discards the unusable -snapshot and creates the normal initial stack. Return `{ policy: "propagate" }` -to let the core `SnapshotLoadError` abort stack creation. +returning `{ ok: false }` from `metadata.parse()`, or throwing from +`storage.load()` causes Stackflow to use its normal initial stack. After the +plugin accepts a record, core still validates and replays its snapshot against +the current Stackflow configuration. An unusable snapshot also falls back to +the normal initial stack by default. `storage.load()`, metadata parsing, the reuse decision, and snapshot loading are all synchronous. Prepare data before creating the stack when the backing @@ -178,9 +158,7 @@ callback receives both values. `storage.save()` runs asynchronously and does not block navigation. The plugin does not wait for an earlier save before starting a later one, so storage backed by asynchronous I/O must prevent an older request from overwriting a newer -record. A rejected save is wrapped in `StackSnapshotRecordSaveError` and sent -to `onRecordSaveError`. Without a handler, the wrapped error is rethrown from -the promise rejection. +record. The storage owns serialization. Ensure that the selected codec can represent the values carried by your application's snapshot events and metadata. @@ -203,7 +181,7 @@ The composed strategy stores a versioned metadata envelope. On load, it requires exactly the same strategy keys, parses each strategy's metadata, and reuses the snapshot only when every `shouldReuse()` call returns `true`. -## Error handling +### Error handling - `onRecordLoadError` receives `StackSnapshotRecordLoadError` when `storage.load()` throws and `StackSnapshotMetadataParseError` when @@ -212,7 +190,8 @@ reuses the snapshot only when every `shouldReuse()` call returns `true`. - `onLoadError` receives core `SnapshotLoadError` values for snapshots that cannot be loaded with the current configuration. It recovers by default. - `onRecordSaveError` receives `StackSnapshotRecordSaveError` when the promise - returned by `storage.save()` rejects. + returned by `storage.save()` rejects. Without a handler, the wrapped error is + rethrown from the promise rejection. The error wrappers expose the original value as `cause` for record load/save errors and as `detail` for metadata parse errors. Exceptions thrown directly by @@ -220,17 +199,42 @@ errors and as `detail` for metadata parse errors. Exceptions thrown directly by propagate during stack creation. Return `{ ok: false, detail }` or `false` for expected rejection paths. -## Public API +To abort stack creation instead of recovering from a core snapshot-load error, +return `{ policy: "propagate" }`: -### `stackPersistencePlugin(options)` +```typescript +stackPersistencePlugin({ + storage: snapshotStorage, + strategy: snapshotStrategy, + onLoadError({ error }) { + console.error("Could not restore the Stackflow snapshot", error); -Creates a Stackflow core plugin. `options` contains: + return { policy: "propagate" }; + }, +}); +``` + +## API -- `storage` — required `StackSnapshotStorage` implementation; -- `strategy` — required `StackSnapshotStrategy` implementation; -- `onRecordLoadError` — optional storage-load and metadata-parse error handler; -- `onRecordSaveError` — optional save-rejection handler; and -- `onLoadError` — optional core snapshot-load policy handler. +### `stackPersistencePlugin()` + +```typescript +function stackPersistencePlugin( + options: StackPersistencePluginOptions, +): StackflowPlugin; +``` + +Creates a Stackflow core plugin. + +| Option | Description | +| --- | --- | +| `storage` | Required `StackSnapshotStorage` implementation. | +| `strategy` | Required `StackSnapshotStrategy` implementation. | +| `onRecordLoadError` | Handles storage-load and metadata-parse errors. | +| `onRecordSaveError` | Handles storage-save rejections. | +| `onLoadError` | Chooses whether to recover from or propagate a core snapshot-load error. | + +The options type is exported as `StackPersistencePluginOptions`. Only one Stackflow plugin can provide a non-null snapshot during stack creation. If this plugin accepts a record while another plugin also provides a @@ -274,9 +278,19 @@ type Result = | { ok: false; detail?: unknown }; ``` -`composeStrategies()` returns another `StackSnapshotStrategy`, so composed -strategies can be passed to `stackPersistencePlugin()` without special setup. -The inferred envelope type is exported as `StrategiesMetadata`. +### `composeStrategies()` + +```typescript +function composeStrategies< + const Strategies extends Record>, +>( + strategies: Strategies, +): StackSnapshotStrategy>; +``` + +Combines keyed strategies into another `StackSnapshotStrategy`. The composed +strategy can be passed to `stackPersistencePlugin()` without special setup, +and its inferred metadata envelope type is exported as `StrategiesMetadata`. ### Error classes From 3daf16c5cf8ff58e878219e3bc8eadc37df42214 Mon Sep 17 00:00:00 2001 From: Jaewon Seo Date: Tue, 4 Aug 2026 16:02:56 +0900 Subject: [PATCH 3/7] Update README.md --- extensions/plugin-stack-persistence/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/extensions/plugin-stack-persistence/README.md b/extensions/plugin-stack-persistence/README.md index d6aa91c83..36a6b66d7 100644 --- a/extensions/plugin-stack-persistence/README.md +++ b/extensions/plugin-stack-persistence/README.md @@ -1,8 +1,7 @@ # @stackflow/plugin-stack-persistence Applications often need to preserve a user's navigation context across a page -reload or JavaScript runtime replacement. Reconstructing only the initial -Activity loses the navigation history and any Steps recorded in the stack. +reload or JavaScript runtime replacement. `@stackflow/plugin-stack-persistence` saves a complete Stackflow snapshot and restores it when the stack starts again. The package is framework-neutral and From 9cec6ee336ce33464564751883bbf2fdb475d85b Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Tue, 4 Aug 2026 16:08:41 +0900 Subject: [PATCH 4/7] docs(plugin-stack-persistence): separate parse and reuse checks (FEP-2672) --- extensions/plugin-stack-persistence/README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/extensions/plugin-stack-persistence/README.md b/extensions/plugin-stack-persistence/README.md index d6aa91c83..4e18ce7a4 100644 --- a/extensions/plugin-stack-persistence/README.md +++ b/extensions/plugin-stack-persistence/README.md @@ -64,7 +64,7 @@ const APP_VERSION = 1 as const; const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000; type SnapshotMetadata = { - appVersion: typeof APP_VERSION; + appVersion: number; savedAt: number; }; @@ -98,7 +98,7 @@ export const snapshotStrategy: StackSnapshotStrategy = { data === null || typeof data !== "object" || !("appVersion" in data) || - data.appVersion !== APP_VERSION || + typeof data.appVersion !== "number" || !("savedAt" in data) || typeof data.savedAt !== "number" ) { @@ -111,14 +111,17 @@ export const snapshotStrategy: StackSnapshotStrategy = { return { ok: true, value: { - appVersion: APP_VERSION, + appVersion: data.appVersion, savedAt: data.savedAt, }, }; }, }, shouldReuse({ record }) { - return Date.now() - record.metadata.savedAt < MAX_AGE_MS; + return ( + record.metadata.appVersion === APP_VERSION && + Date.now() - record.metadata.savedAt < MAX_AGE_MS + ); }, }; ``` From 8dc49eaad8e5fd88571ea6728eec5e7abe21caf2 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Tue, 4 Aug 2026 16:17:20 +0900 Subject: [PATCH 5/7] docs(plugin-stack-persistence): focus behavior on public contracts (FEP-2672) --- extensions/plugin-stack-persistence/README.md | 126 ++++++------------ 1 file changed, 41 insertions(+), 85 deletions(-) diff --git a/extensions/plugin-stack-persistence/README.md b/extensions/plugin-stack-persistence/README.md index 80b6f9630..2db7bbc7b 100644 --- a/extensions/plugin-stack-persistence/README.md +++ b/extensions/plugin-stack-persistence/README.md @@ -129,92 +129,40 @@ export const snapshotStrategy: StackSnapshotStrategy = { ### Restoring a snapshot -Stackflow calls `storage.load()` synchronously while creating the stack. When a -record is present, the plugin: +The plugin attempts to restore a record while the stack is being created. It +restores the snapshot only when the record is present, its metadata is valid, +the strategy accepts it for reuse, and Stackflow can load the snapshot with the +current configuration. -1. passes its untrusted `metadata` through `strategy.metadata.parse()`; -2. passes the parsed record and Stackflow's `initialContext` to - `strategy.shouldReuse()`; and -3. provides the snapshot to Stackflow when the strategy returns `true`. - -Returning `null` from `storage.load()`, returning `false` from `shouldReuse()`, -returning `{ ok: false }` from `metadata.parse()`, or throwing from -`storage.load()` causes Stackflow to use its normal initial stack. After the -plugin accepts a record, core still validates and replays its snapshot against -the current Stackflow configuration. An unusable snapshot also falls back to -the normal initial stack by default. - -`storage.load()`, metadata parsing, the reuse decision, and snapshot loading -are all synchronous. Prepare data before creating the stack when the backing -store has an asynchronous read API. In server environments, return `null` when -the chosen storage is unavailable, as in the example above. - -### Saving snapshots - -The plugin captures a record during stack initialization and after stack -changes, but calls `storage.save()` only when `globalTransitionState` is -`"idle"`. Each record contains the complete core snapshot and metadata created -from the same current `Stack` and `StackSnapshot`. The `metadata.create()` -callback receives both values. - -`storage.save()` runs asynchronously and does not block navigation. The plugin -does not wait for an earlier save before starting a later one, so storage backed -by asynchronous I/O must prevent an older request from overwriting a newer -record. - -The storage owns serialization. Ensure that the selected codec can represent -the values carried by your application's snapshot events and metadata. - -### Composing reuse policies - -Use `composeStrategies()` when a record must satisfy several independent reuse -policies: - -```typescript -import { composeStrategies } from "@stackflow/plugin-stack-persistence"; - -const strategy = composeStrategies({ - appVersion: appVersionStrategy, - session: sessionStrategy, -}); -``` - -The composed strategy stores a versioned metadata envelope. On load, it -requires exactly the same strategy keys, parses each strategy's metadata, and -reuses the snapshot only when every `shouldReuse()` call returns `true`. +Restoration is synchronous. If the backing store has an asynchronous read API, +prepare its record before creating the stack. Return `null` when no prepared +record is available, including in environments where the chosen storage cannot +be accessed. ### Error handling -- `onRecordLoadError` receives `StackSnapshotRecordLoadError` when - `storage.load()` throws and `StackSnapshotMetadataParseError` when - `metadata.parse()` returns `{ ok: false }`. In both cases, startup falls back - to the normal initial stack. -- `onLoadError` receives core `SnapshotLoadError` values for snapshots that - cannot be loaded with the current configuration. It recovers by default. -- `onRecordSaveError` receives `StackSnapshotRecordSaveError` when the promise - returned by `storage.save()` rejects. Without a handler, the wrapped error is - rethrown from the promise rejection. - -The error wrappers expose the original value as `cause` for record load/save -errors and as `detail` for metadata parse errors. Exceptions thrown directly by -`metadata.parse()` or `shouldReuse()` are outside these recovery callbacks and -propagate during stack creation. Return `{ ok: false, detail }` or `false` for -expected rejection paths. - -To abort stack creation instead of recovering from a core snapshot-load error, -return `{ policy: "propagate" }`: - -```typescript -stackPersistencePlugin({ - storage: snapshotStorage, - strategy: snapshotStrategy, - onLoadError({ error }) { - console.error("Could not restore the Stackflow snapshot", error); - - return { policy: "propagate" }; - }, -}); -``` +| Condition | Result | +| --- | --- | +| No record is available | Stackflow starts with its normal initial stack. | +| The storage cannot load the record or its metadata is invalid | Stackflow starts with its normal initial stack. An optional callback can observe the failure. | +| The strategy rejects the record | Stackflow starts with its normal initial stack without reporting an error. | +| Stackflow cannot load the accepted snapshot | The plugin recovers with the normal initial stack by default. Applications can choose to propagate the error and abort stack creation. | +| Saving the record fails | An optional callback handles the failure; without one, the plugin rethrows the wrapped promise rejection. | + +### Storage and strategy requirements + +- `storage.load()` must return a complete record or `null` synchronously. +- `storage.save()` must return a `Promise`. Save requests can overlap, so + asynchronous storage must prevent an older request from overwriting a newer + record. +- Storage owns serialization. Its codec must round-trip the snapshot and + metadata values produced by the application. +- `metadata.parse()` must treat loaded metadata as untrusted input and return + `{ ok: false }` for malformed data. +- `shouldReuse()` must return `false` for valid records that should not be used + in the current application context, such as incompatible or expired records. +- Strategy callbacks are synchronous. Expected metadata or reuse rejection + should use a failed parse result or `false` instead of throwing. ## API @@ -232,9 +180,9 @@ Creates a Stackflow core plugin. | --- | --- | | `storage` | Required `StackSnapshotStorage` implementation. | | `strategy` | Required `StackSnapshotStrategy` implementation. | -| `onRecordLoadError` | Handles storage-load and metadata-parse errors. | -| `onRecordSaveError` | Handles storage-save rejections. | -| `onLoadError` | Chooses whether to recover from or propagate a core snapshot-load error. | +| `onRecordLoadError` | Receives storage-load and metadata-parse errors before startup continues with the initial stack. | +| `onRecordSaveError` | Handles storage-save rejections. Without a handler, the wrapped rejection is rethrown. | +| `onLoadError` | Chooses whether to recover from or propagate a core snapshot-load error. Defaults to recovery. | The options type is exported as `StackPersistencePluginOptions`. @@ -280,6 +228,12 @@ type Result = | { ok: false; detail?: unknown }; ``` +`metadata.create()` produces metadata for new records. `metadata.parse()` is +the only boundary that promotes loaded `unknown` data to `Metadata`, and +`shouldReuse()` decides whether a successfully parsed record is compatible with +the current `initialContext`. Direct exceptions from `metadata.parse()` or +`shouldReuse()` propagate during stack creation. + ### `composeStrategies()` ```typescript @@ -293,6 +247,8 @@ function composeStrategies< Combines keyed strategies into another `StackSnapshotStrategy`. The composed strategy can be passed to `stackPersistencePlugin()` without special setup, and its inferred metadata envelope type is exported as `StrategiesMetadata`. +Every child parser and reuse predicate must succeed. Adding, removing, or +renaming a strategy key makes previously composed metadata invalid. ### Error classes From a88043359dd866a3796b5c46d41fa05a21e1614a Mon Sep 17 00:00:00 2001 From: Jaewon Seo Date: Tue, 4 Aug 2026 16:24:13 +0900 Subject: [PATCH 6/7] Update README.md --- extensions/plugin-stack-persistence/README.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/extensions/plugin-stack-persistence/README.md b/extensions/plugin-stack-persistence/README.md index 2db7bbc7b..c64b24642 100644 --- a/extensions/plugin-stack-persistence/README.md +++ b/extensions/plugin-stack-persistence/README.md @@ -134,11 +134,6 @@ restores the snapshot only when the record is present, its metadata is valid, the strategy accepts it for reuse, and Stackflow can load the snapshot with the current configuration. -Restoration is synchronous. If the backing store has an asynchronous read API, -prepare its record before creating the stack. Return `null` when no prepared -record is available, including in environments where the chosen storage cannot -be accessed. - ### Error handling | Condition | Result | @@ -161,8 +156,6 @@ be accessed. `{ ok: false }` for malformed data. - `shouldReuse()` must return `false` for valid records that should not be used in the current application context, such as incompatible or expired records. -- Strategy callbacks are synchronous. Expected metadata or reuse rejection - should use a failed parse result or `false` instead of throwing. ## API From fef2fdeedd9239baced8978619431ce069178c2f Mon Sep 17 00:00:00 2001 From: Jaewon Seo Date: Tue, 4 Aug 2026 16:28:51 +0900 Subject: [PATCH 7/7] Update README.md --- extensions/plugin-stack-persistence/README.md | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/extensions/plugin-stack-persistence/README.md b/extensions/plugin-stack-persistence/README.md index c64b24642..4fe8e831b 100644 --- a/extensions/plugin-stack-persistence/README.md +++ b/extensions/plugin-stack-persistence/README.md @@ -173,16 +173,10 @@ Creates a Stackflow core plugin. | --- | --- | | `storage` | Required `StackSnapshotStorage` implementation. | | `strategy` | Required `StackSnapshotStrategy` implementation. | -| `onRecordLoadError` | Receives storage-load and metadata-parse errors before startup continues with the initial stack. | +| `onRecordLoadError` | Receives storage-load and metadata-parse errors. | | `onRecordSaveError` | Handles storage-save rejections. Without a handler, the wrapped rejection is rethrown. | | `onLoadError` | Chooses whether to recover from or propagate a core snapshot-load error. Defaults to recovery. | -The options type is exported as `StackPersistencePluginOptions`. - -Only one Stackflow plugin can provide a non-null snapshot during stack -creation. If this plugin accepts a record while another plugin also provides a -snapshot, core rejects the conflicting configuration. - ### Storage and record types ```typescript @@ -224,8 +218,7 @@ type Result = `metadata.create()` produces metadata for new records. `metadata.parse()` is the only boundary that promotes loaded `unknown` data to `Metadata`, and `shouldReuse()` decides whether a successfully parsed record is compatible with -the current `initialContext`. Direct exceptions from `metadata.parse()` or -`shouldReuse()` propagate during stack creation. +the current `initialContext`. ### `composeStrategies()` @@ -242,11 +235,3 @@ strategy can be passed to `stackPersistencePlugin()` without special setup, and its inferred metadata envelope type is exported as `StrategiesMetadata`. Every child parser and reuse predicate must succeed. Adding, removing, or renaming a strategy key makes previously composed metadata invalid. - -### Error classes - -- `StackSnapshotRecordLoadError` — exposes the thrown storage value as `cause`. -- `StackSnapshotMetadataParseError` — exposes the parser failure detail as - `detail`. -- `StackSnapshotRecordSaveError` — exposes the rejected storage value as - `cause`.