better-supabase 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +88 -1
- package/bin/better-supabase.js +3 -0
- package/dist/casing-da0uRTqt.js +14 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +15 -0
- package/dist/cli/index.d.ts +226 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli-CoByWYja.js +6472 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +112 -0
- package/dist/compiler-DL18ukPe.d.ts +14 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.js +3 -0
- package/dist/config-D-aR2ZoN.js +276 -0
- package/dist/define-CTfWw-SS.d.ts +662 -0
- package/dist/define-QGkq04oe.js +1068 -0
- package/dist/edge/index.d.ts +43 -0
- package/dist/edge/index.js +103 -0
- package/dist/entitlements-BBg61DQZ.d.ts +20 -0
- package/dist/entitlements-BC_khwRy.js +17 -0
- package/dist/env/index.d.ts +2 -0
- package/dist/env/index.js +2 -0
- package/dist/env-BEToab0I.js +204 -0
- package/dist/errors-Bl3rkOzo.js +196 -0
- package/dist/events/index.d.ts +2 -0
- package/dist/events/index.js +2 -0
- package/dist/events-2OJsVG_d.d.ts +84 -0
- package/dist/events-BuzTgn2D.js +135 -0
- package/dist/executor-Bazhgsgq.d.ts +195 -0
- package/dist/executor-Bje4suQF.d.ts +25 -0
- package/dist/executor-Ceq60hPM.js +89 -0
- package/dist/hono/index.d.ts +45 -0
- package/dist/hono/index.js +70 -0
- package/dist/index-BD6wKthp.d.ts +597 -0
- package/dist/index-DQVvHzjW.d.ts +74 -0
- package/dist/index-DRid0qIv.d.ts +76 -0
- package/dist/index-Dup-nSe9.d.ts +117 -0
- package/dist/index-Dy6kuF0P.d.ts +71 -0
- package/dist/index-Hf8PP9sP.d.ts +116 -0
- package/dist/index-JXHBuS53.d.ts +144 -0
- package/dist/index-Nxo3TJcm.d.ts +208 -0
- package/dist/index-Sw43J8HG.d.ts +62 -0
- package/dist/index-cEeweTeM.d.ts +38 -0
- package/dist/index.d.ts +110 -0
- package/dist/index.js +42 -0
- package/dist/invalidate-DbxuL2jL.js +18 -0
- package/dist/jobs/index.d.ts +173 -0
- package/dist/jobs/index.js +477 -0
- package/dist/json-schema-BbwO2mZg.js +181 -0
- package/dist/lint/index.d.ts +75 -0
- package/dist/lint/index.js +167 -0
- package/dist/list/index.d.ts +2 -0
- package/dist/list/index.js +410 -0
- package/dist/live-6b9i72Ux.d.ts +180 -0
- package/dist/live-D8rV1Ldw.js +168 -0
- package/dist/mcp/index.d.ts +105 -0
- package/dist/mcp/index.js +330 -0
- package/dist/mfa-CpogP66z.js +521 -0
- package/dist/mfa-TVia554c.d.ts +44 -0
- package/dist/next/image/index.d.ts +34 -0
- package/dist/next/image/index.js +40 -0
- package/dist/next/index.d.ts +210 -0
- package/dist/next/index.js +426 -0
- package/dist/openapi/index.d.ts +2 -0
- package/dist/openapi/index.js +332 -0
- package/dist/orpc/index.d.ts +46 -0
- package/dist/orpc/index.js +50 -0
- package/dist/otel/index.d.ts +40 -0
- package/dist/otel/index.js +168 -0
- package/dist/path-Cv__-OTj.d.ts +17 -0
- package/dist/plugin-BqR6wKMB.js +23 -0
- package/dist/plugin-HvGqCurB.d.ts +118 -0
- package/dist/plugins/actor/index.d.ts +13 -0
- package/dist/plugins/actor/index.js +43 -0
- package/dist/plugins/rules/index.d.ts +88 -0
- package/dist/plugins/rules/index.js +174 -0
- package/dist/plugins/soft-delete/index.d.ts +38 -0
- package/dist/plugins/soft-delete/index.js +69 -0
- package/dist/plugins/tenant/index.d.ts +43 -0
- package/dist/plugins/tenant/index.js +73 -0
- package/dist/plugins/timestamps/index.d.ts +9 -0
- package/dist/plugins/timestamps/index.js +37 -0
- package/dist/plugins/validation/index.d.ts +24 -0
- package/dist/plugins/validation/index.js +59 -0
- package/dist/pool-BFeqzwQS.d.ts +37 -0
- package/dist/postgres/index.d.ts +28 -0
- package/dist/postgres/index.js +79 -0
- package/dist/postgrest-Dm-vhY6R.d.ts +43 -0
- package/dist/problem-DPu6Fh-t.js +97 -0
- package/dist/problem-DlgB0lYl.d.ts +47 -0
- package/dist/query/index.d.ts +2 -0
- package/dist/query/index.js +3 -0
- package/dist/query-ChvDbsDE.js +186 -0
- package/dist/react/index.d.ts +7 -0
- package/dist/react/index.js +5 -0
- package/dist/react/server.d.ts +13 -0
- package/dist/react/server.js +25 -0
- package/dist/react/session.d.ts +2 -0
- package/dist/react/session.js +3 -0
- package/dist/react-CrOvrnIP.js +241 -0
- package/dist/read-set-DhX2d04c.js +216 -0
- package/dist/realtime/index.d.ts +3 -0
- package/dist/realtime/index.js +253 -0
- package/dist/resolve-DW8h9NPm.d.ts +155 -0
- package/dist/resource-CYiu6Ygi.js +175 -0
- package/dist/resource-SkPTRJDD.d.ts +65 -0
- package/dist/respond-4-dhoV7x.js +380 -0
- package/dist/respond-PoTpy5DT.d.ts +169 -0
- package/dist/result-CTkCZ6JA.d.ts +198 -0
- package/dist/result-DWXatkd6.js +103 -0
- package/dist/scope-CDHcS7dE.js +90 -0
- package/dist/seed-CzecODcJ.js +101 -0
- package/dist/server/index.d.ts +42 -0
- package/dist/server/index.js +68 -0
- package/dist/session-C3osL21k.d.ts +21 -0
- package/dist/session-CD-3orO_.js +19 -0
- package/dist/shared-_6-uPL7E.js +57 -0
- package/dist/simplify-oHiDKRVQ.js +665 -0
- package/dist/spec-pins-CJPVghHy.d.ts +20 -0
- package/dist/spec-pins-t6sPaHvl.js +20 -0
- package/dist/sql-M8l3fnS0.js +260 -0
- package/dist/ssr/index.d.ts +5 -0
- package/dist/ssr/index.js +4 -0
- package/dist/standard-CyhRzctb.d.ts +7 -0
- package/dist/standard-FUi3Z3-7.js +21 -0
- package/dist/stats-3iy8u3_P.js +566 -0
- package/dist/storage/index.d.ts +4 -0
- package/dist/storage/index.js +2 -0
- package/dist/storage-BWXVUXGk.js +461 -0
- package/dist/tables-rZxRxNPW.js +59 -0
- package/dist/template-BeOGnynM.js +93 -0
- package/dist/template-mbvlysOo.d.ts +6 -0
- package/dist/testing/index.d.ts +294 -0
- package/dist/testing/index.js +751 -0
- package/dist/types-6cYK9pbJ.js +29 -0
- package/dist/types-C2fslP1z.d.ts +195 -0
- package/dist/version-Cqq1m3UY.js +4 -0
- package/dist/view-B0ltUmTi.d.ts +43 -0
- package/dist/view-B6F9QbG-.js +35 -0
- package/dist/webhooks/index.d.ts +152 -0
- package/dist/webhooks/index.js +2 -0
- package/dist/webhooks-DbMTJJX2.js +139 -0
- package/package.json +246 -17
- package/schemas/config-v1.json +328 -0
- package/schemas/doctor-report-v1.json +105 -0
- package/schemas/snapshot-v2.json +1002 -0
- package/skills/better-supabase/SKILL.md +84 -0
- package/skills/better-supabase/references/plugins.md +56 -0
- package/skills/better-supabase/references/troubleshooting.md +39 -0
- package/skills/better-supabase-api/SKILL.md +67 -0
- package/skills/better-supabase-api/references/adapters.md +108 -0
- package/skills/better-supabase-testing/SKILL.md +65 -0
- package/index.js +0 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { o as Result, p as DbError, t as AsyncResult } from "../result-CTkCZ6JA.js";
|
|
2
|
+
import { t as SqlClient } from "../executor-Bje4suQF.js";
|
|
3
|
+
import { StandardSchemaV1 } from "@standard-schema/spec";
|
|
4
|
+
//#region src/jobs/index.d.ts
|
|
5
|
+
export type QueueSchemas = Readonly<Record<string, StandardSchemaV1>>;
|
|
6
|
+
type PayloadIn<Q extends QueueSchemas, N extends keyof Q> = StandardSchemaV1.InferInput<Q[N]>;
|
|
7
|
+
type PayloadOut<Q extends QueueSchemas, N extends keyof Q> = StandardSchemaV1.InferOutput<Q[N]>;
|
|
8
|
+
export interface Job<P = unknown> {
|
|
9
|
+
/** The pgmq message id. */
|
|
10
|
+
readonly id: number;
|
|
11
|
+
readonly queue: string;
|
|
12
|
+
readonly payload: P;
|
|
13
|
+
/** 1 on the first run (pgmq's `read_ct`). */
|
|
14
|
+
readonly attempts: number;
|
|
15
|
+
readonly maxAttempts: number;
|
|
16
|
+
readonly enqueuedAt: Date;
|
|
17
|
+
/** End of the lease: the job becomes claimable again after this. */
|
|
18
|
+
readonly visibleUntil: Date;
|
|
19
|
+
readonly lastError: string | null;
|
|
20
|
+
}
|
|
21
|
+
export interface EnqueueOptions {
|
|
22
|
+
/** Run no earlier than this. */
|
|
23
|
+
readonly runAt?: Date;
|
|
24
|
+
/** Seconds to wait before running. */
|
|
25
|
+
readonly delay?: number;
|
|
26
|
+
/** Defaults to 5. After this many failed attempts the job is archived as dead. */
|
|
27
|
+
readonly maxAttempts?: number;
|
|
28
|
+
/**
|
|
29
|
+
* While a job with this key is waiting or running, enqueueing again returns
|
|
30
|
+
* its id. SQL connections only.
|
|
31
|
+
*/
|
|
32
|
+
readonly dedupeKey?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ClaimOptions {
|
|
35
|
+
/** Defaults to 1. */
|
|
36
|
+
readonly batch?: number;
|
|
37
|
+
/** Seconds a claimed job stays invisible to other workers. Defaults to 300. */
|
|
38
|
+
readonly lease?: number;
|
|
39
|
+
}
|
|
40
|
+
export interface FailOptions {
|
|
41
|
+
/** Retry after this many seconds instead of the exponential backoff. SQL connections only. */
|
|
42
|
+
readonly retryIn?: number;
|
|
43
|
+
}
|
|
44
|
+
export type JobHandler<P> = (payload: P, job: Job<P>, signal: AbortSignal) => unknown;
|
|
45
|
+
export interface WorkOptions extends ClaimOptions {
|
|
46
|
+
/** Jobs handled at once. Defaults to 1. */
|
|
47
|
+
readonly concurrency?: number;
|
|
48
|
+
/** Wait between polls when the queue is empty, in ms. Defaults to 1000. */
|
|
49
|
+
readonly pollInterval?: number;
|
|
50
|
+
/** Stops the loop; running jobs finish first. */
|
|
51
|
+
readonly signal?: AbortSignal;
|
|
52
|
+
readonly onError?: (error: DbError, job: Job) => void;
|
|
53
|
+
}
|
|
54
|
+
export interface DrainResult {
|
|
55
|
+
readonly succeeded: number;
|
|
56
|
+
readonly failed: number;
|
|
57
|
+
}
|
|
58
|
+
export interface Jobs<Q extends QueueSchemas> {
|
|
59
|
+
/** Validates the payload with the queue's schema, then enqueues it. Returns the job id. */
|
|
60
|
+
enqueue<N extends Extract<keyof Q, string>>(queue: N, payload: PayloadIn<Q, N>, options?: EnqueueOptions): AsyncResult<number>;
|
|
61
|
+
claim<N extends Extract<keyof Q, string>>(queue: N, options?: ClaimOptions): AsyncResult<Job<PayloadOut<Q, N>>[]>;
|
|
62
|
+
/** Archives the job. `false` when the lease was lost (another worker claimed it since). */
|
|
63
|
+
complete(job: Job): AsyncResult<boolean>;
|
|
64
|
+
/** Returns the new state: `queued` (will retry), `dead` (archived), or `null` when the lease was lost. */
|
|
65
|
+
fail(job: Job, error: unknown, options?: FailOptions): AsyncResult<"queued" | "dead" | null>;
|
|
66
|
+
/** Pushes the lease out by `lease` seconds. `false` when it was lost or the transport can't. */
|
|
67
|
+
extend(job: Job, lease: number): AsyncResult<boolean>;
|
|
68
|
+
/**
|
|
69
|
+
* Enqueues `payload` on a cron schedule with pg_cron (`'0 3 * * *'`,
|
|
70
|
+
* `'30 seconds'`). Re-scheduling a name replaces it. SQL connections only.
|
|
71
|
+
*/
|
|
72
|
+
schedule<N extends Extract<keyof Q, string>>(name: string, cron: string, queue: N, payload: PayloadIn<Q, N>): AsyncResult<void>;
|
|
73
|
+
unschedule(name: string): AsyncResult<boolean>;
|
|
74
|
+
/** Processes ready jobs until the queue is empty. For cron and edge invocations. */
|
|
75
|
+
drain<N extends Extract<keyof Q, string>>(queue: N, handler: JobHandler<PayloadOut<Q, N>>, options?: Omit<WorkOptions, "pollInterval" | "signal">): Promise<DrainResult>;
|
|
76
|
+
/** Polls and processes jobs until `signal` aborts. */
|
|
77
|
+
work<N extends Extract<keyof Q, string>>(queue: N, handler: JobHandler<PayloadOut<Q, N>>, options?: WorkOptions): Promise<DrainResult>;
|
|
78
|
+
}
|
|
79
|
+
/** A Supabase client, or anything with `.schema(name).rpc(fn, args)`. */
|
|
80
|
+
export interface QueueRpcClient {
|
|
81
|
+
schema(name: string): {
|
|
82
|
+
rpc(fn: string, args: Readonly<Record<string, unknown>>): PromiseLike<{
|
|
83
|
+
data: unknown;
|
|
84
|
+
error: unknown;
|
|
85
|
+
}>;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Typed jobs on Supabase Queues (pgmq), over the `jobs` SQL kit module.
|
|
90
|
+
*
|
|
91
|
+
* Pass a service SQL connection (`postgres.admin`, `ctx.sql`) for the full
|
|
92
|
+
* feature set, or a service-role Supabase client to go through the
|
|
93
|
+
* `pgmq_public` RPCs where no direct connection exists (edge functions).
|
|
94
|
+
* Queue names are lowercase letters, digits and underscores.
|
|
95
|
+
*/
|
|
96
|
+
export declare function createJobs<const Q extends QueueSchemas>(source: SqlClient | QueueRpcClient, queues: Q): Jobs<Q>;
|
|
97
|
+
export interface IdempotencyOptions {
|
|
98
|
+
/** Separates keys of different endpoints or tenants. Defaults to `''`. */
|
|
99
|
+
readonly scope?: string | ((request: Request) => string);
|
|
100
|
+
/** How long a completed response is replayed. Defaults to `24 hours`. */
|
|
101
|
+
readonly ttl?: number | string;
|
|
102
|
+
/** How long a running request holds the key. Defaults to `1 minute`. */
|
|
103
|
+
readonly lock?: number | string;
|
|
104
|
+
/** Answer 400 when the header is missing. Defaults to false (run without a key). */
|
|
105
|
+
readonly required?: boolean;
|
|
106
|
+
/** Defaults to `Idempotency-Key`. */
|
|
107
|
+
readonly header?: string;
|
|
108
|
+
}
|
|
109
|
+
export type IdempotencyState = "started" | "replay" | "running" | "mismatch";
|
|
110
|
+
export interface Idempotency {
|
|
111
|
+
/**
|
|
112
|
+
* Runs `handler` at most once per key: retries with the same key and body
|
|
113
|
+
* get the stored response (with `Idempotency-Replayed: true`).
|
|
114
|
+
*/
|
|
115
|
+
handle(request: Request, handler: (request: Request) => Promise<Response> | Response): Promise<Response>;
|
|
116
|
+
/** The lower-level protocol, for non-HTTP callers. */
|
|
117
|
+
begin(key: string, fingerprint: string, scope?: string): AsyncResult<{
|
|
118
|
+
state: IdempotencyState;
|
|
119
|
+
status: number | null;
|
|
120
|
+
body: unknown;
|
|
121
|
+
}>;
|
|
122
|
+
complete(key: string, status: number, body: unknown, scope?: string): AsyncResult<void>;
|
|
123
|
+
release(key: string, scope?: string): AsyncResult<void>;
|
|
124
|
+
}
|
|
125
|
+
/** HTTP idempotency keys (IETF `Idempotency-Key` header) over the `idempotency` SQL kit module. */
|
|
126
|
+
export declare function createIdempotency(sql: SqlClient, options?: IdempotencyOptions): Idempotency;
|
|
127
|
+
export interface InboxMessage<T = unknown> {
|
|
128
|
+
readonly id: number;
|
|
129
|
+
readonly source: string;
|
|
130
|
+
readonly messageId: string;
|
|
131
|
+
readonly type: string | null;
|
|
132
|
+
readonly payload: T;
|
|
133
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
134
|
+
readonly attempts: number;
|
|
135
|
+
readonly receivedAt: Date;
|
|
136
|
+
}
|
|
137
|
+
export interface InboxOptions {
|
|
138
|
+
/** Name of the sender, e.g. `stripe` or `supabase-auth`. */
|
|
139
|
+
readonly source: string;
|
|
140
|
+
/** Standard Webhooks secrets; the signature is verified before storing. */
|
|
141
|
+
readonly secrets?: string | readonly string[];
|
|
142
|
+
/** Custom verification for senders that don't use Standard Webhooks. */
|
|
143
|
+
readonly verify?: (request: Request, body: string) => Promise<Result<{
|
|
144
|
+
id: string;
|
|
145
|
+
payload: unknown;
|
|
146
|
+
}>>;
|
|
147
|
+
/** Event type from the payload. Defaults to `payload.type`. */
|
|
148
|
+
readonly typeOf?: (payload: unknown) => string | null;
|
|
149
|
+
/** Headers kept with the message. Defaults to none. */
|
|
150
|
+
readonly keepHeaders?: readonly string[];
|
|
151
|
+
readonly worker?: string;
|
|
152
|
+
}
|
|
153
|
+
export interface Inbox {
|
|
154
|
+
/** Verifies and stores a webhook; answers 202, or 200 for a duplicate delivery. */
|
|
155
|
+
receive(request: Request): Promise<Response>;
|
|
156
|
+
/** Processes stored messages until none are ready. */
|
|
157
|
+
process<T = unknown>(handler: (message: InboxMessage<T>) => unknown, options?: {
|
|
158
|
+
readonly batch?: number;
|
|
159
|
+
readonly lease?: number | string;
|
|
160
|
+
}): Promise<DrainResult>;
|
|
161
|
+
}
|
|
162
|
+
/** Store-then-process webhooks: acknowledge fast, process with retries, never twice. */
|
|
163
|
+
export declare function createInbox(sql: SqlClient, options: InboxOptions): Inbox;
|
|
164
|
+
/** The Stripe event sent when a customer's active entitlements change. */
|
|
165
|
+
export declare const ENTITLEMENTS_UPDATED = "entitlements.active_entitlement_summary.updated";
|
|
166
|
+
/**
|
|
167
|
+
* Users whose `memberships` claim carries the entitlements of the Stripe
|
|
168
|
+
* customer in an `entitlements.active_entitlement_summary.updated` event, so
|
|
169
|
+
* a job can invalidate their sessions. Empty for other payloads.
|
|
170
|
+
*/
|
|
171
|
+
export declare function entitlementMembers(sql: SqlClient, payload: unknown): AsyncResult<readonly string[]>;
|
|
172
|
+
//#endregion
|
|
173
|
+
export type { SqlClient };
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import { c as mapDbError, n as dbError, t as DbException } from "../errors-Bl3rkOzo.js";
|
|
2
|
+
import { i as ok, n as err, o as toDbError, t as AsyncResult } from "../result-DWXatkd6.js";
|
|
3
|
+
import { t as validate } from "../standard-FUi3Z3-7.js";
|
|
4
|
+
import { a as problemResponse } from "../problem-DPu6Fh-t.js";
|
|
5
|
+
import { t as fromPgError } from "../executor-Ceq60hPM.js";
|
|
6
|
+
import { c as verifyWebhook } from "../webhooks-DbMTJJX2.js";
|
|
7
|
+
//#region src/jobs/index.ts
|
|
8
|
+
function run(fn) {
|
|
9
|
+
return AsyncResult.from(async () => {
|
|
10
|
+
try {
|
|
11
|
+
return ok(await fn());
|
|
12
|
+
} catch (cause) {
|
|
13
|
+
const raw = fromPgError(cause);
|
|
14
|
+
return err(raw ? mapDbError(raw) : toDbError(cause));
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
function seconds(value) {
|
|
19
|
+
return typeof value === "number" ? `${String(value)} seconds` : value;
|
|
20
|
+
}
|
|
21
|
+
function workerId() {
|
|
22
|
+
return `worker-${crypto.randomUUID().slice(0, 8)}`;
|
|
23
|
+
}
|
|
24
|
+
const sleep = (ms, signal) => new Promise((resolve) => {
|
|
25
|
+
if (signal?.aborted) return resolve();
|
|
26
|
+
const timer = setTimeout(done, ms);
|
|
27
|
+
function done() {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
signal?.removeEventListener("abort", done);
|
|
30
|
+
resolve();
|
|
31
|
+
}
|
|
32
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
33
|
+
});
|
|
34
|
+
function sqlTransport(sql) {
|
|
35
|
+
return {
|
|
36
|
+
name: "sql",
|
|
37
|
+
async send(queue, payload, delay, maxAttempts, dedupeKey) {
|
|
38
|
+
const [row] = await sql.queryRaw("select better_supabase.enqueue_job($1, $2, $3, $4, $5) as id", [
|
|
39
|
+
queue,
|
|
40
|
+
JSON.stringify(payload ?? {}),
|
|
41
|
+
delay,
|
|
42
|
+
maxAttempts,
|
|
43
|
+
dedupeKey ?? null
|
|
44
|
+
]);
|
|
45
|
+
return Number(row.id);
|
|
46
|
+
},
|
|
47
|
+
read: (queue, lease, batch) => sql.queryRaw("select * from better_supabase.claim_jobs($1, $2, $3)", [
|
|
48
|
+
queue,
|
|
49
|
+
lease,
|
|
50
|
+
batch
|
|
51
|
+
]),
|
|
52
|
+
async complete(job) {
|
|
53
|
+
const [row] = await sql.queryRaw("select better_supabase.complete_job($1, $2, $3) as done", [
|
|
54
|
+
job.queue,
|
|
55
|
+
job.id,
|
|
56
|
+
job.attempts
|
|
57
|
+
]);
|
|
58
|
+
return row?.done ?? false;
|
|
59
|
+
},
|
|
60
|
+
async fail(job, error, retryIn) {
|
|
61
|
+
const [row] = await sql.queryRaw("select better_supabase.fail_job($1, $2, $3, $4, $5) as status", [
|
|
62
|
+
job.queue,
|
|
63
|
+
job.id,
|
|
64
|
+
job.attempts,
|
|
65
|
+
error,
|
|
66
|
+
retryIn ?? null
|
|
67
|
+
]);
|
|
68
|
+
return row?.status ?? null;
|
|
69
|
+
},
|
|
70
|
+
async extend(job, lease) {
|
|
71
|
+
const [row] = await sql.queryRaw("select better_supabase.extend_job_lease($1, $2, $3, $4) as extended", [
|
|
72
|
+
job.queue,
|
|
73
|
+
job.id,
|
|
74
|
+
job.attempts,
|
|
75
|
+
lease
|
|
76
|
+
]);
|
|
77
|
+
return row?.extended ?? false;
|
|
78
|
+
},
|
|
79
|
+
async schedule(name, cron, queue, payload) {
|
|
80
|
+
await sql.queryRaw("select better_supabase.schedule_job($1, $2, $3, $4)", [
|
|
81
|
+
name,
|
|
82
|
+
cron,
|
|
83
|
+
queue,
|
|
84
|
+
JSON.stringify(payload ?? {})
|
|
85
|
+
]);
|
|
86
|
+
},
|
|
87
|
+
async unschedule(name) {
|
|
88
|
+
const [row] = await sql.queryRaw("select better_supabase.unschedule_job($1) as done", [name]);
|
|
89
|
+
return row?.done ?? false;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function sqlOnly(feature) {
|
|
94
|
+
throw new DbException(dbError("invalid_request", `${feature} needs a SQL connection; pgmq_public (PostgREST) does not support it`));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Supabase's `pgmq_public` RPCs (Integrations → Queues → "Expose Queues via
|
|
98
|
+
* PostgREST"). No lease check on complete, no retry delay and no
|
|
99
|
+
* deduplication: a failed job simply reappears when its lease ends.
|
|
100
|
+
*/
|
|
101
|
+
function postgrestTransport(client) {
|
|
102
|
+
const call = async (fn, args) => {
|
|
103
|
+
const { data, error } = await client.schema("pgmq_public").rpc(fn, args);
|
|
104
|
+
if (error) {
|
|
105
|
+
const message = typeof error === "object" && error !== null && "message" in error ? String(error.message) : String(error);
|
|
106
|
+
throw new Error(`pgmq_public.${fn}: ${message}`, { cause: error });
|
|
107
|
+
}
|
|
108
|
+
return data;
|
|
109
|
+
};
|
|
110
|
+
const archive = (job) => call("archive", {
|
|
111
|
+
queue_name: job.queue,
|
|
112
|
+
message_id: job.id
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
name: "postgrest",
|
|
116
|
+
async send(queue, payload, delay, maxAttempts, dedupeKey) {
|
|
117
|
+
if (dedupeKey !== void 0) sqlOnly("dedupeKey");
|
|
118
|
+
const ids = await call("send", {
|
|
119
|
+
queue_name: queue,
|
|
120
|
+
message: {
|
|
121
|
+
payload: payload ?? {},
|
|
122
|
+
max_attempts: maxAttempts
|
|
123
|
+
},
|
|
124
|
+
sleep_seconds: delay
|
|
125
|
+
});
|
|
126
|
+
return Number(ids[0]);
|
|
127
|
+
},
|
|
128
|
+
async read(queue, lease, batch) {
|
|
129
|
+
return (await call("read", {
|
|
130
|
+
queue_name: queue,
|
|
131
|
+
sleep_seconds: lease,
|
|
132
|
+
n: batch
|
|
133
|
+
})).map((row) => ({
|
|
134
|
+
id: row.msg_id,
|
|
135
|
+
attempts: row.read_ct,
|
|
136
|
+
enqueued_at: row.enqueued_at,
|
|
137
|
+
visible_until: row.vt,
|
|
138
|
+
message: row.message
|
|
139
|
+
}));
|
|
140
|
+
},
|
|
141
|
+
complete: archive,
|
|
142
|
+
async fail(job) {
|
|
143
|
+
if (job.attempts < job.maxAttempts) return "queued";
|
|
144
|
+
await archive(job);
|
|
145
|
+
return "dead";
|
|
146
|
+
},
|
|
147
|
+
extend: () => Promise.resolve(false),
|
|
148
|
+
schedule: () => sqlOnly("schedule"),
|
|
149
|
+
unschedule: () => sqlOnly("unschedule")
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const toDate = (value) => value instanceof Date ? value : new Date(value);
|
|
153
|
+
function toJob(queue, row) {
|
|
154
|
+
const message = row.message ?? {};
|
|
155
|
+
return {
|
|
156
|
+
id: Number(row.id),
|
|
157
|
+
queue,
|
|
158
|
+
payload: message.payload,
|
|
159
|
+
attempts: row.attempts,
|
|
160
|
+
maxAttempts: message.max_attempts ?? 5,
|
|
161
|
+
enqueuedAt: toDate(row.enqueued_at),
|
|
162
|
+
visibleUntil: toDate(row.visible_until),
|
|
163
|
+
lastError: message.last_error ?? null
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function errorText(error) {
|
|
167
|
+
if (error instanceof Error) return error.message;
|
|
168
|
+
if (typeof error === "object" && error !== null && "message" in error) return String(error.message);
|
|
169
|
+
return String(error);
|
|
170
|
+
}
|
|
171
|
+
const QUEUE_NAME = /^[a-z_][a-z0-9_]{0,46}$/;
|
|
172
|
+
function isSqlClient(source) {
|
|
173
|
+
return typeof source.queryRaw === "function";
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Typed jobs on Supabase Queues (pgmq), over the `jobs` SQL kit module.
|
|
177
|
+
*
|
|
178
|
+
* Pass a service SQL connection (`postgres.admin`, `ctx.sql`) for the full
|
|
179
|
+
* feature set, or a service-role Supabase client to go through the
|
|
180
|
+
* `pgmq_public` RPCs where no direct connection exists (edge functions).
|
|
181
|
+
* Queue names are lowercase letters, digits and underscores.
|
|
182
|
+
*/
|
|
183
|
+
function createJobs(source, queues) {
|
|
184
|
+
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)`);
|
|
185
|
+
const transport = isSqlClient(source) ? sqlTransport(source) : postgrestTransport(source);
|
|
186
|
+
const schemaOf = (queue) => {
|
|
187
|
+
const schema = queues[queue];
|
|
188
|
+
if (!schema) throw new TypeError(`Unknown queue "${queue}". Queues: ${Object.keys(queues).join(", ")}`);
|
|
189
|
+
return schema;
|
|
190
|
+
};
|
|
191
|
+
const complete = (job) => run(() => transport.complete(job));
|
|
192
|
+
const fail = (job, error, failOptions = {}) => run(() => transport.fail(job, errorText(error), failOptions.retryIn));
|
|
193
|
+
const claim = (queue, claimOptions = {}) => run(async () => {
|
|
194
|
+
schemaOf(queue);
|
|
195
|
+
return (await transport.read(queue, claimOptions.lease ?? 300, claimOptions.batch ?? 1)).map((row) => toJob(queue, row));
|
|
196
|
+
});
|
|
197
|
+
const handle = async (job, handler, workOptions) => {
|
|
198
|
+
const controller = new AbortController();
|
|
199
|
+
const lease = workOptions.lease ?? 300;
|
|
200
|
+
const heartbeat = transport.name === "sql" ? setInterval(() => {
|
|
201
|
+
run(() => transport.extend(job, lease));
|
|
202
|
+
}, Math.max(1e3, lease * 1e3 / 2)) : void 0;
|
|
203
|
+
try {
|
|
204
|
+
const payload = await validate(schemaOf(job.queue), job.payload, "payload");
|
|
205
|
+
if (!payload.ok) {
|
|
206
|
+
await fail(job, payload.error.message);
|
|
207
|
+
workOptions.onError?.(payload.error, job);
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
const outcome = await handler(payload.data, {
|
|
211
|
+
...job,
|
|
212
|
+
payload: payload.data
|
|
213
|
+
}, controller.signal);
|
|
214
|
+
if (typeof outcome === "object" && outcome !== null && "ok" in outcome && outcome.ok === false && "error" in outcome) throw outcome.error;
|
|
215
|
+
await complete(job);
|
|
216
|
+
return true;
|
|
217
|
+
} catch (cause) {
|
|
218
|
+
controller.abort();
|
|
219
|
+
await fail(job, cause);
|
|
220
|
+
workOptions.onError?.(typeof cause === "object" && cause !== null && "kind" in cause ? cause : toDbError(cause), job);
|
|
221
|
+
return false;
|
|
222
|
+
} finally {
|
|
223
|
+
clearInterval(heartbeat);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const loop = async (queue, handler, workOptions, forever) => {
|
|
227
|
+
const concurrency = Math.max(1, workOptions.concurrency ?? 1);
|
|
228
|
+
let succeeded = 0;
|
|
229
|
+
let failed = 0;
|
|
230
|
+
const lanes = Array.from({ length: concurrency }, async () => {
|
|
231
|
+
while (!workOptions.signal?.aborted) {
|
|
232
|
+
const claimed = await claim(queue, {
|
|
233
|
+
...workOptions,
|
|
234
|
+
batch: workOptions.batch ?? 1
|
|
235
|
+
});
|
|
236
|
+
if (!claimed.ok) throw new TypeError(claimed.error.message, { cause: claimed.error });
|
|
237
|
+
if (claimed.data.length === 0) {
|
|
238
|
+
if (!forever) return;
|
|
239
|
+
await sleep(workOptions.pollInterval ?? 1e3, workOptions.signal);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
for (const job of claimed.data) if (await handle(job, handler, workOptions)) succeeded += 1;
|
|
243
|
+
else failed += 1;
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
await Promise.all(lanes);
|
|
247
|
+
return {
|
|
248
|
+
succeeded,
|
|
249
|
+
failed
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
return {
|
|
253
|
+
enqueue(queue, payload, enqueueOptions = {}) {
|
|
254
|
+
return AsyncResult.from(async () => {
|
|
255
|
+
const valid = await validate(schemaOf(queue), payload, "payload");
|
|
256
|
+
if (!valid.ok) return valid;
|
|
257
|
+
const delay = enqueueOptions.delay ?? (enqueueOptions.runAt ? Math.max(0, Math.ceil((enqueueOptions.runAt.getTime() - Date.now()) / 1e3)) : 0);
|
|
258
|
+
return run(() => transport.send(queue, valid.data, delay, enqueueOptions.maxAttempts ?? 5, enqueueOptions.dedupeKey));
|
|
259
|
+
});
|
|
260
|
+
},
|
|
261
|
+
claim,
|
|
262
|
+
complete,
|
|
263
|
+
fail,
|
|
264
|
+
extend: (job, lease) => run(() => transport.extend(job, lease)),
|
|
265
|
+
schedule(name, cron, queue, payload) {
|
|
266
|
+
return AsyncResult.from(async () => {
|
|
267
|
+
const valid = await validate(schemaOf(queue), payload, "payload");
|
|
268
|
+
if (!valid.ok) return valid;
|
|
269
|
+
return run(() => transport.schedule(name, cron, queue, valid.data));
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
unschedule: (name) => run(() => transport.unschedule(name)),
|
|
273
|
+
drain: (queue, handler, drainOptions = {}) => loop(queue, handler, drainOptions, false),
|
|
274
|
+
work: (queue, handler, workOptions = {}) => loop(queue, handler, workOptions, true)
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
async function sha256(text) {
|
|
278
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
279
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
280
|
+
}
|
|
281
|
+
/** HTTP idempotency keys (IETF `Idempotency-Key` header) over the `idempotency` SQL kit module. */
|
|
282
|
+
function createIdempotency(sql, options = {}) {
|
|
283
|
+
const headerName = options.header ?? "idempotency-key";
|
|
284
|
+
const ttl = seconds(options.ttl ?? "24 hours");
|
|
285
|
+
const lock = seconds(options.lock ?? "1 minute");
|
|
286
|
+
const begin = (key, fingerprint, scope = "") => run(async () => {
|
|
287
|
+
const [row] = await sql.queryRaw("select * from better_supabase.begin_idempotent($1, $2, $3, $4::interval, $5::interval)", [
|
|
288
|
+
scope,
|
|
289
|
+
key,
|
|
290
|
+
fingerprint,
|
|
291
|
+
ttl,
|
|
292
|
+
lock
|
|
293
|
+
]);
|
|
294
|
+
return {
|
|
295
|
+
state: row.state,
|
|
296
|
+
status: row.status_code,
|
|
297
|
+
body: row.response
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
const complete = (key, status, body, scope = "") => run(async () => {
|
|
301
|
+
await sql.queryRaw("select better_supabase.complete_idempotent($1, $2, $3, $4)", [
|
|
302
|
+
scope,
|
|
303
|
+
key,
|
|
304
|
+
status,
|
|
305
|
+
JSON.stringify(body)
|
|
306
|
+
]);
|
|
307
|
+
});
|
|
308
|
+
const release = (key, scope = "") => run(async () => {
|
|
309
|
+
await sql.queryRaw("select better_supabase.release_idempotent($1, $2)", [scope, key]);
|
|
310
|
+
});
|
|
311
|
+
return {
|
|
312
|
+
begin,
|
|
313
|
+
complete,
|
|
314
|
+
release,
|
|
315
|
+
async handle(request, handler) {
|
|
316
|
+
const key = request.headers.get(headerName);
|
|
317
|
+
const instance = new URL(request.url).pathname;
|
|
318
|
+
if (!key) return options.required ? problemResponse(dbError("invalid_request", `The ${headerName} header is required`, { code: "IDEMPOTENCY_KEY_MISSING" }), { instance }) : handler(request);
|
|
319
|
+
if (key.length > 255) return problemResponse(dbError("invalid_request", `The ${headerName} header is too long`, { code: "IDEMPOTENCY_KEY_INVALID" }), { instance });
|
|
320
|
+
const scope = typeof options.scope === "function" ? options.scope(request) : options.scope ?? "";
|
|
321
|
+
const body = await request.clone().text();
|
|
322
|
+
const fingerprint = await sha256(`${request.method} ${instance}\n${body}`);
|
|
323
|
+
const started = await begin(key, fingerprint, scope);
|
|
324
|
+
if (!started.ok) return problemResponse(started.error, { instance });
|
|
325
|
+
const { state } = started.data;
|
|
326
|
+
switch (state) {
|
|
327
|
+
case "replay": {
|
|
328
|
+
const stored = started.data.body;
|
|
329
|
+
return new Response(stored.body, {
|
|
330
|
+
status: started.data.status ?? 200,
|
|
331
|
+
headers: {
|
|
332
|
+
...stored.contentType ? { "content-type": stored.contentType } : {},
|
|
333
|
+
"idempotency-replayed": "true"
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
case "running": return problemResponse(dbError("conflict", "A request with this idempotency key is still running", { code: "IDEMPOTENCY_KEY_IN_USE" }), {
|
|
338
|
+
instance,
|
|
339
|
+
headers: { "retry-after": "1" }
|
|
340
|
+
});
|
|
341
|
+
case "mismatch": return problemResponse(dbError("validation", "This idempotency key was used for a different request", {
|
|
342
|
+
code: "IDEMPOTENCY_KEY_REUSED",
|
|
343
|
+
issues: [{
|
|
344
|
+
message: "Use a new key for a new request",
|
|
345
|
+
path: [headerName]
|
|
346
|
+
}]
|
|
347
|
+
}), { instance });
|
|
348
|
+
case "started": break;
|
|
349
|
+
default: throw new TypeError(`Unknown idempotency state ${String(state)}`);
|
|
350
|
+
}
|
|
351
|
+
let response;
|
|
352
|
+
try {
|
|
353
|
+
response = await handler(request);
|
|
354
|
+
} catch (cause) {
|
|
355
|
+
await release(key, scope);
|
|
356
|
+
throw cause;
|
|
357
|
+
}
|
|
358
|
+
if (response.status >= 500) {
|
|
359
|
+
await release(key, scope);
|
|
360
|
+
return response;
|
|
361
|
+
}
|
|
362
|
+
const stored = {
|
|
363
|
+
body: await response.clone().text(),
|
|
364
|
+
contentType: response.headers.get("content-type")
|
|
365
|
+
};
|
|
366
|
+
await complete(key, response.status, stored, scope);
|
|
367
|
+
return response;
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function defaultType(payload) {
|
|
372
|
+
if (typeof payload !== "object" || payload === null) return null;
|
|
373
|
+
const type = payload.type;
|
|
374
|
+
return typeof type === "string" ? type : null;
|
|
375
|
+
}
|
|
376
|
+
/** Store-then-process webhooks: acknowledge fast, process with retries, never twice. */
|
|
377
|
+
function createInbox(sql, options) {
|
|
378
|
+
const worker = options.worker ?? workerId();
|
|
379
|
+
if (!options.secrets && !options.verify) throw new TypeError("createInbox needs `secrets` (Standard Webhooks) or `verify`");
|
|
380
|
+
const verified = async (request) => {
|
|
381
|
+
if (options.verify) return options.verify(request, await request.clone().text());
|
|
382
|
+
const result = await verifyWebhook(request, options.secrets);
|
|
383
|
+
return result.ok ? ok({
|
|
384
|
+
id: result.data.id,
|
|
385
|
+
payload: result.data.payload
|
|
386
|
+
}) : result;
|
|
387
|
+
};
|
|
388
|
+
return {
|
|
389
|
+
async receive(request) {
|
|
390
|
+
const instance = new URL(request.url).pathname;
|
|
391
|
+
if (request.method !== "POST") return new Response(null, {
|
|
392
|
+
status: 405,
|
|
393
|
+
headers: { allow: "POST" }
|
|
394
|
+
});
|
|
395
|
+
const message = await verified(request);
|
|
396
|
+
if (!message.ok) return problemResponse(message.error, { instance });
|
|
397
|
+
const headers = Object.fromEntries((options.keepHeaders ?? []).flatMap((name) => {
|
|
398
|
+
const value = request.headers.get(name);
|
|
399
|
+
return value === null ? [] : [[name.toLowerCase(), value]];
|
|
400
|
+
}));
|
|
401
|
+
const stored = await run(() => sql.queryRaw("select * from better_supabase.receive_webhook($1, $2, $3, $4, $5)", [
|
|
402
|
+
options.source,
|
|
403
|
+
message.data.id,
|
|
404
|
+
(options.typeOf ?? defaultType)(message.data.payload),
|
|
405
|
+
JSON.stringify(message.data.payload),
|
|
406
|
+
JSON.stringify(headers)
|
|
407
|
+
]));
|
|
408
|
+
if (!stored.ok) return problemResponse(stored.error, { instance });
|
|
409
|
+
const row = stored.data[0];
|
|
410
|
+
return Response.json({
|
|
411
|
+
id: Number(row.id),
|
|
412
|
+
duplicate: row.duplicate
|
|
413
|
+
}, { status: row.duplicate ? 200 : 202 });
|
|
414
|
+
},
|
|
415
|
+
async process(handler, processOptions = {}) {
|
|
416
|
+
let succeeded = 0;
|
|
417
|
+
let failed = 0;
|
|
418
|
+
for (;;) {
|
|
419
|
+
const rows = await sql.queryRaw("select * from better_supabase.claim_webhooks($1, $2, $3, $4::interval)", [
|
|
420
|
+
options.source,
|
|
421
|
+
worker,
|
|
422
|
+
processOptions.batch ?? 10,
|
|
423
|
+
seconds(processOptions.lease ?? 300)
|
|
424
|
+
]);
|
|
425
|
+
if (rows.length === 0) return {
|
|
426
|
+
succeeded,
|
|
427
|
+
failed
|
|
428
|
+
};
|
|
429
|
+
for (const row of rows) {
|
|
430
|
+
const message = {
|
|
431
|
+
id: Number(row.id),
|
|
432
|
+
source: row.source,
|
|
433
|
+
messageId: row.message_id,
|
|
434
|
+
type: row.event_type,
|
|
435
|
+
payload: row.payload,
|
|
436
|
+
headers: row.headers,
|
|
437
|
+
attempts: row.attempts,
|
|
438
|
+
receivedAt: toDate(row.received_at)
|
|
439
|
+
};
|
|
440
|
+
try {
|
|
441
|
+
const outcome = await handler(message);
|
|
442
|
+
if (typeof outcome === "object" && outcome !== null && "ok" in outcome && outcome.ok === false) throw new Error(errorText(outcome.error ?? "The handler failed"));
|
|
443
|
+
await sql.queryRaw("select better_supabase.complete_webhook($1, $2)", [message.id, worker]);
|
|
444
|
+
succeeded += 1;
|
|
445
|
+
} catch (cause) {
|
|
446
|
+
await sql.queryRaw("select better_supabase.fail_webhook($1, $2, $3)", [
|
|
447
|
+
message.id,
|
|
448
|
+
worker,
|
|
449
|
+
errorText(cause)
|
|
450
|
+
]);
|
|
451
|
+
failed += 1;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
/** The Stripe event sent when a customer's active entitlements change. */
|
|
459
|
+
const ENTITLEMENTS_UPDATED = "entitlements.active_entitlement_summary.updated";
|
|
460
|
+
function stripeCustomerOf(payload) {
|
|
461
|
+
const customer = (payload?.data?.object)?.customer;
|
|
462
|
+
if (typeof customer === "string") return customer;
|
|
463
|
+
const id = customer?.id;
|
|
464
|
+
return typeof id === "string" ? id : void 0;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Users whose `memberships` claim carries the entitlements of the Stripe
|
|
468
|
+
* customer in an `entitlements.active_entitlement_summary.updated` event, so
|
|
469
|
+
* a job can invalidate their sessions. Empty for other payloads.
|
|
470
|
+
*/
|
|
471
|
+
function entitlementMembers(sql, payload) {
|
|
472
|
+
const customer = stripeCustomerOf(payload);
|
|
473
|
+
if (customer === void 0) return AsyncResult.ok([]);
|
|
474
|
+
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));
|
|
475
|
+
}
|
|
476
|
+
//#endregion
|
|
477
|
+
export { ENTITLEMENTS_UPDATED, createIdempotency, createInbox, createJobs, entitlementMembers };
|