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.
Files changed (179) hide show
  1. package/README.md +2 -0
  2. package/dist/channel-FQW-CnGR.d.ts +70 -0
  3. package/dist/cli/bin.js +1 -1
  4. package/dist/cli/codemod-DqV7612m.js +388 -0
  5. package/dist/cli/{config-D9R0o1LH.js → config-CPVpXP5I.js} +78 -8
  6. package/dist/cli/{config-SSKSTUl2.js → config-dtoekM0v.js} +1 -1
  7. package/dist/cli/{read-sets-CMa29t9a.js → diff-SjkO5rNr.js} +1 -220
  8. package/dist/cli/{dist-CFgGknmZ.js → dist-DBPdlPYK.js} +4 -4
  9. package/dist/cli/{dist-B1MvLv-M.js → dist-DreZ24Sz.js} +1 -1
  10. package/dist/cli/{dist-Du7VnEUG.js → dist-xn4wlboc.js} +112 -1
  11. package/dist/cli/{doctor-BOym3mbL.js → doctor-L7Z8mYTx.js} +531 -50
  12. package/dist/cli/{env-BZ0uy8XK.js → env-DmKv_G4A.js} +1 -1
  13. package/dist/cli/gen-BJc9vdyu.js +2 -0
  14. package/dist/cli/{gen-Ccn_p90L.js → gen-C2osRcr5.js} +283 -87
  15. package/dist/cli/index.d.ts +1 -1
  16. package/dist/cli/index.js +4 -4
  17. package/dist/cli/{init-ClnxRYBF.js → init-Saf5-R9T.js} +2 -2
  18. package/dist/cli/{introspect-DxqfkVIv.js → introspect--H7oGmcd.js} +2 -2
  19. package/dist/cli/{openapi-8WHjxiTJ.js → openapi-Cp5Fyta-.js} +1 -1
  20. package/dist/cli/read-sets-BfMQcKy8.js +289 -0
  21. package/dist/cli/{run-CP20LKk_.js → run-BKR2W_79.js} +16 -12
  22. package/dist/cli/{seed-5J6eu_Xg.js → seed-CUlqWh-8.js} +1 -1
  23. package/dist/cli/sql-DzPbfngx.js +348 -0
  24. package/dist/cli/{version-5mW_8sY1.js → version-BDJuO0UR.js} +1 -1
  25. package/dist/client/index.d.ts +1 -1
  26. package/dist/client/index.js +3 -10
  27. package/dist/clone-BfgmLU90.js +21 -0
  28. package/dist/{compiler-DB3aSFOA.d.ts → compiler-BXH_aZ7N.d.ts} +2 -2
  29. package/dist/config/index.d.ts +4 -4
  30. package/dist/config/index.js +4 -4
  31. package/dist/{config-CbvFmGD8.js → config-DreERlKo.js} +1 -1
  32. package/dist/{config-C1CYcm6o.d.ts → config-T_ejwyd3.d.ts} +176 -11
  33. package/dist/{define-CRS2MRV3.d.ts → define-Bp_BZa5h.d.ts} +11 -7
  34. package/dist/{define-Bh0AqTik.js → define-cNKzqwL_.js} +202 -16
  35. package/dist/edge/index.d.ts +16 -5
  36. package/dist/edge/index.js +10 -7
  37. package/dist/{entitlements-Dm-FHpNH.d.ts → entitlements-DUxU4oHe.d.ts} +1 -1
  38. package/dist/env/index.d.ts +2 -2
  39. package/dist/env/index.js +34 -1
  40. package/dist/{errors-L0xK41y_.js → errors-DtwXa6b8.js} +1 -0
  41. package/dist/events/index.d.ts +3 -2
  42. package/dist/events/index.js +65 -12
  43. package/dist/events-aIO7vsIk.d.ts +230 -0
  44. package/dist/{result-CqEsbgYs.d.ts → executor-BunPOO-k.d.ts} +200 -1
  45. package/dist/{executor-CZ7ImRiD.d.ts → executor-Cghdstwu.d.ts} +1 -2
  46. package/dist/{executor-CVt6dD0B.js → executor-XycESQPH.js} +3 -3
  47. package/dist/hono/index.d.ts +17 -6
  48. package/dist/hono/index.js +40 -5
  49. package/dist/impersonation-VfaGc4Q_.js +41 -0
  50. package/dist/{index-BBdvo-5d.d.ts → index-BGdgBPdf.d.ts} +49 -6
  51. package/dist/{index-BhHEC04r.d.ts → index-C7f8cOBM.d.ts} +2 -2
  52. package/dist/{index-Dy6kuF0P.d.ts → index-CLg87OBW.d.ts} +6 -1
  53. package/dist/{index-D5IfUIDh.d.ts → index-DOESKG27.d.ts} +33 -8
  54. package/dist/{index-DFmOpBGC.d.ts → index-DgILJRXj.d.ts} +35 -9
  55. package/dist/{index-RlLXd8dL.d.ts → index-DmXSxbLi.d.ts} +4 -4
  56. package/dist/{index-Cf-D2jk1.d.ts → index-Dxn2a5cw.d.ts} +2 -2
  57. package/dist/{index-528Ugmyp.d.ts → index-SaTsEfGP.d.ts} +76 -8
  58. package/dist/{index-BAy4tsAP.d.ts → index-fag8VJZ2.d.ts} +3 -4
  59. package/dist/index.d.ts +30 -13
  60. package/dist/index.js +10 -9
  61. package/dist/jobs/index.d.ts +147 -101
  62. package/dist/jobs/index.js +744 -237
  63. package/dist/{json-schema-DP6V7cbr.js → json-schema-m1fiwKgN.js} +22 -1
  64. package/dist/kit-events-CTFDfSwb.js +60 -0
  65. package/dist/kit-transport-B82k1djG.d.ts +33 -0
  66. package/dist/kit-transport-DJlHQUBU.js +55 -0
  67. package/dist/{config-BpxDfnbg.js → kits-CGLLrNPd.js} +37 -9
  68. package/dist/list/index.d.ts +1 -1
  69. package/dist/{live-BHakSWWo.d.ts → live-D9W2M5aO.d.ts} +6 -5
  70. package/dist/mcp/index.d.ts +25 -8
  71. package/dist/mcp/index.js +23 -14
  72. package/dist/{mfa-C7MNQ1Ob.d.ts → mfa-CusDgPaD.d.ts} +5 -1
  73. package/dist/next/index.d.ts +31 -10
  74. package/dist/next/index.js +136 -27
  75. package/dist/notifications/index.d.ts +162 -0
  76. package/dist/notifications/index.js +264 -0
  77. package/dist/openapi/index.d.ts +1 -1
  78. package/dist/openapi/index.js +2 -2
  79. package/dist/orgs/index.d.ts +132 -0
  80. package/dist/orgs/index.js +245 -0
  81. package/dist/orpc/index.d.ts +28 -8
  82. package/dist/orpc/index.js +16 -3
  83. package/dist/otel/index.d.ts +21 -2
  84. package/dist/otel/index.js +50 -9
  85. package/dist/permdock-sql-C2Inc6Xc.d.ts +29 -0
  86. package/dist/permdock-sql-DgOfeixi.js +51 -0
  87. package/dist/plugin-DwARMuQp.js +59 -0
  88. package/dist/{plugin-bNhvJwRh.d.ts → plugin-o144f322.d.ts} +17 -5
  89. package/dist/plugins/actor/index.d.ts +5 -2
  90. package/dist/plugins/actor/index.js +22 -8
  91. package/dist/plugins/rules/index.d.ts +2 -2
  92. package/dist/plugins/rules/index.js +15 -10
  93. package/dist/plugins/soft-delete/index.d.ts +3 -3
  94. package/dist/plugins/soft-delete/index.js +14 -5
  95. package/dist/plugins/tenant/index.d.ts +2 -2
  96. package/dist/plugins/tenant/index.js +38 -20
  97. package/dist/plugins/timestamps/index.d.ts +1 -1
  98. package/dist/plugins/timestamps/index.js +4 -3
  99. package/dist/plugins/validation/index.d.ts +2 -2
  100. package/dist/plugins/validation/index.js +26 -6
  101. package/dist/{pool-DFwEQwmN.d.ts → pool-C9voM4Kw.d.ts} +17 -6
  102. package/dist/postgres/index.d.ts +5 -5
  103. package/dist/postgres/index.js +20 -11
  104. package/dist/{postgrest-Bc-0Xw62.d.ts → postgrest-CruYKAvV.d.ts} +1 -1
  105. package/dist/{problem-BihjPR0t.js → problem-BW1KhzSG.js} +2 -1
  106. package/dist/{problem-U8UEKccv.d.ts → problem-Blxe8S31.d.ts} +1 -1
  107. package/dist/query/index.d.ts +2 -2
  108. package/dist/query/index.js +3 -3
  109. package/dist/{query-BMMSWbI7.js → query-CCbo44lG.js} +2 -2
  110. package/dist/queue-BYUCjr59.d.ts +252 -0
  111. package/dist/react/index.d.ts +8 -7
  112. package/dist/react/index.js +4 -3
  113. package/dist/react/server.d.ts +14 -3
  114. package/dist/react/server.js +10 -2
  115. package/dist/react/session.d.ts +7 -1
  116. package/dist/react/session.js +9 -1
  117. package/dist/{react-CQHMaESR.js → react-C_s7kiNz.js} +103 -11
  118. package/dist/{read-set-FWK8qQ58.js → read-set-DQFt1N_Y.js} +22 -1
  119. package/dist/realtime/index.d.ts +3 -3
  120. package/dist/realtime/index.js +5 -4
  121. package/dist/{refresh-BwYkyNOQ.d.ts → refresh-BTrQ_ZTp.d.ts} +1 -1
  122. package/dist/{resolve-BAqQcwMW.d.ts → resolve-C15TJq77.d.ts} +11 -5
  123. package/dist/{resource-My_y-Syh.js → resource-CRxxwkQq.js} +22 -7
  124. package/dist/{resource-BJcaIfxi.d.ts → resource-D8iqWMr-.d.ts} +5 -5
  125. package/dist/respond-D2OQl62-.d.ts +371 -0
  126. package/dist/{result-Bj6yt33D.js → result-BKiliPY7.js} +1 -1
  127. package/dist/server/index.d.ts +14 -12
  128. package/dist/server/index.js +11 -17
  129. package/dist/{server-BA75B0mT.js → server-Csx2sRjg.js} +251 -34
  130. package/dist/{shared-DnoCdMDr.js → shared-D1dRUrAg.js} +16 -2
  131. package/dist/signers-Cc6r98SW.d.ts +162 -0
  132. package/dist/{simplify-Cqvfr9dj.js → simplify-_0oUKqCd.js} +4 -115
  133. package/dist/sql/index.d.ts +247 -10
  134. package/dist/sql/index.js +8590 -779
  135. package/dist/{sql-B34HU2DI.js → sql-Coh6n4cC.js} +9 -2
  136. package/dist/ssr/index.d.ts +4 -4
  137. package/dist/ssr/index.js +1 -2
  138. package/dist/{standard-BRyBGTtp.js → standard-D7r6dMUu.js} +6 -3
  139. package/dist/{standard-BsaCYQJc.d.ts → standard-J4YgMOLm.d.ts} +1 -1
  140. package/dist/{stats-C_YxA2r1.js → stats-Bqob3WqT.js} +4 -4
  141. package/dist/storage/index.d.ts +4 -4
  142. package/dist/storage/index.js +155 -26
  143. package/dist/support-DT-OhSxD.d.ts +88 -0
  144. package/dist/support-Dk6vdmEw.js +291 -0
  145. package/dist/support-view-Da5peKFg.d.ts +17 -0
  146. package/dist/support-view-g4YLBeqX.js +18 -0
  147. package/dist/{permdock-sql-C4KFzLc9.js → template-TxrGI5jR.js} +2 -31
  148. package/dist/{temporal-BwshQk0W.js → temporal-CVlIV2DZ.js} +17 -4
  149. package/dist/{temporal-required-CBNHT6ni.js → temporal-required-CAt4Dc9Z.js} +2 -2
  150. package/dist/testing/index.d.ts +100 -10
  151. package/dist/testing/index.js +336 -8
  152. package/dist/{types-DONv9SHN.d.ts → types-Dnf_wRDz.d.ts} +26 -3
  153. package/dist/user-change-CHFEUro1.js +49 -0
  154. package/dist/{webhooks-Bh_Jf_-l.js → verify-Ce49ETPz.js} +5 -5
  155. package/dist/{version-Bmf7_FzM.js → version-DM_Roffb.js} +1 -1
  156. package/dist/{view-B64o81wp.d.ts → view-BEf0diC5.d.ts} +8 -3
  157. package/dist/{mfa-byfjruR9.js → view-Bq64ki8-.js} +95 -38
  158. package/dist/webhooks/index.d.ts +135 -4
  159. package/dist/webhooks/index.js +588 -2
  160. package/dist/wire-D2dT86vi.js +35 -0
  161. package/package.json +16 -8
  162. package/schemas/config-v1.json +131 -1
  163. package/schemas/doctor-report-v1.json +10 -0
  164. package/skills/better-supabase/SKILL.md +9 -6
  165. package/skills/better-supabase/references/plugins.md +6 -3
  166. package/skills/better-supabase/references/troubleshooting.md +1 -0
  167. package/skills/better-supabase-api/SKILL.md +9 -2
  168. package/skills/better-supabase-api/references/adapters.md +11 -8
  169. package/skills/better-supabase-auth/SKILL.md +3 -0
  170. package/skills/better-supabase-auth/references/permdock.md +56 -11
  171. package/dist/cli/gen-D5cLCULx.js +0 -2
  172. package/dist/cli/sql-D4VbqmXi.js +0 -199
  173. package/dist/events-B21UOhvP.d.ts +0 -84
  174. package/dist/executor-B5IoUxLE.d.ts +0 -195
  175. package/dist/invalidate-CgxmRamI.js +0 -24
  176. package/dist/permdock-sql-DImJULY-.d.ts +0 -17
  177. package/dist/plugin-BqR6wKMB.js +0 -23
  178. package/dist/respond-DgIbdrt8.d.ts +0 -194
  179. package/dist/view-BrJI2LKp.js +0 -45
