@yieldstar/core 0.4.6 → 0.6.0

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 (79) 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 +244 -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 +26 -5
  22. package/dist/base/step.d.ts.map +1 -1
  23. package/dist/base/store.d.ts +431 -0
  24. package/dist/base/store.d.ts.map +1 -0
  25. package/dist/base/store.test.d.ts +2 -0
  26. package/dist/base/store.test.d.ts.map +1 -0
  27. package/dist/base/workflow.d.ts +17 -0
  28. package/dist/base/workflow.d.ts.map +1 -1
  29. package/dist/index.d.ts +24 -4
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1986 -111
  32. package/dist/lib/effect-state.d.ts +78 -0
  33. package/dist/lib/effect-state.d.ts.map +1 -0
  34. package/dist/lib/execution-dispatcher.d.ts +102 -0
  35. package/dist/lib/execution-dispatcher.d.ts.map +1 -0
  36. package/dist/lib/execution-state.d.ts +248 -0
  37. package/dist/lib/execution-state.d.ts.map +1 -0
  38. package/dist/lib/execution-state.test.d.ts +2 -0
  39. package/dist/lib/execution-state.test.d.ts.map +1 -0
  40. package/dist/lib/wait-for-outcome.d.ts +15 -0
  41. package/dist/lib/wait-for-outcome.d.ts.map +1 -0
  42. package/dist/lib/workflow-runner.d.ts +14 -7
  43. package/dist/lib/workflow-runner.d.ts.map +1 -1
  44. package/dist/utils/map.d.ts +1 -1
  45. package/dist/utils/map.d.ts.map +1 -1
  46. package/package.json +13 -3
  47. package/src/base/durable-value.test.ts +285 -0
  48. package/src/base/durable-value.ts +442 -0
  49. package/src/base/effect.ts +165 -0
  50. package/src/base/errors.test.ts +210 -0
  51. package/src/base/errors.ts +72 -0
  52. package/src/base/event.ts +6 -1
  53. package/src/base/execution.ts +303 -0
  54. package/src/base/heap.ts +17 -3
  55. package/src/base/invoker.ts +60 -2
  56. package/src/base/runtime.ts +28 -7
  57. package/src/base/step.ts +37 -7
  58. package/src/base/store.test.ts +176 -0
  59. package/src/base/store.ts +1123 -0
  60. package/src/base/workflow.ts +17 -0
  61. package/src/index.ts +174 -3
  62. package/src/lib/effect-state.ts +254 -0
  63. package/src/lib/execution-dispatcher.ts +393 -0
  64. package/src/lib/execution-state.test.ts +249 -0
  65. package/src/lib/execution-state.ts +650 -0
  66. package/src/lib/wait-for-outcome.ts +40 -0
  67. package/src/lib/workflow-runner.ts +78 -34
  68. package/src/utils/map.test.ts +1 -1
  69. package/src/utils/map.ts +8 -2
  70. package/dist/base/map.d.ts +0 -21
  71. package/dist/base/map.d.ts.map +0 -1
  72. package/dist/base/map.test.d.ts +0 -2
  73. package/dist/base/map.test.d.ts.map +0 -1
  74. package/dist/base/schedule.d.ts +0 -71
  75. package/dist/base/schedule.d.ts.map +0 -1
  76. package/dist/base/types.d.ts +0 -24
  77. package/dist/base/types.d.ts.map +0 -1
  78. package/src/base/scheduler.ts +0 -8
  79. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,1123 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import { durableValueCodec, type Durable, type IfDurable } from "./durable-value";
