better-supabase 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -1
  3. package/bin/better-supabase.js +3 -0
  4. package/dist/casing-da0uRTqt.js +14 -0
  5. package/dist/cli/bin.d.ts +1 -0
  6. package/dist/cli/bin.js +15 -0
  7. package/dist/cli/index.d.ts +226 -0
  8. package/dist/cli/index.js +3 -0
  9. package/dist/cli-CoByWYja.js +6472 -0
  10. package/dist/client/index.d.ts +2 -0
  11. package/dist/client/index.js +112 -0
  12. package/dist/compiler-DL18ukPe.d.ts +14 -0
  13. package/dist/config/index.d.ts +3 -0
  14. package/dist/config/index.js +3 -0
  15. package/dist/config-D-aR2ZoN.js +276 -0
  16. package/dist/define-CTfWw-SS.d.ts +662 -0
  17. package/dist/define-QGkq04oe.js +1068 -0
  18. package/dist/edge/index.d.ts +43 -0
  19. package/dist/edge/index.js +103 -0
  20. package/dist/entitlements-BBg61DQZ.d.ts +20 -0
  21. package/dist/entitlements-BC_khwRy.js +17 -0
  22. package/dist/env/index.d.ts +2 -0
  23. package/dist/env/index.js +2 -0
  24. package/dist/env-BEToab0I.js +204 -0
  25. package/dist/errors-Bl3rkOzo.js +196 -0
  26. package/dist/events/index.d.ts +2 -0
  27. package/dist/events/index.js +2 -0
  28. package/dist/events-2OJsVG_d.d.ts +84 -0
  29. package/dist/events-BuzTgn2D.js +135 -0
  30. package/dist/executor-Bazhgsgq.d.ts +195 -0
  31. package/dist/executor-Bje4suQF.d.ts +25 -0
  32. package/dist/executor-Ceq60hPM.js +89 -0
  33. package/dist/hono/index.d.ts +45 -0
  34. package/dist/hono/index.js +70 -0
  35. package/dist/index-BD6wKthp.d.ts +597 -0
  36. package/dist/index-DQVvHzjW.d.ts +74 -0
  37. package/dist/index-DRid0qIv.d.ts +76 -0
  38. package/dist/index-Dup-nSe9.d.ts +117 -0
  39. package/dist/index-Dy6kuF0P.d.ts +71 -0
  40. package/dist/index-Hf8PP9sP.d.ts +116 -0
  41. package/dist/index-JXHBuS53.d.ts +144 -0
  42. package/dist/index-Nxo3TJcm.d.ts +208 -0
  43. package/dist/index-Sw43J8HG.d.ts +62 -0
  44. package/dist/index-cEeweTeM.d.ts +38 -0
  45. package/dist/index.d.ts +110 -0
  46. package/dist/index.js +42 -0
  47. package/dist/invalidate-DbxuL2jL.js +18 -0
  48. package/dist/jobs/index.d.ts +173 -0
  49. package/dist/jobs/index.js +477 -0
  50. package/dist/json-schema-BbwO2mZg.js +181 -0
  51. package/dist/lint/index.d.ts +75 -0
  52. package/dist/lint/index.js +167 -0
  53. package/dist/list/index.d.ts +2 -0
  54. package/dist/list/index.js +410 -0
  55. package/dist/live-6b9i72Ux.d.ts +180 -0
  56. package/dist/live-D8rV1Ldw.js +168 -0
  57. package/dist/mcp/index.d.ts +105 -0
  58. package/dist/mcp/index.js +330 -0
  59. package/dist/mfa-CpogP66z.js +521 -0
  60. package/dist/mfa-TVia554c.d.ts +44 -0
  61. package/dist/next/image/index.d.ts +34 -0
  62. package/dist/next/image/index.js +40 -0
  63. package/dist/next/index.d.ts +210 -0
  64. package/dist/next/index.js +426 -0
  65. package/dist/openapi/index.d.ts +2 -0
  66. package/dist/openapi/index.js +332 -0
  67. package/dist/orpc/index.d.ts +46 -0
  68. package/dist/orpc/index.js +50 -0
  69. package/dist/otel/index.d.ts +40 -0
  70. package/dist/otel/index.js +168 -0
  71. package/dist/path-Cv__-OTj.d.ts +17 -0
  72. package/dist/plugin-BqR6wKMB.js +23 -0
  73. package/dist/plugin-HvGqCurB.d.ts +118 -0
  74. package/dist/plugins/actor/index.d.ts +13 -0
  75. package/dist/plugins/actor/index.js +43 -0
  76. package/dist/plugins/rules/index.d.ts +88 -0
  77. package/dist/plugins/rules/index.js +174 -0
  78. package/dist/plugins/soft-delete/index.d.ts +38 -0
  79. package/dist/plugins/soft-delete/index.js +69 -0
  80. package/dist/plugins/tenant/index.d.ts +43 -0
  81. package/dist/plugins/tenant/index.js +73 -0
  82. package/dist/plugins/timestamps/index.d.ts +9 -0
  83. package/dist/plugins/timestamps/index.js +37 -0
  84. package/dist/plugins/validation/index.d.ts +24 -0
  85. package/dist/plugins/validation/index.js +59 -0
  86. package/dist/pool-BFeqzwQS.d.ts +37 -0
  87. package/dist/postgres/index.d.ts +28 -0
  88. package/dist/postgres/index.js +79 -0
  89. package/dist/postgrest-Dm-vhY6R.d.ts +43 -0
  90. package/dist/problem-DPu6Fh-t.js +97 -0
  91. package/dist/problem-DlgB0lYl.d.ts +47 -0
  92. package/dist/query/index.d.ts +2 -0
  93. package/dist/query/index.js +3 -0
  94. package/dist/query-ChvDbsDE.js +186 -0
  95. package/dist/react/index.d.ts +7 -0
  96. package/dist/react/index.js +5 -0
  97. package/dist/react/server.d.ts +13 -0
  98. package/dist/react/server.js +25 -0
  99. package/dist/react/session.d.ts +2 -0
  100. package/dist/react/session.js +3 -0
  101. package/dist/react-CrOvrnIP.js +241 -0
  102. package/dist/read-set-DhX2d04c.js +216 -0
  103. package/dist/realtime/index.d.ts +3 -0
  104. package/dist/realtime/index.js +253 -0
  105. package/dist/resolve-DW8h9NPm.d.ts +155 -0
  106. package/dist/resource-CYiu6Ygi.js +175 -0
  107. package/dist/resource-SkPTRJDD.d.ts +65 -0
  108. package/dist/respond-4-dhoV7x.js +380 -0
  109. package/dist/respond-PoTpy5DT.d.ts +169 -0
  110. package/dist/result-CTkCZ6JA.d.ts +198 -0
  111. package/dist/result-DWXatkd6.js +103 -0
  112. package/dist/scope-CDHcS7dE.js +90 -0
  113. package/dist/seed-CzecODcJ.js +101 -0
  114. package/dist/server/index.d.ts +42 -0
  115. package/dist/server/index.js +68 -0
  116. package/dist/session-C3osL21k.d.ts +21 -0
  117. package/dist/session-CD-3orO_.js +19 -0
  118. package/dist/shared-_6-uPL7E.js +57 -0
  119. package/dist/simplify-oHiDKRVQ.js +665 -0
  120. package/dist/spec-pins-CJPVghHy.d.ts +20 -0
  121. package/dist/spec-pins-t6sPaHvl.js +20 -0
  122. package/dist/sql-M8l3fnS0.js +260 -0
  123. package/dist/ssr/index.d.ts +5 -0
  124. package/dist/ssr/index.js +4 -0
  125. package/dist/standard-CyhRzctb.d.ts +7 -0
  126. package/dist/standard-FUi3Z3-7.js +21 -0
  127. package/dist/stats-3iy8u3_P.js +566 -0
  128. package/dist/storage/index.d.ts +4 -0
  129. package/dist/storage/index.js +2 -0
  130. package/dist/storage-BWXVUXGk.js +461 -0
  131. package/dist/tables-rZxRxNPW.js +59 -0
  132. package/dist/template-BeOGnynM.js +93 -0
  133. package/dist/template-mbvlysOo.d.ts +6 -0
  134. package/dist/testing/index.d.ts +294 -0
  135. package/dist/testing/index.js +751 -0
  136. package/dist/types-6cYK9pbJ.js +29 -0
  137. package/dist/types-C2fslP1z.d.ts +195 -0
  138. package/dist/version-Cqq1m3UY.js +4 -0
  139. package/dist/view-B0ltUmTi.d.ts +43 -0
  140. package/dist/view-B6F9QbG-.js +35 -0
  141. package/dist/webhooks/index.d.ts +152 -0
  142. package/dist/webhooks/index.js +2 -0
  143. package/dist/webhooks-DbMTJJX2.js +139 -0
  144. package/package.json +246 -17
  145. package/schemas/config-v1.json +328 -0
  146. package/schemas/doctor-report-v1.json +105 -0
  147. package/schemas/snapshot-v2.json +1002 -0
  148. package/skills/better-supabase/SKILL.md +84 -0
  149. package/skills/better-supabase/references/plugins.md +56 -0
  150. package/skills/better-supabase/references/troubleshooting.md +39 -0
  151. package/skills/better-supabase-api/SKILL.md +67 -0
  152. package/skills/better-supabase-api/references/adapters.md +108 -0
  153. package/skills/better-supabase-testing/SKILL.md +65 -0
  154. package/index.js +0 -1
