@yieldstar/sqlite-runtime 0.5.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 (56) hide show
  1. package/dist/bun.d.ts +13 -0
  2. package/dist/bun.d.ts.map +1 -0
  3. package/dist/bun.js +43 -0
  4. package/dist/dao/step-response-dao.d.ts +19 -0
  5. package/dist/dao/step-response-dao.d.ts.map +1 -0
  6. package/dist/dao/task-queue-dao.d.ts +21 -0
  7. package/dist/dao/task-queue-dao.d.ts.map +1 -0
  8. package/dist/dao/timers-dao.d.ts +22 -0
  9. package/dist/dao/timers-dao.d.ts.map +1 -0
  10. package/dist/index-4vq70w0r.js +796 -0
  11. package/dist/index-h7bm9xg4.js +26 -0
  12. package/dist/index-s8wqgfwt.js +19 -0
  13. package/dist/index-x891hxwg.js +796 -0
  14. package/dist/index.d.ts +9 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +16 -0
  17. package/dist/node-driver.d.ts +10 -0
  18. package/dist/node-driver.d.ts.map +1 -0
  19. package/dist/node.d.ts +13 -0
  20. package/dist/node.d.ts.map +1 -0
  21. package/dist/node.js +49 -0
  22. package/dist/sqlite-db.d.ts +29 -0
  23. package/dist/sqlite-db.d.ts.map +1 -0
  24. package/dist/sqlite-driver.d.ts +21 -0
  25. package/dist/sqlite-driver.d.ts.map +1 -0
  26. package/dist/sqlite-event-loop.d.ts +18 -0
  27. package/dist/sqlite-event-loop.d.ts.map +1 -0
  28. package/dist/sqlite-heap.d.ts +26 -0
  29. package/dist/sqlite-heap.d.ts.map +1 -0
  30. package/dist/sqlite-scheduler.d.ts +13 -0
  31. package/dist/sqlite-scheduler.d.ts.map +1 -0
  32. package/dist/sqlite-store.d.ts +54 -0
  33. package/dist/sqlite-store.d.ts.map +1 -0
  34. package/dist/sqlite-store.test.d.ts +2 -0
  35. package/dist/sqlite-store.test.d.ts.map +1 -0
  36. package/dist/sqlite-task-queue.d.ts +26 -0
  37. package/dist/sqlite-task-queue.d.ts.map +1 -0
  38. package/dist/sqlite-timers.d.ts +18 -0
  39. package/dist/sqlite-timers.d.ts.map +1 -0
  40. package/package.json +43 -0
  41. package/src/bun.ts +32 -0
  42. package/src/dao/step-response-dao.ts +80 -0
  43. package/src/dao/task-queue-dao.ts +79 -0
  44. package/src/dao/timers-dao.ts +81 -0
  45. package/src/index.ts +14 -0
  46. package/src/node.ts +47 -0
  47. package/src/sqlite-db.ts +70 -0
  48. package/src/sqlite-driver.ts +30 -0
  49. package/src/sqlite-event-loop.ts +41 -0
  50. package/src/sqlite-heap.ts +54 -0
  51. package/src/sqlite-scheduler.ts +24 -0
  52. package/src/sqlite-store.ts +755 -0
  53. package/src/sqlite-task-queue.ts +64 -0
  54. package/src/sqlite-timers.ts +40 -0
  55. package/tsconfig.json +9 -0
  56. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,755 @@
