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.
- package/LICENSE +21 -0
- package/README.md +88 -1
- package/bin/better-supabase.js +4 -0
- package/dist/casing-da0uRTqt.js +14 -0
- package/dist/claims-CoTobNC-.js +25 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +15 -0
- package/dist/cli/index.d.ts +226 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli-DrSgDOQC.js +6646 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +112 -0
- package/dist/compiler-CZ9nIuqy.d.ts +14 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.js +3 -0
- package/dist/config-ClDXmVtY.js +277 -0
- package/dist/define-BXMYD9AY.js +1071 -0
- package/dist/define-e3zI4_4g.d.ts +679 -0
- package/dist/edge/index.d.ts +43 -0
- package/dist/edge/index.js +103 -0
- package/dist/entitlements-CTEBm7IB.js +20 -0
- package/dist/entitlements-Dng-ctuT.d.ts +42 -0
- package/dist/env/index.d.ts +2 -0
- package/dist/env/index.js +2 -0
- package/dist/env-BEToab0I.js +204 -0
- package/dist/errors-Bl3rkOzo.js +196 -0
- package/dist/events/index.d.ts +2 -0
- package/dist/events/index.js +2 -0
- package/dist/events-BuzTgn2D.js +135 -0
- package/dist/events-DOt3nSO-.d.ts +84 -0
- package/dist/executor-BYXQ9AGn.d.ts +25 -0
- package/dist/executor-CGdNQ27g.d.ts +195 -0
- package/dist/executor-DeppvirK.js +89 -0
- package/dist/guards-BnzDvFrz.js +10 -0
- package/dist/hono/index.d.ts +45 -0
- package/dist/hono/index.js +70 -0
- package/dist/index-Bw1bwtC0.d.ts +144 -0
- package/dist/index-CBDufH1M.d.ts +120 -0
- package/dist/index-CG69s15v.d.ts +38 -0
- package/dist/index-Ciu5Xlml.d.ts +74 -0
- package/dist/index-D4v7sncu.d.ts +62 -0
- package/dist/index-DOsjFvMf.d.ts +618 -0
- package/dist/index-DbxFr7XG.d.ts +76 -0
- package/dist/index-DuP14VHo.d.ts +216 -0
- package/dist/index-Dy6kuF0P.d.ts +71 -0
- package/dist/index-K-3WM2Hp.d.ts +117 -0
- package/dist/index.d.ts +110 -0
- package/dist/index.js +43 -0
- package/dist/invalidate-DbxuL2jL.js +18 -0
- package/dist/jobs/index.d.ts +173 -0
- package/dist/jobs/index.js +478 -0
- package/dist/json-schema-BpYqkCKf.js +182 -0
- package/dist/lint/index.d.ts +75 -0
- package/dist/lint/index.js +167 -0
- package/dist/list/index.d.ts +2 -0
- package/dist/list/index.js +410 -0
- package/dist/live-D8rV1Ldw.js +168 -0
- package/dist/live-DJyZMD6w.d.ts +191 -0
- package/dist/logger-glM28NVN.js +21 -0
- package/dist/mcp/index.d.ts +142 -0
- package/dist/mcp/index.js +463 -0
- package/dist/mfa-CpLfJSde.js +541 -0
- package/dist/mfa-TVia554c.d.ts +44 -0
- package/dist/next/image/index.d.ts +34 -0
- package/dist/next/image/index.js +40 -0
- package/dist/next/index.d.ts +210 -0
- package/dist/next/index.js +427 -0
- package/dist/openapi/index.d.ts +2 -0
- package/dist/openapi/index.js +332 -0
- package/dist/orpc/index.d.ts +46 -0
- package/dist/orpc/index.js +50 -0
- package/dist/otel/index.d.ts +40 -0
- package/dist/otel/index.js +168 -0
- package/dist/path-Cv__-OTj.d.ts +17 -0
- package/dist/permdock-sql-CghlHPuH.js +117 -0
- package/dist/plugin-BqR6wKMB.js +23 -0
- package/dist/plugin-C4TR9J0Z.d.ts +118 -0
- package/dist/plugins/actor/index.d.ts +13 -0
- package/dist/plugins/actor/index.js +43 -0
- package/dist/plugins/rules/index.d.ts +89 -0
- package/dist/plugins/rules/index.js +167 -0
- package/dist/plugins/soft-delete/index.d.ts +38 -0
- package/dist/plugins/soft-delete/index.js +69 -0
- package/dist/plugins/tenant/index.d.ts +49 -0
- package/dist/plugins/tenant/index.js +75 -0
- package/dist/plugins/timestamps/index.d.ts +9 -0
- package/dist/plugins/timestamps/index.js +37 -0
- package/dist/plugins/validation/index.d.ts +24 -0
- package/dist/plugins/validation/index.js +59 -0
- package/dist/pool-R2m_DkXi.d.ts +37 -0
- package/dist/postgres/index.d.ts +28 -0
- package/dist/postgres/index.js +79 -0
- package/dist/postgrest-cAEQ_IQN.d.ts +43 -0
- package/dist/problem-BTkTtRV9.js +97 -0
- package/dist/problem-DlgB0lYl.d.ts +47 -0
- package/dist/query/index.d.ts +2 -0
- package/dist/query/index.js +3 -0
- package/dist/query-ChvDbsDE.js +186 -0
- package/dist/react/index.d.ts +7 -0
- package/dist/react/index.js +5 -0
- package/dist/react/server.d.ts +13 -0
- package/dist/react/server.js +25 -0
- package/dist/react/session.d.ts +2 -0
- package/dist/react/session.js +3 -0
- package/dist/react-C83BdBGt.js +242 -0
- package/dist/read-set-4lI3W_gZ.js +216 -0
- package/dist/realtime/index.d.ts +4 -0
- package/dist/realtime/index.js +268 -0
- package/dist/resolve-CjHILk2w.d.ts +170 -0
- package/dist/resource-BHEWmQJK.js +175 -0
- package/dist/resource-CgZa3YG_.d.ts +65 -0
- package/dist/respond-DMcbQwfN.js +383 -0
- package/dist/respond-Dne1ARSa.d.ts +169 -0
- package/dist/result-CTkCZ6JA.d.ts +198 -0
- package/dist/result-DWXatkd6.js +103 -0
- package/dist/scope-CDHcS7dE.js +90 -0
- package/dist/seed-CzecODcJ.js +101 -0
- package/dist/server/index.d.ts +42 -0
- package/dist/server/index.js +68 -0
- package/dist/session-C3LxnQ0e.d.ts +22 -0
- package/dist/session-CqEwzVup.js +20 -0
- package/dist/shared-_6-uPL7E.js +57 -0
- package/dist/simplify-bwJpLnaS.js +665 -0
- package/dist/spec-pins-C3oc-Tdp.js +20 -0
- package/dist/spec-pins-CqorWDnO.d.ts +20 -0
- package/dist/sql-DTjA1LPj.js +260 -0
- package/dist/ssr/index.d.ts +5 -0
- package/dist/ssr/index.js +4 -0
- package/dist/standard-CyhRzctb.d.ts +7 -0
- package/dist/standard-FUi3Z3-7.js +21 -0
- package/dist/stats-C5W-rBfR.js +566 -0
- package/dist/storage/index.d.ts +5 -0
- package/dist/storage/index.js +2 -0
- package/dist/storage-DuGn38fr.js +527 -0
- package/dist/tables-rZxRxNPW.js +59 -0
- package/dist/template-mbvlysOo.d.ts +6 -0
- package/dist/testing/index.d.ts +325 -0
- package/dist/testing/index.js +819 -0
- package/dist/types-6cYK9pbJ.js +29 -0
- package/dist/types-C83v5SQz.d.ts +248 -0
- package/dist/version-Cqq1m3UY.js +4 -0
- package/dist/view-D1s0wa0T.d.ts +49 -0
- package/dist/view-aOYizTeE.js +36 -0
- package/dist/webhooks/index.d.ts +152 -0
- package/dist/webhooks/index.js +2 -0
- package/dist/webhooks-DMipjhSP.js +139 -0
- package/package.json +250 -17
- package/schemas/config-v1.json +373 -0
- package/schemas/doctor-report-v1.json +106 -0
- package/schemas/snapshot-v2.json +1006 -0
- package/skills/better-supabase/SKILL.md +84 -0
- package/skills/better-supabase/references/plugins.md +65 -0
- package/skills/better-supabase/references/troubleshooting.md +39 -0
- package/skills/better-supabase-api/SKILL.md +67 -0
- package/skills/better-supabase-api/references/adapters.md +108 -0
- package/skills/better-supabase-testing/SKILL.md +66 -0
- 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 };
|