@yieldstar/sqlite-runtime 0.5.0 → 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 (81) hide show
  1. package/dist/bun.js +15 -17
  2. package/dist/dao/effect-waiters-dao.d.ts +19 -0
  3. package/dist/dao/effect-waiters-dao.d.ts.map +1 -0
  4. package/dist/dao/effects-dao.d.ts +17 -0
  5. package/dist/dao/effects-dao.d.ts.map +1 -0
  6. package/dist/dao/executions-dao.d.ts +23 -0
  7. package/dist/dao/executions-dao.d.ts.map +1 -0
  8. package/dist/dao/start-requests-dao.d.ts +15 -0
  9. package/dist/dao/start-requests-dao.d.ts.map +1 -0
  10. package/dist/dao/step-response-dao.d.ts +1 -0
  11. package/dist/dao/step-response-dao.d.ts.map +1 -1
  12. package/dist/dao/store-waiters-dao.d.ts +26 -0
  13. package/dist/dao/store-waiters-dao.d.ts.map +1 -0
  14. package/dist/index-083bmj5k.js +800 -0
  15. package/dist/index-4kybaxga.js +798 -0
  16. package/dist/index-a8thbaee.js +718 -0
  17. package/dist/index-ag93sv33.js +664 -0
  18. package/dist/index-eabcgmaa.js +996 -0
  19. package/dist/index-gjfs8rn6.js +997 -0
  20. package/dist/index-h3h7r4b6.js +981 -0
  21. package/dist/index-ma984fze.js +718 -0
  22. package/dist/index-ne28djfn.js +718 -0
  23. package/dist/index-v6rn0pr6.js +664 -0
  24. package/dist/index-wx6vk994.js +669 -0
  25. package/dist/index.d.ts +3 -4
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +11 -13
  28. package/dist/node.js +15 -17
  29. package/dist/sqlite-effect.d.ts +30 -0
  30. package/dist/sqlite-effect.d.ts.map +1 -0
  31. package/dist/sqlite-execution.d.ts +54 -0
  32. package/dist/sqlite-execution.d.ts.map +1 -0
  33. package/dist/sqlite-heap.d.ts +12 -4
  34. package/dist/sqlite-heap.d.ts.map +1 -1
  35. package/dist/sqlite-runtime.d.ts +22 -0
  36. package/dist/sqlite-runtime.d.ts.map +1 -0
  37. package/dist/sqlite-store.d.ts +31 -25
  38. package/dist/sqlite-store.d.ts.map +1 -1
  39. package/dist/sqlite-transaction.d.ts +9 -0
  40. package/dist/sqlite-transaction.d.ts.map +1 -0
  41. package/package.json +2 -2
  42. package/src/dao/effect-waiters-dao.ts +63 -0
  43. package/src/dao/effects-dao.ts +123 -0
  44. package/src/dao/executions-dao.ts +188 -0
  45. package/src/dao/start-requests-dao.ts +72 -0
  46. package/src/dao/step-response-dao.ts +16 -1
  47. package/src/dao/store-waiters-dao.ts +92 -0
  48. package/src/index.ts +6 -4
  49. package/src/sqlite-effect.ts +99 -0
  50. package/src/sqlite-execution.ts +238 -0
  51. package/src/sqlite-heap.ts +64 -12
  52. package/src/sqlite-runtime.ts +36 -0
  53. package/src/sqlite-store.ts +184 -520
  54. package/src/sqlite-transaction.ts +19 -0
  55. package/dist/dao/task-queue-dao.d.ts +0 -21
  56. package/dist/dao/task-queue-dao.d.ts.map +0 -1
  57. package/dist/dao/timers-dao.d.ts +0 -22
  58. package/dist/dao/timers-dao.d.ts.map +0 -1
  59. package/dist/index-4vq70w0r.js +0 -796
  60. package/dist/index-s8wqgfwt.js +0 -19
  61. package/dist/index-x891hxwg.js +0 -796
  62. package/dist/node-driver.d.ts +0 -10
  63. package/dist/node-driver.d.ts.map +0 -1
  64. package/dist/sqlite-event-loop.d.ts +0 -18
  65. package/dist/sqlite-event-loop.d.ts.map +0 -1
  66. package/dist/sqlite-scheduler.d.ts +0 -13
  67. package/dist/sqlite-scheduler.d.ts.map +0 -1
  68. package/dist/sqlite-store.test.d.ts +0 -2
  69. package/dist/sqlite-store.test.d.ts.map +0 -1
  70. package/dist/sqlite-task-queue.d.ts +0 -26
  71. package/dist/sqlite-task-queue.d.ts.map +0 -1
  72. package/dist/sqlite-timers.d.ts +0 -18
  73. package/dist/sqlite-timers.d.ts.map +0 -1
  74. package/src/dao/task-queue-dao.ts +0 -79
  75. package/src/dao/timers-dao.ts +0 -81
  76. package/src/sqlite-event-loop.ts +0 -41
  77. package/src/sqlite-scheduler.ts +0 -24
  78. package/src/sqlite-task-queue.ts +0 -64
  79. package/src/sqlite-timers.ts +0 -40
  80. package/tsconfig.tsbuildinfo +0 -1
  81. /package/dist/{index-h7bm9xg4.js → index-dapqh99v.js} +0 -0
