@loomwork/plugin-durable-state 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,281 @@
1
+ import type { TaskDefinition } from "@loomwork/core";
2
+ import { ToolEffect } from "@loomwork/effect-runtime";
3
+ import { z } from "zod";
4
+ import { canonicalEqual } from "./canonical";
5
+ import { RevisionConflictError, StateContractMismatchError } from "./errors";
6
+ import type {
7
+ ClearInput,
8
+ ClearResult,
9
+ DurableStateConfig,
10
+ DurableStateHandle,
11
+ DurableStateRecord,
12
+ EffectRegistry,
13
+ LoadInput,
14
+ LoadResult,
15
+ SaveInput,
16
+ SaveResult,
17
+ TaskExecutor,
18
+ } from "./types";
19
+
20
+ /**
21
+ * DS-3 — bring a stored envelope to the current contract: apply the
22
+ * version-to-version migration chain, then validate against the current
23
+ * schema. Every failure mode is a loud, typed {@link StateContractMismatchError}
24
+ * (DS-2: a run never proceeds on unvalidated state).
25
+ */
26
+ function migrateRecord<T>(
27
+ config: DurableStateConfig<T>,
28
+ key: string,
29
+ record: DurableStateRecord
30
+ ): { value: T; migrated: { from: number; to: number } | null } {
31
+ const from = record.contractVersion;
32
+ if (from > config.version) {
33
+ throw new StateContractMismatchError(
34
+ config.id,
35
+ "future-version",
36
+ from,
37
+ config.version,
38
+ `key "${key}" was written by a newer deployment`
39
+ );
40
+ }
41
+ let payload = record.payload;
42
+ for (let v = from; v < config.version; v++) {
43
+ const step = config.migrations?.[v];
44
+ if (step === undefined) {
45
+ throw new StateContractMismatchError(
46
+ config.id,
47
+ "no-migration-path",
48
+ from,
49
+ config.version,
50
+ `key "${key}": no migration registered for v${v} → v${v + 1}`
51
+ );
52
+ }
53
+ try {
54
+ payload = step(payload);
55
+ } catch (err) {
56
+ throw new StateContractMismatchError(
57
+ config.id,
58
+ "migration-failed",
59
+ from,
60
+ config.version,
61
+ `key "${key}": migration v${v} → v${v + 1} threw: ${String(err)}`
62
+ );
63
+ }
64
+ }
65
+ const parsed = config.schema.safeParse(payload);
66
+ if (!parsed.success) {
67
+ throw new StateContractMismatchError(
68
+ config.id,
69
+ "validation-failed",
70
+ from,
71
+ config.version,
72
+ `key "${key}": ${parsed.error.issues.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ")}`
73
+ );
74
+ }
75
+ return {
76
+ value: parsed.data,
77
+ migrated: from < config.version ? { from, to: config.version } : null,
78
+ };
79
+ }
80
+
81
+ function isRevisionConflict(err: unknown): err is RevisionConflictError {
82
+ return (
83
+ err instanceof RevisionConflictError ||
84
+ (typeof err === "object" && err !== null && (err as Error).name === "RevisionConflictError")
85
+ );
86
+ }
87
+
88
+ /**
89
+ * ADR 0051 D2 — typed, versioned, migratable keyed state as RECORDED effects.
90
+ * `load`/`save` run through `ctx.execute`, so results land in STEP_COMPLETED:
91
+ * replay folds the recorded value and never touches the backing store —
92
+ * replay-correct by construction (DS-1). This is deliberately NOT `memory$`,
93
+ * which is a non-replayable agent-layer side channel.
94
+ */
95
+ export function durableState<T>(config: DurableStateConfig<T>): DurableStateHandle<T> {
96
+ if (config.id.length === 0) {
97
+ throw new TypeError("durableState: config.id must be a non-empty string");
98
+ }
99
+ if (!Number.isInteger(config.version) || config.version < 1) {
100
+ throw new TypeError(`durableState "${config.id}": version must be an integer ≥ 1`);
101
+ }
102
+
103
+ const toolIds = {
104
+ load: `${config.id}.load`,
105
+ save: `${config.id}.save`,
106
+ clear: `${config.id}.clear`,
107
+ } as const;
108
+
109
+ const loadOutputSchema = z.object({
110
+ found: z.boolean(),
111
+ value: config.schema.nullable(),
112
+ revision: z.number().int().nonnegative(),
113
+ contractVersion: z.number().int().positive(),
114
+ migrated: z
115
+ .object({ from: z.number().int().positive(), to: z.number().int().positive() })
116
+ .nullable(),
117
+ }) as unknown as z.ZodSchema<LoadResult<T>>;
118
+
119
+ const tasks: DurableStateHandle<T>["tasks"] = {
120
+ load: {
121
+ _tag: "TaskDefinition",
122
+ kind: "memory",
123
+ id: toolIds.load,
124
+ toolId: toolIds.load,
125
+ name: `${config.id} load`,
126
+ inputSchema: z.object({ key: z.string() }) as z.ZodSchema<LoadInput>,
127
+ outputSchema: loadOutputSchema,
128
+ },
129
+ save: {
130
+ _tag: "TaskDefinition",
131
+ kind: "memory",
132
+ id: toolIds.save,
133
+ toolId: toolIds.save,
134
+ name: `${config.id} save`,
135
+ outputSchema: z.object({ revision: z.number().int().positive() }) as z.ZodSchema<SaveResult>,
136
+ },
137
+ clear: {
138
+ _tag: "TaskDefinition",
139
+ kind: "memory",
140
+ id: toolIds.clear,
141
+ toolId: toolIds.clear,
142
+ name: `${config.id} clear`,
143
+ outputSchema: z.object({ cleared: z.literal(true) }) as z.ZodSchema<ClearResult>,
144
+ },
145
+ };
146
+
147
+ const loadEffect = new ToolEffect<LoadInput, LoadResult<T>>(
148
+ async ({ key }) => {
149
+ const record = await config.store.load(key);
150
+ if (record === null) {
151
+ return {
152
+ found: false,
153
+ value: null,
154
+ revision: 0,
155
+ contractVersion: config.version,
156
+ migrated: null,
157
+ };
158
+ }
159
+ const { value, migrated } = migrateRecord(config, key, record);
160
+ return {
161
+ found: true,
162
+ value,
163
+ revision: record.revision,
164
+ contractVersion: config.version,
165
+ migrated,
166
+ };
167
+ },
168
+ // ADR 0073 — deliberately NO `mode`. `load` is genuinely dual-seam: journaled
169
+ // inside a flow (where the recorded value is what replay folds), and a
170
+ // perfectly reasonable ambient read outside one — an operator dashboard
171
+ // peeking at a sentinel's carry state changes nothing and should not have to
172
+ // start an execution to look. Pinning it to either seam would forbid a
173
+ // legitimate caller, which is why the marker is opt-in rather than required.
174
+ { idempotent: true }
175
+ );
176
+
177
+ // K9 — declared idempotent: the CAS makes a crash-recovery re-run safe. If the
178
+ // prior attempt committed, the re-run's expectedRevision now trails by exactly
179
+ // one; when the stored envelope is byte-equal to what we tried to write, adopt
180
+ // it as our own committed write. A genuinely concurrent writer either lands at
181
+ // a different revision or a different payload — both still conflict loudly.
182
+ const saveEffect = new ToolEffect<SaveInput<T>, SaveResult>(
183
+ async ({ key, value, expectedRevision }) => {
184
+ const parsed = config.schema.safeParse(value);
185
+ if (!parsed.success) {
186
+ throw new StateContractMismatchError(
187
+ config.id,
188
+ "validation-failed",
189
+ config.version,
190
+ config.version,
191
+ `key "${key}" (save): ${parsed.error.issues.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ")}`
192
+ );
193
+ }
194
+ try {
195
+ const record = await config.store.save(
196
+ key,
197
+ { contractVersion: config.version, payload: parsed.data },
198
+ expectedRevision
199
+ );
200
+ return { revision: record.revision };
201
+ } catch (err) {
202
+ if (isRevisionConflict(err) && err.actualRevision === expectedRevision + 1) {
203
+ const current = await config.store.load(key);
204
+ if (
205
+ current !== null &&
206
+ current.revision === expectedRevision + 1 &&
207
+ current.contractVersion === config.version &&
208
+ canonicalEqual(current.payload, parsed.data)
209
+ ) {
210
+ return { revision: current.revision };
211
+ }
212
+ }
213
+ throw err;
214
+ }
215
+ },
216
+ {
217
+ idempotent: true,
218
+ // ADR 0073 — a mutation that MUST be recorded. The whole contract of this
219
+ // plugin is "RECORDED effects: replay folds the recorded value, never the
220
+ // store" (ADR 0053). A transient write would mutate the store while the log
221
+ // learns nothing, so replay would fold a state the run never produced —
222
+ // exactly the split-brain the recorded-effect design exists to prevent.
223
+ mode: "journaled",
224
+ }
225
+ );
226
+
227
+ const clearEffect = new ToolEffect<ClearInput, ClearResult>(
228
+ async ({ key }) => {
229
+ await config.store.clear(key);
230
+ return { cleared: true };
231
+ },
232
+ {
233
+ idempotent: true,
234
+ // ADR 0073 — a mutation that MUST be recorded. The whole contract of this
235
+ // plugin is "RECORDED effects: replay folds the recorded value, never the
236
+ // store" (ADR 0053). A transient write would mutate the store while the log
237
+ // learns nothing, so replay would fold a state the run never produced —
238
+ // exactly the split-brain the recorded-effect design exists to prevent.
239
+ mode: "journaled",
240
+ }
241
+ );
242
+
243
+ const effects: DurableStateHandle<T>["effects"] = {
244
+ load: loadEffect,
245
+ save: saveEffect,
246
+ clear: clearEffect,
247
+ };
248
+
249
+ const taskWith = <TIn, TOut>(
250
+ task: TaskDefinition<TIn, TOut>,
251
+ stepId: string | undefined
252
+ ): TaskDefinition<TIn, TOut> => (stepId === undefined ? task : { ...task, id: stepId });
253
+
254
+ return {
255
+ id: config.id,
256
+ version: config.version,
257
+ toolIds,
258
+ tasks,
259
+ effects,
260
+ register(runtime: EffectRegistry): void {
261
+ runtime.register(toolIds.load, loadEffect);
262
+ runtime.register(toolIds.save, saveEffect);
263
+ runtime.register(toolIds.clear, clearEffect);
264
+ },
265
+ load(ctx: TaskExecutor, key: string, opts?: { stepId?: string }): Promise<LoadResult<T>> {
266
+ return ctx.execute(taskWith(tasks.load, opts?.stepId), { key });
267
+ },
268
+ save(
269
+ ctx: TaskExecutor,
270
+ key: string,
271
+ value: T,
272
+ expectedRevision: number,
273
+ opts?: { stepId?: string }
274
+ ): Promise<SaveResult> {
275
+ return ctx.execute(taskWith(tasks.save, opts?.stepId), { key, value, expectedRevision });
276
+ },
277
+ clear(ctx: TaskExecutor, key: string, opts?: { stepId?: string }): Promise<ClearResult> {
278
+ return ctx.execute(taskWith(tasks.clear, opts?.stepId), { key });
279
+ },
280
+ };
281
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * DS-4 — compare-and-set failed: the stored revision was not the expected one.
3
+ * A genuinely concurrent writer got there first (or the key was created/cleared
4
+ * underneath the caller). The schedule-driven pattern makes this a
5
+ * misconfiguration signal, not a normal outcome — fail fast, never lost-update.
6
+ */
7
+ export class RevisionConflictError extends Error {
8
+ constructor(
9
+ readonly key: string,
10
+ readonly expectedRevision: number,
11
+ readonly actualRevision: number
12
+ ) {
13
+ super(
14
+ `durable-state CAS conflict on "${key}": expected revision ${expectedRevision}, found ${actualRevision}`
15
+ );
16
+ this.name = "RevisionConflictError";
17
+ Object.setPrototypeOf(this, RevisionConflictError.prototype);
18
+ }
19
+ }
20
+
21
+ export type StateContractMismatchReason =
22
+ | "no-migration-path"
23
+ | "migration-failed"
24
+ | "future-version"
25
+ | "validation-failed";
26
+
27
+ /**
28
+ * DS-2/DS-3 — stored state cannot be brought to the current contract: a
29
+ * migration step is missing or threw, the stored version is newer than the
30
+ * contract (rollback deploy), or the (migrated) payload fails schema
31
+ * validation. Loud and typed by design: a run never proceeds on unvalidated
32
+ * state, and state is never silently coerced. Alert on it, register the
33
+ * missing migration (or fix the deploy), and the next run resumes cleanly.
34
+ */
35
+ export class StateContractMismatchError extends Error {
36
+ constructor(
37
+ readonly contractId: string,
38
+ readonly reason: StateContractMismatchReason,
39
+ readonly storedVersion: number,
40
+ readonly expectedVersion: number,
41
+ detail?: string
42
+ ) {
43
+ super(
44
+ `durable-state contract mismatch for "${contractId}" (${reason}): stored v${storedVersion}, contract v${expectedVersion}${detail ? ` — ${detail}` : ""}`
45
+ );
46
+ this.name = "StateContractMismatchError";
47
+ Object.setPrototypeOf(this, StateContractMismatchError.prototype);
48
+ }
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ export { durableState } from "./durableState";
2
+ export { sentinelState } from "./sentinel";
3
+ export type { SentinelStateConfig, SentinelStateHandle } from "./sentinel";
4
+ export { InMemoryDurableStateStore } from "./InMemoryDurableStateStore";
5
+ export {
6
+ RevisionConflictError,
7
+ StateContractMismatchError,
8
+ type StateContractMismatchReason,
9
+ } from "./errors";
10
+ export { stableStringify, canonicalEqual } from "./canonical";
11
+ export type {
12
+ ClearInput,
13
+ ClearResult,
14
+ DurableStateConfig,
15
+ DurableStateHandle,
16
+ DurableStateRecord,
17
+ DurableStateStore,
18
+ EffectRegistry,
19
+ LoadInput,
20
+ LoadResult,
21
+ Migration,
22
+ SaveInput,
23
+ SaveResult,
24
+ TaskExecutor,
25
+ } from "./types";
@@ -0,0 +1,62 @@
1
+ import type { ScheduleDefinition } from "@loomwork/core";
2
+ import type { ZodSchema } from "zod";
3
+ import { durableState } from "./durableState";
4
+ import type {
5
+ ClearResult,
6
+ DurableStateHandle,
7
+ DurableStateStore,
8
+ EffectRegistry,
9
+ LoadResult,
10
+ Migration,
11
+ SaveResult,
12
+ TaskExecutor,
13
+ } from "./types";
14
+
15
+ export interface SentinelStateConfig<T> {
16
+ /** The schedule whose occurrences share this state — the key derives from its id. */
17
+ schedule: Pick<ScheduleDefinition, "id">;
18
+ schema: ZodSchema<T>;
19
+ /** Current contract version; defaults to 1. */
20
+ version?: number;
21
+ migrations?: Readonly<Record<number, Migration>>;
22
+ store: DurableStateStore;
23
+ /** Contract id override; defaults to `<scheduleId>.state`. */
24
+ id?: string;
25
+ }
26
+
27
+ export interface SentinelStateHandle<T> {
28
+ /** The derived state key: `sentinel:<scheduleId>`. */
29
+ readonly key: string;
30
+ /** The underlying handle, for advanced use (extra keys, explicit step ids). */
31
+ readonly state: DurableStateHandle<T>;
32
+ register(runtime: EffectRegistry): void;
33
+ load(ctx: TaskExecutor): Promise<LoadResult<T>>;
34
+ save(ctx: TaskExecutor, value: T, expectedRevision: number): Promise<SaveResult>;
35
+ clear(ctx: TaskExecutor): Promise<ClearResult>;
36
+ }
37
+
38
+ /**
39
+ * DS-5 — the §2 P-1 sentinel pattern as one declaration: a `schedule$`-driven
40
+ * agent's carry state, keyed by the schedule id. Each occurrence loads at wake
41
+ * (recorded, validated, migrated), does its work, and saves with the loaded
42
+ * revision — T1's one-live-occurrence-per-schedule guarantee makes the CAS a
43
+ * misconfiguration tripwire rather than a contention point.
44
+ */
45
+ export function sentinelState<T>(config: SentinelStateConfig<T>): SentinelStateHandle<T> {
46
+ const state = durableState<T>({
47
+ id: config.id ?? `${config.schedule.id}.state`,
48
+ version: config.version ?? 1,
49
+ schema: config.schema,
50
+ ...(config.migrations !== undefined && { migrations: config.migrations }),
51
+ store: config.store,
52
+ });
53
+ const key = `sentinel:${config.schedule.id}`;
54
+ return {
55
+ key,
56
+ state,
57
+ register: (runtime) => state.register(runtime),
58
+ load: (ctx) => state.load(ctx, key),
59
+ save: (ctx, value, expectedRevision) => state.save(ctx, key, value, expectedRevision),
60
+ clear: (ctx) => state.clear(ctx, key),
61
+ };
62
+ }
package/src/types.ts ADDED
@@ -0,0 +1,122 @@
1
+ import type { ExecutableTask, TaskDefinition } from "@loomwork/core";
2
+ import type { Effect } from "@loomwork/effect-runtime";
3
+ import type { ZodSchema } from "zod";
4
+
5
+ /** The stored envelope: what a backend persists per key. Revisions start at 1. */
6
+ export interface DurableStateRecord {
7
+ contractVersion: number;
8
+ revision: number;
9
+ payload: unknown;
10
+ }
11
+
12
+ /**
13
+ * The durable-state store SPI. Backends are dumb keyed CAS stores — all
14
+ * contract knowledge (schemas, migrations) lives above, in {@link durableState}.
15
+ * `save` with `expectedRevision: 0` creates; any mismatch rejects with
16
+ * {@link RevisionConflictError} (`name: "RevisionConflictError"` for 3P
17
+ * backends that cannot share the class). Conformance:
18
+ * `runDurableStateStoreConformance` in `@loomwork/conformance`.
19
+ */
20
+ export interface DurableStateStore {
21
+ load(key: string): Promise<DurableStateRecord | null>;
22
+ save(
23
+ key: string,
24
+ value: { contractVersion: number; payload: unknown },
25
+ expectedRevision: number
26
+ ): Promise<DurableStateRecord>;
27
+ /** Remove the key. Idempotent. */
28
+ clear(key: string): Promise<void>;
29
+ close?(): Promise<void>;
30
+ }
31
+
32
+ /** DS-3 — one migration step: payload at version N → payload at version N+1. */
33
+ export type Migration = (value: unknown) => unknown;
34
+
35
+ export interface DurableStateConfig<T> {
36
+ /** Contract id — namespaces the registered tool ids (`<id>.load` / `<id>.save` / `<id>.clear`). */
37
+ id: string;
38
+ /** Current contract version (integer ≥ 1). Stored envelopes carry the version they were written under. */
39
+ version: number;
40
+ /** The CURRENT version's schema. Every load validates against it after migration. */
41
+ schema: ZodSchema<T>;
42
+ /** `migrations[n]` migrates a payload from version n to n+1. Sparse maps stall loudly (DS-3). */
43
+ migrations?: Readonly<Record<number, Migration>>;
44
+ store: DurableStateStore;
45
+ }
46
+
47
+ export interface LoadInput {
48
+ key: string;
49
+ }
50
+
51
+ export interface LoadResult<T> {
52
+ found: boolean;
53
+ /** Validated, current-contract value — `null` when not found. */
54
+ value: T | null;
55
+ /** Pass back to `save` as `expectedRevision` (0 when not found → create). */
56
+ revision: number;
57
+ contractVersion: number;
58
+ /** Non-null when a stored older envelope was migrated on this load (recorded in the step output). */
59
+ migrated: { from: number; to: number } | null;
60
+ }
61
+
62
+ export interface SaveInput<T> {
63
+ key: string;
64
+ value: T;
65
+ expectedRevision: number;
66
+ }
67
+
68
+ export interface SaveResult {
69
+ revision: number;
70
+ }
71
+
72
+ export interface ClearInput {
73
+ key: string;
74
+ }
75
+
76
+ export interface ClearResult {
77
+ cleared: true;
78
+ }
79
+
80
+ /** Structural view of `EffectRuntime.register` — anything that accepts (toolId, effect). */
81
+ export interface EffectRegistry {
82
+ register(toolId: string, effect: Effect): void;
83
+ }
84
+
85
+ /** Structural view of the execution context — `WorkflowContext.execute` satisfies it. */
86
+ export interface TaskExecutor {
87
+ execute<TIn, TOut>(task: ExecutableTask<TIn, TOut>, input: TIn): Promise<TOut>;
88
+ }
89
+
90
+ export interface DurableStateHandle<T> {
91
+ readonly id: string;
92
+ readonly version: number;
93
+ readonly toolIds: { load: string; save: string; clear: string };
94
+ /** `ctx.execute`-able task metadata (kind "memory"); ids default to the tool ids. */
95
+ readonly tasks: {
96
+ load: TaskDefinition<LoadInput, LoadResult<T>>;
97
+ save: TaskDefinition<SaveInput<T>, SaveResult>;
98
+ clear: TaskDefinition<ClearInput, ClearResult>;
99
+ };
100
+ /** The effects to register — or call {@link DurableStateHandle.register}. */
101
+ readonly effects: {
102
+ load: Effect<LoadInput, LoadResult<T>>;
103
+ save: Effect<SaveInput<T>, SaveResult>;
104
+ clear: Effect<ClearInput, ClearResult>;
105
+ };
106
+ register(runtime: EffectRegistry): void;
107
+ /**
108
+ * Recorded read (DS-1): the result folds from STEP_COMPLETED on replay — the
109
+ * store is never re-read. One call per default step id; pass `stepId` for
110
+ * additional loads in the same run.
111
+ */
112
+ load(ctx: TaskExecutor, key: string, opts?: { stepId?: string }): Promise<LoadResult<T>>;
113
+ /** Recorded CAS write (DS-1/DS-4). `expectedRevision` comes from the prior load (0 = create). */
114
+ save(
115
+ ctx: TaskExecutor,
116
+ key: string,
117
+ value: T,
118
+ expectedRevision: number,
119
+ opts?: { stepId?: string }
120
+ ): Promise<SaveResult>;
121
+ clear(ctx: TaskExecutor, key: string, opts?: { stepId?: string }): Promise<ClearResult>;
122
+ }