@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.
@@ -0,0 +1,352 @@
1
+ -- Genesis jobs: one background-job queue and one way to schedule work.
2
+ --
3
+ -- Two halves:
4
+ --
5
+ -- 1. A queue, private.genesis_jobs. Code (a trigger, a server function, the app) enqueues a job
6
+ -- of a kind with a JSON payload; a runner claims due jobs with SKIP LOCKED, each leased to a
7
+ -- claim id, does the work and settles each one: done, retry later, or failed. A job whose
8
+ -- lease runs out comes back as a redelivery; one that keeps dying fails as lease_expired.
9
+ -- An idempotency key makes an enqueue that repeats (a retried trigger, a double click) one job.
10
+ -- The runner half is `runJobs` in @marktiderman/genesis-supabase/jobs.
11
+ --
12
+ -- 2. Schedules. genesis_schedule_job(name, cron, job) asks pg_cron to POST to the app's job route
13
+ -- (`<url>/<job>`) through pg_net. The request carries no secret: it is signed,
14
+ -- `Authorization: GenesisJob t=<unix seconds>,sig=<hex HMAC-SHA256 of "<t>.<job>">`, with the
15
+ -- secret the route checks. A signature is good for that one job for five minutes, so the copy
16
+ -- pg_net keeps in its queue until it sends (net.http_request_queue, readable by more roles than
17
+ -- you'd think) is worth little. The URL and the secret are read from Vault by name at call
18
+ -- time and never stored in cron's table:
19
+ --
20
+ -- select vault.create_secret('https://app.example.com/api/jobs', 'genesis_jobs_url');
21
+ -- select vault.create_secret('<a long random secret>', 'genesis_jobs_secret');
22
+ -- select genesis_schedule_job('send-digests', '*/5 * * * *', 'digests');
23
+ --
24
+ -- The app's route is `createJobsHandler` in @marktiderman/genesis-supabase/jobs, which
25
+ -- checks the signature (or a plain bearer secret, for a platform cron or a person). pg_cron,
26
+ -- pg_net and pgcrypto are only needed for schedules: the queue works without them. Schedules
27
+ -- are cron jobs named `genesis-job:<name>`, so these functions never touch another cron job.
28
+ -- The 14-day purge is scheduled only if pg_cron is there when this runs; enable pg_cron later
29
+ -- and schedule it yourself: select cron.schedule('genesis-jobs-purge', '20 4 * * *',
30
+ -- 'select private.genesis_jobs_purge()');
31
+ --
32
+ -- Access: the queue has row level security on, no policies and no grants to anon or
33
+ -- authenticated. Everything goes through the functions below, granted by name to service_role
34
+ -- only (a server job, a trigger running as its owner). Payloads never hold secrets: they are
35
+ -- readable by anyone with the service role.
36
+ --
37
+ -- Use: copy this file into your app's `supabase/migrations/` under a new timestamped name
38
+ -- (`genesis migrate` does it). Never edit it once applied; change it with a new migration.
39
+ -- It stands alone.
40
+ --
41
+ -- Verify: the pgTAP test beside this file (`tests/0007_genesis_jobs.test.sql`).
42
+
43
+ create schema if not exists private;
44
+
45
+ create table private.genesis_jobs (
46
+ id uuid primary key default gen_random_uuid(),
47
+ kind text not null
48
+ constraint genesis_jobs_kind_format check (kind ~ '^[a-z][a-z0-9]*([-_.:][a-z0-9]+)*$' and char_length(kind) <= 100),
49
+ payload jsonb not null default '{}'::jsonb,
50
+ -- One job per (kind, key) while it is queued or running: a second enqueue returns the first.
51
+ -- Once the job is done, failed or cancelled the key is free again.
52
+ idempotency_key text
53
+ constraint genesis_jobs_idempotency_key_length check (char_length(idempotency_key) between 1 and 200),
54
+ status text not null default 'pending'
55
+ constraint genesis_jobs_status check (status in ('pending', 'running', 'done', 'failed', 'cancelled')),
56
+ run_after timestamptz not null default now(),
57
+ -- Attempts started (claims), including redeliveries.
58
+ attempts integer not null default 0,
59
+ max_attempts integer not null default 8
60
+ constraint genesis_jobs_max_attempts check (max_attempts between 1 and 100),
61
+ lease_until timestamptz,
62
+ -- The claim this job is leased to; settling with another one is stale.
63
+ claim_id uuid,
64
+ last_error text
65
+ constraint genesis_jobs_last_error_length check (char_length(last_error) <= 1000),
66
+ created_at timestamptz not null default now(),
67
+ updated_at timestamptz not null default now(),
68
+ finished_at timestamptz
69
+ );
70
+
71
+ create unique index genesis_jobs_idempotency on private.genesis_jobs (kind, idempotency_key)
72
+ where idempotency_key is not null and status in ('pending', 'running');
73
+ create index genesis_jobs_due on private.genesis_jobs (run_after) where status = 'pending';
74
+ create index genesis_jobs_leased on private.genesis_jobs (lease_until) where status = 'running';
75
+ create index genesis_jobs_finished on private.genesis_jobs (finished_at) where finished_at is not null;
76
+
77
+ alter table private.genesis_jobs enable row level security;
78
+ revoke all on table private.genesis_jobs from public, anon, authenticated;
79
+
80
+ -- Queues one job and returns its id. With an idempotency key, a job of the same kind and key
81
+ -- that is still pending or running is returned instead of a new one.
82
+ create or replace function public.genesis_job_enqueue(
83
+ p_kind text,
84
+ p_payload jsonb default '{}'::jsonb,
85
+ p_run_after timestamptz default null,
86
+ p_idempotency_key text default null,
87
+ p_max_attempts integer default 8
88
+ ) returns uuid
89
+ language plpgsql
90
+ security definer
91
+ set search_path = ''
92
+ as $$
93
+ declare
94
+ job uuid;
95
+ begin
96
+ if p_kind is null then
97
+ raise exception 'genesis_job_enqueue: kind is required' using errcode = '22023';
98
+ end if;
99
+ if p_payload is null or jsonb_typeof(p_payload) <> 'object' then
100
+ raise exception 'genesis_job_enqueue: payload is a JSON object' using errcode = '22023';
101
+ end if;
102
+ if p_idempotency_key is not null then
103
+ select j.id into job from private.genesis_jobs j
104
+ where j.kind = p_kind and j.idempotency_key = p_idempotency_key and j.status in ('pending', 'running');
105
+ if found then
106
+ return job;
107
+ end if;
108
+ end if;
109
+ begin
110
+ insert into private.genesis_jobs (kind, payload, run_after, idempotency_key, max_attempts)
111
+ values (p_kind, p_payload, coalesce(p_run_after, now()), p_idempotency_key, coalesce(p_max_attempts, 8))
112
+ returning id into job;
113
+ exception when unique_violation then
114
+ -- Two enqueues with one key raced; the other one won.
115
+ select j.id into job from private.genesis_jobs j
116
+ where j.kind = p_kind and j.idempotency_key = p_idempotency_key and j.status in ('pending', 'running');
117
+ end;
118
+ return job;
119
+ end $$;
120
+
121
+ -- Hands out up to p_limit due jobs of the given kinds (all kinds when null), oldest first, each
122
+ -- leased for p_lease_seconds to one new claim id. Due: pending and past run_after, or running
123
+ -- with a lease that ran out (`redelivery`: the last attempt may have done the work). A job whose
124
+ -- lease ran out on its last attempt fails as lease_expired instead of coming back for ever.
125
+ create or replace function public.genesis_jobs_claim(
126
+ p_kinds text[] default null,
127
+ p_limit integer default 25,
128
+ p_lease_seconds integer default 300
129
+ ) returns table (id uuid, kind text, payload jsonb, attempt integer, redelivery boolean, claim_id uuid)
130
+ language plpgsql
131
+ security definer
132
+ set search_path = ''
133
+ as $$
134
+ #variable_conflict use_column
135
+ declare
136
+ claim uuid := gen_random_uuid();
137
+ begin
138
+ if p_limit is null or p_limit not between 1 and 1000 then
139
+ raise exception 'genesis_jobs_claim: limit is 1 to 1000' using errcode = '22023';
140
+ end if;
141
+ if p_lease_seconds is null or p_lease_seconds not between 1 and 3600 then
142
+ raise exception 'genesis_jobs_claim: lease is 1 to 3600 seconds' using errcode = '22023';
143
+ end if;
144
+
145
+ -- Out of attempts with the lease gone: the runner died with it every time.
146
+ update private.genesis_jobs j
147
+ set status = 'failed', last_error = 'lease_expired', lease_until = null, claim_id = null,
148
+ finished_at = now(), updated_at = now()
149
+ where j.status = 'running' and j.lease_until < now() and j.attempts >= j.max_attempts
150
+ and (p_kinds is null or j.kind = any (p_kinds));
151
+
152
+ return query
153
+ with due as (
154
+ select j.id, j.status = 'running' as redelivery
155
+ from private.genesis_jobs j
156
+ where ((j.status = 'pending' and j.run_after <= now()) or (j.status = 'running' and j.lease_until < now()))
157
+ and (p_kinds is null or j.kind = any (p_kinds))
158
+ order by j.run_after, j.created_at
159
+ limit p_limit
160
+ for update skip locked
161
+ )
162
+ update private.genesis_jobs j
163
+ set status = 'running', attempts = j.attempts + 1, lease_until = now() + make_interval(secs => p_lease_seconds),
164
+ claim_id = claim, updated_at = now()
165
+ from due
166
+ where j.id = due.id
167
+ returning j.id, j.kind, j.payload, j.attempts, due.redelivery, j.claim_id;
168
+ end $$;
169
+
170
+ -- Records what happened to a claimed job. p_outcome: 'done'; 'retry' (back to pending after
171
+ -- p_retry_after_seconds, or failed when out of attempts); 'failed' (permanent). Returns false,
172
+ -- changing nothing, when the claim is stale (the lease ran out and the job was claimed again).
173
+ create or replace function public.genesis_jobs_settle(
174
+ p_id uuid,
175
+ p_claim_id uuid,
176
+ p_outcome text,
177
+ p_retry_after_seconds integer default null,
178
+ p_error text default null
179
+ ) returns boolean
180
+ language plpgsql
181
+ security definer
182
+ set search_path = ''
183
+ as $$
184
+ declare
185
+ j private.genesis_jobs;
186
+ begin
187
+ if p_outcome is null or p_outcome not in ('done', 'retry', 'failed') then
188
+ raise exception 'genesis_jobs_settle: outcome is done, retry or failed' using errcode = '22023';
189
+ end if;
190
+ if p_outcome = 'retry' and (p_retry_after_seconds is null or p_retry_after_seconds not between 0 and 604800) then
191
+ raise exception 'genesis_jobs_settle: a retry needs retry_after_seconds, 0 to 604800' using errcode = '22023';
192
+ end if;
193
+
194
+ select * into j from private.genesis_jobs x where x.id = p_id for update;
195
+ if not found or j.status <> 'running' or j.claim_id is distinct from p_claim_id then
196
+ return false;
197
+ end if;
198
+
199
+ if p_outcome = 'done' then
200
+ update private.genesis_jobs x set status = 'done', last_error = null, lease_until = null, claim_id = null,
201
+ finished_at = now(), updated_at = now() where x.id = p_id;
202
+ elsif p_outcome = 'retry' and j.attempts < j.max_attempts then
203
+ update private.genesis_jobs x set status = 'pending', last_error = left(p_error, 1000),
204
+ run_after = now() + make_interval(secs => p_retry_after_seconds), lease_until = null, claim_id = null,
205
+ updated_at = now() where x.id = p_id;
206
+ else
207
+ update private.genesis_jobs x set status = 'failed', last_error = left(coalesce(p_error, 'failed'), 1000),
208
+ lease_until = null, claim_id = null, finished_at = now(), updated_at = now() where x.id = p_id;
209
+ end if;
210
+ return true;
211
+ end $$;
212
+
213
+ -- Cancels a job that hasn't run yet. A running job finishes its attempt; true when cancelled.
214
+ create or replace function public.genesis_job_cancel(p_id uuid) returns boolean
215
+ language sql
216
+ security definer
217
+ set search_path = ''
218
+ as $$
219
+ with cancelled as (
220
+ update private.genesis_jobs j set status = 'cancelled', finished_at = now(), updated_at = now()
221
+ where j.id = p_id and j.status = 'pending'
222
+ returning 1
223
+ )
224
+ select exists (select 1 from cancelled)
225
+ $$;
226
+
227
+ -- Deletes finished jobs (done, failed, cancelled) older than p_keep. Scheduled below when pg_cron
228
+ -- is there; returns how many went.
229
+ create or replace function private.genesis_jobs_purge(p_keep interval default interval '14 days') returns integer
230
+ language sql
231
+ security definer
232
+ set search_path = ''
233
+ as $$
234
+ with gone as (
235
+ delete from private.genesis_jobs j
236
+ where j.status in ('done', 'failed', 'cancelled') and j.finished_at < now() - p_keep
237
+ returning 1
238
+ )
239
+ select count(*)::integer from gone
240
+ $$;
241
+
242
+ -- POSTs {"job": p_job} to <genesis_jobs_url>/<p_job>, signed with genesis_jobs_secret (both read
243
+ -- from Vault now; the secret itself is never sent). Returns pg_net's request id. What cron runs for
244
+ -- each schedule.
245
+ create or replace function private.genesis_jobs_call(p_job text) returns bigint
246
+ language plpgsql
247
+ security definer
248
+ set search_path = ''
249
+ as $$
250
+ declare
251
+ base text;
252
+ secret text;
253
+ stamp text;
254
+ signature text;
255
+ request bigint;
256
+ begin
257
+ if p_job is null or p_job !~ '^[a-z][a-z0-9]*([-_][a-z0-9]+)*$' or char_length(p_job) > 100 then
258
+ raise exception 'genesis_jobs_call: job is a lowercase name like "send-digests"' using errcode = '22023';
259
+ end if;
260
+ if to_regclass('vault.decrypted_secrets') is null then
261
+ raise exception 'genesis_jobs_call: Vault is not installed' using errcode = '55000';
262
+ end if;
263
+ if to_regnamespace('net') is null then
264
+ raise exception 'genesis_jobs_call: pg_net is not installed (create extension pg_net)' using errcode = '55000';
265
+ end if;
266
+ execute 'select decrypted_secret from vault.decrypted_secrets where name = $1' into base using 'genesis_jobs_url';
267
+ execute 'select decrypted_secret from vault.decrypted_secrets where name = $1' into secret using 'genesis_jobs_secret';
268
+ if base is null or secret is null then
269
+ raise exception 'genesis_jobs_call: set the Vault secrets genesis_jobs_url and genesis_jobs_secret' using errcode = '55000';
270
+ end if;
271
+ if to_regprocedure('extensions.hmac(text, text, text)') is null then
272
+ raise exception 'genesis_jobs_call: pgcrypto is not installed (create extension pgcrypto with schema extensions)' using errcode = '55000';
273
+ end if;
274
+ stamp := floor(extract(epoch from clock_timestamp()))::bigint::text;
275
+ execute 'select encode(extensions.hmac($1, $2, ''sha256''), ''hex'')' into signature using stamp || '.' || p_job, secret;
276
+ execute 'select net.http_post(url := $1, body := $2, headers := $3, timeout_milliseconds := 10000)'
277
+ into request
278
+ using rtrim(base, '/') || '/' || p_job,
279
+ jsonb_build_object('job', p_job),
280
+ jsonb_build_object('Content-Type', 'application/json', 'Authorization', 'GenesisJob t=' || stamp || ',sig=' || signature);
281
+ return request;
282
+ end $$;
283
+
284
+ -- Schedules (or reschedules) p_job on a cron schedule, as the cron job `genesis-job:<p_name>`.
285
+ -- Returns cron's job id.
286
+ create or replace function public.genesis_schedule_job(p_name text, p_schedule text, p_job text) returns bigint
287
+ language plpgsql
288
+ security definer
289
+ set search_path = ''
290
+ as $$
291
+ declare
292
+ scheduled bigint;
293
+ begin
294
+ if p_name is null or p_name !~ '^[a-z][a-z0-9]*([-_][a-z0-9]+)*$' or char_length(p_name) > 100 then
295
+ raise exception 'genesis_schedule_job: name is a lowercase name like "send-digests"' using errcode = '22023';
296
+ end if;
297
+ if p_job is null or p_job !~ '^[a-z][a-z0-9]*([-_][a-z0-9]+)*$' or char_length(p_job) > 100 then
298
+ raise exception 'genesis_schedule_job: job is a lowercase name like "digests"' using errcode = '22023';
299
+ end if;
300
+ if to_regnamespace('cron') is null then
301
+ raise exception 'genesis_schedule_job: pg_cron is not installed (create extension pg_cron)' using errcode = '55000';
302
+ end if;
303
+ execute 'select cron.schedule($1, $2, $3)' into scheduled
304
+ using 'genesis-job:' || p_name, p_schedule, format('select private.genesis_jobs_call(%L)', p_job);
305
+ return scheduled;
306
+ end $$;
307
+
308
+ -- Removes a schedule made by genesis_schedule_job (only `genesis-job:` cron jobs). True when one was there.
309
+ create or replace function public.genesis_unschedule_job(p_name text) returns boolean
310
+ language plpgsql
311
+ security definer
312
+ set search_path = ''
313
+ as $$
314
+ declare
315
+ removed boolean := false;
316
+ begin
317
+ if to_regnamespace('cron') is null then
318
+ return false;
319
+ end if;
320
+ if p_name is null or p_name !~ '^[a-z][a-z0-9]*([-_][a-z0-9]+)*$' or char_length(p_name) > 100 then
321
+ return false;
322
+ end if;
323
+ execute 'select exists (select 1 from cron.job where jobname = $1)' into removed using 'genesis-job:' || p_name;
324
+ if removed then
325
+ execute 'select cron.unschedule($1)' using 'genesis-job:' || p_name;
326
+ end if;
327
+ return removed;
328
+ end $$;
329
+
330
+ revoke execute on function public.genesis_job_enqueue(text, jsonb, timestamptz, text, integer) from public, anon, authenticated;
331
+ revoke execute on function public.genesis_jobs_claim(text[], integer, integer) from public, anon, authenticated;
332
+ revoke execute on function public.genesis_jobs_settle(uuid, uuid, text, integer, text) from public, anon, authenticated;
333
+ revoke execute on function public.genesis_job_cancel(uuid) from public, anon, authenticated;
334
+ revoke execute on function public.genesis_schedule_job(text, text, text) from public, anon, authenticated;
335
+ revoke execute on function public.genesis_unschedule_job(text) from public, anon, authenticated;
336
+ revoke execute on function private.genesis_jobs_purge(interval) from public, anon, authenticated;
337
+ revoke execute on function private.genesis_jobs_call(text) from public, anon, authenticated;
338
+
339
+ grant execute on function public.genesis_job_enqueue(text, jsonb, timestamptz, text, integer) to service_role;
340
+ grant execute on function public.genesis_jobs_claim(text[], integer, integer) to service_role;
341
+ grant execute on function public.genesis_jobs_settle(uuid, uuid, text, integer, text) to service_role;
342
+ grant execute on function public.genesis_job_cancel(uuid) to service_role;
343
+ grant execute on function public.genesis_schedule_job(text, text, text) to service_role;
344
+ grant execute on function public.genesis_unschedule_job(text) to service_role;
345
+
346
+ -- Finished jobs go after 14 days, when pg_cron is there.
347
+ do $$
348
+ begin
349
+ if to_regnamespace('cron') is not null then
350
+ perform cron.schedule('genesis-jobs-purge', '20 4 * * *', 'select private.genesis_jobs_purge()');
351
+ end if;
352
+ end $$;
@@ -0,0 +1,129 @@
1
+ -- @feat:jobs
2
+ -- pgTAP test for 0007_genesis_jobs.sql.
3
+ -- Copy into your app's `supabase/tests/database/` and run `supabase test db`.
4
+ -- It writes only its own jobs and rolls everything back. The schedule checks run when pg_cron
5
+ -- is installed and pass as skipped when it isn't.
6
+ begin;
7
+ create extension if not exists pgtap with schema extensions;
8
+ select plan(46);
9
+
10
+ -- Access: only the service role reaches the queue, and only through the functions.
11
+ select ok(not has_function_privilege('anon', 'public.genesis_job_enqueue(text, jsonb, timestamptz, text, integer)', 'execute'), 'anon cannot enqueue');
12
+ select ok(not has_function_privilege('authenticated', 'public.genesis_job_enqueue(text, jsonb, timestamptz, text, integer)', 'execute'), 'authenticated cannot enqueue');
13
+ select ok(not has_function_privilege('authenticated', 'public.genesis_jobs_claim(text[], integer, integer)', 'execute'), 'authenticated cannot claim');
14
+ select ok(not has_function_privilege('authenticated', 'public.genesis_jobs_settle(uuid, uuid, text, integer, text)', 'execute'), 'authenticated cannot settle');
15
+ select ok(not has_function_privilege('authenticated', 'public.genesis_schedule_job(text, text, text)', 'execute'), 'authenticated cannot schedule');
16
+ select ok(not has_function_privilege('authenticated', 'private.genesis_jobs_call(text)', 'execute'), 'authenticated cannot call a job route');
17
+ select ok(has_function_privilege('service_role', 'public.genesis_jobs_claim(text[], integer, integer)', 'execute'), 'service_role claims');
18
+ select ok(not has_table_privilege('authenticated', 'private.genesis_jobs', 'select'), 'authenticated cannot read the queue');
19
+ select ok((select relrowsecurity from pg_class where oid = 'private.genesis_jobs'::regclass), 'the queue has row level security on');
20
+
21
+ -- Start from an empty queue so other jobs in the app's database don't change the counts.
22
+ create temp table saved_jobs as select * from private.genesis_jobs;
23
+ delete from private.genesis_jobs;
24
+
25
+ -- Enqueue.
26
+ select throws_ok($$ select public.genesis_job_enqueue('Bad Kind') $$, '23514', null, 'a kind is lowercase');
27
+ select throws_ok($$ select public.genesis_job_enqueue('test.a', '[1]'::jsonb) $$, '22023', null, 'a payload is an object');
28
+
29
+ create temp table ids (name text primary key, id uuid);
30
+ insert into ids values ('a1', public.genesis_job_enqueue('test.a', '{"n": 1}'));
31
+ insert into ids values ('a2', public.genesis_job_enqueue('test.a', '{"n": 2}', p_idempotency_key => 'k'));
32
+ select is(public.genesis_job_enqueue('test.a', '{"n": 3}', p_idempotency_key => 'k'), (select id from ids where name = 'a2'),
33
+ 'an enqueue with a queued key returns the queued job');
34
+ select isnt(public.genesis_job_enqueue('test.b', '{}', p_idempotency_key => 'k'), (select id from ids where name = 'a2'),
35
+ 'the same key on another kind is another job');
36
+ insert into ids values ('later', public.genesis_job_enqueue('test.a', '{}', now() + interval '1 hour'));
37
+ select is((select count(*)::int from private.genesis_jobs), 4, 'four jobs queued');
38
+
39
+ -- Claim.
40
+ select throws_ok($$ select * from public.genesis_jobs_claim(null, 0) $$, '22023', null, 'a claim takes 1 or more');
41
+ create temp table claim1 as select * from public.genesis_jobs_claim(array['test.a'], 10, 60);
42
+ select is((select count(*)::int from claim1), 2, 'a claim hands out the due jobs of its kinds, not the future one');
43
+ select ok((select bool_and(attempt = 1 and not redelivery) from claim1), 'first attempts, not redeliveries');
44
+ select is((select count(distinct claim_id)::int from claim1), 1, 'one claim id for the batch');
45
+ select is((select count(*)::int from public.genesis_jobs_claim(array['test.a'], 10, 60)), 0, 'leased jobs are not handed out again');
46
+ select is((select count(*)::int from public.genesis_jobs_claim(null, 10, 60)), 1, 'no kinds claims every kind (test.b)');
47
+
48
+ -- Settle.
49
+ select ok(public.genesis_jobs_settle((select id from ids where name = 'a1'), (select claim_id from claim1 limit 1), 'done'), 'done settles');
50
+ select is((select status from private.genesis_jobs where id = (select id from ids where name = 'a1')), 'done', 'the job is done');
51
+ select ok(not public.genesis_jobs_settle((select id from ids where name = 'a1'), (select claim_id from claim1 limit 1), 'done'), 'settling twice is stale');
52
+ select ok(not public.genesis_jobs_settle((select id from ids where name = 'a2'), gen_random_uuid(), 'done'), 'another claim id is stale');
53
+ select throws_ok(format('select public.genesis_jobs_settle(%L, %L, %L)', (select id from ids where name = 'a2'), (select claim_id from claim1 limit 1), 'retry'),
54
+ '22023', null, 'a retry needs a delay');
55
+ select ok(public.genesis_jobs_settle((select id from ids where name = 'a2'), (select claim_id from claim1 limit 1), 'retry', 0, 'timeout'), 'retry settles');
56
+ select is((select status || ':' || last_error from private.genesis_jobs where id = (select id from ids where name = 'a2')), 'pending:timeout', 'a retry is pending again with its error');
57
+ select ok(public.genesis_job_enqueue('test.a', '{}', p_idempotency_key => 'k') = (select id from ids where name = 'a2'), 'a retrying job still holds its key');
58
+
59
+ -- Out of attempts: a retry on the last attempt fails.
60
+ update private.genesis_jobs set max_attempts = 2 where id = (select id from ids where name = 'a2');
61
+ create temp table claim2 as select * from public.genesis_jobs_claim(array['test.a'], 10, 60);
62
+ select is((select attempt from claim2 where id = (select id from ids where name = 'a2')), 2, 'the second attempt');
63
+ select ok(public.genesis_jobs_settle((select id from ids where name = 'a2'), (select claim_id from claim2 limit 1), 'retry', 0, 'timeout again'), 'the last retry settles');
64
+ select is((select status from private.genesis_jobs where id = (select id from ids where name = 'a2')), 'failed', 'out of attempts, it fails');
65
+ select isnt(public.genesis_job_enqueue('test.a', '{}', p_idempotency_key => 'k'), (select id from ids where name = 'a2'), 'a failed job frees its key');
66
+
67
+ -- Lease expiry: redelivered, then failed as lease_expired when out of attempts.
68
+ delete from private.genesis_jobs;
69
+ insert into ids values ('lease', public.genesis_job_enqueue('test.lease', '{}', p_max_attempts => 2));
70
+ select is((select count(*)::int from public.genesis_jobs_claim(array['test.lease'], 1, 60)), 1, 'the first attempt is claimed');
71
+ update private.genesis_jobs set lease_until = now() - interval '1 second' where id = (select id from ids where name = 'lease');
72
+ select is((select redelivery from public.genesis_jobs_claim(array['test.lease'], 1, 60)), true, 'a job whose lease ran out comes back as a redelivery');
73
+ update private.genesis_jobs set lease_until = now() - interval '1 second' where id = (select id from ids where name = 'lease');
74
+ select is((select count(*)::int from public.genesis_jobs_claim(array['test.lease'], 1, 60)), 0, 'out of attempts with the lease gone, it is not handed out');
75
+ select is((select status || ':' || last_error from private.genesis_jobs where id = (select id from ids where name = 'lease')), 'failed:lease_expired', 'it fails as lease_expired');
76
+
77
+ -- Cancel and purge.
78
+ insert into ids values ('cancel', public.genesis_job_enqueue('test.c', '{}'));
79
+ select ok(public.genesis_job_cancel((select id from ids where name = 'cancel')), 'a pending job cancels');
80
+ update private.genesis_jobs set finished_at = now() - interval '30 days';
81
+ select is(private.genesis_jobs_purge(), 2, 'finished jobs older than 14 days are purged');
82
+
83
+ -- Schedules. cron.job is read through a function so this file parses without pg_cron.
84
+ create function pg_temp.cron_has(p_name text) returns boolean language plpgsql as $$
85
+ declare found boolean;
86
+ begin
87
+ execute 'select exists (select 1 from cron.job where jobname = $1)' into found using p_name;
88
+ return found;
89
+ end $$;
90
+ select throws_ok($$ select public.genesis_schedule_job('Bad Name', '* * * * *', 'x') $$, '22023', null, 'a schedule name is lowercase');
91
+ select throws_ok($$ select private.genesis_jobs_call('../admin') $$, '22023', null, 'a job name is never a path');
92
+ select case when to_regnamespace('cron') is null
93
+ then skip('pg_cron is not installed', 1)
94
+ else ok(public.genesis_schedule_job('genesis-jobs-test', '*/5 * * * *', 'digests') > 0, 'schedules through pg_cron') end;
95
+ select case when to_regnamespace('cron') is null
96
+ then skip('pg_cron is not installed', 1)
97
+ else ok(pg_temp.cron_has('genesis-job:genesis-jobs-test'), 'a schedule is the cron job genesis-job:<name>') end;
98
+ create temp table unscheduled as select case when to_regnamespace('cron') is null then null else public.genesis_unschedule_job('genesis-jobs-test') end as removed;
99
+ select case when to_regnamespace('cron') is null
100
+ then skip('pg_cron is not installed', 1)
101
+ else ok((select removed from unscheduled)
102
+ and not pg_temp.cron_has('genesis-job:genesis-jobs-test'), 'unschedules it') end;
103
+ select case when to_regnamespace('cron') is null
104
+ then skip('pg_cron is not installed', 1)
105
+ else ok(not public.genesis_unschedule_job('genesis-jobs-purge'), 'never touches a cron job it did not make') end;
106
+
107
+ -- A call is signed, never carrying the secret. Needs Vault, pg_net and pgcrypto; skipped without.
108
+ -- pg_net's queue, read through a function so this file parses without pg_net.
109
+ create function pg_temp.last_call_signed() returns boolean language plpgsql as $$
110
+ declare ok boolean;
111
+ begin
112
+ execute $q$select url = 'http://127.0.0.1:9/api/jobs/digests'
113
+ and headers->>'Authorization' ~ '^GenesisJob t=[0-9]+,sig=[0-9a-f]{64}$'
114
+ and headers::text !~ 'genesis-test-secret'
115
+ from net.http_request_queue order by id desc limit 1$q$ into ok;
116
+ return ok;
117
+ end $$;
118
+ create temp table call_ready as select to_regclass('vault.secrets') is not null and to_regnamespace('net') is not null
119
+ and to_regprocedure('extensions.hmac(text, text, text)') is not null as ready;
120
+ select case when not (select ready from call_ready)
121
+ then skip('Vault, pg_net or pgcrypto is not installed', 1)
122
+ else lives_ok($$ select vault.create_secret('http://127.0.0.1:9/api/jobs/', 'genesis_jobs_url'), vault.create_secret('genesis-test-secret', 'genesis_jobs_secret'), private.genesis_jobs_call('digests') $$, 'a call queues a request') end;
123
+ select case when not (select ready from call_ready)
124
+ then skip('Vault, pg_net or pgcrypto is not installed', 1)
125
+ else ok(pg_temp.last_call_signed(), 'posts to <url>/<job>, signed, with no secret in the queue') end;
126
+
127
+ insert into private.genesis_jobs select * from saved_jobs;
128
+ select * from finish();
129
+ rollback;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@marktiderman/genesis-supabase",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
- "description": "Supabase for Genesis apps on web and React Native: a client with PKCE and safe defaults, Keychain session storage that survives interrupted writes, refresh only while the app is in front, native Sign in with Apple and Google, OAuth redirects, in-app account deletion, per-tenant feature switches (tenant_features), and a result helper. Part of the Genesis framework.",
7
+ "description": "Supabase for Genesis apps on web and React Native: a client with PKCE and safe defaults, Keychain session storage that survives interrupted writes, refresh only while the app is in front, native Sign in with Apple and Google, OAuth redirects, in-app account deletion, per-tenant feature switches (tenant_features), a background-job queue with schedules (genesis_jobs), and a result helper. Part of the Genesis framework.",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/marktiderman/genesis.git",
@@ -31,16 +31,21 @@
31
31
  "./blocks": {
32
32
  "types": "./dist/blocks.d.ts",
33
33
  "import": "./dist/blocks.js"
34
+ },
35
+ "./jobs": {
36
+ "types": "./dist/jobs.d.ts",
37
+ "import": "./dist/jobs.js"
34
38
  }
35
39
  },