@@ -1,17 +1,19 @@
1
- import type { SchedulerClient, WorkflowEvent } from "@yieldstar/core";
2
1
  import { v7 as uuidv7 } from "uuid";
2
+ import { StoreWaitersDao } from "./dao/store-waiters-dao";
3
3
  import type { SqliteDriver } from "./sqlite-driver";
4
+ import { SqliteExecutionClient } from "./sqlite-execution";
5
+ import { runTransaction } from "./sqlite-transaction";
4
6
  import {
5
7
  cloneStoreState,
6
8
  CasStoreClient,
9
+ decodeStoreValue,
10
+ encodeStoreValue,
7
11
  storePathsIntersect,
8
- validateStoreState,
9
12
  type StandardSchemaV1,
10
13
  type StoreDefinition,
11
- type StoreDeleteFromResult,
14
+ type StoreDeletion,
12
15
  type StorePath,
13
16
  type StoreSnapshot,
14
- type StoreState,
15
17
  type StoreStepId,
16
18
  type StoreWaiter,
17
19
  type StoreMutation,
@@ -32,140 +34,62 @@ class StoreIdRow {
32
34
  store_id!: string;
33
35
  }
34
36
 
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
-
37
+ /**
38
+ * Stores, waiters and the applied-steps ledger, in the same database as the
39
+ * execution records. A commit that satisfies waiters requests their wakes in
40
+ * the same transaction, so a durable commit always has durable wakes.
41
+ *
42
+ * Every write is one synchronous IMMEDIATE transaction: nothing awaits
43
+ * between BEGIN and COMMIT, so operations on a shared connection cannot
44
+ * interleave, and processes sharing the file serialize on SQLite's lock.
45
+ */
55
46
  export class SqliteStoreClient extends CasStoreClient {
56
47
  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 }) {
48
+ private executions: SqliteExecutionClient;
49
+ private storeWaitersDao: StoreWaitersDao;
50
+
51
+ /**
52
+ * @param params.executions The execution client whose records waiters wake.
53
+ * Defaults to one over `db` with the default clock; SqliteRuntime passes
54
+ * its own so both share the runtime clock.
55
+ */
56
+ constructor(params: { db: SqliteDriver; executions?: SqliteExecutionClient }) {
62
57
  super();
63
58
  this.db = params.db;
64
- this.schedulerClient = params.schedulerClient;
59
+ this.executions = params.executions ?? new SqliteExecutionClient(params.db);
65
60
  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());
61
+ this.storeWaitersDao = new StoreWaitersDao(params.db);
70
62
  }
71
63
 
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;
64
+ protected createInstanceId(): string {
65
+ return uuidv7();
76
66
  }
77
67
 
