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.
- package/LICENSE +21 -0
- package/README.md +88 -1
- package/bin/better-supabase.js +3 -0
- package/dist/casing-da0uRTqt.js +14 -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-CoByWYja.js +6472 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +112 -0
- package/dist/compiler-DL18ukPe.d.ts +14 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.js +3 -0
- package/dist/config-D-aR2ZoN.js +276 -0
- package/dist/define-CTfWw-SS.d.ts +662 -0
- package/dist/define-QGkq04oe.js +1068 -0
- package/dist/edge/index.d.ts +43 -0
- package/dist/edge/index.js +103 -0
- package/dist/entitlements-BBg61DQZ.d.ts +20 -0
- package/dist/entitlements-BC_khwRy.js +17 -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-2OJsVG_d.d.ts +84 -0
- package/dist/events-BuzTgn2D.js +135 -0
- package/dist/executor-Bazhgsgq.d.ts +195 -0
- package/dist/executor-Bje4suQF.d.ts +25 -0
- package/dist/executor-Ceq60hPM.js +89 -0
- package/dist/hono/index.d.ts +45 -0
- package/dist/hono/index.js +70 -0
- package/dist/index-BD6wKthp.d.ts +597 -0
- package/dist/index-DQVvHzjW.d.ts +74 -0
- package/dist/index-DRid0qIv.d.ts +76 -0
- package/dist/index-Dup-nSe9.d.ts +117 -0
- package/dist/index-Dy6kuF0P.d.ts +71 -0
- package/dist/index-Hf8PP9sP.d.ts +116 -0
- package/dist/index-JXHBuS53.d.ts +144 -0
- package/dist/index-Nxo3TJcm.d.ts +208 -0
- package/dist/index-Sw43J8HG.d.ts +62 -0
- package/dist/index-cEeweTeM.d.ts +38 -0
- package/dist/index.d.ts +110 -0
- package/dist/index.js +42 -0
- package/dist/invalidate-DbxuL2jL.js +18 -0
- package/dist/jobs/index.d.ts +173 -0
- package/dist/jobs/index.js +477 -0
- package/dist/json-schema-BbwO2mZg.js +181 -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-6b9i72Ux.d.ts +180 -0
- package/dist/live-D8rV1Ldw.js +168 -0
- package/dist/mcp/index.d.ts +105 -0
- package/dist/mcp/index.js +330 -0
- package/dist/mfa-CpogP66z.js +521 -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 +426 -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/plugin-BqR6wKMB.js +23 -0
- package/dist/plugin-HvGqCurB.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 +88 -0
- package/dist/plugins/rules/index.js +174 -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 +43 -0
- package/dist/plugins/tenant/index.js +73 -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-BFeqzwQS.d.ts +37 -0
- package/dist/postgres/index.d.ts +28 -0
- package/dist/postgres/index.js +79 -0
- package/dist/postgrest-Dm-vhY6R.d.ts +43 -0
- package/dist/problem-DPu6Fh-t.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-CrOvrnIP.js +241 -0
- package/dist/read-set-DhX2d04c.js +216 -0
- package/dist/realtime/index.d.ts +3 -0
- package/dist/realtime/index.js +253 -0
- package/dist/resolve-DW8h9NPm.d.ts +155 -0
- package/dist/resource-CYiu6Ygi.js +175 -0
- package/dist/resource-SkPTRJDD.d.ts +65 -0
- package/dist/respond-4-dhoV7x.js +380 -0
- package/dist/respond-PoTpy5DT.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-C3osL21k.d.ts +21 -0
- package/dist/session-CD-3orO_.js +19 -0
- package/dist/shared-_6-uPL7E.js +57 -0
- package/dist/simplify-oHiDKRVQ.js +665 -0
- package/dist/spec-pins-CJPVghHy.d.ts +20 -0
- package/dist/spec-pins-t6sPaHvl.js +20 -0
- package/dist/sql-M8l3fnS0.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-3iy8u3_P.js +566 -0
- package/dist/storage/index.d.ts +4 -0
- package/dist/storage/index.js +2 -0
- package/dist/storage-BWXVUXGk.js +461 -0
- package/dist/tables-rZxRxNPW.js +59 -0
- package/dist/template-BeOGnynM.js +93 -0
- package/dist/template-mbvlysOo.d.ts +6 -0
- package/dist/testing/index.d.ts +294 -0
- package/dist/testing/index.js +751 -0
- package/dist/types-6cYK9pbJ.js +29 -0
- package/dist/types-C2fslP1z.d.ts +195 -0
- package/dist/version-Cqq1m3UY.js +4 -0
- package/dist/view-B0ltUmTi.d.ts +43 -0
- package/dist/view-B6F9QbG-.js +35 -0
- package/dist/webhooks/index.d.ts +152 -0
- package/dist/webhooks/index.js +2 -0
- package/dist/webhooks-DbMTJJX2.js +139 -0
- package/package.json +246 -17
- package/schemas/config-v1.json +328 -0
- package/schemas/doctor-report-v1.json +105 -0
- package/schemas/snapshot-v2.json +1002 -0
- package/skills/better-supabase/SKILL.md +84 -0
- package/skills/better-supabase/references/plugins.md +56 -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 +65 -0
- package/index.js +0 -1
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import { n as dbError, o as isDbError, t as DbException } from "./errors-Bl3rkOzo.js";
|
|
2
|
+
import { i as ok, n as err, o as toDbError, t as AsyncResult } from "./result-DWXatkd6.js";
|
|
3
|
+
import { i as sqlString, n as slug, r as sqlIdent, t as parseTemplate } from "./template-BeOGnynM.js";
|
|
4
|
+
//#region src/storage/index.ts
|
|
5
|
+
const TTL = {
|
|
6
|
+
minute: 60,
|
|
7
|
+
hour: 3600,
|
|
8
|
+
day: 86400,
|
|
9
|
+
week: 604800
|
|
10
|
+
};
|
|
11
|
+
const SIZE_UNITS = {
|
|
12
|
+
b: 1,
|
|
13
|
+
kb: 1e3,
|
|
14
|
+
kib: 1024,
|
|
15
|
+
mb: 1e3 ** 2,
|
|
16
|
+
mib: 1024 ** 2,
|
|
17
|
+
gb: 1e3 ** 3,
|
|
18
|
+
gib: 1024 ** 3
|
|
19
|
+
};
|
|
20
|
+
function parseSize(value) {
|
|
21
|
+
if (typeof value === "number") return value;
|
|
22
|
+
const match = /^\s*(\d+(?:\.\d+)?)\s*([a-z]*)\s*$/i.exec(value);
|
|
23
|
+
const unit = SIZE_UNITS[(match?.[2] || "b").toLowerCase()];
|
|
24
|
+
if (!match || unit === void 0) throw new TypeError(`Invalid size "${value}"`);
|
|
25
|
+
return Math.round(Number(match[1]) * unit);
|
|
26
|
+
}
|
|
27
|
+
const SAFE_SEGMENT = /^[\w!\-.*'() &$@=;:+,?]+$/;
|
|
28
|
+
function validateSegment(_name, value) {
|
|
29
|
+
if (value === "." || value === "..") return "is a relative path";
|
|
30
|
+
if (!SAFE_SEGMENT.test(value)) return "contains characters Storage does not allow";
|
|
31
|
+
}
|
|
32
|
+
function claimSql(claim) {
|
|
33
|
+
const expressions = (typeof claim === "string" ? [claim] : claim).map((path) => {
|
|
34
|
+
const keys = path.split(".");
|
|
35
|
+
const last = keys.pop();
|
|
36
|
+
return `(select auth.jwt())${keys.map((key) => ` -> ${sqlString(key)}`).join("")} ->> ${sqlString(last)}`;
|
|
37
|
+
});
|
|
38
|
+
return expressions.length === 1 ? expressions[0] : `coalesce(${expressions.join(", ")})`;
|
|
39
|
+
}
|
|
40
|
+
function mimeAllowed(allowed, type) {
|
|
41
|
+
const base = type.split(";")[0].trim().toLowerCase();
|
|
42
|
+
return allowed.some((entry) => {
|
|
43
|
+
const pattern = entry.toLowerCase();
|
|
44
|
+
return pattern.endsWith("/*") ? base.startsWith(pattern.slice(0, -1)) : base === pattern;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function sizeLabel(bytes) {
|
|
48
|
+
for (const [unit, size] of [
|
|
49
|
+
["GiB", 1024 ** 3],
|
|
50
|
+
["MiB", 1024 ** 2],
|
|
51
|
+
["KiB", 1024]
|
|
52
|
+
]) if (bytes >= size && bytes % size === 0) return `${String(bytes / size)}${unit}`;
|
|
53
|
+
return `${String(bytes)}B`;
|
|
54
|
+
}
|
|
55
|
+
/** Maps a Storage error (`StorageApiError`, fetch failures) to a `DbError`. */
|
|
56
|
+
function fromStorageError(raw, table) {
|
|
57
|
+
if (isDbError(raw)) return raw;
|
|
58
|
+
if (raw instanceof DbException) return raw.error;
|
|
59
|
+
if (typeof raw !== "object" || raw === null) return toDbError(raw);
|
|
60
|
+
const failure = raw;
|
|
61
|
+
if (failure.name === "AbortError") return toDbError(raw);
|
|
62
|
+
const message = failure.message ?? "Storage request failed";
|
|
63
|
+
const code = failure.code ?? failure.statusCode;
|
|
64
|
+
const base = {
|
|
65
|
+
...code ? { code } : {},
|
|
66
|
+
...table ? { table } : {}
|
|
67
|
+
};
|
|
68
|
+
const statusCode = Number(failure.statusCode);
|
|
69
|
+
const status = Number.isFinite(statusCode) && statusCode >= 400 ? statusCode : failure.status ?? 0;
|
|
70
|
+
if (/row-level security|unauthorized to|AccessDenied/i.test(`${message} ${code ?? ""}`)) return dbError("forbidden", message, base);
|
|
71
|
+
if (/already exists|duplicate/i.test(`${message} ${code ?? ""}`)) return dbError("conflict", message, base);
|
|
72
|
+
if (failure.name === "StorageUnknownError" && status === 0) return dbError("network", message, base);
|
|
73
|
+
switch (status) {
|
|
74
|
+
case 400: return dbError("invalid_request", message, base);
|
|
75
|
+
case 401: return dbError("unauthorized", message, base);
|
|
76
|
+
case 403: return dbError("forbidden", message, base);
|
|
77
|
+
case 404: return dbError("not_found", message, base);
|
|
78
|
+
case 409: return dbError("conflict", message, base);
|
|
79
|
+
case 413:
|
|
80
|
+
case 415: return dbError("invalid_input", message, {
|
|
81
|
+
...base,
|
|
82
|
+
status
|
|
83
|
+
});
|
|
84
|
+
case 408:
|
|
85
|
+
case 429: return dbError("network", message, {
|
|
86
|
+
...base,
|
|
87
|
+
status
|
|
88
|
+
});
|
|
89
|
+
default: return status >= 500 ? dbError("network", message, base) : dbError("unexpected", message, base);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A typed Storage bucket: path builders from a template, limits, generated
|
|
94
|
+
* SQL policies and `config.toml`, and helpers for the multi-step work apps
|
|
95
|
+
* repeat (replace, signed uploads, orphan cleanup).
|
|
96
|
+
*
|
|
97
|
+
* ```ts
|
|
98
|
+
* export const logos = defineBucket({
|
|
99
|
+
* id: 'customer-logos',
|
|
100
|
+
* path: '{orgId}/{customerId}/logo/{version}.webp',
|
|
101
|
+
* policy: 'tenant',
|
|
102
|
+
* fileSizeLimit: '5MiB',
|
|
103
|
+
* allowedMimeTypes: ['image/webp'],
|
|
104
|
+
* });
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
function defineBucket(config) {
|
|
108
|
+
const template = parseTemplate(config.path, "/", validateSegment);
|
|
109
|
+
const policy = config.policy ?? "none";
|
|
110
|
+
const fileSizeLimit = config.fileSizeLimit === void 0 ? void 0 : parseSize(config.fileSizeLimit);
|
|
111
|
+
const allowedMimeTypes = config.allowedMimeTypes;
|
|
112
|
+
function segmentFor(param, kind) {
|
|
113
|
+
const index = template.segmentOf(param);
|
|
114
|
+
if (index === void 0) throw new TypeError(`defineBucket: a ${kind} policy needs {${param}} as a whole path segment in "${config.path}"`);
|
|
115
|
+
return index;
|
|
116
|
+
}
|
|
117
|
+
const accessCheck = (() => {
|
|
118
|
+
switch (policy) {
|
|
119
|
+
case "tenant": {
|
|
120
|
+
const index = segmentFor(config.tenant?.param ?? "orgId", "tenant");
|
|
121
|
+
const expression = config.tenant?.sql ?? claimSql(config.tenant?.claim ?? ["org_id", "app_metadata.org_id"]);
|
|
122
|
+
return `split_part(name, '/', ${String(index)}) = (${expression})`;
|
|
123
|
+
}
|
|
124
|
+
case "owner": {
|
|
125
|
+
const index = segmentFor(config.owner?.param ?? "userId", "owner");
|
|
126
|
+
return `split_part(name, '/', ${String(index)}) = (select auth.uid())::text`;
|
|
127
|
+
}
|
|
128
|
+
case "public":
|
|
129
|
+
case "none": return;
|
|
130
|
+
default: throw new TypeError(`defineBucket: unknown policy "${String(policy)}"`);
|
|
131
|
+
}
|
|
132
|
+
})();
|
|
133
|
+
const resolve = (target) => {
|
|
134
|
+
if (typeof target !== "string") return template.build(target);
|
|
135
|
+
if (!template.match(target)) throw new DbException(dbError("invalid_input", `Path "${target}" does not match "${config.path}"`));
|
|
136
|
+
return target;
|
|
137
|
+
};
|
|
138
|
+
const check = (file) => {
|
|
139
|
+
if (fileSizeLimit !== void 0 && file.size !== void 0 && file.size > fileSizeLimit) return dbError("invalid_input", `File is larger than ${sizeLabel(fileSizeLimit)}`, { status: 413 });
|
|
140
|
+
if (allowedMimeTypes && file.type && !mimeAllowed(allowedMimeTypes, file.type)) return dbError("invalid_input", `File type ${file.type} is not allowed`, { status: 415 });
|
|
141
|
+
};
|
|
142
|
+
const bucket = {
|
|
143
|
+
id: config.id,
|
|
144
|
+
template: config.path,
|
|
145
|
+
params: template.params,
|
|
146
|
+
owner: policy === "owner" ? config.owner?.param ?? "userId" : template.params.includes("userId") ? "userId" : void 0,
|
|
147
|
+
public: config.public ?? false,
|
|
148
|
+
policy,
|
|
149
|
+
fileSizeLimit,
|
|
150
|
+
allowedMimeTypes,
|
|
151
|
+
path: (values) => template.build(values),
|
|
152
|
+
match: (path) => template.match(path),
|
|
153
|
+
prefix: (values = {}) => template.prefix(values, template.segments - 1),
|
|
154
|
+
check,
|
|
155
|
+
sql() {
|
|
156
|
+
const id = sqlString(config.id);
|
|
157
|
+
const name = slug(config.id);
|
|
158
|
+
const lines = [
|
|
159
|
+
`-- better-supabase: bucket ${config.id} (${config.path})`,
|
|
160
|
+
"insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)",
|
|
161
|
+
`values (${id}, ${id}, ${String(bucket.public)}, ${fileSizeLimit === void 0 ? "null" : String(fileSizeLimit)}, ${allowedMimeTypes ? `array[${allowedMimeTypes.map(sqlString).join(", ")}]` : "null"})`,
|
|
162
|
+
"on conflict (id) do update set public = excluded.public, file_size_limit = excluded.file_size_limit,",
|
|
163
|
+
" allowed_mime_types = excluded.allowed_mime_types;"
|
|
164
|
+
];
|
|
165
|
+
const policies = [];
|
|
166
|
+
const inBucket = `bucket_id = ${id}`;
|
|
167
|
+
const shape = `name ~ ${sqlString(template.sqlPattern)}`;
|
|
168
|
+
if (accessCheck) {
|
|
169
|
+
const using = `${inBucket} and ${accessCheck}`;
|
|
170
|
+
const write = `${using} and ${shape}`;
|
|
171
|
+
policies.push([
|
|
172
|
+
"select",
|
|
173
|
+
"select",
|
|
174
|
+
"authenticated",
|
|
175
|
+
using,
|
|
176
|
+
void 0
|
|
177
|
+
], [
|
|
178
|
+
"insert",
|
|
179
|
+
"insert",
|
|
180
|
+
"authenticated",
|
|
181
|
+
void 0,
|
|
182
|
+
write
|
|
183
|
+
], [
|
|
184
|
+
"update",
|
|
185
|
+
"update",
|
|
186
|
+
"authenticated",
|
|
187
|
+
using,
|
|
188
|
+
write
|
|
189
|
+
], [
|
|
190
|
+
"delete",
|
|
191
|
+
"delete",
|
|
192
|
+
"authenticated",
|
|
193
|
+
using,
|
|
194
|
+
void 0
|
|
195
|
+
]);
|
|
196
|
+
} else if (policy === "public") policies.push([
|
|
197
|
+
"select",
|
|
198
|
+
"select",
|
|
199
|
+
"anon, authenticated",
|
|
200
|
+
inBucket,
|
|
201
|
+
void 0
|
|
202
|
+
]);
|
|
203
|
+
for (const [suffix, command, roles, using, withCheck] of policies) {
|
|
204
|
+
const policyName = sqlIdent(`bs_${name}_${suffix}`);
|
|
205
|
+
lines.push("", `drop policy if exists ${policyName} on storage.objects;`, `create policy ${policyName} on storage.objects for ${command} to ${roles}${using ? `\n using (${using})` : ""}${withCheck ? `\n with check (${withCheck})` : ""};`);
|
|
206
|
+
}
|
|
207
|
+
return `${lines.join("\n")}\n`;
|
|
208
|
+
},
|
|
209
|
+
toml() {
|
|
210
|
+
const lines = [`[storage.buckets.${config.id}]`, `public = ${String(bucket.public)}`];
|
|
211
|
+
if (config.fileSizeLimit !== void 0) lines.push(`file_size_limit = "${typeof config.fileSizeLimit === "number" ? sizeLabel(config.fileSizeLimit) : config.fileSizeLimit}"`);
|
|
212
|
+
if (allowedMimeTypes) lines.push(`allowed_mime_types = [${allowedMimeTypes.map((type) => JSON.stringify(type)).join(", ")}]`);
|
|
213
|
+
return `${lines.join("\n")}\n`;
|
|
214
|
+
},
|
|
215
|
+
drift(actual) {
|
|
216
|
+
if (!actual) return [{
|
|
217
|
+
field: "missing",
|
|
218
|
+
expected: config.id,
|
|
219
|
+
actual: void 0,
|
|
220
|
+
message: `Bucket "${config.id}" does not exist`
|
|
221
|
+
}];
|
|
222
|
+
const issues = [];
|
|
223
|
+
if (actual.public !== bucket.public) issues.push({
|
|
224
|
+
field: "public",
|
|
225
|
+
expected: bucket.public,
|
|
226
|
+
actual: actual.public,
|
|
227
|
+
message: `Bucket "${config.id}" public is ${String(actual.public)}`
|
|
228
|
+
});
|
|
229
|
+
if ((actual.fileSizeLimit ?? void 0) !== fileSizeLimit) issues.push({
|
|
230
|
+
field: "fileSizeLimit",
|
|
231
|
+
expected: fileSizeLimit,
|
|
232
|
+
actual: actual.fileSizeLimit,
|
|
233
|
+
message: `Bucket "${config.id}" file size limit differs`
|
|
234
|
+
});
|
|
235
|
+
const sorted = (list) => list ? [...list].sort().join(",") : "";
|
|
236
|
+
if (sorted(actual.allowedMimeTypes) !== sorted(allowedMimeTypes)) issues.push({
|
|
237
|
+
field: "allowedMimeTypes",
|
|
238
|
+
expected: allowedMimeTypes,
|
|
239
|
+
actual: actual.allowedMimeTypes,
|
|
240
|
+
message: `Bucket "${config.id}" allowed MIME types differ`
|
|
241
|
+
});
|
|
242
|
+
return issues;
|
|
243
|
+
},
|
|
244
|
+
connect: (client) => connectBucket(bucket, client, resolve)
|
|
245
|
+
};
|
|
246
|
+
return bucket;
|
|
247
|
+
}
|
|
248
|
+
function bodyInfo(body, contentType) {
|
|
249
|
+
const info = {};
|
|
250
|
+
if (typeof Blob !== "undefined" && body instanceof Blob) {
|
|
251
|
+
info.size = body.size;
|
|
252
|
+
if (body.type) info.type = body.type;
|
|
253
|
+
} else if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) info.size = body.byteLength;
|
|
254
|
+
if (contentType) info.type = contentType;
|
|
255
|
+
return info;
|
|
256
|
+
}
|
|
257
|
+
function ttlSeconds(ttl) {
|
|
258
|
+
return typeof ttl === "number" ? ttl : TTL[ttl ?? "hour"];
|
|
259
|
+
}
|
|
260
|
+
function isErrorResult(value) {
|
|
261
|
+
return typeof value === "object" && value !== null && value.ok === false && "error" in value;
|
|
262
|
+
}
|
|
263
|
+
function connectBucket(bucket, client, resolve) {
|
|
264
|
+
const api = () => client.storage.from(bucket.id);
|
|
265
|
+
const run = (fn) => AsyncResult.from(async () => {
|
|
266
|
+
try {
|
|
267
|
+
const { data, error } = await fn();
|
|
268
|
+
return error || data === null ? err(fromStorageError(error, bucket.id)) : ok(data);
|
|
269
|
+
} catch (cause) {
|
|
270
|
+
return err(fromStorageError(cause, bucket.id));
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
const fileOptions = (options, contentType) => ({
|
|
274
|
+
...contentType ? { contentType } : {},
|
|
275
|
+
...options?.cacheControl ? { cacheControl: options.cacheControl } : {},
|
|
276
|
+
...options?.metadata ? { metadata: { ...options.metadata } } : {},
|
|
277
|
+
upsert: options?.upsert ?? false
|
|
278
|
+
});
|
|
279
|
+
const download = (value) => value === void 0 ? {} : { download: value };
|
|
280
|
+
const upload = (target, body, options) => AsyncResult.from(async () => {
|
|
281
|
+
options?.signal?.throwIfAborted();
|
|
282
|
+
const path = resolve(target);
|
|
283
|
+
const contentType = options?.contentType;
|
|
284
|
+
const problem = bucket.check(bodyInfo(body, contentType));
|
|
285
|
+
if (problem) return err(problem);
|
|
286
|
+
return run(() => api().upload(path, body, fileOptions(options, contentType))).map(() => ({ path }));
|
|
287
|
+
});
|
|
288
|
+
const remove = (targets) => AsyncResult.from(async () => {
|
|
289
|
+
const paths = targets.map(resolve);
|
|
290
|
+
if (paths.length === 0) return ok([]);
|
|
291
|
+
return run(() => api().remove(paths)).map(() => paths);
|
|
292
|
+
});
|
|
293
|
+
const walk = async (folder, signal, out) => {
|
|
294
|
+
const limit = 1e3;
|
|
295
|
+
for (let offset = 0;; offset += limit) {
|
|
296
|
+
signal?.throwIfAborted();
|
|
297
|
+
const { data, error } = await api().list(folder, {
|
|
298
|
+
limit,
|
|
299
|
+
offset,
|
|
300
|
+
sortBy: {
|
|
301
|
+
column: "name",
|
|
302
|
+
order: "asc"
|
|
303
|
+
}
|
|
304
|
+
}, signal ? { signal } : {});
|
|
305
|
+
if (error) throw new DbException(fromStorageError(error, bucket.id));
|
|
306
|
+
for (const item of data) {
|
|
307
|
+
const path = folder ? `${folder}/${item.name}` : item.name;
|
|
308
|
+
if (item.id === null) await walk(path, signal, out);
|
|
309
|
+
else {
|
|
310
|
+
const metadata = item.metadata ?? {};
|
|
311
|
+
out.push({
|
|
312
|
+
path,
|
|
313
|
+
...typeof metadata.size === "number" ? { size: metadata.size } : {},
|
|
314
|
+
...metadata.mimetype ? { contentType: metadata.mimetype } : {},
|
|
315
|
+
createdAt: item.created_at,
|
|
316
|
+
updatedAt: item.updated_at
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (data.length < limit) return;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
const list = (within, options) => AsyncResult.from(async () => {
|
|
324
|
+
const out = [];
|
|
325
|
+
await walk(bucket.prefix(within ?? {}), options?.signal, out);
|
|
326
|
+
return ok(out);
|
|
327
|
+
}).mapError((error) => ({
|
|
328
|
+
...error,
|
|
329
|
+
table: bucket.id
|
|
330
|
+
}));
|
|
331
|
+
const signedUrl = (target, options) => AsyncResult.from(async () => {
|
|
332
|
+
const path = resolve(target);
|
|
333
|
+
return run(() => api().createSignedUrl(path, ttlSeconds(options?.ttl), {
|
|
334
|
+
...download(options?.download),
|
|
335
|
+
...options?.transform ? { transform: { ...options.transform } } : {}
|
|
336
|
+
})).map((data) => data.signedUrl);
|
|
337
|
+
});
|
|
338
|
+
return {
|
|
339
|
+
bucket,
|
|
340
|
+
upload,
|
|
341
|
+
download: (target, options) => AsyncResult.from(async () => {
|
|
342
|
+
const path = resolve(target);
|
|
343
|
+
return run(() => api().download(path, {}, options?.signal ? { signal: options.signal } : {}));
|
|
344
|
+
}),
|
|
345
|
+
exists: (target) => AsyncResult.from(async () => {
|
|
346
|
+
const path = resolve(target);
|
|
347
|
+
const found = await api().exists(path);
|
|
348
|
+
if (!found.data) return ok(false);
|
|
349
|
+
return found.error ? err(fromStorageError(found.error, bucket.id)) : ok(true);
|
|
350
|
+
}),
|
|
351
|
+
remove,
|
|
352
|
+
list,
|
|
353
|
+
signedUrl,
|
|
354
|
+
signedUrls: (targets, options) => AsyncResult.from(async () => {
|
|
355
|
+
const paths = targets.map(resolve);
|
|
356
|
+
if (paths.length === 0) return ok([]);
|
|
357
|
+
return run(() => api().createSignedUrls(paths, ttlSeconds(options?.ttl), download(options?.download))).andThen(async (data) => {
|
|
358
|
+
const failed = data.find((entry) => entry.error || !entry.signedUrl);
|
|
359
|
+
if (failed) return err(dbError("not_found", failed.error ?? `No URL for ${String(failed.path)}`, { table: bucket.id }));
|
|
360
|
+
return ok(data.map((entry) => entry.signedUrl));
|
|
361
|
+
});
|
|
362
|
+
}),
|
|
363
|
+
publicUrl(target, options) {
|
|
364
|
+
const path = resolve(target);
|
|
365
|
+
return api().getPublicUrl(path, {
|
|
366
|
+
...download(options?.download),
|
|
367
|
+
...options?.transform ? { transform: { ...options.transform } } : {}
|
|
368
|
+
}).data.publicUrl;
|
|
369
|
+
},
|
|
370
|
+
renderUrl(target, transform, options) {
|
|
371
|
+
if (!bucket.public) return signedUrl(target, {
|
|
372
|
+
...options,
|
|
373
|
+
transform
|
|
374
|
+
});
|
|
375
|
+
return AsyncResult.from(async () => ok(api().getPublicUrl(resolve(target), {
|
|
376
|
+
...download(options?.download),
|
|
377
|
+
transform: { ...transform }
|
|
378
|
+
}).data.publicUrl));
|
|
379
|
+
},
|
|
380
|
+
replace: (target, body, options = {}) => AsyncResult.from(async () => {
|
|
381
|
+
const path = resolve(target);
|
|
382
|
+
const previous = options.previous ?? null;
|
|
383
|
+
const same = previous === path;
|
|
384
|
+
const uploaded = await upload(path, body, {
|
|
385
|
+
...options,
|
|
386
|
+
upsert: same || (options.upsert ?? false)
|
|
387
|
+
});
|
|
388
|
+
if (!uploaded.ok) return uploaded;
|
|
389
|
+
const undo = async (error) => {
|
|
390
|
+
if (!same) await remove([path]);
|
|
391
|
+
return err(error);
|
|
392
|
+
};
|
|
393
|
+
try {
|
|
394
|
+
options.signal?.throwIfAborted();
|
|
395
|
+
const outcome = await options.commit?.(path);
|
|
396
|
+
if (isErrorResult(outcome)) return await undo(outcome.error);
|
|
397
|
+
} catch (cause) {
|
|
398
|
+
return undo(fromStorageError(cause, bucket.id));
|
|
399
|
+
}
|
|
400
|
+
if (!previous || same) return ok({
|
|
401
|
+
path,
|
|
402
|
+
removed: null
|
|
403
|
+
});
|
|
404
|
+
const removed = await run(() => api().remove([previous]));
|
|
405
|
+
return ok(removed.ok ? {
|
|
406
|
+
path,
|
|
407
|
+
removed: previous
|
|
408
|
+
} : {
|
|
409
|
+
path,
|
|
410
|
+
removed: null,
|
|
411
|
+
cleanup: removed.error
|
|
412
|
+
});
|
|
413
|
+
}),
|
|
414
|
+
reserve: (target, options) => AsyncResult.from(async () => {
|
|
415
|
+
const path = resolve(target);
|
|
416
|
+
return run(() => api().createSignedUploadUrl(path, options?.upsert ? { upsert: true } : void 0)).map((data) => ({
|
|
417
|
+
path: data.path,
|
|
418
|
+
token: data.token,
|
|
419
|
+
signedUrl: data.signedUrl
|
|
420
|
+
}));
|
|
421
|
+
}),
|
|
422
|
+
uploadReserved: (reservation, body, options) => AsyncResult.from(async () => {
|
|
423
|
+
options?.signal?.throwIfAborted();
|
|
424
|
+
const path = resolve(reservation.path);
|
|
425
|
+
const problem = bucket.check(bodyInfo(body, options?.contentType));
|
|
426
|
+
if (problem) return err(problem);
|
|
427
|
+
return run(() => api().uploadToSignedUrl(path, reservation.token, body, fileOptions(options, options?.contentType))).map(() => ({ path }));
|
|
428
|
+
}),
|
|
429
|
+
sweep: (options) => AsyncResult.from(async () => {
|
|
430
|
+
const now = options.now?.() ?? Date.now();
|
|
431
|
+
const cutoff = options.olderThan instanceof Date ? options.olderThan.getTime() : now - options.olderThan;
|
|
432
|
+
const found = await list(options.within, options.signal ? { signal: options.signal } : void 0);
|
|
433
|
+
if (!found.ok) return found;
|
|
434
|
+
const candidates = found.data.filter((object) => bucket.match(object.path) !== null).filter((object) => {
|
|
435
|
+
const created = Date.parse(object.createdAt ?? object.updatedAt ?? "");
|
|
436
|
+
return Number.isFinite(created) && created < cutoff;
|
|
437
|
+
}).map((object) => object.path);
|
|
438
|
+
const size = options.batchSize ?? 100;
|
|
439
|
+
const orphans = [];
|
|
440
|
+
const removed = [];
|
|
441
|
+
for (let index = 0; index < candidates.length; index += size) {
|
|
442
|
+
options.signal?.throwIfAborted();
|
|
443
|
+
const batch = candidates.slice(index, index + size);
|
|
444
|
+
const keep = new Set(await options.referenced(batch));
|
|
445
|
+
const unreferenced = batch.filter((path) => !keep.has(path));
|
|
446
|
+
orphans.push(...unreferenced);
|
|
447
|
+
if (options.dryRun || unreferenced.length === 0) continue;
|
|
448
|
+
const result = await run(() => api().remove(unreferenced));
|
|
449
|
+
if (!result.ok) return result;
|
|
450
|
+
removed.push(...unreferenced);
|
|
451
|
+
}
|
|
452
|
+
return ok({
|
|
453
|
+
scanned: found.data.length,
|
|
454
|
+
orphans,
|
|
455
|
+
removed
|
|
456
|
+
});
|
|
457
|
+
})
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
//#endregion
|
|
461
|
+
export { parseSize as i, defineBucket as n, fromStorageError as r, TTL as t };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
//#region src/ir/tables.ts
|
|
2
|
+
/**
|
|
3
|
+
* App keys of every table an operation reads or writes: its own table, each
|
|
4
|
+
* included relation and each relation used in a filter. Cached reads of these
|
|
5
|
+
* tables go stale when any of them changes.
|
|
6
|
+
*/
|
|
7
|
+
function touchedTables(op) {
|
|
8
|
+
const tables = /* @__PURE__ */ new Set([op.table.key]);
|
|
9
|
+
const selection = op.kind === "select" ? op.selection : op.returning;
|
|
10
|
+
if (selection) visitSelection(selection, tables);
|
|
11
|
+
if (op.kind !== "insert") visitCondition(op.where, tables);
|
|
12
|
+
return [...tables];
|
|
13
|
+
}
|
|
14
|
+
function visitSelection(selection, tables) {
|
|
15
|
+
for (const include of selection.includes) {
|
|
16
|
+
tables.add(include.target.key);
|
|
17
|
+
visitSelection(include.selection, tables);
|
|
18
|
+
visitCondition(include.where, tables);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function visitCondition(condition, tables) {
|
|
22
|
+
if (!condition) return;
|
|
23
|
+
switch (condition.kind) {
|
|
24
|
+
case "and":
|
|
25
|
+
case "or":
|
|
26
|
+
for (const item of condition.items) visitCondition(item, tables);
|
|
27
|
+
return;
|
|
28
|
+
case "not":
|
|
29
|
+
visitCondition(condition.item, tables);
|
|
30
|
+
return;
|
|
31
|
+
case "column": return;
|
|
32
|
+
case "relation":
|
|
33
|
+
tables.add(condition.target.key);
|
|
34
|
+
visitCondition(condition.where, tables);
|
|
35
|
+
return;
|
|
36
|
+
default: return condition;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Tables whose rows can change when rows of `table` change: the table itself
|
|
41
|
+
* plus every table whose foreign key cascades or sets a value on delete,
|
|
42
|
+
* followed through chains of cascades.
|
|
43
|
+
*/
|
|
44
|
+
function invalidationTargets(meta, table) {
|
|
45
|
+
const targets = /* @__PURE__ */ new Set([table]);
|
|
46
|
+
const queue = [table];
|
|
47
|
+
for (let key = queue.shift(); key !== void 0; key = queue.shift()) {
|
|
48
|
+
const relations = meta.tables[key]?.relations ?? {};
|
|
49
|
+
for (const relation of Object.values(relations)) {
|
|
50
|
+
if (relation.direction !== "reverse" || !relation.onDelete) continue;
|
|
51
|
+
if (targets.has(relation.table)) continue;
|
|
52
|
+
targets.add(relation.table);
|
|
53
|
+
if (relation.onDelete === "cascade") queue.push(relation.table);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...targets];
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
export { touchedTables as n, invalidationTargets as t };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { n as dbError, t as DbException } from "./errors-Bl3rkOzo.js";
|
|
2
|
+
//#region src/core/template.ts
|
|
3
|
+
const PLACEHOLDER = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
4
|
+
function parseSegment(segment) {
|
|
5
|
+
const parts = [];
|
|
6
|
+
let last = 0;
|
|
7
|
+
for (const match of segment.matchAll(PLACEHOLDER)) {
|
|
8
|
+
if (match.index > last) parts.push({
|
|
9
|
+
kind: "literal",
|
|
10
|
+
text: segment.slice(last, match.index)
|
|
11
|
+
});
|
|
12
|
+
parts.push({
|
|
13
|
+
kind: "param",
|
|
14
|
+
name: match[1]
|
|
15
|
+
});
|
|
16
|
+
last = match.index + match[0].length;
|
|
17
|
+
}
|
|
18
|
+
if (last < segment.length) parts.push({
|
|
19
|
+
kind: "literal",
|
|
20
|
+
text: segment.slice(last)
|
|
21
|
+
});
|
|
22
|
+
return parts;
|
|
23
|
+
}
|
|
24
|
+
const escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
25
|
+
function parseTemplate(source, separator, validate) {
|
|
26
|
+
const segments = source.split(separator).map(parseSegment);
|
|
27
|
+
const params = [];
|
|
28
|
+
for (const segment of segments) {
|
|
29
|
+
if (segment.length === 0) throw new TypeError(`Template "${source}" has an empty segment`);
|
|
30
|
+
for (let index = 1; index < segment.length; index++) if (segment[index].kind === "param" && segment[index - 1].kind === "param") throw new TypeError(`Template "${source}" has adjacent placeholders`);
|
|
31
|
+
for (const part of segment) {
|
|
32
|
+
if (part.kind !== "param") continue;
|
|
33
|
+
if (params.includes(part.name)) throw new TypeError(`Template "${source}" repeats {${part.name}}`);
|
|
34
|
+
params.push(part.name);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (/[{}]/.test(segments.flat().map((part) => part.kind === "literal" ? part.text : "").join(""))) throw new TypeError(`Template "${source}" has an unbalanced brace`);
|
|
38
|
+
const sep = escapeRegex(separator);
|
|
39
|
+
const valueClass = `[^${separator === "/" ? "/" : sep}]+`;
|
|
40
|
+
const regexFor = (named) => segments.map((segment) => segment.map((part) => part.kind === "literal" ? escapeRegex(part.text) : named ? `(?<${part.name}>${valueClass})` : valueClass).join("")).join(sep);
|
|
41
|
+
const matcher = new RegExp(`^${regexFor(true)}$`);
|
|
42
|
+
const render = (name, raw) => {
|
|
43
|
+
if (raw === void 0 || typeof raw !== "string" && typeof raw !== "number") throw new DbException(dbError("invalid_input", `Missing value for {${name}}`));
|
|
44
|
+
const value = String(raw);
|
|
45
|
+
const problem = value === "" ? "is empty" : value.includes(separator) ? `contains "${separator}"` : validate(name, value);
|
|
46
|
+
if (problem) throw new DbException(dbError("invalid_input", `Value for {${name}} ${problem}`));
|
|
47
|
+
return value;
|
|
48
|
+
};
|
|
49
|
+
const renderSegment = (segment, values) => segment.map((part) => part.kind === "literal" ? part.text : render(part.name, values[part.name])).join("");
|
|
50
|
+
return {
|
|
51
|
+
source,
|
|
52
|
+
separator,
|
|
53
|
+
params,
|
|
54
|
+
segments: segments.length,
|
|
55
|
+
build: (values) => segments.map((segment) => renderSegment(segment, values)).join(separator),
|
|
56
|
+
match(value) {
|
|
57
|
+
const found = matcher.exec(value);
|
|
58
|
+
if (!found?.groups) return found ? {} : null;
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const name of params) {
|
|
61
|
+
const part = found.groups[name];
|
|
62
|
+
if (validate(name, part)) return null;
|
|
63
|
+
out[name] = part;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
},
|
|
67
|
+
prefix(values, maxSegments = segments.length) {
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const segment of segments.slice(0, maxSegments)) {
|
|
70
|
+
if (segment.some((part) => part.kind === "param" && values[part.name] === void 0)) break;
|
|
71
|
+
out.push(renderSegment(segment, values));
|
|
72
|
+
}
|
|
73
|
+
return out.join(separator);
|
|
74
|
+
},
|
|
75
|
+
segmentOf(name) {
|
|
76
|
+
const index = segments.findIndex((segment) => segment.length === 1 && segment[0].kind === "param" && segment[0].name === name);
|
|
77
|
+
return index === -1 ? void 0 : index + 1;
|
|
78
|
+
},
|
|
79
|
+
sqlPattern: `^${regexFor(false)}$`
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function sqlString(value) {
|
|
83
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
84
|
+
}
|
|
85
|
+
function sqlIdent(value) {
|
|
86
|
+
return `"${value.replaceAll("\"", "\"\"")}"`;
|
|
87
|
+
}
|
|
88
|
+
/** Postgres identifier-safe name part: `customer-logos` → `customer_logos`. */
|
|
89
|
+
function slug(value) {
|
|
90
|
+
return value.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_|_$/g, "").toLowerCase();
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
export { sqlString as i, slug as n, sqlIdent as r, parseTemplate as t };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
//#region src/core/template.d.ts
|
|
2
|
+
/** Placeholder names in a `{name}` template. */
|
|
3
|
+
type TemplateParams<P extends string> = P extends `${string}{${infer K}}${infer R}` ? K | TemplateParams<R> : never;
|
|
4
|
+
type TemplateValues<P extends string> = { readonly [K in TemplateParams<P>]: string | number; };
|
|
5
|
+
//#endregion
|
|
6
|
+
export { TemplateValues as n, TemplateParams as t };
|