better-supabase 0.0.0 → 0.2.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 (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -1
  3. package/bin/better-supabase.js +4 -0
  4. package/dist/casing-da0uRTqt.js +14 -0
  5. package/dist/claims-CoTobNC-.js +25 -0
  6. package/dist/cli/bin.d.ts +1 -0
  7. package/dist/cli/bin.js +15 -0
  8. package/dist/cli/index.d.ts +226 -0
  9. package/dist/cli/index.js +3 -0
  10. package/dist/cli-DrSgDOQC.js +6646 -0
  11. package/dist/client/index.d.ts +2 -0
  12. package/dist/client/index.js +112 -0
  13. package/dist/compiler-CZ9nIuqy.d.ts +14 -0
  14. package/dist/config/index.d.ts +3 -0
  15. package/dist/config/index.js +3 -0
  16. package/dist/config-ClDXmVtY.js +277 -0
  17. package/dist/define-BXMYD9AY.js +1071 -0
  18. package/dist/define-e3zI4_4g.d.ts +679 -0
  19. package/dist/edge/index.d.ts +43 -0
  20. package/dist/edge/index.js +103 -0
  21. package/dist/entitlements-CTEBm7IB.js +20 -0
  22. package/dist/entitlements-Dng-ctuT.d.ts +42 -0
  23. package/dist/env/index.d.ts +2 -0
  24. package/dist/env/index.js +2 -0
  25. package/dist/env-BEToab0I.js +204 -0
  26. package/dist/errors-Bl3rkOzo.js +196 -0
  27. package/dist/events/index.d.ts +2 -0
  28. package/dist/events/index.js +2 -0
  29. package/dist/events-BuzTgn2D.js +135 -0
  30. package/dist/events-DOt3nSO-.d.ts +84 -0
  31. package/dist/executor-BYXQ9AGn.d.ts +25 -0
  32. package/dist/executor-CGdNQ27g.d.ts +195 -0
  33. package/dist/executor-DeppvirK.js +89 -0
  34. package/dist/guards-BnzDvFrz.js +10 -0
  35. package/dist/hono/index.d.ts +45 -0
  36. package/dist/hono/index.js +70 -0
  37. package/dist/index-Bw1bwtC0.d.ts +144 -0
  38. package/dist/index-CBDufH1M.d.ts +120 -0
  39. package/dist/index-CG69s15v.d.ts +38 -0
  40. package/dist/index-Ciu5Xlml.d.ts +74 -0
  41. package/dist/index-D4v7sncu.d.ts +62 -0
  42. package/dist/index-DOsjFvMf.d.ts +618 -0
  43. package/dist/index-DbxFr7XG.d.ts +76 -0
  44. package/dist/index-DuP14VHo.d.ts +216 -0
  45. package/dist/index-Dy6kuF0P.d.ts +71 -0
  46. package/dist/index-K-3WM2Hp.d.ts +117 -0
  47. package/dist/index.d.ts +110 -0
  48. package/dist/index.js +43 -0
  49. package/dist/invalidate-DbxuL2jL.js +18 -0
  50. package/dist/jobs/index.d.ts +173 -0
  51. package/dist/jobs/index.js +478 -0
  52. package/dist/json-schema-BpYqkCKf.js +182 -0
  53. package/dist/lint/index.d.ts +75 -0
  54. package/dist/lint/index.js +167 -0
  55. package/dist/list/index.d.ts +2 -0
  56. package/dist/list/index.js +410 -0
  57. package/dist/live-D8rV1Ldw.js +168 -0
  58. package/dist/live-DJyZMD6w.d.ts +191 -0
  59. package/dist/logger-glM28NVN.js +21 -0
  60. package/dist/mcp/index.d.ts +142 -0
  61. package/dist/mcp/index.js +463 -0
  62. package/dist/mfa-CpLfJSde.js +541 -0
  63. package/dist/mfa-TVia554c.d.ts +44 -0
  64. package/dist/next/image/index.d.ts +34 -0
  65. package/dist/next/image/index.js +40 -0
  66. package/dist/next/index.d.ts +210 -0
  67. package/dist/next/index.js +427 -0
  68. package/dist/openapi/index.d.ts +2 -0
  69. package/dist/openapi/index.js +332 -0
  70. package/dist/orpc/index.d.ts +46 -0
  71. package/dist/orpc/index.js +50 -0
  72. package/dist/otel/index.d.ts +40 -0
  73. package/dist/otel/index.js +168 -0
  74. package/dist/path-Cv__-OTj.d.ts +17 -0
  75. package/dist/permdock-sql-CghlHPuH.js +117 -0
  76. package/dist/plugin-BqR6wKMB.js +23 -0
  77. package/dist/plugin-C4TR9J0Z.d.ts +118 -0
  78. package/dist/plugins/actor/index.d.ts +13 -0
  79. package/dist/plugins/actor/index.js +43 -0
  80. package/dist/plugins/rules/index.d.ts +89 -0
  81. package/dist/plugins/rules/index.js +167 -0
  82. package/dist/plugins/soft-delete/index.d.ts +38 -0
  83. package/dist/plugins/soft-delete/index.js +69 -0
  84. package/dist/plugins/tenant/index.d.ts +49 -0
  85. package/dist/plugins/tenant/index.js +75 -0
  86. package/dist/plugins/timestamps/index.d.ts +9 -0
  87. package/dist/plugins/timestamps/index.js +37 -0
  88. package/dist/plugins/validation/index.d.ts +24 -0
  89. package/dist/plugins/validation/index.js +59 -0
  90. package/dist/pool-R2m_DkXi.d.ts +37 -0
  91. package/dist/postgres/index.d.ts +28 -0
  92. package/dist/postgres/index.js +79 -0
  93. package/dist/postgrest-cAEQ_IQN.d.ts +43 -0
  94. package/dist/problem-BTkTtRV9.js +97 -0
  95. package/dist/problem-DlgB0lYl.d.ts +47 -0
  96. package/dist/query/index.d.ts +2 -0
  97. package/dist/query/index.js +3 -0
  98. package/dist/query-ChvDbsDE.js +186 -0
  99. package/dist/react/index.d.ts +7 -0
  100. package/dist/react/index.js +5 -0
  101. package/dist/react/server.d.ts +13 -0
  102. package/dist/react/server.js +25 -0
  103. package/dist/react/session.d.ts +2 -0
  104. package/dist/react/session.js +3 -0
  105. package/dist/react-C83BdBGt.js +242 -0
  106. package/dist/read-set-4lI3W_gZ.js +216 -0
  107. package/dist/realtime/index.d.ts +4 -0
  108. package/dist/realtime/index.js +268 -0
  109. package/dist/resolve-CjHILk2w.d.ts +170 -0
  110. package/dist/resource-BHEWmQJK.js +175 -0
  111. package/dist/resource-CgZa3YG_.d.ts +65 -0
  112. package/dist/respond-DMcbQwfN.js +383 -0
  113. package/dist/respond-Dne1ARSa.d.ts +169 -0
  114. package/dist/result-CTkCZ6JA.d.ts +198 -0
  115. package/dist/result-DWXatkd6.js +103 -0
  116. package/dist/scope-CDHcS7dE.js +90 -0
  117. package/dist/seed-CzecODcJ.js +101 -0
  118. package/dist/server/index.d.ts +42 -0
  119. package/dist/server/index.js +68 -0
  120. package/dist/session-C3LxnQ0e.d.ts +22 -0
  121. package/dist/session-CqEwzVup.js +20 -0
  122. package/dist/shared-_6-uPL7E.js +57 -0
  123. package/dist/simplify-bwJpLnaS.js +665 -0
  124. package/dist/spec-pins-C3oc-Tdp.js +20 -0
  125. package/dist/spec-pins-CqorWDnO.d.ts +20 -0
  126. package/dist/sql-DTjA1LPj.js +260 -0
  127. package/dist/ssr/index.d.ts +5 -0
  128. package/dist/ssr/index.js +4 -0
  129. package/dist/standard-CyhRzctb.d.ts +7 -0
  130. package/dist/standard-FUi3Z3-7.js +21 -0
  131. package/dist/stats-C5W-rBfR.js +566 -0
  132. package/dist/storage/index.d.ts +5 -0
  133. package/dist/storage/index.js +2 -0
  134. package/dist/storage-DuGn38fr.js +527 -0
  135. package/dist/tables-rZxRxNPW.js +59 -0
  136. package/dist/template-mbvlysOo.d.ts +6 -0
  137. package/dist/testing/index.d.ts +325 -0
  138. package/dist/testing/index.js +819 -0
  139. package/dist/types-6cYK9pbJ.js +29 -0
  140. package/dist/types-C83v5SQz.d.ts +248 -0
  141. package/dist/version-Cqq1m3UY.js +4 -0
  142. package/dist/view-D1s0wa0T.d.ts +49 -0
  143. package/dist/view-aOYizTeE.js +36 -0
  144. package/dist/webhooks/index.d.ts +152 -0
  145. package/dist/webhooks/index.js +2 -0
  146. package/dist/webhooks-DMipjhSP.js +139 -0
  147. package/package.json +250 -17
  148. package/schemas/config-v1.json +373 -0
  149. package/schemas/doctor-report-v1.json +106 -0
  150. package/schemas/snapshot-v2.json +1006 -0
  151. package/skills/better-supabase/SKILL.md +84 -0
  152. package/skills/better-supabase/references/plugins.md +65 -0
  153. package/skills/better-supabase/references/troubleshooting.md +39 -0
  154. package/skills/better-supabase-api/SKILL.md +67 -0
  155. package/skills/better-supabase-api/references/adapters.md +108 -0
  156. package/skills/better-supabase-testing/SKILL.md +66 -0
  157. package/index.js +0 -1
@@ -0,0 +1,410 @@
1
+ //#region src/list/index.ts
2
+ /** Facet value meaning "not set": filters with `is null`. */
3
+ const UNSET = "__unset__";
4
+ const RESERVED = /* @__PURE__ */ new Set([
5
+ "q",
6
+ "sort",
7
+ "page",
8
+ "size"
9
+ ]);
10
+ function first(value) {
11
+ return typeof value === "string" ? value : value?.[0];
12
+ }
13
+ function all(value) {
14
+ return (typeof value === "string" ? [value] : [...value ?? []]).flatMap((entry) => entry.split(",")).map((entry) => entry.trim()).filter(Boolean);
15
+ }
16
+ function isSearchParams(value) {
17
+ return value instanceof URLSearchParams || !("facets" in value && typeof value.facets === "object");
18
+ }
19
+ function readRecord(input) {
20
+ if (!(input instanceof URLSearchParams)) return input;
21
+ const out = {};
22
+ for (const [key, value] of input) (out[key] ??= []).push(value);
23
+ return out;
24
+ }
25
+ function toInt(raw) {
26
+ if (typeof raw === "number") return raw;
27
+ if (typeof raw !== "string" || !/^\d+$/.test(raw)) return NaN;
28
+ return Number(raw);
29
+ }
30
+ /**
31
+ * A reusable list endpoint for one table: search, facets, sorts and
32
+ * pagination, validated from URL params or typed input, with URL parsers,
33
+ * OpenAPI parameters and a JSON Schema derived from the same definition.
34
+ *
35
+ * ```ts
36
+ * export const customerList = defineListQuery(sb, 'customers', {
37
+ * search: ['name', 'kvk'],
38
+ * facets: { status: 'status' },
39
+ * sorts: { name: { name: 'asc' }, newest: [{ createdAt: 'desc' }, { id: 'asc' }] },
40
+ * defaultSort: 'newest',
41
+ * });
42
+ * ```
43
+ */
44
+ function defineListQuery(sb, table, config) {
45
+ const meta = sb.meta.tables[table];
46
+ if (!meta) throw new TypeError(`defineListQuery: unknown table "${table}"`);
47
+ const pageSize = config.pageSize ?? 50;
48
+ const maxPageSize = config.maxPageSize ?? 200;
49
+ const maxSearch = config.maxSearchLength ?? 200;
50
+ const sortKeys = Object.keys(config.sorts);
51
+ const facetEntries = Object.entries(config.facets ?? {});
52
+ for (const [key, column] of facetEntries) {
53
+ if (RESERVED.has(key)) throw new TypeError(`defineListQuery: facet key "${key}" is reserved`);
54
+ if (!meta.columns[column]) throw new TypeError(`defineListQuery: unknown column "${table}.${column}"`);
55
+ }
56
+ if (!sortKeys.includes(config.defaultSort)) throw new TypeError(`defineListQuery: defaultSort "${config.defaultSort}" is not in sorts`);
57
+ const facets = facetEntries.map(([key, column]) => {
58
+ const columnMeta = meta.columns[column];
59
+ return {
60
+ key,
61
+ column,
62
+ nullable: columnMeta.nullable,
63
+ ...columnMeta.enum ? { values: columnMeta.enum } : {}
64
+ };
65
+ });
66
+ const defaults = {
67
+ sort: config.defaultSort,
68
+ page: 1,
69
+ size: pageSize,
70
+ facets: {}
71
+ };
72
+ function normalize(raw) {
73
+ const issues = [];
74
+ let q;
75
+ if (raw.q !== void 0 && raw.q !== null) {
76
+ if (typeof raw.q !== "string") issues.push({
77
+ message: "Must be text",
78
+ path: ["q"]
79
+ });
80
+ else if (raw.q.trim().length > maxSearch) issues.push({
81
+ message: `At most ${String(maxSearch)} characters`,
82
+ path: ["q"]
83
+ });
84
+ else if (raw.q.trim()) q = raw.q.trim();
85
+ }
86
+ let sort = config.defaultSort;
87
+ if (raw.sort !== void 0 && raw.sort !== "") {
88
+ if (typeof raw.sort === "string" && sortKeys.includes(raw.sort)) sort = raw.sort;
89
+ else issues.push({
90
+ message: `Must be one of: ${sortKeys.join(", ")}`,
91
+ path: ["sort"]
92
+ });
93
+ }
94
+ const page = raw.page === void 0 || raw.page === "" ? 1 : toInt(raw.page);
95
+ if (page === void 0 || !Number.isInteger(page) || page < 1) issues.push({
96
+ message: "Must be a whole number of at least 1",
97
+ path: ["page"]
98
+ });
99
+ const size = raw.size === void 0 || raw.size === "" ? pageSize : toInt(raw.size);
100
+ if (size === void 0 || !Number.isInteger(size) || size < 1 || size > maxPageSize) issues.push({
101
+ message: `Must be a whole number from 1 to ${String(maxPageSize)}`,
102
+ path: ["size"]
103
+ });
104
+ const facetValues = {};
105
+ for (const facet of facets) {
106
+ const values = raw.facets[facet.key];
107
+ if (!values || values.length === 0) continue;
108
+ for (const value of values) if (!(value === "__unset__" ? facet.nullable : !facet.values || facet.values.includes(value))) issues.push({
109
+ message: value === "__unset__" ? "This filter cannot be empty" : `Must be one of: ${(facet.values ?? []).join(", ")}`,
110
+ path: ["facets", facet.key]
111
+ });
112
+ facetValues[facet.key] = [...new Set(values)];
113
+ }
114
+ if (issues.length > 0) return {
115
+ ok: false,
116
+ issues
117
+ };
118
+ return {
119
+ ok: true,
120
+ value: {
121
+ ...q ? { q } : {},
122
+ sort,
123
+ page: page ?? 1,
124
+ size: size ?? pageSize,
125
+ facets: facetValues
126
+ }
127
+ };
128
+ }
129
+ function parse(input) {
130
+ if (input === void 0 || input === null) return {
131
+ ok: true,
132
+ value: defaults
133
+ };
134
+ if (typeof input !== "object") return {
135
+ ok: false,
136
+ issues: [{ message: "Expected an object or URL parameters" }]
137
+ };
138
+ if (isSearchParams(input)) {
139
+ const record = readRecord(input);
140
+ const facetValues = {};
141
+ for (const facet of facets) facetValues[facet.key] = all(record[facet.key]);
142
+ return normalize({
143
+ q: first(record["q"]),
144
+ sort: first(record["sort"]),
145
+ page: first(record["page"]),
146
+ size: first(record["size"]),
147
+ facets: facetValues
148
+ });
149
+ }
150
+ const typed = input;
151
+ const rawFacets = typed.facets ?? {};
152
+ for (const [key, value] of Object.entries(rawFacets)) if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) return {
153
+ ok: false,
154
+ issues: [{
155
+ message: "Must be a list of values",
156
+ path: ["facets", key]
157
+ }]
158
+ };
159
+ return normalize({
160
+ q: typed.q,
161
+ sort: typed.sort,
162
+ page: typed.page,
163
+ size: typed.size,
164
+ facets: rawFacets
165
+ });
166
+ }
167
+ function searchWhere(query) {
168
+ if (!query.q || !config.search) return void 0;
169
+ if (Array.isArray(config.search)) {
170
+ const columns = config.search;
171
+ if (columns.length === 0) return void 0;
172
+ return { OR: columns.map((column) => ({ [column]: { contains: query.q } })) };
173
+ }
174
+ const fts = config.search;
175
+ return { [fts.fts]: { search: fts.config ? {
176
+ query: query.q,
177
+ config: fts.config
178
+ } : query.q } };
179
+ }
180
+ function facetWhere(facet, values) {
181
+ if (!values || values.length === 0) return void 0;
182
+ const unset = values.includes(UNSET);
183
+ const set = values.filter((value) => value !== UNSET);
184
+ if (unset && set.length === 0) return { [facet.column]: null };
185
+ if (!unset) return { [facet.column]: { in: set } };
186
+ return { OR: [{ [facet.column]: { in: set } }, { [facet.column]: null }] };
187
+ }
188
+ function and(parts) {
189
+ const present = parts.filter((part) => part !== void 0);
190
+ if (present.length === 0) return void 0;
191
+ return present.length === 1 ? present[0] : { AND: present };
192
+ }
193
+ function where(query) {
194
+ return and([searchWhere(query), ...facets.map((facet) => facetWhere(facet, query.facets[facet.key]))]);
195
+ }
196
+ function args(query) {
197
+ const filter = where(query);
198
+ return {
199
+ ...filter ? { where: filter } : {},
200
+ orderBy: config.sorts[query.sort],
201
+ page: query.page,
202
+ size: query.size,
203
+ count: config.count ?? "exact"
204
+ };
205
+ }
206
+ /** Whether a group's value passes the facet's selected values. */
207
+ function selects(value, selected) {
208
+ if (!selected || selected.length === 0) return true;
209
+ return value === null || value === void 0 ? selected.includes(UNSET) : selected.includes(String(value));
210
+ }
211
+ /**
212
+ * Per facet, rows per value under every other facet's selection: the
213
+ * usual faceted-search counts, where picking a value doesn't zero the
214
+ * other values of the same facet.
215
+ */
216
+ function marginals(groups, query) {
217
+ const out = {};
218
+ for (const facet of facets) {
219
+ const counts = {};
220
+ for (const value of facet.values ?? []) counts[value] = 0;
221
+ for (const group of groups) {
222
+ if (!facets.every((other) => other === facet || selects(group[other.column], query.facets[other.key]))) continue;
223
+ const raw = group[facet.column];
224
+ const key = raw === null || raw === void 0 ? UNSET : String(raw);
225
+ counts[key] = (counts[key] ?? 0) + Number(group["_count"] ?? 0);
226
+ }
227
+ out[facet.key] = counts;
228
+ }
229
+ return out;
230
+ }
231
+ const listParser = {
232
+ parse: (value) => {
233
+ const values = all(value);
234
+ return values.length > 0 ? values : null;
235
+ },
236
+ serialize: (values) => values.join(",")
237
+ };
238
+ const intParser = {
239
+ parse: (value) => /^\d+$/.test(value) ? Number(value) : null,
240
+ serialize: String
241
+ };
242
+ const parsers = {
243
+ q: {
244
+ parse: (value) => value.trim() || null,
245
+ serialize: (value) => value
246
+ },
247
+ sort: {
248
+ parse: (value) => sortKeys.includes(value) ? value : null,
249
+ serialize: (value) => value
250
+ },
251
+ page: intParser,
252
+ size: intParser,
253
+ ...Object.fromEntries(facets.map((facet) => [facet.key, listParser]))
254
+ };
255
+ const facetSchema = (facet) => ({
256
+ type: "array",
257
+ items: {
258
+ type: "string",
259
+ ...facet.values ? { enum: [...facet.values, ...facet.nullable ? [UNSET] : []] } : {}
260
+ },
261
+ uniqueItems: true
262
+ });
263
+ const searchDescription = config.search ? Array.isArray(config.search) ? `Case-insensitive search in ${config.search.join(", ")}.` : "Full-text search (web search syntax)." : void 0;
264
+ return {
265
+ table,
266
+ defaults,
267
+ facets,
268
+ parse,
269
+ schema: { "~standard": {
270
+ version: 1,
271
+ vendor: "better-supabase",
272
+ validate(value) {
273
+ const result = parse(value);
274
+ return result.ok ? { value: result.value } : { issues: result.issues };
275
+ }
276
+ } },
277
+ toSearchParams(query) {
278
+ const params = new URLSearchParams();
279
+ if (query.q) params.set("q", query.q);
280
+ if (query.sort && query.sort !== config.defaultSort) params.set("sort", query.sort);
281
+ if (query.page && query.page !== 1) params.set("page", String(query.page));
282
+ if (query.size && query.size !== pageSize) params.set("size", String(query.size));
283
+ for (const facet of facets) {
284
+ const values = query.facets?.[facet.key];
285
+ if (values && values.length > 0) params.set(facet.key, values.join(","));
286
+ }
287
+ return params;
288
+ },
289
+ parsers,
290
+ nuqs: (createParser) => Object.fromEntries(Object.entries(parsers).map(([key, spec]) => [key, createParser(spec)])),
291
+ args,
292
+ run(db, query, extra) {
293
+ const base = args(query);
294
+ const { where: extraWhere, count, select: _select, include: _include, ...options } = extra ?? {};
295
+ const combined = and([extraWhere, base["where"]]);
296
+ const repository = db[table];
297
+ const page = repository.paginate({
298
+ ...extra,
299
+ ...base,
300
+ count: count ?? base["count"],
301
+ ...combined ? { where: combined } : {}
302
+ });
303
+ if (!config.facetCounts) return page;
304
+ if (facets.length === 0) return page.map((data) => ({
305
+ ...data,
306
+ facetCounts: {}
307
+ }));
308
+ const facetFilter = and([extraWhere, searchWhere(query)]);
309
+ const groups = repository.aggregate({
310
+ ...options,
311
+ ...facetFilter ? { where: facetFilter } : {},
312
+ groupBy: [...new Set(facets.map((facet) => facet.column))],
313
+ _count: true
314
+ });
315
+ return page.andThen((data) => groups.map((rows) => ({
316
+ ...data,
317
+ facetCounts: marginals(rows, query)
318
+ })));
319
+ },
320
+ openapi: [
321
+ ...searchDescription ? [{
322
+ name: "q",
323
+ in: "query",
324
+ required: false,
325
+ description: searchDescription,
326
+ schema: {
327
+ type: "string",
328
+ maxLength: maxSearch
329
+ }
330
+ }] : [],
331
+ {
332
+ name: "sort",
333
+ in: "query",
334
+ required: false,
335
+ description: `Ordering. Default \`${config.defaultSort}\`.`,
336
+ schema: {
337
+ type: "string",
338
+ enum: sortKeys,
339
+ default: config.defaultSort
340
+ }
341
+ },
342
+ {
343
+ name: "page",
344
+ in: "query",
345
+ required: false,
346
+ description: "1-based page number.",
347
+ schema: {
348
+ type: "integer",
349
+ minimum: 1,
350
+ default: 1
351
+ }
352
+ },
353
+ {
354
+ name: "size",
355
+ in: "query",
356
+ required: false,
357
+ description: "Page size.",
358
+ schema: {
359
+ type: "integer",
360
+ minimum: 1,
361
+ maximum: maxPageSize,
362
+ default: pageSize
363
+ }
364
+ },
365
+ ...facets.map((facet) => ({
366
+ name: facet.key,
367
+ in: "query",
368
+ required: false,
369
+ description: `Filter on ${facet.column}. Comma-separated; any value matches.${facet.nullable ? ` \`${UNSET}\` matches empty.` : ""}`,
370
+ schema: facetSchema(facet),
371
+ style: "form",
372
+ explode: false
373
+ }))
374
+ ],
375
+ jsonSchema: {
376
+ type: "object",
377
+ additionalProperties: false,
378
+ properties: {
379
+ ...searchDescription ? { q: {
380
+ type: "string",
381
+ maxLength: maxSearch,
382
+ description: searchDescription
383
+ } } : {},
384
+ sort: {
385
+ type: "string",
386
+ enum: sortKeys,
387
+ default: config.defaultSort
388
+ },
389
+ page: {
390
+ type: "integer",
391
+ minimum: 1,
392
+ default: 1
393
+ },
394
+ size: {
395
+ type: "integer",
396
+ minimum: 1,
397
+ maximum: maxPageSize,
398
+ default: pageSize
399
+ },
400
+ ...facets.length > 0 ? { facets: {
401
+ type: "object",
402
+ additionalProperties: false,
403
+ properties: Object.fromEntries(facets.map((facet) => [facet.key, facetSchema(facet)]))
404
+ } } : {}
405
+ }
406
+ }
407
+ };
408
+ }
409
+ //#endregion
410
+ export { UNSET, defineListQuery };
@@ -0,0 +1,168 @@
1
+ import { REALTIME_SUBSCRIBE_STATES } from "@supabase/supabase-js";
2
+ //#region src/realtime/live.ts
3
+ /** Topic a `realtime-tables` trigger broadcasts on: `bs:t:<schema>.<table>[:<tenant>]`. */
4
+ function liveTopic(meta, table, tenant) {
5
+ const found = meta.tables[table];
6
+ if (!found) throw new TypeError(`better-supabase: unknown table "${table}"`);
7
+ const base = `bs:t:${found.schema}.${found.name}`;
8
+ if (!meta.realtime?.[table]?.tenant) return base;
9
+ if (tenant === void 0) throw new TypeError(`better-supabase: "${table}" broadcasts per tenant; pass \`tenant\` to watch it`);
10
+ return `${base}:${tenant}`;
11
+ }
12
+ const channels = /* @__PURE__ */ new WeakMap();
13
+ function join(client, topic, listener) {
14
+ let byTopic = channels.get(client);
15
+ if (!byTopic) {
16
+ byTopic = /* @__PURE__ */ new Map();
17
+ channels.set(client, byTopic);
18
+ }
19
+ let shared = byTopic.get(topic);
20
+ if (!shared) {
21
+ const channel = client.channel(topic, { config: { private: true } });
22
+ const listeners = /* @__PURE__ */ new Set();
23
+ channel.on("broadcast", { event: "change" }, () => {
24
+ for (const notify of listeners) notify();
25
+ });
26
+ let joined = false;
27
+ const ready = (async () => {
28
+ await client.realtime.setAuth();
29
+ await new Promise((resolve, reject) => {
30
+ channel.subscribe((status, error) => {
31
+ switch (status) {
32
+ case REALTIME_SUBSCRIBE_STATES.SUBSCRIBED:
33
+ if (joined) for (const notify of listeners) notify();
34
+ joined = true;
35
+ resolve();
36
+ return;
37
+ case REALTIME_SUBSCRIBE_STATES.CLOSED:
38
+ resolve();
39
+ return;
40
+ case REALTIME_SUBSCRIBE_STATES.CHANNEL_ERROR:
41
+ case REALTIME_SUBSCRIBE_STATES.TIMED_OUT:
42
+ reject(error ?? /* @__PURE__ */ new Error(`Realtime ${status.toLowerCase()} on ${topic}`));
43
+ return;
44
+ default: reject(/* @__PURE__ */ new Error(`Unknown realtime status ${String(status)}`));
45
+ }
46
+ });
47
+ });
48
+ })();
49
+ ready.catch(() => void 0);
50
+ shared = {
51
+ channel,
52
+ listeners,
53
+ ready
54
+ };
55
+ byTopic.set(topic, shared);
56
+ }
57
+ const current = shared;
58
+ current.listeners.add(listener);
59
+ let left = false;
60
+ return {
61
+ ready: current.ready,
62
+ leave: async () => {
63
+ if (left) return;
64
+ left = true;
65
+ current.listeners.delete(listener);
66
+ if (current.listeners.size > 0) return;
67
+ byTopic.delete(topic);
68
+ await client.removeChannel(current.channel);
69
+ }
70
+ };
71
+ }
72
+ /**
73
+ * Calls `onChange` when a table the spec reads changes, so you can refetch.
74
+ * Needs the `realtime-tables` SQL module and the tables in
75
+ * `realtime.tables`. Live queries on the same client share one channel per
76
+ * table, so any number of them can run at once.
77
+ *
78
+ * ```ts
79
+ * using live = liveQuery(sb, supabase, spec, { onChange: () => refetch() });
80
+ * ```
81
+ */
82
+ function liveQuery(sb, client, spec, options) {
83
+ const touched = Array.isArray(spec) ? spec : sb.tablesOf(spec);
84
+ const tables = touched.filter((table) => sb.meta.realtime?.[table]);
85
+ const unwatched = touched.filter((table) => !sb.meta.realtime?.[table]);
86
+ const debounceMs = options.debounceMs ?? 100;
87
+ const changed = /* @__PURE__ */ new Set();
88
+ let timer;
89
+ let closed = false;
90
+ const flush = () => {
91
+ timer = void 0;
92
+ if (closed || changed.size === 0) return;
93
+ const batch = [...changed];
94
+ changed.clear();
95
+ options.onChange(batch);
96
+ };
97
+ const memberships = tables.map((table) => join(client, liveTopic(sb.meta, table, options.tenant), () => {
98
+ changed.add(table);
99
+ if (timer !== void 0) clearTimeout(timer);
100
+ timer = setTimeout(flush, debounceMs);
101
+ }));
102
+ options.onStatus?.(tables.length === 0 ? "closed" : "joining");
103
+ const ready = Promise.all(memberships.map((member) => member.ready)).then(() => {
104
+ if (!closed && tables.length > 0) options.onStatus?.("subscribed");
105
+ }, (cause) => {
106
+ const error = cause instanceof Error ? cause : new Error(String(cause));
107
+ options.onStatus?.("error", error);
108
+ throw error;
109
+ });
110
+ ready.catch(() => void 0);
111
+ const unsubscribe = async () => {
112
+ if (closed) return;
113
+ closed = true;
114
+ if (timer !== void 0) clearTimeout(timer);
115
+ await Promise.all(memberships.map((member) => member.leave()));
116
+ options.onStatus?.("closed");
117
+ };
118
+ return {
119
+ tables,
120
+ unwatched,
121
+ ready,
122
+ unsubscribe,
123
+ [Symbol.dispose]: () => void unsubscribe(),
124
+ [Symbol.asyncDispose]: unsubscribe
125
+ };
126
+ }
127
+ /**
128
+ * Keeps a count current: runs the `count` spec (a HEAD request) now, after
129
+ * each debounced change to a table it reads, and after the channel rejoins.
130
+ * Responses that arrive out of order are dropped.
131
+ *
132
+ * ```ts
133
+ * using live = liveCount(sb, supabase, db, sb.spec.notes.count(), {
134
+ * onCount: (count) => render(count),
135
+ * });
136
+ * ```
137
+ */
138
+ function liveCount(sb, client, db, spec, options) {
139
+ let latest = 0;
140
+ let closed = false;
141
+ const refetch = () => {
142
+ const call = ++latest;
143
+ Promise.resolve(db.$run(spec)).then((result) => {
144
+ if (closed || call !== latest) return;
145
+ if (result.ok) options.onCount(result.data);
146
+ else options.onError?.(result.error);
147
+ });
148
+ };
149
+ const live = liveQuery(sb, client, spec, {
150
+ onChange: refetch,
151
+ ...options.tenant === void 0 ? {} : { tenant: options.tenant },
152
+ ...options.debounceMs === void 0 ? {} : { debounceMs: options.debounceMs },
153
+ ...options.onStatus ? { onStatus: options.onStatus } : {}
154
+ });
155
+ if (options.immediate ?? true) refetch();
156
+ const unsubscribe = async () => {
157
+ closed = true;
158
+ await live.unsubscribe();
159
+ };
160
+ return {
161
+ ...live,
162
+ unsubscribe,
163
+ [Symbol.dispose]: () => void unsubscribe(),
164
+ [Symbol.asyncDispose]: unsubscribe
165
+ };
166
+ }
167
+ //#endregion
168
+ export { liveQuery as n, liveTopic as r, liveCount as t };