78
- async getOrCreateStore<Schema extends StandardSchemaV1>(params: {
79
- definition: StoreDefinition<Schema>;
68
+ protected async readStore(params: {
69
+ definition: StoreDefinition;
80
70
  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
- });
71
+ }): Promise<StoreSnapshot<unknown> | undefined> {
72
+ const row = this.getStoreRow(params.definition.name, params.id);
73
+ return row ? toSnapshot(row) : undefined;
150
74
  }
151
75
 
152
- async getStore<Schema extends StandardSchemaV1>(params: {
153
- definition: StoreDefinition<Schema>;
76
+ protected async insertStore(params: {
77
+ definition: StoreDefinition;
154
78
  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
- };
79
+ snapshot: StoreSnapshot<unknown>;
80
+ }): Promise<StoreSnapshot<unknown>> {
81
+ return runTransaction(this.db, () => {
82
+ const row = this.getStoreRow(params.definition.name, params.id);
83
+ if (row) return toSnapshot(row);
84
+ this.insertStoreRow({
85
+ storeName: params.definition.name,
86
+ storeId: params.id,
87
+ instanceId: params.snapshot.instanceId,
88
+ version: params.snapshot.version,
89
+ state: params.snapshot.state,
90
+ });
91
+ return cloneStoreState(params.snapshot);
92
+ });
169
93
  }
170
94
 
171
95
  protected async getAppliedStoreStep(params: {
@@ -178,52 +102,48 @@ export class SqliteStoreClient extends CasStoreClient {
178
102
  storeId: params.id,
179
103
  stepId: params.stepId,
180
104
  });
181
- return row ? { result: JSON.parse(row.result) } : undefined;
105
+ return row ? { result: decodeStoreValue(row.result) } : undefined;
182
106
  }
183
107
 
184
- protected commitStoreMutation(
108
+ protected async commitStoreMutation(
185
109
  mutation: StoreMutation
186
110
  ): 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");
111
+ return runTransaction(this.db, (): StoreMutationCommitResult => {
112
+ if (mutation.stepId) {
113
+ const applied = this.getAppliedStepRow({
114
+ storeName: mutation.definition.name,
115
+ storeId: mutation.id,
116
+ stepId: mutation.stepId,
117
+ });
118
+ if (applied) {
216
119
  return {
217
- status: "conflict",
218
- snapshot: {
219
- state: JSON.parse(row.state),
220
- instanceId: row.instance_id,
221
- version: row.version,
222
- },
120
+ status: "already-applied",
121
+ result: decodeStoreValue(applied.result),
223
122
  };
224
123
  }
124
+ }
125
+
126
+ const row = this.getStoreRow(mutation.definition.name, mutation.id);
127
+ if (!row && !mutation.expected.created) return { status: "missing" };
128
+ if (
129
+ row &&
130
+ (mutation.expected.created ||
131
+ row.instance_id !== mutation.expected.instanceId ||
132
+ row.version !== mutation.expected.version)
133
+ ) {
134
+ return { status: "conflict", snapshot: toSnapshot(row) };
135
+ }
225
136
 
226
- const version = mutation.expected.version + 1;
137
+ const version = mutation.expected.version + 1;
138
+ if (mutation.expected.created) {
139
+ this.insertStoreRow({
140
+ storeName: mutation.definition.name,
141
+ storeId: mutation.id,
142
+ instanceId: mutation.expected.instanceId,
143
+ version,
144
+ state: mutation.nextState,
145
+ });
146
+ } else {
227
147
  const changed = this.db
228
148
  .query(
229
149
  `UPDATE stores
@@ -234,46 +154,35 @@ export class SqliteStoreClient extends CasStoreClient {
234
154
  $instanceId: mutation.expected.instanceId,
235
155
  $expectedVersion: mutation.expected.version,
236
156
  $version: version,
237
- $state: JSON.stringify(mutation.nextState),
157
+ $state: encodeStoreValue(mutation.nextState),
238
158
  });
239
159
  if (Number(changed.changes) !== 1) {
240
160
  throw new Error("Store changed during its mutation transaction");
241
161
  }
162
+ }
242
163
 
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({
164
+ if (mutation.stepId) {
165
+ this.insertAppliedStepRow({
253
166
  storeName: mutation.definition.name,
254
167
  storeId: mutation.id,
255
- version,
256
- changedPaths: mutation.changedPaths,
168
+ stepId: mutation.stepId,
169
+ result: encodeStoreValue(mutation.result),
257
170
  });
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
- }
171
+ }
266
172
 
267
- this.db.run("COMMIT");
268
- } catch (error) {
269
- this.db.run("ROLLBACK");
270
- throw error;
173
+ // Wakes commit with the mutation. Waiters stay registered until the
174
+ // heap records their step's result; a replay whose selector still does
175
+ // not match re-registers with a newer sinceVersion.
176
+ const executionIds = this.selectMatchingExecutionIds({
177
+ storeName: mutation.definition.name,
178
+ storeId: mutation.id,
179
+ version,
180
+ changedPaths: mutation.changedPaths,
181
+ });
182
+ for (const executionId of executionIds) {
183
+ this.executions.wakeInTransaction(executionId);
271
184
  }
272
185
 
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
186
  return { status: "committed" };
278
187
  });
279
188
  }
@@ -295,176 +204,78 @@ export class SqliteStoreClient extends CasStoreClient {
295
204
  definition: StoreDefinition<Schema>;
296
205
  id: string;
297
206
  }): 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
- }
207
+ runTransaction(this.db, () => {
208
+ this.db
209
+ .query(
210
+ `DELETE FROM stores WHERE store_name = $storeName AND store_id = $storeId`
211
+ )
212
+ .run({
213
+ $storeName: params.definition.name,
214
+ $storeId: params.id,
215
+ });
216
+ this.storeWaitersDao.deleteStoreWaiters(params.definition.name, params.id);
325
217
  });
326
218
  }
327
219
 
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");
220
+ protected async commitStoreDeletion(
221
+ deletion: StoreDeletion
222
+ ): Promise<StoreMutationCommitResult> {
223
+ return runTransaction(this.db, (): StoreMutationCommitResult => {
224
+ if (deletion.stepId) {
225
+ const applied = this.getAppliedStepRow({
226
+ storeName: deletion.definition.name,
227
+ storeId: deletion.id,
228
+ stepId: deletion.stepId,
229
+ });
230
+ if (applied) {
370
231
  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,
232
+ status: "already-applied",
233
+ result: decodeStoreValue(applied.result),
377
234
  };
378
235
  }
236
+ }
379
237
 
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;
238
+ const row = this.getStoreRow(deletion.definition.name, deletion.id);
239
+ if (!row) return { status: "missing" };
240
+ if (
241
+ row.instance_id !== deletion.expected.instanceId ||
242
+ row.version !== deletion.expected.version
243
+ ) {
244
+ return { status: "conflict", snapshot: toSnapshot(row) };
407
245
  }
246
+
247
+ this.storeWaitersDao.deleteStoreWaiters(deletion.definition.name, deletion.id);
248
+ this.db
249
+ .query(`DELETE FROM stores WHERE instance_id = $instanceId`)
250
+ .run({ $instanceId: row.instance_id });
251
+ if (deletion.stepId) {
252
+ this.insertAppliedStepRow({
253
+ storeName: deletion.definition.name,
254
+ storeId: deletion.id,
255
+ stepId: deletion.stepId,
256
+ result: encodeStoreValue(deletion.result),
257
+ });
258
+ }
259
+ return { status: "committed" };
408
260
  });
409
261
  }
410
262
 
411
263
  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();
264
+ runTransaction(this.db, () => {
265
+ this.storeWaitersDao.upsertWaiter(waiter);
266
+
267
+ // Lost-wakeup guard: if an update committed between the caller's
268
+ // getStore and this registration, the version has already advanced
269
+ // past sinceVersion and no future update is guaranteed. Request an
270
+ // immediate wake – the replay re-evaluates the selector, so a
271
+ // spurious wake is safe.
272
+ const row = this.getStoreRow(waiter.storeName, waiter.storeId);
273
+ if (
274
+ !row ||
275
+ row.instance_id !== waiter.instanceId ||
276
+ row.version > waiter.sinceVersion
277
+ ) {
278
+ this.executions.wakeInTransaction(waiter.executionId);
468
279
  }
469
280
  });
470
281
  }
@@ -473,18 +284,6 @@ export class SqliteStoreClient extends CasStoreClient {
473
284
  this.createStoresTable();
474
285
 
475
286
  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
287
  CREATE TABLE IF NOT EXISTS store_applied_steps (
489
288
  store_name TEXT NOT NULL,
490
289
  store_id TEXT NOT NULL,
@@ -493,14 +292,6 @@ export class SqliteStoreClient extends CasStoreClient {
493
292
  result TEXT NOT NULL,
494
293
  PRIMARY KEY (store_name, store_id, execution_id, step_key)
495
294
  );
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
295
  `);
505
296
  }
506
297
 
@@ -572,22 +363,18 @@ export class SqliteStoreClient extends CasStoreClient {
572
363
  });
573
364
  }
574
365
 
575
- private selectMatchingWaiters(params: {
366
+ /** Executions with a waiter that the commit at `version` satisfies. */
367
+ private selectMatchingExecutionIds(params: {
576
368
  storeName: string;
577
369
  storeId: string;
578
370
  version: number;
579
371
  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
- });
372
+ }): Set<string> {
373
+ const executionIds = new Set<string>();
374
+ const rows = this.storeWaitersDao.getStoreWaiters(
375
+ params.storeName,
376
+ params.storeId
377
+ );
591
378
 
592
379
  for (const row of rows) {
593
380
  if (row.since_version >= params.version) continue;
@@ -595,161 +382,38 @@ export class SqliteStoreClient extends CasStoreClient {
595
382
  const readPaths = JSON.parse(row.read_paths) as StorePath[];
596
383
  if (!storePathsIntersect(readPaths, params.changedPaths)) continue;
597
384
 
598
- waiters.push({
599
- executionId: row.execution_id,
600
- stepKey: row.step_key,
601
- event: deserializeEvent(JSON.parse(row.event)),
602
- });
385
+ executionIds.add(row.execution_id);
603
386
  }
604
387
 
605
- return waiters;
388
+ return executionIds;
606
389
  }
607
390
 
608
- private deleteWaiterRow(params: {
391
+ private insertStoreRow(params: {
609
392
  storeName: string;
610
393
  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;
394
+ instanceId: string;
395
+ version: number;
396
+ state: unknown;
638
397
  }) {
639
398
  this.db
640
399
  .query(
641
- `INSERT OR IGNORE INTO store_wake_outbox
642
- (store_name, store_id, execution_id, step_key)
643
- VALUES ($storeName, $storeId, $executionId, $stepKey)`
400
+ `INSERT INTO stores (instance_id, store_name, store_id, version, state)
401
+ VALUES ($instanceId, $storeName, $storeId, $version, $state)`
644
402
  )
645
403
  .run({
404
+ $instanceId: params.instanceId,
646
405
  $storeName: params.storeName,
647
406
  $storeId: params.storeId,
648
- $executionId: params.executionId,
649
- $stepKey: params.stepKey,
407
+ $version: params.version,
408
+ $state: encodeStoreValue(params.state),
650
409
  });
651
410
  }
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
411
  }
747
412
 
748
- function deserializeEvent(event: any): WorkflowEvent {
413
+ function toSnapshot(row: StoreRow) {
749
414
  return {
750
- workflowId: event.workflowId,
751
- executionId: event.executionId,
752
- params: event.params,
753
- context: new Map(event.context ?? []),
415
+ state: decodeStoreValue(row.state),
416
+ instanceId: row.instance_id,
417
+ version: row.version,
754
418
  };
755
419
  }