@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.
@@ -0,0 +1,347 @@
1
+ import { describe, test, expect, beforeAll, afterEach } from "vitest";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { createTestAbsurd, randomName, type TestContext } from "./setup.js";
4
+ import { type Absurd, type SpawnOptions } from "../src/index.js";
5
+
6
+ describe("Hooks", () => {
7
+ let ctx: TestContext;
8
+ let queueName: string;
9
+
10
+ beforeAll(async () => {
11
+ ctx = await createTestAbsurd(randomName("hooks_queue"));
12
+ queueName = ctx.queueName;
13
+ });
14
+
15
+ afterEach(async () => {
16
+ await ctx.cleanupTasks();
17
+ await ctx.setFakeNow(null);
18
+ });
19
+
20
+ describe("beforeSpawn hook", () => {
21
+ test("can inject headers before spawn", async () => {
22
+ const injectedHeaders: SpawnOptions["headers"][] = [];
23
+
24
+ const absurd = ctx.createClient({
25
+ queueName,
26
+ hooks: {
27
+ beforeSpawn: (_taskName, _params, options) => {
28
+ return {
29
+ ...options,
30
+ headers: {
31
+ ...options.headers,
32
+ traceId: "trace-123",
33
+ correlationId: "corr-456",
34
+ },
35
+ };
36
+ },
37
+ },
38
+ });
39
+
40
+ let capturedHeaders: any = null;
41
+ absurd.registerTask({ name: "capture-headers" }, async (_params, ctx) => {
42
+ capturedHeaders = ctx.headers;
43
+ return "done";
44
+ });
45
+
46
+ await absurd.spawn("capture-headers", { test: true });
47
+ await absurd.workBatch("worker1", 60, 1);
48
+
49
+ expect(capturedHeaders).toEqual({
50
+ traceId: "trace-123",
51
+ correlationId: "corr-456",
52
+ });
53
+ });
54
+
55
+ test("can use async beforeSpawn hook", async () => {
56
+ const absurd = ctx.createClient({
57
+ queueName,
58
+ hooks: {
59
+ beforeSpawn: async (_taskName, _params, options) => {
60
+ // Simulate async operation (e.g., fetching context)
61
+ await new Promise((resolve) => setTimeout(resolve, 10));
62
+ return {
63
+ ...options,
64
+ headers: {
65
+ ...options.headers,
66
+ asyncHeader: "fetched-value",
67
+ },
68
+ };
69
+ },
70
+ },
71
+ });
72
+
73
+ let capturedHeader: any = null;
74
+ absurd.registerTask({ name: "async-header" }, async (_params, ctx) => {
75
+ capturedHeader = ctx.headers["asyncHeader"];
76
+ return "done";
77
+ });
78
+
79
+ await absurd.spawn("async-header", {});
80
+ await absurd.workBatch("worker1", 60, 1);
81
+
82
+ expect(capturedHeader).toBe("fetched-value");
83
+ });
84
+
85
+ test("preserves existing headers when adding new ones", async () => {
86
+ const absurd = ctx.createClient({
87
+ queueName,
88
+ hooks: {
89
+ beforeSpawn: (_taskName, _params, options) => {
90
+ return {
91
+ ...options,
92
+ headers: {
93
+ ...options.headers,
94
+ injected: "by-hook",
95
+ },
96
+ };
97
+ },
98
+ },
99
+ });
100
+
101
+ let capturedHeaders: any = null;
102
+ absurd.registerTask({ name: "merge-headers" }, async (_params, ctx) => {
103
+ capturedHeaders = ctx.headers;
104
+ return "done";
105
+ });
106
+
107
+ await absurd.spawn(
108
+ "merge-headers",
109
+ {},
110
+ {
111
+ headers: { existing: "user-provided" },
112
+ }
113
+ );
114
+ await absurd.workBatch("worker1", 60, 1);
115
+
116
+ expect(capturedHeaders).toEqual({
117
+ existing: "user-provided",
118
+ injected: "by-hook",
119
+ });
120
+ });
121
+ });
122
+
123
+ describe("wrapTaskExecution hook", () => {
124
+ test("wraps task execution", async () => {
125
+ const executionOrder: string[] = [];
126
+
127
+ const absurd = ctx.createClient({
128
+ queueName,
129
+ hooks: {
130
+ wrapTaskExecution: async (_ctx, execute) => {
131
+ executionOrder.push("before");
132
+ const result = await execute();
133
+ executionOrder.push("after");
134
+ return result;
135
+ },
136
+ },
137
+ });
138
+
139
+ absurd.registerTask({ name: "wrapped-task" }, async () => {
140
+ executionOrder.push("handler");
141
+ return "done";
142
+ });
143
+
144
+ await absurd.spawn("wrapped-task", {});
145
+ await absurd.workBatch("worker1", 60, 1);
146
+
147
+ expect(executionOrder).toEqual(["before", "handler", "after"]);
148
+ });
149
+
150
+ test("provides TaskContext to wrapper", async () => {
151
+ let capturedTaskId: string | null = null;
152
+ let capturedHeaders: any = null;
153
+
154
+ const absurd = ctx.createClient({
155
+ queueName,
156
+ hooks: {
157
+ beforeSpawn: (_taskName, _params, options) => ({
158
+ ...options,
159
+ headers: { traceId: "from-spawn" },
160
+ }),
161
+ wrapTaskExecution: async (ctx, execute) => {
162
+ capturedTaskId = ctx.taskID;
163
+ capturedHeaders = ctx.headers;
164
+ return execute();
165
+ },
166
+ },
167
+ });
168
+
169
+ absurd.registerTask({ name: "ctx-in-wrapper" }, async () => "done");
170
+
171
+ const { taskID } = await absurd.spawn("ctx-in-wrapper", {});
172
+ await absurd.workBatch("worker1", 60, 1);
173
+
174
+ expect(capturedTaskId).toBe(taskID);
175
+ expect(capturedHeaders).toEqual({ traceId: "from-spawn" });
176
+ });
177
+ });
178
+
179
+ describe("AsyncLocalStorage integration", () => {
180
+ test("full round-trip: inject context on spawn, restore on execution", async () => {
181
+ interface TraceContext {
182
+ traceId: string;
183
+ spanId: string;
184
+ }
185
+ const als = new AsyncLocalStorage<TraceContext>();
186
+
187
+ const absurd = ctx.createClient({
188
+ queueName,
189
+ hooks: {
190
+ beforeSpawn: (_taskName, _params, options) => {
191
+ const store = als.getStore();
192
+ if (store) {
193
+ return {
194
+ ...options,
195
+ headers: {
196
+ ...options.headers,
197
+ traceId: store.traceId,
198
+ spanId: store.spanId,
199
+ },
200
+ };
201
+ }
202
+ return options;
203
+ },
204
+ wrapTaskExecution: async (ctx, execute) => {
205
+ const traceId = ctx.headers["traceId"] as string | undefined;
206
+ const spanId = ctx.headers["spanId"] as string | undefined;
207
+ if (traceId && spanId) {
208
+ return als.run({ traceId, spanId }, execute);
209
+ }
210
+ return execute();
211
+ },
212
+ },
213
+ });
214
+
215
+ let capturedInHandler: TraceContext | undefined;
216
+ absurd.registerTask({ name: "als-test" }, async () => {
217
+ capturedInHandler = als.getStore();
218
+ return "done";
219
+ });
220
+
221
+ // Spawn within an ALS context
222
+ await als.run({ traceId: "trace-abc", spanId: "span-xyz" }, async () => {
223
+ await absurd.spawn("als-test", {});
224
+ });
225
+
226
+ // Execute the task (outside original ALS context)
227
+ await absurd.workBatch("worker1", 60, 1);
228
+
229
+ // Handler should have received the context via wrapTaskExecution
230
+ expect(capturedInHandler).toEqual({
231
+ traceId: "trace-abc",
232
+ spanId: "span-xyz",
233
+ });
234
+ });
235
+
236
+ test("child spawns inherit context from parent task", async () => {
237
+ interface TraceContext {
238
+ traceId: string;
239
+ }
240
+ const als = new AsyncLocalStorage<TraceContext>();
241
+
242
+ const absurd = ctx.createClient({
243
+ queueName,
244
+ hooks: {
245
+ beforeSpawn: (_taskName, _params, options) => {
246
+ const store = als.getStore();
247
+ if (store) {
248
+ return {
249
+ ...options,
250
+ headers: {
251
+ ...options.headers,
252
+ traceId: store.traceId,
253
+ },
254
+ };
255
+ }
256
+ return options;
257
+ },
258
+ wrapTaskExecution: async (ctx, execute) => {
259
+ const traceId = ctx.headers["traceId"] as string | undefined;
260
+ if (traceId) {
261
+ return als.run({ traceId }, execute);
262
+ }
263
+ return execute();
264
+ },
265
+ },
266
+ });
267
+
268
+ let childTraceId: string | undefined;
269
+
270
+ absurd.registerTask({ name: "parent-task" }, async (_params, _ctx) => {
271
+ // Spawn child task - should inherit trace context via beforeSpawn hook
272
+ await absurd.spawn("child-task", {});
273
+ return "parent-done";
274
+ });
275
+
276
+ absurd.registerTask({ name: "child-task" }, async (_params, ctx) => {
277
+ childTraceId = ctx.headers["traceId"] as string | undefined;
278
+ return "child-done";
279
+ });
280
+
281
+ // Spawn parent within ALS context
282
+ await als.run({ traceId: "parent-trace" }, async () => {
283
+ await absurd.spawn("parent-task", {});
284
+ });
285
+
286
+ // Execute parent task
287
+ await absurd.workBatch("worker1", 60, 1);
288
+ // Execute child task
289
+ await absurd.workBatch("worker1", 60, 1);
290
+
291
+ expect(childTraceId).toBe("parent-trace");
292
+ });
293
+ });
294
+
295
+ describe("TaskContext header accessors", () => {
296
+ test("headers returns undefined for missing key", async () => {
297
+ const absurd = ctx.createClient({ queueName });
298
+
299
+ let result: any;
300
+ absurd.registerTask({ name: "no-headers" }, async (_params, ctx) => {
301
+ result = ctx.headers["nonexistent"];
302
+ return "done";
303
+ });
304
+
305
+ await absurd.spawn("no-headers", {});
306
+ await absurd.workBatch("worker1", 60, 1);
307
+
308
+ expect(result).toBeUndefined();
309
+ });
310
+
311
+ test("headers getter returns empty object when no headers set", async () => {
312
+ const absurd = ctx.createClient({ queueName });
313
+
314
+ let result: any;
315
+ absurd.registerTask({ name: "empty-headers" }, async (_params, ctx) => {
316
+ result = ctx.headers;
317
+ return "done";
318
+ });
319
+
320
+ await absurd.spawn("empty-headers", {});
321
+ await absurd.workBatch("worker1", 60, 1);
322
+
323
+ expect(result).toEqual({});
324
+ });
325
+
326
+ test("headers getter returns all headers", async () => {
327
+ const absurd = ctx.createClient({ queueName });
328
+
329
+ let result: any;
330
+ absurd.registerTask({ name: "all-headers" }, async (_params, ctx) => {
331
+ result = ctx.headers;
332
+ return "done";
333
+ });
334
+
335
+ await absurd.spawn(
336
+ "all-headers",
337
+ {},
338
+ {
339
+ headers: { a: 1, b: "two", c: true },
340
+ }
341
+ );
342
+ await absurd.workBatch("worker1", 60, 1);
343
+
344
+ expect(result).toEqual({ a: 1, b: "two", c: true });
345
+ });
346
+ });
347
+ });
@@ -0,0 +1,195 @@
1
+ import { describe, test, expect, beforeAll, afterEach } from "vitest";
2
+ import { createTestAbsurd, randomName, type TestContext } from "./setup.js";
3
+ import type { Absurd } from "../src/index.js";
4
+
5
+ describe("Idempotent Task Spawning", () => {
6
+ let ctx: TestContext;
7
+ let absurd: Absurd;
8
+
9
+ beforeAll(async () => {
10
+ ctx = await createTestAbsurd(randomName("idempotent"));
11
+ absurd = ctx.absurd;
12
+ });
13
+
14
+ afterEach(async () => {
15
+ await ctx.cleanupTasks();
16
+ await ctx.setFakeNow(null);
17
+ });
18
+
19
+ describe("spawn with idempotencyKey", () => {
20
+ test("creates task when idempotencyKey is new", async () => {
21
+ absurd.registerTask({ name: "idempotent-task" }, async () => {
22
+ return { done: true };
23
+ });
24
+
25
+ const result = await absurd.spawn(
26
+ "idempotent-task",
27
+ { value: 42 },
28
+ { idempotencyKey: "unique-key-1" },
29
+ );
30
+
31
+ expect(result.taskID).toBeDefined();
32
+ expect(result.runID).toBeDefined();
33
+ expect(result.attempt).toBe(1);
34
+ expect(Boolean(result.created)).toBe(true);
35
+
36
+ const task = await ctx.getTask(result.taskID);
37
+ expect(task).toBeDefined();
38
+ expect(task?.state).toBe("pending");
39
+ });
40
+
41
+ test("returns existing task when idempotencyKey already exists", async () => {
42
+ absurd.registerTask({ name: "dup-task" }, async () => {
43
+ return { done: true };
44
+ });
45
+
46
+ const first = await absurd.spawn(
47
+ "dup-task",
48
+ { value: 1 },
49
+ { idempotencyKey: "dup-key" },
50
+ );
51
+
52
+ expect(first.taskID).toBeDefined();
53
+ expect(first.runID).toBeDefined();
54
+ expect(first.attempt).toBe(1);
55
+ expect(Boolean(first.created)).toBe(true);
56
+
57
+ const second = await absurd.spawn(
58
+ "dup-task",
59
+ { value: 2 },
60
+ { idempotencyKey: "dup-key" },
61
+ );
62
+
63
+ expect(second.taskID).toBe(first.taskID);
64
+ expect(second.runID).toBe(first.runID);
65
+ expect(second.attempt).toBe(first.attempt);
66
+ expect(Boolean(second.created)).toBe(false);
67
+ });
68
+
69
+ test("spawn without idempotencyKey always creates new task", async () => {
70
+ absurd.registerTask({ name: "no-idem-task" }, async () => {
71
+ return { done: true };
72
+ });
73
+
74
+ const first = await absurd.spawn("no-idem-task", { value: 1 });
75
+ const second = await absurd.spawn("no-idem-task", { value: 2 });
76
+
77
+ expect(first.taskID).not.toBe(second.taskID);
78
+ expect(first.runID).toBeDefined();
79
+ expect(second.runID).toBeDefined();
80
+ expect(Boolean(first.created)).toBe(true);
81
+ expect(Boolean(second.created)).toBe(true);
82
+ });
83
+
84
+ test("different idempotencyKeys create separate tasks", async () => {
85
+ absurd.registerTask({ name: "diff-keys-task" }, async () => {
86
+ return { done: true };
87
+ });
88
+
89
+ const first = await absurd.spawn(
90
+ "diff-keys-task",
91
+ { value: 1 },
92
+ { idempotencyKey: "key-a" },
93
+ );
94
+ const second = await absurd.spawn(
95
+ "diff-keys-task",
96
+ { value: 2 },
97
+ { idempotencyKey: "key-b" },
98
+ );
99
+
100
+ expect(first.taskID).not.toBe(second.taskID);
101
+ expect(first.runID).toBeDefined();
102
+ expect(second.runID).toBeDefined();
103
+ expect(Boolean(first.created)).toBe(true);
104
+ expect(Boolean(second.created)).toBe(true);
105
+ });
106
+
107
+ test("idempotencyKey persists after task completes", async () => {
108
+ absurd.registerTask({ name: "complete-idem-task" }, async () => {
109
+ return { result: "done" };
110
+ });
111
+
112
+ const first = await absurd.spawn(
113
+ "complete-idem-task",
114
+ { value: 1 },
115
+ { idempotencyKey: "complete-key" },
116
+ );
117
+
118
+ await absurd.workBatch("test-worker", 60, 1);
119
+
120
+ const task = await ctx.getTask(first.taskID);
121
+ expect(task?.state).toBe("completed");
122
+
123
+ const second = await absurd.spawn(
124
+ "complete-idem-task",
125
+ { value: 2 },
126
+ { idempotencyKey: "complete-key" },
127
+ );
128
+
129
+ expect(second.taskID).toBe(first.taskID);
130
+ expect(second.runID).toBe(first.runID);
131
+ expect(second.attempt).toBe(first.attempt);
132
+ expect(Boolean(second.created)).toBe(false);
133
+ });
134
+
135
+ test("idempotencyKey is scoped to queue", async () => {
136
+ const otherQueueName = randomName("other_queue");
137
+ await absurd.createQueue(otherQueueName);
138
+
139
+ absurd.registerTask(
140
+ { name: "queue-scoped-task", queue: ctx.queueName },
141
+ async () => ({ done: true }),
142
+ );
143
+ absurd.registerTask(
144
+ { name: "queue-scoped-task-other", queue: otherQueueName },
145
+ async () => ({ done: true }),
146
+ );
147
+
148
+ const first = await absurd.spawn(
149
+ "queue-scoped-task",
150
+ { value: 1 },
151
+ { idempotencyKey: "scoped-key" },
152
+ );
153
+
154
+ const second = await absurd.spawn(
155
+ "queue-scoped-task-other",
156
+ { value: 2 },
157
+ { idempotencyKey: "scoped-key" },
158
+ );
159
+
160
+ expect(first.taskID).not.toBe(second.taskID);
161
+ expect(Boolean(first.created)).toBe(true);
162
+ expect(Boolean(second.created)).toBe(true);
163
+
164
+ await absurd.dropQueue(otherQueueName);
165
+ });
166
+
167
+ test("idempotent spawn can be used for fire-and-forget patterns", async () => {
168
+ let execCount = 0;
169
+ absurd.registerTask({ name: "fire-forget-task" }, async () => {
170
+ execCount++;
171
+ return { done: true };
172
+ });
173
+
174
+ await absurd.spawn(
175
+ "fire-forget-task",
176
+ {},
177
+ { idempotencyKey: "daily-report:2025-01-15" },
178
+ );
179
+ await absurd.spawn(
180
+ "fire-forget-task",
181
+ {},
182
+ { idempotencyKey: "daily-report:2025-01-15" },
183
+ );
184
+ await absurd.spawn(
185
+ "fire-forget-task",
186
+ {},
187
+ { idempotencyKey: "daily-report:2025-01-15" },
188
+ );
189
+
190
+ await absurd.workBatch("test-worker", 60, 10);
191
+
192
+ expect(execCount).toBe(1);
193
+ });
194
+ });
195
+ });
@@ -0,0 +1,92 @@
1
+ import sqlite from "better-sqlite3";
2
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { afterEach, describe, expect, it } from "vitest";
7
+
8
+ import { Absurd, SQLiteDatabase } from "../src/index";
9
+
10
+ const testDir = fileURLToPath(new URL(".", import.meta.url));
11
+ const repoRoot = join(testDir, "../../..");
12
+ const extensionBase = join(repoRoot, "target/release/libabsurd");
13
+
14
+ function resolveExtensionPath(base: string): string {
15
+ const platformExt =
16
+ process.platform === "win32"
17
+ ? ".dll"
18
+ : process.platform === "darwin"
19
+ ? ".dylib"
20
+ : ".so";
21
+ const candidates = [base, `${base}${platformExt}`];
22
+ for (const candidate of candidates) {
23
+ if (existsSync(candidate)) {
24
+ return candidate;
25
+ }
26
+ }
27
+ throw new Error(
28
+ `SQLite extension not found at ${base} (expected ${platformExt})`
29
+ );
30
+ }
31
+
32
+ const extensionPath = resolveExtensionPath(extensionBase);
33
+
34
+ let tempDir: string | null = null;
35
+
36
+ function createDatabaseWithMigrations(): string {
37
+ tempDir = mkdtempSync(join(tmpdir(), "absurd-sqlite-"));
38
+ const dbPath = join(tempDir, "absurd.db");
39
+ const db = new sqlite(dbPath);
40
+ db.loadExtension(extensionPath);
41
+ db.prepare("select absurd_apply_migrations()").get();
42
+ db.close();
43
+ return dbPath;
44
+ }
45
+
46
+ afterEach(() => {
47
+ if (tempDir) {
48
+ rmSync(tempDir, { recursive: true, force: true });
49
+ tempDir = null;
50
+ }
51
+ });
52
+
53
+ describe("Absurd", () => {
54
+ it("creates and lists queues using the sqlite extension", async () => {
55
+ const dbPath = createDatabaseWithMigrations();
56
+ const db = new sqlite(dbPath) as unknown as SQLiteDatabase;
57
+ const absurd = new Absurd(db, extensionPath);
58
+
59
+ await absurd.createQueue("alpha");
60
+ await absurd.createQueue("beta");
61
+
62
+ const queues = await absurd.listQueues();
63
+ expect(queues).toContain("alpha");
64
+ expect(queues).toContain("beta");
65
+
66
+ await absurd.dropQueue("alpha");
67
+ const remaining = await absurd.listQueues();
68
+ expect(remaining).not.toContain("alpha");
69
+
70
+ const db2 = new sqlite(dbPath);
71
+ const { count } = db2
72
+ .prepare("select count(*) as count from absurd_queues")
73
+ .get() as { count: number };
74
+ expect(count).toBe(1);
75
+ db.close();
76
+
77
+ await absurd.close();
78
+ });
79
+
80
+ it("closes the sqlite database on close()", async () => {
81
+ const dbPath = createDatabaseWithMigrations();
82
+ const db = new sqlite(dbPath) as unknown as SQLiteDatabase;
83
+ const absurd = new Absurd(db, extensionPath);
84
+
85
+ await absurd.close();
86
+
87
+ const db2 = new sqlite(dbPath);
88
+ const { ok } = db2.prepare("select 1 as ok").get() as { ok: number };
89
+ expect(ok).toBe(1);
90
+ db.close();
91
+ });
92
+ });