36
40
  "files": [
37
41
  "dist",
38
42
  "functions",
39
43
  "migrations",
44
+ "sql",
40
45
  "README.md"
41
46
  ],
42
47
  "dependencies": {
43
- "@marktiderman/genesis-features": "^0.3.0"
48
+ "@marktiderman/genesis-features": "^0.4.0"
44
49
  },
45
50
  "peerDependencies": {
46
51
  "@supabase/supabase-js": "^2.118.0",
@@ -122,6 +127,37 @@
122
127
  "steps": [
123
128
  "Give tenant_features your membership rule in a migration of your own: create private.genesis_tenant_admin(tenant uuid, person uuid) returns boolean, and revoke execute on it from public, anon, authenticated (genesis-supabase README, Tenant feature switches). Without it nobody can change a switch."
124
129
  ]
130
+ },
131
+ "jobs": {
132
+ "feature": {
133
+ "name": "Background jobs",
134
+ "summary": "A job queue (enqueue, claim with leases, retry with backoff) and named schedules that call the app's job route with a secret.",
135
+ "keywords": [
136
+ "background jobs",
137
+ "cron",
138
+ "queue",
139
+ "scheduled tasks"
140
+ ],
141
+ "audience": [
142
+ "developer"
143
+ ]
144
+ },
145
+ "platforms": [
146
+ "web"
147
+ ],
148
+ "migrations": [
149
+ {
150
+ "file": "migrations/0007_genesis_jobs.sql",
151
+ "test": "migrations/tests/0007_genesis_jobs.test.sql"
152
+ }
153
+ ],
154
+ "install": [
155
+ "@supabase/supabase-js@^2.118.0"
156
+ ],
157
+ "steps": [
158
+ "Add one job route with createJobsHandler from @marktiderman/genesis-supabase/jobs, and set GENESIS_JOBS_SECRET on the server.",
159
+ "For schedules: enable pg_cron and pg_net, add the Vault secrets genesis_jobs_url (the route's base URL) and genesis_jobs_secret (the same secret), then call genesis_schedule_job(name, cron, job) in a migration."
160
+ ]
125
161
  }
126
162
  }
127
163
  },