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.
Files changed (139) hide show
  1. package/README.md +34 -31
  2. package/dist/base64-CQeB1eSf.js +42 -0
  3. package/dist/client/index.d.ts +1 -1
  4. package/dist/client/index.js +4 -14
  5. package/dist/{compiler-CZ9nIuqy.d.ts → compiler-2zqGzxVZ.d.ts} +2 -2
  6. package/dist/config/index.d.ts +4 -3
  7. package/dist/config/index.js +5 -3
  8. package/dist/config-Be4GwsqI.js +129 -0
  9. package/dist/{index-DOsjFvMf.d.ts → config-C1CYcm6o.d.ts} +49 -49
  10. package/dist/{config-ClDXmVtY.js → config-DOsGYSiT.js} +14 -137
  11. package/dist/{define-BXMYD9AY.js → define-DF341GDj.js} +71 -51
  12. package/dist/{define-e3zI4_4g.d.ts → define-DUbydmJa.d.ts} +10 -9
  13. package/dist/edge/index.d.ts +4 -4
  14. package/dist/edge/index.js +4 -4
  15. package/dist/{entitlements-Dng-ctuT.d.ts → entitlements-DvJXoI02.d.ts} +1 -1
  16. package/dist/env/index.js +203 -1
  17. package/dist/events/index.d.ts +1 -1
  18. package/dist/events/index.js +139 -1
  19. package/dist/{events-DOt3nSO-.d.ts → events-Do8jXh4D.d.ts} +3 -3
  20. package/dist/{executor-BYXQ9AGn.d.ts → executor-B26lgbHD.d.ts} +2 -2
  21. package/dist/{executor-DeppvirK.js → executor-C57jx2pn.js} +7 -2
  22. package/dist/{executor-CGdNQ27g.d.ts → executor-DynmIWhq.d.ts} +2 -2
  23. package/dist/hono/index.d.ts +5 -5
  24. package/dist/hono/index.js +4 -4
  25. package/dist/{index-CBDufH1M.d.ts → index-BNsABq7P.d.ts} +8 -8
  26. package/dist/{index-Bw1bwtC0.d.ts → index-CADlNHK-.d.ts} +27 -14
  27. package/dist/{index-DuP14VHo.d.ts → index-CTTMS6_1.d.ts} +19 -8
  28. package/dist/index-Cf-D2jk1.d.ts +50 -0
  29. package/dist/{index-K-3WM2Hp.d.ts → index-CriFFJ_p.d.ts} +4 -4
  30. package/dist/{index-Ciu5Xlml.d.ts → index-GV77NpRP.d.ts} +5 -4
  31. package/dist/{index-D4v7sncu.d.ts → index-UJ-LUxtI.d.ts} +3 -3
  32. package/dist/{index-CG69s15v.d.ts → index-kpgopLNv.d.ts} +4 -3
  33. package/dist/{index-DbxFr7XG.d.ts → index-qriRM9cZ.d.ts} +7 -2
  34. package/dist/index.d.ts +12 -12
  35. package/dist/index.js +16 -7
  36. package/dist/jobs/index.d.ts +25 -24
  37. package/dist/jobs/index.js +16 -10
  38. package/dist/{json-schema-BpYqkCKf.js → json-schema--aOXAGfU.js} +7 -2
  39. package/dist/lint/index.js +1 -1
  40. package/dist/list/index.d.ts +2 -2
  41. package/dist/list/index.js +128 -93
  42. package/dist/{live-DJyZMD6w.d.ts → live-QE3UPbGm.d.ts} +16 -6
  43. package/dist/mcp/index.d.ts +24 -17
  44. package/dist/mcp/index.js +41 -12
  45. package/dist/{mfa-CpLfJSde.js → mfa-BxoR1RkN.js} +74 -19
  46. package/dist/{mfa-TVia554c.d.ts → mfa-x3V3vGh-.d.ts} +1 -1
  47. package/dist/next/index.d.ts +39 -26
  48. package/dist/next/index.js +28 -23
  49. package/dist/openapi/index.d.ts +1 -1
  50. package/dist/openapi/index.js +57 -34
  51. package/dist/orpc/index.d.ts +6 -6
  52. package/dist/orpc/index.js +3 -3
  53. package/dist/otel/index.d.ts +2 -2
  54. package/dist/otel/index.js +4 -2
  55. package/dist/{permdock-sql-CghlHPuH.js → permdock-sql-Bta_G0v-.js} +11 -6
  56. package/dist/permdock-sql-DImJULY-.d.ts +17 -0
  57. package/dist/{plugin-C4TR9J0Z.d.ts → plugin-DVRB8qNE.d.ts} +5 -4
  58. package/dist/plugins/actor/index.d.ts +1 -1
  59. package/dist/plugins/rules/index.d.ts +2 -2
  60. package/dist/plugins/rules/index.js +4 -4
  61. package/dist/plugins/soft-delete/index.d.ts +3 -3
  62. package/dist/plugins/soft-delete/index.js +2 -2
  63. package/dist/plugins/tenant/index.d.ts +2 -2
  64. package/dist/plugins/timestamps/index.d.ts +1 -1
  65. package/dist/plugins/timestamps/index.js +1 -1
  66. package/dist/plugins/validation/index.d.ts +2 -2
  67. package/dist/pool-B9qsLyFR.d.ts +71 -0
  68. package/dist/postgres/index.d.ts +10 -5
  69. package/dist/postgres/index.js +36 -16
  70. package/dist/{postgrest-cAEQ_IQN.d.ts → postgrest-DmBlevh-.d.ts} +1 -1
  71. package/dist/{problem-BTkTtRV9.js → problem-CGt1ADix.js} +10 -4
  72. package/dist/{problem-DlgB0lYl.d.ts → problem-Z2RTQjZD.d.ts} +6 -4
  73. package/dist/query/index.d.ts +2 -2
  74. package/dist/query/index.js +1 -1
  75. package/dist/{query-ChvDbsDE.js → query-BdKLTPlj.js} +2 -2
  76. package/dist/react/index.d.ts +7 -7
  77. package/dist/react/index.js +2 -2
  78. package/dist/react/server.d.ts +3 -3
  79. package/dist/react/server.js +1 -1
  80. package/dist/react/session.d.ts +21 -2
  81. package/dist/react/session.js +19 -1
  82. package/dist/{react-C83BdBGt.js → react-DNBYNazu.js} +20 -16
  83. package/dist/{read-set-4lI3W_gZ.js → read-set-D0qBcboM.js} +3 -3
  84. package/dist/realtime/index.d.ts +4 -3
  85. package/dist/realtime/index.js +7 -6
  86. package/dist/{resolve-CjHILk2w.d.ts → resolve-BRciX2jK.d.ts} +6 -5
  87. package/dist/{resource-CgZa3YG_.d.ts → resource-CwVgiI5q.d.ts} +16 -7
  88. package/dist/{resource-BHEWmQJK.js → resource-D30rp0GN.js} +38 -16
  89. package/dist/{respond-DMcbQwfN.js → respond-C44EZAJo.js} +35 -19
  90. package/dist/{respond-Dne1ARSa.d.ts → respond-DAkPIdTH.d.ts} +15 -9
  91. package/dist/{result-CTkCZ6JA.d.ts → result-c2OYgCV2.d.ts} +13 -8
  92. package/dist/server/index.d.ts +35 -10
  93. package/dist/server/index.js +38 -9
  94. package/dist/{simplify-bwJpLnaS.js → simplify-LakqK1vH.js} +10 -16
  95. package/dist/sql/index.d.ts +139 -0
  96. package/dist/sql/index.js +1983 -0
  97. package/dist/{sql-DTjA1LPj.js → sql-BIgk12ew.js} +72 -16
  98. package/dist/ssr/index.d.ts +4 -4
  99. package/dist/ssr/index.js +2 -2
  100. package/dist/{standard-CyhRzctb.d.ts → standard-eudBiRk1.d.ts} +1 -1
  101. package/dist/{stats-C5W-rBfR.js → stats-D78UQ8Yu.js} +8 -7
  102. package/dist/storage/index.d.ts +4 -3
  103. package/dist/storage/index.js +530 -1
  104. package/dist/{tables-rZxRxNPW.js → tables-Dhha3BXM.js} +5 -5
  105. package/dist/temporal-BwshQk0W.js +27 -0
  106. package/dist/temporal-required-CMxeSVPo.js +19 -0
  107. package/dist/testing/index.d.ts +13 -11
  108. package/dist/testing/index.js +128 -29
  109. package/dist/{types-C83v5SQz.d.ts → types-DONv9SHN.d.ts} +14 -11
  110. package/dist/{version-Cqq1m3UY.js → version-BfpG2gks.js} +1 -1
  111. package/dist/{view-D1s0wa0T.d.ts → view-3Po_PVlr.d.ts} +34 -4
  112. package/dist/{view-aOYizTeE.js → view-wZ-9qIUz.js} +11 -2
  113. package/dist/webhooks/index.d.ts +18 -16
  114. package/dist/webhooks/index.js +1 -1
  115. package/dist/{webhooks-DMipjhSP.js → webhooks-CAIFQSTM.js} +11 -9
  116. package/package.json +24 -28
  117. package/schemas/config-v1.json +31 -1
  118. package/schemas/doctor-report-v1.json +17 -1
  119. package/schemas/snapshot-v2.json +35 -0
  120. package/skills/better-supabase/SKILL.md +23 -11
  121. package/skills/better-supabase/references/plugins.md +21 -18
  122. package/skills/better-supabase/references/troubleshooting.md +15 -15
  123. package/skills/better-supabase-api/SKILL.md +7 -7
  124. package/skills/better-supabase-api/references/adapters.md +30 -25
  125. package/skills/better-supabase-testing/SKILL.md +11 -8
  126. package/bin/better-supabase.js +0 -4
  127. package/dist/casing-da0uRTqt.js +0 -14
  128. package/dist/cli/bin.d.ts +0 -1
  129. package/dist/cli/bin.js +0 -15
  130. package/dist/cli/index.d.ts +0 -226
  131. package/dist/cli/index.js +0 -3
  132. package/dist/cli-DrSgDOQC.js +0 -6646
  133. package/dist/env-BEToab0I.js +0 -204
  134. package/dist/events-BuzTgn2D.js +0 -135
  135. package/dist/pool-R2m_DkXi.d.ts +0 -37
  136. package/dist/seed-CzecODcJ.js +0 -101
  137. package/dist/session-C3LxnQ0e.d.ts +0 -22
  138. package/dist/session-CqEwzVup.js +0 -20
  139. 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 };