@absurd-sqlite/bun-worker 0.1.0-alpha.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/test/setup.ts ADDED
@@ -0,0 +1,650 @@
1
+ import { afterAll } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import {
8
+ Absurd,
9
+ type AbsurdHooks,
10
+ type JsonValue,
11
+ } from "absurd-sdk";
12
+
13
+ import { BunSqliteConnection } from "../src/sqlite";
14
+
15
+ configureBunSqlite();
16
+
17
+ // Database row types matching the SQLite schema
18
+ export interface TaskRow {
19
+ task_id: string;
20
+ task_name: string;
21
+ params: JsonValue;
22
+ headers: JsonValue | null;
23
+ retry_strategy: JsonValue | null;
24
+ max_attempts: number | null;
25
+ cancellation: JsonValue | null;
26
+ enqueue_at: Date;
27
+ first_started_at: Date | null;
28
+ state:
29
+ | "pending"
30
+ | "running"
31
+ | "sleeping"
32
+ | "completed"
33
+ | "failed"
34
+ | "cancelled";
35
+ attempts: number;
36
+ last_attempt_run: string | null;
37
+ completed_payload: JsonValue | null;
38
+ cancelled_at: Date | null;
39
+ }
40
+
41
+ export interface RunRow {
42
+ run_id: string;
43
+ task_id: string;
44
+ attempt: number;
45
+ state:
46
+ | "pending"
47
+ | "running"
48
+ | "sleeping"
49
+ | "completed"
50
+ | "failed"
51
+ | "cancelled";
52
+ claimed_by: string | null;
53
+ claim_expires_at: Date | null;
54
+ available_at: Date;
55
+ wake_event: string | null;
56
+ event_payload: JsonValue | null;
57
+ started_at: Date | null;
58
+ completed_at: Date | null;
59
+ failed_at: Date | null;
60
+ result: JsonValue | null;
61
+ failure_reason: JsonValue | null;
62
+ created_at: Date;
63
+ }
64
+
65
+ interface SqliteFixture {
66
+ db: Database;
67
+ conn: BunSqliteConnection;
68
+ dbPath: string;
69
+ cleanup: () => void;
70
+ }
71
+
72
+ const fixtures: SqliteFixture[] = [];
73
+
74
+ afterAll(() => {
75
+ for (const fixture of fixtures) {
76
+ fixture.db.close();
77
+ fixture.cleanup();
78
+ }
79
+ fixtures.length = 0;
80
+ });
81
+
82
+ export interface TestContext {
83
+ absurd: Absurd;
84
+ pool: BunSqliteConnection;
85
+ queueName: string;
86
+ dbPath: string;
87
+ cleanupTasks(): Promise<void>;
88
+ getQueueStorageState(
89
+ queueName: string
90
+ ): Promise<{ exists: boolean; tables: string[] }>;
91
+ getTask(taskID: string): Promise<TaskRow | null>;
92
+ getRun(runID: string): Promise<RunRow | null>;
93
+ getRuns(taskID: string): Promise<RunRow[]>;
94
+ setFakeNow(ts: Date | null): Promise<void>;
95
+ sleep(ms: number): Promise<void>;
96
+ getRemainingTasksCount(): Promise<number>;
97
+ getRemainingEventsCount(): Promise<number>;
98
+ getWaitsCount(): Promise<number>;
99
+ getCheckpoint(
100
+ taskID: string,
101
+ checkpointName: string
102
+ ): Promise<{
103
+ checkpoint_name: string;
104
+ state: JsonValue;
105
+ owner_run_id: string;
106
+ } | null>;
107
+ scheduleRun(runID: string, wakeAt: Date): Promise<void>;
108
+ completeRun(runID: string, payload: JsonValue): Promise<void>;
109
+ cleanupTasksByTTL(ttlSeconds: number, limit: number): Promise<number>;
110
+ cleanupEventsByTTL(ttlSeconds: number, limit: number): Promise<number>;
111
+ setTaskCheckpointState(
112
+ taskID: string,
113
+ stepName: string,
114
+ state: JsonValue,
115
+ runID: string,
116
+ extendClaimBySeconds: number | null
117
+ ): Promise<void>;
118
+ awaitEventInternal(
119
+ taskID: string,
120
+ runID: string,
121
+ stepName: string,
122
+ eventName: string,
123
+ timeoutSeconds: number | null
124
+ ): Promise<void>;
125
+ extendClaim(runID: string, extendBySeconds: number): Promise<void>;
126
+ expectCancelledError(promise: Promise<unknown>): Promise<void>;
127
+ createClient(options?: { queueName?: string; hooks?: AbsurdHooks }): Absurd;
128
+ }
129
+
130
+ export function randomName(prefix = "test"): string {
131
+ return `${prefix}_${Math.random().toString(36).substring(7)}`;
132
+ }
133
+
134
+ const testDir = fileURLToPath(new URL(".", import.meta.url));
135
+ const repoRoot = join(testDir, "../../..");
136
+ const extensionBase = join(repoRoot, "target/release/libabsurd");
137
+
138
+ function resolveExtensionPath(base: string): string {
139
+ const platformExt =
140
+ process.platform === "win32"
141
+ ? ".dll"
142
+ : process.platform === "darwin"
143
+ ? ".dylib"
144
+ : ".so";
145
+ const candidates = buildExtensionCandidates(base, platformExt);
146
+ for (const candidate of candidates) {
147
+ if (existsSync(candidate)) {
148
+ return candidate;
149
+ }
150
+ }
151
+ throw new Error(
152
+ `SQLite extension not found at ${base} (expected ${platformExt})`
153
+ );
154
+ }
155
+
156
+ let cachedExtensionPath: string | null = null;
157
+
158
+ function getExtensionPath(): string {
159
+ if (!cachedExtensionPath) {
160
+ cachedExtensionPath = resolveExtensionPath(extensionBase);
161
+ }
162
+ return cachedExtensionPath;
163
+ }
164
+
165
+ export function loadExtension(db: Database): void {
166
+ (db as unknown as { loadExtension(path: string): void }).loadExtension(
167
+ getExtensionPath()
168
+ );
169
+ }
170
+
171
+ function createFixture(): SqliteFixture {
172
+ const tempDir = mkdtempSync(join(tmpdir(), "absurd-sqlite-"));
173
+ const dbPath = join(tempDir, "absurd.db");
174
+ const db = new Database(dbPath);
175
+ loadExtension(db);
176
+ db.query("select absurd_apply_migrations()").get();
177
+ const conn = new BunSqliteConnection(db);
178
+
179
+ const cleanup = () => {
180
+ rmSync(tempDir, { recursive: true, force: true });
181
+ };
182
+
183
+ const fixture = { db, conn, dbPath, cleanup };
184
+ fixtures.push(fixture);
185
+ return fixture;
186
+ }
187
+
188
+ function buildExtensionCandidates(base: string, platformExt: string): string[] {
189
+ const candidates = new Set<string>();
190
+ const envPath = process.env.ABSURD_SQLITE_EXTENSION_PATH;
191
+ if (envPath) {
192
+ candidates.add(envPath);
193
+ }
194
+ candidates.add(base);
195
+ candidates.add(`${base}${platformExt}`);
196
+
197
+ return Array.from(candidates);
198
+ }
199
+
200
+ function configureBunSqlite(): void {
201
+ if (process.platform !== "darwin") {
202
+ return;
203
+ }
204
+
205
+ const customSQLite = resolveCustomSQLitePath();
206
+ if (!customSQLite) {
207
+ throw new Error(
208
+ "Bun's SQLite build on macOS does not support extensions. " +
209
+ "Install sqlite via Homebrew and set ABSURD_SQLITE_CUSTOM_SQLITE_PATH " +
210
+ "to the libsqlite3.dylib path."
211
+ );
212
+ }
213
+ Database.setCustomSQLite(customSQLite);
214
+ }
215
+
216
+ function resolveCustomSQLitePath(): string | null {
217
+ const envPath = process.env.ABSURD_SQLITE_CUSTOM_SQLITE_PATH;
218
+ if (envPath && existsSync(envPath)) {
219
+ return envPath;
220
+ }
221
+
222
+ const prefixes = [
223
+ process.env.HOMEBREW_PREFIX,
224
+ "/opt/homebrew",
225
+ "/usr/local",
226
+ ].filter(Boolean) as string[];
227
+
228
+ for (const prefix of prefixes) {
229
+ const optPath = join(prefix, "opt", "sqlite", "lib", "libsqlite3.dylib");
230
+ if (existsSync(optPath)) {
231
+ return optPath;
232
+ }
233
+
234
+ const cellarPath = join(prefix, "Cellar", "sqlite");
235
+ if (!existsSync(cellarPath)) {
236
+ continue;
237
+ }
238
+ try {
239
+ const entries = readdirSync(cellarPath, { withFileTypes: true });
240
+ for (const entry of entries) {
241
+ if (!entry.isDirectory()) {
242
+ continue;
243
+ }
244
+ const candidate = join(
245
+ cellarPath,
246
+ entry.name,
247
+ "lib",
248
+ "libsqlite3.dylib"
249
+ );
250
+ if (existsSync(candidate)) {
251
+ return candidate;
252
+ }
253
+ }
254
+ } catch {
255
+ // fall through to other prefixes
256
+ }
257
+ }
258
+
259
+ return null;
260
+ }
261
+
262
+ export async function createTestAbsurd(
263
+ queueName: string = "default"
264
+ ): Promise<TestContext> {
265
+ const fixture = createFixture();
266
+ const absurd = new Absurd({
267
+ db: fixture.conn,
268
+ queueName,
269
+ });
270
+
271
+ await absurd.createQueue(queueName);
272
+
273
+ return {
274
+ absurd,
275
+ pool: fixture.conn,
276
+ queueName,
277
+ dbPath: fixture.dbPath,
278
+ cleanupTasks: () => cleanupTasks(fixture.conn, queueName),
279
+ getQueueStorageState: (targetQueueName: string) =>
280
+ getQueueStorageState(fixture.conn, targetQueueName),
281
+ getTask: (taskID: string) => getTask(fixture.conn, taskID, queueName),
282
+ getRun: (runID: string) => getRun(fixture.conn, runID, queueName),
283
+ getRuns: (taskID: string) => getRuns(fixture.conn, taskID, queueName),
284
+ setFakeNow: (ts: Date | null) => setFakeNow(fixture.conn, ts),
285
+ sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
286
+ getRemainingTasksCount: () =>
287
+ getRemainingTasksCount(fixture.conn, queueName),
288
+ getRemainingEventsCount: () =>
289
+ getRemainingEventsCount(fixture.conn, queueName),
290
+ getWaitsCount: () => getWaitsCount(fixture.conn, queueName),
291
+ getCheckpoint: (taskID: string, checkpointName: string) =>
292
+ getCheckpoint(fixture.conn, taskID, checkpointName, queueName),
293
+ scheduleRun: (runID: string, wakeAt: Date) =>
294
+ scheduleRun(fixture.conn, runID, wakeAt, queueName),
295
+ completeRun: (runID: string, payload: JsonValue) =>
296
+ completeRun(fixture.conn, runID, payload, queueName),
297
+ cleanupTasksByTTL: (ttlSeconds: number, limit: number) =>
298
+ cleanupTasksByTTL(fixture.conn, ttlSeconds, limit, queueName),
299
+ cleanupEventsByTTL: (ttlSeconds: number, limit: number) =>
300
+ cleanupEventsByTTL(fixture.conn, ttlSeconds, limit, queueName),
301
+ setTaskCheckpointState: (
302
+ taskID: string,
303
+ stepName: string,
304
+ state: JsonValue,
305
+ runID: string,
306
+ extendClaimBySeconds: number | null
307
+ ) =>
308
+ setTaskCheckpointState(
309
+ fixture.conn,
310
+ taskID,
311
+ stepName,
312
+ state,
313
+ runID,
314
+ extendClaimBySeconds,
315
+ queueName
316
+ ),
317
+ awaitEventInternal: (
318
+ taskID: string,
319
+ runID: string,
320
+ stepName: string,
321
+ eventName: string,
322
+ timeoutSeconds: number | null
323
+ ) =>
324
+ awaitEventInternal(
325
+ fixture.conn,
326
+ taskID,
327
+ runID,
328
+ stepName,
329
+ eventName,
330
+ timeoutSeconds,
331
+ queueName
332
+ ),
333
+ extendClaim: (runID: string, extendBySeconds: number) =>
334
+ extendClaim(fixture.conn, runID, extendBySeconds, queueName),
335
+ expectCancelledError: (promise: Promise<unknown>) =>
336
+ expectCancelledError(promise),
337
+ createClient: (options) => {
338
+ const client = new Absurd({
339
+ db: fixture.conn,
340
+ queueName: options?.queueName ?? queueName,
341
+ hooks: options?.hooks,
342
+ });
343
+ return client;
344
+ },
345
+ };
346
+ }
347
+
348
+ async function setFakeNow(
349
+ conn: BunSqliteConnection,
350
+ ts: Date | null
351
+ ): Promise<void> {
352
+ if (ts === null) {
353
+ await conn.exec("select absurd.set_fake_now(null)");
354
+ return;
355
+ }
356
+ await conn.exec("select absurd.set_fake_now($1)", [ts.getTime()]);
357
+ }
358
+
359
+ async function cleanupTasks(
360
+ conn: BunSqliteConnection,
361
+ queue: string
362
+ ): Promise<void> {
363
+ const tables = [
364
+ "absurd_tasks",
365
+ "absurd_runs",
366
+ "absurd_events",
367
+ "absurd_waits",
368
+ "absurd_checkpoints",
369
+ ];
370
+ for (const table of tables) {
371
+ await conn.exec(`DELETE FROM ${table} WHERE queue_name = $1`, [queue]);
372
+ }
373
+ }
374
+
375
+ async function getQueueStorageState(
376
+ conn: BunSqliteConnection,
377
+ queue: string
378
+ ): Promise<{ exists: boolean; tables: string[] }> {
379
+ const { rows } = await conn.query<{ count: number }>(
380
+ `SELECT COUNT(*) AS count FROM absurd_queues WHERE queue_name = $1`,
381
+ [queue]
382
+ );
383
+ const tableRows = await conn.query<{ name: string }>(
384
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'absurd_%'`
385
+ );
386
+ return {
387
+ exists: rows[0]?.count > 0,
388
+ tables: tableRows.rows.map((row) => row.name),
389
+ };
390
+ }
391
+
392
+ async function getTask(
393
+ conn: BunSqliteConnection,
394
+ taskID: string,
395
+ queue: string
396
+ ): Promise<TaskRow | null> {
397
+ const { rows } = await conn.query<TaskRow>(
398
+ `SELECT task_id,
399
+ task_name,
400
+ json(params) as params,
401
+ json(headers) as headers,
402
+ json(retry_strategy) as retry_strategy,
403
+ max_attempts,
404
+ json(cancellation) as cancellation,
405
+ enqueue_at,
406
+ first_started_at,
407
+ state,
408
+ attempts,
409
+ last_attempt_run,
410
+ json(completed_payload) as completed_payload,
411
+ cancelled_at
412
+ FROM absurd_tasks
413
+ WHERE task_id = $1 AND queue_name = $2`,
414
+ [taskID, queue]
415
+ );
416
+ return rows.length > 0 ? rows[0] : null;
417
+ }
418
+
419
+ async function getRun(
420
+ conn: BunSqliteConnection,
421
+ runID: string,
422
+ queue: string
423
+ ): Promise<RunRow | null> {
424
+ const { rows } = await conn.query<RunRow>(
425
+ `SELECT run_id,
426
+ task_id,
427
+ attempt,
428
+ state,
429
+ claimed_by,
430
+ claim_expires_at,
431
+ available_at,
432
+ wake_event,
433
+ json(event_payload) as event_payload,
434
+ started_at,
435
+ completed_at,
436
+ failed_at,
437
+ json(result) as result,
438
+ json(failure_reason) as failure_reason,
439
+ created_at
440
+ FROM absurd_runs
441
+ WHERE run_id = $1 AND queue_name = $2`,
442
+ [runID, queue]
443
+ );
444
+ return rows.length > 0 ? rows[0] : null;
445
+ }
446
+
447
+ async function getRuns(
448
+ conn: BunSqliteConnection,
449
+ taskID: string,
450
+ queue: string
451
+ ): Promise<RunRow[]> {
452
+ const { rows } = await conn.query<RunRow>(
453
+ `SELECT run_id,
454
+ task_id,
455
+ attempt,
456
+ state,
457
+ claimed_by,
458
+ claim_expires_at,
459
+ available_at,
460
+ wake_event,
461
+ json(event_payload) as event_payload,
462
+ started_at,
463
+ completed_at,
464
+ failed_at,
465
+ json(result) as result,
466
+ json(failure_reason) as failure_reason,
467
+ created_at
468
+ FROM absurd_runs
469
+ WHERE task_id = $1 AND queue_name = $2
470
+ ORDER BY attempt`,
471
+ [taskID, queue]
472
+ );
473
+ return rows;
474
+ }
475
+
476
+ async function getRemainingTasksCount(
477
+ conn: BunSqliteConnection,
478
+ queue: string
479
+ ): Promise<number> {
480
+ const { rows } = await conn.query<{ count: number }>(
481
+ `SELECT COUNT(*) AS count FROM absurd_tasks WHERE queue_name = $1`,
482
+ [queue]
483
+ );
484
+ return Number(rows[0]?.count ?? 0);
485
+ }
486
+
487
+ async function getRemainingEventsCount(
488
+ conn: BunSqliteConnection,
489
+ queue: string
490
+ ): Promise<number> {
491
+ const { rows } = await conn.query<{ count: number }>(
492
+ `SELECT COUNT(*) AS count FROM absurd_events WHERE queue_name = $1`,
493
+ [queue]
494
+ );
495
+ return Number(rows[0]?.count ?? 0);
496
+ }
497
+
498
+ async function getWaitsCount(
499
+ conn: BunSqliteConnection,
500
+ queue: string
501
+ ): Promise<number> {
502
+ const { rows } = await conn.query<{ count: number }>(
503
+ `SELECT COUNT(*) AS count FROM absurd_waits WHERE queue_name = $1`,
504
+ [queue]
505
+ );
506
+ return Number(rows[0]?.count ?? 0);
507
+ }
508
+
509
+ async function getCheckpoint(
510
+ conn: BunSqliteConnection,
511
+ taskID: string,
512
+ checkpointName: string,
513
+ queue: string
514
+ ): Promise<{
515
+ checkpoint_name: string;
516
+ state: JsonValue;
517
+ owner_run_id: string;
518
+ } | null> {
519
+ const { rows } = await conn.query<{
520
+ checkpoint_name: string;
521
+ state: JsonValue;
522
+ owner_run_id: string;
523
+ }>(
524
+ `SELECT checkpoint_name,
525
+ json(state) as state,
526
+ owner_run_id
527
+ FROM absurd_checkpoints
528
+ WHERE task_id = $1
529
+ AND checkpoint_name = $2
530
+ AND queue_name = $3`,
531
+ [taskID, checkpointName, queue]
532
+ );
533
+ return rows.length > 0 ? rows[0] : null;
534
+ }
535
+
536
+ async function scheduleRun(
537
+ conn: BunSqliteConnection,
538
+ runID: string,
539
+ wakeAt: Date,
540
+ queue: string
541
+ ): Promise<void> {
542
+ await conn.exec(`SELECT absurd.schedule_run($1, $2, $3)`, [
543
+ queue,
544
+ runID,
545
+ wakeAt,
546
+ ]);
547
+ }
548
+
549
+ async function completeRun(
550
+ conn: BunSqliteConnection,
551
+ runID: string,
552
+ payload: JsonValue,
553
+ queue: string
554
+ ): Promise<void> {
555
+ await conn.exec(`SELECT absurd.complete_run($1, $2, $3)`, [
556
+ queue,
557
+ runID,
558
+ JSON.stringify(payload),
559
+ ]);
560
+ }
561
+
562
+ async function cleanupTasksByTTL(
563
+ conn: BunSqliteConnection,
564
+ ttlSeconds: number,
565
+ limit: number,
566
+ queue: string
567
+ ): Promise<number> {
568
+ const { rows } = await conn.query<{ count: number }>(
569
+ `SELECT absurd.cleanup_tasks($1, $2, $3) AS count`,
570
+ [queue, ttlSeconds, limit]
571
+ );
572
+ return Number(rows[0]?.count ?? 0);
573
+ }
574
+
575
+ async function cleanupEventsByTTL(
576
+ conn: BunSqliteConnection,
577
+ ttlSeconds: number,
578
+ limit: number,
579
+ queue: string
580
+ ): Promise<number> {
581
+ const { rows } = await conn.query<{ count: number }>(
582
+ `SELECT absurd.cleanup_events($1, $2, $3) AS count`,
583
+ [queue, ttlSeconds, limit]
584
+ );
585
+ return Number(rows[0]?.count ?? 0);
586
+ }
587
+
588
+ async function setTaskCheckpointState(
589
+ conn: BunSqliteConnection,
590
+ taskID: string,
591
+ stepName: string,
592
+ state: JsonValue,
593
+ runID: string,
594
+ extendClaimBySeconds: number | null,
595
+ queue: string
596
+ ): Promise<void> {
597
+ await conn.exec(
598
+ `SELECT absurd.set_task_checkpoint_state($1, $2, $3, $4, $5, $6)`,
599
+ [
600
+ queue,
601
+ taskID,
602
+ stepName,
603
+ JSON.stringify(state),
604
+ runID,
605
+ extendClaimBySeconds,
606
+ ]
607
+ );
608
+ }
609
+
610
+ async function awaitEventInternal(
611
+ conn: BunSqliteConnection,
612
+ taskID: string,
613
+ runID: string,
614
+ stepName: string,
615
+ eventName: string,
616
+ timeoutSeconds: number | null,
617
+ queue: string
618
+ ): Promise<void> {
619
+ await conn.query(
620
+ `SELECT should_suspend, json(payload) as payload
621
+ FROM absurd.await_event($1, $2, $3, $4, $5, $6)`,
622
+ [queue, taskID, runID, stepName, eventName, timeoutSeconds]
623
+ );
624
+ }
625
+
626
+ async function extendClaim(
627
+ conn: BunSqliteConnection,
628
+ runID: string,
629
+ extendBySeconds: number,
630
+ queue: string
631
+ ): Promise<void> {
632
+ await conn.exec(`SELECT absurd.extend_claim($1, $2, $3)`, [
633
+ queue,
634
+ runID,
635
+ extendBySeconds,
636
+ ]);
637
+ }
638
+
639
+ async function expectCancelledError(promise: Promise<unknown>): Promise<void> {
640
+ try {
641
+ await promise;
642
+ } catch (err: any) {
643
+ const message = String(err?.message ?? "");
644
+ if (message.toLowerCase().includes("cancelled")) {
645
+ return;
646
+ }
647
+ throw err;
648
+ }
649
+ throw new Error("Expected cancellation error");
650
+ }