@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.
- package/dist/bun.d.ts +13 -0
- package/dist/bun.d.ts.map +1 -0
- package/dist/bun.js +43 -0
- package/dist/dao/step-response-dao.d.ts +19 -0
- package/dist/dao/step-response-dao.d.ts.map +1 -0
- package/dist/dao/task-queue-dao.d.ts +21 -0
- package/dist/dao/task-queue-dao.d.ts.map +1 -0
- package/dist/dao/timers-dao.d.ts +22 -0
- package/dist/dao/timers-dao.d.ts.map +1 -0
- package/dist/index-4vq70w0r.js +796 -0
- package/dist/index-h7bm9xg4.js +26 -0
- package/dist/index-s8wqgfwt.js +19 -0
- package/dist/index-x891hxwg.js +796 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/node-driver.d.ts +10 -0
- package/dist/node-driver.d.ts.map +1 -0
- package/dist/node.d.ts +13 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +49 -0
- package/dist/sqlite-db.d.ts +29 -0
- package/dist/sqlite-db.d.ts.map +1 -0
- package/dist/sqlite-driver.d.ts +21 -0
- package/dist/sqlite-driver.d.ts.map +1 -0
- package/dist/sqlite-event-loop.d.ts +18 -0
- package/dist/sqlite-event-loop.d.ts.map +1 -0
- package/dist/sqlite-heap.d.ts +26 -0
- package/dist/sqlite-heap.d.ts.map +1 -0
- package/dist/sqlite-scheduler.d.ts +13 -0
- package/dist/sqlite-scheduler.d.ts.map +1 -0
- package/dist/sqlite-store.d.ts +54 -0
- package/dist/sqlite-store.d.ts.map +1 -0
- package/dist/sqlite-store.test.d.ts +2 -0
- package/dist/sqlite-store.test.d.ts.map +1 -0
- package/dist/sqlite-task-queue.d.ts +26 -0
- package/dist/sqlite-task-queue.d.ts.map +1 -0
- package/dist/sqlite-timers.d.ts +18 -0
- package/dist/sqlite-timers.d.ts.map +1 -0
- package/package.json +43 -0
- package/src/bun.ts +32 -0
- package/src/dao/step-response-dao.ts +80 -0
- package/src/dao/task-queue-dao.ts +79 -0
- package/src/dao/timers-dao.ts +81 -0
- package/src/index.ts +14 -0
- package/src/node.ts +47 -0
- package/src/sqlite-db.ts +70 -0
- package/src/sqlite-driver.ts +30 -0
- package/src/sqlite-event-loop.ts +41 -0
- package/src/sqlite-heap.ts +54 -0
- package/src/sqlite-scheduler.ts +24 -0
- package/src/sqlite-store.ts +755 -0
- package/src/sqlite-task-queue.ts +64 -0
- package/src/sqlite-timers.ts +40 -0
- package/tsconfig.json +9 -0
- 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
|
+
}
|