@@ -1,19 +1,168 @@
1
- import { c as mapDbError, n as dbError, t as DbException } from "../errors-L0xK41y_.js";
2
- import { n as nowInstant } from "../temporal-BwshQk0W.js";
3
- import { t as temporal } from "../temporal-required-CBNHT6ni.js";
4
- import { i as ok, n as err, o as toDbError, t as AsyncResult } from "../result-Bj6yt33D.js";
5
- import { t as validate } from "../standard-BRyBGTtp.js";
6
- import { a as problemResponse } from "../problem-BihjPR0t.js";
7
- import { t as fromPgError } from "../executor-CVt6dD0B.js";
8
- import { c as verifyWebhook } from "../webhooks-Bh_Jf_-l.js";
9
- //#region src/jobs/jobs.ts
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
- const raw = fromPgError(cause);
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
- function sqlTransport(sql) {
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 schedule(name, cron, queue, payload) {
85
- await sql.queryRaw("select better_supabase.schedule_job($1, $2, $3, $4)", [
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 → Queues → "Expose Queues via
103
- * PostgREST"). No lease check on complete, no retry delay and no
104
- * deduplication: a failed job simply reappears when its lease ends.
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 postgrestTransport(client) {
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
- name: "postgrest",
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
- return (await call("read", {
725
+ const rows = await call("read", {
135
726
  queue_name: queue,
136
727
  sleep_seconds: lease,
137
728
  n: batch
138
- })).map((row) => ({
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
- /** pgmq and the inbox return `timestamptz` as text over RPC and as `Date` from `pg`. */
158
- const toInstant = (value) => value instanceof Date ? temporal().Instant.fromEpochMilliseconds(value.getTime()) : temporal().Instant.from(value);
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: message.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, or a service-role Supabase client to go through the
186
- * `pgmq_public` RPCs where no direct connection exists (edge functions).
187
- * Queue names are lowercase letters, digits and underscores.
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 = isSqlClient(source) ? sqlTransport(source) : postgrestTransport(source);
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.name === "sql" ? setInterval(() => {
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
- schedule(name, cron, queue, payload) {
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
- return run(() => transport.schedule(name, cron, queue, valid.data));
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
- drain: (queue, handler, drainOptions = {}) => loop(queue, handler, drainOptions, false),
296
- work: (queue, handler, workOptions = {}) => loop(queue, handler, workOptions, true)
297
- };
298
- }
299
- async function sha256(text) {
300
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
301
- return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
302
- }
303
- /** HTTP idempotency keys (IETF `Idempotency-Key` header) over the `idempotency` SQL kit module. */
304
- function createIdempotency(sql, options = {}) {
305
- const headerName = options.header ?? "idempotency-key";
306
- const ttl = seconds(options.ttl ?? "24 hours");
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
- case "mismatch": return problemResponse(dbError("validation", "This idempotency key was used for a different request", {
364
- code: "IDEMPOTENCY_KEY_REUSED",
365
- issues: [{
366
- message: "Use a new key for a new request",
367
- path: [headerName]
368
- }]
369
- }), { instance });
370
- case "started": break;
371
- default: throw new TypeError(`Unknown idempotency state ${String(state)}`);
372
- }
373
- let response;
374
- try {
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
- const outcome = await handler(message);
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
- await sql.queryRaw("select better_supabase.fail_webhook($1, $2, $3)", [
469
- message.id,
470
- worker,
471
- errorText(cause)
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 };