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