@@ -0,0 +1,751 @@
1
+ import { n as dbError } from "../errors-Bl3rkOzo.js";
2
+ import { i as ok } from "../result-DWXatkd6.js";
3
+ import { t as BetterSupabase } from "../define-QGkq04oe.js";
4
+ import { r as resolveConfig } from "../config-D-aR2ZoN.js";
5
+ import { t as ENV_VARIABLES } from "../env-BEToab0I.js";
6
+ import { n as postgresExecutor } from "../executor-Ceq60hPM.js";
7
+ import { o as toCloudEvents } from "../events-BuzTgn2D.js";
8
+ import { n as isSeed, t as defineSeed } from "../seed-CzecODcJ.js";
9
+ import { createClient } from "@supabase/supabase-js";
10
+ //#region src/testing/jwt.ts
11
+ const encoder = new TextEncoder();
12
+ function base64url(input) {
13
+ const bytes = typeof input === "string" ? encoder.encode(input) : input;
14
+ let binary = "";
15
+ for (const byte of bytes) binary += String.fromCharCode(byte);
16
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
17
+ }
18
+ /**
19
+ * Signs an HS256 access token the local Supabase stack accepts. Test-only:
20
+ * production tokens come from Supabase Auth.
21
+ */
22
+ async function signTestJwt(secret, claims) {
23
+ const { expiresIn = 3600, ...rest } = claims;
24
+ const now = Math.floor(Date.now() / 1e3);
25
+ const payload = {
26
+ aud: "authenticated",
27
+ role: "authenticated",
28
+ aal: "aal1",
29
+ iat: now,
30
+ exp: now + expiresIn,
31
+ ...rest
32
+ };
33
+ const header = base64url(JSON.stringify({
34
+ alg: "HS256",
35
+ typ: "JWT"
36
+ }));
37
+ const body = base64url(JSON.stringify(payload));
38
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
39
+ name: "HMAC",
40
+ hash: "SHA-256"
41
+ }, false, ["sign"]);
42
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(`${header}.${body}`));
43
+ return `${header}.${body}.${base64url(new Uint8Array(signature))}`;
44
+ }
45
+ function base64urlDecode(input) {
46
+ const binary = atob(input.replaceAll("-", "+").replaceAll("_", "/"));
47
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
48
+ }
49
+ /**
50
+ * Accepts `signTestJwt` tokens as users, for API servers tested against the
51
+ * local stack: `createServer(sb, { auth: { resolvers: [localAuth(secret)] } })`.
52
+ * Tokens that are not HS256-signed with `secret` fall through. Test-only.
53
+ */
54
+ function localAuth(secret) {
55
+ const key = crypto.subtle.importKey("raw", encoder.encode(secret), {
56
+ name: "HMAC",
57
+ hash: "SHA-256"
58
+ }, false, ["verify"]);
59
+ return {
60
+ name: "local-hs256",
61
+ async resolve(request) {
62
+ const token = /^Bearer (.+)$/i.exec(request.headers.get("authorization") ?? "")?.[1];
63
+ const [header, body, signature] = token?.split(".") ?? [];
64
+ if (!token || !header || !body || !signature) return void 0;
65
+ if (!await crypto.subtle.verify("HMAC", await key, base64urlDecode(signature), encoder.encode(`${header}.${body}`)).catch(() => false)) return void 0;
66
+ const claims = JSON.parse(new TextDecoder().decode(base64urlDecode(body)));
67
+ if (typeof claims.exp === "number" && claims.exp * 1e3 < Date.now()) return {
68
+ kind: "invalid",
69
+ error: dbError("unauthorized", "The token has expired")
70
+ };
71
+ return {
72
+ kind: "user",
73
+ token,
74
+ claims,
75
+ user: {
76
+ id: claims.sub,
77
+ ...typeof claims.role === "string" ? { role: claims.role } : {},
78
+ ...typeof claims.email === "string" ? { email: claims.email } : {}
79
+ },
80
+ source: "bearer",
81
+ expiresAt: typeof claims.exp === "number" ? claims.exp : null
82
+ };
83
+ }
84
+ };
85
+ }
86
+ /** An in-memory ES256 key pair that signs tokens like Supabase Auth's asymmetric keys. */
87
+ async function createTestSigner() {
88
+ const pair = await crypto.subtle.generateKey({
89
+ name: "ECDSA",
90
+ namedCurve: "P-256"
91
+ }, true, ["sign", "verify"]);
92
+ const kid = crypto.randomUUID();
93
+ return {
94
+ jwks: { keys: [{
95
+ ...await crypto.subtle.exportKey("jwk", pair.publicKey),
96
+ kid,
97
+ alg: "ES256",
98
+ use: "sig"
99
+ }] },
100
+ async sign(claims) {
101
+ const { expiresIn = 3600, ...rest } = claims;
102
+ const now = Math.floor(Date.now() / 1e3);
103
+ const payload = {
104
+ aud: "authenticated",
105
+ role: "authenticated",
106
+ aal: "aal1",
107
+ iat: now,
108
+ exp: now + expiresIn,
109
+ ...rest
110
+ };
111
+ const header = base64url(JSON.stringify({
112
+ alg: "ES256",
113
+ typ: "JWT",
114
+ kid
115
+ }));
116
+ const body = base64url(JSON.stringify(payload));
117
+ const signature = await crypto.subtle.sign({
118
+ name: "ECDSA",
119
+ hash: "SHA-256"
120
+ }, pair.privateKey, encoder.encode(`${header}.${body}`));
121
+ return `${header}.${body}.${base64url(new Uint8Array(signature))}`;
122
+ }
123
+ };
124
+ }
125
+ //#endregion
126
+ //#region src/testing/as-user.ts
127
+ /** The local CLI stack's default JWT secret. */
128
+ const LOCAL_JWT_SECRET = "super-secret-jwt-token-with-at-least-32-characters-long";
129
+ function env$1(...names) {
130
+ if (typeof process === "undefined") return void 0;
131
+ for (const name of names) {
132
+ const value = process.env[name];
133
+ if (value) return value;
134
+ }
135
+ }
136
+ /**
137
+ * A test user against the local stack: a signed token and repositories that
138
+ * run as that user, for live RLS tests.
139
+ *
140
+ * ```ts
141
+ * const alice = await asUser(sb, { sub: aliceId, org_id: acme });
142
+ * expect(await alice.db.customers.count().orThrow()).toBe(3);
143
+ * ```
144
+ */
145
+ async function asUser(sb, claims, stack = {}) {
146
+ const url = stack.url ?? env$1(...ENV_VARIABLES.url) ?? "http://127.0.0.1:54321";
147
+ const publishableKey = stack.publishableKey ?? env$1(...ENV_VARIABLES.publishableKey, "SUPABASE_ANON_KEY");
148
+ if (!publishableKey) throw new TypeError("asUser needs publishableKey or $SUPABASE_PUBLISHABLE_KEY. Run `better-supabase env` to write it to .env.local.");
149
+ const token = await signTestJwt(stack.jwtSecret ?? env$1("SUPABASE_JWT_SECRET") ?? "super-secret-jwt-token-with-at-least-32-characters-long", claims);
150
+ const full = {
151
+ role: "authenticated",
152
+ ...claims
153
+ };
154
+ const context = {
155
+ actor: {
156
+ id: claims.sub,
157
+ kind: "user",
158
+ role: full.role ?? "authenticated",
159
+ ...typeof claims.email === "string" ? { email: claims.email } : {}
160
+ },
161
+ claims: full
162
+ };
163
+ const supabase = createClient(url, publishableKey, { accessToken: () => Promise.resolve(token) });
164
+ return {
165
+ token,
166
+ claims: full,
167
+ supabase,
168
+ db: sb.connect(supabase, context),
169
+ sql: stack.postgres ? sb.connect(postgresExecutor(stack.postgres.asUser(full)), context) : void 0
170
+ };
171
+ }
172
+ //#endregion
173
+ //#region src/testing/budget.ts
174
+ function isPrefetch(response) {
175
+ const headers = response.request().headers();
176
+ return headers["next-router-prefetch"] !== void 0 || headers["purpose"] === "prefetch" || (headers["sec-purpose"]?.includes("prefetch") ?? false);
177
+ }
178
+ function settle(promise, ms) {
179
+ let timer;
180
+ return Promise.race([promise, new Promise((done) => {
181
+ timer = setTimeout(done, ms);
182
+ })]).finally(() => clearTimeout(timer));
183
+ }
184
+ /**
185
+ * Measures every document and RSC navigation response during `during` and
186
+ * fails when one render makes more database calls or waves than allowed.
187
+ * Needs `createNext(sb, { debug })` and `next.debugRoute()` in the app.
188
+ */
189
+ async function expectDbBudget(page, expectation) {
190
+ const seen = [];
191
+ const listener = (response) => {
192
+ const headers = response.headers();
193
+ const id = headers["x-bs-request-id"];
194
+ const stats = headers["x-bs-stats"];
195
+ if (!id || !stats || !expectation.prefetches && isPrefetch(response)) return;
196
+ seen.push({
197
+ response,
198
+ id,
199
+ url: new URL(stats, response.url()).href
200
+ });
201
+ };
202
+ page.on("response", listener);
203
+ try {
204
+ await (expectation.during ? expectation.during() : page.reload());
205
+ } finally {
206
+ page.off("response", listener);
207
+ }
208
+ if (seen.length === 0) throw new Error("expectDbBudget: no response carried x-bs-request-id. Enable createNext(sb, { debug: { enabled: true } }) and run the proxy.");
209
+ await Promise.all(seen.map(({ response }) => settle(response.finished(), expectation.timeoutMs ?? 1e4)));
210
+ const renders = [];
211
+ for (const { response, id, url } of seen) {
212
+ const reply = await page.request.get(url);
213
+ if (!reply.ok()) throw new Error(`expectDbBudget: ${url} answered ${reply.status()}. Mount next.debugRoute() at that path.`);
214
+ renders.push({
215
+ url: response.url(),
216
+ requestId: id,
217
+ stats: await reply.json()
218
+ });
219
+ }
220
+ const over = renders.filter(({ stats }) => expectation.maxCalls !== void 0 && stats.calls > expectation.maxCalls || expectation.maxWaves !== void 0 && stats.waves > expectation.maxWaves);
221
+ if (over.length > 0) {
222
+ const lines = over.map(({ url, stats }) => ` ${url}: ${stats.calls} calls in ${stats.waves} waves (${stats.tables.join(", ")})`);
223
+ throw new Error(`expectDbBudget: over the budget of ${expectation.maxCalls ?? "∞"} calls and ${expectation.maxWaves ?? "∞"} waves:\n${lines.join("\n")}`);
224
+ }
225
+ return renders;
226
+ }
227
+ //#endregion
228
+ //#region src/testing/conformance.ts
229
+ /** Thrown by a conformance kit when any check fails. Lists every failure, not just the first. */
230
+ var ConformanceError = class extends Error {
231
+ report;
232
+ constructor(report) {
233
+ const failed = report.checks.filter((check) => !check.ok);
234
+ super(`${report.subject} failed ${failed.length} of ${report.checks.length} conformance checks:\n${failed.map((check) => ` - ${check.name}: ${check.message ?? "failed"}`).join("\n")}`);
235
+ this.name = "ConformanceError";
236
+ this.report = report;
237
+ }
238
+ };
239
+ var Violation = class extends Error {};
240
+ function expect(condition, message) {
241
+ if (!condition) throw new Violation(message);
242
+ }
243
+ async function conform(subject, checks) {
244
+ const results = [];
245
+ for (const entry of checks) {
246
+ if (!entry) continue;
247
+ const [name, run] = entry;
248
+ try {
249
+ await run();
250
+ results.push({
251
+ name,
252
+ ok: true
253
+ });
254
+ } catch (cause) {
255
+ const message = cause instanceof Violation ? cause.message : `threw ${cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause)}`;
256
+ results.push({
257
+ name,
258
+ ok: false,
259
+ message
260
+ });
261
+ }
262
+ }
263
+ const report = {
264
+ subject,
265
+ checks: results
266
+ };
267
+ if (results.some((check) => !check.ok)) throw new ConformanceError(report);
268
+ return report;
269
+ }
270
+ function deepFreeze(value) {
271
+ if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
272
+ Object.freeze(value);
273
+ for (const key of Reflect.ownKeys(value)) deepFreeze(value[key]);
274
+ }
275
+ return value;
276
+ }
277
+ const frozenCopy = (value) => deepFreeze(structuredClone(value));
278
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
279
+ const recorder = (ops) => ({
280
+ name: "recorder",
281
+ execute: (op) => {
282
+ ops.push(op);
283
+ return Promise.resolve(ok({
284
+ rows: [],
285
+ count: 0
286
+ }));
287
+ }
288
+ });
289
+ /** The operation `run` sends to the executor, built by `sb` without touching a database. */
290
+ async function captureOp(sb, run) {
291
+ const ops = [];
292
+ await run(sb.connect(recorder(ops)));
293
+ const [op] = ops;
294
+ if (!op) throw new Error("the repository call did not reach the executor");
295
+ return op;
296
+ }
297
+ function tableKey(sb, table) {
298
+ const key = table ?? Object.keys(sb.meta.tables)[0];
299
+ if (!key || !(key in sb.meta.tables)) throw new TypeError(`better-supabase: unknown table "${String(key)}"`);
300
+ return key;
301
+ }
302
+ const hasName = (subject) => ["has a name", () => expect(typeof subject.name === "string" && subject.name.length > 0, "name must be a non-empty string")];
303
+ /**
304
+ * Proves an `Executor` meets the contract: rows keyed by the selection's
305
+ * aliases, counts, `aborted` errors, failures returned as `Result`s rather
306
+ * than thrown, and (with `create`) a write round trip.
307
+ *
308
+ * ```ts
309
+ * it('conforms', () => testExecutor(myExecutor, { sb, table: 'tags', create: { name: 'x' } }));
310
+ * ```
311
+ */
312
+ function testExecutor(executor, options) {
313
+ const { sb } = options;
314
+ const table = tableKey(sb, options.table);
315
+ const context = { errorMappers: [] };
316
+ const read = () => captureOp(sb, (db) => db[table].findMany({ limit: 2 }));
317
+ const create = options.create;
318
+ return conform(`Executor "${executor.name}"`, [
319
+ hasName(executor),
320
+ ["reads rows keyed by the selection aliases", async () => {
321
+ const op = await read();
322
+ const result = await executor.execute(op, context);
323
+ expect(result.ok, `select failed: ${result.ok ? "" : result.error.message}`);
324
+ expect(Array.isArray(result.data.rows), "rows must be an array");
325
+ expect(result.data.rows.length <= 2, `limit 2 returned ${result.data.rows.length} rows`);
326
+ expect(result.data.count === null || typeof result.data.count === "number", "count must be a number or null");
327
+ if (op.kind !== "select") return;
328
+ const aliases = new Set(op.selection.columns.map((column) => column.alias));
329
+ for (const row of result.data.rows) {
330
+ const extra = Object.keys(row).filter((key) => !aliases.has(key));
331
+ expect(extra.length === 0, `rows must use selection aliases; got ${extra.join(", ")}`);
332
+ }
333
+ }],
334
+ ["counts rows", async () => {
335
+ const op = await captureOp(sb, (db) => db[table].count());
336
+ const result = await executor.execute(op, context);
337
+ expect(result.ok, `count failed: ${result.ok ? "" : result.error.message}`);
338
+ expect(typeof result.data.count === "number" && result.data.count >= 0, "count must be a number");
339
+ }],
340
+ ["returns an aborted error for an aborted signal", async () => {
341
+ const controller = new AbortController();
342
+ controller.abort();
343
+ const result = await executor.execute(await read(), {
344
+ ...context,
345
+ signal: controller.signal
346
+ });
347
+ expect(!result.ok, "an aborted request must fail");
348
+ expect(result.error.kind === "aborted", `expected kind "aborted", got "${result.error.kind}"`);
349
+ }],
350
+ ["returns failures as results instead of throwing", async () => {
351
+ const op = await read();
352
+ const missing = {
353
+ ...op,
354
+ table: {
355
+ ...op.table,
356
+ name: "__better_supabase_missing__"
357
+ }
358
+ };
359
+ const result = await executor.execute(missing, context);
360
+ expect(!result.ok, "a missing table must fail");
361
+ expect(typeof result.error.kind === "string" && typeof result.error.message === "string", "errors must be DbErrors");
362
+ }],
363
+ executor.rpc && ["returns rpc failures as results", async () => {
364
+ expect(!(await executor.rpc("__better_supabase_missing__", {}, {
365
+ ...context,
366
+ schema: "public"
367
+ })).ok, "calling a missing function must fail");
368
+ }],
369
+ executor.functionSources && ["reads from SelectOp.source instead of the table", async () => {
370
+ const op = await read();
371
+ if (op.kind !== "select") return;
372
+ expect(!(await executor.execute({
373
+ ...op,
374
+ source: {
375
+ schema: op.table.schema,
376
+ name: "__better_supabase_missing__",
377
+ args: {}
378
+ }
379
+ }, context)).ok, "a missing source function must fail; reading the table ignores source");
380
+ }],
381
+ executor.batch && ["batch returns one result per operation, in order", async () => {
382
+ const rows = await read();
383
+ const count = await captureOp(sb, (db) => db[table].count());
384
+ const missing = {
385
+ ...rows,
386
+ table: {
387
+ ...rows.table,
388
+ name: "__better_supabase_missing__"
389
+ }
390
+ };
391
+ const results = await executor.batch([
392
+ rows,
393
+ count,
394
+ missing
395
+ ], context);
396
+ expect(results.length === 3, `expected 3 results, got ${results.length}`);
397
+ const [first, second, third] = results;
398
+ expect(first?.ok === true && Array.isArray(first.data.rows), "the first operation must return rows");
399
+ expect(second?.ok === true && typeof second.data.count === "number", "the second operation must return a count");
400
+ expect(third?.ok === false, "a failing operation must fail on its own, as a result");
401
+ }],
402
+ create && ["round-trips a write", async () => {
403
+ const repository = sb.connect(executor)[table];
404
+ const created = await repository.create(create);
405
+ expect(created.ok, `create failed: ${JSON.stringify(created.error)}`);
406
+ const row = created.data;
407
+ for (const [key, value] of Object.entries(create)) expect(same(row[key], value), `created row has ${key} = ${JSON.stringify(row[key])}`);
408
+ const [primary] = sb.meta.tables[table].primaryKey;
409
+ expect(primary, "write checks need a table with a primary key");
410
+ const removed = await repository.delete(row[primary]);
411
+ expect(removed.ok, `delete failed: ${JSON.stringify(removed.error)}`);
412
+ const again = await repository.findFirst({ where: { [primary]: row[primary] } });
413
+ expect(again.ok && again.data === null, "the deleted row is still readable");
414
+ }]
415
+ ]);
416
+ }
417
+ /**
418
+ * Proves a `CacheAdapter` accepts table and row targets, repeated and
419
+ * concurrent calls, and never mutates its input.
420
+ */
421
+ function testCacheAdapter(adapter, options = {}) {
422
+ const table = options.table ?? "customers";
423
+ const invalidate = (target) => Promise.resolve(adapter.invalidate(deepFreeze(target)));
424
+ return conform(`CacheAdapter "${adapter.name}"`, [
425
+ hasName(adapter),
426
+ ["invalidates a table", () => invalidate({
427
+ table,
428
+ tables: [table],
429
+ ids: []
430
+ })],
431
+ ["invalidates rows of a tenant", () => invalidate({
432
+ table,
433
+ tables: [table],
434
+ ids: ["1", "a,b"],
435
+ tenant: "org_1"
436
+ })],
437
+ ["accepts repeated and concurrent calls", async () => {
438
+ await invalidate({
439
+ table,
440
+ tables: [table],
441
+ ids: ["1"]
442
+ });
443
+ await Promise.all(Array.from({ length: 10 }, (_, index) => invalidate({
444
+ table,
445
+ tables: [table],
446
+ ids: [String(index)]
447
+ })));
448
+ }],
449
+ ["accepts tables it has never seen", () => invalidate({
450
+ table: "__better_supabase_unknown__",
451
+ tables: ["__better_supabase_unknown__"],
452
+ ids: []
453
+ })],
454
+ ["invalidates tables reached through foreign keys", () => invalidate({
455
+ table,
456
+ tables: [table, `${table}_children`],
457
+ ids: ["1"]
458
+ })]
459
+ ]);
460
+ }
461
+ /** Proves an `EventSink` accepts empty and full batches without mutating them, and delivers them when readable. */
462
+ function testEventSink(sink, options = {}) {
463
+ const events = toCloudEvents({
464
+ table: "customers",
465
+ kind: "insert",
466
+ rows: [{
467
+ id: "1",
468
+ name: "Acme"
469
+ }, {
470
+ id: "2",
471
+ name: "Globex"
472
+ }],
473
+ context: { tenant: "org_1" }
474
+ }, { source: "/better-supabase/conformance" });
475
+ return conform("EventSink", [
476
+ ["accepts an empty batch", () => sink.send(deepFreeze([]))],
477
+ ["accepts a batch without mutating it", () => sink.send(frozenCopy(events))],
478
+ options.received && ["delivers every event", async () => {
479
+ const ids = new Set((await options.received()).map((event) => event.id));
480
+ const missing = events.filter((event) => !ids.has(event.id));
481
+ expect(missing.length === 0, `${missing.length} of ${events.length} events were not delivered`);
482
+ }]
483
+ ]);
484
+ }
485
+ async function resolveSafely(resolver, request) {
486
+ try {
487
+ return await resolver.resolve(request);
488
+ } catch (cause) {
489
+ throw new Violation(`resolve() threw: ${cause instanceof Error ? cause.message : String(cause)}`);
490
+ }
491
+ }
492
+ /**
493
+ * Proves an `AuthResolver` passes on requests it doesn't understand, fails
494
+ * closed (`invalid`, never anon) on bad credentials, and never throws.
495
+ */
496
+ function testAuthResolver(resolver, options) {
497
+ return conform(`AuthResolver "${resolver.name}"`, [
498
+ hasName(resolver),
499
+ ["passes on requests without its credentials", async () => {
500
+ const state = await resolveSafely(resolver, options.unrelated ?? new Request("https://example.com/"));
501
+ expect(state === void 0, `expected undefined, got kind "${state?.kind}"`);
502
+ }],
503
+ ["fails closed on invalid credentials", async () => {
504
+ expect(options.invalid.length > 0, "pass at least one invalid request");
505
+ for (const request of options.invalid) {
506
+ const state = await resolveSafely(resolver, request);
507
+ expect(state?.kind === "invalid", `expected kind "invalid", got "${state?.kind ?? "undefined"}"`);
508
+ expect(typeof state.error.kind === "string", "invalid states carry a DbError");
509
+ }
510
+ }],
511
+ options.valid && ["resolves valid credentials", async () => {
512
+ for (const { request, userId } of options.valid) {
513
+ const state = await resolveSafely(resolver, request);
514
+ expect(state?.kind === "user" || state?.kind === "service", `expected a user or service, got "${state?.kind}"`);
515
+ if (userId !== void 0) expect(state.kind === "user" && state.user.id === userId, `expected user ${userId}`);
516
+ }
517
+ }]
518
+ ]);
519
+ }
520
+ function relativeImport(from, to) {
521
+ const fromParts = from.split("/").slice(0, -1);
522
+ const toParts = to.split("/");
523
+ let common = 0;
524
+ while (common < fromParts.length && fromParts[common] === toParts[common]) common += 1;
525
+ const path = [...Array.from({ length: fromParts.length - common }, () => ".."), ...toParts.slice(common)].join("/");
526
+ return path.startsWith(".") ? path : `./${path}`;
527
+ }
528
+ const EMPTY_INTROSPECTION = {
529
+ version: 1,
530
+ schemas: [],
531
+ tables: [],
532
+ foreignTables: [],
533
+ views: [],
534
+ materializedViews: [],
535
+ columns: [],
536
+ primaryKeys: [],
537
+ relationships: [],
538
+ functions: [],
539
+ types: []
540
+ };
541
+ /** Proves a `Generator` writes relative, unique paths, is deterministic and doesn't mutate its input. */
542
+ function testGenerator(generator, options) {
543
+ const config = options.config ?? resolveConfig({}, "/project");
544
+ const input = () => ({
545
+ meta: frozenCopy(options.meta),
546
+ introspection: frozenCopy(options.introspection ?? EMPTY_INTROSPECTION),
547
+ extras: frozenCopy(options.extras ?? {
548
+ tables: [],
549
+ buckets: [],
550
+ realtime: []
551
+ }),
552
+ config,
553
+ output: `${config.root}/${config.output}`,
554
+ importPath: (from, to) => relativeImport(from, to)
555
+ });
556
+ return conform(`Generator "${generator.name}"`, [
557
+ hasName(generator),
558
+ ["writes files inside the project", async () => {
559
+ const files = await generator.generate(input());
560
+ expect(Array.isArray(files), "generate() must return an array");
561
+ for (const file of files) {
562
+ expect(typeof file.path === "string" && typeof file.contents === "string", "files need a path and contents");
563
+ expect(!file.path.startsWith("/") && !/^[A-Za-z]:/.test(file.path), `${file.path} must be relative`);
564
+ expect(!file.path.split(/[\\/]/).includes(".."), `${file.path} must stay inside the project`);
565
+ }
566
+ const paths = files.map((file) => file.path);
567
+ expect(new Set(paths).size === paths.length, "paths must be unique");
568
+ }],
569
+ ["is deterministic and does not mutate its input", async () => {
570
+ const first = await generator.generate(input());
571
+ const second = await generator.generate(input());
572
+ expect(same(first, second), "two runs on the same input differ");
573
+ }]
574
+ ]);
575
+ }
576
+ /**
577
+ * Proves a `Plugin` targets API v1, installs cleanly, keeps `transformQuery`
578
+ * and `beforeMutation` pure and deterministic, and keeps executors'
579
+ * results intact when it wraps them.
580
+ */
581
+ function testPlugin(plugin, options) {
582
+ const { sb } = options;
583
+ const table = tableKey(sb, options.table);
584
+ const context = options.context ?? {};
585
+ const hook = (op) => ({
586
+ table: op.table,
587
+ schema: sb.meta,
588
+ context,
589
+ options: {},
590
+ now: () => /* @__PURE__ */ new Date(0)
591
+ });
592
+ const create = options.create;
593
+ return conform(`Plugin "${plugin.name}"`, [
594
+ hasName(plugin),
595
+ ["targets plugin API v1", () => expect(plugin.apiVersion === 1, `apiVersion is ${String(plugin.apiVersion)}`)],
596
+ ["installs and builds repositories", () => {
597
+ const db = sb.use(plugin).connect(recorder([]), context);
598
+ for (const key of Object.keys(sb.meta.tables)) expect(db[key], `db.${key} is missing`);
599
+ }],
600
+ plugin.transformQuery && ["transformQuery is pure and deterministic", async () => {
601
+ const op = await captureOp(sb, (db) => db[table].findMany({ limit: 1 }));
602
+ const first = plugin.transformQuery(frozenCopy(op), hook(op));
603
+ const second = plugin.transformQuery(frozenCopy(op), hook(op));
604
+ expect(typeof first === "object" && first !== null && first.kind === op.kind, "must return an operation of the same kind");
605
+ expect(same(first, second), "two calls with the same input differ");
606
+ }],
607
+ plugin.beforeMutation && create && ["beforeMutation is pure and deterministic", async () => {
608
+ const op = await captureOp(sb, (db) => db[table].create(create));
609
+ if (op.kind === "select") throw new Violation("create did not produce a mutation");
610
+ const first = await plugin.beforeMutation(frozenCopy(op), hook(op));
611
+ const second = await plugin.beforeMutation(frozenCopy(op), hook(op));
612
+ expect(first.kind === op.kind, "must return a mutation of the same kind");
613
+ expect(same(first, second), "two calls with the same input differ");
614
+ }],
615
+ plugin.wrapExecutor && ["wrapExecutor keeps results intact", async () => {
616
+ const op = await captureOp(sb, (db) => db[table].findMany({ limit: 1 }));
617
+ const data = {
618
+ rows: [{ marker: "conformance" }],
619
+ count: 1
620
+ };
621
+ const wrapped = plugin.wrapExecutor({
622
+ name: "inner",
623
+ execute: () => Promise.resolve(ok(data))
624
+ });
625
+ expect(typeof wrapped.name === "string", "the wrapped executor needs a name");
626
+ const result = await wrapped.execute(op, { errorMappers: [] });
627
+ expect(result.ok && same(result.data, data), "the wrapped executor changed the result");
628
+ }],
629
+ plugin.mapError && ["mapError returns a DbError or undefined", () => {
630
+ const fallback = dbError("unexpected", "internal error");
631
+ const mapped = plugin.mapError({
632
+ code: "XX000",
633
+ message: "internal error"
634
+ }, fallback);
635
+ expect(mapped === void 0 || typeof mapped === "object" && typeof mapped.kind === "string", "must return a DbError or undefined");
636
+ }]
637
+ ]);
638
+ }
639
+ //#endregion
640
+ //#region src/testing/isolation.ts
641
+ const scalar = (value) => typeof value === "string" || typeof value === "number" || typeof value === "boolean";
642
+ function keyOf(table, row) {
643
+ return Object.fromEntries(table.primaryKey.map((column) => [column, row[column]]));
644
+ }
645
+ async function rowsOf(outcome) {
646
+ const result = await outcome;
647
+ if (!result.ok) throw new Error(result.error.message);
648
+ return Array.isArray(result.data) ? result.data : [result.data];
649
+ }
650
+ function env(names) {
651
+ if (typeof process === "undefined") return void 0;
652
+ for (const name of names) {
653
+ const value = process.env[name];
654
+ if (value) return value;
655
+ }
656
+ }
657
+ /**
658
+ * Checks row-level security between two tenants against a running stack: for
659
+ * each table, a user of one tenant must not select, insert, update or delete
660
+ * the other tenant's rows. Runs without `sb`'s plugins, so the `tenant()`
661
+ * plugin can't hide a missing policy. Throws a `ConformanceError` naming each
662
+ * table and command that leaks; removes every row it created.
663
+ *
664
+ * ```ts
665
+ * await expectTenantIsolation(sb, {
666
+ * tenants: [
667
+ * { id: ACME, claims: { sub: alice, org_id: ACME } },
668
+ * { id: GLOBEX, claims: { sub: bob, org_id: GLOBEX } },
669
+ * ],
670
+ * tables: {
671
+ * tags: {
672
+ * row: (tenant, n) => ({ organizationId: tenant.id, name: `iso-${n}` }),
673
+ * update: { name: 'changed' },
674
+ * },
675
+ * },
676
+ * });
677
+ * ```
678
+ */
679
+ async function expectTenantIsolation(sb, options) {
680
+ const stack = options.stack ?? {};
681
+ const bare = new BetterSupabase(sb.schema);
682
+ const url = stack.url ?? env(ENV_VARIABLES.url) ?? "http://127.0.0.1:54321";
683
+ const secretKey = stack.secretKey ?? env(ENV_VARIABLES.secretKey);
684
+ if (!secretKey) throw new TypeError("expectTenantIsolation needs stack.secretKey or $SUPABASE_SECRET_KEY to seed rows.");
685
+ const admin = bare.connect(createClient(url, secretKey, { auth: {
686
+ persistSession: false,
687
+ autoRefreshToken: false
688
+ } }));
689
+ await options.seed?.();
690
+ const tenants = options.tenants;
691
+ const users = await Promise.all(tenants.map(async (tenant) => (await asUser(bare, tenant.claims, stack)).db));
692
+ const label = (tenant) => tenant.name ?? tenant.id;
693
+ const cleanup = [];
694
+ const reads = [];
695
+ const deletes = [];
696
+ try {
697
+ for (const [key, spec] of Object.entries(options.tables)) {
698
+ const table = bare.meta.tables[key];
699
+ if (!table || table.primaryKey.length === 0) throw new TypeError(`expectTenantIsolation: "${key}" is not a table with a primary key`);
700
+ const seeded = [];
701
+ for (const tenant of tenants) {
702
+ const [row] = await rowsOf(admin[key].create(spec.row(tenant, 0)));
703
+ const where = keyOf(table, row);
704
+ cleanup.push(() => admin[key].deleteMany({ where }));
705
+ seeded.push(row);
706
+ }
707
+ for (const [self, other] of [[0, 1], [1, 0]]) {
708
+ const user = users[self][key];
709
+ const who = label(tenants[self]);
710
+ const whose = `${label(tenants[other])}'s`;
711
+ const own = keyOf(table, seeded[self]);
712
+ const target = keyOf(table, seeded[other]);
713
+ const current = async () => (await rowsOf(admin[key].findMany({ where: target })))[0];
714
+ reads.push([`${key}: ${who} reads its own row`, async () => {
715
+ expect((await rowsOf(user.findMany({ where: own }))).length === 1, `${who} can't read its own row, so the other checks prove nothing; check its claims and memberships`);
716
+ }], [`${key}: ${who} can't select ${whose} rows`, async () => {
717
+ expect((await rowsOf(user.findMany({ where: target }))).length === 0, `select returned ${whose} row`);
718
+ }], [`${key}: ${who} can't insert ${whose} rows`, async () => {
719
+ const row = spec.row(tenants[other], 1);
720
+ const result = await user.create(row, { returning: false });
721
+ if (result.ok) {
722
+ const where = Object.fromEntries(Object.entries(row).filter(([, value]) => scalar(value)));
723
+ cleanup.push(() => admin[key].deleteMany({ where }));
724
+ }
725
+ expect(!result.ok, `insert of a row for ${whose} tenant succeeded`);
726
+ expect(result.error.kind === "forbidden", `insert failed with ${result.error.kind} (${result.error.message}), not an RLS error; check the row factory`);
727
+ }], [`${key}: ${who} can't update ${whose} rows`, async () => {
728
+ const before = await current();
729
+ const patch = spec.update;
730
+ expect(Object.entries(patch).some(([column, value]) => before?.[column] !== value), "update must change the row to detect a leak");
731
+ await user.updateMany({
732
+ where: target,
733
+ data: patch,
734
+ returning: false
735
+ });
736
+ const after = await current();
737
+ expect(Object.entries(patch).every(([column]) => after?.[column] === before?.[column]), `update changed ${whose} row`);
738
+ }]);
739
+ deletes.push([`${key}: ${who} can't delete ${whose} rows`, async () => {
740
+ await user.deleteMany({ where: target });
741
+ expect(await current() !== void 0, `delete removed ${whose} row`);
742
+ }]);
743
+ }
744
+ }
745
+ return await conform("Tenant isolation", [...reads, ...deletes]);
746
+ } finally {
747
+ for (const remove of cleanup.reverse()) await remove();
748
+ }
749
+ }
750
+ //#endregion
751
+ export { ConformanceError, LOCAL_JWT_SECRET, asUser, createTestSigner, defineSeed, expectDbBudget, expectTenantIsolation, isSeed, localAuth, signTestJwt, testAuthResolver, testCacheAdapter, testEventSink, testExecutor, testGenerator, testPlugin };