@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.
Files changed (64) hide show
  1. package/dist/base/durable-value.d.ts +71 -0
  2. package/dist/base/durable-value.d.ts.map +1 -0
  3. package/dist/base/durable-value.test.d.ts +2 -0
  4. package/dist/base/durable-value.test.d.ts.map +1 -0
  5. package/dist/base/effect.d.ts +136 -0
  6. package/dist/base/effect.d.ts.map +1 -0
  7. package/dist/base/errors.d.ts +23 -0
  8. package/dist/base/errors.d.ts.map +1 -0
  9. package/dist/base/errors.test.d.ts +2 -0
  10. package/dist/base/errors.test.d.ts.map +1 -0
  11. package/dist/base/event.d.ts +6 -1
  12. package/dist/base/event.d.ts.map +1 -1
  13. package/dist/base/execution.d.ts +255 -0
  14. package/dist/base/execution.d.ts.map +1 -0
  15. package/dist/base/heap.d.ts +15 -3
  16. package/dist/base/heap.d.ts.map +1 -1
  17. package/dist/base/invoker.d.ts +47 -2
  18. package/dist/base/invoker.d.ts.map +1 -1
  19. package/dist/base/runtime.d.ts +27 -4
  20. package/dist/base/runtime.d.ts.map +1 -1
  21. package/dist/base/step.d.ts +21 -5
  22. package/dist/base/step.d.ts.map +1 -1
  23. package/dist/base/store.d.ts +135 -21
  24. package/dist/base/store.d.ts.map +1 -1
  25. package/dist/base/workflow.d.ts +15 -0
  26. package/dist/base/workflow.d.ts.map +1 -1
  27. package/dist/index.d.ts +23 -6
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1653 -172
  30. package/dist/lib/effect-state.d.ts +78 -0
  31. package/dist/lib/effect-state.d.ts.map +1 -0
  32. package/dist/lib/execution-dispatcher.d.ts +112 -0
  33. package/dist/lib/execution-dispatcher.d.ts.map +1 -0
  34. package/dist/lib/execution-state.d.ts +248 -0
  35. package/dist/lib/execution-state.d.ts.map +1 -0
  36. package/dist/lib/execution-state.test.d.ts +2 -0
  37. package/dist/lib/execution-state.test.d.ts.map +1 -0
  38. package/dist/lib/wait-for-outcome.d.ts +15 -0
  39. package/dist/lib/wait-for-outcome.d.ts.map +1 -0
  40. package/dist/lib/workflow-runner.d.ts +14 -9
  41. package/dist/lib/workflow-runner.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/base/durable-value.test.ts +285 -0
  44. package/src/base/durable-value.ts +442 -0
  45. package/src/base/effect.ts +165 -0
  46. package/src/base/errors.test.ts +210 -0
  47. package/src/base/errors.ts +72 -0
  48. package/src/base/event.ts +6 -1
  49. package/src/base/execution.ts +315 -0
  50. package/src/base/heap.ts +17 -3
  51. package/src/base/invoker.ts +60 -2
  52. package/src/base/runtime.ts +28 -7
  53. package/src/base/step.ts +29 -7
  54. package/src/base/store.ts +339 -49
  55. package/src/base/workflow.ts +15 -0
  56. package/src/index.ts +138 -3
  57. package/src/lib/effect-state.ts +254 -0
  58. package/src/lib/execution-dispatcher.ts +408 -0
  59. package/src/lib/execution-state.test.ts +249 -0
  60. package/src/lib/execution-state.ts +650 -0
  61. package/src/lib/wait-for-outcome.ts +40 -0
  62. package/src/lib/workflow-runner.ts +75 -43
  63. package/src/base/scheduler.ts +0 -8
  64. 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 { WorkflowEvent } from "./event";
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
- expected: StoreSnapshot<unknown>;
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
- export function defineStore<Schema extends StandardSchemaV1>(
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 { name, schema };
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
- abstract getOrCreateStore<Schema extends StandardSchemaV1>(params: {
173
- definition: StoreDefinition<Schema>;
174
- id: string;
175
- initial?:
176
- | StoreState<Schema>
177
- | (() => StoreState<Schema> | Promise<StoreState<Schema>>);
178
- }): Promise<StoreSnapshot<StoreState<Schema>>>;
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 all of its associated rows (state, waiters,
263
- * applied-steps ledger). A no-op if the store does not exist.
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
- /** Deletes a store only if its instance ID and version still match the snapshot. */
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 transaction.
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, records the step
293
- * result, and persists the changed paths for durable wake delivery.
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
- let snapshot = await this.getStore({
311
- definition: params.definition,
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 = committed.snapshot as StoreSnapshot<StoreState<Schema>>;
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 current = await this.getStore({
360
- definition: params.definition,
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(params.snapshot, committed.snapshot);
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
- let snapshot = await this.getStore({
409
- definition: params.definition,
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 = committed.snapshot as StoreSnapshot<StoreState<Schema>>;
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 Error(`Invalid store state for "${definition.name}": ${messages}`);
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);
@@ -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