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.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/_protocol/migrations/postgres/0001_init.sql +75 -0
- package/dist/_protocol/migrations/sqlite/0001_init.sql +74 -0
- package/dist/_protocol/sql/postgres/cancel.sql +16 -0
- package/dist/_protocol/sql/postgres/claim.sql +27 -0
- package/dist/_protocol/sql/postgres/complete.sql +17 -0
- package/dist/_protocol/sql/postgres/fail.sql +21 -0
- package/dist/_protocol/sql/postgres/get.sql +2 -0
- package/dist/_protocol/sql/postgres/get_by_key.sql +4 -0
- package/dist/_protocol/sql/postgres/get_key.sql +3 -0
- package/dist/_protocol/sql/postgres/heartbeat.sql +12 -0
- package/dist/_protocol/sql/postgres/insert_task.sql +20 -0
- package/dist/_protocol/sql/postgres/list.sql +13 -0
- package/dist/_protocol/sql/postgres/progress.sql +13 -0
- package/dist/_protocol/sql/postgres/recover_leases.sql +20 -0
- package/dist/_protocol/sql/postgres/retry.sql +17 -0
- package/dist/_protocol/sql/postgres/succeed.sql +16 -0
- package/dist/_protocol/sql/postgres/upsert_key.sql +12 -0
- package/dist/_protocol/sql/sqlite/cancel.sql +12 -0
- package/dist/_protocol/sql/sqlite/claim.sql +20 -0
- package/dist/_protocol/sql/sqlite/claimable_probe.sql +14 -0
- package/dist/_protocol/sql/sqlite/complete.sql +18 -0
- package/dist/_protocol/sql/sqlite/fail.sql +19 -0
- package/dist/_protocol/sql/sqlite/get.sql +2 -0
- package/dist/_protocol/sql/sqlite/get_by_key.sql +4 -0
- package/dist/_protocol/sql/sqlite/get_key.sql +3 -0
- package/dist/_protocol/sql/sqlite/heartbeat.sql +10 -0
- package/dist/_protocol/sql/sqlite/insert_task.sql +16 -0
- package/dist/_protocol/sql/sqlite/list.sql +11 -0
- package/dist/_protocol/sql/sqlite/progress.sql +10 -0
- package/dist/_protocol/sql/sqlite/recover_leases.sql +17 -0
- package/dist/_protocol/sql/sqlite/retry.sql +16 -0
- package/dist/_protocol/sql/sqlite/succeed.sql +15 -0
- package/dist/_protocol/sql/sqlite/upsert_key.sql +7 -0
- package/dist/client.d.ts +46 -0
- package/dist/client.js +70 -0
- package/dist/context.d.ts +31 -0
- package/dist/context.js +78 -0
- package/dist/errors.d.ts +60 -0
- package/dist/errors.js +100 -0
- package/dist/ids.d.ts +3 -0
- package/dist/ids.js +19 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +8 -0
- package/dist/models.d.ts +36 -0
- package/dist/models.js +31 -0
- package/dist/sql.d.ts +6 -0
- package/dist/sql.js +44 -0
- package/dist/store/base.d.ts +77 -0
- package/dist/store/base.js +1 -0
- package/dist/store/postgres.d.ts +87 -0
- package/dist/store/postgres.js +349 -0
- package/dist/store/sqlite.d.ts +77 -0
- package/dist/store/sqlite.js +297 -0
- package/dist/task.d.ts +21 -0
- package/dist/task.js +7 -0
- package/dist/wait.d.ts +8 -0
- package/dist/wait.js +18 -0
- package/dist/worker.d.ts +60 -0
- package/dist/worker.js +252 -0
- package/package.json +57 -0
- package/src/client.ts +98 -0
- package/src/context.ts +85 -0
- package/src/errors.ts +112 -0
- package/src/ids.ts +23 -0
- package/src/index.ts +31 -0
- package/src/models.ts +63 -0
- package/src/sql.ts +49 -0
- package/src/store/base.ts +67 -0
- package/src/store/postgres.ts +409 -0
- package/src/store/sqlite.ts +351 -0
- package/src/task.ts +27 -0
- package/src/wait.ts +23 -0
- package/src/worker.ts +284 -0
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cairnq",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SQLite-first, cross-language, storage-centered durable task runtime",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Jannchie <jannchie@gmail.com>",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"sqlite",
|
|
9
|
+
"task-queue",
|
|
10
|
+
"job-queue",
|
|
11
|
+
"durable",
|
|
12
|
+
"task-runtime",
|
|
13
|
+
"worker",
|
|
14
|
+
"cross-language",
|
|
15
|
+
"better-sqlite3"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/Jannchie/cairnq.git",
|
|
20
|
+
"directory": "cairnq-node"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/Jannchie/cairnq#readme",
|
|
23
|
+
"bugs": "https://github.com/Jannchie/cairnq/issues",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "dist/index.js",
|
|
26
|
+
"types": "dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": ["dist", "src"],
|
|
34
|
+
"engines": { "node": ">=20" },
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.json",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"better-sqlite3": "^11.3.0"
|
|
42
|
+
},
|
|
43
|
+
"optionalDependencies": {
|
|
44
|
+
"pg": "^8.13.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/better-sqlite3": "^7.6.11",
|
|
48
|
+
"@types/node": "^22.7.0",
|
|
49
|
+
"@types/pg": "^8.11.10",
|
|
50
|
+
"tsx": "^4.19.0",
|
|
51
|
+
"typescript": "^5.6.0",
|
|
52
|
+
"vitest": "^2.1.0"
|
|
53
|
+
},
|
|
54
|
+
"pnpm": {
|
|
55
|
+
"onlyBuiltDependencies": ["better-sqlite3", "esbuild"]
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { TaskCanceled, TaskFailed } from "./errors.js";
|
|
2
|
+
import { isFailed, isSucceeded, type Task } from "./models.js";
|
|
3
|
+
import { SQLiteStore } from "./store/sqlite.js";
|
|
4
|
+
import { PostgresStore } from "./store/postgres.js";
|
|
5
|
+
import type { ListInput, SubmitInput, TaskStore } from "./store/base.js";
|
|
6
|
+
import { type TaskDef, taskName } from "./task.js";
|
|
7
|
+
import { pollWait } from "./wait.js";
|
|
8
|
+
|
|
9
|
+
export type SubmitOptions = Omit<SubmitInput, "name" | "payload">;
|
|
10
|
+
export interface CallOptions extends SubmitOptions {
|
|
11
|
+
waitTimeoutMs?: number;
|
|
12
|
+
pollMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** API-side handle. Thin wrapper over a TaskStore + SDK-orchestrated wait/call. */
|
|
16
|
+
export class CairnQ {
|
|
17
|
+
constructor(private readonly _store: TaskStore) {}
|
|
18
|
+
|
|
19
|
+
static sqlite(path: string, opts?: { busyTimeoutMs?: number }): CairnQ {
|
|
20
|
+
return new CairnQ(new SQLiteStore(path, opts));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Multi-host backend. `dsn` is a libpq connection string; requires the
|
|
24
|
+
* optional `pg` package. */
|
|
25
|
+
static postgres(dsn: string, opts?: { max?: number }): CairnQ {
|
|
26
|
+
return new CairnQ(new PostgresStore(dsn, opts));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get store(): TaskStore {
|
|
30
|
+
return this._store;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
connect(): Promise<void> {
|
|
34
|
+
return this._store.connect();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
close(): Promise<void> {
|
|
38
|
+
return this._store.close();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
submit(name: string, payload?: unknown, opts?: SubmitOptions): Promise<Task>;
|
|
42
|
+
submit<P, R>(task: TaskDef<P, R>, payload?: P, opts?: SubmitOptions): Promise<Task>;
|
|
43
|
+
submit(task: string | TaskDef, payload?: unknown, opts: SubmitOptions = {}): Promise<Task> {
|
|
44
|
+
return this._store.submit({ name: taskName(task), payload, ...opts });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get(taskId: string): Promise<Task | null> {
|
|
48
|
+
return this._store.get(taskId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getByKey(key: string): Promise<Task | null> {
|
|
52
|
+
return this._store.getByKey(key);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
list(input?: ListInput): Promise<Task[]> {
|
|
56
|
+
return this._store.list(input);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
cancel(taskId: string): Promise<Task | null> {
|
|
60
|
+
return this._store.cancel(taskId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
cancelByKey(key: string): Promise<Task | null> {
|
|
64
|
+
return this._store.cancelByKey(key);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
retry(taskId: string, opts?: { resetAttempt?: boolean }): Promise<Task | null> {
|
|
68
|
+
return this._store.retry(taskId, opts);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
retryByKey(key: string, opts?: { resetAttempt?: boolean }): Promise<Task | null> {
|
|
72
|
+
return this._store.retryByKey(key, opts);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
wait(
|
|
76
|
+
taskId: string,
|
|
77
|
+
opts: { timeoutMs?: number; pollMs?: number } = {},
|
|
78
|
+
): Promise<Task> {
|
|
79
|
+
return pollWait(this._store, taskId, {
|
|
80
|
+
timeoutMs: opts.timeoutMs ?? 30_000,
|
|
81
|
+
pollMs: opts.pollMs,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** submit + wait. Resolves with the result on success; rejects with
|
|
86
|
+
* TaskFailed / TaskCanceled / TaskTimeout otherwise. Pass a TaskDef and the
|
|
87
|
+
* resolved value is typed as its Result. */
|
|
88
|
+
async call(name: string, payload?: unknown, opts?: CallOptions): Promise<unknown>;
|
|
89
|
+
async call<P, R>(task: TaskDef<P, R>, payload?: P, opts?: CallOptions): Promise<R>;
|
|
90
|
+
async call(task: string | TaskDef, payload?: unknown, opts: CallOptions = {}): Promise<unknown> {
|
|
91
|
+
const { waitTimeoutMs = 30_000, pollMs, ...submit } = opts;
|
|
92
|
+
const created = await this.submit(taskName(task), payload, submit);
|
|
93
|
+
const final = await pollWait(this._store, created.id, { timeoutMs: waitTimeoutMs, pollMs });
|
|
94
|
+
if (isSucceeded(final)) return final.result;
|
|
95
|
+
if (isFailed(final)) throw new TaskFailed(final.error);
|
|
96
|
+
throw new TaskCanceled(final.id);
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { cancelRequested, type Task } from "./models.js";
|
|
2
|
+
import type { SubmitOptions } from "./client.js";
|
|
3
|
+
import type { TaskStore } from "./store/base.js";
|
|
4
|
+
import { type TaskDef, taskName } from "./task.js";
|
|
5
|
+
import { pollWait } from "./wait.js";
|
|
6
|
+
|
|
7
|
+
/** Handed to a task handler. Worker-side capabilities mirror the Python SDK. */
|
|
8
|
+
export class TaskContext {
|
|
9
|
+
constructor(
|
|
10
|
+
private readonly store: TaskStore,
|
|
11
|
+
private readonly task: Task,
|
|
12
|
+
public readonly workerId: string,
|
|
13
|
+
private readonly leaseMs: number,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
get taskId(): string {
|
|
17
|
+
return this.task.id;
|
|
18
|
+
}
|
|
19
|
+
get name(): string {
|
|
20
|
+
return this.task.name;
|
|
21
|
+
}
|
|
22
|
+
get queue(): string {
|
|
23
|
+
return this.task.queue;
|
|
24
|
+
}
|
|
25
|
+
get attempt(): number {
|
|
26
|
+
return this.task.attempt;
|
|
27
|
+
}
|
|
28
|
+
get metadata(): any {
|
|
29
|
+
return this.task.metadata;
|
|
30
|
+
}
|
|
31
|
+
get rootId(): string | null {
|
|
32
|
+
return this.task.root_id;
|
|
33
|
+
}
|
|
34
|
+
get correlationId(): string | null {
|
|
35
|
+
return this.task.correlation_id;
|
|
36
|
+
}
|
|
37
|
+
get payload(): any {
|
|
38
|
+
return this.task.payload;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async progress(value: number | null, message: string | null = null): Promise<Task> {
|
|
42
|
+
return this.store.progress({
|
|
43
|
+
taskId: this.task.id,
|
|
44
|
+
workerId: this.workerId,
|
|
45
|
+
progress: value,
|
|
46
|
+
message,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async heartbeat(): Promise<Task> {
|
|
51
|
+
return this.store.heartbeat({
|
|
52
|
+
taskId: this.task.id,
|
|
53
|
+
workerId: this.workerId,
|
|
54
|
+
leaseMs: this.leaseMs,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Cooperative cancel check. */
|
|
59
|
+
async canceled(): Promise<boolean> {
|
|
60
|
+
const t = await this.store.get(this.task.id);
|
|
61
|
+
if (!t) return true;
|
|
62
|
+
return cancelRequested(t) || t.status === "canceled";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Submit a child task; parent/root/correlation are wired automatically. */
|
|
66
|
+
submit(name: string, payload?: unknown, opts?: SubmitOptions): Promise<Task>;
|
|
67
|
+
submit<P, R>(task: TaskDef<P, R>, payload?: P, opts?: SubmitOptions): Promise<Task>;
|
|
68
|
+
async submit(task: string | TaskDef, payload?: unknown, opts: SubmitOptions = {}): Promise<Task> {
|
|
69
|
+
return this.store.submit({
|
|
70
|
+
name: taskName(task),
|
|
71
|
+
payload,
|
|
72
|
+
parentId: this.task.id,
|
|
73
|
+
rootId: this.task.root_id,
|
|
74
|
+
correlationId: this.task.correlation_id,
|
|
75
|
+
...opts,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async wait(taskId: string, opts: { timeoutMs?: number; pollMs?: number } = {}): Promise<Task> {
|
|
80
|
+
return pollWait(this.store, taskId, {
|
|
81
|
+
timeoutMs: opts.timeoutMs ?? 30_000,
|
|
82
|
+
pollMs: opts.pollMs,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** The single shape of the JSON error envelope (see PROTOCOL.md). Everything that
|
|
2
|
+
* records an error — a handler exception, a missing handler, lease expiry, a thrown
|
|
3
|
+
* TaskError — builds it here, so the contract's fields live in one place. */
|
|
4
|
+
export function errorEnvelope(e: {
|
|
5
|
+
type: string;
|
|
6
|
+
code: string;
|
|
7
|
+
message: string;
|
|
8
|
+
retryable: boolean;
|
|
9
|
+
details?: Record<string, unknown>;
|
|
10
|
+
}): Record<string, unknown> {
|
|
11
|
+
return {
|
|
12
|
+
type: e.type,
|
|
13
|
+
code: e.code,
|
|
14
|
+
message: e.message,
|
|
15
|
+
retryable: e.retryable,
|
|
16
|
+
details: e.details ?? {},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class CairnQError extends Error {}
|
|
21
|
+
|
|
22
|
+
export class AlreadyExists extends CairnQError {
|
|
23
|
+
constructor(public key: string) {
|
|
24
|
+
super(`task with key ${key} already exists`);
|
|
25
|
+
this.name = "AlreadyExists";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** wait/call did not reach a terminal status in time. The task keeps running. */
|
|
30
|
+
export class TaskTimeout extends CairnQError {
|
|
31
|
+
constructor(public taskId: string) {
|
|
32
|
+
super(`task ${taskId} did not finish in time`);
|
|
33
|
+
this.name = "TaskTimeout";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A waited-on task ended in `failed`. The envelope's fields are unpacked onto the
|
|
38
|
+
* error — read `e.code` / `e.message` / `e.retryable` / `e.details` instead of
|
|
39
|
+
* digging into `e.error` (the raw envelope stays available on `e.error`). */
|
|
40
|
+
export class TaskFailed extends CairnQError {
|
|
41
|
+
readonly type: string;
|
|
42
|
+
readonly code: string;
|
|
43
|
+
readonly retryable: boolean;
|
|
44
|
+
readonly details: Record<string, unknown>;
|
|
45
|
+
constructor(public error: unknown) {
|
|
46
|
+
const env = (error ?? {}) as {
|
|
47
|
+
type?: string;
|
|
48
|
+
code?: string;
|
|
49
|
+
message?: string;
|
|
50
|
+
retryable?: boolean;
|
|
51
|
+
details?: Record<string, unknown>;
|
|
52
|
+
};
|
|
53
|
+
super(env.message ?? "task failed");
|
|
54
|
+
this.name = "TaskFailed";
|
|
55
|
+
this.type = env.type ?? "TaskError";
|
|
56
|
+
this.code = env.code ?? "task_error";
|
|
57
|
+
this.retryable = env.retryable ?? false;
|
|
58
|
+
this.details = env.details ?? {};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class TaskCanceled extends CairnQError {
|
|
63
|
+
constructor(public taskId: string) {
|
|
64
|
+
super(`task ${taskId} was canceled`);
|
|
65
|
+
this.name = "TaskCanceled";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A worker write affected 0 rows: the lease expired and was reclaimed. */
|
|
70
|
+
export class LostLease extends CairnQError {
|
|
71
|
+
constructor(public taskId: string) {
|
|
72
|
+
super(`lost lease on task ${taskId}`);
|
|
73
|
+
this.name = "LostLease";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class ProtocolVersionMismatch extends CairnQError {
|
|
78
|
+
constructor(message: string) {
|
|
79
|
+
super(message);
|
|
80
|
+
this.name = "ProtocolVersionMismatch";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Throw inside a handler to control how the failure is recorded. Defaults to
|
|
85
|
+
* non-retryable so deterministic errors fail fast instead of burning retries.
|
|
86
|
+
* Any other thrown value is treated as retryable. */
|
|
87
|
+
export class TaskError extends CairnQError {
|
|
88
|
+
code: string;
|
|
89
|
+
retryable: boolean;
|
|
90
|
+
type: string;
|
|
91
|
+
details: Record<string, unknown>;
|
|
92
|
+
constructor(
|
|
93
|
+
message: string,
|
|
94
|
+
opts: { code?: string; retryable?: boolean; type?: string; details?: Record<string, unknown> } = {},
|
|
95
|
+
) {
|
|
96
|
+
super(message);
|
|
97
|
+
this.name = "TaskError";
|
|
98
|
+
this.code = opts.code ?? "task_error";
|
|
99
|
+
this.retryable = opts.retryable ?? false;
|
|
100
|
+
this.type = opts.type ?? "TaskError";
|
|
101
|
+
this.details = opts.details ?? {};
|
|
102
|
+
}
|
|
103
|
+
envelope(): Record<string, unknown> {
|
|
104
|
+
return errorEnvelope({
|
|
105
|
+
type: this.type,
|
|
106
|
+
code: this.code,
|
|
107
|
+
message: this.message,
|
|
108
|
+
retryable: this.retryable,
|
|
109
|
+
details: this.details,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/ids.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// ULID-style id. Must match the Python SDK byte-for-byte in format (PROTOCOL.md):
|
|
4
|
+
// <prefix>_ + 26-char Crockford base32 of (48-bit ms timestamp << 80 | 80-bit random).
|
|
5
|
+
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
6
|
+
|
|
7
|
+
export function newUlid(tsMs: number = Date.now()): string {
|
|
8
|
+
let value = (BigInt(tsMs) << 80n) | BigInt("0x" + randomBytes(10).toString("hex"));
|
|
9
|
+
const chars: string[] = [];
|
|
10
|
+
for (let i = 0; i < 26; i++) {
|
|
11
|
+
chars.push(CROCKFORD[Number(value & 31n)]);
|
|
12
|
+
value >>= 5n;
|
|
13
|
+
}
|
|
14
|
+
return chars.reverse().join("");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function newId(prefix = "task"): string {
|
|
18
|
+
return `${prefix}_${newUlid()}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function nowMs(): number {
|
|
22
|
+
return Date.now();
|
|
23
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export { CairnQ } from "./client.js";
|
|
2
|
+
export type { CallOptions, SubmitOptions } from "./client.js";
|
|
3
|
+
export { Worker } from "./worker.js";
|
|
4
|
+
export type { Handler, TypedHandler, WorkerOptions } from "./worker.js";
|
|
5
|
+
export { TaskContext } from "./context.js";
|
|
6
|
+
export { defineTask } from "./task.js";
|
|
7
|
+
export type { TaskDef } from "./task.js";
|
|
8
|
+
export { SQLiteStore } from "./store/sqlite.js";
|
|
9
|
+
export { PostgresStore } from "./store/postgres.js";
|
|
10
|
+
export type { ListInput, SubmitInput, TaskStore, Conflict } from "./store/base.js";
|
|
11
|
+
export type { Task, TaskStatus } from "./models.js";
|
|
12
|
+
export {
|
|
13
|
+
STATUSES,
|
|
14
|
+
isTerminal,
|
|
15
|
+
cancelRequested,
|
|
16
|
+
isQueued,
|
|
17
|
+
isRunning,
|
|
18
|
+
isSucceeded,
|
|
19
|
+
isFailed,
|
|
20
|
+
isCanceled,
|
|
21
|
+
} from "./models.js";
|
|
22
|
+
export {
|
|
23
|
+
CairnQError,
|
|
24
|
+
AlreadyExists,
|
|
25
|
+
TaskTimeout,
|
|
26
|
+
TaskFailed,
|
|
27
|
+
TaskCanceled,
|
|
28
|
+
TaskError,
|
|
29
|
+
LostLease,
|
|
30
|
+
ProtocolVersionMismatch,
|
|
31
|
+
} from "./errors.js";
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// STATUSES is the canonical declaration within the TS SDK; TaskStatus derives from
|
|
2
|
+
// it so the type and the runtime set can't drift apart. The cross-language source of
|
|
3
|
+
// truth is the status CHECK constraint in cairnq-protocol's migration, which the
|
|
4
|
+
// conformance suite pins this set against.
|
|
5
|
+
export const STATUSES = ["queued", "running", "succeeded", "failed", "canceled"] as const;
|
|
6
|
+
export type TaskStatus = (typeof STATUSES)[number];
|
|
7
|
+
|
|
8
|
+
export interface Task {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
queue: string;
|
|
12
|
+
status: TaskStatus;
|
|
13
|
+
payload: any;
|
|
14
|
+
metadata: any;
|
|
15
|
+
result: any | null;
|
|
16
|
+
error: any | null;
|
|
17
|
+
progress: number | null;
|
|
18
|
+
message: string | null;
|
|
19
|
+
attempt: number;
|
|
20
|
+
max_attempts: number;
|
|
21
|
+
priority: number;
|
|
22
|
+
worker_id: string | null;
|
|
23
|
+
lease_until_ms: number | null;
|
|
24
|
+
run_at_ms: number;
|
|
25
|
+
cancel_requested_at_ms: number | null;
|
|
26
|
+
parent_id: string | null;
|
|
27
|
+
root_id: string | null;
|
|
28
|
+
correlation_id: string | null;
|
|
29
|
+
created_at_ms: number;
|
|
30
|
+
updated_at_ms: number;
|
|
31
|
+
completed_at_ms: number | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const JSON_COLUMNS = ["payload", "result", "error", "metadata"] as const;
|
|
35
|
+
export const TERMINAL: TaskStatus[] = ["succeeded", "failed", "canceled"];
|
|
36
|
+
|
|
37
|
+
export function rowToTask(row: Record<string, unknown>): Task {
|
|
38
|
+
const t: Record<string, unknown> = { ...row };
|
|
39
|
+
for (const col of JSON_COLUMNS) {
|
|
40
|
+
const v = row[col];
|
|
41
|
+
// The driver decides a JSON column's wire form: SQLite (TEXT) hands back a
|
|
42
|
+
// string to parse; a jsonb-aware driver (Postgres `pg`) hands back an
|
|
43
|
+
// already-decoded object. Parse only a string — never assume one backend.
|
|
44
|
+
t[col] = typeof v === "string" ? JSON.parse(v) : (v ?? null);
|
|
45
|
+
}
|
|
46
|
+
return t as unknown as Task;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isTerminal(task: Task): boolean {
|
|
50
|
+
return TERMINAL.includes(task.status);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function cancelRequested(task: Task): boolean {
|
|
54
|
+
return task.cancel_requested_at_ms != null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Status predicates — mirror the Python `task.succeeded` properties so callers
|
|
58
|
+
// don't compare status strings by hand.
|
|
59
|
+
export const isQueued = (task: Task): boolean => task.status === "queued";
|
|
60
|
+
export const isRunning = (task: Task): boolean => task.status === "running";
|
|
61
|
+
export const isSucceeded = (task: Task): boolean => task.status === "succeeded";
|
|
62
|
+
export const isFailed = (task: Task): boolean => task.status === "failed";
|
|
63
|
+
export const isCanceled = (task: Task): boolean => task.status === "canceled";
|
package/src/sql.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
// Locate the shared cairnq-protocol dir. Resolution: $CAIRNQ_PROTOCOL_DIR ->
|
|
6
|
+
// vendored `_protocol/` next to this module -> walk up to `cairnq-protocol/`
|
|
7
|
+
// (monorepo dev). Both SDKs load the SAME .sql strings (zero-drift guarantee).
|
|
8
|
+
// The dir is laid out per-dialect (sql/<dialect>/*.sql, migrations/<dialect>/*.sql)
|
|
9
|
+
// so a second backend (Postgres) slots in beside sqlite; `dialect` picks the subtree.
|
|
10
|
+
export function findProtocolRoot(): string {
|
|
11
|
+
const env = process.env.CAIRNQ_PROTOCOL_DIR;
|
|
12
|
+
if (env) return env;
|
|
13
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const vendored = join(dir, "_protocol");
|
|
15
|
+
if (existsSync(join(vendored, "sql"))) return vendored;
|
|
16
|
+
for (let i = 0; i < 10; i++) {
|
|
17
|
+
const candidate = join(dir, "cairnq-protocol");
|
|
18
|
+
if (existsSync(join(candidate, "sql"))) return candidate;
|
|
19
|
+
const parent = dirname(dir);
|
|
20
|
+
if (parent === dir) break;
|
|
21
|
+
dir = parent;
|
|
22
|
+
}
|
|
23
|
+
throw new Error("cannot locate cairnq-protocol; set CAIRNQ_PROTOCOL_DIR");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function loadStatements(
|
|
27
|
+
dialect = "sqlite",
|
|
28
|
+
root = findProtocolRoot(),
|
|
29
|
+
): Record<string, string> {
|
|
30
|
+
const dir = join(root, "sql", dialect);
|
|
31
|
+
const out: Record<string, string> = {};
|
|
32
|
+
for (const file of readdirSync(dir).sort()) {
|
|
33
|
+
if (file.endsWith(".sql")) {
|
|
34
|
+
out[file.slice(0, -4)] = readFileSync(join(dir, file), "utf-8");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function loadMigrations(
|
|
41
|
+
dialect = "sqlite",
|
|
42
|
+
root = findProtocolRoot(),
|
|
43
|
+
): { name: string; sql: string }[] {
|
|
44
|
+
const dir = join(root, "migrations", dialect);
|
|
45
|
+
return readdirSync(dir)
|
|
46
|
+
.filter((f) => f.endsWith(".sql"))
|
|
47
|
+
.sort()
|
|
48
|
+
.map((f) => ({ name: f, sql: readFileSync(join(dir, f), "utf-8") }));
|
|
49
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Task } from "../models.js";
|
|
2
|
+
|
|
3
|
+
export type Conflict = "reuse" | "reject" | "replace";
|
|
4
|
+
|
|
5
|
+
export interface SubmitInput {
|
|
6
|
+
name: string;
|
|
7
|
+
payload: unknown;
|
|
8
|
+
queue?: string;
|
|
9
|
+
key?: string | null;
|
|
10
|
+
conflict?: Conflict;
|
|
11
|
+
maxAttempts?: number;
|
|
12
|
+
priority?: number;
|
|
13
|
+
metadata?: unknown;
|
|
14
|
+
parentId?: string | null;
|
|
15
|
+
rootId?: string | null;
|
|
16
|
+
correlationId?: string | null;
|
|
17
|
+
runAtDelayMs?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ListInput {
|
|
21
|
+
status?: string | null;
|
|
22
|
+
queue?: string | null;
|
|
23
|
+
name?: string | null;
|
|
24
|
+
rootId?: string | null;
|
|
25
|
+
correlationId?: string | null;
|
|
26
|
+
limit?: number;
|
|
27
|
+
offset?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The storage seam. SQLiteStore is the only MVP implementation. */
|
|
31
|
+
export interface TaskStore {
|
|
32
|
+
connect(): Promise<void>;
|
|
33
|
+
close(): Promise<void>;
|
|
34
|
+
protocolVersion(): Promise<number>;
|
|
35
|
+
|
|
36
|
+
submit(input: SubmitInput): Promise<Task>;
|
|
37
|
+
get(taskId: string): Promise<Task | null>;
|
|
38
|
+
getByKey(key: string): Promise<Task | null>;
|
|
39
|
+
list(input?: ListInput): Promise<Task[]>;
|
|
40
|
+
cancel(taskId: string): Promise<Task | null>;
|
|
41
|
+
cancelByKey(key: string): Promise<Task | null>;
|
|
42
|
+
retry(taskId: string, opts?: { resetAttempt?: boolean }): Promise<Task | null>;
|
|
43
|
+
retryByKey(key: string, opts?: { resetAttempt?: boolean }): Promise<Task | null>;
|
|
44
|
+
|
|
45
|
+
claim(input: {
|
|
46
|
+
queues: string[];
|
|
47
|
+
workerId: string;
|
|
48
|
+
leaseMs?: number;
|
|
49
|
+
limit?: number;
|
|
50
|
+
}): Promise<Task[]>;
|
|
51
|
+
heartbeat(input: { taskId: string; workerId: string; leaseMs?: number }): Promise<Task>;
|
|
52
|
+
progress(input: {
|
|
53
|
+
taskId: string;
|
|
54
|
+
workerId: string;
|
|
55
|
+
progress: number | null;
|
|
56
|
+
message: string | null;
|
|
57
|
+
}): Promise<Task>;
|
|
58
|
+
succeed(input: { taskId: string; workerId: string; result: unknown }): Promise<Task>;
|
|
59
|
+
complete(input: { taskId: string; workerId: string; result: unknown }): Promise<Task>;
|
|
60
|
+
fail(input: {
|
|
61
|
+
taskId: string;
|
|
62
|
+
workerId: string;
|
|
63
|
+
error: unknown;
|
|
64
|
+
retryable?: boolean;
|
|
65
|
+
delayMs?: number;
|
|
66
|
+
}): Promise<Task>;
|
|
67
|
+
}
|