@torkbot/sledge 0.1.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.
@@ -0,0 +1,798 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Value } from "@sinclair/typebox/value";
3
+ export function createDatabaseLedger(input) {
4
+ return openDatabaseLedgerEngine({
5
+ boundModel: input.boundModel,
6
+ timing: input.timing,
7
+ database: input.database,
8
+ leaseMs: input.leaseMs,
9
+ defaultRetryDelayMs: input.defaultRetryDelayMs,
10
+ maxInFlight: input.maxInFlight,
11
+ maxBusyRetries: input.maxBusyRetries,
12
+ maxBusyRetryDelayMs: input.maxBusyRetryDelayMs,
13
+ });
14
+ }
15
+ class RuntimeBuilder {
16
+ projectorsByEvent = new Map();
17
+ materializersByEvent = new Map();
18
+ handlersByQueue = new Map();
19
+ project(eventName, projector) {
20
+ const key = String(eventName);
21
+ const existing = this.projectorsByEvent.get(key) ?? [];
22
+ existing.push(projector);
23
+ this.projectorsByEvent.set(key, existing);
24
+ return this;
25
+ }
26
+ materialize(eventName, materializer) {
27
+ const key = String(eventName);
28
+ const existing = this.materializersByEvent.get(key) ?? [];
29
+ existing.push(materializer);
30
+ this.materializersByEvent.set(key, existing);
31
+ return this;
32
+ }
33
+ handle(queueName, handler) {
34
+ const key = String(queueName);
35
+ if (this.handlersByQueue.has(key)) {
36
+ throw new Error(`duplicate queue handler registration: ${key}`);
37
+ }
38
+ this.handlersByQueue.set(key, handler);
39
+ return this;
40
+ }
41
+ }
42
+ function parseJson(value, context) {
43
+ try {
44
+ return JSON.parse(value);
45
+ }
46
+ catch (error) {
47
+ throw new Error(`invalid JSON at ${context}`, {
48
+ cause: error,
49
+ });
50
+ }
51
+ }
52
+ function isSqliteBusyError(error) {
53
+ if (!(error instanceof Error)) {
54
+ return false;
55
+ }
56
+ const maybeCode = error.code;
57
+ if (maybeCode === "SQLITE_BUSY") {
58
+ return true;
59
+ }
60
+ return error.message.includes("SQLITE_BUSY");
61
+ }
62
+ function computeBusyRetryDelayMs(attempt, maxDelayMs) {
63
+ return Math.min(maxDelayMs, 2 ** attempt);
64
+ }
65
+ async function sleepMs(ms) {
66
+ await new Promise((resolve) => {
67
+ setTimeout(resolve, ms);
68
+ });
69
+ }
70
+ function readNumberField(row, field) {
71
+ const value = row[field];
72
+ if (typeof value !== "number") {
73
+ throw new Error(`expected numeric field ${field}`);
74
+ }
75
+ return value;
76
+ }
77
+ function readNullableNumberField(row, field) {
78
+ const value = row[field];
79
+ if (value === null) {
80
+ return null;
81
+ }
82
+ if (typeof value !== "number") {
83
+ throw new Error(`expected nullable numeric field ${field}`);
84
+ }
85
+ return value;
86
+ }
87
+ function readStringField(row, field) {
88
+ const value = row[field];
89
+ if (typeof value !== "string") {
90
+ throw new Error(`expected string field ${field}`);
91
+ }
92
+ return value;
93
+ }
94
+ function readNullableStringField(row, field) {
95
+ const value = row[field];
96
+ if (value === null) {
97
+ return null;
98
+ }
99
+ if (typeof value !== "string") {
100
+ throw new Error(`expected nullable string field ${field}`);
101
+ }
102
+ return value;
103
+ }
104
+ function openDatabaseLedgerEngine(input) {
105
+ const builder = new RuntimeBuilder();
106
+ const clock = input.timing.clock;
107
+ const scheduler = input.timing.scheduler;
108
+ const database = input.database;
109
+ const model = input.boundModel.model;
110
+ const implementations = input.boundModel.implementations;
111
+ const register = input.boundModel.register;
112
+ register(builder);
113
+ const leaseMs = input.leaseMs ?? 1_000;
114
+ const defaultRetryDelayMs = input.defaultRetryDelayMs ?? 1_000;
115
+ const maxInFlight = input.maxInFlight ?? 16;
116
+ const maxBusyRetries = input.maxBusyRetries ?? 8;
117
+ const maxBusyRetryDelayMs = input.maxBusyRetryDelayMs ?? 50;
118
+ if (!Number.isInteger(maxInFlight) || maxInFlight <= 0) {
119
+ throw new Error(`maxInFlight must be a positive integer, received ${maxInFlight}`);
120
+ }
121
+ if (!Number.isInteger(maxBusyRetries) || maxBusyRetries < 0) {
122
+ throw new Error(`maxBusyRetries must be a non-negative integer, received ${maxBusyRetries}`);
123
+ }
124
+ if (!Number.isInteger(maxBusyRetryDelayMs) || maxBusyRetryDelayMs <= 0) {
125
+ throw new Error(`maxBusyRetryDelayMs must be a positive integer, received ${maxBusyRetryDelayMs}`);
126
+ }
127
+ let closed = false;
128
+ let dispatchLoopActive = false;
129
+ let dispatchLoopQueued = false;
130
+ let scheduledDispatch = null;
131
+ const inFlight = new Set();
132
+ const leaseAbortControllers = new Map();
133
+ const leaseExpiryTasks = new Map();
134
+ const leaseHeartbeatTasks = new Map();
135
+ let mutationTail = Promise.resolve();
136
+ const startup = (async () => {
137
+ await withBusyRetry(async () => {
138
+ await database.exec(`
139
+ CREATE TABLE IF NOT EXISTS events (
140
+ event_id INTEGER PRIMARY KEY AUTOINCREMENT,
141
+ ts_ms INTEGER NOT NULL,
142
+ event_name TEXT NOT NULL,
143
+ payload_json TEXT NOT NULL,
144
+ causation_event_id INTEGER,
145
+ dedupe_key TEXT UNIQUE
146
+ );
147
+
148
+ CREATE TABLE IF NOT EXISTS work (
149
+ work_id INTEGER PRIMARY KEY AUTOINCREMENT,
150
+ queue_name TEXT NOT NULL,
151
+ payload_json TEXT NOT NULL,
152
+ source_event_id INTEGER NOT NULL,
153
+ attempt INTEGER NOT NULL DEFAULT 0,
154
+ available_at_ms INTEGER NOT NULL,
155
+ dead INTEGER NOT NULL DEFAULT 0,
156
+ lease_id TEXT,
157
+ lease_acquired_at_ms INTEGER,
158
+ lease_expires_at_ms INTEGER,
159
+ last_error TEXT
160
+ );
161
+
162
+ CREATE INDEX IF NOT EXISTS idx_work_due
163
+ ON work(dead, lease_id, available_at_ms, work_id);
164
+ `);
165
+ });
166
+ await releaseExpiredLeases();
167
+ await scheduleNextDispatchFromStore();
168
+ })();
169
+ function runSerialized(run) {
170
+ const operation = mutationTail.then(run, run);
171
+ mutationTail = operation.then(() => undefined, () => undefined);
172
+ return operation;
173
+ }
174
+ async function withBusyRetry(run) {
175
+ let attempt = 0;
176
+ while (true) {
177
+ try {
178
+ return await run();
179
+ }
180
+ catch (error) {
181
+ if (!isSqliteBusyError(error) || attempt >= maxBusyRetries) {
182
+ throw error;
183
+ }
184
+ attempt += 1;
185
+ await sleepMs(computeBusyRetryDelayMs(attempt, maxBusyRetryDelayMs));
186
+ }
187
+ }
188
+ }
189
+ async function runInTransaction(run) {
190
+ return await runSerialized(async () => {
191
+ return await withBusyRetry(async () => {
192
+ let began = false;
193
+ try {
194
+ await database.exec("BEGIN IMMEDIATE");
195
+ began = true;
196
+ const result = await run();
197
+ await database.exec("COMMIT");
198
+ return result;
199
+ }
200
+ catch (error) {
201
+ if (began) {
202
+ try {
203
+ await database.exec("ROLLBACK");
204
+ }
205
+ catch {
206
+ // Suppress rollback failures to preserve the root cause.
207
+ }
208
+ }
209
+ throw error;
210
+ }
211
+ });
212
+ });
213
+ }
214
+ function decodeEventPayload(eventName, payload) {
215
+ const schema = model.events[eventName];
216
+ if (schema === undefined) {
217
+ throw new Error(`unknown event name: ${String(eventName)}`);
218
+ }
219
+ return Value.Decode(schema, payload);
220
+ }
221
+ async function appendEventInTransaction(eventInput) {
222
+ const eventName = eventInput.eventName;
223
+ const eventSchema = model.events[eventName];
224
+ if (eventSchema === undefined) {
225
+ throw new Error(`unknown event name: ${eventInput.eventName}`);
226
+ }
227
+ const decodedPayload = decodeEventPayload(eventName, eventInput.payload);
228
+ const encodedPayload = Value.Encode(eventSchema, decodedPayload);
229
+ const payloadJson = JSON.stringify(encodedPayload);
230
+ let created = false;
231
+ let eventId = 0;
232
+ if (eventInput.dedupeKey === undefined) {
233
+ const eventInsert = await database
234
+ .prepare(`INSERT INTO events (ts_ms, event_name, payload_json, causation_event_id, dedupe_key)
235
+ VALUES (?, ?, ?, ?, NULL)`)
236
+ .run(eventInput.nowMs, eventInput.eventName, payloadJson, eventInput.causationEventId);
237
+ created = true;
238
+ eventId = Number(eventInsert.lastInsertRowid);
239
+ }
240
+ else {
241
+ const eventInsert = await database
242
+ .prepare(`INSERT INTO events (ts_ms, event_name, payload_json, causation_event_id, dedupe_key)
243
+ VALUES (?, ?, ?, ?, ?)
244
+ ON CONFLICT(dedupe_key) DO NOTHING`)
245
+ .run(eventInput.nowMs, eventInput.eventName, payloadJson, eventInput.causationEventId, eventInput.dedupeKey);
246
+ if (eventInsert.changes > 0) {
247
+ created = true;
248
+ eventId = Number(eventInsert.lastInsertRowid);
249
+ }
250
+ else {
251
+ const existing = await database
252
+ .prepare(`SELECT event_id FROM events WHERE dedupe_key = ?`)
253
+ .get(eventInput.dedupeKey);
254
+ if (existing === undefined) {
255
+ throw new Error(`dedupe conflict resolved without durable winner for key ${eventInput.dedupeKey}`);
256
+ }
257
+ eventId = readNumberField(existing, "event_id");
258
+ }
259
+ }
260
+ if (!created) {
261
+ return {
262
+ eventId,
263
+ created: false,
264
+ };
265
+ }
266
+ const envelope = {
267
+ eventId,
268
+ tsMs: eventInput.nowMs,
269
+ eventName,
270
+ payload: decodedPayload,
271
+ causationEventId: eventInput.causationEventId,
272
+ dedupeKey: eventInput.dedupeKey ?? null,
273
+ };
274
+ const projectors = builder.projectorsByEvent.get(eventInput.eventName) ?? [];
275
+ for (const projector of projectors) {
276
+ await projector({
277
+ event: envelope,
278
+ actions: {
279
+ index: async (indexName, indexInput) => {
280
+ const schema = model.indexers[indexName];
281
+ const implementation = implementations.indexers[indexName];
282
+ if (schema === undefined || implementation === undefined) {
283
+ throw new Error(`unknown indexer: ${String(indexName)}`);
284
+ }
285
+ const decodedInput = Value.Decode(schema, indexInput);
286
+ const encodedInput = Value.Encode(schema, decodedInput);
287
+ const canonicalInput = Value.Decode(schema, encodedInput);
288
+ await implementation(canonicalInput);
289
+ },
290
+ },
291
+ });
292
+ }
293
+ const materializers = builder.materializersByEvent.get(eventInput.eventName) ?? [];
294
+ for (const materializer of materializers) {
295
+ const queued = [];
296
+ await materializer({
297
+ event: envelope,
298
+ actions: {
299
+ enqueue: (queueName, payload, options) => {
300
+ const queueSchema = model.queues[queueName];
301
+ if (queueSchema === undefined) {
302
+ throw new Error(`unknown queue: ${String(queueName)}`);
303
+ }
304
+ const decodedQueuePayload = Value.Decode(queueSchema, payload);
305
+ const encodedQueuePayload = Value.Encode(queueSchema, decodedQueuePayload);
306
+ queued.push({
307
+ queueName: String(queueName),
308
+ payload: encodedQueuePayload,
309
+ availableAtMs: options?.availableAtMs ?? eventInput.nowMs,
310
+ });
311
+ },
312
+ },
313
+ });
314
+ for (const work of queued) {
315
+ await database
316
+ .prepare(`INSERT INTO work (
317
+ queue_name,
318
+ payload_json,
319
+ source_event_id,
320
+ attempt,
321
+ available_at_ms,
322
+ dead,
323
+ lease_id,
324
+ lease_acquired_at_ms,
325
+ lease_expires_at_ms,
326
+ last_error
327
+ ) VALUES (?, ?, ?, 0, ?, 0, NULL, NULL, NULL, NULL)`)
328
+ .run(work.queueName, JSON.stringify(work.payload), eventId, work.availableAtMs);
329
+ }
330
+ }
331
+ return {
332
+ eventId,
333
+ created,
334
+ };
335
+ }
336
+ async function releaseExpiredLeases() {
337
+ await runInTransaction(async () => {
338
+ await database
339
+ .prepare(`UPDATE work
340
+ SET
341
+ lease_id = NULL,
342
+ lease_acquired_at_ms = NULL,
343
+ lease_expires_at_ms = NULL,
344
+ available_at_ms = ?
345
+ WHERE dead = 0
346
+ AND lease_id IS NOT NULL
347
+ AND lease_expires_at_ms IS NOT NULL
348
+ AND lease_expires_at_ms < ?`)
349
+ .run(clock.nowMs(), clock.nowMs());
350
+ });
351
+ }
352
+ function scheduleDispatchAt(targetAtMs) {
353
+ if (closed) {
354
+ return;
355
+ }
356
+ if (scheduledDispatch !== null && scheduledDispatch.dueAtMs <= targetAtMs) {
357
+ return;
358
+ }
359
+ scheduledDispatch?.cancel();
360
+ const delayMs = Math.max(0, targetAtMs - clock.nowMs());
361
+ const task = scheduler.scheduleOnce(delayMs, () => {
362
+ scheduledDispatch = null;
363
+ requestDispatchRun();
364
+ });
365
+ scheduledDispatch = {
366
+ dueAtMs: clock.nowMs() + delayMs,
367
+ cancel: () => task.cancel(),
368
+ };
369
+ }
370
+ async function scheduleNextDispatchFromStore() {
371
+ const row = await database
372
+ .prepare(`SELECT available_at_ms
373
+ FROM work
374
+ WHERE dead = 0
375
+ AND lease_id IS NULL
376
+ ORDER BY available_at_ms ASC
377
+ LIMIT 1`)
378
+ .get();
379
+ if (row === undefined) {
380
+ return;
381
+ }
382
+ scheduleDispatchAt(readNumberField(row, "available_at_ms"));
383
+ }
384
+ async function claimNextDueWork() {
385
+ return await runInTransaction(async () => {
386
+ const nowMs = clock.nowMs();
387
+ const candidate = await database
388
+ .prepare(`SELECT work_id
389
+ FROM work
390
+ WHERE dead = 0
391
+ AND lease_id IS NULL
392
+ AND available_at_ms <= ?
393
+ ORDER BY work_id ASC
394
+ LIMIT 1`)
395
+ .get(nowMs);
396
+ if (candidate === undefined) {
397
+ return null;
398
+ }
399
+ const candidateWorkId = readNumberField(candidate, "work_id");
400
+ const leaseId = randomUUID();
401
+ const leaseExpiresAtMs = nowMs + leaseMs;
402
+ const updateResult = await database
403
+ .prepare(`UPDATE work
404
+ SET
405
+ attempt = attempt + 1,
406
+ lease_id = ?,
407
+ lease_acquired_at_ms = ?,
408
+ lease_expires_at_ms = ?
409
+ WHERE work_id = ?
410
+ AND dead = 0
411
+ AND lease_id IS NULL`)
412
+ .run(leaseId, nowMs, leaseExpiresAtMs, candidateWorkId);
413
+ if (updateResult.changes <= 0) {
414
+ return null;
415
+ }
416
+ const claimed = await database
417
+ .prepare(`SELECT
418
+ work_id,
419
+ queue_name,
420
+ payload_json,
421
+ source_event_id,
422
+ attempt,
423
+ lease_id,
424
+ lease_acquired_at_ms,
425
+ lease_expires_at_ms
426
+ FROM work
427
+ WHERE work_id = ?`)
428
+ .get(candidateWorkId);
429
+ if (claimed === undefined) {
430
+ return null;
431
+ }
432
+ if (readNullableStringField(claimed, "lease_id") !== leaseId) {
433
+ return null;
434
+ }
435
+ const leaseAcquiredAtMs = readNullableNumberField(claimed, "lease_acquired_at_ms");
436
+ const leaseExpiresAtMsValue = readNullableNumberField(claimed, "lease_expires_at_ms");
437
+ if (leaseAcquiredAtMs === null || leaseExpiresAtMsValue === null) {
438
+ return null;
439
+ }
440
+ return {
441
+ workId: readNumberField(claimed, "work_id"),
442
+ queueName: readStringField(claimed, "queue_name"),
443
+ payloadJson: readStringField(claimed, "payload_json"),
444
+ sourceEventId: readNumberField(claimed, "source_event_id"),
445
+ attempt: readNumberField(claimed, "attempt"),
446
+ leaseId,
447
+ leaseAcquiredAtMs,
448
+ leaseExpiresAtMs: leaseExpiresAtMsValue,
449
+ };
450
+ });
451
+ }
452
+ function requestDispatchRun() {
453
+ if (closed) {
454
+ return;
455
+ }
456
+ if (dispatchLoopActive) {
457
+ dispatchLoopQueued = true;
458
+ return;
459
+ }
460
+ dispatchLoopActive = true;
461
+ void runDispatchLoop().finally(() => {
462
+ dispatchLoopActive = false;
463
+ if (dispatchLoopQueued && !closed) {
464
+ dispatchLoopQueued = false;
465
+ requestDispatchRun();
466
+ }
467
+ });
468
+ }
469
+ async function runDispatchLoop() {
470
+ await startup;
471
+ if (closed) {
472
+ return;
473
+ }
474
+ while (!closed && inFlight.size < maxInFlight) {
475
+ const claimed = await claimNextDueWork();
476
+ if (claimed === null) {
477
+ await scheduleNextDispatchFromStore();
478
+ return;
479
+ }
480
+ const handler = builder.handlersByQueue.get(claimed.queueName);
481
+ if (handler === undefined) {
482
+ await runInTransaction(async () => {
483
+ await database
484
+ .prepare(`UPDATE work
485
+ SET
486
+ dead = 1,
487
+ lease_id = NULL,
488
+ lease_acquired_at_ms = NULL,
489
+ lease_expires_at_ms = NULL,
490
+ last_error = ?
491
+ WHERE work_id = ?
492
+ AND lease_id = ?`)
493
+ .run(`no handler for queue ${claimed.queueName}`, claimed.workId, claimed.leaseId);
494
+ });
495
+ continue;
496
+ }
497
+ const run = processClaimedWork(claimed, handler).finally(() => {
498
+ inFlight.delete(run);
499
+ requestDispatchRun();
500
+ });
501
+ inFlight.add(run);
502
+ }
503
+ }
504
+ async function processClaimedWork(claimed, handler) {
505
+ const leaseAbortController = new AbortController();
506
+ leaseAbortControllers.set(claimed.leaseId, leaseAbortController);
507
+ let currentLeaseExpiresAtMs = claimed.leaseExpiresAtMs;
508
+ let activeLeaseHolds = 0;
509
+ const clearLeaseHeartbeat = () => {
510
+ leaseHeartbeatTasks.get(claimed.leaseId)?.cancel();
511
+ leaseHeartbeatTasks.delete(claimed.leaseId);
512
+ };
513
+ const releaseLeaseInStore = async () => {
514
+ await runInTransaction(async () => {
515
+ await database
516
+ .prepare(`UPDATE work
517
+ SET
518
+ lease_id = NULL,
519
+ lease_acquired_at_ms = NULL,
520
+ lease_expires_at_ms = NULL,
521
+ available_at_ms = ?
522
+ WHERE work_id = ?
523
+ AND lease_id = ?
524
+ AND dead = 0`)
525
+ .run(clock.nowMs(), claimed.workId, claimed.leaseId);
526
+ });
527
+ };
528
+ const abortLease = (reason) => {
529
+ if (!leaseAbortController.signal.aborted) {
530
+ leaseAbortController.abort(new Error(reason));
531
+ }
532
+ };
533
+ const scheduleLeaseExpiry = () => {
534
+ leaseExpiryTasks.get(claimed.leaseId)?.cancel();
535
+ const delayMs = Math.max(0, currentLeaseExpiresAtMs - clock.nowMs());
536
+ const expiryTask = scheduler.scheduleOnce(delayMs, () => {
537
+ abortLease("lease expired");
538
+ clearLeaseHeartbeat();
539
+ void releaseLeaseInStore().then(() => {
540
+ scheduleDispatchAt(clock.nowMs());
541
+ }, () => undefined);
542
+ });
543
+ leaseExpiryTasks.set(claimed.leaseId, {
544
+ cancel: () => expiryTask.cancel(),
545
+ });
546
+ };
547
+ const renewLease = async () => {
548
+ const nowMs = clock.nowMs();
549
+ const renewedLeaseExpiresAtMs = nowMs + leaseMs;
550
+ const renewal = await runInTransaction(async () => {
551
+ return await database
552
+ .prepare(`UPDATE work
553
+ SET
554
+ lease_expires_at_ms = ?
555
+ WHERE work_id = ?
556
+ AND lease_id = ?
557
+ AND dead = 0`)
558
+ .run(renewedLeaseExpiresAtMs, claimed.workId, claimed.leaseId);
559
+ });
560
+ if (renewal.changes <= 0) {
561
+ throw new Error("lease renewal lost ownership");
562
+ }
563
+ currentLeaseExpiresAtMs = renewedLeaseExpiresAtMs;
564
+ scheduleLeaseExpiry();
565
+ };
566
+ scheduleLeaseExpiry();
567
+ const startLeaseHeartbeat = () => {
568
+ if (leaseHeartbeatTasks.has(claimed.leaseId)) {
569
+ return;
570
+ }
571
+ const heartbeatEveryMs = Math.max(1, Math.floor(leaseMs / 3));
572
+ const heartbeatTask = scheduler.scheduleRepeating(heartbeatEveryMs, () => {
573
+ if (leaseAbortController.signal.aborted) {
574
+ clearLeaseHeartbeat();
575
+ return;
576
+ }
577
+ void renewLease().catch(() => {
578
+ clearLeaseHeartbeat();
579
+ abortLease("lease renewal failed");
580
+ });
581
+ });
582
+ leaseHeartbeatTasks.set(claimed.leaseId, {
583
+ cancel: () => heartbeatTask.cancel(),
584
+ });
585
+ };
586
+ const stopLeaseHeartbeat = () => {
587
+ clearLeaseHeartbeat();
588
+ };
589
+ const queueSchema = model.queues[claimed.queueName];
590
+ if (queueSchema === undefined) {
591
+ throw new Error(`unknown queue schema for ${claimed.queueName}`);
592
+ }
593
+ const decodedPayload = Value.Decode(queueSchema, parseJson(claimed.payloadJson, "work.payload_json"));
594
+ const work = {
595
+ workId: claimed.workId,
596
+ queueName: claimed.queueName,
597
+ payload: decodedPayload,
598
+ attempt: claimed.attempt,
599
+ sourceEventId: claimed.sourceEventId,
600
+ };
601
+ const lease = {
602
+ workId: claimed.workId,
603
+ queueName: claimed.queueName,
604
+ sourceEventId: claimed.sourceEventId,
605
+ attempt: claimed.attempt,
606
+ leaseId: claimed.leaseId,
607
+ leaseAcquiredAtMs: claimed.leaseAcquiredAtMs,
608
+ leaseExpiresAtMs: claimed.leaseExpiresAtMs,
609
+ signal: leaseAbortController.signal,
610
+ hold: () => {
611
+ if (leaseAbortController.signal.aborted) {
612
+ throw new Error("cannot hold aborted lease");
613
+ }
614
+ activeLeaseHolds += 1;
615
+ if (activeLeaseHolds === 1) {
616
+ startLeaseHeartbeat();
617
+ }
618
+ let disposed = false;
619
+ return {
620
+ signal: leaseAbortController.signal,
621
+ [Symbol.asyncDispose]: async () => {
622
+ if (disposed) {
623
+ return;
624
+ }
625
+ disposed = true;
626
+ activeLeaseHolds = Math.max(0, activeLeaseHolds - 1);
627
+ if (activeLeaseHolds === 0) {
628
+ stopLeaseHeartbeat();
629
+ }
630
+ },
631
+ };
632
+ },
633
+ };
634
+ const stagedEvents = [];
635
+ const actions = {
636
+ emit: (eventName, event, options) => {
637
+ stagedEvents.push({
638
+ eventName: String(eventName),
639
+ payload: event,
640
+ nowMs: clock.nowMs(),
641
+ dedupeKey: options?.dedupeKey,
642
+ causationEventId: claimed.sourceEventId,
643
+ });
644
+ },
645
+ query: async (queryName, params) => {
646
+ const schema = model.queries[queryName];
647
+ const implementation = implementations.queries[queryName];
648
+ if (schema === undefined || implementation === undefined) {
649
+ throw new Error(`unknown query: ${String(queryName)}`);
650
+ }
651
+ const decodedParams = Value.Decode(schema.params, params);
652
+ const encodedParams = Value.Encode(schema.params, decodedParams);
653
+ const canonicalParams = Value.Decode(schema.params, encodedParams);
654
+ const rawResult = await implementation(canonicalParams);
655
+ const decodedResult = Value.Decode(schema.result, rawResult);
656
+ return decodedResult;
657
+ },
658
+ };
659
+ let outcome;
660
+ try {
661
+ outcome = await handler({
662
+ work,
663
+ lease,
664
+ actions,
665
+ });
666
+ }
667
+ catch (error) {
668
+ outcome = {
669
+ outcome: "retry",
670
+ error: error instanceof Error ? error.message : String(error),
671
+ };
672
+ }
673
+ await runInTransaction(async () => {
674
+ const active = await database
675
+ .prepare(`SELECT work_id
676
+ FROM work
677
+ WHERE work_id = ?
678
+ AND lease_id = ?
679
+ AND dead = 0`)
680
+ .get(claimed.workId, claimed.leaseId);
681
+ if (active === undefined) {
682
+ return;
683
+ }
684
+ for (const stagedEvent of stagedEvents) {
685
+ await appendEventInTransaction(stagedEvent);
686
+ }
687
+ switch (outcome.outcome) {
688
+ case "ack":
689
+ await database
690
+ .prepare(`DELETE FROM work
691
+ WHERE work_id = ?
692
+ AND lease_id = ?
693
+ AND dead = 0`)
694
+ .run(claimed.workId, claimed.leaseId);
695
+ break;
696
+ case "retry":
697
+ await database
698
+ .prepare(`UPDATE work
699
+ SET
700
+ available_at_ms = ?,
701
+ lease_id = NULL,
702
+ lease_acquired_at_ms = NULL,
703
+ lease_expires_at_ms = NULL,
704
+ last_error = ?
705
+ WHERE work_id = ?
706
+ AND lease_id = ?
707
+ AND dead = 0`)
708
+ .run(outcome.retryAtMs ?? clock.nowMs() + defaultRetryDelayMs, outcome.error, claimed.workId, claimed.leaseId);
709
+ break;
710
+ case "dead_letter":
711
+ await database
712
+ .prepare(`UPDATE work
713
+ SET
714
+ dead = 1,
715
+ lease_id = NULL,
716
+ lease_acquired_at_ms = NULL,
717
+ lease_expires_at_ms = NULL,
718
+ last_error = ?
719
+ WHERE work_id = ?
720
+ AND lease_id = ?
721
+ AND dead = 0`)
722
+ .run(outcome.error, claimed.workId, claimed.leaseId);
723
+ break;
724
+ }
725
+ });
726
+ stopLeaseHeartbeat();
727
+ leaseExpiryTasks.get(claimed.leaseId)?.cancel();
728
+ leaseExpiryTasks.delete(claimed.leaseId);
729
+ leaseAbortControllers.delete(claimed.leaseId);
730
+ scheduleDispatchAt(clock.nowMs());
731
+ }
732
+ async function close() {
733
+ if (closed) {
734
+ return;
735
+ }
736
+ closed = true;
737
+ scheduledDispatch?.cancel();
738
+ scheduledDispatch = null;
739
+ for (const expiryTask of leaseExpiryTasks.values()) {
740
+ expiryTask.cancel();
741
+ }
742
+ leaseExpiryTasks.clear();
743
+ for (const heartbeatTask of leaseHeartbeatTasks.values()) {
744
+ heartbeatTask.cancel();
745
+ }
746
+ leaseHeartbeatTasks.clear();
747
+ for (const controller of leaseAbortControllers.values()) {
748
+ controller.abort(new Error("ledger closed"));
749
+ }
750
+ leaseAbortControllers.clear();
751
+ await Promise.allSettled(inFlight);
752
+ await runInTransaction(async () => {
753
+ await database
754
+ .prepare(`UPDATE work
755
+ SET
756
+ lease_id = NULL,
757
+ lease_acquired_at_ms = NULL,
758
+ lease_expires_at_ms = NULL,
759
+ available_at_ms = ?
760
+ WHERE dead = 0
761
+ AND lease_id IS NOT NULL`)
762
+ .run(clock.nowMs());
763
+ });
764
+ }
765
+ const ledger = {
766
+ emit: async (eventName, event, options) => {
767
+ await startup;
768
+ const result = await runInTransaction(async () => await appendEventInTransaction({
769
+ eventName: String(eventName),
770
+ payload: event,
771
+ nowMs: clock.nowMs(),
772
+ dedupeKey: options?.dedupeKey,
773
+ causationEventId: null,
774
+ }));
775
+ if (result.created) {
776
+ scheduleDispatchAt(clock.nowMs());
777
+ }
778
+ },
779
+ query: async (queryName, params) => {
780
+ await startup;
781
+ const schema = model.queries[queryName];
782
+ const implementation = implementations.queries[queryName];
783
+ if (schema === undefined || implementation === undefined) {
784
+ throw new Error(`unknown query: ${String(queryName)}`);
785
+ }
786
+ const decodedParams = Value.Decode(schema.params, params);
787
+ const encodedParams = Value.Encode(schema.params, decodedParams);
788
+ const canonicalParams = Value.Decode(schema.params, encodedParams);
789
+ const rawResult = await implementation(canonicalParams);
790
+ const decodedResult = Value.Decode(schema.result, rawResult);
791
+ return decodedResult;
792
+ },
793
+ close,
794
+ [Symbol.asyncDispose]: close,
795
+ };
796
+ return ledger;
797
+ }
798
+ //# sourceMappingURL=database-ledger-engine.js.map