cairnq 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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +66 -0
  3. package/dist/_protocol/migrations/postgres/0001_init.sql +75 -0
  4. package/dist/_protocol/migrations/sqlite/0001_init.sql +74 -0
  5. package/dist/_protocol/sql/postgres/cancel.sql +16 -0
  6. package/dist/_protocol/sql/postgres/claim.sql +27 -0
  7. package/dist/_protocol/sql/postgres/complete.sql +17 -0
  8. package/dist/_protocol/sql/postgres/fail.sql +21 -0
  9. package/dist/_protocol/sql/postgres/get.sql +2 -0
  10. package/dist/_protocol/sql/postgres/get_by_key.sql +4 -0
  11. package/dist/_protocol/sql/postgres/get_key.sql +3 -0
  12. package/dist/_protocol/sql/postgres/heartbeat.sql +12 -0
  13. package/dist/_protocol/sql/postgres/insert_task.sql +20 -0
  14. package/dist/_protocol/sql/postgres/list.sql +13 -0
  15. package/dist/_protocol/sql/postgres/progress.sql +13 -0
  16. package/dist/_protocol/sql/postgres/recover_leases.sql +20 -0
  17. package/dist/_protocol/sql/postgres/retry.sql +17 -0
  18. package/dist/_protocol/sql/postgres/succeed.sql +16 -0
  19. package/dist/_protocol/sql/postgres/upsert_key.sql +12 -0
  20. package/dist/_protocol/sql/sqlite/cancel.sql +12 -0
  21. package/dist/_protocol/sql/sqlite/claim.sql +20 -0
  22. package/dist/_protocol/sql/sqlite/claimable_probe.sql +14 -0
  23. package/dist/_protocol/sql/sqlite/complete.sql +18 -0
  24. package/dist/_protocol/sql/sqlite/fail.sql +19 -0
  25. package/dist/_protocol/sql/sqlite/get.sql +2 -0
  26. package/dist/_protocol/sql/sqlite/get_by_key.sql +4 -0
  27. package/dist/_protocol/sql/sqlite/get_key.sql +3 -0
  28. package/dist/_protocol/sql/sqlite/heartbeat.sql +10 -0
  29. package/dist/_protocol/sql/sqlite/insert_task.sql +16 -0
  30. package/dist/_protocol/sql/sqlite/list.sql +11 -0
  31. package/dist/_protocol/sql/sqlite/progress.sql +10 -0
  32. package/dist/_protocol/sql/sqlite/recover_leases.sql +17 -0
  33. package/dist/_protocol/sql/sqlite/retry.sql +16 -0
  34. package/dist/_protocol/sql/sqlite/succeed.sql +15 -0
  35. package/dist/_protocol/sql/sqlite/upsert_key.sql +7 -0
  36. package/dist/client.d.ts +46 -0
  37. package/dist/client.js +70 -0
  38. package/dist/context.d.ts +31 -0
  39. package/dist/context.js +78 -0
  40. package/dist/errors.d.ts +60 -0
  41. package/dist/errors.js +100 -0
  42. package/dist/ids.d.ts +3 -0
  43. package/dist/ids.js +19 -0
  44. package/dist/index.d.ts +13 -0
  45. package/dist/index.js +8 -0
  46. package/dist/models.d.ts +36 -0
  47. package/dist/models.js +31 -0
  48. package/dist/sql.d.ts +6 -0
  49. package/dist/sql.js +44 -0
  50. package/dist/store/base.d.ts +77 -0
  51. package/dist/store/base.js +1 -0
  52. package/dist/store/postgres.d.ts +87 -0
  53. package/dist/store/postgres.js +349 -0
  54. package/dist/store/sqlite.d.ts +77 -0
  55. package/dist/store/sqlite.js +297 -0
  56. package/dist/task.d.ts +21 -0
  57. package/dist/task.js +7 -0
  58. package/dist/wait.d.ts +8 -0
  59. package/dist/wait.js +18 -0
  60. package/dist/worker.d.ts +60 -0
  61. package/dist/worker.js +252 -0
  62. package/package.json +57 -0
  63. package/src/client.ts +98 -0
  64. package/src/context.ts +85 -0
  65. package/src/errors.ts +112 -0
  66. package/src/ids.ts +23 -0
  67. package/src/index.ts +31 -0
  68. package/src/models.ts +63 -0
  69. package/src/sql.ts +49 -0
  70. package/src/store/base.ts +67 -0
  71. package/src/store/postgres.ts +409 -0
  72. package/src/store/sqlite.ts +351 -0
  73. package/src/task.ts +27 -0
  74. package/src/wait.ts +23 -0
  75. package/src/worker.ts +284 -0
