better-supabase 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -31
- package/dist/base64-CQeB1eSf.js +42 -0
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +4 -14
- package/dist/{compiler-CZ9nIuqy.d.ts → compiler-2zqGzxVZ.d.ts} +2 -2
- package/dist/config/index.d.ts +4 -3
- package/dist/config/index.js +5 -3
- package/dist/config-Be4GwsqI.js +129 -0
- package/dist/{index-DOsjFvMf.d.ts → config-C1CYcm6o.d.ts} +49 -49
- package/dist/{config-ClDXmVtY.js → config-DOsGYSiT.js} +14 -137
- package/dist/{define-BXMYD9AY.js → define-DF341GDj.js} +71 -51
- package/dist/{define-e3zI4_4g.d.ts → define-DUbydmJa.d.ts} +10 -9
- package/dist/edge/index.d.ts +4 -4
- package/dist/edge/index.js +4 -4
- package/dist/{entitlements-Dng-ctuT.d.ts → entitlements-DvJXoI02.d.ts} +1 -1
- package/dist/env/index.js +203 -1
- package/dist/events/index.d.ts +1 -1
- package/dist/events/index.js +139 -1
- package/dist/{events-DOt3nSO-.d.ts → events-Do8jXh4D.d.ts} +3 -3
- package/dist/{executor-BYXQ9AGn.d.ts → executor-B26lgbHD.d.ts} +2 -2
- package/dist/{executor-DeppvirK.js → executor-C57jx2pn.js} +7 -2
- package/dist/{executor-CGdNQ27g.d.ts → executor-DynmIWhq.d.ts} +2 -2
- package/dist/hono/index.d.ts +5 -5
- package/dist/hono/index.js +4 -4
- package/dist/{index-CBDufH1M.d.ts → index-BNsABq7P.d.ts} +8 -8
- package/dist/{index-Bw1bwtC0.d.ts → index-CADlNHK-.d.ts} +27 -14
- package/dist/{index-DuP14VHo.d.ts → index-CTTMS6_1.d.ts} +19 -8
- package/dist/index-Cf-D2jk1.d.ts +50 -0
- package/dist/{index-K-3WM2Hp.d.ts → index-CriFFJ_p.d.ts} +4 -4
- package/dist/{index-Ciu5Xlml.d.ts → index-GV77NpRP.d.ts} +5 -4
- package/dist/{index-D4v7sncu.d.ts → index-UJ-LUxtI.d.ts} +3 -3
- package/dist/{index-CG69s15v.d.ts → index-kpgopLNv.d.ts} +4 -3
- package/dist/{index-DbxFr7XG.d.ts → index-qriRM9cZ.d.ts} +7 -2
- package/dist/index.d.ts +12 -12
- package/dist/index.js +16 -7
- package/dist/jobs/index.d.ts +25 -24
- package/dist/jobs/index.js +16 -10
- package/dist/{json-schema-BpYqkCKf.js → json-schema--aOXAGfU.js} +7 -2
- package/dist/lint/index.js +1 -1
- package/dist/list/index.d.ts +2 -2
- package/dist/list/index.js +128 -93
- package/dist/{live-DJyZMD6w.d.ts → live-QE3UPbGm.d.ts} +16 -6
- package/dist/mcp/index.d.ts +24 -17
- package/dist/mcp/index.js +41 -12
- package/dist/{mfa-CpLfJSde.js → mfa-BxoR1RkN.js} +74 -19
- package/dist/{mfa-TVia554c.d.ts → mfa-x3V3vGh-.d.ts} +1 -1
- package/dist/next/index.d.ts +39 -26
- package/dist/next/index.js +28 -23
- package/dist/openapi/index.d.ts +1 -1
- package/dist/openapi/index.js +57 -34
- package/dist/orpc/index.d.ts +6 -6
- package/dist/orpc/index.js +3 -3
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +4 -2
- package/dist/{permdock-sql-CghlHPuH.js → permdock-sql-Bta_G0v-.js} +11 -6
- package/dist/permdock-sql-DImJULY-.d.ts +17 -0
- package/dist/{plugin-C4TR9J0Z.d.ts → plugin-DVRB8qNE.d.ts} +5 -4
- package/dist/plugins/actor/index.d.ts +1 -1
- package/dist/plugins/rules/index.d.ts +2 -2
- package/dist/plugins/rules/index.js +4 -4
- package/dist/plugins/soft-delete/index.d.ts +3 -3
- package/dist/plugins/soft-delete/index.js +2 -2
- package/dist/plugins/tenant/index.d.ts +2 -2
- package/dist/plugins/timestamps/index.d.ts +1 -1
- package/dist/plugins/timestamps/index.js +1 -1
- package/dist/plugins/validation/index.d.ts +2 -2
- package/dist/pool-B9qsLyFR.d.ts +71 -0
- package/dist/postgres/index.d.ts +10 -5
- package/dist/postgres/index.js +36 -16
- package/dist/{postgrest-cAEQ_IQN.d.ts → postgrest-DmBlevh-.d.ts} +1 -1
- package/dist/{problem-BTkTtRV9.js → problem-CGt1ADix.js} +10 -4
- package/dist/{problem-DlgB0lYl.d.ts → problem-Z2RTQjZD.d.ts} +6 -4
- package/dist/query/index.d.ts +2 -2
- package/dist/query/index.js +1 -1
- package/dist/{query-ChvDbsDE.js → query-BdKLTPlj.js} +2 -2
- package/dist/react/index.d.ts +7 -7
- package/dist/react/index.js +2 -2
- package/dist/react/server.d.ts +3 -3
- package/dist/react/server.js +1 -1
- package/dist/react/session.d.ts +21 -2
- package/dist/react/session.js +19 -1
- package/dist/{react-C83BdBGt.js → react-DNBYNazu.js} +20 -16
- package/dist/{read-set-4lI3W_gZ.js → read-set-D0qBcboM.js} +3 -3
- package/dist/realtime/index.d.ts +4 -3
- package/dist/realtime/index.js +7 -6
- package/dist/{resolve-CjHILk2w.d.ts → resolve-BRciX2jK.d.ts} +6 -5
- package/dist/{resource-CgZa3YG_.d.ts → resource-CwVgiI5q.d.ts} +16 -7
- package/dist/{resource-BHEWmQJK.js → resource-D30rp0GN.js} +38 -16
- package/dist/{respond-DMcbQwfN.js → respond-C44EZAJo.js} +35 -19
- package/dist/{respond-Dne1ARSa.d.ts → respond-DAkPIdTH.d.ts} +15 -9
- package/dist/{result-CTkCZ6JA.d.ts → result-c2OYgCV2.d.ts} +13 -8
- package/dist/server/index.d.ts +35 -10
- package/dist/server/index.js +38 -9
- package/dist/{simplify-bwJpLnaS.js → simplify-LakqK1vH.js} +10 -16
- package/dist/sql/index.d.ts +139 -0
- package/dist/sql/index.js +1983 -0
- package/dist/{sql-DTjA1LPj.js → sql-BIgk12ew.js} +72 -16
- package/dist/ssr/index.d.ts +4 -4
- package/dist/ssr/index.js +2 -2
- package/dist/{standard-CyhRzctb.d.ts → standard-eudBiRk1.d.ts} +1 -1
- package/dist/{stats-C5W-rBfR.js → stats-D78UQ8Yu.js} +8 -7
- package/dist/storage/index.d.ts +4 -3
- package/dist/storage/index.js +530 -1
- package/dist/{tables-rZxRxNPW.js → tables-Dhha3BXM.js} +5 -5
- package/dist/temporal-BwshQk0W.js +27 -0
- package/dist/temporal-required-CMxeSVPo.js +19 -0
- package/dist/testing/index.d.ts +13 -11
- package/dist/testing/index.js +128 -29
- package/dist/{types-C83v5SQz.d.ts → types-DONv9SHN.d.ts} +14 -11
- package/dist/{version-Cqq1m3UY.js → version-BfpG2gks.js} +1 -1
- package/dist/{view-D1s0wa0T.d.ts → view-3Po_PVlr.d.ts} +34 -4
- package/dist/{view-aOYizTeE.js → view-wZ-9qIUz.js} +11 -2
- package/dist/webhooks/index.d.ts +18 -16
- package/dist/webhooks/index.js +1 -1
- package/dist/{webhooks-DMipjhSP.js → webhooks-CAIFQSTM.js} +11 -9
- package/package.json +24 -28
- package/schemas/config-v1.json +31 -1
- package/schemas/doctor-report-v1.json +17 -1
- package/schemas/snapshot-v2.json +35 -0
- package/skills/better-supabase/SKILL.md +23 -11
- package/skills/better-supabase/references/plugins.md +21 -18
- package/skills/better-supabase/references/troubleshooting.md +15 -15
- package/skills/better-supabase-api/SKILL.md +7 -7
- package/skills/better-supabase-api/references/adapters.md +30 -25
- package/skills/better-supabase-testing/SKILL.md +11 -8
- package/bin/better-supabase.js +0 -4
- package/dist/casing-da0uRTqt.js +0 -14
- package/dist/cli/bin.d.ts +0 -1
- package/dist/cli/bin.js +0 -15
- package/dist/cli/index.d.ts +0 -226
- package/dist/cli/index.js +0 -3
- package/dist/cli-DrSgDOQC.js +0 -6646
- package/dist/env-BEToab0I.js +0 -204
- package/dist/events-BuzTgn2D.js +0 -135
- package/dist/pool-R2m_DkXi.d.ts +0 -37
- package/dist/seed-CzecODcJ.js +0 -101
- package/dist/session-C3LxnQ0e.d.ts +0 -22
- package/dist/session-CqEwzVup.js +0 -20
- package/dist/storage-DuGn38fr.js +0 -527
|
@@ -0,0 +1,1983 @@
|
|
|
1
|
+
import { i as temporalText } from "../temporal-BwshQk0W.js";
|
|
2
|
+
import { i as ok } from "../result-DWXatkd6.js";
|
|
3
|
+
import { a as hasPlaceholder, l as splitPlaceholders, s as placeholderOf } from "../read-set-D0qBcboM.js";
|
|
4
|
+
import { t as DEFAULT_CLAIMS } from "../claims-CoTobNC-.js";
|
|
5
|
+
import { i as resolveJsonSchema } from "../config-Be4GwsqI.js";
|
|
6
|
+
import { t as compileSql } from "../sql-BIgk12ew.js";
|
|
7
|
+
import { a as sqlIdent, n as permdockKeys, o as sqlString } from "../permdock-sql-Bta_G0v-.js";
|
|
8
|
+
import { t as VERSION } from "../version-BfpG2gks.js";
|
|
9
|
+
//#region src/sql/kit.ts
|
|
10
|
+
const requiresOf = (module, layout) => layout.permdock && module.permdockRequires ? module.permdockRequires : module.requires;
|
|
11
|
+
function jwtClaim(name) {
|
|
12
|
+
return `coalesce(auth.jwt() ->> ${sqlString(name)}, auth.jwt() -> 'app_metadata' ->> ${sqlString(name)})`;
|
|
13
|
+
}
|
|
14
|
+
const SCHEMA = `create schema if not exists better_supabase;
|
|
15
|
+
grant usage on schema better_supabase to anon, authenticated, service_role;`;
|
|
16
|
+
const UPDATED_AT = {
|
|
17
|
+
name: "updated-at",
|
|
18
|
+
title: "updated_at triggers",
|
|
19
|
+
description: "Keeps an updated_at column current on every update.",
|
|
20
|
+
requires: [],
|
|
21
|
+
target: "schema",
|
|
22
|
+
sql: `${SCHEMA}
|
|
23
|
+
|
|
24
|
+
create or replace function better_supabase.set_updated_at()
|
|
25
|
+
returns trigger
|
|
26
|
+
language plpgsql
|
|
27
|
+
set search_path = ''
|
|
28
|
+
as $$
|
|
29
|
+
begin
|
|
30
|
+
new := jsonb_populate_record(
|
|
31
|
+
new,
|
|
32
|
+
jsonb_build_object(coalesce(tg_argv[0], 'updated_at'), now())
|
|
33
|
+
);
|
|
34
|
+
return new;
|
|
35
|
+
end;
|
|
36
|
+
$$;
|
|
37
|
+
|
|
38
|
+
-- select better_supabase.track_updated_at('public.customers');
|
|
39
|
+
create or replace function better_supabase.track_updated_at(
|
|
40
|
+
target regclass,
|
|
41
|
+
column_name text default 'updated_at'
|
|
42
|
+
)
|
|
43
|
+
returns void
|
|
44
|
+
language plpgsql
|
|
45
|
+
set search_path = ''
|
|
46
|
+
as $$
|
|
47
|
+
begin
|
|
48
|
+
execute format('drop trigger if exists bs_updated_at on %s', target);
|
|
49
|
+
execute format(
|
|
50
|
+
'create trigger bs_updated_at before update on %s for each row execute function better_supabase.set_updated_at(%L)',
|
|
51
|
+
target,
|
|
52
|
+
column_name
|
|
53
|
+
);
|
|
54
|
+
end;
|
|
55
|
+
$$;`
|
|
56
|
+
};
|
|
57
|
+
const ACTOR = {
|
|
58
|
+
name: "actor",
|
|
59
|
+
title: "Actor stamping",
|
|
60
|
+
description: "Sets created_by on insert and updated_by on update from auth.uid(). Service writes keep the values they send.",
|
|
61
|
+
requires: [],
|
|
62
|
+
target: "schema",
|
|
63
|
+
sql: `${SCHEMA}
|
|
64
|
+
|
|
65
|
+
create or replace function better_supabase.set_actor()
|
|
66
|
+
returns trigger
|
|
67
|
+
language plpgsql
|
|
68
|
+
set search_path = ''
|
|
69
|
+
as $$
|
|
70
|
+
declare
|
|
71
|
+
actor uuid := auth.uid();
|
|
72
|
+
begin
|
|
73
|
+
if actor is null then
|
|
74
|
+
return new;
|
|
75
|
+
end if;
|
|
76
|
+
if tg_op = 'INSERT' and tg_argv[0] <> '' then
|
|
77
|
+
new := jsonb_populate_record(new, jsonb_build_object(tg_argv[0], actor));
|
|
78
|
+
end if;
|
|
79
|
+
if tg_argv[1] <> '' then
|
|
80
|
+
new := jsonb_populate_record(new, jsonb_build_object(tg_argv[1], actor));
|
|
81
|
+
end if;
|
|
82
|
+
-- The admin behind an impersonated write (the act claim), null otherwise.
|
|
83
|
+
if tg_nargs > 2 and tg_argv[2] <> '' then
|
|
84
|
+
new := jsonb_populate_record(new, jsonb_build_object(tg_argv[2], auth.jwt() -> 'act' ->> 'sub'));
|
|
85
|
+
end if;
|
|
86
|
+
return new;
|
|
87
|
+
end;
|
|
88
|
+
$$;
|
|
89
|
+
|
|
90
|
+
drop function if exists better_supabase.track_actor(regclass, text, text);
|
|
91
|
+
|
|
92
|
+
-- select better_supabase.track_actor('public.customers');
|
|
93
|
+
-- impersonated_by => 'impersonated_by' also stamps the impersonating admin.
|
|
94
|
+
create or replace function better_supabase.track_actor(
|
|
95
|
+
target regclass,
|
|
96
|
+
created_by text default 'created_by',
|
|
97
|
+
updated_by text default 'updated_by',
|
|
98
|
+
impersonated_by text default null
|
|
99
|
+
)
|
|
100
|
+
returns void
|
|
101
|
+
language plpgsql
|
|
102
|
+
set search_path = ''
|
|
103
|
+
as $$
|
|
104
|
+
begin
|
|
105
|
+
execute format('drop trigger if exists bs_actor on %s', target);
|
|
106
|
+
execute format(
|
|
107
|
+
'create trigger bs_actor before insert or update on %s for each row execute function better_supabase.set_actor(%L, %L, %L)',
|
|
108
|
+
target,
|
|
109
|
+
coalesce(created_by, ''),
|
|
110
|
+
coalesce(updated_by, ''),
|
|
111
|
+
coalesce(impersonated_by, '')
|
|
112
|
+
);
|
|
113
|
+
end;
|
|
114
|
+
$$;`
|
|
115
|
+
};
|
|
116
|
+
const AUDIT = {
|
|
117
|
+
name: "audit",
|
|
118
|
+
title: "Audit log",
|
|
119
|
+
description: "Records inserts, updates and deletes with the actor and changed columns, for tables you register.",
|
|
120
|
+
requires: [],
|
|
121
|
+
target: "schema",
|
|
122
|
+
sql: `${SCHEMA}
|
|
123
|
+
|
|
124
|
+
create table if not exists better_supabase.audited_tables (
|
|
125
|
+
target regclass primary key,
|
|
126
|
+
ignore text[] not null default '{}'
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
create table if not exists better_supabase.audit_log (
|
|
130
|
+
id bigint generated always as identity primary key,
|
|
131
|
+
table_name text not null,
|
|
132
|
+
record_id text,
|
|
133
|
+
op text not null check (op in ('insert', 'update', 'delete')),
|
|
134
|
+
old_record jsonb,
|
|
135
|
+
new_record jsonb,
|
|
136
|
+
changed text[],
|
|
137
|
+
actor_id uuid,
|
|
138
|
+
actor_role text,
|
|
139
|
+
org_id uuid,
|
|
140
|
+
at timestamptz not null default now()
|
|
141
|
+
);
|
|
142
|
+
-- Set when an admin acted as the user (the act claim).
|
|
143
|
+
alter table better_supabase.audit_log add column if not exists impersonated_by uuid;
|
|
144
|
+
alter table better_supabase.audit_log add column if not exists impersonation_reason text;
|
|
145
|
+
create index if not exists audit_log_record_idx on better_supabase.audit_log (table_name, record_id, at desc);
|
|
146
|
+
create index if not exists audit_log_org_idx on better_supabase.audit_log (org_id, at desc);
|
|
147
|
+
|
|
148
|
+
alter table better_supabase.audited_tables enable row level security;
|
|
149
|
+
alter table better_supabase.audit_log enable row level security;
|
|
150
|
+
revoke all on better_supabase.audited_tables, better_supabase.audit_log from anon, authenticated;
|
|
151
|
+
grant select on better_supabase.audit_log to service_role;
|
|
152
|
+
|
|
153
|
+
create or replace function better_supabase.audit_trigger()
|
|
154
|
+
returns trigger
|
|
155
|
+
language plpgsql
|
|
156
|
+
security definer
|
|
157
|
+
set search_path = ''
|
|
158
|
+
as $$
|
|
159
|
+
declare
|
|
160
|
+
ignored text[];
|
|
161
|
+
old_row jsonb := case when tg_op <> 'INSERT' then to_jsonb(old) end;
|
|
162
|
+
new_row jsonb := case when tg_op <> 'DELETE' then to_jsonb(new) end;
|
|
163
|
+
row_data jsonb := coalesce(new_row, old_row);
|
|
164
|
+
changed_columns text[];
|
|
165
|
+
begin
|
|
166
|
+
select a.ignore into ignored
|
|
167
|
+
from better_supabase.audited_tables a
|
|
168
|
+
where a.target = tg_relid::regclass;
|
|
169
|
+
ignored := coalesce(ignored, '{}');
|
|
170
|
+
old_row := old_row - ignored;
|
|
171
|
+
new_row := new_row - ignored;
|
|
172
|
+
if tg_op = 'UPDATE' then
|
|
173
|
+
select array_agg(key order by key) into changed_columns
|
|
174
|
+
from jsonb_each(new_row) n
|
|
175
|
+
where n.value is distinct from old_row -> n.key;
|
|
176
|
+
if changed_columns is null then
|
|
177
|
+
return null;
|
|
178
|
+
end if;
|
|
179
|
+
end if;
|
|
180
|
+
insert into better_supabase.audit_log
|
|
181
|
+
(table_name, record_id, op, old_record, new_record, changed, actor_id, actor_role, org_id,
|
|
182
|
+
impersonated_by, impersonation_reason)
|
|
183
|
+
values (
|
|
184
|
+
tg_table_schema || '.' || tg_table_name,
|
|
185
|
+
row_data ->> 'id',
|
|
186
|
+
lower(tg_op),
|
|
187
|
+
old_row,
|
|
188
|
+
new_row,
|
|
189
|
+
changed_columns,
|
|
190
|
+
auth.uid(),
|
|
191
|
+
coalesce(auth.jwt() ->> 'role', current_user),
|
|
192
|
+
case
|
|
193
|
+
when row_data ->> 'organization_id' ~ '^[0-9a-f-]{36}$'
|
|
194
|
+
then (row_data ->> 'organization_id')::uuid
|
|
195
|
+
end,
|
|
196
|
+
case
|
|
197
|
+
when auth.jwt() -> 'act' ->> 'sub' ~ '^[0-9a-f-]{36}$'
|
|
198
|
+
then (auth.jwt() -> 'act' ->> 'sub')::uuid
|
|
199
|
+
end,
|
|
200
|
+
auth.jwt() -> 'act' ->> 'reason'
|
|
201
|
+
);
|
|
202
|
+
return null;
|
|
203
|
+
end;
|
|
204
|
+
$$;
|
|
205
|
+
|
|
206
|
+
-- select better_supabase.audit('public.customers', ignore => '{updated_at}');
|
|
207
|
+
create or replace function better_supabase.audit(target regclass, ignore text[] default '{}')
|
|
208
|
+
returns void
|
|
209
|
+
language plpgsql
|
|
210
|
+
set search_path = ''
|
|
211
|
+
as $$
|
|
212
|
+
begin
|
|
213
|
+
insert into better_supabase.audited_tables as a (target, ignore) values (audit.target, audit.ignore)
|
|
214
|
+
on conflict on constraint audited_tables_pkey do update set ignore = excluded.ignore;
|
|
215
|
+
execute format('drop trigger if exists bs_audit on %s', target);
|
|
216
|
+
execute format(
|
|
217
|
+
'create trigger bs_audit after insert or update or delete on %s for each row execute function better_supabase.audit_trigger()',
|
|
218
|
+
target
|
|
219
|
+
);
|
|
220
|
+
end;
|
|
221
|
+
$$;
|
|
222
|
+
|
|
223
|
+
create or replace function better_supabase.unaudit(target regclass)
|
|
224
|
+
returns void
|
|
225
|
+
language plpgsql
|
|
226
|
+
set search_path = ''
|
|
227
|
+
as $$
|
|
228
|
+
begin
|
|
229
|
+
execute format('drop trigger if exists bs_audit on %s', target);
|
|
230
|
+
delete from better_supabase.audited_tables a where a.target = unaudit.target;
|
|
231
|
+
end;
|
|
232
|
+
$$;
|
|
233
|
+
|
|
234
|
+
-- Deletes up to batch entries older than older_than and returns how many.
|
|
235
|
+
-- Ids grow with time, so the primary key finds the oldest rows first.
|
|
236
|
+
-- Nightly with pg_cron: select cron.schedule('purge-audit-log', '15 3 * * *', 'select better_supabase.purge_audit_log()');
|
|
237
|
+
create or replace function better_supabase.purge_audit_log(
|
|
238
|
+
older_than interval default '1 year',
|
|
239
|
+
batch integer default 10000
|
|
240
|
+
)
|
|
241
|
+
returns integer
|
|
242
|
+
language sql
|
|
243
|
+
set search_path = ''
|
|
244
|
+
as $$
|
|
245
|
+
with purged as (
|
|
246
|
+
delete from better_supabase.audit_log
|
|
247
|
+
where id in (
|
|
248
|
+
select l.id from better_supabase.audit_log l
|
|
249
|
+
where l.at < now() - older_than
|
|
250
|
+
order by l.id
|
|
251
|
+
limit batch
|
|
252
|
+
)
|
|
253
|
+
returning 1
|
|
254
|
+
)
|
|
255
|
+
select count(*)::integer from purged
|
|
256
|
+
$$;
|
|
257
|
+
revoke execute on function better_supabase.purge_audit_log(interval, integer) from public, anon, authenticated;
|
|
258
|
+
grant execute on function better_supabase.purge_audit_log(interval, integer) to service_role;`
|
|
259
|
+
};
|
|
260
|
+
const tenantSql = (claims) => `${SCHEMA}
|
|
261
|
+
grant usage on schema better_supabase to supabase_auth_admin;
|
|
262
|
+
|
|
263
|
+
create table if not exists better_supabase.memberships (
|
|
264
|
+
org_id uuid not null,
|
|
265
|
+
user_id uuid not null references auth.users (id) on delete cascade,
|
|
266
|
+
role text not null default 'member' check (role in ('owner', 'admin', 'member', 'viewer')),
|
|
267
|
+
created_at timestamptz not null default now(),
|
|
268
|
+
primary key (org_id, user_id)
|
|
269
|
+
);
|
|
270
|
+
create index if not exists memberships_user_idx on better_supabase.memberships (user_id);
|
|
271
|
+
|
|
272
|
+
alter table better_supabase.memberships enable row level security;
|
|
273
|
+
grant select on better_supabase.memberships to authenticated;
|
|
274
|
+
grant all on better_supabase.memberships to service_role;
|
|
275
|
+
|
|
276
|
+
-- The tenant of the current request: the top-level \`${claims.tenant}\` claim
|
|
277
|
+
-- (custom access token hook) or \`app_metadata.${claims.tenant}\` (Auth admin API).
|
|
278
|
+
-- Never user_metadata: users can write it.
|
|
279
|
+
create or replace function better_supabase.current_tenant_id()
|
|
280
|
+
returns uuid
|
|
281
|
+
language sql
|
|
282
|
+
stable
|
|
283
|
+
set search_path = ''
|
|
284
|
+
as $$
|
|
285
|
+
select nullif(${jwtClaim(claims.tenant)}, '')::uuid
|
|
286
|
+
$$;
|
|
287
|
+
|
|
288
|
+
-- Policies compare against the set once per statement:
|
|
289
|
+
-- using (organization_id in (select better_supabase.member_org_ids('{owner,admin}')))
|
|
290
|
+
-- has_org_role(org) answers for one organization, in functions and checks.
|
|
291
|
+
create or replace function better_supabase.member_org_ids(roles text[] default null)
|
|
292
|
+
returns setof uuid
|
|
293
|
+
language sql
|
|
294
|
+
stable
|
|
295
|
+
security definer
|
|
296
|
+
set search_path = ''
|
|
297
|
+
as $$
|
|
298
|
+
select m.org_id
|
|
299
|
+
from better_supabase.memberships m
|
|
300
|
+
where m.user_id = (select auth.uid())
|
|
301
|
+
and (roles is null or m.role = any (roles))
|
|
302
|
+
$$;
|
|
303
|
+
|
|
304
|
+
create or replace function better_supabase.has_org_role(org uuid, roles text[] default null)
|
|
305
|
+
returns boolean
|
|
306
|
+
language sql
|
|
307
|
+
stable
|
|
308
|
+
security definer
|
|
309
|
+
set search_path = ''
|
|
310
|
+
as $$
|
|
311
|
+
select exists (
|
|
312
|
+
select 1
|
|
313
|
+
from better_supabase.memberships m
|
|
314
|
+
where m.org_id = org
|
|
315
|
+
and m.user_id = auth.uid()
|
|
316
|
+
and (roles is null or m.role = any (roles))
|
|
317
|
+
)
|
|
318
|
+
$$;
|
|
319
|
+
|
|
320
|
+
drop policy if exists bs_memberships_read on better_supabase.memberships;
|
|
321
|
+
create policy bs_memberships_read on better_supabase.memberships
|
|
322
|
+
for select to authenticated
|
|
323
|
+
using (user_id = (select auth.uid()) or org_id in (select better_supabase.member_org_ids()));
|
|
324
|
+
|
|
325
|
+
revoke execute on function better_supabase.member_org_ids(text[]) from public, anon;
|
|
326
|
+
revoke execute on function better_supabase.has_org_role(uuid, text[]) from public, anon;
|
|
327
|
+
grant execute on function better_supabase.member_org_ids(text[]) to authenticated, service_role;
|
|
328
|
+
grant execute on function better_supabase.has_org_role(uuid, text[]) to authenticated, service_role;
|
|
329
|
+
|
|
330
|
+
-- The memberships claim in PermDock's shape: [{ scope, id, roles }]. With
|
|
331
|
+
-- PermDock, \`permdock supabase hook generate\` writes the hook instead.
|
|
332
|
+
-- Otherwise call it from your custom access token hook:
|
|
333
|
+
-- return jsonb_set(event, '{claims,memberships}',
|
|
334
|
+
-- better_supabase.membership_claims((event ->> 'user_id')::uuid));
|
|
335
|
+
create or replace function better_supabase.membership_claims(user_id uuid)
|
|
336
|
+
returns jsonb
|
|
337
|
+
language sql
|
|
338
|
+
stable
|
|
339
|
+
security definer
|
|
340
|
+
set search_path = ''
|
|
341
|
+
as $$
|
|
342
|
+
select coalesce(jsonb_agg(jsonb_build_object(
|
|
343
|
+
'scope', ${sqlString(claims.scope)},
|
|
344
|
+
'id', m.org_id,
|
|
345
|
+
'roles', jsonb_build_array(m.role)
|
|
346
|
+
) order by m.created_at, m.org_id), '[]'::jsonb)
|
|
347
|
+
from better_supabase.memberships m
|
|
348
|
+
where m.user_id = membership_claims.user_id
|
|
349
|
+
$$;
|
|
350
|
+
|
|
351
|
+
revoke execute on function better_supabase.membership_claims(uuid) from public, anon, authenticated;
|
|
352
|
+
grant execute on function better_supabase.membership_claims(uuid) to service_role, supabase_auth_admin;`;
|
|
353
|
+
const TENANT = {
|
|
354
|
+
name: "tenant",
|
|
355
|
+
title: "Tenant memberships and permission helper",
|
|
356
|
+
description: "Memberships with roles, member_org_ids() and has_org_role() for RLS policies, and membership_claims() for the access token hook. A template: edit the roles to fit your app.",
|
|
357
|
+
requires: [],
|
|
358
|
+
target: "schema",
|
|
359
|
+
sql: tenantSql(DEFAULT_CLAIMS),
|
|
360
|
+
render: tenantSql
|
|
361
|
+
};
|
|
362
|
+
const MFA = {
|
|
363
|
+
name: "mfa",
|
|
364
|
+
title: "MFA enforcement",
|
|
365
|
+
description: "mfa_satisfied() for restrictive policies: true when the caller has no verified factor, or verified one in this session (aal2).",
|
|
366
|
+
requires: [],
|
|
367
|
+
target: "schema",
|
|
368
|
+
sql: `${SCHEMA}
|
|
369
|
+
|
|
370
|
+
-- authenticated can't read auth.mfa_factors, so the check runs as the owner.
|
|
371
|
+
create or replace function better_supabase.mfa_satisfied()
|
|
372
|
+
returns boolean
|
|
373
|
+
language sql
|
|
374
|
+
stable
|
|
375
|
+
security definer
|
|
376
|
+
set search_path = ''
|
|
377
|
+
as $$
|
|
378
|
+
select coalesce(auth.jwt() ->> 'aal', 'aal1') = 'aal2'
|
|
379
|
+
or not exists (
|
|
380
|
+
select 1
|
|
381
|
+
from auth.mfa_factors f
|
|
382
|
+
where f.user_id = auth.uid()
|
|
383
|
+
and f.status = 'verified'
|
|
384
|
+
)
|
|
385
|
+
$$;
|
|
386
|
+
|
|
387
|
+
revoke execute on function better_supabase.mfa_satisfied() from public, anon;
|
|
388
|
+
grant execute on function better_supabase.mfa_satisfied() to authenticated;
|
|
389
|
+
|
|
390
|
+
-- Restrictive, so it applies on top of the table's other policies:
|
|
391
|
+
-- create policy mfa_required on public.invoices as restrictive
|
|
392
|
+
-- for all to authenticated
|
|
393
|
+
-- using ((select better_supabase.mfa_satisfied()))
|
|
394
|
+
-- with check ((select better_supabase.mfa_satisfied()));`
|
|
395
|
+
};
|
|
396
|
+
/** `has_entitlement` and `feature_claims` on the kit's `better_supabase.memberships`. */
|
|
397
|
+
const tenantEntitlementChecks = (claims) => `
|
|
398
|
+
-- using ((select better_supabase.has_entitlement(organization_id, 'exports')))
|
|
399
|
+
create or replace function better_supabase.has_entitlement(tenant uuid, key text)
|
|
400
|
+
returns boolean
|
|
401
|
+
language sql
|
|
402
|
+
stable
|
|
403
|
+
security definer
|
|
404
|
+
set search_path = ''
|
|
405
|
+
as $$
|
|
406
|
+
select better_supabase.has_org_role(tenant)
|
|
407
|
+
and key = any (better_supabase.tenant_entitlements(tenant))
|
|
408
|
+
$$;
|
|
409
|
+
|
|
410
|
+
revoke execute on function better_supabase.has_entitlement(uuid, text) from public, anon;
|
|
411
|
+
grant execute on function better_supabase.has_entitlement(uuid, text) to authenticated, service_role;
|
|
412
|
+
|
|
413
|
+
-- The \`${claims.features}\` claim: { [tenant id]: lookup keys }, read by
|
|
414
|
+
-- hasEntitlement(). Tenants without entitlements are left out. Call it from
|
|
415
|
+
-- your custom access token hook:
|
|
416
|
+
-- return jsonb_set(event, '{claims,${claims.features}}',
|
|
417
|
+
-- better_supabase.feature_claims((event ->> 'user_id')::uuid));
|
|
418
|
+
create or replace function better_supabase.feature_claims(user_id uuid)
|
|
419
|
+
returns jsonb
|
|
420
|
+
language sql
|
|
421
|
+
stable
|
|
422
|
+
security definer
|
|
423
|
+
set search_path = ''
|
|
424
|
+
as $$
|
|
425
|
+
select coalesce(jsonb_object_agg(m.org_id::text, to_jsonb(e.keys)), '{}'::jsonb)
|
|
426
|
+
from better_supabase.memberships m
|
|
427
|
+
cross join lateral (select better_supabase.tenant_entitlements(m.org_id) as keys) e
|
|
428
|
+
where m.user_id = feature_claims.user_id
|
|
429
|
+
and cardinality(e.keys) > 0
|
|
430
|
+
$$;`;
|
|
431
|
+
/** `has_entitlement` and `feature_claims` on PermDock's `member_<scope>_ids` helpers. */
|
|
432
|
+
const permdockEntitlementChecks = (claims, permdock) => {
|
|
433
|
+
const member = `${sqlIdent(permdock.schema)}.${sqlIdent(`member_${permdock.scope}_ids`)}`;
|
|
434
|
+
const memberFor = `${sqlIdent(permdock.schema)}.${sqlIdent(`member_${permdock.scope}_ids_for`)}`;
|
|
435
|
+
return `
|
|
436
|
+
-- PermDock mode: memberships come from PermDock's ${permdock.scope} scope
|
|
437
|
+
-- (${member}() and ${memberFor}(uuid), from \`permdock rls generate\`).
|
|
438
|
+
|
|
439
|
+
-- using ((select better_supabase.has_entitlement(organization_id, 'exports')))
|
|
440
|
+
create or replace function better_supabase.has_entitlement(tenant uuid, key text)
|
|
441
|
+
returns boolean
|
|
442
|
+
language sql
|
|
443
|
+
stable
|
|
444
|
+
security definer
|
|
445
|
+
set search_path = ''
|
|
446
|
+
as $$
|
|
447
|
+
select tenant in (select ${member}())
|
|
448
|
+
and key = any (better_supabase.tenant_entitlements(tenant))
|
|
449
|
+
$$;
|
|
450
|
+
|
|
451
|
+
revoke execute on function better_supabase.has_entitlement(uuid, text) from public, anon;
|
|
452
|
+
grant execute on function better_supabase.has_entitlement(uuid, text) to authenticated, service_role;
|
|
453
|
+
|
|
454
|
+
-- The \`${claims.features}\` claim: { [${permdock.scope} id]: lookup keys }, read by
|
|
455
|
+
-- hasEntitlement(). Register it with PermDock instead of writing a hook:
|
|
456
|
+
-- supabase: { hook: { claims: { ${claims.features}: 'better_supabase.feature_claims' } } }
|
|
457
|
+
-- \`permdock supabase hook generate --grants-out\` then grants ${memberFor}
|
|
458
|
+
-- to supabase_auth_admin.
|
|
459
|
+
create or replace function better_supabase.feature_claims(user_id uuid)
|
|
460
|
+
returns jsonb
|
|
461
|
+
language sql
|
|
462
|
+
stable
|
|
463
|
+
security definer
|
|
464
|
+
set search_path = ''
|
|
465
|
+
as $$
|
|
466
|
+
select coalesce(jsonb_object_agg(t.id::text, to_jsonb(e.keys)), '{}'::jsonb)
|
|
467
|
+
from ${memberFor}(feature_claims.user_id) as t(id)
|
|
468
|
+
cross join lateral (select better_supabase.tenant_entitlements(t.id) as keys) e
|
|
469
|
+
where cardinality(e.keys) > 0
|
|
470
|
+
$$;`;
|
|
471
|
+
};
|
|
472
|
+
const entitlementsSql = (claims, layout = {}) => `${SCHEMA}
|
|
473
|
+
grant usage on schema better_supabase to supabase_auth_admin;
|
|
474
|
+
|
|
475
|
+
-- Lookup keys of the tenant's active entitlements, from the Stripe Sync
|
|
476
|
+
-- Engine's stripe.active_entitlements. Empty before the engine is installed.
|
|
477
|
+
create or replace function better_supabase.tenant_entitlements(tenant uuid)
|
|
478
|
+
returns text[]
|
|
479
|
+
language plpgsql
|
|
480
|
+
stable
|
|
481
|
+
security definer
|
|
482
|
+
set search_path = ''
|
|
483
|
+
as $$
|
|
484
|
+
declare
|
|
485
|
+
customer_id text := better_supabase.tenant_stripe_customer(tenant);
|
|
486
|
+
begin
|
|
487
|
+
if customer_id is null or to_regclass('stripe.active_entitlements') is null then
|
|
488
|
+
return '{}';
|
|
489
|
+
end if;
|
|
490
|
+
return coalesce((
|
|
491
|
+
select array_agg(distinct e.lookup_key order by e.lookup_key)
|
|
492
|
+
from stripe.active_entitlements e
|
|
493
|
+
where e.customer = customer_id
|
|
494
|
+
and e.lookup_key is not null
|
|
495
|
+
), '{}');
|
|
496
|
+
end
|
|
497
|
+
$$;
|
|
498
|
+
|
|
499
|
+
revoke execute on function better_supabase.tenant_entitlements(uuid) from public, anon, authenticated;
|
|
500
|
+
grant execute on function better_supabase.tenant_entitlements(uuid) to service_role, supabase_auth_admin;
|
|
501
|
+
${layout.permdock ? permdockEntitlementChecks(claims, layout.permdock) : tenantEntitlementChecks(claims)}
|
|
502
|
+
|
|
503
|
+
revoke execute on function better_supabase.feature_claims(uuid) from public, anon, authenticated;
|
|
504
|
+
grant execute on function better_supabase.feature_claims(uuid) to service_role, supabase_auth_admin;`;
|
|
505
|
+
const ENTITLEMENTS = {
|
|
506
|
+
name: "entitlements",
|
|
507
|
+
title: "Stripe entitlements",
|
|
508
|
+
description: "Active Stripe entitlements per tenant from the Stripe Sync Engine, feature_claims() for the access token hook, and has_entitlement() for RLS.",
|
|
509
|
+
requires: ["tenant"],
|
|
510
|
+
permdockRequires: [],
|
|
511
|
+
target: "schema",
|
|
512
|
+
sql: entitlementsSql(DEFAULT_CLAIMS),
|
|
513
|
+
render: entitlementsSql
|
|
514
|
+
};
|
|
515
|
+
const INVITATIONS = {
|
|
516
|
+
name: "invitations",
|
|
517
|
+
title: "Invitations",
|
|
518
|
+
description: "Owners and admins invite by email; the invitee accepts with a one-time token and becomes a member.",
|
|
519
|
+
requires: ["tenant"],
|
|
520
|
+
target: "schema",
|
|
521
|
+
sql: `${SCHEMA}
|
|
522
|
+
|
|
523
|
+
create table if not exists better_supabase.invitations (
|
|
524
|
+
id uuid primary key default gen_random_uuid(),
|
|
525
|
+
org_id uuid not null,
|
|
526
|
+
email text not null,
|
|
527
|
+
role text not null default 'member',
|
|
528
|
+
token_hash text not null unique,
|
|
529
|
+
invited_by uuid references auth.users (id) on delete set null,
|
|
530
|
+
created_at timestamptz not null default now(),
|
|
531
|
+
expires_at timestamptz not null,
|
|
532
|
+
accepted_at timestamptz,
|
|
533
|
+
accepted_by uuid references auth.users (id) on delete set null
|
|
534
|
+
);
|
|
535
|
+
create unique index if not exists invitations_open_idx
|
|
536
|
+
on better_supabase.invitations (org_id, lower(email)) where accepted_at is null;
|
|
537
|
+
create index if not exists invitations_invited_by_idx
|
|
538
|
+
on better_supabase.invitations (invited_by);
|
|
539
|
+
create index if not exists invitations_accepted_by_idx
|
|
540
|
+
on better_supabase.invitations (accepted_by);
|
|
541
|
+
|
|
542
|
+
alter table better_supabase.invitations drop constraint if exists invitations_role_check;
|
|
543
|
+
alter table better_supabase.invitations
|
|
544
|
+
add constraint invitations_role_check check (role in ('owner', 'admin', 'member', 'viewer'));
|
|
545
|
+
|
|
546
|
+
alter table better_supabase.invitations enable row level security;
|
|
547
|
+
revoke all on better_supabase.invitations from anon, authenticated;
|
|
548
|
+
grant select on better_supabase.invitations to authenticated;
|
|
549
|
+
grant all on better_supabase.invitations to service_role;
|
|
550
|
+
|
|
551
|
+
drop policy if exists bs_invitations_read on better_supabase.invitations;
|
|
552
|
+
create policy bs_invitations_read on better_supabase.invitations
|
|
553
|
+
for select to authenticated
|
|
554
|
+
using (org_id in (select better_supabase.member_org_ids('{owner,admin}')));
|
|
555
|
+
|
|
556
|
+
-- Returns the token to send; only its hash is stored.
|
|
557
|
+
create or replace function better_supabase.create_invitation(
|
|
558
|
+
org uuid,
|
|
559
|
+
invitee_email text,
|
|
560
|
+
invitee_role text default 'member',
|
|
561
|
+
valid_for interval default '7 days'
|
|
562
|
+
)
|
|
563
|
+
returns text
|
|
564
|
+
language plpgsql
|
|
565
|
+
security definer
|
|
566
|
+
set search_path = ''
|
|
567
|
+
as $$
|
|
568
|
+
declare
|
|
569
|
+
token text := encode(extensions.gen_random_bytes(24), 'hex');
|
|
570
|
+
privileged boolean := coalesce(auth.jwt() ->> 'role', '') = 'service_role';
|
|
571
|
+
begin
|
|
572
|
+
if not privileged and not better_supabase.has_org_role(org, '{owner,admin}') then
|
|
573
|
+
raise exception 'Only owners and admins can invite' using errcode = '42501';
|
|
574
|
+
end if;
|
|
575
|
+
-- Admins invite admins and below; only an owner hands out ownership.
|
|
576
|
+
if invitee_role = 'owner' and not privileged and not better_supabase.has_org_role(org, '{owner}') then
|
|
577
|
+
raise exception 'Only owners can invite an owner' using errcode = '42501', hint = 'INVITATION_ROLE_FORBIDDEN';
|
|
578
|
+
end if;
|
|
579
|
+
delete from better_supabase.invitations i
|
|
580
|
+
where i.org_id = org and lower(i.email) = lower(invitee_email) and i.accepted_at is null;
|
|
581
|
+
insert into better_supabase.invitations (org_id, email, role, token_hash, invited_by, expires_at)
|
|
582
|
+
values (
|
|
583
|
+
org,
|
|
584
|
+
invitee_email,
|
|
585
|
+
invitee_role,
|
|
586
|
+
encode(extensions.digest(token, 'sha256'), 'hex'),
|
|
587
|
+
auth.uid(),
|
|
588
|
+
now() + valid_for
|
|
589
|
+
);
|
|
590
|
+
return token;
|
|
591
|
+
end;
|
|
592
|
+
$$;
|
|
593
|
+
|
|
594
|
+
create or replace function better_supabase.accept_invitation(token text)
|
|
595
|
+
returns uuid
|
|
596
|
+
language plpgsql
|
|
597
|
+
security definer
|
|
598
|
+
set search_path = ''
|
|
599
|
+
as $$
|
|
600
|
+
declare
|
|
601
|
+
invite better_supabase.invitations;
|
|
602
|
+
begin
|
|
603
|
+
if auth.uid() is null then
|
|
604
|
+
raise exception 'Sign in to accept an invitation' using errcode = '42501';
|
|
605
|
+
end if;
|
|
606
|
+
select * into invite
|
|
607
|
+
from better_supabase.invitations i
|
|
608
|
+
where i.token_hash = encode(extensions.digest(token, 'sha256'), 'hex')
|
|
609
|
+
for update;
|
|
610
|
+
if invite.id is null or invite.accepted_at is not null or invite.expires_at < now() then
|
|
611
|
+
raise exception 'The invitation is invalid or has expired' using errcode = 'P0002', hint = 'INVITATION_INVALID';
|
|
612
|
+
end if;
|
|
613
|
+
if lower(invite.email) <> lower(coalesce(auth.jwt() ->> 'email', '')) then
|
|
614
|
+
raise exception 'The invitation is for another email address' using errcode = '42501', hint = 'INVITATION_EMAIL_MISMATCH';
|
|
615
|
+
end if;
|
|
616
|
+
-- The email claim alone does not prove the address: an unconfirmed sign-up carries it too.
|
|
617
|
+
if not exists (
|
|
618
|
+
select 1 from auth.users u
|
|
619
|
+
where u.id = auth.uid() and u.email_confirmed_at is not null and lower(u.email) = lower(invite.email)
|
|
620
|
+
) then
|
|
621
|
+
raise exception 'Confirm your email address before accepting the invitation' using errcode = '42501', hint = 'INVITATION_EMAIL_UNCONFIRMED';
|
|
622
|
+
end if;
|
|
623
|
+
insert into better_supabase.memberships (org_id, user_id, role)
|
|
624
|
+
values (invite.org_id, auth.uid(), invite.role)
|
|
625
|
+
on conflict (org_id, user_id) do update set role = excluded.role;
|
|
626
|
+
update better_supabase.invitations
|
|
627
|
+
set accepted_at = now(), accepted_by = auth.uid()
|
|
628
|
+
where id = invite.id;
|
|
629
|
+
return invite.org_id;
|
|
630
|
+
end;
|
|
631
|
+
$$;
|
|
632
|
+
|
|
633
|
+
revoke execute on function better_supabase.create_invitation(uuid, text, text, interval) from public, anon;
|
|
634
|
+
revoke execute on function better_supabase.accept_invitation(text) from public, anon;
|
|
635
|
+
grant execute on function better_supabase.create_invitation(uuid, text, text, interval) to authenticated, service_role;
|
|
636
|
+
grant execute on function better_supabase.accept_invitation(text) to authenticated;`
|
|
637
|
+
};
|
|
638
|
+
const RESERVED_SLUGS = {
|
|
639
|
+
name: "reserved-slugs",
|
|
640
|
+
title: "Reserved slugs",
|
|
641
|
+
description: "A slug format check and a list of reserved words (admin, api, www, ...), enforced by a trigger.",
|
|
642
|
+
requires: [],
|
|
643
|
+
target: "schema",
|
|
644
|
+
sql: `${SCHEMA}
|
|
645
|
+
|
|
646
|
+
create table if not exists better_supabase.reserved_slugs (
|
|
647
|
+
slug text primary key,
|
|
648
|
+
reason text
|
|
649
|
+
);
|
|
650
|
+
alter table better_supabase.reserved_slugs enable row level security;
|
|
651
|
+
grant select on better_supabase.reserved_slugs to anon, authenticated;
|
|
652
|
+
drop policy if exists bs_reserved_slugs_read on better_supabase.reserved_slugs;
|
|
653
|
+
create policy bs_reserved_slugs_read on better_supabase.reserved_slugs for select using (true);
|
|
654
|
+
|
|
655
|
+
insert into better_supabase.reserved_slugs (slug, reason)
|
|
656
|
+
select value, 'system'
|
|
657
|
+
from unnest(array[
|
|
658
|
+
'about', 'account', 'admin', 'api', 'app', 'assets', 'auth', 'billing', 'blog',
|
|
659
|
+
'callback', 'cdn', 'dashboard', 'docs', 'help', 'invite', 'login', 'logout',
|
|
660
|
+
'mail', 'new', 'oauth', 'onboarding', 'pricing', 'privacy', 'root', 'settings',
|
|
661
|
+
'signin', 'signout', 'signup', 'static', 'status', 'support', 'system', 'terms',
|
|
662
|
+
'www'
|
|
663
|
+
]) as value
|
|
664
|
+
on conflict (slug) do nothing;
|
|
665
|
+
|
|
666
|
+
create or replace function better_supabase.slug_problem(slug text)
|
|
667
|
+
returns text
|
|
668
|
+
language sql
|
|
669
|
+
stable
|
|
670
|
+
set search_path = ''
|
|
671
|
+
as $$
|
|
672
|
+
select case
|
|
673
|
+
when slug is null then null
|
|
674
|
+
when slug !~ '^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$' then 'invalid'
|
|
675
|
+
when slug ~ '--' then 'invalid'
|
|
676
|
+
when exists (select 1 from better_supabase.reserved_slugs r where r.slug = slug_problem.slug) then 'reserved'
|
|
677
|
+
end
|
|
678
|
+
$$;
|
|
679
|
+
|
|
680
|
+
create or replace function better_supabase.enforce_slug()
|
|
681
|
+
returns trigger
|
|
682
|
+
language plpgsql
|
|
683
|
+
set search_path = ''
|
|
684
|
+
as $$
|
|
685
|
+
declare
|
|
686
|
+
value text := to_jsonb(new) ->> coalesce(tg_argv[0], 'slug');
|
|
687
|
+
problem text := better_supabase.slug_problem(value);
|
|
688
|
+
begin
|
|
689
|
+
if problem = 'invalid' then
|
|
690
|
+
raise exception 'Invalid slug "%"', value using errcode = '23514', hint = 'SLUG_INVALID';
|
|
691
|
+
elsif problem = 'reserved' then
|
|
692
|
+
raise exception 'The slug "%" is reserved', value using errcode = '23514', hint = 'SLUG_RESERVED';
|
|
693
|
+
end if;
|
|
694
|
+
return new;
|
|
695
|
+
end;
|
|
696
|
+
$$;
|
|
697
|
+
|
|
698
|
+
-- select better_supabase.track_slug('public.organizations');
|
|
699
|
+
create or replace function better_supabase.track_slug(target regclass, column_name text default 'slug')
|
|
700
|
+
returns void
|
|
701
|
+
language plpgsql
|
|
702
|
+
set search_path = ''
|
|
703
|
+
as $$
|
|
704
|
+
begin
|
|
705
|
+
execute format('drop trigger if exists bs_slug on %s', target);
|
|
706
|
+
execute format(
|
|
707
|
+
'create trigger bs_slug before insert or update of %I on %s for each row execute function better_supabase.enforce_slug(%L)',
|
|
708
|
+
column_name,
|
|
709
|
+
target,
|
|
710
|
+
column_name
|
|
711
|
+
);
|
|
712
|
+
end;
|
|
713
|
+
$$;`
|
|
714
|
+
};
|
|
715
|
+
const JOBS = {
|
|
716
|
+
name: "jobs",
|
|
717
|
+
title: "Job queue",
|
|
718
|
+
description: "Typed jobs on Supabase Queues (pgmq): leases, retries with backoff, dead letters, deduplication keys, and pg_cron schedules.",
|
|
719
|
+
requires: [],
|
|
720
|
+
target: "schema",
|
|
721
|
+
sql: `${SCHEMA}
|
|
722
|
+
|
|
723
|
+
-- Supabase Queues. Messages are {payload, max_attempts, dedupe_key?, last_error?};
|
|
724
|
+
-- pgmq's read_ct is the attempt number and vt the lease.
|
|
725
|
+
create extension if not exists pgmq;
|
|
726
|
+
|
|
727
|
+
-- Functions of the earlier table-based queue (better_supabase.jobs is left in place).
|
|
728
|
+
drop function if exists better_supabase.enqueue_job(text, jsonb, timestamptz, integer, text, integer);
|
|
729
|
+
drop function if exists better_supabase.claim_jobs(text, text, integer, interval);
|
|
730
|
+
drop function if exists better_supabase.complete_job(bigint, text);
|
|
731
|
+
drop function if exists better_supabase.fail_job(bigint, text, text, interval);
|
|
732
|
+
drop function if exists better_supabase.extend_job_lease(bigint, text, interval);
|
|
733
|
+
|
|
734
|
+
-- Deduplicated enqueues look the key up under an advisory lock, so each
|
|
735
|
+
-- queue table gets a partial index on it.
|
|
736
|
+
create or replace function better_supabase.index_job_queue(queue text)
|
|
737
|
+
returns void
|
|
738
|
+
language plpgsql
|
|
739
|
+
security definer
|
|
740
|
+
set search_path = ''
|
|
741
|
+
as $$
|
|
742
|
+
begin
|
|
743
|
+
execute format(
|
|
744
|
+
'create index if not exists %I on pgmq.%I ((message ->> ''dedupe_key'')) where message ? ''dedupe_key''',
|
|
745
|
+
'q_' || queue || '_dedupe_idx',
|
|
746
|
+
'q_' || queue
|
|
747
|
+
);
|
|
748
|
+
end;
|
|
749
|
+
$$;
|
|
750
|
+
|
|
751
|
+
-- Queue names: lowercase letters, digits and underscores (pgmq's rule).
|
|
752
|
+
create or replace function better_supabase.ensure_job_queue(queue text)
|
|
753
|
+
returns void
|
|
754
|
+
language plpgsql
|
|
755
|
+
security definer
|
|
756
|
+
set search_path = ''
|
|
757
|
+
as $$
|
|
758
|
+
begin
|
|
759
|
+
if not exists (select 1 from pgmq.list_queues() q where q.queue_name = queue) then
|
|
760
|
+
perform pgmq.create(queue);
|
|
761
|
+
perform better_supabase.index_job_queue(queue);
|
|
762
|
+
end if;
|
|
763
|
+
end;
|
|
764
|
+
$$;
|
|
765
|
+
|
|
766
|
+
select better_supabase.index_job_queue(q.queue_name) from pgmq.list_queues() q;
|
|
767
|
+
|
|
768
|
+
-- While a message with dedupe_key is waiting or running, enqueueing again returns its id.
|
|
769
|
+
create or replace function better_supabase.enqueue_job(
|
|
770
|
+
queue text,
|
|
771
|
+
payload jsonb default '{}',
|
|
772
|
+
delay integer default 0,
|
|
773
|
+
max_attempts integer default 5,
|
|
774
|
+
dedupe_key text default null
|
|
775
|
+
)
|
|
776
|
+
returns bigint
|
|
777
|
+
language plpgsql
|
|
778
|
+
security definer
|
|
779
|
+
set search_path = ''
|
|
780
|
+
as $$
|
|
781
|
+
declare
|
|
782
|
+
existing bigint;
|
|
783
|
+
begin
|
|
784
|
+
perform better_supabase.ensure_job_queue(queue);
|
|
785
|
+
if dedupe_key is not null then
|
|
786
|
+
perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(queue), pg_catalog.hashtext(dedupe_key));
|
|
787
|
+
execute format('select msg_id from pgmq.%I where message ? ''dedupe_key'' and message ->> ''dedupe_key'' = $1 limit 1', 'q_' || queue)
|
|
788
|
+
into existing using dedupe_key;
|
|
789
|
+
if existing is not null then
|
|
790
|
+
return existing;
|
|
791
|
+
end if;
|
|
792
|
+
end if;
|
|
793
|
+
return (
|
|
794
|
+
select pgmq.send(
|
|
795
|
+
queue,
|
|
796
|
+
jsonb_build_object('payload', payload, 'max_attempts', max_attempts)
|
|
797
|
+
|| case when dedupe_key is null then '{}'::jsonb else jsonb_build_object('dedupe_key', dedupe_key) end,
|
|
798
|
+
greatest(delay, 0)
|
|
799
|
+
)
|
|
800
|
+
);
|
|
801
|
+
end;
|
|
802
|
+
$$;
|
|
803
|
+
|
|
804
|
+
create or replace function better_supabase.claim_jobs(queue text, lease integer default 300, batch integer default 1)
|
|
805
|
+
returns table (id bigint, attempts integer, enqueued_at timestamptz, visible_until timestamptz, message jsonb)
|
|
806
|
+
language plpgsql
|
|
807
|
+
security definer
|
|
808
|
+
set search_path = ''
|
|
809
|
+
as $$
|
|
810
|
+
begin
|
|
811
|
+
perform better_supabase.ensure_job_queue(queue);
|
|
812
|
+
return query
|
|
813
|
+
select r.msg_id, r.read_ct, r.enqueued_at, r.vt, r.message
|
|
814
|
+
from pgmq.read(queue, lease, batch) r;
|
|
815
|
+
end;
|
|
816
|
+
$$;
|
|
817
|
+
|
|
818
|
+
-- Each claim bumps read_ct, so a stale worker (its lease expired and another
|
|
819
|
+
-- worker claimed the message) no longer matches and gets false / null.
|
|
820
|
+
create or replace function better_supabase.complete_job(queue text, job_id bigint, attempt integer)
|
|
821
|
+
returns boolean
|
|
822
|
+
language plpgsql
|
|
823
|
+
security definer
|
|
824
|
+
set search_path = ''
|
|
825
|
+
as $$
|
|
826
|
+
declare
|
|
827
|
+
hit bigint;
|
|
828
|
+
begin
|
|
829
|
+
execute format('select msg_id from pgmq.%I where msg_id = $1 and read_ct = $2 for update', 'q_' || queue)
|
|
830
|
+
into hit using job_id, attempt;
|
|
831
|
+
if hit is null then
|
|
832
|
+
return false;
|
|
833
|
+
end if;
|
|
834
|
+
return pgmq.archive(queue, job_id);
|
|
835
|
+
end;
|
|
836
|
+
$$;
|
|
837
|
+
|
|
838
|
+
-- Retries with exponential backoff (10s, 20s, 40s, ... at most an hour) until
|
|
839
|
+
-- max_attempts, then archives the message with dead = true.
|
|
840
|
+
create or replace function better_supabase.fail_job(
|
|
841
|
+
queue text,
|
|
842
|
+
job_id bigint,
|
|
843
|
+
attempt integer,
|
|
844
|
+
error text,
|
|
845
|
+
retry_in integer default null
|
|
846
|
+
)
|
|
847
|
+
returns text
|
|
848
|
+
language plpgsql
|
|
849
|
+
security definer
|
|
850
|
+
set search_path = ''
|
|
851
|
+
as $$
|
|
852
|
+
declare
|
|
853
|
+
msg jsonb;
|
|
854
|
+
begin
|
|
855
|
+
execute format('select message from pgmq.%I where msg_id = $1 and read_ct = $2 for update', 'q_' || queue)
|
|
856
|
+
into msg using job_id, attempt;
|
|
857
|
+
if msg is null then
|
|
858
|
+
return null;
|
|
859
|
+
end if;
|
|
860
|
+
if attempt >= coalesce((msg ->> 'max_attempts')::integer, 5) then
|
|
861
|
+
execute format('update pgmq.%I set message = message || jsonb_build_object(''last_error'', $2::text, ''dead'', true) where msg_id = $1', 'q_' || queue)
|
|
862
|
+
using job_id, left(error, 4000);
|
|
863
|
+
perform pgmq.archive(queue, job_id);
|
|
864
|
+
return 'dead';
|
|
865
|
+
end if;
|
|
866
|
+
execute format('update pgmq.%I set message = message || jsonb_build_object(''last_error'', $2::text), vt = clock_timestamp() + make_interval(secs => $3) where msg_id = $1', 'q_' || queue)
|
|
867
|
+
using job_id, left(error, 4000), coalesce(retry_in, least(3600, 10 * power(2, attempt - 1)::integer));
|
|
868
|
+
return 'queued';
|
|
869
|
+
end;
|
|
870
|
+
$$;
|
|
871
|
+
|
|
872
|
+
create or replace function better_supabase.extend_job_lease(queue text, job_id bigint, attempt integer, lease integer)
|
|
873
|
+
returns boolean
|
|
874
|
+
language plpgsql
|
|
875
|
+
security definer
|
|
876
|
+
set search_path = ''
|
|
877
|
+
as $$
|
|
878
|
+
declare
|
|
879
|
+
hit bigint;
|
|
880
|
+
begin
|
|
881
|
+
execute format('update pgmq.%I set vt = clock_timestamp() + make_interval(secs => $3) where msg_id = $1 and read_ct = $2 returning msg_id', 'q_' || queue)
|
|
882
|
+
into hit using job_id, attempt, lease;
|
|
883
|
+
return hit is not null;
|
|
884
|
+
end;
|
|
885
|
+
$$;
|
|
886
|
+
|
|
887
|
+
-- pgmq keeps completed and dead messages in pgmq.a_<queue>. Deletes up to
|
|
888
|
+
-- batch of them archived longer than older_than.
|
|
889
|
+
-- Nightly with pg_cron: select cron.schedule('purge-emails-archive', '45 3 * * *', $$select better_supabase.purge_job_archive('emails')$$);
|
|
890
|
+
create or replace function better_supabase.purge_job_archive(
|
|
891
|
+
queue text,
|
|
892
|
+
older_than interval default '7 days',
|
|
893
|
+
batch integer default 10000
|
|
894
|
+
)
|
|
895
|
+
returns integer
|
|
896
|
+
language plpgsql
|
|
897
|
+
security definer
|
|
898
|
+
set search_path = ''
|
|
899
|
+
as $$
|
|
900
|
+
declare
|
|
901
|
+
purged integer;
|
|
902
|
+
begin
|
|
903
|
+
execute format(
|
|
904
|
+
'with purged as (delete from pgmq.%1$I where msg_id in (select a.msg_id from pgmq.%1$I a where a.archived_at < now() - $1 order by a.msg_id limit $2) returning 1) select count(*)::integer from purged',
|
|
905
|
+
'a_' || queue
|
|
906
|
+
) into purged using older_than, batch;
|
|
907
|
+
return purged;
|
|
908
|
+
end;
|
|
909
|
+
$$;
|
|
910
|
+
|
|
911
|
+
-- Recurring jobs with pg_cron: select better_supabase.schedule_job('nightly-digest', '0 3 * * *', 'emails', '{"kind": "digest"}');
|
|
912
|
+
create or replace function better_supabase.schedule_job(job_name text, schedule text, queue text, payload jsonb default '{}')
|
|
913
|
+
returns bigint
|
|
914
|
+
language plpgsql
|
|
915
|
+
security definer
|
|
916
|
+
set search_path = ''
|
|
917
|
+
as $$
|
|
918
|
+
begin
|
|
919
|
+
if pg_catalog.to_regnamespace('cron') is null then
|
|
920
|
+
raise exception 'schedule_job needs pg_cron: create extension pg_cron with schema pg_catalog';
|
|
921
|
+
end if;
|
|
922
|
+
perform better_supabase.ensure_job_queue(queue);
|
|
923
|
+
return cron.schedule(job_name, schedule, format('select better_supabase.enqueue_job(%L, %L::jsonb)', queue, payload::text));
|
|
924
|
+
end;
|
|
925
|
+
$$;
|
|
926
|
+
|
|
927
|
+
create or replace function better_supabase.unschedule_job(job_name text)
|
|
928
|
+
returns boolean
|
|
929
|
+
language plpgsql
|
|
930
|
+
security definer
|
|
931
|
+
set search_path = ''
|
|
932
|
+
as $$
|
|
933
|
+
begin
|
|
934
|
+
if pg_catalog.to_regnamespace('cron') is null then
|
|
935
|
+
return false;
|
|
936
|
+
end if;
|
|
937
|
+
return cron.unschedule(job_name);
|
|
938
|
+
end;
|
|
939
|
+
$$;
|
|
940
|
+
|
|
941
|
+
do $$
|
|
942
|
+
declare
|
|
943
|
+
fn text;
|
|
944
|
+
begin
|
|
945
|
+
foreach fn in array array[
|
|
946
|
+
'index_job_queue(text)',
|
|
947
|
+
'ensure_job_queue(text)',
|
|
948
|
+
'enqueue_job(text, jsonb, integer, integer, text)',
|
|
949
|
+
'claim_jobs(text, integer, integer)',
|
|
950
|
+
'complete_job(text, bigint, integer)',
|
|
951
|
+
'fail_job(text, bigint, integer, text, integer)',
|
|
952
|
+
'extend_job_lease(text, bigint, integer, integer)',
|
|
953
|
+
'schedule_job(text, text, text, jsonb)',
|
|
954
|
+
'unschedule_job(text)',
|
|
955
|
+
'purge_job_archive(text, interval, integer)'
|
|
956
|
+
] loop
|
|
957
|
+
execute format('revoke execute on function better_supabase.%s from public, anon, authenticated', fn);
|
|
958
|
+
execute format('grant execute on function better_supabase.%s to service_role', fn);
|
|
959
|
+
end loop;
|
|
960
|
+
end;
|
|
961
|
+
$$;`
|
|
962
|
+
};
|
|
963
|
+
const IDEMPOTENCY = {
|
|
964
|
+
name: "idempotency",
|
|
965
|
+
title: "Idempotency keys",
|
|
966
|
+
description: "Stores responses by Idempotency-Key so retried requests replay the first result instead of running twice.",
|
|
967
|
+
requires: [],
|
|
968
|
+
target: "schema",
|
|
969
|
+
sql: `${SCHEMA}
|
|
970
|
+
|
|
971
|
+
create table if not exists better_supabase.idempotency_keys (
|
|
972
|
+
scope text not null default '',
|
|
973
|
+
key text not null,
|
|
974
|
+
request_hash text not null,
|
|
975
|
+
status text not null default 'running' check (status in ('running', 'completed')),
|
|
976
|
+
status_code integer,
|
|
977
|
+
response jsonb,
|
|
978
|
+
locked_until timestamptz,
|
|
979
|
+
created_at timestamptz not null default now(),
|
|
980
|
+
expires_at timestamptz not null,
|
|
981
|
+
primary key (scope, key)
|
|
982
|
+
);
|
|
983
|
+
create index if not exists idempotency_keys_expiry_idx on better_supabase.idempotency_keys (expires_at);
|
|
984
|
+
|
|
985
|
+
alter table better_supabase.idempotency_keys enable row level security;
|
|
986
|
+
revoke all on better_supabase.idempotency_keys from anon, authenticated;
|
|
987
|
+
grant all on better_supabase.idempotency_keys to service_role;
|
|
988
|
+
|
|
989
|
+
-- state: 'started' (run it), 'replay' (send the stored response),
|
|
990
|
+
-- 'running' (another request holds the key) or 'mismatch' (same key, different request).
|
|
991
|
+
create or replace function better_supabase.begin_idempotent(
|
|
992
|
+
scope text,
|
|
993
|
+
key text,
|
|
994
|
+
request_hash text,
|
|
995
|
+
ttl interval default '24 hours',
|
|
996
|
+
lock interval default '1 minute'
|
|
997
|
+
)
|
|
998
|
+
returns table (state text, status_code integer, response jsonb)
|
|
999
|
+
language plpgsql
|
|
1000
|
+
set search_path = ''
|
|
1001
|
+
as $$
|
|
1002
|
+
#variable_conflict use_column
|
|
1003
|
+
declare
|
|
1004
|
+
existing better_supabase.idempotency_keys;
|
|
1005
|
+
begin
|
|
1006
|
+
delete from better_supabase.idempotency_keys k
|
|
1007
|
+
where k.scope = begin_idempotent.scope and k.key = begin_idempotent.key and k.expires_at < now();
|
|
1008
|
+
|
|
1009
|
+
insert into better_supabase.idempotency_keys (scope, key, request_hash, locked_until, expires_at)
|
|
1010
|
+
values (scope, key, request_hash, now() + lock, now() + ttl)
|
|
1011
|
+
on conflict on constraint idempotency_keys_pkey do nothing;
|
|
1012
|
+
if found then
|
|
1013
|
+
return query select 'started'::text, null::integer, null::jsonb;
|
|
1014
|
+
return;
|
|
1015
|
+
end if;
|
|
1016
|
+
|
|
1017
|
+
select * into existing from better_supabase.idempotency_keys k
|
|
1018
|
+
where k.scope = begin_idempotent.scope and k.key = begin_idempotent.key
|
|
1019
|
+
for update;
|
|
1020
|
+
if existing.request_hash <> request_hash then
|
|
1021
|
+
return query select 'mismatch'::text, null::integer, null::jsonb;
|
|
1022
|
+
elsif existing.status = 'completed' then
|
|
1023
|
+
return query select 'replay'::text, existing.status_code, existing.response;
|
|
1024
|
+
elsif existing.locked_until < now() then
|
|
1025
|
+
update better_supabase.idempotency_keys k set locked_until = now() + lock
|
|
1026
|
+
where k.scope = begin_idempotent.scope and k.key = begin_idempotent.key;
|
|
1027
|
+
return query select 'started'::text, null::integer, null::jsonb;
|
|
1028
|
+
else
|
|
1029
|
+
return query select 'running'::text, null::integer, null::jsonb;
|
|
1030
|
+
end if;
|
|
1031
|
+
end;
|
|
1032
|
+
$$;
|
|
1033
|
+
|
|
1034
|
+
create or replace function better_supabase.complete_idempotent(
|
|
1035
|
+
scope text,
|
|
1036
|
+
key text,
|
|
1037
|
+
status_code integer,
|
|
1038
|
+
response jsonb
|
|
1039
|
+
)
|
|
1040
|
+
returns void
|
|
1041
|
+
language sql
|
|
1042
|
+
set search_path = ''
|
|
1043
|
+
as $$
|
|
1044
|
+
update better_supabase.idempotency_keys k
|
|
1045
|
+
set status = 'completed', status_code = complete_idempotent.status_code,
|
|
1046
|
+
response = complete_idempotent.response, locked_until = null
|
|
1047
|
+
where k.scope = complete_idempotent.scope and k.key = complete_idempotent.key
|
|
1048
|
+
$$;
|
|
1049
|
+
|
|
1050
|
+
-- On failure: forget the key so the client can retry.
|
|
1051
|
+
create or replace function better_supabase.release_idempotent(scope text, key text)
|
|
1052
|
+
returns void
|
|
1053
|
+
language sql
|
|
1054
|
+
set search_path = ''
|
|
1055
|
+
as $$
|
|
1056
|
+
delete from better_supabase.idempotency_keys k
|
|
1057
|
+
where k.scope = release_idempotent.scope and k.key = release_idempotent.key and k.status = 'running'
|
|
1058
|
+
$$;
|
|
1059
|
+
|
|
1060
|
+
create or replace function better_supabase.purge_idempotency_keys()
|
|
1061
|
+
returns integer
|
|
1062
|
+
language sql
|
|
1063
|
+
set search_path = ''
|
|
1064
|
+
as $$
|
|
1065
|
+
with purged as (
|
|
1066
|
+
delete from better_supabase.idempotency_keys where expires_at < now() returning 1
|
|
1067
|
+
)
|
|
1068
|
+
select count(*)::integer from purged
|
|
1069
|
+
$$;
|
|
1070
|
+
|
|
1071
|
+
do $$
|
|
1072
|
+
declare
|
|
1073
|
+
fn text;
|
|
1074
|
+
begin
|
|
1075
|
+
foreach fn in array array[
|
|
1076
|
+
'begin_idempotent(text, text, text, interval, interval)',
|
|
1077
|
+
'complete_idempotent(text, text, integer, jsonb)',
|
|
1078
|
+
'release_idempotent(text, text)',
|
|
1079
|
+
'purge_idempotency_keys()'
|
|
1080
|
+
] loop
|
|
1081
|
+
execute format('revoke execute on function better_supabase.%s from public, anon, authenticated', fn);
|
|
1082
|
+
execute format('grant execute on function better_supabase.%s to service_role', fn);
|
|
1083
|
+
end loop;
|
|
1084
|
+
end;
|
|
1085
|
+
$$;`
|
|
1086
|
+
};
|
|
1087
|
+
const WEBHOOK_INBOX = {
|
|
1088
|
+
name: "webhook-inbox",
|
|
1089
|
+
title: "Webhook inbox",
|
|
1090
|
+
description: "Stores verified webhooks once per message id, then processes them with leases and retries.",
|
|
1091
|
+
requires: [],
|
|
1092
|
+
target: "schema",
|
|
1093
|
+
sql: `${SCHEMA}
|
|
1094
|
+
|
|
1095
|
+
create table if not exists better_supabase.webhook_inbox (
|
|
1096
|
+
id bigint generated always as identity primary key,
|
|
1097
|
+
source text not null,
|
|
1098
|
+
message_id text not null,
|
|
1099
|
+
event_type text,
|
|
1100
|
+
payload jsonb not null,
|
|
1101
|
+
headers jsonb not null default '{}',
|
|
1102
|
+
status text not null default 'pending'
|
|
1103
|
+
check (status in ('pending', 'processing', 'processed', 'dead')),
|
|
1104
|
+
attempts integer not null default 0,
|
|
1105
|
+
max_attempts integer not null default 8,
|
|
1106
|
+
available_at timestamptz not null default now(),
|
|
1107
|
+
locked_by text,
|
|
1108
|
+
locked_until timestamptz,
|
|
1109
|
+
last_error text,
|
|
1110
|
+
received_at timestamptz not null default now(),
|
|
1111
|
+
processed_at timestamptz,
|
|
1112
|
+
unique (source, message_id)
|
|
1113
|
+
);
|
|
1114
|
+
create index if not exists webhook_inbox_ready_idx
|
|
1115
|
+
on better_supabase.webhook_inbox (source, available_at, id) where status in ('pending', 'processing');
|
|
1116
|
+
|
|
1117
|
+
alter table better_supabase.webhook_inbox enable row level security;
|
|
1118
|
+
revoke all on better_supabase.webhook_inbox from anon, authenticated;
|
|
1119
|
+
grant all on better_supabase.webhook_inbox to service_role;
|
|
1120
|
+
|
|
1121
|
+
-- duplicate = true when the message id was seen before (the sender retried).
|
|
1122
|
+
create or replace function better_supabase.receive_webhook(
|
|
1123
|
+
source text,
|
|
1124
|
+
message_id text,
|
|
1125
|
+
event_type text,
|
|
1126
|
+
payload jsonb,
|
|
1127
|
+
headers jsonb default '{}'
|
|
1128
|
+
)
|
|
1129
|
+
returns table (id bigint, duplicate boolean)
|
|
1130
|
+
language plpgsql
|
|
1131
|
+
set search_path = ''
|
|
1132
|
+
as $$
|
|
1133
|
+
#variable_conflict use_column
|
|
1134
|
+
declare
|
|
1135
|
+
inbox_id bigint;
|
|
1136
|
+
begin
|
|
1137
|
+
insert into better_supabase.webhook_inbox (source, message_id, event_type, payload, headers)
|
|
1138
|
+
values (source, message_id, event_type, payload, headers)
|
|
1139
|
+
on conflict on constraint webhook_inbox_source_message_id_key do nothing
|
|
1140
|
+
returning webhook_inbox.id into inbox_id;
|
|
1141
|
+
if inbox_id is not null then
|
|
1142
|
+
return query select inbox_id, false;
|
|
1143
|
+
return;
|
|
1144
|
+
end if;
|
|
1145
|
+
return query
|
|
1146
|
+
select w.id, true from better_supabase.webhook_inbox w
|
|
1147
|
+
where w.source = receive_webhook.source and w.message_id = receive_webhook.message_id;
|
|
1148
|
+
end;
|
|
1149
|
+
$$;
|
|
1150
|
+
|
|
1151
|
+
create or replace function better_supabase.claim_webhooks(
|
|
1152
|
+
source text,
|
|
1153
|
+
worker text,
|
|
1154
|
+
batch integer default 10,
|
|
1155
|
+
lease interval default '5 minutes'
|
|
1156
|
+
)
|
|
1157
|
+
returns setof better_supabase.webhook_inbox
|
|
1158
|
+
language sql
|
|
1159
|
+
set search_path = ''
|
|
1160
|
+
as $$
|
|
1161
|
+
with next as materialized (
|
|
1162
|
+
select c.id from better_supabase.webhook_inbox c
|
|
1163
|
+
where c.source = claim_webhooks.source
|
|
1164
|
+
and (
|
|
1165
|
+
(c.status = 'pending' and c.available_at <= now())
|
|
1166
|
+
or (c.status = 'processing' and c.locked_until < now())
|
|
1167
|
+
)
|
|
1168
|
+
order by c.available_at, c.id
|
|
1169
|
+
for update skip locked
|
|
1170
|
+
limit batch
|
|
1171
|
+
)
|
|
1172
|
+
update better_supabase.webhook_inbox w
|
|
1173
|
+
set status = 'processing', attempts = w.attempts + 1, locked_by = worker, locked_until = now() + lease
|
|
1174
|
+
from next
|
|
1175
|
+
where w.id = next.id
|
|
1176
|
+
returning w.*
|
|
1177
|
+
$$;
|
|
1178
|
+
|
|
1179
|
+
create or replace function better_supabase.complete_webhook(inbox_id bigint, worker text)
|
|
1180
|
+
returns boolean
|
|
1181
|
+
language sql
|
|
1182
|
+
set search_path = ''
|
|
1183
|
+
as $$
|
|
1184
|
+
with done as (
|
|
1185
|
+
update better_supabase.webhook_inbox
|
|
1186
|
+
set status = 'processed', processed_at = now(), locked_by = null, locked_until = null, last_error = null
|
|
1187
|
+
where id = inbox_id and locked_by = worker and status = 'processing'
|
|
1188
|
+
returning 1
|
|
1189
|
+
)
|
|
1190
|
+
select exists (select 1 from done)
|
|
1191
|
+
$$;
|
|
1192
|
+
|
|
1193
|
+
create or replace function better_supabase.fail_webhook(
|
|
1194
|
+
inbox_id bigint,
|
|
1195
|
+
worker text,
|
|
1196
|
+
error text,
|
|
1197
|
+
retry_in interval default null
|
|
1198
|
+
)
|
|
1199
|
+
returns text
|
|
1200
|
+
language sql
|
|
1201
|
+
set search_path = ''
|
|
1202
|
+
as $$
|
|
1203
|
+
update better_supabase.webhook_inbox
|
|
1204
|
+
set status = case when attempts >= max_attempts then 'dead' else 'pending' end,
|
|
1205
|
+
available_at = now() + coalesce(retry_in, least(interval '1 hour', interval '1 second' * power(2, attempts))),
|
|
1206
|
+
last_error = left(error, 4000),
|
|
1207
|
+
locked_by = null,
|
|
1208
|
+
locked_until = null
|
|
1209
|
+
where id = inbox_id and locked_by = worker and status = 'processing'
|
|
1210
|
+
returning status
|
|
1211
|
+
$$;
|
|
1212
|
+
|
|
1213
|
+
-- Deletes up to batch processed messages (and dead ones with include_dead)
|
|
1214
|
+
-- older than older_than. A sender that retries a purged message id gets it
|
|
1215
|
+
-- stored again, so keep older_than above the sender's retry window.
|
|
1216
|
+
-- Nightly with pg_cron: select cron.schedule('purge-webhooks', '30 3 * * *', 'select better_supabase.purge_webhooks()');
|
|
1217
|
+
create or replace function better_supabase.purge_webhooks(
|
|
1218
|
+
older_than interval default '30 days',
|
|
1219
|
+
include_dead boolean default false,
|
|
1220
|
+
batch integer default 10000
|
|
1221
|
+
)
|
|
1222
|
+
returns integer
|
|
1223
|
+
language sql
|
|
1224
|
+
set search_path = ''
|
|
1225
|
+
as $$
|
|
1226
|
+
with purged as (
|
|
1227
|
+
delete from better_supabase.webhook_inbox
|
|
1228
|
+
where id in (
|
|
1229
|
+
select w.id from better_supabase.webhook_inbox w
|
|
1230
|
+
where (w.status = 'processed' and w.processed_at < now() - older_than)
|
|
1231
|
+
or (include_dead and w.status = 'dead' and w.received_at < now() - older_than)
|
|
1232
|
+
order by w.id
|
|
1233
|
+
limit batch
|
|
1234
|
+
)
|
|
1235
|
+
returning 1
|
|
1236
|
+
)
|
|
1237
|
+
select count(*)::integer from purged
|
|
1238
|
+
$$;
|
|
1239
|
+
|
|
1240
|
+
do $$
|
|
1241
|
+
declare
|
|
1242
|
+
fn text;
|
|
1243
|
+
begin
|
|
1244
|
+
foreach fn in array array[
|
|
1245
|
+
'receive_webhook(text, text, text, jsonb, jsonb)',
|
|
1246
|
+
'claim_webhooks(text, text, integer, interval)',
|
|
1247
|
+
'complete_webhook(bigint, text)',
|
|
1248
|
+
'fail_webhook(bigint, text, text, interval)',
|
|
1249
|
+
'purge_webhooks(interval, boolean, integer)'
|
|
1250
|
+
] loop
|
|
1251
|
+
execute format('revoke execute on function better_supabase.%s from public, anon, authenticated', fn);
|
|
1252
|
+
execute format('grant execute on function better_supabase.%s to service_role', fn);
|
|
1253
|
+
end loop;
|
|
1254
|
+
end;
|
|
1255
|
+
$$;`
|
|
1256
|
+
};
|
|
1257
|
+
const realtimeTablesSql = (claims) => `${SCHEMA}
|
|
1258
|
+
|
|
1259
|
+
-- Clients refetch through RLS, so the payload carries no row data.
|
|
1260
|
+
create or replace function better_supabase.broadcast_changes()
|
|
1261
|
+
returns trigger
|
|
1262
|
+
language plpgsql
|
|
1263
|
+
security definer
|
|
1264
|
+
set search_path = ''
|
|
1265
|
+
as $$
|
|
1266
|
+
declare
|
|
1267
|
+
base text := 'bs:t:' || tg_table_schema || '.' || tg_table_name;
|
|
1268
|
+
payload jsonb := jsonb_build_object('schema', tg_table_schema, 'table', tg_table_name, 'operation', tg_op);
|
|
1269
|
+
source text;
|
|
1270
|
+
tenant text;
|
|
1271
|
+
begin
|
|
1272
|
+
if tg_nargs = 0 or tg_argv[0] = '' then
|
|
1273
|
+
perform realtime.send(payload, 'change', base, true);
|
|
1274
|
+
return null;
|
|
1275
|
+
end if;
|
|
1276
|
+
source := case tg_op
|
|
1277
|
+
when 'INSERT' then format('select %1$I from new_rows', tg_argv[0])
|
|
1278
|
+
when 'DELETE' then format('select %1$I from old_rows', tg_argv[0])
|
|
1279
|
+
else format('select %1$I from new_rows union select %1$I from old_rows', tg_argv[0])
|
|
1280
|
+
end;
|
|
1281
|
+
for tenant in execute format('select distinct t.v::text from (%s) as t(v) where t.v is not null', source) loop
|
|
1282
|
+
perform realtime.send(payload, 'change', base || ':' || tenant, true);
|
|
1283
|
+
end loop;
|
|
1284
|
+
return null;
|
|
1285
|
+
end;
|
|
1286
|
+
$$;
|
|
1287
|
+
|
|
1288
|
+
-- select better_supabase.track_realtime('public.customers', 'organization_id');
|
|
1289
|
+
-- A tenant column the table lacks is ignored: the table broadcasts on one topic.
|
|
1290
|
+
create or replace function better_supabase.track_realtime(target regclass, tenant_column text default null)
|
|
1291
|
+
returns void
|
|
1292
|
+
language plpgsql
|
|
1293
|
+
set search_path = ''
|
|
1294
|
+
as $$
|
|
1295
|
+
begin
|
|
1296
|
+
if tenant_column is not null and not exists (
|
|
1297
|
+
select 1 from pg_catalog.pg_attribute a
|
|
1298
|
+
where a.attrelid = target and a.attname = tenant_column and a.attnum > 0 and not a.attisdropped
|
|
1299
|
+
) then
|
|
1300
|
+
tenant_column := null;
|
|
1301
|
+
end if;
|
|
1302
|
+
execute format('drop trigger if exists bs_realtime on %s', target);
|
|
1303
|
+
execute format('drop trigger if exists bs_realtime_insert on %s', target);
|
|
1304
|
+
execute format('drop trigger if exists bs_realtime_update on %s', target);
|
|
1305
|
+
execute format('drop trigger if exists bs_realtime_delete on %s', target);
|
|
1306
|
+
if tenant_column is null then
|
|
1307
|
+
execute format(
|
|
1308
|
+
'create trigger bs_realtime after insert or update or delete on %s for each statement execute function better_supabase.broadcast_changes()',
|
|
1309
|
+
target
|
|
1310
|
+
);
|
|
1311
|
+
return;
|
|
1312
|
+
end if;
|
|
1313
|
+
execute format(
|
|
1314
|
+
'create trigger bs_realtime_insert after insert on %s referencing new table as new_rows for each statement execute function better_supabase.broadcast_changes(%L)',
|
|
1315
|
+
target, tenant_column
|
|
1316
|
+
);
|
|
1317
|
+
execute format(
|
|
1318
|
+
'create trigger bs_realtime_update after update on %s referencing old table as old_rows new table as new_rows for each statement execute function better_supabase.broadcast_changes(%L)',
|
|
1319
|
+
target, tenant_column
|
|
1320
|
+
);
|
|
1321
|
+
execute format(
|
|
1322
|
+
'create trigger bs_realtime_delete after delete on %s referencing old table as old_rows for each statement execute function better_supabase.broadcast_changes(%L)',
|
|
1323
|
+
target, tenant_column
|
|
1324
|
+
);
|
|
1325
|
+
end;
|
|
1326
|
+
$$;
|
|
1327
|
+
|
|
1328
|
+
create or replace function better_supabase.untrack_realtime(target regclass)
|
|
1329
|
+
returns void
|
|
1330
|
+
language plpgsql
|
|
1331
|
+
set search_path = ''
|
|
1332
|
+
as $$
|
|
1333
|
+
begin
|
|
1334
|
+
execute format('drop trigger if exists bs_realtime on %s', target);
|
|
1335
|
+
execute format('drop trigger if exists bs_realtime_insert on %s', target);
|
|
1336
|
+
execute format('drop trigger if exists bs_realtime_update on %s', target);
|
|
1337
|
+
execute format('drop trigger if exists bs_realtime_delete on %s', target);
|
|
1338
|
+
end;
|
|
1339
|
+
$$;
|
|
1340
|
+
|
|
1341
|
+
revoke execute on function better_supabase.track_realtime(regclass, text) from public, anon, authenticated;
|
|
1342
|
+
revoke execute on function better_supabase.untrack_realtime(regclass) from public, anon, authenticated;
|
|
1343
|
+
|
|
1344
|
+
-- Signed-in users receive unscoped topics, and topics of their active tenant.
|
|
1345
|
+
drop policy if exists bs_realtime_tables_receive on realtime.messages;
|
|
1346
|
+
create policy bs_realtime_tables_receive on realtime.messages for select to authenticated
|
|
1347
|
+
using (
|
|
1348
|
+
realtime.messages.extension = 'broadcast'
|
|
1349
|
+
and (select realtime.topic()) like 'bs:t:%'
|
|
1350
|
+
and (
|
|
1351
|
+
split_part((select realtime.topic()), ':', 4) = ''
|
|
1352
|
+
or split_part((select realtime.topic()), ':', 4) = coalesce(
|
|
1353
|
+
(select auth.jwt()) ->> ${sqlString(claims.tenant)},
|
|
1354
|
+
(select auth.jwt()) -> 'app_metadata' ->> ${sqlString(claims.tenant)},
|
|
1355
|
+
''
|
|
1356
|
+
)
|
|
1357
|
+
)
|
|
1358
|
+
);`;
|
|
1359
|
+
const REALTIME_TABLES = {
|
|
1360
|
+
name: "realtime-tables",
|
|
1361
|
+
title: "Realtime table changes",
|
|
1362
|
+
description: "Broadcasts a change signal (no row data) once per statement on bs:t:<schema>.<table>[:<tenant>] for live queries.",
|
|
1363
|
+
requires: [],
|
|
1364
|
+
target: "schema",
|
|
1365
|
+
sql: realtimeTablesSql(DEFAULT_CLAIMS),
|
|
1366
|
+
render: realtimeTablesSql
|
|
1367
|
+
};
|
|
1368
|
+
const JSONB_SCHEMAS = {
|
|
1369
|
+
name: "jsonb-schemas",
|
|
1370
|
+
title: "JSON Schema checks for jsonb",
|
|
1371
|
+
description: "pg_jsonschema check constraints for jsonb columns with a `schema` in the `json` config, so the database enforces the same shape as the types.",
|
|
1372
|
+
requires: [],
|
|
1373
|
+
target: "schema",
|
|
1374
|
+
sql: `create extension if not exists pg_jsonschema with schema extensions;`
|
|
1375
|
+
};
|
|
1376
|
+
const PGTAP = {
|
|
1377
|
+
name: "pgtap",
|
|
1378
|
+
title: "pgTAP helpers",
|
|
1379
|
+
description: "tests.create_user, tests.authenticate_as and tests.rls_enabled for `supabase test db`. Written to supabase/tests, never to your schema.",
|
|
1380
|
+
requires: [],
|
|
1381
|
+
target: "test",
|
|
1382
|
+
sql: `-- Runs first (000_) and commits, so later test files can use the helpers.
|
|
1383
|
+
create extension if not exists pgtap with schema extensions;
|
|
1384
|
+
create schema if not exists tests;
|
|
1385
|
+
grant usage on schema tests to anon, authenticated, service_role;
|
|
1386
|
+
|
|
1387
|
+
create or replace function tests.create_user(
|
|
1388
|
+
email text,
|
|
1389
|
+
app_metadata jsonb default '{}',
|
|
1390
|
+
user_metadata jsonb default '{}'
|
|
1391
|
+
)
|
|
1392
|
+
returns uuid
|
|
1393
|
+
language plpgsql
|
|
1394
|
+
security definer
|
|
1395
|
+
set search_path = ''
|
|
1396
|
+
as $$
|
|
1397
|
+
declare
|
|
1398
|
+
user_id uuid := gen_random_uuid();
|
|
1399
|
+
begin
|
|
1400
|
+
insert into auth.users (
|
|
1401
|
+
id, instance_id, aud, role, email, encrypted_password, email_confirmed_at,
|
|
1402
|
+
raw_app_meta_data, raw_user_meta_data, created_at, updated_at
|
|
1403
|
+
) values (
|
|
1404
|
+
user_id, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated',
|
|
1405
|
+
email, '', now(),
|
|
1406
|
+
'{"provider": "email", "providers": ["email"]}'::jsonb || app_metadata,
|
|
1407
|
+
user_metadata, now(), now()
|
|
1408
|
+
);
|
|
1409
|
+
return user_id;
|
|
1410
|
+
end;
|
|
1411
|
+
$$;
|
|
1412
|
+
|
|
1413
|
+
-- Runs the rest of the transaction as this user, like PostgREST does. Extra claims (tenant_id, ...) go in the JWT.
|
|
1414
|
+
create or replace function tests.authenticate_as(user_id uuid, claims jsonb default '{}')
|
|
1415
|
+
returns void
|
|
1416
|
+
language plpgsql
|
|
1417
|
+
as $$
|
|
1418
|
+
declare
|
|
1419
|
+
user_email text;
|
|
1420
|
+
begin
|
|
1421
|
+
select u.email into user_email from auth.users u where u.id = user_id;
|
|
1422
|
+
perform set_config(
|
|
1423
|
+
'request.jwt.claims',
|
|
1424
|
+
(jsonb_build_object('sub', user_id, 'role', 'authenticated', 'email', user_email, 'aud', 'authenticated') || claims)::text,
|
|
1425
|
+
true
|
|
1426
|
+
);
|
|
1427
|
+
set local role authenticated;
|
|
1428
|
+
end;
|
|
1429
|
+
$$;
|
|
1430
|
+
|
|
1431
|
+
create or replace function tests.authenticate_as_anon()
|
|
1432
|
+
returns void
|
|
1433
|
+
language plpgsql
|
|
1434
|
+
as $$
|
|
1435
|
+
begin
|
|
1436
|
+
perform set_config('request.jwt.claims', '{"role": "anon"}', true);
|
|
1437
|
+
set local role anon;
|
|
1438
|
+
end;
|
|
1439
|
+
$$;
|
|
1440
|
+
|
|
1441
|
+
create or replace function tests.clear_authentication()
|
|
1442
|
+
returns void
|
|
1443
|
+
language plpgsql
|
|
1444
|
+
as $$
|
|
1445
|
+
begin
|
|
1446
|
+
perform set_config('request.jwt.claims', '', true);
|
|
1447
|
+
reset role;
|
|
1448
|
+
end;
|
|
1449
|
+
$$;
|
|
1450
|
+
|
|
1451
|
+
-- select tests.rls_enabled('public');
|
|
1452
|
+
create or replace function tests.rls_enabled(schema_name text)
|
|
1453
|
+
returns text
|
|
1454
|
+
language sql
|
|
1455
|
+
set search_path = extensions, pg_catalog
|
|
1456
|
+
as $$
|
|
1457
|
+
select extensions.is(
|
|
1458
|
+
(
|
|
1459
|
+
select coalesce(array_agg(c.relname::text order by c.relname), '{}')
|
|
1460
|
+
from pg_catalog.pg_class c
|
|
1461
|
+
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
|
1462
|
+
where n.nspname = schema_name and c.relkind in ('r', 'p') and not c.relrowsecurity
|
|
1463
|
+
),
|
|
1464
|
+
'{}'::text[],
|
|
1465
|
+
format('every table in %I has row level security enabled', schema_name)
|
|
1466
|
+
)
|
|
1467
|
+
$$;
|
|
1468
|
+
|
|
1469
|
+
grant execute on all functions in schema tests to anon, authenticated, service_role;
|
|
1470
|
+
-- create_user writes auth.users as its definer; anon tests must not mint users.
|
|
1471
|
+
revoke execute on function tests.create_user(text, jsonb, jsonb) from public, anon;
|
|
1472
|
+
grant execute on function tests.create_user(text, jsonb, jsonb) to authenticated, service_role;
|
|
1473
|
+
|
|
1474
|
+
select extensions.plan(1);
|
|
1475
|
+
select extensions.pass('better-supabase test helpers installed');
|
|
1476
|
+
select * from extensions.finish();`
|
|
1477
|
+
};
|
|
1478
|
+
const GRANTS = {
|
|
1479
|
+
name: "grants",
|
|
1480
|
+
title: "Data API grants",
|
|
1481
|
+
description: "Grants the tables in the `expose` config to anon and authenticated. Supabase no longer grants new tables to the Data API roles automatically.",
|
|
1482
|
+
requires: [],
|
|
1483
|
+
target: "schema",
|
|
1484
|
+
sql: `-- List tables in \`expose\` (better-supabase.config.ts); \`sql sync\` rewrites the grants below.
|
|
1485
|
+
-- RLS still decides which rows each role sees; grants decide whether the role reaches the table at all.`
|
|
1486
|
+
};
|
|
1487
|
+
const READ_SETS = {
|
|
1488
|
+
name: "read-sets",
|
|
1489
|
+
title: "Read sets",
|
|
1490
|
+
description: "One `stable` function per `defineReadSet` in `readSets`, so `db.$many(readSet, params)` is a single GET.",
|
|
1491
|
+
requires: [],
|
|
1492
|
+
target: "schema",
|
|
1493
|
+
sql: `-- Functions for the read sets in \`readSets\` (better-supabase.config.ts); \`gen\` and \`sql sync\` rewrite them.
|
|
1494
|
+
-- They are security invoker: RLS decides what each caller reads, as for any other query.`
|
|
1495
|
+
};
|
|
1496
|
+
const RATE_LIMIT = {
|
|
1497
|
+
name: "rate-limit",
|
|
1498
|
+
title: "Write rate limits",
|
|
1499
|
+
description: "Fixed-window limits on Data API writes (POST, PATCH, PUT, DELETE) per user or claim, checked by pgrst.db_pre_request. Over the limit: 429 with Retry-After.",
|
|
1500
|
+
requires: [],
|
|
1501
|
+
target: "schema",
|
|
1502
|
+
sql: `${SCHEMA}
|
|
1503
|
+
|
|
1504
|
+
-- One rule per scope: '*' (every write), a table path ('/customers') or an
|
|
1505
|
+
-- RPC path ('/rpc/send_invite'). key_claim is the JWT claim each caller is
|
|
1506
|
+
-- counted by; callers without it are counted by their first x-forwarded-for hop.
|
|
1507
|
+
create table if not exists better_supabase.rate_limit_rules (
|
|
1508
|
+
scope text primary key,
|
|
1509
|
+
max_requests integer not null check (max_requests > 0),
|
|
1510
|
+
period interval not null check (period > interval '0'),
|
|
1511
|
+
key_claim text not null default 'sub'
|
|
1512
|
+
);
|
|
1513
|
+
|
|
1514
|
+
create unlogged table if not exists better_supabase.rate_limits (
|
|
1515
|
+
scope text not null,
|
|
1516
|
+
key text not null,
|
|
1517
|
+
window_start timestamptz not null,
|
|
1518
|
+
hits integer not null,
|
|
1519
|
+
primary key (scope, key)
|
|
1520
|
+
);
|
|
1521
|
+
|
|
1522
|
+
alter table better_supabase.rate_limit_rules enable row level security;
|
|
1523
|
+
alter table better_supabase.rate_limits enable row level security;
|
|
1524
|
+
revoke all on table better_supabase.rate_limit_rules from anon, authenticated;
|
|
1525
|
+
revoke all on table better_supabase.rate_limits from anon, authenticated;
|
|
1526
|
+
|
|
1527
|
+
-- select better_supabase.set_rate_limit('/rpc/send_invite', 5, interval '1 minute');
|
|
1528
|
+
-- A null max_requests removes the rule.
|
|
1529
|
+
create or replace function better_supabase.set_rate_limit(
|
|
1530
|
+
scope text,
|
|
1531
|
+
max_requests integer,
|
|
1532
|
+
period interval default interval '1 minute',
|
|
1533
|
+
key_claim text default 'sub'
|
|
1534
|
+
)
|
|
1535
|
+
returns void
|
|
1536
|
+
language plpgsql
|
|
1537
|
+
security definer
|
|
1538
|
+
set search_path = ''
|
|
1539
|
+
as $$
|
|
1540
|
+
begin
|
|
1541
|
+
if max_requests is null then
|
|
1542
|
+
delete from better_supabase.rate_limit_rules r where r.scope = set_rate_limit.scope;
|
|
1543
|
+
delete from better_supabase.rate_limits l where l.scope = set_rate_limit.scope;
|
|
1544
|
+
return;
|
|
1545
|
+
end if;
|
|
1546
|
+
insert into better_supabase.rate_limit_rules as r (scope, max_requests, period, key_claim)
|
|
1547
|
+
values (scope, max_requests, period, key_claim)
|
|
1548
|
+
on conflict on constraint rate_limit_rules_pkey do update
|
|
1549
|
+
set max_requests = excluded.max_requests, period = excluded.period, key_claim = excluded.key_claim;
|
|
1550
|
+
end
|
|
1551
|
+
$$;
|
|
1552
|
+
|
|
1553
|
+
revoke execute on function better_supabase.set_rate_limit(text, integer, interval, text) from public, anon, authenticated;
|
|
1554
|
+
grant execute on function better_supabase.set_rate_limit(text, integer, interval, text) to service_role;
|
|
1555
|
+
|
|
1556
|
+
-- PostgREST's pre-request hook. GET and HEAD run read-only (and may be served
|
|
1557
|
+
-- by a replica), so only writes count. The service role is never limited.
|
|
1558
|
+
-- With your own db_pre_request, call it from there: perform better_supabase.check_request();
|
|
1559
|
+
create or replace function better_supabase.check_request()
|
|
1560
|
+
returns void
|
|
1561
|
+
language plpgsql
|
|
1562
|
+
security definer
|
|
1563
|
+
set search_path = ''
|
|
1564
|
+
as $$
|
|
1565
|
+
declare
|
|
1566
|
+
method text := current_setting('request.method', true);
|
|
1567
|
+
path text := current_setting('request.path', true);
|
|
1568
|
+
claims jsonb := nullif(current_setting('request.jwt.claims', true), '')::jsonb;
|
|
1569
|
+
headers jsonb := nullif(current_setting('request.headers', true), '')::jsonb;
|
|
1570
|
+
rule better_supabase.rate_limit_rules;
|
|
1571
|
+
caller text;
|
|
1572
|
+
used integer;
|
|
1573
|
+
started timestamptz;
|
|
1574
|
+
retry integer;
|
|
1575
|
+
begin
|
|
1576
|
+
if method is null or method not in ('POST', 'PATCH', 'PUT', 'DELETE') then
|
|
1577
|
+
return;
|
|
1578
|
+
end if;
|
|
1579
|
+
if claims ->> 'role' = 'service_role' then
|
|
1580
|
+
return;
|
|
1581
|
+
end if;
|
|
1582
|
+
for rule in
|
|
1583
|
+
select * from better_supabase.rate_limit_rules r where r.scope in ('*', path) order by r.scope
|
|
1584
|
+
loop
|
|
1585
|
+
caller := coalesce(
|
|
1586
|
+
claims ->> rule.key_claim,
|
|
1587
|
+
'ip:' || coalesce(nullif(trim(split_part(headers ->> 'x-forwarded-for', ',', 1)), ''), 'unknown')
|
|
1588
|
+
);
|
|
1589
|
+
insert into better_supabase.rate_limits as l (scope, key, window_start, hits)
|
|
1590
|
+
values (rule.scope, caller, now(), 1)
|
|
1591
|
+
on conflict on constraint rate_limits_pkey do update set
|
|
1592
|
+
window_start = case when l.window_start + rule.period <= now() then now() else l.window_start end,
|
|
1593
|
+
hits = case when l.window_start + rule.period <= now() then 1 else l.hits + 1 end
|
|
1594
|
+
returning l.hits, l.window_start into used, started;
|
|
1595
|
+
if used > rule.max_requests then
|
|
1596
|
+
retry := greatest(1, ceil(extract(epoch from started + rule.period - now()))::integer);
|
|
1597
|
+
raise sqlstate 'PGRST' using
|
|
1598
|
+
message = json_build_object(
|
|
1599
|
+
'code', 'BS429',
|
|
1600
|
+
'message', format('Rate limit for %s exceeded: %s writes per %s', rule.scope, rule.max_requests, rule.period),
|
|
1601
|
+
'details', format('Retry after %s seconds.', retry),
|
|
1602
|
+
'hint', null
|
|
1603
|
+
)::text,
|
|
1604
|
+
detail = json_build_object(
|
|
1605
|
+
'status', 429,
|
|
1606
|
+
'status_text', 'Too Many Requests',
|
|
1607
|
+
'headers', json_build_object('Retry-After', retry::text)
|
|
1608
|
+
)::text;
|
|
1609
|
+
end if;
|
|
1610
|
+
end loop;
|
|
1611
|
+
end
|
|
1612
|
+
$$;
|
|
1613
|
+
|
|
1614
|
+
revoke execute on function better_supabase.check_request() from public;
|
|
1615
|
+
grant execute on function better_supabase.check_request() to anon, authenticated, service_role;
|
|
1616
|
+
|
|
1617
|
+
-- supabase db diff doesn't capture role settings: put this block in a
|
|
1618
|
+
-- migration too. It keeps an existing pre-request function; chain from it.
|
|
1619
|
+
do $$
|
|
1620
|
+
declare
|
|
1621
|
+
current_hook text;
|
|
1622
|
+
begin
|
|
1623
|
+
select split_part(setting, '=', 2) into current_hook
|
|
1624
|
+
from pg_catalog.pg_db_role_setting s
|
|
1625
|
+
join pg_catalog.pg_roles r on r.oid = s.setrole
|
|
1626
|
+
cross join lateral unnest(s.setconfig) setting
|
|
1627
|
+
where r.rolname = 'authenticator' and s.setdatabase = 0 and setting like 'pgrst.db_pre_request=%';
|
|
1628
|
+
if current_hook is null then
|
|
1629
|
+
alter role authenticator set pgrst.db_pre_request = 'better_supabase.check_request';
|
|
1630
|
+
elsif current_hook <> 'better_supabase.check_request' then
|
|
1631
|
+
raise notice 'pgrst.db_pre_request is %; call better_supabase.check_request() from it', current_hook;
|
|
1632
|
+
end if;
|
|
1633
|
+
end
|
|
1634
|
+
$$;
|
|
1635
|
+
notify pgrst, 'reload config';`
|
|
1636
|
+
};
|
|
1637
|
+
/** New modules go at the end: the position is part of the file name. */
|
|
1638
|
+
const SQL_MODULES = Object.fromEntries([
|
|
1639
|
+
UPDATED_AT,
|
|
1640
|
+
ACTOR,
|
|
1641
|
+
AUDIT,
|
|
1642
|
+
TENANT,
|
|
1643
|
+
INVITATIONS,
|
|
1644
|
+
RESERVED_SLUGS,
|
|
1645
|
+
JOBS,
|
|
1646
|
+
IDEMPOTENCY,
|
|
1647
|
+
WEBHOOK_INBOX,
|
|
1648
|
+
REALTIME_TABLES,
|
|
1649
|
+
JSONB_SCHEMAS,
|
|
1650
|
+
PGTAP,
|
|
1651
|
+
GRANTS,
|
|
1652
|
+
READ_SETS,
|
|
1653
|
+
MFA,
|
|
1654
|
+
ENTITLEMENTS,
|
|
1655
|
+
RATE_LIMIT,
|
|
1656
|
+
{
|
|
1657
|
+
name: "vector-search",
|
|
1658
|
+
title: "Vector search",
|
|
1659
|
+
description: "search_<table>(query, k) for each table in vectorSearch: the k nearest rows the caller can read, with pgvector iterative index scans so RLS filters still return k rows.",
|
|
1660
|
+
requires: [],
|
|
1661
|
+
target: "schema",
|
|
1662
|
+
sql: `create extension if not exists vector with schema extensions;
|
|
1663
|
+
|
|
1664
|
+
-- Functions for the tables in \`vectorSearch\` (better-supabase.config.ts); \`sql sync\` rewrites them.
|
|
1665
|
+
-- They are security invoker, so RLS (a tenant policy, say) filters inside the
|
|
1666
|
+
-- index scan. hnsw.iterative_scan keeps scanning until k visible rows are found
|
|
1667
|
+
-- (pgvector 0.8+) instead of returning fewer.`
|
|
1668
|
+
}
|
|
1669
|
+
].map((module) => [module.name, module]));
|
|
1670
|
+
const ORDER = Object.keys(SQL_MODULES);
|
|
1671
|
+
/** The modules to install for `names`, dependencies first. Throws on an unknown name. */
|
|
1672
|
+
function resolveModules(names, layout = {}) {
|
|
1673
|
+
const ordered = [];
|
|
1674
|
+
const visit = (name, from) => {
|
|
1675
|
+
const module = SQL_MODULES[name];
|
|
1676
|
+
if (!module) throw new TypeError(`Unknown SQL kit module "${name}"${from ? ` (required by ${from})` : ""}. Available: ${Object.keys(SQL_MODULES).join(", ")}`);
|
|
1677
|
+
if (ordered.includes(module)) return;
|
|
1678
|
+
for (const dependency of requiresOf(module, layout)) visit(dependency, name);
|
|
1679
|
+
ordered.push(module);
|
|
1680
|
+
};
|
|
1681
|
+
for (const name of names) visit(name);
|
|
1682
|
+
return ordered.sort((a, b) => ORDER.indexOf(a.name) - ORDER.indexOf(b.name));
|
|
1683
|
+
}
|
|
1684
|
+
const DISTANCE_OPERATORS = {
|
|
1685
|
+
cosine: "<=>",
|
|
1686
|
+
l2: "<->",
|
|
1687
|
+
inner_product: "<#>"
|
|
1688
|
+
};
|
|
1689
|
+
function vectorSearchFunctions(tables) {
|
|
1690
|
+
if (tables.length === 0) return "";
|
|
1691
|
+
return `\n-- config.vectorSearch\n${tables.map((entry) => {
|
|
1692
|
+
const [schema, table] = entry.table.includes(".") ? entry.table.split(".", 2) : ["public", entry.table];
|
|
1693
|
+
const target = `${sqlIdent(schema)}.${sqlIdent(table)}`;
|
|
1694
|
+
const fn = `${sqlIdent(schema)}.${sqlIdent(`search_${table}`)}`;
|
|
1695
|
+
const column = `t.${sqlIdent(entry.column)}`;
|
|
1696
|
+
const operator = DISTANCE_OPERATORS[entry.distance];
|
|
1697
|
+
const signature = `${fn}(extensions.vector, integer)`;
|
|
1698
|
+
return `-- ${entry.table}.${entry.column} (${entry.distance})
|
|
1699
|
+
create or replace function ${fn}(query extensions.vector, k integer default 10)
|
|
1700
|
+
returns setof ${target}
|
|
1701
|
+
language sql
|
|
1702
|
+
stable
|
|
1703
|
+
security invoker
|
|
1704
|
+
set search_path = ''
|
|
1705
|
+
set hnsw.iterative_scan = 'strict_order'
|
|
1706
|
+
as $$
|
|
1707
|
+
select t.* from ${target} t
|
|
1708
|
+
where ${column} is not null
|
|
1709
|
+
order by ${column} operator(extensions.${operator}) query
|
|
1710
|
+
limit least(greatest(k, 1), 1000)
|
|
1711
|
+
$$;
|
|
1712
|
+
|
|
1713
|
+
revoke execute on function ${signature} from public, anon;
|
|
1714
|
+
grant execute on function ${signature} to authenticated, service_role;`;
|
|
1715
|
+
}).join("\n\n")}\n`;
|
|
1716
|
+
}
|
|
1717
|
+
function tableGrants(grants) {
|
|
1718
|
+
const statements = grants.filter((grant) => grant.privileges.length > 0).map((grant) => {
|
|
1719
|
+
const [schema, table] = grant.table.includes(".") ? grant.table.split(".", 2) : ["public", grant.table];
|
|
1720
|
+
return `grant ${grant.privileges.join(", ")} on table ${sqlIdent(schema)}.${sqlIdent(table)} to ${grant.role};`;
|
|
1721
|
+
});
|
|
1722
|
+
if (statements.length === 0) return "";
|
|
1723
|
+
return `\n-- config.expose\n${statements.join("\n")}\n`;
|
|
1724
|
+
}
|
|
1725
|
+
function jsonSchemaChecks(checks) {
|
|
1726
|
+
if (checks.length === 0) return "";
|
|
1727
|
+
return `\n-- config.json schemas\n${checks.map((check) => {
|
|
1728
|
+
const [schema, table] = check.table.includes(".") ? check.table.split(".", 2) : ["public", check.table];
|
|
1729
|
+
const target = `${sqlIdent(schema)}.${sqlIdent(table)}`;
|
|
1730
|
+
const name = sqlIdent(`bs_json_${check.column}`.slice(0, 63));
|
|
1731
|
+
return [
|
|
1732
|
+
`alter table ${target} drop constraint if exists ${name};`,
|
|
1733
|
+
`alter table ${target} add constraint ${name}`,
|
|
1734
|
+
` check (extensions.jsonb_matches_schema(${sqlString(JSON.stringify(check.schema))}::json, ${sqlIdent(check.column)}));`
|
|
1735
|
+
].join("\n");
|
|
1736
|
+
}).join("\n\n")}\n`;
|
|
1737
|
+
}
|
|
1738
|
+
/** Users with a membership in a tenant of `customer`, from PermDock's membership sources. */
|
|
1739
|
+
function permdockEntitlementMembers(permdock) {
|
|
1740
|
+
const sources = permdock.memberships.flatMap((source) => {
|
|
1741
|
+
const scoped = "value" in source.scope ? source.scope.value === permdock.scope ? "" : void 0 : `\n and m.${sqlIdent(source.scope.column)}::text = ${sqlString(permdock.scope)}`;
|
|
1742
|
+
if (scoped === void 0) return [];
|
|
1743
|
+
const [schema, table] = source.table.split(".", 2);
|
|
1744
|
+
return [` select distinct m.${sqlIdent(source.userColumn)}::uuid
|
|
1745
|
+
from ${sqlIdent(schema)}.${sqlIdent(table)} m
|
|
1746
|
+
where m.${sqlIdent(source.idColumn)}::text in (select t::text from better_supabase.stripe_customer_tenants(customer) t)${scoped}`];
|
|
1747
|
+
});
|
|
1748
|
+
return sources.length > 0 ? sources.join("\n union\n") : " select null::uuid where false";
|
|
1749
|
+
}
|
|
1750
|
+
function entitlementsSource(source, permdock) {
|
|
1751
|
+
const [schema, table] = source.table.includes(".") ? source.table.split(".", 2) : ["public", source.table];
|
|
1752
|
+
const target = `${sqlIdent(schema)}.${sqlIdent(table)}`;
|
|
1753
|
+
const key = `t.${sqlIdent(source.key)}`;
|
|
1754
|
+
const column = `t.${sqlIdent(source.column)}`;
|
|
1755
|
+
const definer = "language sql\nstable\nsecurity definer\nset search_path = ''";
|
|
1756
|
+
return `
|
|
1757
|
+
-- config.entitlements: ${source.table}.${source.column}
|
|
1758
|
+
create or replace function better_supabase.tenant_stripe_customer(tenant uuid)
|
|
1759
|
+
returns text
|
|
1760
|
+
${definer}
|
|
1761
|
+
as $$
|
|
1762
|
+
select ${column} from ${target} t where ${key} = tenant
|
|
1763
|
+
$$;
|
|
1764
|
+
|
|
1765
|
+
create or replace function better_supabase.stripe_customer_tenants(customer text)
|
|
1766
|
+
returns setof uuid
|
|
1767
|
+
${definer}
|
|
1768
|
+
as $$
|
|
1769
|
+
select ${key} from ${target} t where ${column} = customer
|
|
1770
|
+
$$;
|
|
1771
|
+
|
|
1772
|
+
-- Users whose features claim carries the customer's entitlements, for
|
|
1773
|
+
-- invalidating their sessions after entitlements.active_entitlement_summary.updated.
|
|
1774
|
+
create or replace function better_supabase.entitlement_members(customer text)
|
|
1775
|
+
returns setof uuid
|
|
1776
|
+
${definer}
|
|
1777
|
+
as $$
|
|
1778
|
+
${permdock ? permdockEntitlementMembers(permdock) : ` select distinct m.user_id
|
|
1779
|
+
from better_supabase.memberships m
|
|
1780
|
+
where m.org_id in (select better_supabase.stripe_customer_tenants(customer))`}
|
|
1781
|
+
$$;
|
|
1782
|
+
|
|
1783
|
+
revoke execute on function better_supabase.tenant_stripe_customer(uuid) from public, anon, authenticated;
|
|
1784
|
+
revoke execute on function better_supabase.stripe_customer_tenants(text) from public, anon, authenticated;
|
|
1785
|
+
revoke execute on function better_supabase.entitlement_members(text) from public, anon, authenticated;
|
|
1786
|
+
grant execute on function better_supabase.entitlement_members(text) to service_role;
|
|
1787
|
+
`;
|
|
1788
|
+
}
|
|
1789
|
+
function moduleExtras(module, layout) {
|
|
1790
|
+
if (module.name === "entitlements") return entitlementsSource(layout.entitlements ?? {
|
|
1791
|
+
table: "organizations",
|
|
1792
|
+
column: "stripe_customer_id",
|
|
1793
|
+
key: "id"
|
|
1794
|
+
}, layout.permdock);
|
|
1795
|
+
if (module.name === "realtime-tables") return realtimeRegistrations(layout.realtimeTables ?? [], layout.tenantColumn);
|
|
1796
|
+
if (module.name === "jsonb-schemas") return jsonSchemaChecks(layout.jsonSchemas ?? []);
|
|
1797
|
+
if (module.name === "grants") return tableGrants(layout.grants ?? []);
|
|
1798
|
+
if (module.name === "vector-search") return vectorSearchFunctions(layout.vectorSearch ?? []);
|
|
1799
|
+
if (module.name === "read-sets") {
|
|
1800
|
+
const sets = layout.readSets ?? [];
|
|
1801
|
+
if (sets.length === 0) return "";
|
|
1802
|
+
return `\n-- config.readSets\n${sets.map((set) => `-- ${set.name}\n${set.sql}`).join("\n\n")}\n`;
|
|
1803
|
+
}
|
|
1804
|
+
return "";
|
|
1805
|
+
}
|
|
1806
|
+
/** Whether an installed file matches, ignoring the version stamped in its header. */
|
|
1807
|
+
function sameKitFile(current, expected) {
|
|
1808
|
+
if (current === void 0) return false;
|
|
1809
|
+
const strip = (text) => text.replace(/^(-- better-supabase SQL kit: [^\n(]*?)(?: \([^)]*\))?\n/, "$1\n");
|
|
1810
|
+
return strip(current) === strip(expected);
|
|
1811
|
+
}
|
|
1812
|
+
/** The files `sql add` writes for these modules. */
|
|
1813
|
+
function renderKit(names, layout = {}) {
|
|
1814
|
+
const dir = (layout.dir ?? "supabase/schemas").replace(/\/$/, "");
|
|
1815
|
+
const prefix = layout.prefix ?? "900_better_supabase";
|
|
1816
|
+
const testsDir = (layout.testsDir ?? "supabase/tests").replace(/\/$/, "");
|
|
1817
|
+
return resolveModules(names, layout).map((module) => {
|
|
1818
|
+
const slug = module.name.replaceAll("-", "_");
|
|
1819
|
+
const path = module.target === "test" ? `${testsDir}/000_better_supabase_${slug}.test.sql` : `${dir}/${prefix}_${String(ORDER.indexOf(module.name) + 1).padStart(2, "0")}_${slug}.sql`;
|
|
1820
|
+
const header = [
|
|
1821
|
+
`-- better-supabase SQL kit: ${module.name}${layout.version ? ` (${layout.version})` : ""}`,
|
|
1822
|
+
`-- ${module.description}`,
|
|
1823
|
+
"-- Managed by `better-supabase sql add`; re-running it overwrites this file."
|
|
1824
|
+
].join("\n");
|
|
1825
|
+
const extra = moduleExtras(module, layout);
|
|
1826
|
+
const sql = module.render && (layout.claims || layout.permdock) ? module.render(layout.claims ?? DEFAULT_CLAIMS, layout) : module.sql;
|
|
1827
|
+
return {
|
|
1828
|
+
module: module.name,
|
|
1829
|
+
path,
|
|
1830
|
+
contents: `${header}\n\n${sql.trim()}\n${extra}`
|
|
1831
|
+
};
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
function realtimeRegistrations(tables, tenantColumn) {
|
|
1835
|
+
if (tables.length === 0) return "";
|
|
1836
|
+
const tenant = tenantColumn ? `, ${sqlString(tenantColumn)}` : "";
|
|
1837
|
+
return `\n-- config.realtime.tables\n${tables.map((table) => {
|
|
1838
|
+
const target = table.includes(".") ? table : `public.${table}`;
|
|
1839
|
+
return `select better_supabase.track_realtime(${sqlString(target)}${tenant});`;
|
|
1840
|
+
}).join("\n")}\n`;
|
|
1841
|
+
}
|
|
1842
|
+
//#endregion
|
|
1843
|
+
//#region src/sql/layout.ts
|
|
1844
|
+
/** Where and how `sql add` writes kit files for this config. */
|
|
1845
|
+
function kitLayout(config, testsDir = config.sql.testsDir, readSets = [], permdock) {
|
|
1846
|
+
return {
|
|
1847
|
+
...permdock ? { permdock } : {},
|
|
1848
|
+
dir: config.sql.dir,
|
|
1849
|
+
prefix: config.sql.prefix,
|
|
1850
|
+
testsDir,
|
|
1851
|
+
version: VERSION,
|
|
1852
|
+
readSets,
|
|
1853
|
+
realtimeTables: config.realtime.tables,
|
|
1854
|
+
entitlements: config.entitlements,
|
|
1855
|
+
claims: config.claims,
|
|
1856
|
+
vectorSearch: config.vectorSearch,
|
|
1857
|
+
grants: Object.entries(config.expose).flatMap(([table, roles]) => [{
|
|
1858
|
+
table,
|
|
1859
|
+
role: "anon",
|
|
1860
|
+
privileges: roles.anon
|
|
1861
|
+
}, {
|
|
1862
|
+
table,
|
|
1863
|
+
role: "authenticated",
|
|
1864
|
+
privileges: roles.authenticated
|
|
1865
|
+
}]),
|
|
1866
|
+
jsonSchemas: Object.entries(config.json).flatMap(([key, entry]) => {
|
|
1867
|
+
if (!entry.schema) return [];
|
|
1868
|
+
const dot = key.lastIndexOf(".");
|
|
1869
|
+
return [{
|
|
1870
|
+
table: key.slice(0, dot),
|
|
1871
|
+
column: key.slice(dot + 1),
|
|
1872
|
+
schema: resolveJsonSchema(entry.schema)
|
|
1873
|
+
}];
|
|
1874
|
+
}),
|
|
1875
|
+
...config.plugins.tenant ? { tenantColumn: config.plugins.tenant.column } : {}
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1878
|
+
//#endregion
|
|
1879
|
+
//#region src/sql/read-sets.ts
|
|
1880
|
+
const TAG = "$rs$";
|
|
1881
|
+
/** The operation each spec of the set runs, without plugins. */
|
|
1882
|
+
async function operations(set) {
|
|
1883
|
+
const ops = /* @__PURE__ */ new Map();
|
|
1884
|
+
for (const [key, spec] of Object.entries(set.specs)) {
|
|
1885
|
+
const seen = [];
|
|
1886
|
+
const method = set.definition.connect({
|
|
1887
|
+
name: "read-set-compiler",
|
|
1888
|
+
execute: (op) => {
|
|
1889
|
+
seen.push(op);
|
|
1890
|
+
return Promise.resolve(ok({
|
|
1891
|
+
rows: [],
|
|
1892
|
+
count: 0
|
|
1893
|
+
}));
|
|
1894
|
+
}
|
|
1895
|
+
}).$withoutPlugins()[spec.table]?.[spec.method];
|
|
1896
|
+
if (!method) throw new TypeError(`Read set "${set.name}" entry "${key}": unknown table "${spec.table}"`);
|
|
1897
|
+
const result = await method(...spec.args);
|
|
1898
|
+
const [op, ...more] = seen;
|
|
1899
|
+
if (!op) throw new TypeError(`Read set "${set.name}" entry "${key}": ${result.error?.message ?? "runs no query"}`);
|
|
1900
|
+
if (more.length > 0 || op.kind !== "select") throw new TypeError(`Read set "${set.name}" entry "${key}": ${spec.method} needs more than one query; read sets take one select per entry`);
|
|
1901
|
+
ops.set(key, op);
|
|
1902
|
+
}
|
|
1903
|
+
return ops;
|
|
1904
|
+
}
|
|
1905
|
+
function paramRef(set, name, array) {
|
|
1906
|
+
const type = set.params[name];
|
|
1907
|
+
if (!type) throw new TypeError(`Read set "${set.name}" has no parameter "${name}"`);
|
|
1908
|
+
if (array !== type.endsWith("[]")) throw new TypeError(`Read set "${set.name}": parameter "${name}" (${type}) is used as ${array ? "a list" : "a single value"}`);
|
|
1909
|
+
const key = sqlString(name);
|
|
1910
|
+
return array ? `(array(select jsonb_array_elements_text(p->${key}))::${type})` : `((p->>${key})::${type})`;
|
|
1911
|
+
}
|
|
1912
|
+
function arrayElement(value) {
|
|
1913
|
+
if (value === null || value === void 0) return "NULL";
|
|
1914
|
+
const text = temporalText(value) ?? String(value);
|
|
1915
|
+
if (hasPlaceholder(text)) throw new TypeError("A placeholder cannot sit inside a literal list");
|
|
1916
|
+
return `"${text.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`;
|
|
1917
|
+
}
|
|
1918
|
+
/** A parameter value as SQL: a reference to `p` for placeholders, else a literal. */
|
|
1919
|
+
function literal(set, value) {
|
|
1920
|
+
const placeholder = placeholderOf(value);
|
|
1921
|
+
if (placeholder) return paramRef(set, placeholder.name, placeholder.array);
|
|
1922
|
+
if (value === null || value === void 0) return "null";
|
|
1923
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
1924
|
+
if (typeof value === "number") {
|
|
1925
|
+
if (!Number.isFinite(value)) throw new TypeError(`Cannot inline ${value}`);
|
|
1926
|
+
return String(value);
|
|
1927
|
+
}
|
|
1928
|
+
if (typeof value === "bigint") return value.toString();
|
|
1929
|
+
const temporalValue = temporalText(value);
|
|
1930
|
+
if (temporalValue !== void 0) return sqlString(temporalValue);
|
|
1931
|
+
if (typeof value === "string") {
|
|
1932
|
+
if (!hasPlaceholder(value)) return sqlString(value);
|
|
1933
|
+
return `(${splitPlaceholders(value).map((part) => "text" in part ? sqlString(part.text) : `(p->>${sqlString(part.param)})`).join(" || ")})`;
|
|
1934
|
+
}
|
|
1935
|
+
if (Array.isArray(value)) return sqlString(`{${value.map(arrayElement).join(",")}}`);
|
|
1936
|
+
throw new TypeError(`Cannot inline a ${typeof value} parameter`);
|
|
1937
|
+
}
|
|
1938
|
+
function inline(set, query) {
|
|
1939
|
+
return query.text.replaceAll(/\$(\d+)/g, (_, index) => literal(set, query.params[Number(index) - 1]));
|
|
1940
|
+
}
|
|
1941
|
+
function entry(set, op) {
|
|
1942
|
+
const plan = compileSql(op);
|
|
1943
|
+
if (plan.never) return `jsonb_build_object('rows', '[]'::jsonb, 'count', 0)`;
|
|
1944
|
+
return `jsonb_build_object('rows', ${plan.rows ? `(select coalesce(jsonb_agg(s.row), '[]'::jsonb) from (${inline(set, plan.rows)}) s)` : `'[]'::jsonb`}, 'count', ${plan.count ? `(${inline(set, plan.count)})` : "null"})`;
|
|
1945
|
+
}
|
|
1946
|
+
/**
|
|
1947
|
+
* The function for a read set: `stable`, `security invoker` (RLS applies to
|
|
1948
|
+
* the caller) and with an empty `search_path`. Parameters are read from the
|
|
1949
|
+
* `p jsonb` argument; nothing is built dynamically.
|
|
1950
|
+
*/
|
|
1951
|
+
async function compileReadSet(set) {
|
|
1952
|
+
const body = ` select jsonb_build_object(\n${[...await operations(set)].map(([key, op]) => ` ${sqlString(key)}, ${entry(set, op)}`).join(",\n")}\n )`;
|
|
1953
|
+
if (body.includes(TAG)) throw new TypeError(`Read set "${set.name}" contains "${TAG}"`);
|
|
1954
|
+
const fn = `public.${set.functionName}`;
|
|
1955
|
+
const grants = set.roles.map((role) => `grant execute on function ${fn}(jsonb) to ${role};`);
|
|
1956
|
+
const sql = [
|
|
1957
|
+
`create or replace function ${fn}(p jsonb)`,
|
|
1958
|
+
" returns jsonb",
|
|
1959
|
+
" language sql stable security invoker set search_path = ''",
|
|
1960
|
+
`as ${TAG}`,
|
|
1961
|
+
body,
|
|
1962
|
+
`${TAG};`,
|
|
1963
|
+
`revoke execute on function ${fn}(jsonb) from public, anon, authenticated;`,
|
|
1964
|
+
...grants
|
|
1965
|
+
].join("\n");
|
|
1966
|
+
return {
|
|
1967
|
+
name: set.name,
|
|
1968
|
+
sql
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1971
|
+
/** Compiles every set, sorted by name. Throws on a duplicate name. */
|
|
1972
|
+
async function compileReadSets(sets) {
|
|
1973
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1974
|
+
for (const set of sets) {
|
|
1975
|
+
const existing = byName.get(set.name);
|
|
1976
|
+
if (existing && existing !== set) throw new TypeError(`Two read sets are named "${set.name}"`);
|
|
1977
|
+
byName.set(set.name, set);
|
|
1978
|
+
}
|
|
1979
|
+
const names = [...byName.keys()].sort();
|
|
1980
|
+
return Promise.all(names.map((name) => compileReadSet(byName.get(name))));
|
|
1981
|
+
}
|
|
1982
|
+
//#endregion
|
|
1983
|
+
export { SQL_MODULES, compileReadSet, compileReadSets, kitLayout, permdockKeys, renderKit, resolveModules, sameKitFile };
|