3
+ import { WorkflowValueError, registerErrorClass } from "./errors";
4
+
5
+ export type Draft<T> = T extends object
6
+ ? { -readonly [K in keyof T]: Draft<T[K]> }
7
+ : T;
8
+
9
+ export type StoreVersion = number;
10
+ export type StorePath = readonly (string | number)[];
11
+
12
+ export type StoreDefinition<Schema extends StandardSchemaV1 = StandardSchemaV1> =
13
+ {
14
+ name: string;
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>;
22
+ };
23
+
24
+ export type StoreState<Schema extends StandardSchemaV1> =
25
+ StandardSchemaV1.InferOutput<Schema>;
26
+
27
+ export type StoreKey = {
28
+ storeName: string;
29
+ storeId: string;
30
+ };
31
+
32
+ /**
33
+ * Identifies the durable workflow step performing a store operation.
34
+ * When provided to `updateStore`/`takeFromStore`, the store client records
35
+ * the committed result in an applied-steps ledger keyed by
36
+ * (storeName, storeId, executionId, stepKey), written atomically with the
37
+ * state change. A retried call with the same StoreStepId returns the recorded
38
+ * result without re-running the updater/selector/claim, making the operation
39
+ * exactly-once even if the caller crashes before persisting its own record
40
+ * of the result (e.g. the workflow heap write).
41
+ */
42
+ export type StoreStepId = {
43
+ executionId: string;
44
+ stepKey: string;
45
+ };
46
+
47
+ export type StoreSnapshot<T> = {
48
+ state: T;
49
+ instanceId: string;
50
+ version: StoreVersion;
51
+ };
52
+
53
+ export type StoreUpdateResult<T> = {
54
+ state: T;
55
+ previousVersion: StoreVersion;
56
+ version: StoreVersion;
57
+ };
58
+
59
+ export type StoreUpdateFromResult<T> =
60
+ | ({ updated: true } & StoreUpdateResult<T>)
61
+ | {
62
+ updated: false;
63
+ expectedInstanceId: string;
64
+ actualInstanceId: string;
65
+ expectedVersion: StoreVersion;
66
+ actualVersion: StoreVersion;
67
+ };
68
+
69
+ export type StoreDeleteFromResult =
70
+ | { deleted: true }
71
+ | {
72
+ deleted: false;
73
+ reason: "conflict";
74
+ expectedInstanceId: string;
75
+ actualInstanceId: string;
76
+ expectedVersion: StoreVersion;
77
+ actualVersion: StoreVersion;
78
+ };
79
+
80
+ export type StoreSelector<T, R> = (state: Readonly<T>) => R;
81
+
82
+ export type StoreTakeResult<R> =
83
+ | {
84
+ matched: true;
85
+ selected: NonNullable<R>;
86
+ instanceId: string;
87
+ version: StoreVersion;
88
+ }
89
+ | {
90
+ matched: false;
91
+ instanceId: string;
92
+ version: StoreVersion;
93
+ readPaths: StorePath[];
94
+ };
95
+
96
+ export type StoreWaiter = {
97
+ workflowId: string;
98
+ executionId: string;
99
+ stepKey: string;
100
+ storeName: string;
101
+ storeId: string;
102
+ instanceId: string;
103
+ sinceVersion: StoreVersion;
104
+ readPaths: StorePath[];
105
+ };
106
+
107
+ export type StoreMutationCommitResult =
108
+ | { status: "committed" }
109
+ | { status: "already-applied"; result: 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 };
119
+
120
+ export type StoreMutation = {
121
+ definition: StoreDefinition;
122
+ id: string;
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>;
129
+ nextState: unknown;
130
+ changedPaths: StorePath[];
131
+ stepId?: StoreStepId;
132
+ result: unknown;
133
+ };
134
+
135
+ export type StoreDeletion = {
136
+ definition: StoreDefinition;
137
+ id: string;
138
+ expected: StoreSnapshot<unknown>;
139
+ stepId?: StoreStepId;
140
+ result: StoreDeleteFromResult;
141
+ };
142
+
143
+ export type RuntimeStore<T> = {
144
+ get(): Promise<StoreSnapshot<T>>;
145
+ update(updater: (draft: Draft<T>) => void | T): Promise<StoreUpdateResult<T>>;
146
+ updateFrom(
147
+ snapshot: StoreSnapshot<T>,
148
+ updater: (draft: Draft<T>) => void | T
149
+ ): Promise<StoreUpdateFromResult<T>>;
150
+ deleteFrom(snapshot: StoreSnapshot<T>): Promise<StoreDeleteFromResult>;
151
+ };
152
+
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
+ >(
183
+ name: string,
184
+ schema: Schema & StandardSchemaV1<any, State> & DurableStoreSchema<Schema>,
185
+ options: {
186
+ initial: (id: string) => NoInfer<Durable<State>>;
187
+ }
188
+ ): StoreDefinition<Schema> {
189
+ return {
190
+ name,
191
+ schema,
192
+ initial: options.initial as (id: string) => StoreState<Schema>,
193
+ };
194
+ }
195
+
196
+ export abstract class StoreClient {
197
+ store<Schema extends StandardSchemaV1>(
198
+ definition: StoreDefinition<Schema>,
199
+ id: string
200
+ ): RuntimeStore<StoreState<Schema>> {
201
+ return {
202
+ get: () =>
203
+ this.getStore({
204
+ definition,
205
+ id,
206
+ }),
207
+ update: (updater) =>
208
+ this.updateStore({
209
+ definition,
210
+ id,
211
+ updater,
212
+ }),
213
+ updateFrom: (snapshot, updater) =>
214
+ this.updateStoreFrom({
215
+ definition,
216
+ id,
217
+ snapshot,
218
+ updater,
219
+ }),
220
+ deleteFrom: (snapshot) =>
221
+ this.deleteStoreFrom({
222
+ definition,
223
+ id,
224
+ snapshot,
225
+ }),
226
+ };
227
+ }
228
+
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
+ */
236
+ abstract getStore<Schema extends StandardSchemaV1>(params: {
237
+ definition: StoreDefinition<Schema>;
238
+ id: string;
239
+ }): Promise<StoreSnapshot<StoreState<Schema>>>;
240
+
241
+ /**
242
+ * Updates a store's state atomically.
243
+ * Updaters must be synchronous, deterministic, and side-effect-free. CAS-backed
244
+ * clients may run an updater again when another writer wins the version race.
245
+ * A rejected promise does not prove that the mutation was rolled back: a
246
+ * connector may commit state and then fail while delivering its durable wake
247
+ * intents. Retrying is exactly-once only when the same `stepId` is supplied.
248
+ * Callers without a `stepId` must read and reconcile instead of blindly retrying.
249
+ *
250
+ * When `stepId` is provided the update is exactly-once per
251
+ * (executionId, stepKey): the committed StoreUpdateResult is recorded in
252
+ * the applied-steps ledger inside the same transaction as the state change,
253
+ * and a repeated call with the same stepId returns the recorded result
254
+ * verbatim without running the updater or bumping the version.
255
+ */
256
+ abstract updateStore<Schema extends StandardSchemaV1>(params: {
257
+ definition: StoreDefinition<Schema>;
258
+ id: string;
259
+ updater: (draft: Draft<StoreState<Schema>>) => void | StoreState<Schema>;
260
+ stepId?: StoreStepId;
261
+ }): Promise<StoreUpdateResult<StoreState<Schema>>>;
262
+
263
+ /**
264
+ * Updates a store only if it has not changed since `snapshot` was read.
265
+ * A conflict does not change the store, though a racing writer may cause a
266
+ * pure updater to have been evaluated before the conflict is observed. When `stepId`
267
+ * identifies an update that already committed, its recorded result wins
268
+ * over the version check so replay remains exactly-once.
269
+ */
270
+ abstract updateStoreFrom<Schema extends StandardSchemaV1>(params: {
271
+ definition: StoreDefinition<Schema>;
272
+ id: string;
273
+ snapshot: StoreSnapshot<StoreState<Schema>>;
274
+ updater: (draft: Draft<StoreState<Schema>>) => void | StoreState<Schema>;
275
+ stepId?: StoreStepId;
276
+ }): Promise<StoreUpdateFromResult<StoreState<Schema>>>;
277
+
278
+ /**
279
+ * Atomically selects and claims from a store. The selector and claim run
280
+ * locally against a snapshot, then commit with a single conditional version
281
+ * bump. A conflict re-runs both functions against the new snapshot. If the selector does not match, nothing is
282
+ * committed and the store's current version plus the selector's tracked
283
+ * read paths are returned so the caller can register a waiter with the
284
+ * correct sinceVersion (closing the lost-wakeup gap by construction).
285
+ *
286
+ * `claim` must be synchronous – implementations must reject (throw) if it
287
+ * returns a Promise.
288
+ *
289
+ * When `stepId` is provided, a MATCHED (committed) take is recorded in the
290
+ * applied-steps ledger inside the same transaction as the claim commit, and
291
+ * a repeated call with the same stepId returns the recorded outcome without
292
+ * re-running the selector or claim. Unmatched outcomes are NOT recorded:
293
+ * an unmatched take commits nothing and registers a waiter, so it must
294
+ * remain free to re-evaluate the selector on every wake.
295
+ */
296
+ abstract takeFromStore<Schema extends StandardSchemaV1, R>(params: {
297
+ definition: StoreDefinition<Schema>;
298
+ id: string;
299
+ selector: StoreSelector<StoreState<Schema>, R>;
300
+ claim: (
301
+ draft: Draft<StoreState<Schema>>,
302
+ selected: NonNullable<R>
303
+ ) => void;
304
+ stepId?: StoreStepId;
305
+ }): Promise<StoreTakeResult<R>>;
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
+ */
315
+ abstract registerWaiter(waiter: StoreWaiter): Promise<void>;
316
+
317
+ /**
318
+ * Enumerates the ids of every store created under a given definition name.
319
+ * Needed by external consumers (e.g. Notation's state backend) that treat a
320
+ * store name as a collection of instances and must list them. Order is
321
+ * ascending by id so callers get a deterministic sequence.
322
+ */
323
+ abstract listStores(definition: StoreDefinition): Promise<string[]>;
324
+
325
+ /**
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.
329
+ */
330
+ abstract deleteStore(params: {
331
+ definition: StoreDefinition;
332
+ id: string;
333
+ }): Promise<void>;
334
+
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
+ */
341
+ abstract deleteStoreFrom(params: {
342
+ definition: StoreDefinition;
343
+ id: string;
344
+ snapshot: StoreSnapshot<unknown>;
345
+ stepId?: StoreStepId;
346
+ }): Promise<StoreDeleteFromResult>;
347
+ }
348
+
349
+ /**
350
+ * Implements store transformations as read/compute/compare-and-swap loops.
351
+ * Storage connectors only implement the atomic mutation protocol and never
352
+ * execute application callbacks, including `initial`, while holding a storage
353
+ * transaction.
354
+ */
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
+
373
+ protected abstract getAppliedStoreStep(params: {
374
+ definition: StoreDefinition;
375
+ id: string;
376
+ stepId: StoreStepId;
377
+ }): Promise<{ result: unknown } | undefined>;
378
+
379
+ /**
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.
387
+ */
388
+ protected abstract commitStoreMutation(
389
+ mutation: StoreMutation
390
+ ): Promise<StoreMutationCommitResult>;
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
+
413
+ async updateStore<Schema extends StandardSchemaV1>(params: {
414
+ definition: StoreDefinition<Schema>;
415
+ id: string;
416
+ updater: (draft: Draft<StoreState<Schema>>) => void | StoreState<Schema>;
417
+ stepId?: StoreStepId;
418
+ }): Promise<StoreUpdateResult<StoreState<Schema>>> {
419
+ const applied = await this.getAppliedResult(params);
420
+ if (applied) {
421
+ return applied.result as StoreUpdateResult<StoreState<Schema>>;
422
+ }
423
+
424
+ const stepKey = params.stepId?.stepKey;
425
+ let snapshot = await this.readOrInitialize(params.definition, params.id, stepKey);
426
+ while (true) {
427
+ const prepared = await prepareStoreUpdate(
428
+ params.definition,
429
+ snapshot,
430
+ params.updater,
431
+ stepKey
432
+ );
433
+ const result: StoreUpdateResult<StoreState<Schema>> = {
434
+ state: cloneStoreState(prepared.nextState),
435
+ previousVersion: snapshot.version,
436
+ version: snapshot.version + 1,
437
+ };
438
+ const committed = await this.commitStoreMutation({
439
+ definition: params.definition,
440
+ id: params.id,
441
+ expected: snapshot,
442
+ nextState: prepared.nextState,
443
+ changedPaths: prepared.changedPaths,
444
+ stepId: params.stepId,
445
+ result,
446
+ });
447
+
448
+ if (committed.status === "committed") return result;
449
+ if (committed.status === "already-applied") {
450
+ return committed.result as StoreUpdateResult<StoreState<Schema>>;
451
+ }
452
+ snapshot =
453
+ committed.status === "conflict"
454
+ ? existing(committed.snapshot as StoreSnapshot<StoreState<Schema>>)
455
+ : await this.readOrInitialize(params.definition, params.id, stepKey);
456
+ // TODO: add a configurable retry cap/backoff policy before remote CAS
457
+ // connectors are introduced; SQLite serializes commits locally.
458
+ }
459
+ }
460
+
461
+ async updateStoreFrom<Schema extends StandardSchemaV1>(params: {
462
+ definition: StoreDefinition<Schema>;
463
+ id: string;
464
+ snapshot: StoreSnapshot<StoreState<Schema>>;
465
+ updater: (draft: Draft<StoreState<Schema>>) => void | StoreState<Schema>;
466
+ stepId?: StoreStepId;
467
+ }): Promise<StoreUpdateFromResult<StoreState<Schema>>> {
468
+ const applied = await this.getAppliedResult(params);
469
+ if (applied) {
470
+ return applied.result as StoreUpdateFromResult<StoreState<Schema>>;
471
+ }
472
+
473
+ assertStoreSnapshotInstanceId(params.snapshot);
474
+
475
+ const stepKey = params.stepId?.stepKey;
476
+ const current = await this.getOrInsertStore(params.definition, params.id, stepKey);
477
+ if (!sameStoreVersion(current, params.snapshot)) {
478
+ return storeUpdateConflict(params.snapshot, current);
479
+ }
480
+
481
+ const prepared = await prepareStoreUpdate(
482
+ params.definition,
483
+ params.snapshot,
484
+ params.updater,
485
+ stepKey
486
+ );
487
+ const result: StoreUpdateFromResult<StoreState<Schema>> = {
488
+ updated: true,
489
+ state: cloneStoreState(prepared.nextState),
490
+ previousVersion: params.snapshot.version,
491
+ version: params.snapshot.version + 1,
492
+ };
493
+ const committed = await this.commitStoreMutation({
494
+ definition: params.definition,
495
+ id: params.id,
496
+ expected: existing(params.snapshot),
497
+ nextState: prepared.nextState,
498
+ changedPaths: prepared.changedPaths,
499
+ stepId: params.stepId,
500
+ result,
501
+ });
502
+
503
+ if (committed.status === "committed") return result;
504
+ if (committed.status === "already-applied") {
505
+ return committed.result as StoreUpdateFromResult<StoreState<Schema>>;
506
+ }
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
+ };
555
+ }
556
+
557
+ async takeFromStore<Schema extends StandardSchemaV1, R>(params: {
558
+ definition: StoreDefinition<Schema>;
559
+ id: string;
560
+ selector: StoreSelector<StoreState<Schema>, R>;
561
+ claim: (
562
+ draft: Draft<StoreState<Schema>>,
563
+ selected: NonNullable<R>
564
+ ) => void;
565
+ stepId?: StoreStepId;
566
+ }): Promise<StoreTakeResult<R>> {
567
+ const applied = await this.getAppliedResult(params);
568
+ if (applied) return applied.result as StoreTakeResult<R>;
569
+
570
+ const stepKey = params.stepId?.stepKey;
571
+ let snapshot = await this.readOrInitialize(params.definition, params.id, stepKey);
572
+ while (true) {
573
+ const previousState = cloneStoreState(snapshot.state);
574
+ const draft = cloneStoreState(previousState) as Draft<StoreState<Schema>>;
575
+ const tracked = trackStoreUpdater(draft);
576
+ const { result: selectedResult, readPaths } = trackStoreSelector(
577
+ tracked.draft as StoreState<Schema>,
578
+ params.selector
579
+ );
580
+
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
+ }
595
+ return {
596
+ matched: false,
597
+ instanceId: snapshot.instanceId,
598
+ version: snapshot.version,
599
+ readPaths,
600
+ };
601
+ }
602
+
603
+ const selected = unwrapTrackedValue(selectedResult) as NonNullable<R>;
604
+ assertSynchronousClaim(params.claim(tracked.draft, selected));
605
+ const prepared = await finalizeStoreMutation({
606
+ definition: params.definition,
607
+ previousState,
608
+ draft,
609
+ tracked,
610
+ stepKey,
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
+ );
619
+ const selectedSnapshot = cloneStoreState(selected);
620
+ const result: StoreTakeResult<R> = {
621
+ matched: true,
622
+ selected: selectedSnapshot,
623
+ instanceId: snapshot.instanceId,
624
+ version: snapshot.version + 1,
625
+ };
626
+ const committed = await this.commitStoreMutation({
627
+ definition: params.definition,
628
+ id: params.id,
629
+ expected: snapshot,
630
+ nextState: prepared.nextState,
631
+ changedPaths: prepared.changedPaths,
632
+ stepId: params.stepId,
633
+ result,
634
+ });
635
+
636
+ if (committed.status === "committed") return result;
637
+ if (committed.status === "already-applied") {
638
+ return committed.result as StoreTakeResult<R>;
639
+ }
640
+ snapshot =
641
+ committed.status === "conflict"
642
+ ? existing(committed.snapshot as StoreSnapshot<StoreState<Schema>>)
643
+ : await this.readOrInitialize(params.definition, params.id, stepKey);
644
+ // TODO: share the configurable retry cap/backoff policy with updateStore.
645
+ }
646
+ }
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
+
684
+ private getAppliedResult(params: {
685
+ definition: StoreDefinition;
686
+ id: string;
687
+ stepId?: StoreStepId;
688
+ }) {
689
+ return params.stepId
690
+ ? this.getAppliedStoreStep({
691
+ definition: params.definition,
692
+ id: params.id,
693
+ stepId: params.stepId,
694
+ })
695
+ : Promise.resolve(undefined);
696
+ }
697
+ }
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
+
716
+ async function prepareStoreUpdate<Schema extends StandardSchemaV1>(
717
+ definition: StoreDefinition<Schema>,
718
+ snapshot: StoreSnapshot<StoreState<Schema>>,
719
+ updater: (draft: Draft<StoreState<Schema>>) => void | StoreState<Schema>,
720
+ stepKey: string | undefined
721
+ ) {
722
+ const previousState = cloneStoreState(snapshot.state);
723
+ const draft = cloneStoreState(previousState) as Draft<StoreState<Schema>>;
724
+ const tracked = trackStoreUpdater(draft);
725
+ const updated = updater(tracked.draft);
726
+ assertSynchronousUpdater(updated);
727
+ const returned = updated === undefined ? undefined : unwrapTrackedValue(updated);
728
+ return finalizeStoreMutation({
729
+ definition,
730
+ previousState,
731
+ draft,
732
+ tracked,
733
+ replacement: returned,
734
+ stepKey,
735
+ });
736
+ }
737
+
738
+ async function finalizeStoreMutation<Schema extends StandardSchemaV1>(params: {
739
+ definition: StoreDefinition<Schema>;
740
+ previousState: StoreState<Schema>;
741
+ draft: Draft<StoreState<Schema>>;
742
+ tracked: TrackedStoreUpdater<Draft<StoreState<Schema>>>;
743
+ replacement?: unknown;
744
+ stepKey?: string;
745
+ }) {
746
+ const isReplacement =
747
+ params.replacement !== undefined && params.replacement !== params.draft;
748
+ const nextState = await validateStoreState(
749
+ params.definition,
750
+ isReplacement ? params.replacement : params.draft,
751
+ params.stepKey
752
+ );
753
+ const changedPaths =
754
+ !isReplacement && (nextState as unknown) === params.draft
755
+ ? params.tracked.writePaths()
756
+ : diffStorePaths(params.previousState, nextState);
757
+ return { nextState, changedPaths };
758
+ }
759
+
760
+ function sameStoreVersion(
761
+ left: StoreSnapshot<unknown>,
762
+ right: StoreSnapshot<unknown>
763
+ ) {
764
+ return (
765
+ left.instanceId === right.instanceId && left.version === right.version
766
+ );
767
+ }
768
+
769
+ function assertStoreSnapshotInstanceId(snapshot: { instanceId?: string }) {
770
+ if (!snapshot.instanceId) {
771
+ throw new Error(
772
+ "Store snapshot is missing instanceId; read a fresh snapshot"
773
+ );
774
+ }
775
+ }
776
+
777
+ function storeUpdateConflict(
778
+ expected: StoreSnapshot<unknown>,
779
+ actual: StoreSnapshot<unknown>
780
+ ): Extract<StoreUpdateFromResult<never>, { updated: false }> {
781
+ return {
782
+ updated: false,
783
+ expectedInstanceId: expected.instanceId,
784
+ actualInstanceId: actual.instanceId,
785
+ expectedVersion: expected.version,
786
+ actualVersion: actual.version,
787
+ };
788
+ }
789
+
790
+ export function isStoreSelectorMatch<R>(
791
+ result: R
792
+ ): result is Exclude<R, undefined | null | false> {
793
+ return result !== undefined && result !== null && result !== false;
794
+ }
795
+
796
+ export function assertSynchronousClaim(claimResult: unknown): void {
797
+ if (
798
+ claimResult &&
799
+ typeof (claimResult as PromiseLike<unknown>).then === "function"
800
+ ) {
801
+ throw new Error("Store take claims must be synchronous");
802
+ }
803
+ }
804
+
805
+ export function assertSynchronousUpdater(updaterResult: unknown): void {
806
+ if (
807
+ updaterResult &&
808
+ typeof (updaterResult as PromiseLike<unknown>).then === "function"
809
+ ) {
810
+ throw new Error("Store updaters must be synchronous");
811
+ }
812
+ }
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
+ */
821
+ export async function validateStoreState<Schema extends StandardSchemaV1>(
822
+ definition: StoreDefinition<Schema>,
823
+ state: unknown,
824
+ stepKey = ""
825
+ ): Promise<StoreState<Schema>> {
826
+ const result = await definition.schema["~standard"].validate(state);
827
+
828
+ if ("issues" in result && result.issues) {
829
+ const messages = result.issues.map((issue) => issue.message).join("; ");
830
+ throw new StoreValidationError(
831
+ `Invalid store state for "${definition.name}": ${messages}`
832
+ );
833
+ }
834
+
835
+ assertDurableStoreValue(result.value, stepKey, `Store state for "${definition.name}"`);
836
+ return result.value as StoreState<Schema>;
837
+ }
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
+ */
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
+ }
878
+ if (typeof structuredClone === "function") {
879
+ try {
880
+ return structuredClone(state);
881
+ } catch {
882
+ // Fallback if structuredClone fails (e.g. for Proxy objects in JavaScriptCore)
883
+ }
884
+ }
885
+
886
+ return JSON.parse(JSON.stringify(state));
887
+ }
888
+
889
+ /**
890
+ * Diffs two store states and returns the set of changed paths.
891
+ *
892
+ * This is O(total state size). `trackStoreUpdater` records ordinary mutation
893
+ * paths in O(changes); the diff handles cases a recording proxy cannot observe:
894
+ * - the updater RETURNED a replacement state instead of mutating the draft
895
+ * - schema validation returned a transformed copy of the draft
896
+ *
897
+ * NOTE: array splices (insert/remove in the middle) report all shifted
898
+ * indices as changed, which can over-wake waiters. This is safe – wakes are
899
+ * spurious at worst, since replay re-evaluates the selector.
900
+ */
901
+ export function diffStorePaths(previous: unknown, next: unknown): StorePath[] {
902
+ const paths: StorePath[] = [];
903
+
904
+ function visit(left: unknown, right: unknown, path: StorePath) {
905
+ if (Object.is(left, right)) return;
906
+
907
+ if (!isDiffableObject(left) || !isDiffableObject(right)) {
908
+ paths.push(path);
909
+ return;
910
+ }
911
+
912
+ const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
913
+
914
+ for (const key of keys) {
915
+ visit(
916
+ (left as Record<string, unknown>)[key],
917
+ (right as Record<string, unknown>)[key],
918
+ [...path, arrayKeyToPathSegment(key)]
919
+ );
920
+ }
921
+ }
922
+
923
+ visit(previous, next, []);
924
+ return collapseStorePaths(paths);
925
+ }
926
+
927
+ const TRACKING_TARGET = Symbol("yieldstar.trackingTarget");
928
+
929
+ /**
930
+ * Unwraps a tracking proxy (`trackStoreSelector` or `trackStoreUpdater`)
931
+ * one level, back to the object it wraps. When a selector runs over a
932
+ * write-recording draft, the selected value unwraps to the RECORDING proxy,
933
+ * so claim mutations applied through it land on the draft AND are recorded
934
+ * as write paths. Derived values (fresh arrays/objects built inside the
935
+ * selector) are returned as-is.
936
+ */
937
+ export function unwrapTrackedValue<V>(value: V): V {
938
+ if (isDiffableObject(value)) {
939
+ const target = (value as Record<PropertyKey, unknown>)[
940
+ TRACKING_TARGET as unknown as string
941
+ ];
942
+ if (target !== undefined) return target as V;
943
+ }
944
+ return value;
945
+ }
946
+
947
+ export function trackStoreSelector<T, R>(
948
+ state: T,
949
+ selector: StoreSelector<T, R>
950
+ ): { result: R; readPaths: StorePath[] } {
951
+ const paths: StorePath[] = [];
952
+ const proxies = new WeakMap<object, unknown>();
953
+
954
+ function track(value: unknown, path: StorePath): unknown {
955
+ if (!isDiffableObject(value)) return value;
956
+
957
+ const cached = proxies.get(value);
958
+ if (cached) return cached;
959
+
960
+ const proxy = new Proxy(value, {
961
+ get(target, property, receiver) {
962
+ if (typeof property === "symbol") {
963
+ if (property === TRACKING_TARGET) return target;
964
+ return Reflect.get(target, property, receiver);
965
+ }
966
+
967
+ const nextPath = [...path, arrayKeyToPathSegment(property)];
968
+ if (nextPath.length > 0) {
969
+ paths.push(nextPath);
970
+ }
971
+
972
+ const child = Reflect.get(target, property, receiver);
973
+ return track(child, nextPath);
974
+ },
975
+ set() {
976
+ throw new Error("Store selectors must not mutate state");
977
+ },
978
+ deleteProperty() {
979
+ throw new Error("Store selectors must not mutate state");
980
+ },
981
+ });
982
+
983
+ proxies.set(value, proxy);
984
+ return proxy;
985
+ }
986
+
987
+ const result = selector(track(state, []) as Readonly<T>);
988
+
989
+ return {
990
+ result,
991
+ readPaths: collapseStorePaths(paths),
992
+ };
993
+ }
994
+
995
+ export type TrackedStoreUpdater<T> = {
996
+ /** Write-recording proxy over the raw draft. Hand this to the updater. */
997
+ draft: T;
998
+ /** The collapsed set of paths written so far (O(changes), not O(state)). */
999
+ writePaths(): StorePath[];
1000
+ };
1001
+
1002
+ /**
1003
+ * Wraps a draft in a write-recording Proxy so changed paths can be derived
1004
+ * from the updater's own mutations in O(changes) instead of deep-diffing the
1005
+ * whole previous/next state (O(state size) – see `diffStorePaths`).
1006
+ *
1007
+ * - Every `set` and `deleteProperty` through the proxy records its full
1008
+ * path. Mutating array methods are trapped naturally via the index/length
1009
+ * sets they perform: a push onto `messages` records ["messages", 3] (and
1010
+ * ["messages", "length"]), preserving prefix-match wake semantics – a
1011
+ * waiter on ["messages"] still wakes via prefix intersection.
1012
+ * - Reads return proxied children so nested mutations are captured, but the
1013
+ * mutation itself lands on the underlying raw draft (Reflect.set on the
1014
+ * target), because the mutated draft becomes the next state.
1015
+ * - Values assigned through the proxy are unwrapped (top-level) so the raw
1016
+ * draft never stores a proxy wrapper for the common `draft.a = draft.b`
1017
+ * case. Deeper wrappers (a proxy nested inside a fresh object) are
1018
+ * laundered at commit time: `cloneStoreState` always produces fresh plain
1019
+ * objects (structuredClone rejects proxies and falls back to JSON), and
1020
+ * the SQLite client serializes state through JSON.stringify.
1021
+ * - Ambiguous traps (`defineProperty`) record conservatively. Changed paths
1022
+ * only affect wake precision, never state correctness: an extra path is a
1023
+ * spurious wake (safe – replay re-evaluates the selector), while a missed
1024
+ * path would lose a wakeup. When in doubt, over-report.
1025
+ *
1026
+ * IMPORTANT: this only observes MUTATIONS of the draft. If the updater
1027
+ * RETURNS a replacement state instead of mutating (the
1028
+ * `updated === undefined ? draft : updated` contract), no writes go through
1029
+ * the proxy – callers must detect that case and fall back to
1030
+ * `diffStorePaths(previousState, replacement)`.
1031
+ */
1032
+ export function trackStoreUpdater<T>(target: T): TrackedStoreUpdater<T> {
1033
+ const paths: StorePath[] = [];
1034
+ const proxies = new WeakMap<object, unknown>();
1035
+
1036
+ function record(path: StorePath, property: PropertyKey) {
1037
+ if (typeof property === "symbol") {
1038
+ // Symbol-keyed writes are invisible to JSON state and read paths;
1039
+ // record the parent path so any waiter on it still wakes.
1040
+ paths.push(path);
1041
+ return;
1042
+ }
1043
+ paths.push([...path, arrayKeyToPathSegment(String(property))]);
1044
+ }
1045
+
1046
+ function track(value: unknown, path: StorePath): unknown {
1047
+ if (!isDiffableObject(value)) return value;
1048
+
1049
+ const cached = proxies.get(value);
1050
+ if (cached) return cached;
1051
+
1052
+ const proxy = new Proxy(value, {
1053
+ get(target, property, receiver) {
1054
+ if (typeof property === "symbol") {
1055
+ if (property === TRACKING_TARGET) return target;
1056
+ return Reflect.get(target, property, receiver);
1057
+ }
1058
+
1059
+ // Read without the proxy receiver so any getters run against the
1060
+ // raw draft, then wrap the child so nested mutations are captured.
1061
+ const child = Reflect.get(target, property);
1062
+ return track(child, [...path, arrayKeyToPathSegment(property)]);
1063
+ },
1064
+ set(target, property, value) {
1065
+ record(path, property);
1066
+ // Unwrap so the raw draft never stores a proxy wrapper, and set on
1067
+ // the raw target (no proxy receiver) so the draft receives the
1068
+ // mutation directly.
1069
+ return Reflect.set(target, property, unwrapTrackedValue(value));
1070
+ },
1071
+ deleteProperty(target, property) {
1072
+ record(path, property);
1073
+ return Reflect.deleteProperty(target, property);
1074
+ },
1075
+ defineProperty(target, property, descriptor) {
1076
+ record(path, property);
1077
+ if ("value" in descriptor) {
1078
+ descriptor = { ...descriptor, value: unwrapTrackedValue(descriptor.value) };
1079
+ }
1080
+ return Reflect.defineProperty(target, property, descriptor);
1081
+ },
1082
+ });
1083
+
1084
+ proxies.set(value, proxy);
1085
+ return proxy;
1086
+ }
1087
+
1088
+ return {
1089
+ draft: track(target, []) as T,
1090
+ writePaths: () => collapseStorePaths(paths),
1091
+ };
1092
+ }
1093
+
1094
+ export function storePathsIntersect(
1095
+ readPaths: StorePath[],
1096
+ writePaths: StorePath[]
1097
+ ): boolean {
1098
+ return readPaths.some((readPath) =>
1099
+ writePaths.some(
1100
+ (writePath) =>
1101
+ isPathPrefix(readPath, writePath) || isPathPrefix(writePath, readPath)
1102
+ )
1103
+ );
1104
+ }
1105
+
1106
+ function collapseStorePaths(paths: StorePath[]): StorePath[] {
1107
+ const unique = new Map(paths.map((path) => [path.join("\u0000"), path]));
1108
+ return [...unique.values()];
1109
+ }
1110
+
1111
+ function isPathPrefix(prefix: StorePath, path: StorePath): boolean {
1112
+ if (prefix.length > path.length) return false;
1113
+ return prefix.every((segment, index) => segment === path[index]);
1114
+ }
1115
+
1116
+ function isDiffableObject(value: unknown): value is Record<string, unknown> {
1117
+ return typeof value === "object" && value !== null;
1118
+ }
1119
+
1120
+ function arrayKeyToPathSegment(key: string): string | number {
1121
+ if (/^(0|[1-9]\d*)$/.test(key)) return Number(key);
1122
+ return key;
1123
+ }