@@ -0,0 +1,409 @@
1
+ import type * as PG from "pg";
2
+
3
+ import { newId } from "../ids.js";
4
+ import { AlreadyExists, errorEnvelope, LostLease, ProtocolVersionMismatch } from "../errors.js";
5
+ import { rowToTask, type Task } from "../models.js";
6
+ import { loadMigrations, loadStatements } from "../sql.js";
7
+ import type { ListInput, SubmitInput, TaskStore } from "./base.js";
8
+
9
+ const SUPPORTED_PROTOCOL_MAJOR = 1;
10
+
11
+ const LEASE_EXPIRED_ERROR_JSON = JSON.stringify(
12
+ errorEnvelope({
13
+ type: "LeaseExpired",
14
+ code: "lease_expired",
15
+ message: "task lease expired and max attempts reached",
16
+ retryable: false,
17
+ }),
18
+ );
19
+
20
+ // `pg` is an optional dependency: the SDK is SQLite-first, so it's loaded lazily
21
+ // the first time a PostgresStore connects. Absent -> a clear install hint.
22
+ let pgModule: typeof import("pg") | null = null;
23
+ async function loadPg(): Promise<typeof import("pg")> {
24
+ if (pgModule) return pgModule;
25
+ let mod: { default?: typeof import("pg") } & typeof import("pg");
26
+ try {
27
+ mod = (await import("pg")) as never;
28
+ } catch {
29
+ throw new Error("PostgresStore requires the 'pg' package — install it (e.g. `npm i pg`)");
30
+ }
31
+ const pg = (mod.default ?? mod) as typeof import("pg");
32
+ // Postgres returns bigint (int8, OID 20) as a string to avoid precision loss.
33
+ // Every cairnq bigint is an epoch-ms or a counter, all within Number's safe
34
+ // integer range, so parse to number once (globally) to match the Task model
35
+ // (*_ms typed as number, same as the SQLite SDK). Set before any query runs.
36
+ pg.types.setTypeParser(pg.types.builtins.INT8, (v: string) => (v == null ? null : Number(v)));
37
+ pgModule = pg;
38
+ return pg;
39
+ }
40
+
41
+ /**
42
+ * Translate the protocol's named-parameter SQL (`:name`) into Postgres positional
43
+ * placeholders (`$1`), collapsing each DISTINCT name to ONE slot — statements reuse
44
+ * a name across CASE branches / IS NULL guards (e.g. list.sql). SQL comments are
45
+ * stripped first so a `:name` mentioned in a header comment (e.g. "now + :lease_ms")
46
+ * never leaks into the parameter list. Exported for unit testing.
47
+ */
48
+ export function toPositional(
49
+ sql: string,
50
+ params: Record<string, unknown>,
51
+ ): { text: string; values: unknown[] } {
52
+ const body = sql.replace(/--[^\n]*/g, "");
53
+ const order: string[] = [];
54
+ const slot = new Map<string, number>();
55
+ const text = body.replace(/(?<!:):(\w+)/g, (_m, name: string) => {
56
+ let i = slot.get(name);
57
+ if (i === undefined) {
58
+ order.push(name);
59
+ i = order.length; // 1-based $n
60
+ slot.set(name, i);
61
+ }
62
+ return `$${i}`;
63
+ });
64
+ return { text, values: order.map((n) => params[n]) };
65
+ }
66
+
67
+ /**
68
+ * PostgresStore — asyncpg's TS counterpart: a `pg` Pool executing the shared
69
+ * cairnq-protocol SQL (postgres dialect). Multi-host capable — unlike SQLite this
70
+ * coordinates API and worker processes across machines through one database. Time
71
+ * comes from the DB clock (now()), claim uses FOR UPDATE SKIP LOCKED, JSON columns
72
+ * are jsonb (bound as JSON text, read back as objects by rowToTask's adaptive parse).
73
+ */
74
+ export class PostgresStore implements TaskStore {
75
+ private pool: PG.Pool | null = null;
76
+ private connecting: Promise<void> | null = null;
77
+ private readonly statements: Record<string, string>;
78
+
79
+ constructor(
80
+ private readonly dsn: string,
81
+ private readonly opts: { max?: number } = {},
82
+ ) {
83
+ this.statements = loadStatements("postgres");
84
+ }
85
+
86
+ async connect(): Promise<void> {
87
+ await this.ensure();
88
+ }
89
+
90
+ async close(): Promise<void> {
91
+ if (this.pool) {
92
+ const p = this.pool;
93
+ this.pool = null;
94
+ this.connecting = null;
95
+ await p.end();
96
+ }
97
+ }
98
+
99
+ private async ensure(): Promise<void> {
100
+ if (this.pool) return;
101
+ // Cache the in-flight connect so concurrent calls share one pool. On failure,
102
+ // clear it so a later call retries instead of re-awaiting a rejected promise.
103
+ if (!this.connecting) {
104
+ this.connecting = this.doConnect().catch((e) => {
105
+ this.connecting = null;
106
+ throw e;
107
+ });
108
+ }
109
+ await this.connecting;
110
+ }
111
+
112
+ private async doConnect(): Promise<void> {
113
+ const pg = await loadPg();
114
+ const pool = new pg.Pool({ connectionString: this.dsn, max: this.opts.max });
115
+ try {
116
+ const client = await pool.connect();
117
+ try {
118
+ await this.applyMigrations(client);
119
+ const version = await this.readProtocolVersion(client);
120
+ if (version !== SUPPORTED_PROTOCOL_MAJOR) {
121
+ throw new ProtocolVersionMismatch(
122
+ `storage protocol_version=${version}, SDK supports ${SUPPORTED_PROTOCOL_MAJOR}`,
123
+ );
124
+ }
125
+ } finally {
126
+ client.release();
127
+ }
128
+ } catch (e) {
129
+ await pool.end(); // never leak a pool when connect fails
130
+ throw e;
131
+ }
132
+ this.pool = pool; // publish only a fully-migrated, version-checked pool
133
+ }
134
+
135
+ private async applyMigrations(client: PG.PoolClient): Promise<void> {
136
+ await client.query(
137
+ "create table if not exists cairnq_migrations " +
138
+ "(name text primary key, applied_at_ms bigint not null)",
139
+ );
140
+ const applied = new Set(
141
+ (await client.query("select name from cairnq_migrations")).rows.map(
142
+ (r: { name: string }) => r.name,
143
+ ),
144
+ );
145
+ for (const { name, sql } of loadMigrations("postgres")) {
146
+ if (applied.has(name)) continue;
147
+ try {
148
+ await client.query("begin");
149
+ await client.query(sql); // multi-statement DDL (simple-query, no params)
150
+ await client.query(
151
+ "insert into cairnq_migrations (name, applied_at_ms) values " +
152
+ "($1, (extract(epoch from now()) * 1000)::bigint) on conflict (name) do nothing",
153
+ [name],
154
+ );
155
+ await client.query("commit");
156
+ } catch (e) {
157
+ await client.query("rollback");
158
+ throw e;
159
+ }
160
+ }
161
+ }
162
+
163
+ private async readProtocolVersion(client: PG.PoolClient): Promise<number> {
164
+ const res = await client.query(
165
+ "select value from cairnq_meta where key = 'protocol_version'",
166
+ );
167
+ return res.rows.length ? Number(res.rows[0].value) : 0;
168
+ }
169
+
170
+ async protocolVersion(): Promise<number> {
171
+ await this.ensure();
172
+ const client = await this.pool!.connect();
173
+ try {
174
+ return await this.readProtocolVersion(client);
175
+ } finally {
176
+ client.release();
177
+ }
178
+ }
179
+
180
+ // ------------------------------------------------------------------ helpers
181
+ private async run(name: string, params: Record<string, unknown>): Promise<any[]> {
182
+ const { text, values } = toPositional(this.statements[name], params);
183
+ return (await this.pool!.query(text, values)).rows;
184
+ }
185
+
186
+ private async runOn(
187
+ client: PG.PoolClient,
188
+ name: string,
189
+ params: Record<string, unknown>,
190
+ ): Promise<any[]> {
191
+ const { text, values } = toPositional(this.statements[name], params);
192
+ return (await client.query(text, values)).rows;
193
+ }
194
+
195
+ // BEGIN … COMMIT on a dedicated pool client, rolling back on any error.
196
+ private async tx<T>(fn: (client: PG.PoolClient) => Promise<T>): Promise<T> {
197
+ const client = await this.pool!.connect();
198
+ try {
199
+ await client.query("begin");
200
+ const out = await fn(client);
201
+ await client.query("commit");
202
+ return out;
203
+ } catch (e) {
204
+ await client.query("rollback");
205
+ throw e;
206
+ } finally {
207
+ client.release();
208
+ }
209
+ }
210
+
211
+ // An ownership-checked worker write: each statement's WHERE pins worker_id + a
212
+ // live lease, so 0 rows back means the lease was lost.
213
+ private async ownedWrite(
214
+ name: string,
215
+ taskId: string,
216
+ params: Record<string, unknown>,
217
+ ): Promise<Task> {
218
+ const rows = await this.run(name, params);
219
+ if (!rows.length) throw new LostLease(taskId);
220
+ return rowToTask(rows[0]);
221
+ }
222
+
223
+ // ------------------------------------------------------------- client side
224
+ async submit(input: SubmitInput): Promise<Task> {
225
+ await this.ensure();
226
+ const id = newId("task");
227
+ // No now_ms / run_at_ms: the DB clock supplies time, so submit passes a
228
+ // relative :delay_ms and the SQL computes run_at = now + delay.
229
+ const ins = {
230
+ id,
231
+ name: input.name,
232
+ queue: input.queue ?? "default",
233
+ payload: JSON.stringify(input.payload ?? {}),
234
+ metadata: JSON.stringify(input.metadata ?? {}),
235
+ max_attempts: input.maxAttempts ?? 3,
236
+ priority: input.priority ?? 0,
237
+ delay_ms: input.runAtDelayMs ?? 0,
238
+ parent_id: input.parentId ?? null,
239
+ root_id: input.rootId ?? id,
240
+ correlation_id: input.correlationId ?? null,
241
+ };
242
+ const key = input.key ?? null;
243
+ const conflict = input.conflict ?? "reuse";
244
+ if (key === null) return rowToTask((await this.run("insert_task", ins))[0]);
245
+
246
+ return this.tx(async (client) => {
247
+ const existing = (await this.runOn(client, "get_key", { key })) as { task_id: string }[];
248
+ if (existing.length) {
249
+ const exId = existing[0].task_id;
250
+ if (conflict === "reuse") return rowToTask((await this.runOn(client, "get", { id: exId }))[0]);
251
+ if (conflict === "reject") throw new AlreadyExists(key);
252
+ if (conflict === "replace") {
253
+ await this.runOn(client, "cancel", { id: exId });
254
+ const row = (await this.runOn(client, "insert_task", ins))[0];
255
+ await this.runOn(client, "upsert_key", { key, task_id: id });
256
+ return rowToTask(row);
257
+ }
258
+ throw new Error(`unknown conflict strategy: ${conflict}`);
259
+ }
260
+ const row = (await this.runOn(client, "insert_task", ins))[0];
261
+ await this.runOn(client, "upsert_key", { key, task_id: id });
262
+ return rowToTask(row);
263
+ });
264
+ }
265
+
266
+ async get(taskId: string): Promise<Task | null> {
267
+ await this.ensure();
268
+ const rows = await this.run("get", { id: taskId });
269
+ return rows.length ? rowToTask(rows[0]) : null;
270
+ }
271
+
272
+ async getByKey(key: string): Promise<Task | null> {
273
+ await this.ensure();
274
+ const rows = await this.run("get_by_key", { key });
275
+ return rows.length ? rowToTask(rows[0]) : null;
276
+ }
277
+
278
+ async list(input: ListInput = {}): Promise<Task[]> {
279
+ await this.ensure();
280
+ const rows = await this.run("list", {
281
+ status: input.status ?? null,
282
+ queue: input.queue ?? null,
283
+ name: input.name ?? null,
284
+ root_id: input.rootId ?? null,
285
+ correlation_id: input.correlationId ?? null,
286
+ limit: input.limit ?? 100,
287
+ offset: input.offset ?? 0,
288
+ });
289
+ return rows.map(rowToTask);
290
+ }
291
+
292
+ async cancel(taskId: string): Promise<Task | null> {
293
+ await this.ensure();
294
+ const rows = await this.run("cancel", { id: taskId });
295
+ return rows.length ? rowToTask(rows[0]) : null;
296
+ }
297
+
298
+ async cancelByKey(key: string): Promise<Task | null> {
299
+ await this.ensure();
300
+ return this.tx(async (client) => {
301
+ const existing = (await this.runOn(client, "get_key", { key })) as { task_id: string }[];
302
+ if (!existing.length) return null;
303
+ const rows = await this.runOn(client, "cancel", { id: existing[0].task_id });
304
+ return rows.length ? rowToTask(rows[0]) : null;
305
+ });
306
+ }
307
+
308
+ async retry(taskId: string, opts: { resetAttempt?: boolean } = {}): Promise<Task | null> {
309
+ await this.ensure();
310
+ const rows = await this.run("retry", { id: taskId, reset_attempt: opts.resetAttempt ?? false });
311
+ return rows.length ? rowToTask(rows[0]) : null;
312
+ }
313
+
314
+ async retryByKey(key: string, opts: { resetAttempt?: boolean } = {}): Promise<Task | null> {
315
+ await this.ensure();
316
+ return this.tx(async (client) => {
317
+ const existing = (await this.runOn(client, "get_key", { key })) as { task_id: string }[];
318
+ if (!existing.length) return null;
319
+ const rows = await this.runOn(client, "retry", {
320
+ id: existing[0].task_id,
321
+ reset_attempt: opts.resetAttempt ?? false,
322
+ });
323
+ return rows.length ? rowToTask(rows[0]) : null;
324
+ });
325
+ }
326
+
327
+ // ------------------------------------------------------------- worker side
328
+ async claim(input: {
329
+ queues: string[];
330
+ workerId: string;
331
+ leaseMs?: number;
332
+ limit?: number;
333
+ }): Promise<Task[]> {
334
+ await this.ensure();
335
+ // No claimable_probe: PG readers don't block writers, so a plain transaction
336
+ // (recover then claim) is cheap even when idle. FOR UPDATE SKIP LOCKED in
337
+ // claim.sql gives true concurrent, non-contending dispatch.
338
+ return this.tx(async (client) => {
339
+ await this.runOn(client, "recover_leases", { lease_expired_error: LEASE_EXPIRED_ERROR_JSON });
340
+ const rows = await this.runOn(client, "claim", {
341
+ queues: input.queues,
342
+ worker_id: input.workerId,
343
+ lease_ms: input.leaseMs ?? 30_000,
344
+ limit: input.limit ?? 1,
345
+ });
346
+ return rows.map(rowToTask);
347
+ });
348
+ }
349
+
350
+ async heartbeat(input: { taskId: string; workerId: string; leaseMs?: number }): Promise<Task> {
351
+ await this.ensure();
352
+ return this.ownedWrite("heartbeat", input.taskId, {
353
+ id: input.taskId,
354
+ worker_id: input.workerId,
355
+ lease_ms: input.leaseMs ?? 30_000,
356
+ });
357
+ }
358
+
359
+ async progress(input: {
360
+ taskId: string;
361
+ workerId: string;
362
+ progress: number | null;
363
+ message: string | null;
364
+ }): Promise<Task> {
365
+ await this.ensure();
366
+ return this.ownedWrite("progress", input.taskId, {
367
+ id: input.taskId,
368
+ worker_id: input.workerId,
369
+ progress: input.progress,
370
+ message: input.message,
371
+ });
372
+ }
373
+
374
+ async succeed(input: { taskId: string; workerId: string; result: unknown }): Promise<Task> {
375
+ await this.ensure();
376
+ return this.ownedWrite("succeed", input.taskId, {
377
+ id: input.taskId,
378
+ worker_id: input.workerId,
379
+ result: input.result == null ? null : JSON.stringify(input.result),
380
+ message: null,
381
+ });
382
+ }
383
+
384
+ async complete(input: { taskId: string; workerId: string; result: unknown }): Promise<Task> {
385
+ await this.ensure();
386
+ return this.ownedWrite("complete", input.taskId, {
387
+ id: input.taskId,
388
+ worker_id: input.workerId,
389
+ result: input.result == null ? null : JSON.stringify(input.result),
390
+ });
391
+ }
392
+
393
+ async fail(input: {
394
+ taskId: string;
395
+ workerId: string;
396
+ error: unknown;
397
+ retryable?: boolean;
398
+ delayMs?: number;
399
+ }): Promise<Task> {
400
+ await this.ensure();
401
+ return this.ownedWrite("fail", input.taskId, {
402
+ id: input.taskId,
403
+ worker_id: input.workerId,
404
+ error: JSON.stringify(input.error ?? {}),
405
+ retryable: input.retryable !== false,
406
+ delay_ms: input.delayMs ?? 0,
407
+ });
408
+ }
409
+ }