@yieldstar/core 0.5.0 → 0.6.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/dist/base/durable-value.d.ts +71 -0
- package/dist/base/durable-value.d.ts.map +1 -0
- package/dist/base/durable-value.test.d.ts +2 -0
- package/dist/base/durable-value.test.d.ts.map +1 -0
- package/dist/base/effect.d.ts +136 -0
- package/dist/base/effect.d.ts.map +1 -0
- package/dist/base/errors.d.ts +23 -0
- package/dist/base/errors.d.ts.map +1 -0
- package/dist/base/errors.test.d.ts +2 -0
- package/dist/base/errors.test.d.ts.map +1 -0
- package/dist/base/event.d.ts +6 -1
- package/dist/base/event.d.ts.map +1 -1
- package/dist/base/execution.d.ts +255 -0
- package/dist/base/execution.d.ts.map +1 -0
- package/dist/base/heap.d.ts +15 -3
- package/dist/base/heap.d.ts.map +1 -1
- package/dist/base/invoker.d.ts +47 -2
- package/dist/base/invoker.d.ts.map +1 -1
- package/dist/base/runtime.d.ts +27 -4
- package/dist/base/runtime.d.ts.map +1 -1
- package/dist/base/step.d.ts +21 -5
- package/dist/base/step.d.ts.map +1 -1
- package/dist/base/store.d.ts +135 -21
- package/dist/base/store.d.ts.map +1 -1
- package/dist/base/workflow.d.ts +15 -0
- package/dist/base/workflow.d.ts.map +1 -1
- package/dist/index.d.ts +23 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1653 -172
- package/dist/lib/effect-state.d.ts +78 -0
- package/dist/lib/effect-state.d.ts.map +1 -0
- package/dist/lib/execution-dispatcher.d.ts +112 -0
- package/dist/lib/execution-dispatcher.d.ts.map +1 -0
- package/dist/lib/execution-state.d.ts +248 -0
- package/dist/lib/execution-state.d.ts.map +1 -0
- package/dist/lib/execution-state.test.d.ts +2 -0
- package/dist/lib/execution-state.test.d.ts.map +1 -0
- package/dist/lib/wait-for-outcome.d.ts +15 -0
- package/dist/lib/wait-for-outcome.d.ts.map +1 -0
- package/dist/lib/workflow-runner.d.ts +14 -9
- package/dist/lib/workflow-runner.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/base/durable-value.test.ts +285 -0
- package/src/base/durable-value.ts +442 -0
- package/src/base/effect.ts +165 -0
- package/src/base/errors.test.ts +210 -0
- package/src/base/errors.ts +72 -0
- package/src/base/event.ts +6 -1
- package/src/base/execution.ts +315 -0
- package/src/base/heap.ts +17 -3
- package/src/base/invoker.ts +60 -2
- package/src/base/runtime.ts +28 -7
- package/src/base/step.ts +29 -7
- package/src/base/store.ts +339 -49
- package/src/base/workflow.ts +15 -0
- package/src/index.ts +138 -3
- package/src/lib/effect-state.ts +254 -0
- package/src/lib/execution-dispatcher.ts +408 -0
- package/src/lib/execution-state.test.ts +249 -0
- package/src/lib/execution-state.ts +650 -0
- package/src/lib/wait-for-outcome.ts +40 -0
- package/src/lib/workflow-runner.ts +75 -43
- package/src/base/scheduler.ts +0 -8
- package/tsconfig.tsbuildinfo +0 -1
package/src/base/store.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
-
import type
|
|
2
|
+
import { durableValueCodec, type Durable, type IfDurable } from "./durable-value";
|
|
3
|
+
import { WorkflowValueError, registerErrorClass } from "./errors";
|
|
3
4
|
|
|
4
5
|
export type Draft<T> = T extends object
|
|
5
6
|
? { -readonly [K in keyof T]: Draft<T[K]> }
|
|
@@ -12,6 +13,12 @@ export type StoreDefinition<Schema extends StandardSchemaV1 = StandardSchemaV1>
|
|
|
12
13
|
{
|
|
13
14
|
name: string;
|
|
14
15
|
schema: Schema;
|
|
16
|
+
/**
|
|
17
|
+
* The state a store starts with. Every operation on a missing store
|
|
18
|
+
* creates it from this state, so it is evaluated only when the store with
|
|
19
|
+
* this `id` does not exist.
|
|
20
|
+
*/
|
|
21
|
+
initial: (id: string) => StoreState<Schema>;
|
|
15
22
|
};
|
|
16
23
|
|
|
17
24
|
export type StoreState<Schema extends StandardSchemaV1> =
|
|
@@ -68,12 +75,6 @@ export type StoreDeleteFromResult =
|
|
|
68
75
|
actualInstanceId: string;
|
|
69
76
|
expectedVersion: StoreVersion;
|
|
70
77
|
actualVersion: StoreVersion;
|
|
71
|
-
}
|
|
72
|
-
| {
|
|
73
|
-
deleted: false;
|
|
74
|
-
reason: "not-found";
|
|
75
|
-
expectedInstanceId: string;
|
|
76
|
-
expectedVersion: StoreVersion;
|
|
77
78
|
};
|
|
78
79
|
|
|
79
80
|
export type StoreSelector<T, R> = (state: Readonly<T>) => R;
|
|
@@ -96,7 +97,6 @@ export type StoreWaiter = {
|
|
|
96
97
|
workflowId: string;
|
|
97
98
|
executionId: string;
|
|
98
99
|
stepKey: string;
|
|
99
|
-
event: WorkflowEvent;
|
|
100
100
|
storeName: string;
|
|
101
101
|
storeId: string;
|
|
102
102
|
instanceId: string;
|
|
@@ -107,18 +107,39 @@ export type StoreWaiter = {
|
|
|
107
107
|
export type StoreMutationCommitResult =
|
|
108
108
|
| { status: "committed" }
|
|
109
109
|
| { status: "already-applied"; result: unknown }
|
|
110
|
-
| { status: "conflict"; snapshot: StoreSnapshot<unknown> }
|
|
110
|
+
| { status: "conflict"; snapshot: StoreSnapshot<unknown> }
|
|
111
|
+
| { status: "missing" };
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A snapshot read from a store, or, when `created` is true, the initial state
|
|
115
|
+
* of a store that did not exist when it was read. A created snapshot's
|
|
116
|
+
* `instanceId` is new and its `version` is 0.
|
|
117
|
+
*/
|
|
118
|
+
export type StoreReadSnapshot<T> = StoreSnapshot<T> & { created: boolean };
|
|
111
119
|
|
|
112
120
|
export type StoreMutation = {
|
|
113
121
|
definition: StoreDefinition;
|
|
114
122
|
id: string;
|
|
115
|
-
|
|
123
|
+
/**
|
|
124
|
+
* The snapshot the mutation was computed from. When `created` is true the
|
|
125
|
+
* commit first inserts it as the store's initial state, and conflicts if
|
|
126
|
+
* the store exists by then.
|
|
127
|
+
*/
|
|
128
|
+
expected: StoreReadSnapshot<unknown>;
|
|
116
129
|
nextState: unknown;
|
|
117
130
|
changedPaths: StorePath[];
|
|
118
131
|
stepId?: StoreStepId;
|
|
119
132
|
result: unknown;
|
|
120
133
|
};
|
|
121
134
|
|
|
135
|
+
export type StoreDeletion = {
|
|
136
|
+
definition: StoreDefinition;
|
|
137
|
+
id: string;
|
|
138
|
+
expected: StoreSnapshot<unknown>;
|
|
139
|
+
stepId?: StoreStepId;
|
|
140
|
+
result: StoreDeleteFromResult;
|
|
141
|
+
};
|
|
142
|
+
|
|
122
143
|
export type RuntimeStore<T> = {
|
|
123
144
|
get(): Promise<StoreSnapshot<T>>;
|
|
124
145
|
update(updater: (draft: Draft<T>) => void | T): Promise<StoreUpdateResult<T>>;
|
|
@@ -129,11 +150,47 @@ export type RuntimeStore<T> = {
|
|
|
129
150
|
deleteFrom(snapshot: StoreSnapshot<T>): Promise<StoreDeleteFromResult>;
|
|
130
151
|
};
|
|
131
152
|
|
|
132
|
-
|
|
153
|
+
/** A store state failed its schema. Workflow code can catch it. */
|
|
154
|
+
export class StoreValidationError extends Error {
|
|
155
|
+
constructor(message: string) {
|
|
156
|
+
super(message);
|
|
157
|
+
this.name = "StoreValidationError";
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
registerErrorClass("StoreValidationError", StoreValidationError);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A schema whose output, the store state, is a durable value. The state is
|
|
165
|
+
* still validated by the codec at runtime.
|
|
166
|
+
*/
|
|
167
|
+
export type DurableStoreSchema<Schema extends StandardSchemaV1> = IfDurable<
|
|
168
|
+
StoreState<Schema>,
|
|
169
|
+
StandardSchemaV1<
|
|
170
|
+
StandardSchemaV1.InferInput<Schema>,
|
|
171
|
+
Durable<StoreState<Schema>>
|
|
172
|
+
>
|
|
173
|
+
>;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* `State` is inferred from the schema alone, so `initial` is checked against
|
|
177
|
+
* it and keeps literal types such as `status: "idle"`.
|
|
178
|
+
*/
|
|
179
|
+
export function defineStore<
|
|
180
|
+
Schema extends StandardSchemaV1<any, State>,
|
|
181
|
+
State = StoreState<Schema>,
|
|
182
|
+
>(
|
|
133
183
|
name: string,
|
|
134
|
-
schema: Schema
|
|
184
|
+
schema: Schema & StandardSchemaV1<any, State> & DurableStoreSchema<Schema>,
|
|
185
|
+
options: {
|
|
186
|
+
initial: (id: string) => NoInfer<Durable<State>>;
|
|
187
|
+
}
|
|
135
188
|
): StoreDefinition<Schema> {
|
|
136
|
-
return {
|
|
189
|
+
return {
|
|
190
|
+
name,
|
|
191
|
+
schema,
|
|
192
|
+
initial: options.initial as (id: string) => StoreState<Schema>,
|
|
193
|
+
};
|
|
137
194
|
}
|
|
138
195
|
|
|
139
196
|
export abstract class StoreClient {
|
|
@@ -169,14 +226,13 @@ export abstract class StoreClient {
|
|
|
169
226
|
};
|
|
170
227
|
}
|
|
171
228
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
229
|
+
/**
|
|
230
|
+
* Every operation that takes a `definition` and `id`, except `deleteStore`,
|
|
231
|
+
* first creates a missing store from `definition.initial(id)`, in the same
|
|
232
|
+
* atomic write as the operation. `initial` is evaluated only when the store
|
|
233
|
+
* is missing, and concurrent operations on a missing store record exactly
|
|
234
|
+
* one initial state. A creation is version 0 of a new instance.
|
|
235
|
+
*/
|
|
180
236
|
abstract getStore<Schema extends StandardSchemaV1>(params: {
|
|
181
237
|
definition: StoreDefinition<Schema>;
|
|
182
238
|
id: string;
|
|
@@ -248,6 +304,14 @@ export abstract class StoreClient {
|
|
|
248
304
|
stepId?: StoreStepId;
|
|
249
305
|
}): Promise<StoreTakeResult<R>>;
|
|
250
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Registers a versioned read observation. Registration must be linearizable
|
|
309
|
+
* with mutations: a stale observation is woken immediately, while a current
|
|
310
|
+
* observation is woken by a later intersecting mutation. A wake requests a
|
|
311
|
+
* delivery of `waiter.executionId`; it carries no copy of the event. Once a
|
|
312
|
+
* mutation is durable, the wakes it requires are durable too. The waiter
|
|
313
|
+
* stays registered until the heap records its step's result.
|
|
314
|
+
*/
|
|
251
315
|
abstract registerWaiter(waiter: StoreWaiter): Promise<void>;
|
|
252
316
|
|
|
253
317
|
/**
|
|
@@ -259,15 +323,21 @@ export abstract class StoreClient {
|
|
|
259
323
|
abstract listStores(definition: StoreDefinition): Promise<string[]>;
|
|
260
324
|
|
|
261
325
|
/**
|
|
262
|
-
* Removes a store instance and
|
|
263
|
-
*
|
|
326
|
+
* Removes a physical store instance and its waiters. Applied-step receipts
|
|
327
|
+
* remain authoritative for the logical store key across recreation. A no-op
|
|
328
|
+
* if the store does not exist; it never creates one.
|
|
264
329
|
*/
|
|
265
330
|
abstract deleteStore(params: {
|
|
266
331
|
definition: StoreDefinition;
|
|
267
332
|
id: string;
|
|
268
333
|
}): Promise<void>;
|
|
269
334
|
|
|
270
|
-
/**
|
|
335
|
+
/**
|
|
336
|
+
* Deletes a store only if its instance ID and version still match the
|
|
337
|
+
* snapshot. A missing store is created first, so the result is a conflict
|
|
338
|
+
* with the new instance. With a `stepId`, a committed deletion is recorded
|
|
339
|
+
* and a repeated call returns the recorded result.
|
|
340
|
+
*/
|
|
271
341
|
abstract deleteStoreFrom(params: {
|
|
272
342
|
definition: StoreDefinition;
|
|
273
343
|
id: string;
|
|
@@ -279,9 +349,27 @@ export abstract class StoreClient {
|
|
|
279
349
|
/**
|
|
280
350
|
* Implements store transformations as read/compute/compare-and-swap loops.
|
|
281
351
|
* Storage connectors only implement the atomic mutation protocol and never
|
|
282
|
-
* execute application callbacks while holding a storage
|
|
352
|
+
* execute application callbacks, including `initial`, while holding a storage
|
|
353
|
+
* transaction.
|
|
283
354
|
*/
|
|
284
355
|
export abstract class CasStoreClient extends StoreClient {
|
|
356
|
+
/** Reads a store's current snapshot, or `undefined` if it does not exist. */
|
|
357
|
+
protected abstract readStore(params: {
|
|
358
|
+
definition: StoreDefinition;
|
|
359
|
+
id: string;
|
|
360
|
+
}): Promise<StoreSnapshot<unknown> | undefined>;
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Atomically inserts `snapshot` as a store's initial state unless the store
|
|
364
|
+
* exists, and returns the store's snapshot after the write: `snapshot` if it
|
|
365
|
+
* was inserted, otherwise the existing store's.
|
|
366
|
+
*/
|
|
367
|
+
protected abstract insertStore(params: {
|
|
368
|
+
definition: StoreDefinition;
|
|
369
|
+
id: string;
|
|
370
|
+
snapshot: StoreSnapshot<unknown>;
|
|
371
|
+
}): Promise<StoreSnapshot<unknown>>;
|
|
372
|
+
|
|
285
373
|
protected abstract getAppliedStoreStep(params: {
|
|
286
374
|
definition: StoreDefinition;
|
|
287
375
|
id: string;
|
|
@@ -289,13 +377,39 @@ export abstract class CasStoreClient extends StoreClient {
|
|
|
289
377
|
}): Promise<{ result: unknown } | undefined>;
|
|
290
378
|
|
|
291
379
|
/**
|
|
292
|
-
* Atomically compares `expected`, writes the next state
|
|
293
|
-
*
|
|
380
|
+
* Atomically compares `expected`, writes the next state at
|
|
381
|
+
* `expected.version + 1`, records the optional step result, and records the
|
|
382
|
+
* wake obligations created by `changedPaths`. When `expected.created` is
|
|
383
|
+
* true the store must still be missing, and the commit inserts it under
|
|
384
|
+
* `expected.instanceId`; otherwise a missing store returns `missing`. A
|
|
385
|
+
* recorded step result wins over both checks. A transactional outbox is
|
|
386
|
+
* recommended but is not part of the protocol.
|
|
294
387
|
*/
|
|
295
388
|
protected abstract commitStoreMutation(
|
|
296
389
|
mutation: StoreMutation
|
|
297
390
|
): Promise<StoreMutationCommitResult>;
|
|
298
391
|
|
|
392
|
+
/**
|
|
393
|
+
* Atomically compares `expected`, deletes the store and its waiters, and
|
|
394
|
+
* records the optional step result. A recorded step result wins over the
|
|
395
|
+
* comparison, and a missing store returns `missing`.
|
|
396
|
+
*/
|
|
397
|
+
protected abstract commitStoreDeletion(
|
|
398
|
+
deletion: StoreDeletion
|
|
399
|
+
): Promise<StoreMutationCommitResult>;
|
|
400
|
+
|
|
401
|
+
/** Names the instance a store is created as. Connectors may override it. */
|
|
402
|
+
protected createInstanceId(): string {
|
|
403
|
+
return crypto.randomUUID();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async getStore<Schema extends StandardSchemaV1>(params: {
|
|
407
|
+
definition: StoreDefinition<Schema>;
|
|
408
|
+
id: string;
|
|
409
|
+
}): Promise<StoreSnapshot<StoreState<Schema>>> {
|
|
410
|
+
return this.getOrInsertStore(params.definition, params.id);
|
|
411
|
+
}
|
|
412
|
+
|
|
299
413
|
async updateStore<Schema extends StandardSchemaV1>(params: {
|
|
300
414
|
definition: StoreDefinition<Schema>;
|
|
301
415
|
id: string;
|
|
@@ -307,15 +421,14 @@ export abstract class CasStoreClient extends StoreClient {
|
|
|
307
421
|
return applied.result as StoreUpdateResult<StoreState<Schema>>;
|
|
308
422
|
}
|
|
309
423
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
id: params.id,
|
|
313
|
-
});
|
|
424
|
+
const stepKey = params.stepId?.stepKey;
|
|
425
|
+
let snapshot = await this.readOrInitialize(params.definition, params.id, stepKey);
|
|
314
426
|
while (true) {
|
|
315
427
|
const prepared = await prepareStoreUpdate(
|
|
316
428
|
params.definition,
|
|
317
429
|
snapshot,
|
|
318
|
-
params.updater
|
|
430
|
+
params.updater,
|
|
431
|
+
stepKey
|
|
319
432
|
);
|
|
320
433
|
const result: StoreUpdateResult<StoreState<Schema>> = {
|
|
321
434
|
state: cloneStoreState(prepared.nextState),
|
|
@@ -336,7 +449,10 @@ export abstract class CasStoreClient extends StoreClient {
|
|
|
336
449
|
if (committed.status === "already-applied") {
|
|
337
450
|
return committed.result as StoreUpdateResult<StoreState<Schema>>;
|
|
338
451
|
}
|
|
339
|
-
snapshot =
|
|
452
|
+
snapshot =
|
|
453
|
+
committed.status === "conflict"
|
|
454
|
+
? existing(committed.snapshot as StoreSnapshot<StoreState<Schema>>)
|
|
455
|
+
: await this.readOrInitialize(params.definition, params.id, stepKey);
|
|
340
456
|
// TODO: add a configurable retry cap/backoff policy before remote CAS
|
|
341
457
|
// connectors are introduced; SQLite serializes commits locally.
|
|
342
458
|
}
|
|
@@ -356,10 +472,8 @@ export abstract class CasStoreClient extends StoreClient {
|
|
|
356
472
|
|
|
357
473
|
assertStoreSnapshotInstanceId(params.snapshot);
|
|
358
474
|
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
id: params.id,
|
|
362
|
-
});
|
|
475
|
+
const stepKey = params.stepId?.stepKey;
|
|
476
|
+
const current = await this.getOrInsertStore(params.definition, params.id, stepKey);
|
|
363
477
|
if (!sameStoreVersion(current, params.snapshot)) {
|
|
364
478
|
return storeUpdateConflict(params.snapshot, current);
|
|
365
479
|
}
|
|
@@ -367,7 +481,8 @@ export abstract class CasStoreClient extends StoreClient {
|
|
|
367
481
|
const prepared = await prepareStoreUpdate(
|
|
368
482
|
params.definition,
|
|
369
483
|
params.snapshot,
|
|
370
|
-
params.updater
|
|
484
|
+
params.updater,
|
|
485
|
+
stepKey
|
|
371
486
|
);
|
|
372
487
|
const result: StoreUpdateFromResult<StoreState<Schema>> = {
|
|
373
488
|
updated: true,
|
|
@@ -378,7 +493,7 @@ export abstract class CasStoreClient extends StoreClient {
|
|
|
378
493
|
const committed = await this.commitStoreMutation({
|
|
379
494
|
definition: params.definition,
|
|
380
495
|
id: params.id,
|
|
381
|
-
expected: params.snapshot,
|
|
496
|
+
expected: existing(params.snapshot),
|
|
382
497
|
nextState: prepared.nextState,
|
|
383
498
|
changedPaths: prepared.changedPaths,
|
|
384
499
|
stepId: params.stepId,
|
|
@@ -389,7 +504,54 @@ export abstract class CasStoreClient extends StoreClient {
|
|
|
389
504
|
if (committed.status === "already-applied") {
|
|
390
505
|
return committed.result as StoreUpdateFromResult<StoreState<Schema>>;
|
|
391
506
|
}
|
|
392
|
-
return storeUpdateConflict(
|
|
507
|
+
return storeUpdateConflict(
|
|
508
|
+
params.snapshot,
|
|
509
|
+
committed.status === "conflict"
|
|
510
|
+
? committed.snapshot
|
|
511
|
+
: await this.getOrInsertStore(params.definition, params.id, stepKey)
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async deleteStoreFrom(params: {
|
|
516
|
+
definition: StoreDefinition;
|
|
517
|
+
id: string;
|
|
518
|
+
snapshot: StoreSnapshot<unknown>;
|
|
519
|
+
stepId?: StoreStepId;
|
|
520
|
+
}): Promise<StoreDeleteFromResult> {
|
|
521
|
+
const applied = await this.getAppliedResult(params);
|
|
522
|
+
if (applied) return applied.result as StoreDeleteFromResult;
|
|
523
|
+
|
|
524
|
+
assertStoreSnapshotInstanceId(params.snapshot);
|
|
525
|
+
|
|
526
|
+
const stepKey = params.stepId?.stepKey;
|
|
527
|
+
let current = await this.getOrInsertStore(params.definition, params.id, stepKey);
|
|
528
|
+
while (sameStoreVersion(current, params.snapshot)) {
|
|
529
|
+
const result: StoreDeleteFromResult = { deleted: true };
|
|
530
|
+
const committed = await this.commitStoreDeletion({
|
|
531
|
+
definition: params.definition,
|
|
532
|
+
id: params.id,
|
|
533
|
+
expected: params.snapshot,
|
|
534
|
+
stepId: params.stepId,
|
|
535
|
+
result,
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
if (committed.status === "committed") return result;
|
|
539
|
+
if (committed.status === "already-applied") {
|
|
540
|
+
return committed.result as StoreDeleteFromResult;
|
|
541
|
+
}
|
|
542
|
+
current =
|
|
543
|
+
committed.status === "conflict"
|
|
544
|
+
? committed.snapshot
|
|
545
|
+
: await this.getOrInsertStore(params.definition, params.id, stepKey);
|
|
546
|
+
}
|
|
547
|
+
return {
|
|
548
|
+
deleted: false,
|
|
549
|
+
reason: "conflict",
|
|
550
|
+
expectedInstanceId: params.snapshot.instanceId,
|
|
551
|
+
actualInstanceId: current.instanceId,
|
|
552
|
+
expectedVersion: params.snapshot.version,
|
|
553
|
+
actualVersion: current.version,
|
|
554
|
+
};
|
|
393
555
|
}
|
|
394
556
|
|
|
395
557
|
async takeFromStore<Schema extends StandardSchemaV1, R>(params: {
|
|
@@ -405,10 +567,8 @@ export abstract class CasStoreClient extends StoreClient {
|
|
|
405
567
|
const applied = await this.getAppliedResult(params);
|
|
406
568
|
if (applied) return applied.result as StoreTakeResult<R>;
|
|
407
569
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
id: params.id,
|
|
411
|
-
});
|
|
570
|
+
const stepKey = params.stepId?.stepKey;
|
|
571
|
+
let snapshot = await this.readOrInitialize(params.definition, params.id, stepKey);
|
|
412
572
|
while (true) {
|
|
413
573
|
const previousState = cloneStoreState(snapshot.state);
|
|
414
574
|
const draft = cloneStoreState(previousState) as Draft<StoreState<Schema>>;
|
|
@@ -419,6 +579,19 @@ export abstract class CasStoreClient extends StoreClient {
|
|
|
419
579
|
);
|
|
420
580
|
|
|
421
581
|
if (!isStoreSelectorMatch(selectedResult)) {
|
|
582
|
+
// An unmatched take still creates the store, so a waiter registered
|
|
583
|
+
// from this result observes an instance that exists.
|
|
584
|
+
if (snapshot.created) {
|
|
585
|
+
const stored = await this.insertStore({
|
|
586
|
+
definition: params.definition,
|
|
587
|
+
id: params.id,
|
|
588
|
+
snapshot: detach(snapshot),
|
|
589
|
+
});
|
|
590
|
+
if (stored.instanceId !== snapshot.instanceId) {
|
|
591
|
+
snapshot = existing(stored as StoreSnapshot<StoreState<Schema>>);
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
422
595
|
return {
|
|
423
596
|
matched: false,
|
|
424
597
|
instanceId: snapshot.instanceId,
|
|
@@ -434,7 +607,15 @@ export abstract class CasStoreClient extends StoreClient {
|
|
|
434
607
|
previousState,
|
|
435
608
|
draft,
|
|
436
609
|
tracked,
|
|
610
|
+
stepKey,
|
|
437
611
|
});
|
|
612
|
+
// Checked before the commit, so a selection that cannot be recorded
|
|
613
|
+
// leaves the store unchanged.
|
|
614
|
+
assertDurableStoreValue(
|
|
615
|
+
selected,
|
|
616
|
+
stepKey ?? "",
|
|
617
|
+
`Store take selection for "${params.definition.name}"`
|
|
618
|
+
);
|
|
438
619
|
const selectedSnapshot = cloneStoreState(selected);
|
|
439
620
|
const result: StoreTakeResult<R> = {
|
|
440
621
|
matched: true,
|
|
@@ -456,11 +637,50 @@ export abstract class CasStoreClient extends StoreClient {
|
|
|
456
637
|
if (committed.status === "already-applied") {
|
|
457
638
|
return committed.result as StoreTakeResult<R>;
|
|
458
639
|
}
|
|
459
|
-
snapshot =
|
|
640
|
+
snapshot =
|
|
641
|
+
committed.status === "conflict"
|
|
642
|
+
? existing(committed.snapshot as StoreSnapshot<StoreState<Schema>>)
|
|
643
|
+
: await this.readOrInitialize(params.definition, params.id, stepKey);
|
|
460
644
|
// TODO: share the configurable retry cap/backoff policy with updateStore.
|
|
461
645
|
}
|
|
462
646
|
}
|
|
463
647
|
|
|
648
|
+
/**
|
|
649
|
+
* Reads a store, or evaluates and validates `definition.initial` when it is
|
|
650
|
+
* missing. Nothing is written: a created snapshot is committed by the
|
|
651
|
+
* operation computed from it.
|
|
652
|
+
*/
|
|
653
|
+
private async readOrInitialize<Schema extends StandardSchemaV1>(
|
|
654
|
+
definition: StoreDefinition<Schema>,
|
|
655
|
+
id: string,
|
|
656
|
+
stepKey?: string
|
|
657
|
+
): Promise<StoreReadSnapshot<StoreState<Schema>>> {
|
|
658
|
+
const snapshot = await this.readStore({ definition, id });
|
|
659
|
+
if (snapshot) return existing(snapshot as StoreSnapshot<StoreState<Schema>>);
|
|
660
|
+
|
|
661
|
+
const state = await validateStoreState(definition, definition.initial(id), stepKey);
|
|
662
|
+
return {
|
|
663
|
+
state,
|
|
664
|
+
instanceId: this.createInstanceId(),
|
|
665
|
+
version: 0,
|
|
666
|
+
created: true,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
private async getOrInsertStore<Schema extends StandardSchemaV1>(
|
|
671
|
+
definition: StoreDefinition<Schema>,
|
|
672
|
+
id: string,
|
|
673
|
+
stepKey?: string
|
|
674
|
+
): Promise<StoreSnapshot<StoreState<Schema>>> {
|
|
675
|
+
const snapshot = await this.readOrInitialize(definition, id, stepKey);
|
|
676
|
+
if (!snapshot.created) return detach(snapshot);
|
|
677
|
+
return (await this.insertStore({
|
|
678
|
+
definition,
|
|
679
|
+
id,
|
|
680
|
+
snapshot: detach(snapshot),
|
|
681
|
+
})) as StoreSnapshot<StoreState<Schema>>;
|
|
682
|
+
}
|
|
683
|
+
|
|
464
684
|
private getAppliedResult(params: {
|
|
465
685
|
definition: StoreDefinition;
|
|
466
686
|
id: string;
|
|
@@ -476,10 +696,28 @@ export abstract class CasStoreClient extends StoreClient {
|
|
|
476
696
|
}
|
|
477
697
|
}
|
|
478
698
|
|
|
699
|
+
function existing<T>(snapshot: StoreSnapshot<T>): StoreReadSnapshot<T> {
|
|
700
|
+
return {
|
|
701
|
+
state: snapshot.state,
|
|
702
|
+
instanceId: snapshot.instanceId,
|
|
703
|
+
version: snapshot.version,
|
|
704
|
+
created: false,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function detach<T>(snapshot: StoreReadSnapshot<T>): StoreSnapshot<T> {
|
|
709
|
+
return {
|
|
710
|
+
state: snapshot.state,
|
|
711
|
+
instanceId: snapshot.instanceId,
|
|
712
|
+
version: snapshot.version,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
479
716
|
async function prepareStoreUpdate<Schema extends StandardSchemaV1>(
|
|
480
717
|
definition: StoreDefinition<Schema>,
|
|
481
718
|
snapshot: StoreSnapshot<StoreState<Schema>>,
|
|
482
|
-
updater: (draft: Draft<StoreState<Schema>>) => void | StoreState<Schema
|
|
719
|
+
updater: (draft: Draft<StoreState<Schema>>) => void | StoreState<Schema>,
|
|
720
|
+
stepKey: string | undefined
|
|
483
721
|
) {
|
|
484
722
|
const previousState = cloneStoreState(snapshot.state);
|
|
485
723
|
const draft = cloneStoreState(previousState) as Draft<StoreState<Schema>>;
|
|
@@ -493,6 +731,7 @@ async function prepareStoreUpdate<Schema extends StandardSchemaV1>(
|
|
|
493
731
|
draft,
|
|
494
732
|
tracked,
|
|
495
733
|
replacement: returned,
|
|
734
|
+
stepKey,
|
|
496
735
|
});
|
|
497
736
|
}
|
|
498
737
|
|
|
@@ -502,12 +741,14 @@ async function finalizeStoreMutation<Schema extends StandardSchemaV1>(params: {
|
|
|
502
741
|
draft: Draft<StoreState<Schema>>;
|
|
503
742
|
tracked: TrackedStoreUpdater<Draft<StoreState<Schema>>>;
|
|
504
743
|
replacement?: unknown;
|
|
744
|
+
stepKey?: string;
|
|
505
745
|
}) {
|
|
506
746
|
const isReplacement =
|
|
507
747
|
params.replacement !== undefined && params.replacement !== params.draft;
|
|
508
748
|
const nextState = await validateStoreState(
|
|
509
749
|
params.definition,
|
|
510
|
-
isReplacement ? params.replacement : params.draft
|
|
750
|
+
isReplacement ? params.replacement : params.draft,
|
|
751
|
+
params.stepKey
|
|
511
752
|
);
|
|
512
753
|
const changedPaths =
|
|
513
754
|
!isReplacement && (nextState as unknown) === params.draft
|
|
@@ -570,21 +811,70 @@ export function assertSynchronousUpdater(updaterResult: unknown): void {
|
|
|
570
811
|
}
|
|
571
812
|
}
|
|
572
813
|
|
|
814
|
+
/**
|
|
815
|
+
* Validates a state against its store's schema, then checks that the schema's
|
|
816
|
+
* output is a durable value. Throws `StoreValidationError` when the schema
|
|
817
|
+
* rejects the state, and `WorkflowValueError` (`INVALID_STEP_VALUE`) when the
|
|
818
|
+
* output is not durable, so callers that validate before committing leave the
|
|
819
|
+
* store unchanged. `stepKey` names the step in that error.
|
|
820
|
+
*/
|
|
573
821
|
export async function validateStoreState<Schema extends StandardSchemaV1>(
|
|
574
822
|
definition: StoreDefinition<Schema>,
|
|
575
|
-
state: unknown
|
|
823
|
+
state: unknown,
|
|
824
|
+
stepKey = ""
|
|
576
825
|
): Promise<StoreState<Schema>> {
|
|
577
826
|
const result = await definition.schema["~standard"].validate(state);
|
|
578
827
|
|
|
579
828
|
if ("issues" in result && result.issues) {
|
|
580
829
|
const messages = result.issues.map((issue) => issue.message).join("; ");
|
|
581
|
-
throw new
|
|
830
|
+
throw new StoreValidationError(
|
|
831
|
+
`Invalid store state for "${definition.name}": ${messages}`
|
|
832
|
+
);
|
|
582
833
|
}
|
|
583
834
|
|
|
835
|
+
assertDurableStoreValue(result.value, stepKey, `Store state for "${definition.name}"`);
|
|
584
836
|
return result.value as StoreState<Schema>;
|
|
585
837
|
}
|
|
586
838
|
|
|
839
|
+
function assertDurableStoreValue(value: unknown, stepKey: string, label: string) {
|
|
840
|
+
const encoding = durableValueCodec.encode(value);
|
|
841
|
+
if (encoding.status === "invalid") {
|
|
842
|
+
throw new WorkflowValueError(
|
|
843
|
+
"INVALID_STEP_VALUE",
|
|
844
|
+
stepKey,
|
|
845
|
+
`${label} is not a durable value: ${encoding.message}`
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Encodes a store state or step result for storage. Throws if it is not durable. */
|
|
851
|
+
export function encodeStoreValue(value: unknown): string {
|
|
852
|
+
const encoding = durableValueCodec.encode(value);
|
|
853
|
+
if (encoding.status === "invalid") {
|
|
854
|
+
throw new Error(`Store value is not a durable value: ${encoding.message}`);
|
|
855
|
+
}
|
|
856
|
+
return encoding.canonical;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
export function decodeStoreValue(canonical: string): unknown {
|
|
860
|
+
const decoding = durableValueCodec.decode(canonical);
|
|
861
|
+
if (decoding.status === "invalid") {
|
|
862
|
+
throw new Error(`Invalid store value encoding: ${decoding.message}`);
|
|
863
|
+
}
|
|
864
|
+
return decoding.value;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Copies a state as the durable value codec would read it back. Values the
|
|
869
|
+
* codec rejects, which validation keeps out of committed state, are copied
|
|
870
|
+
* with `structuredClone`.
|
|
871
|
+
*/
|
|
587
872
|
export function cloneStoreState<T>(state: T): T {
|
|
873
|
+
const encoding = durableValueCodec.encode(state);
|
|
874
|
+
if (encoding.status === "encoded") {
|
|
875
|
+
const decoding = durableValueCodec.decode(encoding.canonical);
|
|
876
|
+
if (decoding.status === "decoded") return decoding.value as T;
|
|
877
|
+
}
|
|
588
878
|
if (typeof structuredClone === "function") {
|
|
589
879
|
try {
|
|
590
880
|
return structuredClone(state);
|
package/src/base/workflow.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Logger } from "pino";
|
|
2
2
|
import type { EventParams, WorkflowEvent, EventContext } from "./event";
|
|
3
|
+
import type { EffectClient } from "./effect";
|
|
4
|
+
import type { ExecutionClient, ExecutionLease } from "./execution";
|
|
3
5
|
import { HeapClient } from "./heap";
|
|
4
6
|
import { StoreClient } from "./store";
|
|
5
7
|
import { StepResponse, WorkflowResult } from "./step";
|
|
@@ -9,8 +11,21 @@ export type WorkflowGeneratorParams<
|
|
|
9
11
|
Context extends EventContext
|
|
10
12
|
> = {
|
|
11
13
|
event: WorkflowEvent<Params, Context>;
|
|
14
|
+
/** Every heap step write is fenced by this lease. */
|
|
15
|
+
lease: ExecutionLease;
|
|
16
|
+
/**
|
|
17
|
+
* Aborted when the delivery must stop. The driver checks it before each
|
|
18
|
+
* step function and each fenced write.
|
|
19
|
+
*/
|
|
20
|
+
signal: AbortSignal;
|
|
12
21
|
heapClient: HeapClient;
|
|
13
22
|
storeClient: StoreClient;
|
|
23
|
+
/** `step.start` admits its target through this client. */
|
|
24
|
+
executionClient: ExecutionClient;
|
|
25
|
+
/** `step.effect` records and settles its effects through this client. */
|
|
26
|
+
effectClient: EffectClient;
|
|
27
|
+
/** The runtime clock. Delay and retry wake times are read from it. */
|
|
28
|
+
clock: () => number;
|
|
14
29
|
logger: Logger;
|
|
15
30
|
};
|
|
16
31
|
|