1
+ import type { SchedulerClient, WorkflowEvent } from "@yieldstar/core";
2
+ import { v7 as uuidv7 } from "uuid";
3
+ import type { SqliteDriver } from "./sqlite-driver";
4
+ import {
5
+ cloneStoreState,
6
+ CasStoreClient,
7
+ storePathsIntersect,
8
+ validateStoreState,
9
+ type StandardSchemaV1,
10
+ type StoreDefinition,
11
+ type StoreDeleteFromResult,
12
+ type StorePath,
13
+ type StoreSnapshot,
14
+ type StoreState,
15
+ type StoreStepId,
16
+ type StoreWaiter,
17
+ type StoreMutation,
18
+ type StoreMutationCommitResult,
19
+ } from "@yieldstar/core";
20
+
21
+ class StoreRow {
22
+ instance_id!: string;
23
+ state!: string;
24
+ version!: number;
25
+ }
26
+
27
+ class AppliedStepRow {
28
+ result!: string;
29
+ }
30
+
31
+ class StoreIdRow {
32
+ store_id!: string;
33
+ }
34
+
35
+ class WaiterRow {
36
+ workflow_id!: string;
37
+ execution_id!: string;
38
+ step_key!: string;
39
+ event!: string;
40
+ store_name!: string;
41
+ store_id!: string;
42
+ since_version!: number;
43
+ read_paths!: string;
44
+ }
45
+
46
+ class WakeOutboxRow {
47
+ store_name!: string;
48
+ store_id!: string;
49
+ execution_id!: string;
50
+ step_key!: string;
51
+ event!: string | null;
52
+ since_version!: number | null;
53
+ }
54
+
55
+ export class SqliteStoreClient extends CasStoreClient {
56
+ private db: SqliteDriver;
57
+ private schedulerClient: SchedulerClient;
58
+ // Serializes write transactions on this single SQLite connection.
59
+ private writeLock: Promise<unknown> = Promise.resolve();
60
+
61
+ constructor(params: { db: SqliteDriver; schedulerClient: SchedulerClient }) {
62
+ super();
63
+ this.db = params.db;
64
+ this.schedulerClient = params.schedulerClient;
65
+ this.setupDb();
66
+ // A previous process may have committed a store mutation and its wake
67
+ // intents before it could enqueue them. Delivery is idempotent, so every
68
+ // client startup can safely resume the durable outbox.
69
+ void this.enqueueWrite(() => this.drainWakeOutboxBestEffort());
70
+ }
71
+
72
+ private enqueueWrite<R>(fn: () => Promise<R>): Promise<R> {
73
+ const result = this.writeLock.then(fn, fn);
74
+ this.writeLock = result.catch(() => {});
75
+ return result;
76
+ }
77
+
78
+ async getOrCreateStore<Schema extends StandardSchemaV1>(params: {
79
+ definition: StoreDefinition<Schema>;
80
+ id: string;
81
+ initial?:
82
+ | StoreState<Schema>
83
+ | (() => StoreState<Schema> | Promise<StoreState<Schema>>);
84
+ }): Promise<StoreSnapshot<StoreState<Schema>>> {
85
+ const existing = this.getStoreRow(params.definition.name, params.id);
86
+
87
+ if (existing) {
88
+ return {
89
+ state: JSON.parse(existing.state),
90
+ instanceId: existing.instance_id,
91
+ version: existing.version,
92
+ };
93
+ }
94
+
95
+ if (!params.initial) {
96
+ throw new Error(
97
+ `Store "${params.definition.name}:${params.id}" does not exist`
98
+ );
99
+ }
100
+
101
+ const initialValue = params.initial;
102
+ const initial =
103
+ typeof initialValue === "function"
104
+ ? await (
105
+ initialValue as () =>
106
+ | StoreState<Schema>
107
+ | Promise<StoreState<Schema>>
108
+ )()
109
+ : initialValue;
110
+ const state = await validateStoreState(params.definition, initial);
111
+
112
+ return this.enqueueWrite(async () => {
113
+ this.db.run("BEGIN IMMEDIATE");
114
+ try {
115
+ const existingTx = this.getStoreRow(params.definition.name, params.id);
116
+ if (existingTx) {
117
+ this.db.run("COMMIT");
118
+ return {
119
+ state: JSON.parse(existingTx.state),
120
+ instanceId: existingTx.instance_id,
121
+ version: existingTx.version,
122
+ };
123
+ }
124
+
125
+ const instanceId = uuidv7();
126
+ this.db
127
+ .query(
128
+ `INSERT INTO stores (instance_id, store_name, store_id, version, state)
129
+ VALUES ($instanceId, $storeName, $storeId, 0, $state)`
130
+ )
131
+ .run({
132
+ $instanceId: instanceId,
133
+ $storeName: params.definition.name,
134
+ $storeId: params.id,
135
+ $state: JSON.stringify(state),
136
+ });
137
+ this.db.run("COMMIT");
138
+ return {
139
+ state: cloneStoreState(state),
140
+ instanceId,
141
+ version: 0,
142
+ };
143
+ } catch (err) {
144
+ this.db.run("ROLLBACK");
145
+ throw err;
146
+ }
147
+
148
+ throw new Error("Store creation transaction returned unexpectedly");
149
+ });
150
+ }
151
+
152
+ async getStore<Schema extends StandardSchemaV1>(params: {
153
+ definition: StoreDefinition<Schema>;
154
+ id: string;
155
+ }): Promise<StoreSnapshot<StoreState<Schema>>> {
156
+ const row = this.getStoreRow(params.definition.name, params.id);
157
+
158
+ if (!row) {
159
+ throw new Error(
160
+ `Store "${params.definition.name}:${params.id}" does not exist`
161
+ );
162
+ }
163
+
164
+ return {
165
+ state: JSON.parse(row.state),
166
+ instanceId: row.instance_id,
167
+ version: row.version,
168
+ };
169
+ }
170
+
171
+ protected async getAppliedStoreStep(params: {
172
+ definition: StoreDefinition;
173
+ id: string;
174
+ stepId: StoreStepId;
175
+ }): Promise<{ result: unknown } | undefined> {
176
+ const row = this.getAppliedStepRow({
177
+ storeName: params.definition.name,
178
+ storeId: params.id,
179
+ stepId: params.stepId,
180
+ });
181
+ return row ? { result: JSON.parse(row.result) } : undefined;
182
+ }
183
+
184
+ protected commitStoreMutation(
185
+ mutation: StoreMutation
186
+ ): Promise<StoreMutationCommitResult> {
187
+ return this.enqueueWrite(async () => {
188
+ this.db.run("BEGIN IMMEDIATE");
189
+ try {
190
+ if (mutation.stepId) {
191
+ const applied = this.getAppliedStepRow({
192
+ storeName: mutation.definition.name,
193
+ storeId: mutation.id,
194
+ stepId: mutation.stepId,
195
+ });
196
+ if (applied) {
197
+ this.db.run("COMMIT");
198
+ return {
199
+ status: "already-applied",
200
+ result: JSON.parse(applied.result),
201
+ };
202
+ }
203
+ }
204
+
205
+ const row = this.getStoreRow(mutation.definition.name, mutation.id);
206
+ if (!row) {
207
+ throw new Error(
208
+ `Store "${mutation.definition.name}:${mutation.id}" does not exist`
209
+ );
210
+ }
211
+ if (
212
+ row.instance_id !== mutation.expected.instanceId ||
213
+ row.version !== mutation.expected.version
214
+ ) {
215
+ this.db.run("COMMIT");
216
+ return {
217
+ status: "conflict",
218
+ snapshot: {
219
+ state: JSON.parse(row.state),
220
+ instanceId: row.instance_id,
221
+ version: row.version,
222
+ },
223
+ };
224
+ }
225
+
226
+ const version = mutation.expected.version + 1;
227
+ const changed = this.db
228
+ .query(
229
+ `UPDATE stores
230
+ SET version = $version, state = $state
231
+ WHERE instance_id = $instanceId AND version = $expectedVersion`
232
+ )
233
+ .run({
234
+ $instanceId: mutation.expected.instanceId,
235
+ $expectedVersion: mutation.expected.version,
236
+ $version: version,
237
+ $state: JSON.stringify(mutation.nextState),
238
+ });
239
+ if (Number(changed.changes) !== 1) {
240
+ throw new Error("Store changed during its mutation transaction");
241
+ }
242
+
243
+ if (mutation.stepId) {
244
+ this.insertAppliedStepRow({
245
+ storeName: mutation.definition.name,
246
+ storeId: mutation.id,
247
+ stepId: mutation.stepId,
248
+ result: JSON.stringify(mutation.result),
249
+ });
250
+ }
251
+
252
+ const waiters = this.selectMatchingWaiters({
253
+ storeName: mutation.definition.name,
254
+ storeId: mutation.id,
255
+ version,
256
+ changedPaths: mutation.changedPaths,
257
+ });
258
+ for (const waiter of waiters) {
259
+ this.insertWakeOutboxRow({
260
+ storeName: mutation.definition.name,
261
+ storeId: mutation.id,
262
+ executionId: waiter.executionId,
263
+ stepKey: waiter.stepKey,
264
+ });
265
+ }
266
+
267
+ this.db.run("COMMIT");
268
+ } catch (error) {
269
+ this.db.run("ROLLBACK");
270
+ throw error;
271
+ }
272
+
273
+ // Wake delivery is deliberately outside the mutation result. Once the
274
+ // outbox intent commits, scheduler failure must not poison later store
275
+ // operations; another operation or client startup will retry it.
276
+ await this.drainWakeOutboxBestEffort();
277
+ return { status: "committed" };
278
+ });
279
+ }
280
+
281
+ async listStores<Schema extends StandardSchemaV1>(
282
+ definition: StoreDefinition<Schema>
283
+ ): Promise<string[]> {
284
+ const rows = this.db
285
+ .query<StoreIdRow>(
286
+ `SELECT store_id FROM stores
287
+ WHERE store_name = $storeName
288
+ ORDER BY store_id ASC`
289
+ )
290
+ .all({ $storeName: definition.name });
291
+ return rows.map((row) => row.store_id);
292
+ }
293
+
294
+ async deleteStore<Schema extends StandardSchemaV1>(params: {
295
+ definition: StoreDefinition<Schema>;
296
+ id: string;
297
+ }): Promise<void> {
298
+ return this.enqueueWrite(async () => {
299
+ const bindings = {
300
+ $storeName: params.definition.name,
301
+ $storeId: params.id,
302
+ };
303
+ this.db.run("BEGIN IMMEDIATE");
304
+ try {
305
+ this.db
306
+ .query(
307
+ `DELETE FROM stores WHERE store_name = $storeName AND store_id = $storeId`
308
+ )
309
+ .run(bindings);
310
+ this.db
311
+ .query(
312
+ `DELETE FROM store_waiters WHERE store_name = $storeName AND store_id = $storeId`
313
+ )
314
+ .run(bindings);
315
+ this.db
316
+ .query(
317
+ `DELETE FROM store_wake_outbox WHERE store_name = $storeName AND store_id = $storeId`
318
+ )
319
+ .run(bindings);
320
+ this.db.run("COMMIT");
321
+ } catch (err) {
322
+ this.db.run("ROLLBACK");
323
+ throw err;
324
+ }
325
+ });
326
+ }
327
+
328
+ async deleteStoreFrom(params: {
329
+ definition: StoreDefinition;
330
+ id: string;
331
+ snapshot: StoreSnapshot<unknown>;
332
+ stepId?: StoreStepId;
333
+ }): Promise<StoreDeleteFromResult> {
334
+ return this.enqueueWrite(async () => {
335
+ const bindings = {
336
+ $storeName: params.definition.name,
337
+ $storeId: params.id,
338
+ };
339
+ this.db.run("BEGIN IMMEDIATE");
340
+ try {
341
+ if (params.stepId) {
342
+ const applied = this.getAppliedStepRow({
343
+ storeName: params.definition.name,
344
+ storeId: params.id,
345
+ stepId: params.stepId,
346
+ });
347
+ if (applied) {
348
+ this.db.run("COMMIT");
349
+ return JSON.parse(applied.result) as StoreDeleteFromResult;
350
+ }
351
+ }
352
+
353
+ this.assertSnapshotHasInstanceId(params.snapshot);
354
+
355
+ const row = this.getStoreRow(params.definition.name, params.id);
356
+ if (!row) {
357
+ this.db.run("COMMIT");
358
+ return {
359
+ deleted: false,
360
+ reason: "not-found",
361
+ expectedInstanceId: params.snapshot.instanceId,
362
+ expectedVersion: params.snapshot.version,
363
+ };
364
+ }
365
+ if (
366
+ row.instance_id !== params.snapshot.instanceId ||
367
+ row.version !== params.snapshot.version
368
+ ) {
369
+ this.db.run("COMMIT");
370
+ return {
371
+ deleted: false,
372
+ reason: "conflict",
373
+ expectedInstanceId: params.snapshot.instanceId,
374
+ actualInstanceId: row.instance_id,
375
+ expectedVersion: params.snapshot.version,
376
+ actualVersion: row.version,
377
+ };
378
+ }
379
+
380
+ this.db
381
+ .query(
382
+ `DELETE FROM store_waiters WHERE store_name = $storeName AND store_id = $storeId`
383
+ )
384
+ .run(bindings);
385
+ this.db
386
+ .query(
387
+ `DELETE FROM store_wake_outbox WHERE store_name = $storeName AND store_id = $storeId`
388
+ )
389
+ .run(bindings);
390
+ this.db
391
+ .query(`DELETE FROM stores WHERE instance_id = $instanceId`)
392
+ .run({ $instanceId: row.instance_id });
393
+ const result = { deleted: true as const };
394
+ if (params.stepId) {
395
+ this.insertAppliedStepRow({
396
+ storeName: params.definition.name,
397
+ storeId: params.id,
398
+ stepId: params.stepId,
399
+ result: JSON.stringify(result),
400
+ });
401
+ }
402
+ this.db.run("COMMIT");
403
+ return result;
404
+ } catch (err) {
405
+ this.db.run("ROLLBACK");
406
+ throw err;
407
+ }
408
+ });
409
+ }
410
+
411
+ async registerWaiter(waiter: StoreWaiter): Promise<void> {
412
+ return this.enqueueWrite(async () => {
413
+ let versionAdvanced = false;
414
+
415
+ this.db.run("BEGIN IMMEDIATE");
416
+ try {
417
+ this.db
418
+ .query(
419
+ `INSERT INTO store_waiters
420
+ (workflow_id, execution_id, step_key, event, store_name, store_id, since_version, read_paths)
421
+ VALUES
422
+ ($workflowId, $executionId, $stepKey, $event, $storeName, $storeId, $sinceVersion, $readPaths)
423
+ ON CONFLICT(store_name, store_id, execution_id, step_key)
424
+ DO UPDATE SET
425
+ event = excluded.event,
426
+ since_version = excluded.since_version,
427
+ read_paths = excluded.read_paths`
428
+ )
429
+ .run({
430
+ $workflowId: waiter.workflowId,
431
+ $executionId: waiter.executionId,
432
+ $stepKey: waiter.stepKey,
433
+ $event: JSON.stringify(serializeEvent(waiter.event)),
434
+ $storeName: waiter.storeName,
435
+ $storeId: waiter.storeId,
436
+ $sinceVersion: waiter.sinceVersion,
437
+ $readPaths: JSON.stringify(waiter.readPaths),
438
+ });
439
+
440
+ // Lost-wakeup guard: if an update committed between the caller's
441
+ // getStore and this registration, the version has already advanced
442
+ // past sinceVersion and no future update is guaranteed. Trigger an
443
+ // immediate wake – the replay re-evaluates the selector, so a
444
+ // spurious wake is safe.
445
+ const row = this.getStoreRow(waiter.storeName, waiter.storeId);
446
+ if (
447
+ !row ||
448
+ row.instance_id !== waiter.instanceId ||
449
+ row.version > waiter.sinceVersion
450
+ ) {
451
+ versionAdvanced = true;
452
+ this.insertWakeOutboxRow({
453
+ storeName: waiter.storeName,
454
+ storeId: waiter.storeId,
455
+ executionId: waiter.executionId,
456
+ stepKey: waiter.stepKey,
457
+ });
458
+ }
459
+
460
+ this.db.run("COMMIT");
461
+ } catch (err) {
462
+ this.db.run("ROLLBACK");
463
+ throw err;
464
+ }
465
+
466
+ if (versionAdvanced) {
467
+ await this.drainWakeOutboxBestEffort();
468
+ }
469
+ });
470
+ }
471
+
472
+ private setupDb() {
473
+ this.createStoresTable();
474
+
475
+ this.db.run(`
476
+ CREATE TABLE IF NOT EXISTS store_waiters (
477
+ workflow_id TEXT NOT NULL,
478
+ execution_id TEXT NOT NULL,
479
+ step_key TEXT NOT NULL,
480
+ event TEXT NOT NULL,
481
+ store_name TEXT NOT NULL,
482
+ store_id TEXT NOT NULL,
483
+ since_version INTEGER NOT NULL,
484
+ read_paths TEXT NOT NULL,
485
+ PRIMARY KEY (store_name, store_id, execution_id, step_key)
486
+ );
487
+
488
+ CREATE TABLE IF NOT EXISTS store_applied_steps (
489
+ store_name TEXT NOT NULL,
490
+ store_id TEXT NOT NULL,
491
+ execution_id TEXT NOT NULL,
492
+ step_key TEXT NOT NULL,
493
+ result TEXT NOT NULL,
494
+ PRIMARY KEY (store_name, store_id, execution_id, step_key)
495
+ );
496
+
497
+ CREATE TABLE IF NOT EXISTS store_wake_outbox (
498
+ store_name TEXT NOT NULL,
499
+ store_id TEXT NOT NULL,
500
+ execution_id TEXT NOT NULL,
501
+ step_key TEXT NOT NULL,
502
+ PRIMARY KEY (store_name, store_id, execution_id, step_key)
503
+ );
504
+ `);
505
+ }
506
+
507
+ private createStoresTable() {
508
+ this.db.run(`
509
+ CREATE TABLE IF NOT EXISTS stores (
510
+ instance_id TEXT PRIMARY KEY,
511
+ store_name TEXT NOT NULL,
512
+ store_id TEXT NOT NULL,
513
+ version INTEGER NOT NULL,
514
+ state TEXT NOT NULL,
515
+ UNIQUE (store_name, store_id)
516
+ )
517
+ `);
518
+ }
519
+
520
+ private getAppliedStepRow(params: {
521
+ storeName: string;
522
+ storeId: string;
523
+ stepId: StoreStepId;
524
+ }) {
525
+ return this.db
526
+ .query<AppliedStepRow>(
527
+ `SELECT result FROM store_applied_steps
528
+ WHERE store_name = $storeName
529
+ AND store_id = $storeId
530
+ AND execution_id = $executionId
531
+ AND step_key = $stepKey`
532
+ )
533
+ .get({
534
+ $storeName: params.storeName,
535
+ $storeId: params.storeId,
536
+ $executionId: params.stepId.executionId,
537
+ $stepKey: params.stepId.stepKey,
538
+ });
539
+ }
540
+
541
+ private insertAppliedStepRow(params: {
542
+ storeName: string;
543
+ storeId: string;
544
+ stepId: StoreStepId;
545
+ result: string;
546
+ }) {
547
+ this.db
548
+ .query(
549
+ `INSERT INTO store_applied_steps
550
+ (store_name, store_id, execution_id, step_key, result)
551
+ VALUES
552
+ ($storeName, $storeId, $executionId, $stepKey, $result)`
553
+ )
554
+ .run({
555
+ $storeName: params.storeName,
556
+ $storeId: params.storeId,
557
+ $executionId: params.stepId.executionId,
558
+ $stepKey: params.stepId.stepKey,
559
+ $result: params.result,
560
+ });
561
+ }
562
+
563
+ private getStoreRow(storeName: string, storeId: string) {
564
+ return this.db
565
+ .query<StoreRow>(
566
+ `SELECT instance_id, state, version FROM stores
567
+ WHERE store_name = $storeName AND store_id = $storeId`
568
+ )
569
+ .get({
570
+ $storeName: storeName,
571
+ $storeId: storeId,
572
+ });
573
+ }
574
+
575
+ private selectMatchingWaiters(params: {
576
+ storeName: string;
577
+ storeId: string;
578
+ version: number;
579
+ changedPaths: StorePath[];
580
+ }): MatchedWaiter[] {
581
+ const waiters: MatchedWaiter[] = [];
582
+ const rows = this.db
583
+ .query<WaiterRow>(
584
+ `SELECT * FROM store_waiters
585
+ WHERE store_name = $storeName AND store_id = $storeId`
586
+ )
587
+ .all({
588
+ $storeName: params.storeName,
589
+ $storeId: params.storeId,
590
+ });
591
+
592
+ for (const row of rows) {
593
+ if (row.since_version >= params.version) continue;
594
+
595
+ const readPaths = JSON.parse(row.read_paths) as StorePath[];
596
+ if (!storePathsIntersect(readPaths, params.changedPaths)) continue;
597
+
598
+ waiters.push({
599
+ executionId: row.execution_id,
600
+ stepKey: row.step_key,
601
+ event: deserializeEvent(JSON.parse(row.event)),
602
+ });
603
+ }
604
+
605
+ return waiters;
606
+ }
607
+
608
+ private deleteWaiterRow(params: {
609
+ storeName: string;
610
+ storeId: string;
611
+ executionId: string;
612
+ stepKey: string;
613
+ sinceVersion?: number;
614
+ }) {
615
+ this.db
616
+ .query(
617
+ `DELETE FROM store_waiters
618
+ WHERE store_name = $storeName
619
+ AND store_id = $storeId
620
+ AND execution_id = $executionId
621
+ AND step_key = $stepKey
622
+ AND ($sinceVersion IS NULL OR since_version = $sinceVersion)`
623
+ )
624
+ .run({
625
+ $storeName: params.storeName,
626
+ $storeId: params.storeId,
627
+ $executionId: params.executionId,
628
+ $stepKey: params.stepKey,
629
+ $sinceVersion: params.sinceVersion ?? null,
630
+ });
631
+ }
632
+
633
+ private insertWakeOutboxRow(params: {
634
+ storeName: string;
635
+ storeId: string;
636
+ executionId: string;
637
+ stepKey: string;
638
+ }) {
639
+ this.db
640
+ .query(
641
+ `INSERT OR IGNORE INTO store_wake_outbox
642
+ (store_name, store_id, execution_id, step_key)
643
+ VALUES ($storeName, $storeId, $executionId, $stepKey)`
644
+ )
645
+ .run({
646
+ $storeName: params.storeName,
647
+ $storeId: params.storeId,
648
+ $executionId: params.executionId,
649
+ $stepKey: params.stepKey,
650
+ });
651
+ }
652
+
653
+ private async drainWakeOutbox(): Promise<void> {
654
+ const rows = this.db
655
+ .query<WakeOutboxRow>(
656
+ `SELECT
657
+ o.store_name,
658
+ o.store_id,
659
+ o.execution_id,
660
+ o.step_key,
661
+ w.event,
662
+ w.since_version
663
+ FROM store_wake_outbox o
664
+ LEFT JOIN store_waiters w
665
+ ON w.store_name = o.store_name
666
+ AND w.store_id = o.store_id
667
+ AND w.execution_id = o.execution_id
668
+ AND w.step_key = o.step_key
669
+ ORDER BY o.store_name, o.store_id, o.execution_id, o.step_key`
670
+ )
671
+ .all();
672
+
673
+ for (const row of rows) {
674
+ try {
675
+ if (row.event !== null) {
676
+ await this.schedulerClient.requestWakeUp(
677
+ deserializeEvent(JSON.parse(row.event))
678
+ );
679
+ this.deleteWaiterRow({
680
+ storeName: row.store_name,
681
+ storeId: row.store_id,
682
+ executionId: row.execution_id,
683
+ stepKey: row.step_key,
684
+ sinceVersion: row.since_version ?? undefined,
685
+ });
686
+ }
687
+
688
+ this.db
689
+ .query(
690
+ `DELETE FROM store_wake_outbox
691
+ WHERE store_name = $storeName
692
+ AND store_id = $storeId
693
+ AND execution_id = $executionId
694
+ AND step_key = $stepKey`
695
+ )
696
+ .run({
697
+ $storeName: row.store_name,
698
+ $storeId: row.store_id,
699
+ $executionId: row.execution_id,
700
+ $stepKey: row.step_key,
701
+ });
702
+ } catch (error) {
703
+ // Leave only this row pending. A malformed event or event-specific
704
+ // scheduler failure must not prevent later wake intents from draining.
705
+ console.error("Failed to deliver pending store wake", error);
706
+ }
707
+ }
708
+ }
709
+
710
+ private async drainWakeOutboxBestEffort(): Promise<void> {
711
+ try {
712
+ await this.drainWakeOutbox();
713
+ } catch (error) {
714
+ // Query/database failures also stay outside store-operation success.
715
+ console.error("Failed to drain pending store wakes", error);
716
+ }
717
+ }
718
+
719
+ private assertSnapshotHasInstanceId(snapshot: { instanceId?: string }) {
720
+ if (!snapshot.instanceId) {
721
+ throw new Error(
722
+ "Store snapshot is missing instanceId; read a fresh snapshot"
723
+ );
724
+ }
725
+ }
726
+ }
727
+
728
+ type MatchedWaiter = {
729
+ executionId: string;
730
+ stepKey: string;
731
+ event: WorkflowEvent;
732
+ };
733
+
734
+ function serializeEvent(event: WorkflowEvent) {
735
+ return {
736
+ workflowId: event.workflowId,
737
+ executionId: event.executionId,
738
+ params: event.params,
739
+ context:
740
+ event.context instanceof Map
741
+ ? [...event.context.entries()]
742
+ : event.context
743
+ ? Object.entries(event.context as any)
744
+ : [],
745
+ };
746
+ }
747
+
748
+ function deserializeEvent(event: any): WorkflowEvent {
749
+ return {
750
+ workflowId: event.workflowId,
751
+ executionId: event.executionId,
752
+ params: event.params,
753
+ context: new Map(event.context ?? []),
754
+ };
755
+ }