@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.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +82 -2
- package/dist/InMemoryDurableStateStore.d.ts +17 -0
- package/dist/InMemoryDurableStateStore.d.ts.map +1 -0
- package/dist/InMemoryDurableStateStore.js +35 -0
- package/dist/InMemoryDurableStateStore.js.map +1 -0
- package/dist/canonical.d.ts +8 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +36 -0
- package/dist/canonical.js.map +1 -0
- package/dist/durableState.d.ts +10 -0
- package/dist/durableState.d.ts.map +1 -0
- package/dist/durableState.js +202 -0
- package/dist/durableState.js.map +1 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +48 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/sentinel.d.ts +33 -0
- package/dist/sentinel.d.ts.map +1 -0
- package/dist/sentinel.js +30 -0
- package/dist/sentinel.js.map +1 -0
- package/dist/types.d.ts +116 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +54 -3
- package/src/InMemoryDurableStateStore.ts +39 -0
- package/src/canonical.ts +33 -0
- package/src/durableState.ts +281 -0
- package/src/errors.ts +49 -0
- package/src/index.ts +25 -0
- package/src/sentinel.ts +62 -0
- package/src/types.ts +122 -0
|
@@ -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";
|
package/src/sentinel.ts
ADDED
|
@@ -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
|
+
}
|