@marktiderman/genesis-supabase 0.3.1 → 0.5.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/README.md +217 -33
- package/dist/blocks.d.ts +8 -1
- package/dist/blocks.js +1 -1
- package/dist/{chunk-T2HGFC4C.js → chunk-A3H5CTYT.js} +11 -3
- package/dist/chunk-A3H5CTYT.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/jobs.d.ts +185 -0
- package/dist/jobs.js +163 -0
- package/dist/jobs.js.map +1 -0
- package/migrations/0007_genesis_jobs.sql +352 -0
- package/migrations/tests/0007_genesis_jobs.test.sql +129 -0
- package/package.json +39 -3
- package/sql/testing/genesis_testing.sql +526 -0
- package/sql/testing/tests/genesis_testing.test.sql +181 -0
- package/dist/chunk-T2HGFC4C.js.map +0 -1
package/dist/jobs.d.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@marktiderman/genesis-supabase/jobs`: the server half of
|
|
3
|
+
* `migrations/0007_genesis_jobs.sql`. Server only (it takes the service-role
|
|
4
|
+
* client and the job secret); never import it into browser or app code.
|
|
5
|
+
*
|
|
6
|
+
* - `enqueueJob`: queue work from server code (triggers enqueue in SQL).
|
|
7
|
+
* - `runJobs`: claim due jobs, run each kind's handler, settle each one, with
|
|
8
|
+
* backoff on failure. Never throws for one job's failure.
|
|
9
|
+
* - `createJobsHandler`: the app's one job route. It checks the request is
|
|
10
|
+
* signed for the job (`genesis_schedule_job` signs; the secret never
|
|
11
|
+
* travels) or carries the bearer secret (a platform cron, a person), with a
|
|
12
|
+
* previous secret accepted while you rotate it, and runs the named job.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* // src/routes/api/jobs/$job.ts (any server route that hands over a Request)
|
|
16
|
+
* const handle = createJobsHandler({
|
|
17
|
+
* secret: process.env.GENESIS_JOBS_SECRET,
|
|
18
|
+
* previousSecret: process.env.GENESIS_JOBS_SECRET_PREVIOUS,
|
|
19
|
+
* jobs: {
|
|
20
|
+
* queue: () => runJobs(admin, { handlers: { "email.digest": sendDigest } }),
|
|
21
|
+
* cleanup: () => removeQueuedFiles(admin),
|
|
22
|
+
* },
|
|
23
|
+
* });
|
|
24
|
+
* export const POST = ({ request, params }) => handle(request, params.job);
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* @packageDocumentation
|
|
28
|
+
*/
|
|
29
|
+
/** The parts of a Supabase client these helpers use: a service-role `SupabaseClient` fits. */
|
|
30
|
+
interface JobsClient {
|
|
31
|
+
rpc: (fn: any, ...rest: any[]) => PromiseLike<{
|
|
32
|
+
data: unknown;
|
|
33
|
+
error: {
|
|
34
|
+
message: string;
|
|
35
|
+
code?: string;
|
|
36
|
+
} | null;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
/** A failure from the queue's own functions (not from a handler). */
|
|
40
|
+
declare class JobsError extends Error {
|
|
41
|
+
readonly code: string | undefined;
|
|
42
|
+
constructor(message: string, code?: string);
|
|
43
|
+
}
|
|
44
|
+
/** @stability Beta */
|
|
45
|
+
interface EnqueueOptions {
|
|
46
|
+
/** Run no earlier than this. Default now. */
|
|
47
|
+
runAfter?: Date;
|
|
48
|
+
/** One job per kind and key while it is queued or running: a repeat returns the queued job's id. */
|
|
49
|
+
idempotencyKey?: string;
|
|
50
|
+
/** Attempts before it fails for good, 1 to 100. Default 8. */
|
|
51
|
+
maxAttempts?: number;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Queue one job of `kind` (`"email.digest"`, lowercase) with a JSON object
|
|
55
|
+
* payload. Returns the job's id. Payloads are readable by the service role:
|
|
56
|
+
* put ids in them, never secrets.
|
|
57
|
+
*
|
|
58
|
+
* @stability Beta
|
|
59
|
+
*/
|
|
60
|
+
declare function enqueueJob(client: JobsClient, kind: string, payload?: Record<string, unknown>, options?: EnqueueOptions): Promise<string>;
|
|
61
|
+
/** One claimed job, as a handler sees it. @stability Beta */
|
|
62
|
+
interface Job<Payload = Record<string, unknown>> {
|
|
63
|
+
id: string;
|
|
64
|
+
kind: string;
|
|
65
|
+
payload: Payload;
|
|
66
|
+
/** This attempt's number, from 1. */
|
|
67
|
+
attempt: number;
|
|
68
|
+
/** An earlier attempt's lease ran out: it may have done the work. Handlers that can look first, do. */
|
|
69
|
+
redelivery: boolean;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* What a handler can return. Nothing (or `{ done: true }`) is done. Throwing
|
|
73
|
+
* retries with backoff; throw a `PermanentJobError` (or return
|
|
74
|
+
* `{ failed }`) to stop.
|
|
75
|
+
*
|
|
76
|
+
* @stability Beta
|
|
77
|
+
*/
|
|
78
|
+
type JobOutcome = void | {
|
|
79
|
+
done: true;
|
|
80
|
+
} | {
|
|
81
|
+
retryAfterSeconds: number;
|
|
82
|
+
error?: string;
|
|
83
|
+
} | {
|
|
84
|
+
failed: string;
|
|
85
|
+
};
|
|
86
|
+
/** Thrown by a handler for a failure that won't get better with a retry (a bad payload, a 4xx). @stability Beta */
|
|
87
|
+
declare class PermanentJobError extends Error {
|
|
88
|
+
constructor(message: string);
|
|
89
|
+
}
|
|
90
|
+
type JobHandler = (job: Job<any>) => Promise<JobOutcome> | JobOutcome;
|
|
91
|
+
/** @stability Beta */
|
|
92
|
+
interface RunJobsOptions {
|
|
93
|
+
/** Kind → handler. Only these kinds are claimed. */
|
|
94
|
+
handlers: Readonly<Record<string, JobHandler>>;
|
|
95
|
+
/** Jobs claimed in this run. Default 25. */
|
|
96
|
+
limit?: number;
|
|
97
|
+
/** How long a claim holds a job; a handler must finish inside it. Default 300. */
|
|
98
|
+
leaseSeconds?: number;
|
|
99
|
+
/** Jobs run at once. Default 4. */
|
|
100
|
+
concurrency?: number;
|
|
101
|
+
/** Seconds before retry `attempt` (from 1). Default 30 s doubling, jittered, at most an hour. */
|
|
102
|
+
backoff?: (attempt: number) => number;
|
|
103
|
+
/** Where one line per failed job goes (kind, id, attempt, the error's message). Default console.error. */
|
|
104
|
+
log?: (line: string) => void;
|
|
105
|
+
}
|
|
106
|
+
/** @stability Beta */
|
|
107
|
+
interface RunJobsReport {
|
|
108
|
+
claimed: number;
|
|
109
|
+
done: number;
|
|
110
|
+
retried: number;
|
|
111
|
+
failed: number;
|
|
112
|
+
/** Settles refused because the lease ran out first (another run has the job). */
|
|
113
|
+
stale: number;
|
|
114
|
+
/** Settles that failed (the database unreachable): those jobs come back when their lease runs out. */
|
|
115
|
+
unsettled: number;
|
|
116
|
+
}
|
|
117
|
+
/** 30 s, doubling per attempt, ±20% jitter, at most an hour. */
|
|
118
|
+
declare function defaultBackoff(attempt: number): number;
|
|
119
|
+
/**
|
|
120
|
+
* Claim due jobs of the handled kinds, run them, and settle each: done, a
|
|
121
|
+
* retry after backoff, or failed. One job's failure, or a failure to record
|
|
122
|
+
* it, never stops the others; only a failed claim throws. Nothing about a
|
|
123
|
+
* payload is logged.
|
|
124
|
+
*
|
|
125
|
+
* @stability Beta
|
|
126
|
+
*/
|
|
127
|
+
declare function runJobs(client: JobsClient, options: RunJobsOptions): Promise<RunJobsReport>;
|
|
128
|
+
/** @stability Beta */
|
|
129
|
+
interface VerifyJobRequestOptions {
|
|
130
|
+
/** The job secret. Unset refuses every request (500). */
|
|
131
|
+
secret: string | undefined;
|
|
132
|
+
/** The secret before a rotation, accepted too. Remove it once every caller has the new one. */
|
|
133
|
+
previousSecret?: string | undefined;
|
|
134
|
+
/** How far a signed request's time may be from now, in seconds. Default 300. */
|
|
135
|
+
maxSkewSeconds?: number;
|
|
136
|
+
/** Now, in seconds (for tests). */
|
|
137
|
+
now?: () => number;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The `Authorization` value a scheduler sends for `job`: `GenesisJob
|
|
141
|
+
* t=<unix seconds>,sig=<hex HMAC-SHA256 of "<t>.<job>">`. What
|
|
142
|
+
* `genesis_schedule_job` sends; for calling a job route from your own code.
|
|
143
|
+
*
|
|
144
|
+
* @stability Beta
|
|
145
|
+
*/
|
|
146
|
+
declare function signJobRequest(secret: string, job: string, now?: number): Promise<string>;
|
|
147
|
+
/**
|
|
148
|
+
* Checks a request's `Authorization` in constant time against the secret and
|
|
149
|
+
* the previous one. Two forms: `GenesisJob t=…,sig=…`, signed for `job` within
|
|
150
|
+
* `maxSkewSeconds` (what schedules send: the secret never travels), or
|
|
151
|
+
* `Bearer <secret>` (a platform cron, a person). The scheme's case and extra
|
|
152
|
+
* spaces don't matter. Returns `null` when it passes, else the response to
|
|
153
|
+
* send: 401, or 500 when no secret is configured.
|
|
154
|
+
*
|
|
155
|
+
* @stability Beta
|
|
156
|
+
*/
|
|
157
|
+
declare function verifyJobRequest(request: Request, options: VerifyJobRequestOptions, job?: string): Promise<Response | null>;
|
|
158
|
+
/**
|
|
159
|
+
* One named job a route can run. Its return value (JSON) is the response's `result` for a
|
|
160
|
+
* bearer call; a signed call (a schedule) gets `{ job, ok: true }` and its `body` is always `{}`.
|
|
161
|
+
*
|
|
162
|
+
* @stability Beta
|
|
163
|
+
*/
|
|
164
|
+
type NamedJob = (context: {
|
|
165
|
+
request: Request;
|
|
166
|
+
body: Record<string, unknown>;
|
|
167
|
+
}) => Promise<unknown> | unknown;
|
|
168
|
+
/** @stability Beta */
|
|
169
|
+
interface JobsHandlerOptions extends VerifyJobRequestOptions {
|
|
170
|
+
/** Job name (`"digests"`, as `genesis_schedule_job` names it) → what it runs. */
|
|
171
|
+
jobs: Readonly<Record<string, NamedJob>>;
|
|
172
|
+
/** Where a job's failure goes (name and the error's message). Default console.error. */
|
|
173
|
+
log?: (line: string) => void;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The app's job route: checks the request is signed for (or carries the
|
|
177
|
+
* secret of) the job named by the route (`name`, e.g. a path param), the
|
|
178
|
+
* body's `job`, or the URL's last segment, then runs it. GET and POST (platform crons send GET). Answers 401, 404 for an
|
|
179
|
+
* unknown job, 405, 200 with the job's result, or 500 with no detail.
|
|
180
|
+
*
|
|
181
|
+
* @stability Beta
|
|
182
|
+
*/
|
|
183
|
+
declare function createJobsHandler(options: JobsHandlerOptions): (request: Request, name?: string) => Promise<Response>;
|
|
184
|
+
|
|
185
|
+
export { type EnqueueOptions, type Job, type JobHandler, type JobOutcome, type JobsClient, JobsError, type JobsHandlerOptions, type NamedJob, PermanentJobError, type RunJobsOptions, type RunJobsReport, type VerifyJobRequestOptions, createJobsHandler, defaultBackoff, enqueueJob, runJobs, signJobRequest, verifyJobRequest };
|
package/dist/jobs.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// src/jobs.ts
|
|
2
|
+
var JobsError = class extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(message, code) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "JobsError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
async function call(client, fn, args) {
|
|
11
|
+
const { data, error } = await client.rpc(fn, args);
|
|
12
|
+
if (error) throw new JobsError(`${fn}: ${error.message}`, error.code);
|
|
13
|
+
return data;
|
|
14
|
+
}
|
|
15
|
+
function enqueueJob(client, kind, payload = {}, options = {}) {
|
|
16
|
+
return call(client, "genesis_job_enqueue", {
|
|
17
|
+
p_kind: kind,
|
|
18
|
+
p_payload: payload,
|
|
19
|
+
p_run_after: options.runAfter?.toISOString() ?? null,
|
|
20
|
+
p_idempotency_key: options.idempotencyKey ?? null,
|
|
21
|
+
p_max_attempts: options.maxAttempts ?? 8
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
var PermanentJobError = class extends Error {
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "PermanentJobError";
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
function defaultBackoff(attempt) {
|
|
31
|
+
const base = Math.min(3600, 30 * 2 ** Math.max(0, attempt - 1));
|
|
32
|
+
return Math.round(base * (0.8 + Math.random() * 0.4));
|
|
33
|
+
}
|
|
34
|
+
function messageOf(error) {
|
|
35
|
+
return (error instanceof Error ? `${error.name}: ${error.message}` : String(error)).slice(0, 1e3);
|
|
36
|
+
}
|
|
37
|
+
async function runJobs(client, options) {
|
|
38
|
+
const kinds = Object.keys(options.handlers);
|
|
39
|
+
const report = { claimed: 0, done: 0, retried: 0, failed: 0, stale: 0, unsettled: 0 };
|
|
40
|
+
if (kinds.length === 0) return report;
|
|
41
|
+
const backoff = options.backoff ?? defaultBackoff;
|
|
42
|
+
const log = options.log ?? ((line) => console.error(line));
|
|
43
|
+
const claimed = await call(client, "genesis_jobs_claim", {
|
|
44
|
+
p_kinds: kinds,
|
|
45
|
+
p_limit: options.limit ?? 25,
|
|
46
|
+
p_lease_seconds: options.leaseSeconds ?? 300
|
|
47
|
+
}) ?? [];
|
|
48
|
+
report.claimed = claimed.length;
|
|
49
|
+
const settle = async (job, outcome, retryAfter, error) => {
|
|
50
|
+
const ok = await call(client, "genesis_jobs_settle", {
|
|
51
|
+
p_id: job.id,
|
|
52
|
+
p_claim_id: job.claim_id,
|
|
53
|
+
p_outcome: outcome,
|
|
54
|
+
p_retry_after_seconds: retryAfter,
|
|
55
|
+
p_error: error
|
|
56
|
+
});
|
|
57
|
+
if (!ok) report.stale++;
|
|
58
|
+
else if (outcome === "done") report.done++;
|
|
59
|
+
else if (outcome === "retry") report.retried++;
|
|
60
|
+
else report.failed++;
|
|
61
|
+
};
|
|
62
|
+
const outcomeOf = async (job) => {
|
|
63
|
+
const handler = options.handlers[job.kind];
|
|
64
|
+
const { claim_id: _claim, ...seen } = job;
|
|
65
|
+
try {
|
|
66
|
+
const outcome = handler ? await handler(seen) : { failed: `no handler for ${job.kind}` };
|
|
67
|
+
if (outcome && "failed" in outcome) {
|
|
68
|
+
log(`[jobs] ${job.kind} ${job.id} attempt ${job.attempt} failed: ${outcome.failed}`);
|
|
69
|
+
return ["failed", null, outcome.failed.slice(0, 1e3)];
|
|
70
|
+
}
|
|
71
|
+
if (outcome && "retryAfterSeconds" in outcome) return ["retry", Math.max(0, Math.round(outcome.retryAfterSeconds)), outcome.error ?? null];
|
|
72
|
+
return ["done", null, null];
|
|
73
|
+
} catch (error) {
|
|
74
|
+
const message = messageOf(error);
|
|
75
|
+
log(`[jobs] ${job.kind} ${job.id} attempt ${job.attempt}: ${message}`);
|
|
76
|
+
return error instanceof PermanentJobError ? ["failed", null, message] : ["retry", backoff(job.attempt), message];
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const runOne = async (job) => {
|
|
80
|
+
const [outcome, retryAfter, error] = await outcomeOf(job);
|
|
81
|
+
try {
|
|
82
|
+
await settle(job, outcome, retryAfter, error);
|
|
83
|
+
} catch (failure) {
|
|
84
|
+
report.unsettled++;
|
|
85
|
+
log(`[jobs] ${job.kind} ${job.id}: could not record ${outcome}: ${messageOf(failure)}`);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const queue = [...claimed];
|
|
89
|
+
const workers = Array.from({ length: Math.max(1, Math.min(options.concurrency ?? 4, queue.length)) }, async () => {
|
|
90
|
+
for (let job = queue.shift(); job; job = queue.shift()) await runOne(job);
|
|
91
|
+
});
|
|
92
|
+
await Promise.all(workers);
|
|
93
|
+
return report;
|
|
94
|
+
}
|
|
95
|
+
var encoder = new TextEncoder();
|
|
96
|
+
async function digest(value) {
|
|
97
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
|
|
98
|
+
}
|
|
99
|
+
function sameBytes(a, b) {
|
|
100
|
+
let diff = a.length ^ b.length;
|
|
101
|
+
for (let i = 0; i < a.length && i < b.length; i++) diff |= a[i] ^ b[i];
|
|
102
|
+
return diff === 0;
|
|
103
|
+
}
|
|
104
|
+
var hex = (bytes) => Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
105
|
+
async function hmacHex(secret, message) {
|
|
106
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
107
|
+
return hex(await crypto.subtle.sign("HMAC", key, encoder.encode(message)));
|
|
108
|
+
}
|
|
109
|
+
async function signJobRequest(secret, job, now = Math.floor(Date.now() / 1e3)) {
|
|
110
|
+
return `GenesisJob t=${now},sig=${await hmacHex(secret, `${now}.${job}`)}`;
|
|
111
|
+
}
|
|
112
|
+
async function verifyJobRequest(request, options, job) {
|
|
113
|
+
if (!options.secret) return Response.json({ error: "The job secret is not configured." }, { status: 500 });
|
|
114
|
+
const secrets = [options.secret, ...options.previousSecret ? [options.previousSecret] : []];
|
|
115
|
+
const header = (request.headers.get("authorization") ?? "").trim();
|
|
116
|
+
const unauthorized = () => Response.json({ error: "Unauthorized." }, { status: 401 });
|
|
117
|
+
const bearer = /^bearer\s+([^\s,]+)$/i.exec(header)?.[1];
|
|
118
|
+
if (bearer) {
|
|
119
|
+
const given = await digest(bearer);
|
|
120
|
+
let ok = false;
|
|
121
|
+
for (const secret of secrets) ok = sameBytes(given, await digest(secret)) || ok;
|
|
122
|
+
return ok ? null : unauthorized();
|
|
123
|
+
}
|
|
124
|
+
const signed = /^genesisjob\s+t=(\d{1,12})\s*,\s*sig=([0-9a-f]{64})$/i.exec(header);
|
|
125
|
+
if (signed && job) {
|
|
126
|
+
const t = Number(signed[1]);
|
|
127
|
+
const now = options.now?.() ?? Math.floor(Date.now() / 1e3);
|
|
128
|
+
if (Math.abs(now - t) > (options.maxSkewSeconds ?? 300)) return unauthorized();
|
|
129
|
+
const given = await digest(signed[2].toLowerCase());
|
|
130
|
+
let ok = false;
|
|
131
|
+
for (const secret of secrets) ok = sameBytes(given, await digest(await hmacHex(secret, `${t}.${job}`))) || ok;
|
|
132
|
+
return ok ? null : unauthorized();
|
|
133
|
+
}
|
|
134
|
+
return unauthorized();
|
|
135
|
+
}
|
|
136
|
+
var JOB_NAME = /^[a-z][a-z0-9]*([-_][a-z0-9]+)*$/;
|
|
137
|
+
function createJobsHandler(options) {
|
|
138
|
+
const log = options.log ?? ((line) => console.error(line));
|
|
139
|
+
return async (request, name) => {
|
|
140
|
+
if (request.method !== "GET" && request.method !== "POST") {
|
|
141
|
+
return Response.json({ error: "Use GET or POST." }, { status: 405, headers: { Allow: "GET, POST" } });
|
|
142
|
+
}
|
|
143
|
+
const signed = /^genesisjob\s/i.test((request.headers.get("authorization") ?? "").trim());
|
|
144
|
+
const raw = request.method === "POST" && !signed ? await request.clone().json().catch(() => null) : null;
|
|
145
|
+
const body = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
146
|
+
const job = name ?? (typeof body["job"] === "string" ? body["job"] : new URL(request.url).pathname.split("/").filter(Boolean).at(-1));
|
|
147
|
+
const refused = await verifyJobRequest(request, options, job);
|
|
148
|
+
if (refused) return refused;
|
|
149
|
+
const run = job && JOB_NAME.test(job) && Object.hasOwn(options.jobs, job) ? options.jobs[job] : void 0;
|
|
150
|
+
if (!run) return Response.json({ error: "No such job." }, { status: 404 });
|
|
151
|
+
try {
|
|
152
|
+
const result = await run({ request, body }) ?? null;
|
|
153
|
+
return Response.json(signed ? { job, ok: true } : { job, result });
|
|
154
|
+
} catch (error) {
|
|
155
|
+
log(`[jobs] ${job}: ${messageOf(error)}`);
|
|
156
|
+
return Response.json({ job, error: "The job failed; see the server log." }, { status: 500 });
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export { JobsError, PermanentJobError, createJobsHandler, defaultBackoff, enqueueJob, runJobs, signJobRequest, verifyJobRequest };
|
|
162
|
+
//# sourceMappingURL=jobs.js.map
|
|
163
|
+
//# sourceMappingURL=jobs.js.map
|
package/dist/jobs.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/jobs.ts"],"names":[],"mappings":"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","file":"jobs.js","sourcesContent":["/**\n * `@marktiderman/genesis-supabase/jobs`: the server half of\n * `migrations/0007_genesis_jobs.sql`. Server only (it takes the service-role\n * client and the job secret); never import it into browser or app code.\n *\n * - `enqueueJob`: queue work from server code (triggers enqueue in SQL).\n * - `runJobs`: claim due jobs, run each kind's handler, settle each one, with\n * backoff on failure. Never throws for one job's failure.\n * - `createJobsHandler`: the app's one job route. It checks the request is\n * signed for the job (`genesis_schedule_job` signs; the secret never\n * travels) or carries the bearer secret (a platform cron, a person), with a\n * previous secret accepted while you rotate it, and runs the named job.\n *\n * ```ts\n * // src/routes/api/jobs/$job.ts (any server route that hands over a Request)\n * const handle = createJobsHandler({\n * secret: process.env.GENESIS_JOBS_SECRET,\n * previousSecret: process.env.GENESIS_JOBS_SECRET_PREVIOUS,\n * jobs: {\n * queue: () => runJobs(admin, { handlers: { \"email.digest\": sendDigest } }),\n * cleanup: () => removeQueuedFiles(admin),\n * },\n * });\n * export const POST = ({ request, params }) => handle(request, params.job);\n * ```\n *\n * @packageDocumentation\n */\n\n/** The parts of a Supabase client these helpers use: a service-role `SupabaseClient` fits. */\nexport interface JobsClient {\n // Loose on purpose: a client typed by an app's Database narrows `rpc`'s\n // function names, and the app's generated types may not list these yet.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n rpc: (fn: any, ...rest: any[]) => PromiseLike<{ data: unknown; error: { message: string; code?: string } | null }>;\n}\n\n/** A failure from the queue's own functions (not from a handler). */\nexport class JobsError extends Error {\n readonly code: string | undefined;\n constructor(message: string, code?: string) {\n super(message);\n this.name = \"JobsError\";\n this.code = code;\n }\n}\n\nasync function call<T>(client: JobsClient, fn: string, args: Record<string, unknown>): Promise<T> {\n const { data, error } = await client.rpc(fn, args);\n if (error) throw new JobsError(`${fn}: ${error.message}`, error.code);\n return data as T;\n}\n\n/** @stability Beta */\nexport interface EnqueueOptions {\n /** Run no earlier than this. Default now. */\n runAfter?: Date;\n /** One job per kind and key while it is queued or running: a repeat returns the queued job's id. */\n idempotencyKey?: string;\n /** Attempts before it fails for good, 1 to 100. Default 8. */\n maxAttempts?: number;\n}\n\n/**\n * Queue one job of `kind` (`\"email.digest\"`, lowercase) with a JSON object\n * payload. Returns the job's id. Payloads are readable by the service role:\n * put ids in them, never secrets.\n *\n * @stability Beta\n */\nexport function enqueueJob(client: JobsClient, kind: string, payload: Record<string, unknown> = {}, options: EnqueueOptions = {}): Promise<string> {\n return call<string>(client, \"genesis_job_enqueue\", {\n p_kind: kind,\n p_payload: payload,\n p_run_after: options.runAfter?.toISOString() ?? null,\n p_idempotency_key: options.idempotencyKey ?? null,\n p_max_attempts: options.maxAttempts ?? 8,\n });\n}\n\n/** One claimed job, as a handler sees it. @stability Beta */\nexport interface Job<Payload = Record<string, unknown>> {\n id: string;\n kind: string;\n payload: Payload;\n /** This attempt's number, from 1. */\n attempt: number;\n /** An earlier attempt's lease ran out: it may have done the work. Handlers that can look first, do. */\n redelivery: boolean;\n}\n\n/**\n * What a handler can return. Nothing (or `{ done: true }`) is done. Throwing\n * retries with backoff; throw a `PermanentJobError` (or return\n * `{ failed }`) to stop.\n *\n * @stability Beta\n */\nexport type JobOutcome =\n | void\n | { done: true }\n | { retryAfterSeconds: number; error?: string }\n | { failed: string };\n\n/** Thrown by a handler for a failure that won't get better with a retry (a bad payload, a 4xx). @stability Beta */\nexport class PermanentJobError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"PermanentJobError\";\n }\n}\n\n// Handlers are keyed by kind, so each takes its own payload type.\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type JobHandler = (job: Job<any>) => Promise<JobOutcome> | JobOutcome;\n\n/** @stability Beta */\nexport interface RunJobsOptions {\n /** Kind → handler. Only these kinds are claimed. */\n handlers: Readonly<Record<string, JobHandler>>;\n /** Jobs claimed in this run. Default 25. */\n limit?: number;\n /** How long a claim holds a job; a handler must finish inside it. Default 300. */\n leaseSeconds?: number;\n /** Jobs run at once. Default 4. */\n concurrency?: number;\n /** Seconds before retry `attempt` (from 1). Default 30 s doubling, jittered, at most an hour. */\n backoff?: (attempt: number) => number;\n /** Where one line per failed job goes (kind, id, attempt, the error's message). Default console.error. */\n log?: (line: string) => void;\n}\n\n/** @stability Beta */\nexport interface RunJobsReport {\n claimed: number;\n done: number;\n retried: number;\n failed: number;\n /** Settles refused because the lease ran out first (another run has the job). */\n stale: number;\n /** Settles that failed (the database unreachable): those jobs come back when their lease runs out. */\n unsettled: number;\n}\n\n/** 30 s, doubling per attempt, ±20% jitter, at most an hour. */\nexport function defaultBackoff(attempt: number): number {\n const base = Math.min(3600, 30 * 2 ** Math.max(0, attempt - 1));\n return Math.round(base * (0.8 + Math.random() * 0.4));\n}\n\nfunction messageOf(error: unknown): string {\n return (error instanceof Error ? `${error.name}: ${error.message}` : String(error)).slice(0, 1000);\n}\n\n/**\n * Claim due jobs of the handled kinds, run them, and settle each: done, a\n * retry after backoff, or failed. One job's failure, or a failure to record\n * it, never stops the others; only a failed claim throws. Nothing about a\n * payload is logged.\n *\n * @stability Beta\n */\nexport async function runJobs(client: JobsClient, options: RunJobsOptions): Promise<RunJobsReport> {\n const kinds = Object.keys(options.handlers);\n const report: RunJobsReport = { claimed: 0, done: 0, retried: 0, failed: 0, stale: 0, unsettled: 0 };\n if (kinds.length === 0) return report;\n const backoff = options.backoff ?? defaultBackoff;\n const log = options.log ?? ((line: string) => console.error(line));\n\n type Claimed = Job & { claim_id: string };\n const claimed =\n (await call<Claimed[] | null>(client, \"genesis_jobs_claim\", {\n p_kinds: kinds,\n p_limit: options.limit ?? 25,\n p_lease_seconds: options.leaseSeconds ?? 300,\n })) ?? [];\n report.claimed = claimed.length;\n\n const settle = async (job: Claimed, outcome: \"done\" | \"retry\" | \"failed\", retryAfter: number | null, error: string | null) => {\n const ok = await call<boolean>(client, \"genesis_jobs_settle\", {\n p_id: job.id,\n p_claim_id: job.claim_id,\n p_outcome: outcome,\n p_retry_after_seconds: retryAfter,\n p_error: error,\n });\n if (!ok) report.stale++;\n else if (outcome === \"done\") report.done++;\n else if (outcome === \"retry\") report.retried++;\n else report.failed++;\n };\n\n type Settle = [\"done\" | \"retry\" | \"failed\", number | null, string | null];\n\n // What the handler says, as a settle. Only the handler runs inside this try.\n const outcomeOf = async (job: Claimed): Promise<Settle> => {\n const handler = options.handlers[job.kind];\n const { claim_id: _claim, ...seen } = job;\n try {\n const outcome = handler ? await handler(seen) : { failed: `no handler for ${job.kind}` };\n if (outcome && \"failed\" in outcome) {\n log(`[jobs] ${job.kind} ${job.id} attempt ${job.attempt} failed: ${outcome.failed}`);\n return [\"failed\", null, outcome.failed.slice(0, 1000)];\n }\n if (outcome && \"retryAfterSeconds\" in outcome) return [\"retry\", Math.max(0, Math.round(outcome.retryAfterSeconds)), outcome.error ?? null];\n return [\"done\", null, null];\n } catch (error) {\n const message = messageOf(error);\n log(`[jobs] ${job.kind} ${job.id} attempt ${job.attempt}: ${message}`);\n return error instanceof PermanentJobError ? [\"failed\", null, message] : [\"retry\", backoff(job.attempt), message];\n }\n };\n\n const runOne = async (job: Claimed) => {\n const [outcome, retryAfter, error] = await outcomeOf(job);\n try {\n await settle(job, outcome, retryAfter, error);\n } catch (failure) {\n // The queue didn't take the outcome: the lease runs out and the job comes back as a\n // redelivery. The other jobs in this run carry on.\n report.unsettled++;\n log(`[jobs] ${job.kind} ${job.id}: could not record ${outcome}: ${messageOf(failure)}`);\n }\n };\n\n const queue = [...claimed];\n const workers = Array.from({ length: Math.max(1, Math.min(options.concurrency ?? 4, queue.length)) }, async () => {\n for (let job = queue.shift(); job; job = queue.shift()) await runOne(job);\n });\n await Promise.all(workers);\n return report;\n}\n\nconst encoder = new TextEncoder();\n\nasync function digest(value: string): Promise<Uint8Array> {\n return new Uint8Array(await crypto.subtle.digest(\"SHA-256\", encoder.encode(value)));\n}\n\nfunction sameBytes(a: Uint8Array, b: Uint8Array): boolean {\n let diff = a.length ^ b.length;\n for (let i = 0; i < a.length && i < b.length; i++) diff |= a[i]! ^ b[i]!;\n return diff === 0;\n}\n\n/** @stability Beta */\nexport interface VerifyJobRequestOptions {\n /** The job secret. Unset refuses every request (500). */\n secret: string | undefined;\n /** The secret before a rotation, accepted too. Remove it once every caller has the new one. */\n previousSecret?: string | undefined;\n /** How far a signed request's time may be from now, in seconds. Default 300. */\n maxSkewSeconds?: number;\n /** Now, in seconds (for tests). */\n now?: () => number;\n}\n\nconst hex = (bytes: ArrayBuffer) => Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n\nasync function hmacHex(secret: string, message: string): Promise<string> {\n const key = await crypto.subtle.importKey(\"raw\", encoder.encode(secret), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"]);\n return hex(await crypto.subtle.sign(\"HMAC\", key, encoder.encode(message)));\n}\n\n/**\n * The `Authorization` value a scheduler sends for `job`: `GenesisJob\n * t=<unix seconds>,sig=<hex HMAC-SHA256 of \"<t>.<job>\">`. What\n * `genesis_schedule_job` sends; for calling a job route from your own code.\n *\n * @stability Beta\n */\nexport async function signJobRequest(secret: string, job: string, now: number = Math.floor(Date.now() / 1000)): Promise<string> {\n return `GenesisJob t=${now},sig=${await hmacHex(secret, `${now}.${job}`)}`;\n}\n\n/**\n * Checks a request's `Authorization` in constant time against the secret and\n * the previous one. Two forms: `GenesisJob t=…,sig=…`, signed for `job` within\n * `maxSkewSeconds` (what schedules send: the secret never travels), or\n * `Bearer <secret>` (a platform cron, a person). The scheme's case and extra\n * spaces don't matter. Returns `null` when it passes, else the response to\n * send: 401, or 500 when no secret is configured.\n *\n * @stability Beta\n */\nexport async function verifyJobRequest(request: Request, options: VerifyJobRequestOptions, job?: string): Promise<Response | null> {\n if (!options.secret) return Response.json({ error: \"The job secret is not configured.\" }, { status: 500 });\n const secrets = [options.secret, ...(options.previousSecret ? [options.previousSecret] : [])];\n const header = (request.headers.get(\"authorization\") ?? \"\").trim();\n const unauthorized = () => Response.json({ error: \"Unauthorized.\" }, { status: 401 });\n\n const bearer = /^bearer\\s+([^\\s,]+)$/i.exec(header)?.[1];\n if (bearer) {\n const given = await digest(bearer);\n let ok = false;\n for (const secret of secrets) ok = sameBytes(given, await digest(secret)) || ok;\n return ok ? null : unauthorized();\n }\n\n const signed = /^genesisjob\\s+t=(\\d{1,12})\\s*,\\s*sig=([0-9a-f]{64})$/i.exec(header);\n if (signed && job) {\n const t = Number(signed[1]);\n const now = options.now?.() ?? Math.floor(Date.now() / 1000);\n if (Math.abs(now - t) > (options.maxSkewSeconds ?? 300)) return unauthorized();\n const given = await digest(signed[2]!.toLowerCase());\n let ok = false;\n for (const secret of secrets) ok = sameBytes(given, await digest(await hmacHex(secret, `${t}.${job}`))) || ok;\n return ok ? null : unauthorized();\n }\n return unauthorized();\n}\n\n/**\n * One named job a route can run. Its return value (JSON) is the response's `result` for a\n * bearer call; a signed call (a schedule) gets `{ job, ok: true }` and its `body` is always `{}`.\n *\n * @stability Beta\n */\nexport type NamedJob = (context: { request: Request; body: Record<string, unknown> }) => Promise<unknown> | unknown;\n\n/** @stability Beta */\nexport interface JobsHandlerOptions extends VerifyJobRequestOptions {\n /** Job name (`\"digests\"`, as `genesis_schedule_job` names it) → what it runs. */\n jobs: Readonly<Record<string, NamedJob>>;\n /** Where a job's failure goes (name and the error's message). Default console.error. */\n log?: (line: string) => void;\n}\n\nconst JOB_NAME = /^[a-z][a-z0-9]*([-_][a-z0-9]+)*$/;\n\n/**\n * The app's job route: checks the request is signed for (or carries the\n * secret of) the job named by the route (`name`, e.g. a path param), the\n * body's `job`, or the URL's last segment, then runs it. GET and POST (platform crons send GET). Answers 401, 404 for an\n * unknown job, 405, 200 with the job's result, or 500 with no detail.\n *\n * @stability Beta\n */\nexport function createJobsHandler(options: JobsHandlerOptions): (request: Request, name?: string) => Promise<Response> {\n const log = options.log ?? ((line: string) => console.error(line));\n return async (request, name) => {\n if (request.method !== \"GET\" && request.method !== \"POST\") {\n return Response.json({ error: \"Use GET or POST.\" }, { status: 405, headers: { Allow: \"GET, POST\" } });\n }\n // A signed call (a schedule) is signed for the job name only, so nothing else it carries is\n // trusted: its body is ignored, and its response carries no result (pg_net keeps responses\n // in net._http_response, which other roles may read).\n const signed = /^genesisjob\\s/i.test((request.headers.get(\"authorization\") ?? \"\").trim());\n const raw = request.method === \"POST\" && !signed ? await request.clone().json().catch(() => null) : null;\n const body = raw && typeof raw === \"object\" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};\n const job = name ?? (typeof body[\"job\"] === \"string\" ? body[\"job\"] : new URL(request.url).pathname.split(\"/\").filter(Boolean).at(-1));\n\n // The signature is for one job name, so check it against the name the route will run.\n const refused = await verifyJobRequest(request, options, job);\n if (refused) return refused;\n\n const run = job && JOB_NAME.test(job) && Object.hasOwn(options.jobs, job) ? options.jobs[job] : undefined;\n if (!run) return Response.json({ error: \"No such job.\" }, { status: 404 });\n\n try {\n const result = (await run({ request, body })) ?? null;\n return Response.json(signed ? { job, ok: true } : { job, result });\n } catch (error) {\n log(`[jobs] ${job}: ${messageOf(error)}`);\n return Response.json({ job, error: \"The job failed; see the server log.\" }, { status: 500 });\n }\n };\n}\n"]}
|