better-supabase 0.4.0 → 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 +2 -0
- package/dist/channel-FQW-CnGR.d.ts +70 -0
- package/dist/cli/bin.js +1 -1
- package/dist/cli/codemod-DqV7612m.js +388 -0
- package/dist/cli/{config-D9R0o1LH.js → config-CPVpXP5I.js} +78 -8
- package/dist/cli/{config-SSKSTUl2.js → config-dtoekM0v.js} +1 -1
- package/dist/cli/{read-sets-CMa29t9a.js → diff-SjkO5rNr.js} +1 -220
- package/dist/cli/{dist-CFgGknmZ.js → dist-DBPdlPYK.js} +4 -4
- package/dist/cli/{dist-B1MvLv-M.js → dist-DreZ24Sz.js} +1 -1
- package/dist/cli/{dist-Du7VnEUG.js → dist-xn4wlboc.js} +112 -1
- package/dist/cli/{doctor-BOym3mbL.js → doctor-L7Z8mYTx.js} +531 -50
- package/dist/cli/{env-BZ0uy8XK.js → env-DmKv_G4A.js} +1 -1
- package/dist/cli/gen-BJc9vdyu.js +2 -0
- package/dist/cli/{gen-Ccn_p90L.js → gen-C2osRcr5.js} +283 -87
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +4 -4
- package/dist/cli/{init-ClnxRYBF.js → init-Saf5-R9T.js} +2 -2
- package/dist/cli/{introspect-DxqfkVIv.js → introspect--H7oGmcd.js} +2 -2
- package/dist/cli/{openapi-8WHjxiTJ.js → openapi-Cp5Fyta-.js} +1 -1
- package/dist/cli/read-sets-BfMQcKy8.js +289 -0
- package/dist/cli/{run-CP20LKk_.js → run-BKR2W_79.js} +16 -12
- package/dist/cli/{seed-5J6eu_Xg.js → seed-CUlqWh-8.js} +1 -1
- package/dist/cli/sql-DzPbfngx.js +348 -0
- package/dist/cli/{version-5mW_8sY1.js → version-BDJuO0UR.js} +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +3 -10
- package/dist/clone-BfgmLU90.js +21 -0
- package/dist/{compiler-DB3aSFOA.d.ts → compiler-BXH_aZ7N.d.ts} +2 -2
- package/dist/config/index.d.ts +4 -4
- package/dist/config/index.js +4 -4
- package/dist/{config-CbvFmGD8.js → config-DreERlKo.js} +1 -1
- package/dist/{config-C1CYcm6o.d.ts → config-T_ejwyd3.d.ts} +176 -11
- package/dist/{define-CRS2MRV3.d.ts → define-Bp_BZa5h.d.ts} +11 -7
- package/dist/{define-Bh0AqTik.js → define-cNKzqwL_.js} +202 -16
- package/dist/edge/index.d.ts +16 -5
- package/dist/edge/index.js +10 -7
- package/dist/{entitlements-Dm-FHpNH.d.ts → entitlements-DUxU4oHe.d.ts} +1 -1
- package/dist/env/index.d.ts +2 -2
- package/dist/env/index.js +34 -1
- package/dist/{errors-L0xK41y_.js → errors-DtwXa6b8.js} +1 -0
- package/dist/events/index.d.ts +3 -2
- package/dist/events/index.js +65 -12
- package/dist/events-aIO7vsIk.d.ts +230 -0
- package/dist/{result-CqEsbgYs.d.ts → executor-BunPOO-k.d.ts} +200 -1
- package/dist/{executor-CZ7ImRiD.d.ts → executor-Cghdstwu.d.ts} +1 -2
- package/dist/{executor-CVt6dD0B.js → executor-XycESQPH.js} +3 -3
- package/dist/hono/index.d.ts +17 -6
- package/dist/hono/index.js +40 -5
- package/dist/impersonation-VfaGc4Q_.js +41 -0
- package/dist/{index-BBdvo-5d.d.ts → index-BGdgBPdf.d.ts} +49 -6
- package/dist/{index-BhHEC04r.d.ts → index-C7f8cOBM.d.ts} +2 -2
- package/dist/{index-Dy6kuF0P.d.ts → index-CLg87OBW.d.ts} +6 -1
- package/dist/{index-D5IfUIDh.d.ts → index-DOESKG27.d.ts} +33 -8
- package/dist/{index-DFmOpBGC.d.ts → index-DgILJRXj.d.ts} +35 -9
- package/dist/{index-RlLXd8dL.d.ts → index-DmXSxbLi.d.ts} +4 -4
- package/dist/{index-Cf-D2jk1.d.ts → index-Dxn2a5cw.d.ts} +2 -2
- package/dist/{index-528Ugmyp.d.ts → index-SaTsEfGP.d.ts} +76 -8
- package/dist/{index-BAy4tsAP.d.ts → index-fag8VJZ2.d.ts} +3 -4
- package/dist/index.d.ts +30 -13
- package/dist/index.js +10 -9
- package/dist/jobs/index.d.ts +147 -101
- package/dist/jobs/index.js +744 -237
- package/dist/{json-schema-DP6V7cbr.js → json-schema-m1fiwKgN.js} +22 -1
- package/dist/kit-events-CTFDfSwb.js +60 -0
- package/dist/kit-transport-B82k1djG.d.ts +33 -0
- package/dist/kit-transport-DJlHQUBU.js +55 -0
- package/dist/{config-BpxDfnbg.js → kits-CGLLrNPd.js} +37 -9
- package/dist/list/index.d.ts +1 -1
- package/dist/{live-BHakSWWo.d.ts → live-D9W2M5aO.d.ts} +6 -5
- package/dist/mcp/index.d.ts +25 -8
- package/dist/mcp/index.js +23 -14
- package/dist/{mfa-C7MNQ1Ob.d.ts → mfa-CusDgPaD.d.ts} +5 -1
- package/dist/next/index.d.ts +31 -10
- package/dist/next/index.js +136 -27
- package/dist/notifications/index.d.ts +162 -0
- package/dist/notifications/index.js +264 -0
- package/dist/openapi/index.d.ts +1 -1
- package/dist/openapi/index.js +2 -2
- package/dist/orgs/index.d.ts +132 -0
- package/dist/orgs/index.js +245 -0
- package/dist/orpc/index.d.ts +28 -8
- package/dist/orpc/index.js +16 -3
- package/dist/otel/index.d.ts +21 -2
- package/dist/otel/index.js +50 -9
- package/dist/permdock-sql-C2Inc6Xc.d.ts +29 -0
- package/dist/permdock-sql-DgOfeixi.js +51 -0
- package/dist/plugin-DwARMuQp.js +59 -0
- package/dist/{plugin-bNhvJwRh.d.ts → plugin-o144f322.d.ts} +17 -5
- package/dist/plugins/actor/index.d.ts +5 -2
- package/dist/plugins/actor/index.js +22 -8
- package/dist/plugins/rules/index.d.ts +2 -2
- package/dist/plugins/rules/index.js +15 -10
- package/dist/plugins/soft-delete/index.d.ts +3 -3
- package/dist/plugins/soft-delete/index.js +14 -5
- package/dist/plugins/tenant/index.d.ts +2 -2
- package/dist/plugins/tenant/index.js +38 -20
- package/dist/plugins/timestamps/index.d.ts +1 -1
- package/dist/plugins/timestamps/index.js +4 -3
- package/dist/plugins/validation/index.d.ts +2 -2
- package/dist/plugins/validation/index.js +26 -6
- package/dist/{pool-DFwEQwmN.d.ts → pool-C9voM4Kw.d.ts} +17 -6
- package/dist/postgres/index.d.ts +5 -5
- package/dist/postgres/index.js +20 -11
- package/dist/{postgrest-Bc-0Xw62.d.ts → postgrest-CruYKAvV.d.ts} +1 -1
- package/dist/{problem-BihjPR0t.js → problem-BW1KhzSG.js} +2 -1
- package/dist/{problem-U8UEKccv.d.ts → problem-Blxe8S31.d.ts} +1 -1
- package/dist/query/index.d.ts +2 -2
- package/dist/query/index.js +3 -3
- package/dist/{query-BMMSWbI7.js → query-CCbo44lG.js} +2 -2
- package/dist/queue-BYUCjr59.d.ts +252 -0
- package/dist/react/index.d.ts +8 -7
- package/dist/react/index.js +4 -3
- package/dist/react/server.d.ts +14 -3
- package/dist/react/server.js +10 -2
- package/dist/react/session.d.ts +7 -1
- package/dist/react/session.js +9 -1
- package/dist/{react-CQHMaESR.js → react-C_s7kiNz.js} +103 -11
- package/dist/{read-set-FWK8qQ58.js → read-set-DQFt1N_Y.js} +22 -1
- package/dist/realtime/index.d.ts +3 -3
- package/dist/realtime/index.js +5 -4
- package/dist/{refresh-BwYkyNOQ.d.ts → refresh-BTrQ_ZTp.d.ts} +1 -1
- package/dist/{resolve-BAqQcwMW.d.ts → resolve-C15TJq77.d.ts} +11 -5
- package/dist/{resource-My_y-Syh.js → resource-CRxxwkQq.js} +22 -7
- package/dist/{resource-BJcaIfxi.d.ts → resource-D8iqWMr-.d.ts} +5 -5
- package/dist/respond-D2OQl62-.d.ts +371 -0
- package/dist/{result-Bj6yt33D.js → result-BKiliPY7.js} +1 -1
- package/dist/server/index.d.ts +14 -12
- package/dist/server/index.js +11 -17
- package/dist/{server-BA75B0mT.js → server-Csx2sRjg.js} +251 -34
- package/dist/{shared-DnoCdMDr.js → shared-D1dRUrAg.js} +16 -2
- package/dist/signers-Cc6r98SW.d.ts +162 -0
- package/dist/{simplify-Cqvfr9dj.js → simplify-_0oUKqCd.js} +4 -115
- package/dist/sql/index.d.ts +247 -10
- package/dist/sql/index.js +8590 -779
- package/dist/{sql-B34HU2DI.js → sql-Coh6n4cC.js} +9 -2
- package/dist/ssr/index.d.ts +4 -4
- package/dist/ssr/index.js +1 -2
- package/dist/{standard-BRyBGTtp.js → standard-D7r6dMUu.js} +6 -3
- package/dist/{standard-BsaCYQJc.d.ts → standard-J4YgMOLm.d.ts} +1 -1
- package/dist/{stats-C_YxA2r1.js → stats-Bqob3WqT.js} +4 -4
- package/dist/storage/index.d.ts +4 -4
- package/dist/storage/index.js +155 -26
- package/dist/support-DT-OhSxD.d.ts +88 -0
- package/dist/support-Dk6vdmEw.js +291 -0
- package/dist/support-view-Da5peKFg.d.ts +17 -0
- package/dist/support-view-g4YLBeqX.js +18 -0
- package/dist/{permdock-sql-C4KFzLc9.js → template-TxrGI5jR.js} +2 -31
- package/dist/{temporal-BwshQk0W.js → temporal-CVlIV2DZ.js} +17 -4
- package/dist/{temporal-required-CBNHT6ni.js → temporal-required-CAt4Dc9Z.js} +2 -2
- package/dist/testing/index.d.ts +100 -10
- package/dist/testing/index.js +336 -8
- package/dist/{types-DONv9SHN.d.ts → types-Dnf_wRDz.d.ts} +26 -3
- package/dist/user-change-CHFEUro1.js +49 -0
- package/dist/{webhooks-Bh_Jf_-l.js → verify-Ce49ETPz.js} +5 -5
- package/dist/{version-Bmf7_FzM.js → version-DM_Roffb.js} +1 -1
- package/dist/{view-B64o81wp.d.ts → view-BEf0diC5.d.ts} +8 -3
- package/dist/{mfa-byfjruR9.js → view-Bq64ki8-.js} +95 -38
- package/dist/webhooks/index.d.ts +135 -4
- package/dist/webhooks/index.js +588 -2
- package/dist/wire-D2dT86vi.js +35 -0
- package/package.json +16 -8
- package/schemas/config-v1.json +131 -1
- package/schemas/doctor-report-v1.json +10 -0
- package/skills/better-supabase/SKILL.md +9 -6
- package/skills/better-supabase/references/plugins.md +6 -3
- package/skills/better-supabase/references/troubleshooting.md +1 -0
- package/skills/better-supabase-api/SKILL.md +9 -2
- package/skills/better-supabase-api/references/adapters.md +11 -8
- package/skills/better-supabase-auth/SKILL.md +3 -0
- package/skills/better-supabase-auth/references/permdock.md +56 -11
- package/dist/cli/gen-D5cLCULx.js +0 -2
- package/dist/cli/sql-D4VbqmXi.js +0 -199
- package/dist/events-B21UOhvP.d.ts +0 -84
- package/dist/executor-B5IoUxLE.d.ts +0 -195
- package/dist/invalidate-CgxmRamI.js +0 -24
- package/dist/permdock-sql-DImJULY-.d.ts +0 -17
- package/dist/plugin-BqR6wKMB.js +0 -23
- package/dist/respond-DgIbdrt8.d.ts +0 -194
- package/dist/view-BrJI2LKp.js +0 -45
package/dist/jobs/index.js
CHANGED
|
@@ -1,19 +1,168 @@
|
|
|
1
|
-
import { c as mapDbError, n as dbError, t as DbException } from "../errors-
|
|
2
|
-
import { n as nowInstant } from "../temporal-
|
|
3
|
-
import { t as temporal } from "../temporal-required-
|
|
4
|
-
import { i as ok, n as err, o as toDbError, t as AsyncResult } from "../result-
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
1
|
+
import { c as mapDbError, n as dbError, t as DbException } from "../errors-DtwXa6b8.js";
|
|
2
|
+
import { n as nowInstant } from "../temporal-CVlIV2DZ.js";
|
|
3
|
+
import { t as temporal } from "../temporal-required-CAt4Dc9Z.js";
|
|
4
|
+
import { i as ok, n as err, o as toDbError, t as AsyncResult } from "../result-BKiliPY7.js";
|
|
5
|
+
import { n as allTenantsContext, s as tenantOf } from "../plugin-DwARMuQp.js";
|
|
6
|
+
import { t as validate } from "../standard-D7r6dMUu.js";
|
|
7
|
+
import { a as problemResponse } from "../problem-BW1KhzSG.js";
|
|
8
|
+
import { i as tenantClaimPaths, n as claimAt } from "../claims-BAf6c7is.js";
|
|
9
|
+
import { r as sqlIdent } from "../template-TxrGI5jR.js";
|
|
10
|
+
import { t as fromPgError } from "../executor-XycESQPH.js";
|
|
11
|
+
import { c as verifyWebhook, s as verifySharedSecret } from "../verify-Ce49ETPz.js";
|
|
12
|
+
//#region src/jobs/cron.ts
|
|
13
|
+
const MACROS = {
|
|
14
|
+
"@yearly": "0 0 1 1 *",
|
|
15
|
+
"@annually": "0 0 1 1 *",
|
|
16
|
+
"@monthly": "0 0 1 * *",
|
|
17
|
+
"@weekly": "0 0 * * 0",
|
|
18
|
+
"@daily": "0 0 * * *",
|
|
19
|
+
"@midnight": "0 0 * * *",
|
|
20
|
+
"@hourly": "0 * * * *"
|
|
21
|
+
};
|
|
22
|
+
const MONTHS = [
|
|
23
|
+
"jan",
|
|
24
|
+
"feb",
|
|
25
|
+
"mar",
|
|
26
|
+
"apr",
|
|
27
|
+
"may",
|
|
28
|
+
"jun",
|
|
29
|
+
"jul",
|
|
30
|
+
"aug",
|
|
31
|
+
"sep",
|
|
32
|
+
"oct",
|
|
33
|
+
"nov",
|
|
34
|
+
"dec"
|
|
35
|
+
];
|
|
36
|
+
const WEEKDAYS = [
|
|
37
|
+
"sun",
|
|
38
|
+
"mon",
|
|
39
|
+
"tue",
|
|
40
|
+
"wed",
|
|
41
|
+
"thu",
|
|
42
|
+
"fri",
|
|
43
|
+
"sat"
|
|
44
|
+
];
|
|
45
|
+
/** Replaces `JAN` or `MON` style names with their numbers. */
|
|
46
|
+
function named(text, names, first) {
|
|
47
|
+
return text.replaceAll(/[a-z]{3}/gi, (name) => {
|
|
48
|
+
const index = names.indexOf(name.toLowerCase());
|
|
49
|
+
return index === -1 ? name : String(index + first);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const INTERVAL = /^(\d+)\s+(second|minute|hour)s?$/i;
|
|
53
|
+
/** Gives up when no minute matches within this many days (`0 0 30 2 *`). */
|
|
54
|
+
const SEARCH_DAYS = 1830;
|
|
55
|
+
function invalid(expression, reason) {
|
|
56
|
+
return /* @__PURE__ */ new TypeError(`Invalid cron "${expression}": ${reason}`);
|
|
57
|
+
}
|
|
58
|
+
function field(expression, text, min, max) {
|
|
59
|
+
const values = /* @__PURE__ */ new Set();
|
|
60
|
+
for (const part of text.split(",")) {
|
|
61
|
+
const match = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part);
|
|
62
|
+
if (!match) throw invalid(expression, `"${part}" is not a cron field`);
|
|
63
|
+
const [, range = "", stepText] = match;
|
|
64
|
+
const step = stepText === void 0 ? 1 : Number(stepText);
|
|
65
|
+
let from = min;
|
|
66
|
+
let to = max;
|
|
67
|
+
if (range !== "*") {
|
|
68
|
+
const [start = "", end] = range.split("-");
|
|
69
|
+
from = Number(start);
|
|
70
|
+
to = end === void 0 ? stepText === void 0 ? from : max : Number(end);
|
|
71
|
+
}
|
|
72
|
+
if (step < 1 || from < min || to > max || from > to) throw invalid(expression, `"${part}" is outside ${String(min)}-${String(max)}`);
|
|
73
|
+
for (let value = from; value <= to; value += step) values.add(value);
|
|
74
|
+
}
|
|
75
|
+
return [...values].toSorted((a, b) => a - b);
|
|
76
|
+
}
|
|
77
|
+
function parse(expression) {
|
|
78
|
+
const parts = (MACROS[expression.trim().toLowerCase()] ?? expression).trim().split(/\s+/);
|
|
79
|
+
if (parts.length !== 5) throw invalid(expression, "expected 5 fields (minute hour day month weekday)");
|
|
80
|
+
const [minute = "", hour = "", day = "", monthText = "", weekdayText = ""] = parts;
|
|
81
|
+
const month = named(monthText, MONTHS, 1);
|
|
82
|
+
const weekday = named(weekdayText, WEEKDAYS, 0);
|
|
83
|
+
return {
|
|
84
|
+
minutes: field(expression, minute, 0, 59),
|
|
85
|
+
hours: field(expression, hour, 0, 23),
|
|
86
|
+
days: new Set(field(expression, day, 1, 31)),
|
|
87
|
+
months: new Set(field(expression, month, 1, 12)),
|
|
88
|
+
weekdays: new Set(field(expression, weekday, 0, 7).map((value) => value === 0 ? 7 : value)),
|
|
89
|
+
daysRestricted: day !== "*",
|
|
90
|
+
weekdaysRestricted: weekday !== "*"
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function dayMatches(fields, date) {
|
|
94
|
+
if (!fields.months.has(date.month)) return false;
|
|
95
|
+
const day = fields.days.has(date.day);
|
|
96
|
+
const weekday = fields.weekdays.has(date.dayOfWeek);
|
|
97
|
+
if (fields.daysRestricted && fields.weekdaysRestricted) return day || weekday;
|
|
98
|
+
if (fields.daysRestricted) return day;
|
|
99
|
+
if (fields.weekdaysRestricted) return weekday;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
/** The first matching minute of `date` after `after`, from `start` on its first day. */
|
|
103
|
+
function firstRunOn(fields, date, start, timeZone, after) {
|
|
104
|
+
for (const hour of fields.hours) {
|
|
105
|
+
if (start && hour < start.hour) continue;
|
|
106
|
+
for (const minute of fields.minutes) {
|
|
107
|
+
if (hour === start?.hour && minute < start.minute) continue;
|
|
108
|
+
const instant = date.toPlainDateTime({
|
|
109
|
+
hour,
|
|
110
|
+
minute
|
|
111
|
+
}).toZonedDateTime(timeZone, { disambiguation: "compatible" }).toInstant();
|
|
112
|
+
if (temporal().Instant.compare(instant, after) > 0) return instant;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Throws a `TypeError` unless `expression` is a schedule `nextCronRun`
|
|
118
|
+
* understands: five cron fields, a macro such as `@daily`, or an interval
|
|
119
|
+
* such as `30 seconds`.
|
|
120
|
+
*/
|
|
121
|
+
function assertCron(expression) {
|
|
122
|
+
if (!INTERVAL.test(expression.trim())) parse(expression);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The first run of `expression` after `after`, in `timeZone`.
|
|
126
|
+
*
|
|
127
|
+
* Accepts five cron fields (lists, ranges and steps; a restricted day of
|
|
128
|
+
* month and day of week match either, as in cron), the macros `@hourly`,
|
|
129
|
+
* `@daily`, `@weekly`, `@monthly` and `@yearly`, and intervals such as
|
|
130
|
+
* `30 seconds`, `5 minutes` or `2 hours`, counted from `after`. A local time
|
|
131
|
+
* that a DST change skips runs at the same offset after the gap.
|
|
132
|
+
*/
|
|
133
|
+
function nextCronRun(expression, timeZone, after) {
|
|
134
|
+
const interval = INTERVAL.exec(expression.trim());
|
|
135
|
+
if (interval) {
|
|
136
|
+
const amount = Number(interval[1]);
|
|
137
|
+
if (amount < 1) throw invalid(expression, "the interval must be positive");
|
|
138
|
+
const unit = interval[2].toLowerCase();
|
|
139
|
+
return after.add(unit === "second" ? { seconds: amount } : unit === "minute" ? { minutes: amount } : { hours: amount });
|
|
140
|
+
}
|
|
141
|
+
const fields = parse(expression);
|
|
142
|
+
const start = after.toZonedDateTimeISO(timeZone).toPlainDateTime().round({
|
|
143
|
+
smallestUnit: "minute",
|
|
144
|
+
roundingMode: "floor"
|
|
145
|
+
}).add({ minutes: 1 });
|
|
146
|
+
let date = start.toPlainDate();
|
|
147
|
+
for (let offset = 0; offset < SEARCH_DAYS; offset += 1) {
|
|
148
|
+
const found = dayMatches(fields, date) && firstRunOn(fields, date, offset === 0 ? start : void 0, timeZone, after);
|
|
149
|
+
if (found) return found;
|
|
150
|
+
date = date.add({ days: 1 });
|
|
151
|
+
}
|
|
152
|
+
throw invalid(expression, "it never matches a date");
|
|
153
|
+
}
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/jobs/shared.ts
|
|
156
|
+
function asDbError(cause) {
|
|
157
|
+
const raw = fromPgError(cause);
|
|
158
|
+
return raw ? mapDbError(raw) : toDbError(cause);
|
|
159
|
+
}
|
|
10
160
|
function run(fn) {
|
|
11
161
|
return AsyncResult.from(async () => {
|
|
12
162
|
try {
|
|
13
163
|
return ok(await fn());
|
|
14
164
|
} catch (cause) {
|
|
15
|
-
|
|
16
|
-
return err(raw ? mapDbError(raw) : toDbError(cause));
|
|
165
|
+
return err(asDbError(cause));
|
|
17
166
|
}
|
|
18
167
|
});
|
|
19
168
|
}
|
|
@@ -36,9 +185,425 @@ const sleep = (ms, signal) => new Promise((resolve) => {
|
|
|
36
185
|
}
|
|
37
186
|
signal?.addEventListener("abort", done, { once: true });
|
|
38
187
|
});
|
|
39
|
-
|
|
188
|
+
/** pgmq and the inbox return `timestamptz` as text over RPC and as `Date` from `pg`. */
|
|
189
|
+
const toInstant = (value) => value instanceof Date ? temporal().Instant.fromEpochMilliseconds(value.getTime()) : temporal().Instant.from(value);
|
|
190
|
+
function errorText(error) {
|
|
191
|
+
if (error instanceof Error) return error.message;
|
|
192
|
+
if (typeof error === "object" && error !== null && "message" in error) return String(error.message);
|
|
193
|
+
return String(error);
|
|
194
|
+
}
|
|
195
|
+
//#endregion
|
|
196
|
+
//#region src/jobs/jobs.ts
|
|
197
|
+
async function sha256(text) {
|
|
198
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
199
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
200
|
+
}
|
|
201
|
+
const AUTH_COOKIE = /^sb-.+-auth-token(?:\.\d+)?$/;
|
|
202
|
+
/** A hash of the credentials the request carries, or `''` without any. */
|
|
203
|
+
async function callerScope(request) {
|
|
204
|
+
const authorization = request.headers.get("authorization");
|
|
205
|
+
const cookies = (request.headers.get("cookie") ?? "").split(";").map((pair) => pair.trim()).filter((pair) => AUTH_COOKIE.test(pair.split("=", 1)[0] ?? "")).sort();
|
|
206
|
+
const credential = authorization ?? cookies.join(";");
|
|
207
|
+
return credential === "" ? "" : `caller:${await sha256(credential)}`;
|
|
208
|
+
}
|
|
209
|
+
/** HTTP idempotency keys (IETF `Idempotency-Key` header) over the `idempotency` SQL kit module. */
|
|
210
|
+
function createIdempotency(sql, options = {}) {
|
|
211
|
+
const headerName = options.header ?? "idempotency-key";
|
|
212
|
+
const ttl = seconds(options.ttl ?? "24 hours");
|
|
213
|
+
const lock = seconds(options.lock ?? "1 minute");
|
|
214
|
+
const begin = (key, fingerprint, scope = "") => run(async () => {
|
|
215
|
+
const [row] = await sql.queryRaw("select * from better_supabase.begin_idempotent($1, $2, $3, $4::interval, $5::interval)", [
|
|
216
|
+
scope,
|
|
217
|
+
key,
|
|
218
|
+
fingerprint,
|
|
219
|
+
ttl,
|
|
220
|
+
lock
|
|
221
|
+
]);
|
|
222
|
+
return {
|
|
223
|
+
state: row.state,
|
|
224
|
+
status: row.status_code,
|
|
225
|
+
body: row.response
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
const complete = (key, status, body, scope = "") => run(async () => {
|
|
229
|
+
await sql.queryRaw("select better_supabase.complete_idempotent($1, $2, $3, $4)", [
|
|
230
|
+
scope,
|
|
231
|
+
key,
|
|
232
|
+
status,
|
|
233
|
+
JSON.stringify(body)
|
|
234
|
+
]);
|
|
235
|
+
});
|
|
236
|
+
const release = (key, scope = "") => run(async () => {
|
|
237
|
+
await sql.queryRaw("select better_supabase.release_idempotent($1, $2)", [scope, key]);
|
|
238
|
+
});
|
|
239
|
+
return {
|
|
240
|
+
begin,
|
|
241
|
+
complete,
|
|
242
|
+
release,
|
|
243
|
+
async handle(request, handler) {
|
|
244
|
+
const key = request.headers.get(headerName);
|
|
245
|
+
const instance = new URL(request.url).pathname;
|
|
246
|
+
if (!key) return options.required ? problemResponse(dbError("invalid_request", `The ${headerName} header is required`, { code: "IDEMPOTENCY_KEY_MISSING" }), { instance }) : handler(request);
|
|
247
|
+
if (key.length > 255) return problemResponse(dbError("invalid_request", `The ${headerName} header is too long`, { code: "IDEMPOTENCY_KEY_INVALID" }), { instance });
|
|
248
|
+
const scope = typeof options.scope === "function" ? options.scope(request) : options.scope ?? await callerScope(request);
|
|
249
|
+
const body = await request.clone().text();
|
|
250
|
+
const fingerprint = await sha256(`${request.method} ${instance}\n${body}`);
|
|
251
|
+
const started = await begin(key, fingerprint, scope);
|
|
252
|
+
if (!started.ok) return problemResponse(started.error, { instance });
|
|
253
|
+
const { state } = started.data;
|
|
254
|
+
switch (state) {
|
|
255
|
+
case "replay": {
|
|
256
|
+
const stored = started.data.body;
|
|
257
|
+
return new Response(stored.body, {
|
|
258
|
+
status: started.data.status ?? 200,
|
|
259
|
+
headers: {
|
|
260
|
+
...stored.contentType ? { "content-type": stored.contentType } : {},
|
|
261
|
+
"idempotency-replayed": "true"
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
case "running": return problemResponse(dbError("conflict", "A request with this idempotency key is still running", { code: "IDEMPOTENCY_KEY_IN_USE" }), {
|
|
266
|
+
instance,
|
|
267
|
+
headers: { "retry-after": "1" }
|
|
268
|
+
});
|
|
269
|
+
case "mismatch": return problemResponse(dbError("validation", "This idempotency key was used for a different request", {
|
|
270
|
+
code: "IDEMPOTENCY_KEY_REUSED",
|
|
271
|
+
issues: [{
|
|
272
|
+
message: "Use a new key for a new request",
|
|
273
|
+
path: [headerName]
|
|
274
|
+
}]
|
|
275
|
+
}), { instance });
|
|
276
|
+
case "started": break;
|
|
277
|
+
default: throw new TypeError(`Unknown idempotency state ${String(state)}`);
|
|
278
|
+
}
|
|
279
|
+
let response;
|
|
280
|
+
try {
|
|
281
|
+
response = await handler(request);
|
|
282
|
+
} catch (cause) {
|
|
283
|
+
await release(key, scope);
|
|
284
|
+
throw cause;
|
|
285
|
+
}
|
|
286
|
+
if (response.status >= 500) {
|
|
287
|
+
await release(key, scope);
|
|
288
|
+
return response;
|
|
289
|
+
}
|
|
290
|
+
const stored = {
|
|
291
|
+
body: await response.clone().text(),
|
|
292
|
+
contentType: response.headers.get("content-type")
|
|
293
|
+
};
|
|
294
|
+
await complete(key, response.status, stored, scope);
|
|
295
|
+
return response;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function defaultType(payload) {
|
|
300
|
+
if (typeof payload !== "object" || payload === null) return null;
|
|
301
|
+
const type = payload.type;
|
|
302
|
+
return typeof type === "string" ? type : null;
|
|
303
|
+
}
|
|
304
|
+
/** Store-then-process webhooks: acknowledge fast, process with retries, never twice. */
|
|
305
|
+
function createInbox(sql, options) {
|
|
306
|
+
const worker = options.worker ?? workerId();
|
|
307
|
+
if (!options.secrets && !options.verify) throw new TypeError("createInbox needs `secrets` (Standard Webhooks) or `verify`");
|
|
308
|
+
const verified = async (request) => {
|
|
309
|
+
if (options.verify) return options.verify(request, await request.clone().text());
|
|
310
|
+
const result = await verifyWebhook(request, options.secrets);
|
|
311
|
+
return result.ok ? ok({
|
|
312
|
+
id: result.data.id,
|
|
313
|
+
payload: result.data.payload
|
|
314
|
+
}) : result;
|
|
315
|
+
};
|
|
316
|
+
return {
|
|
317
|
+
async receive(request) {
|
|
318
|
+
const instance = new URL(request.url).pathname;
|
|
319
|
+
if (request.method !== "POST") return new Response(null, {
|
|
320
|
+
status: 405,
|
|
321
|
+
headers: { allow: "POST" }
|
|
322
|
+
});
|
|
323
|
+
const message = await verified(request);
|
|
324
|
+
if (!message.ok) return problemResponse(message.error, { instance });
|
|
325
|
+
const headers = Object.fromEntries((options.keepHeaders ?? []).flatMap((name) => {
|
|
326
|
+
const value = request.headers.get(name);
|
|
327
|
+
return value === null ? [] : [[name.toLowerCase(), value]];
|
|
328
|
+
}));
|
|
329
|
+
const stored = await run(() => sql.queryRaw("select * from better_supabase.receive_webhook($1, $2, $3, $4, $5)", [
|
|
330
|
+
options.source,
|
|
331
|
+
message.data.id,
|
|
332
|
+
(options.typeOf ?? defaultType)(message.data.payload),
|
|
333
|
+
JSON.stringify(message.data.payload),
|
|
334
|
+
JSON.stringify(headers)
|
|
335
|
+
]));
|
|
336
|
+
if (!stored.ok) return problemResponse(stored.error, { instance });
|
|
337
|
+
const row = stored.data[0];
|
|
338
|
+
return Response.json({
|
|
339
|
+
id: Number(row.id),
|
|
340
|
+
duplicate: row.duplicate
|
|
341
|
+
}, { status: row.duplicate ? 200 : 202 });
|
|
342
|
+
},
|
|
343
|
+
async process(handler, processOptions = {}) {
|
|
344
|
+
let succeeded = 0;
|
|
345
|
+
let failed = 0;
|
|
346
|
+
for (;;) {
|
|
347
|
+
const rows = await sql.queryRaw("select * from better_supabase.claim_webhooks($1, $2, $3, $4::interval)", [
|
|
348
|
+
options.source,
|
|
349
|
+
worker,
|
|
350
|
+
processOptions.batch ?? 10,
|
|
351
|
+
seconds(processOptions.lease ?? 300)
|
|
352
|
+
]);
|
|
353
|
+
if (rows.length === 0) return {
|
|
354
|
+
succeeded,
|
|
355
|
+
failed
|
|
356
|
+
};
|
|
357
|
+
for (const row of rows) {
|
|
358
|
+
const message = {
|
|
359
|
+
id: Number(row.id),
|
|
360
|
+
source: row.source,
|
|
361
|
+
messageId: row.message_id,
|
|
362
|
+
type: row.event_type,
|
|
363
|
+
payload: row.payload,
|
|
364
|
+
headers: row.headers,
|
|
365
|
+
attempts: row.attempts,
|
|
366
|
+
receivedAt: toInstant(row.received_at)
|
|
367
|
+
};
|
|
368
|
+
try {
|
|
369
|
+
const outcome = await handler(message);
|
|
370
|
+
if (typeof outcome === "object" && outcome !== null && "ok" in outcome && outcome.ok === false) throw new Error(errorText(outcome.error ?? "The handler failed"));
|
|
371
|
+
await sql.queryRaw("select better_supabase.complete_webhook($1, $2)", [message.id, worker]);
|
|
372
|
+
succeeded += 1;
|
|
373
|
+
} catch (cause) {
|
|
374
|
+
await sql.queryRaw("select better_supabase.fail_webhook($1, $2, $3)", [
|
|
375
|
+
message.id,
|
|
376
|
+
worker,
|
|
377
|
+
errorText(cause)
|
|
378
|
+
]);
|
|
379
|
+
failed += 1;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
/** The Stripe event sent when a customer's active entitlements change. */
|
|
387
|
+
const ENTITLEMENTS_UPDATED = "entitlements.active_entitlement_summary.updated";
|
|
388
|
+
function stripeCustomerOf(payload) {
|
|
389
|
+
if (typeof payload !== "object" || payload === null) return void 0;
|
|
390
|
+
const customer = (payload.data?.object)?.customer;
|
|
391
|
+
if (typeof customer === "string") return customer;
|
|
392
|
+
const id = customer?.id;
|
|
393
|
+
return typeof id === "string" ? id : void 0;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Users whose `memberships` claim carries the entitlements of the Stripe
|
|
397
|
+
* customer in an `entitlements.active_entitlement_summary.updated` event, so
|
|
398
|
+
* a job can invalidate their sessions. Empty for other payloads.
|
|
399
|
+
*/
|
|
400
|
+
function entitlementMembers(sql, payload) {
|
|
401
|
+
const customer = stripeCustomerOf(payload);
|
|
402
|
+
if (customer === void 0) return AsyncResult.ok([]);
|
|
403
|
+
return run(() => sql.queryRaw("select user_id from better_supabase.entitlement_members($1) as user_id", [customer])).map((rows) => rows.map((row) => row.user_id));
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Deletes audit entries past their retention and returns how many. Without
|
|
407
|
+
* `retention` it is one `purge_audit_log` call (which honours an
|
|
408
|
+
* `audit_retention` SQL hook); with it, each tenant is purged with its own
|
|
409
|
+
* interval. Run it from a schedule until it returns 0.
|
|
410
|
+
*/
|
|
411
|
+
function purgeAuditLog(sql, options = {}) {
|
|
412
|
+
const olderThan = seconds(options.olderThan ?? "1 year");
|
|
413
|
+
const batch = options.batch ?? 1e4;
|
|
414
|
+
const purge = async (params, call) => {
|
|
415
|
+
const [row] = await sql.queryRaw(`select better_supabase.purge_audit_log(${call}) as n`, params);
|
|
416
|
+
return Number(row?.n ?? 0);
|
|
417
|
+
};
|
|
418
|
+
const { retention } = options;
|
|
419
|
+
if (!retention) return run(() => purge([olderThan, batch], "$1::interval, $2"));
|
|
420
|
+
return run(async () => {
|
|
421
|
+
const tenants = await sql.queryRaw("select tenant::text from better_supabase.audit_events_tenants($1::interval) as tenant", ["1 day"]);
|
|
422
|
+
let purged = 0;
|
|
423
|
+
for (const { tenant } of tenants) {
|
|
424
|
+
const days = await retention(tenant);
|
|
425
|
+
const keep = days === void 0 ? olderThan : `${days} days`;
|
|
426
|
+
purged += await purge([
|
|
427
|
+
keep,
|
|
428
|
+
batch,
|
|
429
|
+
tenant
|
|
430
|
+
], "$1::interval, $2, $3, true");
|
|
431
|
+
}
|
|
432
|
+
return purged;
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
//#endregion
|
|
436
|
+
//#region src/jobs/outbox.ts
|
|
437
|
+
function isOutboxRow(value) {
|
|
438
|
+
return typeof value === "object" && value !== null && "position" in value && "type" in value && "created_at" in value;
|
|
439
|
+
}
|
|
440
|
+
function toEvent(row) {
|
|
441
|
+
return {
|
|
442
|
+
position: row.position,
|
|
443
|
+
id: String(row.id),
|
|
444
|
+
type: row.type,
|
|
445
|
+
payload: row.payload,
|
|
446
|
+
source: row.source ?? null,
|
|
447
|
+
subject: row.subject ?? null,
|
|
448
|
+
tenant: row.tenant === void 0 || row.tenant === null ? null : String(row.tenant),
|
|
449
|
+
key: row.key ?? null,
|
|
450
|
+
actorId: row.actor_id ?? null,
|
|
451
|
+
createdAt: toInstant(row.created_at)
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function toEvents(value) {
|
|
455
|
+
if (!Array.isArray(value)) return [];
|
|
456
|
+
return value.filter(isOutboxRow).map(toEvent);
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* The CloudEvent for an outbox row: the row id as `id` (stable, so receivers
|
|
460
|
+
* can deduplicate), the payload as `data`, the tenant as `partitionkey` and
|
|
461
|
+
* the writing module as `producer`. The actor stays out of the context
|
|
462
|
+
* attributes, which must not carry personal data.
|
|
463
|
+
*/
|
|
464
|
+
function outboxCloudEvent(event, options) {
|
|
465
|
+
return {
|
|
466
|
+
specversion: "1.0",
|
|
467
|
+
id: event.id,
|
|
468
|
+
source: options.source,
|
|
469
|
+
type: `${options.typePrefix ?? "dev.better-supabase"}.${event.type}`,
|
|
470
|
+
...event.subject ? { subject: event.subject } : {},
|
|
471
|
+
time: event.createdAt.toString(),
|
|
472
|
+
datacontenttype: "application/json",
|
|
473
|
+
data: event.payload,
|
|
474
|
+
...event.tenant ? { partitionkey: event.tenant } : {},
|
|
475
|
+
...event.source ? { producer: event.source } : {}
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
function createOutbox(sql, options) {
|
|
479
|
+
const schema = sqlIdent(options.schema ?? "better_supabase");
|
|
480
|
+
const call = async (fn, args) => {
|
|
481
|
+
const params = args.map((_, index) => `$${String(index + 1)}`).join(", ");
|
|
482
|
+
const [row] = await sql.queryRaw(`select ${schema}.${sqlIdent(fn)}(${params}) as value`, args);
|
|
483
|
+
return row?.value ?? null;
|
|
484
|
+
};
|
|
485
|
+
async function relay(consumer, sink, relayOptions = {}, deadline) {
|
|
486
|
+
const owner = relayOptions.owner ?? workerId();
|
|
487
|
+
const batch = relayOptions.batch ?? 100;
|
|
488
|
+
const lease = relayOptions.lease ?? "1 minute";
|
|
489
|
+
const until = deadline ?? (relayOptions.budgetMs === void 0 ? Number.POSITIVE_INFINITY : Date.now() + relayOptions.budgetMs);
|
|
490
|
+
let delivered = 0;
|
|
491
|
+
while (Date.now() < until) {
|
|
492
|
+
let events;
|
|
493
|
+
try {
|
|
494
|
+
events = toEvents(await call("outbox_claim", [
|
|
495
|
+
consumer,
|
|
496
|
+
owner,
|
|
497
|
+
batch,
|
|
498
|
+
lease
|
|
499
|
+
]));
|
|
500
|
+
} catch (cause) {
|
|
501
|
+
return {
|
|
502
|
+
delivered,
|
|
503
|
+
error: asDbError(cause)
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
const last = events.at(-1);
|
|
507
|
+
if (last === void 0) {
|
|
508
|
+
await call("outbox_ack", [
|
|
509
|
+
consumer,
|
|
510
|
+
owner,
|
|
511
|
+
null
|
|
512
|
+
]).catch(() => null);
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
await sink.send(events.map((event) => outboxCloudEvent(event, options)));
|
|
517
|
+
} catch (cause) {
|
|
518
|
+
await call("outbox_ack", [
|
|
519
|
+
consumer,
|
|
520
|
+
owner,
|
|
521
|
+
null
|
|
522
|
+
]).catch(() => null);
|
|
523
|
+
return {
|
|
524
|
+
delivered,
|
|
525
|
+
error: toDbError(cause)
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
try {
|
|
529
|
+
await call("outbox_ack", [
|
|
530
|
+
consumer,
|
|
531
|
+
owner,
|
|
532
|
+
last.position
|
|
533
|
+
]);
|
|
534
|
+
} catch (cause) {
|
|
535
|
+
return {
|
|
536
|
+
delivered,
|
|
537
|
+
error: asDbError(cause)
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
delivered += events.length;
|
|
541
|
+
if (events.length < batch) break;
|
|
542
|
+
}
|
|
543
|
+
return { delivered };
|
|
544
|
+
}
|
|
545
|
+
return {
|
|
546
|
+
emit: (type, payload = {}, emitOptions = {}) => run(async () => String(await call("emit_event", [
|
|
547
|
+
type,
|
|
548
|
+
JSON.stringify(payload),
|
|
549
|
+
emitOptions.subject ?? null,
|
|
550
|
+
emitOptions.tenant ?? null,
|
|
551
|
+
emitOptions.key ?? null,
|
|
552
|
+
emitOptions.source ?? null
|
|
553
|
+
]))),
|
|
554
|
+
register: (consumer, registerOptions = {}) => run(async () => Number(await call("outbox_register", [
|
|
555
|
+
consumer,
|
|
556
|
+
registerOptions.types ?? null,
|
|
557
|
+
registerOptions.fromStart ?? false
|
|
558
|
+
]))),
|
|
559
|
+
unregister: (consumer) => run(async () => await call("outbox_unregister", [consumer]) === true),
|
|
560
|
+
relay: (consumer, sink, relayOptions) => relay(consumer, sink, relayOptions),
|
|
561
|
+
relayRoute(routeOptions) {
|
|
562
|
+
const secret = routeOptions.secret;
|
|
563
|
+
if (!secret) throw new TypeError("relayRoute needs a secret, such as process.env.CRON_SECRET");
|
|
564
|
+
return async (request) => {
|
|
565
|
+
const instance = new URL(request.url).pathname;
|
|
566
|
+
if (request.method !== "GET" && request.method !== "POST") return new Response(null, {
|
|
567
|
+
status: 405,
|
|
568
|
+
headers: { allow: "GET, POST" }
|
|
569
|
+
});
|
|
570
|
+
if (!verifySharedSecret(request, secret)) return problemResponse(dbError("unauthorized", "The relay route needs its bearer secret"), { instance });
|
|
571
|
+
const deadline = Date.now() + (routeOptions.budgetMs ?? 5e4);
|
|
572
|
+
const results = {};
|
|
573
|
+
for (const [consumer, sink] of Object.entries(routeOptions.consumers)) {
|
|
574
|
+
if (Date.now() >= deadline) break;
|
|
575
|
+
const result = await relay(consumer, sink, routeOptions, deadline);
|
|
576
|
+
if (result.error) routeOptions.onError?.(result.error, consumer);
|
|
577
|
+
results[consumer] = result;
|
|
578
|
+
}
|
|
579
|
+
const result = {
|
|
580
|
+
consumers: results,
|
|
581
|
+
budgetExhausted: Date.now() >= deadline
|
|
582
|
+
};
|
|
583
|
+
return Response.json(result);
|
|
584
|
+
};
|
|
585
|
+
},
|
|
586
|
+
history: (filter = {}) => run(async () => toEvents(await call("outbox_history", [
|
|
587
|
+
filter.subject ?? null,
|
|
588
|
+
filter.type ?? null,
|
|
589
|
+
filter.after ?? 0,
|
|
590
|
+
filter.limit ?? 100
|
|
591
|
+
]))),
|
|
592
|
+
purge: (olderThan, batch) => run(async () => Number(await call("purge_outbox", [olderThan ?? null, batch ?? null])))
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
//#endregion
|
|
596
|
+
//#region src/jobs/queue.ts
|
|
597
|
+
/**
|
|
598
|
+
* The `jobs` SQL kit module over a service SQL connection. The same
|
|
599
|
+
* functions back both `kits.jobs.options.backend` values (pgmq and table)
|
|
600
|
+
* and both schedulers.
|
|
601
|
+
*/
|
|
602
|
+
function sqlQueueBackend(sql) {
|
|
40
603
|
return {
|
|
604
|
+
apiVersion: 1,
|
|
41
605
|
name: "sql",
|
|
606
|
+
leases: true,
|
|
42
607
|
async send(queue, payload, delay, maxAttempts, dedupeKey) {
|
|
43
608
|
const [row] = await sql.queryRaw("select better_supabase.enqueue_job($1, $2, $3, $4, $5) as id", [
|
|
44
609
|
queue,
|
|
@@ -81,17 +646,41 @@ function sqlTransport(sql) {
|
|
|
81
646
|
]);
|
|
82
647
|
return row?.extended ?? false;
|
|
83
648
|
},
|
|
84
|
-
async
|
|
85
|
-
await sql.queryRaw("select better_supabase.
|
|
649
|
+
async replay(queue, id) {
|
|
650
|
+
const [row] = await sql.queryRaw("select better_supabase.replay_dead_job($1, $2) as id", [queue, id]);
|
|
651
|
+
return row?.id === null || row?.id === void 0 ? null : Number(row.id);
|
|
652
|
+
},
|
|
653
|
+
async schedule(name, cron, queue, payload, timeZone, nextRun) {
|
|
654
|
+
await sql.queryRaw("select better_supabase.schedule_job($1, $2, $3, $4, $5, $6)", [
|
|
86
655
|
name,
|
|
87
656
|
cron,
|
|
88
657
|
queue,
|
|
89
|
-
JSON.stringify(payload ?? {})
|
|
658
|
+
JSON.stringify(payload ?? {}),
|
|
659
|
+
timeZone,
|
|
660
|
+
nextRun.toString()
|
|
90
661
|
]);
|
|
91
662
|
},
|
|
92
663
|
async unschedule(name) {
|
|
93
664
|
const [row] = await sql.queryRaw("select better_supabase.unschedule_job($1) as done", [name]);
|
|
94
665
|
return row?.done ?? false;
|
|
666
|
+
},
|
|
667
|
+
async dueSchedules(lease, batch) {
|
|
668
|
+
return (await sql.queryRaw("select * from better_supabase.claim_due_schedules($1, $2)", [lease, batch])).map((row) => ({
|
|
669
|
+
name: row.job_name,
|
|
670
|
+
cron: row.schedule,
|
|
671
|
+
timeZone: row.timezone,
|
|
672
|
+
queue: row.queue,
|
|
673
|
+
payload: row.payload,
|
|
674
|
+
nextRun: toInstant(row.next_run)
|
|
675
|
+
}));
|
|
676
|
+
},
|
|
677
|
+
async advanceSchedule(name, ran, next) {
|
|
678
|
+
const [row] = await sql.queryRaw("select better_supabase.advance_schedule($1, $2, $3) as advanced", [
|
|
679
|
+
name,
|
|
680
|
+
ran.toString(),
|
|
681
|
+
next.toString()
|
|
682
|
+
]);
|
|
683
|
+
return row?.advanced ?? false;
|
|
95
684
|
}
|
|
96
685
|
};
|
|
97
686
|
}
|
|
@@ -99,11 +688,11 @@ function sqlOnly(feature) {
|
|
|
99
688
|
throw new DbException(dbError("invalid_request", `${feature} needs a SQL connection; pgmq_public (PostgREST) does not support it`));
|
|
100
689
|
}
|
|
101
690
|
/**
|
|
102
|
-
* Supabase's `pgmq_public` RPCs (Integrations
|
|
103
|
-
* PostgREST"). No lease check on complete, no retry delay
|
|
104
|
-
*
|
|
691
|
+
* Supabase's `pgmq_public` RPCs (Integrations, Queues, "Expose Queues via
|
|
692
|
+
* PostgREST"). No lease check on complete, no retry delay, no deduplication
|
|
693
|
+
* and no schedules: a failed job reappears when its lease ends.
|
|
105
694
|
*/
|
|
106
|
-
function
|
|
695
|
+
function pgmqPublicBackend(client) {
|
|
107
696
|
const call = async (fn, args) => {
|
|
108
697
|
const { data, error } = await client.schema("pgmq_public").rpc(fn, args);
|
|
109
698
|
if (error) {
|
|
@@ -117,7 +706,9 @@ function postgrestTransport(client) {
|
|
|
117
706
|
message_id: job.id
|
|
118
707
|
});
|
|
119
708
|
return {
|
|
120
|
-
|
|
709
|
+
apiVersion: 1,
|
|
710
|
+
name: "pgmq_public",
|
|
711
|
+
leases: false,
|
|
121
712
|
async send(queue, payload, delay, maxAttempts, dedupeKey) {
|
|
122
713
|
if (dedupeKey !== void 0) sqlOnly("dedupeKey");
|
|
123
714
|
const ids = await call("send", {
|
|
@@ -131,11 +722,18 @@ function postgrestTransport(client) {
|
|
|
131
722
|
return Number(ids[0]);
|
|
132
723
|
},
|
|
133
724
|
async read(queue, lease, batch) {
|
|
134
|
-
|
|
725
|
+
const rows = await call("read", {
|
|
135
726
|
queue_name: queue,
|
|
136
727
|
sleep_seconds: lease,
|
|
137
728
|
n: batch
|
|
138
|
-
})
|
|
729
|
+
});
|
|
730
|
+
const live = [];
|
|
731
|
+
for (const row of rows) if (row.read_ct > (row.message?.max_attempts ?? 5)) await call("archive", {
|
|
732
|
+
queue_name: queue,
|
|
733
|
+
message_id: row.msg_id
|
|
734
|
+
});
|
|
735
|
+
else live.push(row);
|
|
736
|
+
return live.map((row) => ({
|
|
139
737
|
id: row.msg_id,
|
|
140
738
|
attempts: row.read_ct,
|
|
141
739
|
enqueued_at: row.enqueued_at,
|
|
@@ -154,14 +752,55 @@ function postgrestTransport(client) {
|
|
|
154
752
|
unschedule: () => sqlOnly("unschedule")
|
|
155
753
|
};
|
|
156
754
|
}
|
|
157
|
-
/**
|
|
158
|
-
const
|
|
755
|
+
/** Marks a payload that carries a job context; pgmq stores it as the payload. */
|
|
756
|
+
const ENVELOPE = "$bs";
|
|
757
|
+
/** Schedule times are stored to the millisecond, the precision `Date` reads back. */
|
|
758
|
+
const millis = (instant) => instant.round({
|
|
759
|
+
smallestUnit: "millisecond",
|
|
760
|
+
roundingMode: "floor"
|
|
761
|
+
});
|
|
762
|
+
function withContext(payload, context) {
|
|
763
|
+
if (!context) return payload;
|
|
764
|
+
const tenant = tenantOf(context) ?? tenantClaimPaths().map((path) => claimAt(context.claims, path)).find((id) => id !== void 0);
|
|
765
|
+
const recorded = {
|
|
766
|
+
...context.actor ? { actor: context.actor } : {},
|
|
767
|
+
...tenant === void 0 ? {} : { tenant }
|
|
768
|
+
};
|
|
769
|
+
if (!recorded.actor && recorded.tenant === void 0) return payload;
|
|
770
|
+
return {
|
|
771
|
+
[ENVELOPE]: 1,
|
|
772
|
+
context: recorded,
|
|
773
|
+
payload
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
function isRecord(value) {
|
|
777
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
778
|
+
}
|
|
779
|
+
function isActor(value) {
|
|
780
|
+
return isRecord(value) && typeof value["id"] === "string" && (value["kind"] === "user" || value["kind"] === "service" || value["kind"] === "anon");
|
|
781
|
+
}
|
|
782
|
+
function unwrap(stored) {
|
|
783
|
+
if (!isRecord(stored) || stored[ENVELOPE] !== 1 || !Object.hasOwn(stored, "payload")) return {
|
|
784
|
+
payload: stored,
|
|
785
|
+
context: {}
|
|
786
|
+
};
|
|
787
|
+
const { actor, tenant } = isRecord(stored["context"]) ? stored["context"] : {};
|
|
788
|
+
return {
|
|
789
|
+
payload: stored["payload"],
|
|
790
|
+
context: {
|
|
791
|
+
...isActor(actor) ? { actor } : {},
|
|
792
|
+
...typeof tenant === "string" && tenant.length > 0 ? { tenant } : {}
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
}
|
|
159
796
|
function toJob(queue, row) {
|
|
160
797
|
const message = row.message ?? {};
|
|
798
|
+
const { payload, context } = unwrap(message.payload);
|
|
161
799
|
return {
|
|
162
800
|
id: Number(row.id),
|
|
163
801
|
queue,
|
|
164
|
-
payload
|
|
802
|
+
payload,
|
|
803
|
+
context,
|
|
165
804
|
attempts: row.attempts,
|
|
166
805
|
maxAttempts: message.max_attempts ?? 5,
|
|
167
806
|
enqueuedAt: toInstant(row.enqueued_at),
|
|
@@ -169,26 +808,30 @@ function toJob(queue, row) {
|
|
|
169
808
|
lastError: message.last_error ?? null
|
|
170
809
|
};
|
|
171
810
|
}
|
|
172
|
-
function errorText(error) {
|
|
173
|
-
if (error instanceof Error) return error.message;
|
|
174
|
-
if (typeof error === "object" && error !== null && "message" in error) return String(error.message);
|
|
175
|
-
return String(error);
|
|
176
|
-
}
|
|
177
811
|
const QUEUE_NAME = /^[a-z_][a-z0-9_]{0,46}$/;
|
|
812
|
+
function isBackend(source) {
|
|
813
|
+
return "apiVersion" in source;
|
|
814
|
+
}
|
|
178
815
|
function isSqlClient(source) {
|
|
179
816
|
return typeof source.queryRaw === "function";
|
|
180
817
|
}
|
|
818
|
+
function backendOf(source) {
|
|
819
|
+
if (isBackend(source)) return source;
|
|
820
|
+
return isSqlClient(source) ? sqlQueueBackend(source) : pgmqPublicBackend(source);
|
|
821
|
+
}
|
|
822
|
+
const later = (a, b) => temporal().Instant.compare(a, b) >= 0 ? a : b;
|
|
181
823
|
/**
|
|
182
824
|
* Typed jobs on Supabase Queues (pgmq), over the `jobs` SQL kit module.
|
|
183
825
|
*
|
|
184
826
|
* Pass a service SQL connection (`postgres.admin`, `ctx.sql`) for the full
|
|
185
|
-
* feature set,
|
|
186
|
-
* `pgmq_public` RPCs where no direct connection exists (edge functions)
|
|
187
|
-
* Queue names are lowercase letters, digits and
|
|
827
|
+
* feature set, a service-role Supabase client to go through the
|
|
828
|
+
* `pgmq_public` RPCs where no direct connection exists (edge functions), or
|
|
829
|
+
* any `QueueBackend`. Queue names are lowercase letters, digits and
|
|
830
|
+
* underscores.
|
|
188
831
|
*/
|
|
189
832
|
function createJobs(source, queues) {
|
|
190
833
|
for (const name of Object.keys(queues)) if (!QUEUE_NAME.test(name)) throw new TypeError(`Queue "${name}": pgmq queue names are lowercase letters, digits and underscores (at most 47)`);
|
|
191
|
-
const transport =
|
|
834
|
+
const transport = backendOf(source);
|
|
192
835
|
const schemaOf = (queue) => {
|
|
193
836
|
const schema = queues[queue];
|
|
194
837
|
if (!schema) throw new TypeError(`Unknown queue "${queue}". Queues: ${Object.keys(queues).join(", ")}`);
|
|
@@ -204,7 +847,7 @@ function createJobs(source, queues) {
|
|
|
204
847
|
* Extends the lease of every job in `held` at half the lease, so jobs
|
|
205
848
|
* waiting their turn in a claimed batch don't expire before they run.
|
|
206
849
|
*/
|
|
207
|
-
const heartbeat = (held, lease) => transport.
|
|
850
|
+
const heartbeat = (held, lease) => transport.leases ? setInterval(() => {
|
|
208
851
|
for (const job of held) run(() => transport.extend(job, lease));
|
|
209
852
|
}, Math.max(1e3, lease * 1e3 / 2)) : void 0;
|
|
210
853
|
const handle = async (job, handler, workOptions) => {
|
|
@@ -216,9 +859,11 @@ function createJobs(source, queues) {
|
|
|
216
859
|
workOptions.onError?.(payload.error, job);
|
|
217
860
|
return false;
|
|
218
861
|
}
|
|
862
|
+
const context = workOptions.allTenants && job.context.tenant === void 0 ? allTenantsContext(job.context) : job.context;
|
|
219
863
|
const outcome = await handler(payload.data, {
|
|
220
864
|
...job,
|
|
221
|
-
payload: payload.data
|
|
865
|
+
payload: payload.data,
|
|
866
|
+
context
|
|
222
867
|
}, controller.signal);
|
|
223
868
|
if (typeof outcome === "object" && outcome !== null && "ok" in outcome && outcome.ok === false && "error" in outcome) throw outcome.error;
|
|
224
869
|
await complete(job);
|
|
@@ -230,7 +875,7 @@ function createJobs(source, queues) {
|
|
|
230
875
|
return false;
|
|
231
876
|
}
|
|
232
877
|
};
|
|
233
|
-
const loop = async (queue, handler, workOptions, forever) => {
|
|
878
|
+
const loop = async (queue, handler, workOptions, forever, deadline) => {
|
|
234
879
|
const concurrency = Math.max(1, workOptions.concurrency ?? 1);
|
|
235
880
|
const lease = workOptions.lease ?? 300;
|
|
236
881
|
const firstWait = workOptions.pollInterval ?? 1e3;
|
|
@@ -240,6 +885,7 @@ function createJobs(source, queues) {
|
|
|
240
885
|
const lanes = Array.from({ length: concurrency }, async () => {
|
|
241
886
|
let wait = firstWait;
|
|
242
887
|
while (!workOptions.signal?.aborted) {
|
|
888
|
+
if (deadline !== void 0 && Date.now() >= deadline) return;
|
|
243
889
|
const claimed = await claim(queue, {
|
|
244
890
|
...workOptions,
|
|
245
891
|
batch: workOptions.batch ?? 1
|
|
@@ -271,230 +917,91 @@ function createJobs(source, queues) {
|
|
|
271
917
|
failed
|
|
272
918
|
};
|
|
273
919
|
};
|
|
920
|
+
const runSchedules = async (scheduleOptions) => {
|
|
921
|
+
if (!transport.dueSchedules || !transport.advanceSchedule) return 0;
|
|
922
|
+
const due = await transport.dueSchedules(scheduleOptions.lease ?? 60, scheduleOptions.batch ?? 100);
|
|
923
|
+
let ran = 0;
|
|
924
|
+
for (const schedule of due) {
|
|
925
|
+
await transport.send(schedule.queue, schedule.payload, 0, 5, `schedule:${schedule.name}:${schedule.nextRun.toString()}`);
|
|
926
|
+
const next = millis(nextCronRun(schedule.cron, schedule.timeZone, later(nowInstant(), schedule.nextRun)));
|
|
927
|
+
if (await transport.advanceSchedule(schedule.name, schedule.nextRun, next)) ran += 1;
|
|
928
|
+
}
|
|
929
|
+
return ran;
|
|
930
|
+
};
|
|
274
931
|
return {
|
|
275
932
|
enqueue(queue, payload, enqueueOptions = {}) {
|
|
276
933
|
return AsyncResult.from(async () => {
|
|
277
934
|
const valid = await validate(schemaOf(queue), payload, "payload");
|
|
278
935
|
if (!valid.ok) return valid;
|
|
279
936
|
const delay = enqueueOptions.delay ?? (enqueueOptions.runAt ? Math.max(0, Math.ceil((enqueueOptions.runAt.epochMilliseconds - nowInstant().epochMilliseconds) / 1e3)) : 0);
|
|
280
|
-
return run(() => transport.send(queue, valid.data, delay, enqueueOptions.maxAttempts ?? 5, enqueueOptions.dedupeKey));
|
|
937
|
+
return run(() => transport.send(queue, withContext(valid.data, enqueueOptions.context), delay, enqueueOptions.maxAttempts ?? 5, enqueueOptions.dedupeKey));
|
|
281
938
|
});
|
|
282
939
|
},
|
|
283
940
|
claim,
|
|
284
941
|
complete,
|
|
285
942
|
fail,
|
|
286
943
|
extend: (job, lease) => run(() => transport.extend(job, lease)),
|
|
287
|
-
|
|
944
|
+
replay: (queue, id) => run(() => {
|
|
945
|
+
if (!transport.replay) sqlOnly("replay");
|
|
946
|
+
return transport.replay(queue, id);
|
|
947
|
+
}),
|
|
948
|
+
schedule(name, cron, queue, payload, scheduleOptions = {}) {
|
|
288
949
|
return AsyncResult.from(async () => {
|
|
289
950
|
const valid = await validate(schemaOf(queue), payload, "payload");
|
|
290
951
|
if (!valid.ok) return valid;
|
|
291
|
-
|
|
952
|
+
const stored = withContext(valid.data, scheduleOptions.context);
|
|
953
|
+
const timeZone = scheduleOptions.timeZone ?? "UTC";
|
|
954
|
+
return run(() => {
|
|
955
|
+
const nextRun = millis(nextCronRun(cron, timeZone, nowInstant()));
|
|
956
|
+
return transport.schedule(name, cron, queue, stored, timeZone, nextRun);
|
|
957
|
+
});
|
|
292
958
|
});
|
|
293
959
|
},
|
|
294
960
|
unschedule: (name) => run(() => transport.unschedule(name)),
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const lock = seconds(options.lock ?? "1 minute");
|
|
308
|
-
const begin = (key, fingerprint, scope = "") => run(async () => {
|
|
309
|
-
const [row] = await sql.queryRaw("select * from better_supabase.begin_idempotent($1, $2, $3, $4::interval, $5::interval)", [
|
|
310
|
-
scope,
|
|
311
|
-
key,
|
|
312
|
-
fingerprint,
|
|
313
|
-
ttl,
|
|
314
|
-
lock
|
|
315
|
-
]);
|
|
316
|
-
return {
|
|
317
|
-
state: row.state,
|
|
318
|
-
status: row.status_code,
|
|
319
|
-
body: row.response
|
|
320
|
-
};
|
|
321
|
-
});
|
|
322
|
-
const complete = (key, status, body, scope = "") => run(async () => {
|
|
323
|
-
await sql.queryRaw("select better_supabase.complete_idempotent($1, $2, $3, $4)", [
|
|
324
|
-
scope,
|
|
325
|
-
key,
|
|
326
|
-
status,
|
|
327
|
-
JSON.stringify(body)
|
|
328
|
-
]);
|
|
329
|
-
});
|
|
330
|
-
const release = (key, scope = "") => run(async () => {
|
|
331
|
-
await sql.queryRaw("select better_supabase.release_idempotent($1, $2)", [scope, key]);
|
|
332
|
-
});
|
|
333
|
-
return {
|
|
334
|
-
begin,
|
|
335
|
-
complete,
|
|
336
|
-
release,
|
|
337
|
-
async handle(request, handler) {
|
|
338
|
-
const key = request.headers.get(headerName);
|
|
339
|
-
const instance = new URL(request.url).pathname;
|
|
340
|
-
if (!key) return options.required ? problemResponse(dbError("invalid_request", `The ${headerName} header is required`, { code: "IDEMPOTENCY_KEY_MISSING" }), { instance }) : handler(request);
|
|
341
|
-
if (key.length > 255) return problemResponse(dbError("invalid_request", `The ${headerName} header is too long`, { code: "IDEMPOTENCY_KEY_INVALID" }), { instance });
|
|
342
|
-
const scope = typeof options.scope === "function" ? options.scope(request) : options.scope ?? "";
|
|
343
|
-
const body = await request.clone().text();
|
|
344
|
-
const fingerprint = await sha256(`${request.method} ${instance}\n${body}`);
|
|
345
|
-
const started = await begin(key, fingerprint, scope);
|
|
346
|
-
if (!started.ok) return problemResponse(started.error, { instance });
|
|
347
|
-
const { state } = started.data;
|
|
348
|
-
switch (state) {
|
|
349
|
-
case "replay": {
|
|
350
|
-
const stored = started.data.body;
|
|
351
|
-
return new Response(stored.body, {
|
|
352
|
-
status: started.data.status ?? 200,
|
|
353
|
-
headers: {
|
|
354
|
-
...stored.contentType ? { "content-type": stored.contentType } : {},
|
|
355
|
-
"idempotency-replayed": "true"
|
|
356
|
-
}
|
|
357
|
-
});
|
|
358
|
-
}
|
|
359
|
-
case "running": return problemResponse(dbError("conflict", "A request with this idempotency key is still running", { code: "IDEMPOTENCY_KEY_IN_USE" }), {
|
|
360
|
-
instance,
|
|
361
|
-
headers: { "retry-after": "1" }
|
|
961
|
+
runSchedules: (scheduleOptions = {}) => run(() => runSchedules(scheduleOptions)),
|
|
962
|
+
drain: (queue, handler, drainOptions = {}) => loop(queue, handler, drainOptions, false, drainOptions.budgetMs === void 0 ? void 0 : Date.now() + drainOptions.budgetMs),
|
|
963
|
+
work: (queue, handler, workOptions = {}) => loop(queue, handler, workOptions, true),
|
|
964
|
+
drainRoute(routeOptions) {
|
|
965
|
+
const secret = routeOptions.secret;
|
|
966
|
+
if (!secret) throw new TypeError("drainRoute needs a secret, such as process.env.CRON_SECRET");
|
|
967
|
+
for (const queue of Object.keys(routeOptions.handlers)) schemaOf(queue);
|
|
968
|
+
return async (request) => {
|
|
969
|
+
const instance = new URL(request.url).pathname;
|
|
970
|
+
if (request.method !== "GET" && request.method !== "POST") return new Response(null, {
|
|
971
|
+
status: 405,
|
|
972
|
+
headers: { allow: "GET, POST" }
|
|
362
973
|
});
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
response = await handler(request);
|
|
376
|
-
} catch (cause) {
|
|
377
|
-
await release(key, scope);
|
|
378
|
-
throw cause;
|
|
379
|
-
}
|
|
380
|
-
if (response.status >= 500) {
|
|
381
|
-
await release(key, scope);
|
|
382
|
-
return response;
|
|
383
|
-
}
|
|
384
|
-
const stored = {
|
|
385
|
-
body: await response.clone().text(),
|
|
386
|
-
contentType: response.headers.get("content-type")
|
|
387
|
-
};
|
|
388
|
-
await complete(key, response.status, stored, scope);
|
|
389
|
-
return response;
|
|
390
|
-
}
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
function defaultType(payload) {
|
|
394
|
-
if (typeof payload !== "object" || payload === null) return null;
|
|
395
|
-
const type = payload.type;
|
|
396
|
-
return typeof type === "string" ? type : null;
|
|
397
|
-
}
|
|
398
|
-
/** Store-then-process webhooks: acknowledge fast, process with retries, never twice. */
|
|
399
|
-
function createInbox(sql, options) {
|
|
400
|
-
const worker = options.worker ?? workerId();
|
|
401
|
-
if (!options.secrets && !options.verify) throw new TypeError("createInbox needs `secrets` (Standard Webhooks) or `verify`");
|
|
402
|
-
const verified = async (request) => {
|
|
403
|
-
if (options.verify) return options.verify(request, await request.clone().text());
|
|
404
|
-
const result = await verifyWebhook(request, options.secrets);
|
|
405
|
-
return result.ok ? ok({
|
|
406
|
-
id: result.data.id,
|
|
407
|
-
payload: result.data.payload
|
|
408
|
-
}) : result;
|
|
409
|
-
};
|
|
410
|
-
return {
|
|
411
|
-
async receive(request) {
|
|
412
|
-
const instance = new URL(request.url).pathname;
|
|
413
|
-
if (request.method !== "POST") return new Response(null, {
|
|
414
|
-
status: 405,
|
|
415
|
-
headers: { allow: "POST" }
|
|
416
|
-
});
|
|
417
|
-
const message = await verified(request);
|
|
418
|
-
if (!message.ok) return problemResponse(message.error, { instance });
|
|
419
|
-
const headers = Object.fromEntries((options.keepHeaders ?? []).flatMap((name) => {
|
|
420
|
-
const value = request.headers.get(name);
|
|
421
|
-
return value === null ? [] : [[name.toLowerCase(), value]];
|
|
422
|
-
}));
|
|
423
|
-
const stored = await run(() => sql.queryRaw("select * from better_supabase.receive_webhook($1, $2, $3, $4, $5)", [
|
|
424
|
-
options.source,
|
|
425
|
-
message.data.id,
|
|
426
|
-
(options.typeOf ?? defaultType)(message.data.payload),
|
|
427
|
-
JSON.stringify(message.data.payload),
|
|
428
|
-
JSON.stringify(headers)
|
|
429
|
-
]));
|
|
430
|
-
if (!stored.ok) return problemResponse(stored.error, { instance });
|
|
431
|
-
const row = stored.data[0];
|
|
432
|
-
return Response.json({
|
|
433
|
-
id: Number(row.id),
|
|
434
|
-
duplicate: row.duplicate
|
|
435
|
-
}, { status: row.duplicate ? 200 : 202 });
|
|
436
|
-
},
|
|
437
|
-
async process(handler, processOptions = {}) {
|
|
438
|
-
let succeeded = 0;
|
|
439
|
-
let failed = 0;
|
|
440
|
-
for (;;) {
|
|
441
|
-
const rows = await sql.queryRaw("select * from better_supabase.claim_webhooks($1, $2, $3, $4::interval)", [
|
|
442
|
-
options.source,
|
|
443
|
-
worker,
|
|
444
|
-
processOptions.batch ?? 10,
|
|
445
|
-
seconds(processOptions.lease ?? 300)
|
|
446
|
-
]);
|
|
447
|
-
if (rows.length === 0) return {
|
|
448
|
-
succeeded,
|
|
449
|
-
failed
|
|
450
|
-
};
|
|
451
|
-
for (const row of rows) {
|
|
452
|
-
const message = {
|
|
453
|
-
id: Number(row.id),
|
|
454
|
-
source: row.source,
|
|
455
|
-
messageId: row.message_id,
|
|
456
|
-
type: row.event_type,
|
|
457
|
-
payload: row.payload,
|
|
458
|
-
headers: row.headers,
|
|
459
|
-
attempts: row.attempts,
|
|
460
|
-
receivedAt: toInstant(row.received_at)
|
|
461
|
-
};
|
|
974
|
+
if (!verifySharedSecret(request, secret)) return problemResponse(dbError("unauthorized", "The drain route needs its bearer secret"), { instance });
|
|
975
|
+
const deadline = Date.now() + (routeOptions.budgetMs ?? 5e4);
|
|
976
|
+
let scheduled = 0;
|
|
977
|
+
if (routeOptions.schedules ?? true) {
|
|
978
|
+
const ran = await run(() => runSchedules({}));
|
|
979
|
+
if (ran.ok) scheduled = ran.data;
|
|
980
|
+
else routeOptions.onError?.(ran.error);
|
|
981
|
+
}
|
|
982
|
+
const results = {};
|
|
983
|
+
for (const [queue, handler] of Object.entries(routeOptions.handlers)) {
|
|
984
|
+
if (handler === void 0) continue;
|
|
985
|
+
if (Date.now() >= deadline) break;
|
|
462
986
|
try {
|
|
463
|
-
|
|
464
|
-
if (typeof outcome === "object" && outcome !== null && "ok" in outcome && outcome.ok === false) throw new Error(errorText(outcome.error ?? "The handler failed"));
|
|
465
|
-
await sql.queryRaw("select better_supabase.complete_webhook($1, $2)", [message.id, worker]);
|
|
466
|
-
succeeded += 1;
|
|
987
|
+
results[queue] = await loop(queue, handler, routeOptions, false, deadline);
|
|
467
988
|
} catch (cause) {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
failed += 1;
|
|
989
|
+
routeOptions.onError?.(toDbError(cause));
|
|
990
|
+
results[queue] = {
|
|
991
|
+
succeeded: 0,
|
|
992
|
+
failed: 0
|
|
993
|
+
};
|
|
474
994
|
}
|
|
475
995
|
}
|
|
476
|
-
|
|
996
|
+
const result = {
|
|
997
|
+
schedules: scheduled,
|
|
998
|
+
queues: results,
|
|
999
|
+
budgetExhausted: Date.now() >= deadline
|
|
1000
|
+
};
|
|
1001
|
+
return Response.json(result);
|
|
1002
|
+
};
|
|
477
1003
|
}
|
|
478
1004
|
};
|
|
479
1005
|
}
|
|
480
|
-
/** The Stripe event sent when a customer's active entitlements change. */
|
|
481
|
-
const ENTITLEMENTS_UPDATED = "entitlements.active_entitlement_summary.updated";
|
|
482
|
-
function stripeCustomerOf(payload) {
|
|
483
|
-
if (typeof payload !== "object" || payload === null) return void 0;
|
|
484
|
-
const customer = (payload.data?.object)?.customer;
|
|
485
|
-
if (typeof customer === "string") return customer;
|
|
486
|
-
const id = customer?.id;
|
|
487
|
-
return typeof id === "string" ? id : void 0;
|
|
488
|
-
}
|
|
489
|
-
/**
|
|
490
|
-
* Users whose `memberships` claim carries the entitlements of the Stripe
|
|
491
|
-
* customer in an `entitlements.active_entitlement_summary.updated` event, so
|
|
492
|
-
* a job can invalidate their sessions. Empty for other payloads.
|
|
493
|
-
*/
|
|
494
|
-
function entitlementMembers(sql, payload) {
|
|
495
|
-
const customer = stripeCustomerOf(payload);
|
|
496
|
-
if (customer === void 0) return AsyncResult.ok([]);
|
|
497
|
-
return run(() => sql.queryRaw("select user_id from better_supabase.entitlement_members($1) as user_id", [customer])).map((rows) => rows.map((row) => row.user_id));
|
|
498
|
-
}
|
|
499
1006
|
//#endregion
|
|
500
|
-
export { ENTITLEMENTS_UPDATED, createIdempotency, createInbox, createJobs, entitlementMembers };
|
|
1007
|
+
export { ENTITLEMENTS_UPDATED, assertCron, createIdempotency, createInbox, createJobs, createOutbox, entitlementMembers, nextCronRun, outboxCloudEvent, pgmqPublicBackend, purgeAuditLog, sqlQueueBackend };
|