@burojs/file-storage 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/dist/index.js ADDED
@@ -0,0 +1,878 @@
1
+ // src/registry.ts
2
+ var FILE_STORAGE_SERVICE = "fileStorage";
3
+ function createFileStorageRegistry(map) {
4
+ if (!map.default) {
5
+ throw new Error(
6
+ "fileStoragePlugin requires a 'default' entry \u2014 every file field that does not name a storage resolves to it."
7
+ );
8
+ }
9
+ const entries = new Map(Object.entries(map));
10
+ return {
11
+ get(name = "default") {
12
+ const found = entries.get(name);
13
+ if (!found) {
14
+ throw new Error(
15
+ `Unknown file storage '${name}'. Registered: ${[...entries.keys()].join(", ")}.`
16
+ );
17
+ }
18
+ return found;
19
+ },
20
+ has: (name) => entries.has(name),
21
+ names: () => [...entries.keys()],
22
+ hasFused: () => [...entries.values()].some((a) => a.pipeline === "fused")
23
+ };
24
+ }
25
+
26
+ // src/plugin.ts
27
+ function fileStoragePlugin(map) {
28
+ const adapters = Object.freeze({ ...map });
29
+ return {
30
+ name: "@burojs/file-storage",
31
+ enforce: "pre",
32
+ setup: (api) => {
33
+ api.runtime.registerService(FILE_STORAGE_SERVICE, () => createFileStorageRegistry(adapters));
34
+ }
35
+ };
36
+ }
37
+
38
+ // src/mappers/ref.ts
39
+ var seq = 0;
40
+ function newRef(init = {}) {
41
+ seq += 1;
42
+ return { localId: `ref-${seq}`, ...init };
43
+ }
44
+
45
+ // src/kind.ts
46
+ function isImage(ref) {
47
+ if (ref.mime) return ref.mime.startsWith("image/");
48
+ const name = ref.name ?? "";
49
+ return /\.(png|jpe?g|gif|webp|svg|avif)$/i.test(name);
50
+ }
51
+
52
+ // src/mappers/scalar-handle.ts
53
+ function scalarHandle() {
54
+ return {
55
+ carriesPending: true,
56
+ read(column) {
57
+ if (column == null) return [];
58
+ const list = Array.isArray(column) ? column : [column];
59
+ return list.filter((entry) => entry !== "" && entry != null).map((entry) => newRef({ handle: entry, raw: entry, name: tail(entry) }));
60
+ },
61
+ write(next, _prev, ctx) {
62
+ const values = next.map((r) => r.handle ?? r.pending).filter((v) => v != null);
63
+ if (ctx.multiple) return values;
64
+ return values[0] ?? null;
65
+ }
66
+ };
67
+ }
68
+ function tail(value) {
69
+ const seg = value.split("/").pop();
70
+ return seg && seg !== value ? seg : value || void 0;
71
+ }
72
+
73
+ // src/mappers/rich-image.ts
74
+ var DEFAULT_USAGE_KEYS = ["hotspot", "crop"];
75
+ function richImage(opts) {
76
+ const handleKey = opts.handleKey ?? "id";
77
+ const usageKeys = opts.usageKeys ?? DEFAULT_USAGE_KEYS;
78
+ function readOne(entry) {
79
+ const usage = opts.focus === "usage" ? pick(
80
+ entry,
81
+ usageKeys.filter((k) => entry[k] !== void 0)
82
+ ) : void 0;
83
+ return newRef({
84
+ handle: asString(getPath(entry, handleKey)),
85
+ raw: entry,
86
+ name: asString(entry.name ?? entry.filename_download),
87
+ mime: asString(entry.mime ?? entry.type),
88
+ size: asNumber(entry.size ?? entry.filesize),
89
+ width: asNumber(entry.width),
90
+ height: asNumber(entry.height),
91
+ ...usage && Object.keys(usage).length > 0 ? { usage } : {}
92
+ });
93
+ }
94
+ return {
95
+ read(column, ctx) {
96
+ if (column == null) return [];
97
+ const list = Array.isArray(column) ? column : [column];
98
+ return list.filter((e) => e && typeof e === "object").map(readOne);
99
+ },
100
+ write(next, _prev, ctx) {
101
+ const out = next.filter((r) => r.handle != null).map((r) => {
102
+ const base = isObj(r.raw) ? { ...r.raw } : {};
103
+ setPath(base, handleKey, r.handle);
104
+ if (opts.focus === "usage" && r.usage) Object.assign(base, r.usage);
105
+ return base;
106
+ });
107
+ if (ctx.multiple) return out;
108
+ return out[0] ?? null;
109
+ }
110
+ };
111
+ }
112
+ function isObj(v) {
113
+ return typeof v === "object" && v !== null && !Array.isArray(v);
114
+ }
115
+ function asString(v) {
116
+ return typeof v === "string" ? v : void 0;
117
+ }
118
+ function asNumber(v) {
119
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
120
+ }
121
+ function pick(src, keys) {
122
+ const out = {};
123
+ for (const k of keys) out[k] = src[k];
124
+ return out;
125
+ }
126
+ function getPath(src, path) {
127
+ return path.split(".").reduce((acc, k) => isObj(acc) ? acc[k] : void 0, src);
128
+ }
129
+ function setPath(target, path, value) {
130
+ const keys = path.split(".");
131
+ const last = keys.pop();
132
+ let cursor = target;
133
+ for (const k of keys) {
134
+ const nextLevel = cursor[k];
135
+ cursor[k] = isObj(nextLevel) ? { ...nextLevel } : {};
136
+ cursor = cursor[k];
137
+ }
138
+ cursor[last] = value;
139
+ }
140
+
141
+ // src/mappers/junction.ts
142
+ function junction(opts) {
143
+ return {
144
+ read(column, _ctx) {
145
+ if (!Array.isArray(column)) return [];
146
+ return column.filter((row) => row && typeof row === "object").map((row) => newRef({ handle: asString2(row[opts.fileKey]), raw: row }));
147
+ },
148
+ write(next, _prev, _ctx) {
149
+ return next.filter((r) => r.handle != null).map((r, i) => {
150
+ const base = r.raw && typeof r.raw === "object" ? { ...r.raw } : {};
151
+ base[opts.fileKey] = r.handle;
152
+ if (opts.sortKey) base[opts.sortKey] = i + 1;
153
+ return base;
154
+ });
155
+ }
156
+ };
157
+ }
158
+ function asString2(v) {
159
+ return typeof v === "string" ? v : void 0;
160
+ }
161
+
162
+ // src/contract/run-contract.ts
163
+ var single = { resource: "demo", field: "file", multiple: false };
164
+ var many = { resource: "demo", field: "files", multiple: true };
165
+ function runFileStorageContract(opts, runner) {
166
+ const { mapper } = opts;
167
+ const it = runner.it;
168
+ const expect = runner.expect;
169
+ it("declares a pipeline", () => {
170
+ expect(["immediate", "fused", "handshake", "resumable"]).toContain(opts.adapter().pipeline);
171
+ });
172
+ it("ships a default mapper", () => {
173
+ const m = opts.adapter().defaultMapper;
174
+ expect(typeof m.read).toBe("function");
175
+ expect(typeof m.write).toBe("function");
176
+ });
177
+ if (opts.writesColumnPatch) {
178
+ it("emits an empty patch when nothing changed", () => {
179
+ const refs = mapper.read(opts.sampleColumn.multiple, many);
180
+ const out = mapper.write(refs, refs, many);
181
+ expect(Object.keys(out.keys ?? {}).length).toBe(0);
182
+ });
183
+ it("emits only patch keys, never the raw column value", () => {
184
+ const refs = mapper.read(opts.sampleColumn.multiple, many);
185
+ const out = mapper.write(refs, refs, many);
186
+ expect(typeof out.keys).toBe("object");
187
+ });
188
+ } else {
189
+ it("round-trips an untouched single value unchanged", () => {
190
+ const refs = mapper.read(opts.sampleColumn.single, single);
191
+ expect(mapper.write(refs, refs, single)).toEqual(opts.sampleColumn.single);
192
+ });
193
+ it("round-trips an untouched multiple value unchanged", () => {
194
+ const refs = mapper.read(opts.sampleColumn.multiple, many);
195
+ expect(mapper.write(refs, refs, many)).toEqual(opts.sampleColumn.multiple);
196
+ });
197
+ }
198
+ it("reads null as no files", () => {
199
+ expect(mapper.read(null, single)).toEqual([]);
200
+ });
201
+ it("gives every ref a distinct localId", () => {
202
+ const refs = mapper.read(opts.sampleColumn.multiple, many);
203
+ expect(new Set(refs.map((r) => r.localId)).size).toBe(refs.length);
204
+ });
205
+ if (!opts.skipUpload) {
206
+ it("reports progress and resolves to a ref carrying a handle", async () => {
207
+ const adapter = opts.adapter();
208
+ const seen = [];
209
+ const ref = await adapter.upload(new File(["hello"], "a.txt", { type: "text/plain" }), {
210
+ signal: new AbortController().signal,
211
+ onProgress: (loaded) => seen.push(loaded)
212
+ });
213
+ expect(ref.handle).toBeTruthy();
214
+ expect(seen.length).toBeGreaterThan(0);
215
+ expect(seen.at(-1)).toBe(5);
216
+ });
217
+ it("rejects with an AbortError when the signal is already aborted", async () => {
218
+ const adapter = opts.adapter();
219
+ const ac = new AbortController();
220
+ ac.abort();
221
+ await expect(
222
+ adapter.upload(new File(["x"], "x.txt"), {
223
+ signal: ac.signal,
224
+ onProgress: () => {
225
+ }
226
+ })
227
+ ).rejects.toThrow(/abort/i);
228
+ });
229
+ it("accepts a retry after a failure", async () => {
230
+ const adapter = opts.adapter();
231
+ const file = new File(["x"], "x.txt");
232
+ const aborted = new AbortController();
233
+ aborted.abort();
234
+ await expect(
235
+ adapter.upload(file, { signal: aborted.signal, onProgress: () => {
236
+ } })
237
+ ).rejects.toThrow();
238
+ const second = await adapter.upload(file, {
239
+ signal: new AbortController().signal,
240
+ onProgress: () => {
241
+ }
242
+ });
243
+ expect(second.handle).toBeTruthy();
244
+ });
245
+ }
246
+ it("resolves a URL for a ref", async () => {
247
+ const adapter = opts.adapter();
248
+ const resolved = await adapter.resolveUrl({ localId: "x", handle: "h" }, opts.resolveCtx ?? {});
249
+ const url = typeof resolved === "string" ? resolved : resolved.url;
250
+ expect(url).toMatch(/^https?:\/\//);
251
+ });
252
+ it("either returns a permanent string or states a future expiry \u2014 never neither", async () => {
253
+ const adapter = opts.adapter();
254
+ const resolved = await adapter.resolveUrl({ localId: "x", handle: "h" }, opts.resolveCtx ?? {});
255
+ if (typeof resolved === "string") return;
256
+ expect(typeof resolved.expiresAt).toBe("number");
257
+ expect(resolved.expiresAt).toBeGreaterThan(Date.now());
258
+ });
259
+ it("leaves unreportable metadata absent rather than guessing", async () => {
260
+ const adapter = opts.adapter();
261
+ if (!adapter.stat) return;
262
+ const [out] = await adapter.stat([{ localId: "x", handle: "definitely-not-stored" }]);
263
+ expect(out?.size).not.toBe(0);
264
+ });
265
+ }
266
+
267
+ // src/contract/fake-adapter.ts
268
+ function createFakeAdapter(opts = {}) {
269
+ const stored = /* @__PURE__ */ new Map();
270
+ const removed = [];
271
+ let seq2 = 0;
272
+ return {
273
+ stored,
274
+ removed,
275
+ pipeline: opts.pipeline ?? "immediate",
276
+ defaultMapper: scalarHandle(),
277
+ async upload(file, ctx) {
278
+ const ticks = opts.progressTicks ?? 2;
279
+ for (let i = 1; i <= ticks; i += 1) {
280
+ if (ctx.signal.aborted) throw new DOMException("Aborted", "AbortError");
281
+ await Promise.resolve();
282
+ ctx.onProgress(Math.round(file.size * i / ticks), file.size);
283
+ }
284
+ if (ctx.signal.aborted) throw new DOMException("Aborted", "AbortError");
285
+ if (opts.failWith) throw new Error(opts.failWith);
286
+ seq2 += 1;
287
+ const handle = `fake-${seq2}-${file.name}`;
288
+ stored.set(handle, file);
289
+ return newRef({ handle, name: file.name, mime: file.type || void 0, size: file.size });
290
+ },
291
+ resolveUrl(ref, ctx) {
292
+ const q = ctx.variant?.width ? `?w=${ctx.variant.width}` : "";
293
+ const url = `https://fake.test/${ref.handle}${q}`;
294
+ if (opts.signedTtlMs == null) return url;
295
+ return Promise.resolve({ url, expiresAt: Date.now() + opts.signedTtlMs });
296
+ },
297
+ async stat(refs) {
298
+ return refs.map((r) => {
299
+ const f = r.handle ? stored.get(r.handle) : void 0;
300
+ return f ? { ...r, name: f.name, size: f.size, mime: f.type || void 0 } : r;
301
+ });
302
+ },
303
+ async remove(ref) {
304
+ if (ref.handle) {
305
+ stored.delete(ref.handle);
306
+ removed.push(ref.handle);
307
+ }
308
+ }
309
+ };
310
+ }
311
+
312
+ // src/adapters/inline.ts
313
+ function inlineFileStorage(opts) {
314
+ return {
315
+ pipeline: "fused",
316
+ encoding: opts.encoding,
317
+ defaultMapper: scalarHandle(),
318
+ // Nothing was uploaded ahead of the save, so nothing can be orphaned.
319
+ orphans: "keep",
320
+ async upload(file, _ctx) {
321
+ return newRef({
322
+ pending: file,
323
+ name: file.name,
324
+ mime: file.type || void 0,
325
+ size: file.size
326
+ });
327
+ },
328
+ resolveUrl(ref, ctx) {
329
+ return opts.resolveUrl(ref, ctx);
330
+ }
331
+ };
332
+ }
333
+
334
+ // src/adapters/nhost.ts
335
+ function nhostFileStorage(opts) {
336
+ const fetchImpl = opts.fetchImpl ?? fetch;
337
+ const base = opts.baseUrl.replace(/\/+$/, "");
338
+ async function authHeaders() {
339
+ const token = await opts.getToken();
340
+ if (!token) throw new Error("nhostFileStorage: no access token \u2014 the user is not signed in");
341
+ return { Authorization: `Bearer ${token}` };
342
+ }
343
+ return {
344
+ pipeline: "immediate",
345
+ defaultMapper: scalarHandle(),
346
+ orphans: opts.orphans ?? "best-effort",
347
+ async upload(file, ctx) {
348
+ if (ctx.signal.aborted) throw new DOMException("Aborted", "AbortError");
349
+ const headers = await authHeaders();
350
+ const fd = new FormData();
351
+ fd.append("file[]", file);
352
+ if (opts.bucketId) fd.append("bucket-id", opts.bucketId);
353
+ ctx.onProgress(0, file.size);
354
+ const res = await fetchImpl(`${base}/v1/files`, {
355
+ method: "POST",
356
+ headers,
357
+ body: fd,
358
+ signal: ctx.signal
359
+ });
360
+ if (!res.ok) {
361
+ const body = await res.text().catch(() => "");
362
+ throw new Error(`nhost upload failed: ${res.status} ${body}`.trim());
363
+ }
364
+ const json = await res.json();
365
+ const meta = "processedFiles" in json ? json.processedFiles?.[0] : json;
366
+ if (!meta?.id) throw new Error("nhost upload returned no file id");
367
+ ctx.onProgress(file.size, file.size);
368
+ return newRef({
369
+ handle: meta.id,
370
+ name: meta.name ?? file.name,
371
+ mime: meta.mimeType ?? file.type ?? void 0,
372
+ size: meta.size ?? file.size
373
+ });
374
+ },
375
+ async resolveUrl(ref, ctx) {
376
+ const headers = await authHeaders();
377
+ const params = new URLSearchParams();
378
+ if (ctx.variant?.width) params.set("w", String(ctx.variant.width));
379
+ if (ctx.variant?.height) params.set("h", String(ctx.variant.height));
380
+ const qs = params.toString();
381
+ const res = await fetchImpl(
382
+ `${base}/v1/files/${ref.handle}/presignedurl${qs ? `?${qs}` : ""}`,
383
+ { headers }
384
+ );
385
+ if (!res.ok) throw new Error(`nhost presign failed: ${res.status}`);
386
+ const json = await res.json();
387
+ if (!json.url) throw new Error("nhost presign returned no url");
388
+ return {
389
+ url: json.url,
390
+ // `expiration` is a lifetime in seconds; the cache wants an instant.
391
+ expiresAt: Date.now() + (json.expiration ?? 1800) * 1e3
392
+ };
393
+ },
394
+ async stat(refs) {
395
+ const headers = await authHeaders();
396
+ return Promise.all(
397
+ refs.map(async (ref) => {
398
+ const res = await fetchImpl(`${base}/v1/files/${ref.handle}`, {
399
+ method: "HEAD",
400
+ headers
401
+ });
402
+ if (!res.ok) return ref;
403
+ const rawSize = res.headers.get("content-length");
404
+ const size = rawSize == null ? Number.NaN : Number(rawSize);
405
+ const mime = res.headers.get("content-type") ?? void 0;
406
+ return {
407
+ ...ref,
408
+ ...Number.isFinite(size) ? { size } : {},
409
+ ...mime ? { mime } : {}
410
+ };
411
+ })
412
+ );
413
+ },
414
+ async remove(ref) {
415
+ const headers = await authHeaders();
416
+ const res = await fetchImpl(`${base}/v1/files/${ref.handle}`, {
417
+ method: "DELETE",
418
+ headers
419
+ });
420
+ if (!res.ok && res.status !== 404) {
421
+ throw new Error(`nhost delete failed: ${res.status}`);
422
+ }
423
+ }
424
+ };
425
+ }
426
+
427
+ // src/adapters/supabase.ts
428
+ function pathStamp() {
429
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
430
+ return crypto.randomUUID().slice(0, 8);
431
+ }
432
+ return Math.random().toString(36).slice(2, 10).padEnd(8, "0");
433
+ }
434
+ function supabaseFileStorage(opts) {
435
+ const bucket = () => opts.client.storage.from(opts.bucket);
436
+ const ttl = opts.signedTtlSec ?? 3600;
437
+ return {
438
+ pipeline: "immediate",
439
+ defaultMapper: scalarHandle(),
440
+ orphans: opts.orphans ?? "best-effort",
441
+ async upload(file, ctx) {
442
+ if (ctx.signal.aborted) throw new DOMException("Aborted", "AbortError");
443
+ const stamp = `${pathStamp()}-${file.name}`;
444
+ const path = opts.pathPrefix ? `${opts.pathPrefix}/${stamp}` : stamp;
445
+ ctx.onProgress(0, file.size);
446
+ const { data, error } = await bucket().upload(path, file, {
447
+ contentType: file.type || void 0
448
+ });
449
+ if (error) throw new Error(`Supabase upload failed: ${error.message}`);
450
+ if (!data) throw new Error("Supabase upload returned no path");
451
+ ctx.onProgress(file.size, file.size);
452
+ return newRef({
453
+ handle: data.path,
454
+ name: file.name,
455
+ mime: file.type || void 0,
456
+ size: file.size
457
+ });
458
+ },
459
+ async resolveUrl(ref, _ctx) {
460
+ const path = ref.handle;
461
+ if (opts.access === "public") {
462
+ return bucket().getPublicUrl(path).data.publicUrl;
463
+ }
464
+ const { data, error } = await bucket().createSignedUrl(path, ttl);
465
+ if (error) throw new Error(`Supabase sign failed: ${error.message}`);
466
+ if (!data) throw new Error("Supabase sign returned no url");
467
+ return { url: data.signedUrl, expiresAt: Date.now() + ttl * 1e3 };
468
+ },
469
+ async remove(ref) {
470
+ const { error } = await bucket().remove([ref.handle]);
471
+ if (error) throw new Error(`Supabase remove failed: ${error.message}`);
472
+ }
473
+ };
474
+ }
475
+
476
+ // src/adapters/s3.ts
477
+ function s3FileStorage(opts) {
478
+ return {
479
+ pipeline: "handshake",
480
+ defaultMapper: scalarHandle(),
481
+ orphans: opts.orphans ?? "best-effort",
482
+ async upload(file, ctx) {
483
+ if (ctx.signal.aborted) throw new DOMException("Aborted", "AbortError");
484
+ const target = await opts.sign({ name: file.name, type: file.type, size: file.size });
485
+ if (ctx.signal.aborted) throw new DOMException("Aborted", "AbortError");
486
+ const xhr = (opts.xhrImpl ?? (() => new XMLHttpRequest()))();
487
+ const method = target.method ?? "PUT";
488
+ await new Promise((resolve, reject) => {
489
+ const onAbort = () => xhr.abort();
490
+ ctx.signal.addEventListener("abort", onAbort, { once: true });
491
+ xhr.upload.addEventListener("progress", (e) => {
492
+ const ev = e;
493
+ ctx.onProgress(ev.loaded, ev.total || file.size);
494
+ });
495
+ xhr.addEventListener("load", () => {
496
+ ctx.signal.removeEventListener("abort", onAbort);
497
+ if (xhr.status >= 200 && xhr.status < 300) {
498
+ ctx.onProgress(file.size, file.size);
499
+ resolve();
500
+ } else reject(new Error(`S3 upload failed: ${xhr.status}`));
501
+ });
502
+ xhr.addEventListener("error", () => {
503
+ ctx.signal.removeEventListener("abort", onAbort);
504
+ reject(new Error("S3 upload failed: network error"));
505
+ });
506
+ xhr.addEventListener("abort", () => {
507
+ reject(new DOMException("Aborted", "AbortError"));
508
+ });
509
+ xhr.open(method, target.url);
510
+ for (const [k, v] of Object.entries(target.headers ?? {})) {
511
+ xhr.setRequestHeader(k, v);
512
+ }
513
+ if (method === "POST") {
514
+ const fd = new FormData();
515
+ for (const [k, v] of Object.entries(target.fields ?? {})) fd.append(k, v);
516
+ fd.append("file", file);
517
+ xhr.send(fd);
518
+ } else {
519
+ xhr.send(file);
520
+ }
521
+ });
522
+ return newRef({
523
+ handle: target.key,
524
+ name: file.name,
525
+ mime: file.type || void 0,
526
+ size: file.size
527
+ });
528
+ },
529
+ resolveUrl(ref, ctx) {
530
+ const key = ref.handle;
531
+ if (opts.signView) return opts.signView(key, ctx);
532
+ if (opts.publicBaseUrl) return `${opts.publicBaseUrl.replace(/\/+$/, "")}/${key}`;
533
+ throw new Error(
534
+ "s3FileStorage: pass publicBaseUrl for a public bucket or signView for a private one"
535
+ );
536
+ },
537
+ ...opts.remove ? {
538
+ async remove(ref) {
539
+ await opts.remove?.(ref.handle);
540
+ }
541
+ } : {}
542
+ };
543
+ }
544
+
545
+ // src/mappers/column-patch.ts
546
+ var COLUMN_PATCH = /* @__PURE__ */ Symbol.for("buro.fileStorage.columnPatch");
547
+ function columnPatch(keys) {
548
+ return { [COLUMN_PATCH]: true, keys };
549
+ }
550
+ function isColumnPatch(value) {
551
+ return typeof value === "object" && value !== null && COLUMN_PATCH in value;
552
+ }
553
+
554
+ // src/adapters/pocketbase.ts
555
+ function pocketbaseDiff() {
556
+ return {
557
+ carriesPending: true,
558
+ read(column) {
559
+ if (column == null) return [];
560
+ const list = Array.isArray(column) ? column : [column];
561
+ return list.filter((n) => typeof n === "string" && n !== "").map((name) => newRef({ handle: name, raw: name, name }));
562
+ },
563
+ write(next, prev, ctx) {
564
+ const added = next.filter((r) => r.pending).map((r) => r.pending);
565
+ if (!ctx.multiple) {
566
+ if (added.length > 0) return columnPatch({ [ctx.field]: added[0] });
567
+ const kept = next.find((r) => r.handle);
568
+ if (kept) return columnPatch({});
569
+ return prev.length > 0 ? columnPatch({ [ctx.field]: "" }) : columnPatch({});
570
+ }
571
+ const keptHandles = new Set(next.filter((r) => r.handle).map((r) => r.handle));
572
+ const removed = prev.map((r) => r.handle).filter((h) => h != null && !keptHandles.has(h));
573
+ const keys = {};
574
+ if (added.length > 0) keys[`${ctx.field}+`] = added;
575
+ if (removed.length > 0) keys[`${ctx.field}-`] = removed;
576
+ return columnPatch(keys);
577
+ }
578
+ };
579
+ }
580
+ function pocketbaseFileStorage(opts) {
581
+ const base = opts.baseUrl.replace(/\/+$/, "");
582
+ const ttl = opts.tokenTtlSec ?? 120;
583
+ return {
584
+ pipeline: "fused",
585
+ encoding: "multipart",
586
+ defaultMapper: pocketbaseDiff(),
587
+ orphans: "keep",
588
+ async upload(file, _ctx) {
589
+ return newRef({
590
+ pending: file,
591
+ name: file.name,
592
+ mime: file.type || void 0,
593
+ size: file.size
594
+ });
595
+ },
596
+ async resolveUrl(ref, ctx) {
597
+ const recordId = ctx.record?.id;
598
+ if (!ctx.resource || !recordId) {
599
+ throw new Error(
600
+ "pocketbaseFileStorage: a file URL needs its collection and record id \u2014 the filename alone does not identify a file in PocketBase."
601
+ );
602
+ }
603
+ const params = new URLSearchParams();
604
+ if (ctx.variant?.width || ctx.variant?.height) {
605
+ const w = ctx.variant.width ?? 0;
606
+ const h = ctx.variant.height ?? 0;
607
+ params.set("thumb", `${w}x${h}f`);
608
+ }
609
+ if (ctx.disposition === "attachment") params.set("download", "1");
610
+ if (opts.protectedFiles) {
611
+ if (!opts.getFileToken) {
612
+ throw new Error(
613
+ "pocketbaseFileStorage: protectedFiles is set but getFileToken was not supplied"
614
+ );
615
+ }
616
+ const token = await opts.getFileToken();
617
+ params.set("token", token);
618
+ const qs2 = params.toString();
619
+ return {
620
+ url: `${base}/api/files/${ctx.resource}/${recordId}/${ref.handle}${qs2 ? `?${qs2}` : ""}`,
621
+ expiresAt: Date.now() + ttl * 1e3
622
+ };
623
+ }
624
+ const qs = params.toString();
625
+ return `${base}/api/files/${ctx.resource}/${recordId}/${ref.handle}${qs ? `?${qs}` : ""}`;
626
+ }
627
+ };
628
+ }
629
+
630
+ // src/wrapper/encode.ts
631
+ function splicePatches(variables) {
632
+ let touched = false;
633
+ const out = {};
634
+ for (const [key, value] of Object.entries(variables)) {
635
+ if (isColumnPatch(value)) {
636
+ touched = true;
637
+ Object.assign(out, value.keys);
638
+ continue;
639
+ }
640
+ out[key] = value;
641
+ }
642
+ return touched ? out : variables;
643
+ }
644
+ function containsFile(value, seen = /* @__PURE__ */ new WeakSet()) {
645
+ if (typeof File !== "undefined" && value instanceof File) return true;
646
+ if (!value || typeof value !== "object") return false;
647
+ if (seen.has(value)) return false;
648
+ seen.add(value);
649
+ if (Array.isArray(value)) return value.some((v) => containsFile(v, seen));
650
+ return Object.values(value).some((v) => containsFile(v, seen));
651
+ }
652
+ function containsColumnPatch(value, seen = /* @__PURE__ */ new WeakSet()) {
653
+ if (!value || typeof value !== "object") return false;
654
+ try {
655
+ if (isColumnPatch(value)) return true;
656
+ if (seen.has(value)) return false;
657
+ seen.add(value);
658
+ const descriptors = Object.getOwnPropertyDescriptors(value);
659
+ return Object.entries(descriptors).some(
660
+ ([key, descriptor]) => key !== "length" && "value" in descriptor && containsColumnPatch(descriptor.value, seen)
661
+ );
662
+ } catch {
663
+ return false;
664
+ }
665
+ }
666
+ async function toDataUrl(file) {
667
+ const buf = await file.arrayBuffer();
668
+ const bytes = new Uint8Array(buf);
669
+ let binary = "";
670
+ for (const byte of bytes) binary += String.fromCharCode(byte);
671
+ const b64 = typeof btoa === "function" ? btoa(binary) : Buffer.from(bytes).toString("base64");
672
+ return `data:${file.type || "application/octet-stream"};base64,${b64}`;
673
+ }
674
+ async function encodePayload(variables, plan) {
675
+ const staged = {};
676
+ for (const [key, value] of Object.entries(variables)) {
677
+ if (encodingFor(key, plan) !== "base64") {
678
+ staged[key] = value;
679
+ continue;
680
+ }
681
+ if (value instanceof File) staged[key] = await toDataUrl(value);
682
+ else if (Array.isArray(value)) {
683
+ staged[key] = await Promise.all(
684
+ value.map((v) => v instanceof File ? toDataUrl(v) : Promise.resolve(v))
685
+ );
686
+ } else staged[key] = value;
687
+ }
688
+ if (!containsFile(staged)) return staged;
689
+ const encoding = plan.bodyEncoding;
690
+ const fd = new FormData();
691
+ if (encoding === "graphql-upload") {
692
+ const files = [];
693
+ const map = {};
694
+ const scrubbed = {};
695
+ for (const [key, value] of Object.entries(staged)) {
696
+ if (value instanceof File) {
697
+ map[String(files.length)] = [`variables.${key}`];
698
+ files.push(value);
699
+ scrubbed[key] = null;
700
+ } else scrubbed[key] = value;
701
+ }
702
+ fd.append("operations", JSON.stringify({ variables: scrubbed }));
703
+ fd.append("map", JSON.stringify(map));
704
+ files.forEach((f, i) => fd.append(String(i), f));
705
+ return fd;
706
+ }
707
+ for (const [key, value] of Object.entries(staged)) {
708
+ if (value instanceof File) fd.append(key, value);
709
+ else if (Array.isArray(value)) {
710
+ for (const item of value) {
711
+ fd.append(key, item instanceof File ? item : String(item));
712
+ }
713
+ } else if (value == null) fd.append(key, "");
714
+ else if (typeof value === "object") fd.append(key, JSON.stringify(value));
715
+ else fd.append(key, String(value));
716
+ }
717
+ return fd;
718
+ }
719
+
720
+ // src/wrapper/with-file-storage.ts
721
+ var FILE_STORAGE_WRAPPED = /* @__PURE__ */ Symbol.for("buro.fileStorage.wrapped");
722
+ var MUTATING = /* @__PURE__ */ new Set(["create", "update", "createMany", "updateMany"]);
723
+ function isProviderMap(value) {
724
+ if (!value || typeof value !== "object") return false;
725
+ const entries = Object.values(value);
726
+ return entries.length > 0 && entries.every((v) => v && typeof v === "object" && !("then" in v));
727
+ }
728
+ function withFileStorage(provider, app) {
729
+ const registry = app.getService(FILE_STORAGE_SERVICE);
730
+ if (!registry) {
731
+ throw new Error(
732
+ "withFileStorage: no file storage registered \u2014 add fileStoragePlugin({...}) to defineApp"
733
+ );
734
+ }
735
+ if (isProviderMap(provider) && !provider.getList) {
736
+ const out = {};
737
+ for (const [name, entry] of Object.entries(provider)) {
738
+ out[name] = withFileStorage(entry, app);
739
+ }
740
+ Object.defineProperty(out, FILE_STORAGE_WRAPPED, { value: true, enumerable: false });
741
+ return out;
742
+ }
743
+ const source = provider;
744
+ const wrapped = Object.create(
745
+ Object.getPrototypeOf(source)
746
+ );
747
+ for (const key of allKeys(source)) {
748
+ const value = source[key];
749
+ if (typeof value !== "function" || !MUTATING.has(key)) {
750
+ wrapped[key] = value;
751
+ continue;
752
+ }
753
+ wrapped[key] = async (args) => {
754
+ const variables = args?.variables;
755
+ if (!variables || typeof variables !== "object" || variables instanceof FormData) {
756
+ return value(args);
757
+ }
758
+ if ((key === "createMany" || key === "updateMany") && (containsFile(variables) || containsColumnPatch(variables))) {
759
+ throw new Error(
760
+ `withFileStorage: bulk file mutations are not supported for ${key}; submit one record at a time so FileMapper patches and request-body encoding stay unambiguous.`
761
+ );
762
+ }
763
+ if (Array.isArray(variables)) return value(args);
764
+ const spliced = splicePatches(variables);
765
+ if (!containsFile(spliced)) {
766
+ const passthrough = spliced === variables ? args : { ...args, variables: spliced };
767
+ return value(passthrough);
768
+ }
769
+ const encoded = await encodePayload(spliced, planFor(app, registry, String(args.resource)));
770
+ return value({ ...args, variables: encoded });
771
+ };
772
+ }
773
+ Object.defineProperty(wrapped, FILE_STORAGE_WRAPPED, { value: true, enumerable: false });
774
+ return wrapped;
775
+ }
776
+ function allKeys(source) {
777
+ const keys = new Set(Object.keys(source));
778
+ const proto = Object.getPrototypeOf(source);
779
+ if (proto && proto !== Object.prototype) {
780
+ for (const k of Object.getOwnPropertyNames(proto)) if (k !== "constructor") keys.add(k);
781
+ }
782
+ return [...keys];
783
+ }
784
+ function fieldOfKey(key) {
785
+ return key.endsWith("+") || key.endsWith("-") ? key.slice(0, -1) : key;
786
+ }
787
+ function encodingFor(key, plan) {
788
+ return plan.knownEncodings.get(fieldOfKey(key)) ?? plan.defaultEncoding;
789
+ }
790
+ function planFor(app, registry, resource) {
791
+ const knownEncodings = /* @__PURE__ */ new Map();
792
+ const defaultAdapter = registry.has("default") ? registry.get("default") : void 0;
793
+ const defaultEncoding = defaultAdapter?.pipeline === "fused" && defaultAdapter.encoding ? defaultAdapter.encoding : "multipart";
794
+ let bodyEncoding = defaultEncoding === "base64" ? "multipart" : defaultEncoding;
795
+ const def = app.registry.get(resource);
796
+ for (const [fieldName, field] of Object.entries(def?.fields ?? {})) {
797
+ const ui = field.ui;
798
+ if (ui?.component !== "file") continue;
799
+ if (!registry.has(ui.fileStorage ?? "default")) continue;
800
+ const adapter = registry.get(ui.fileStorage);
801
+ if (adapter.pipeline !== "fused" || !adapter.encoding) continue;
802
+ knownEncodings.set(fieldName, adapter.encoding);
803
+ if (adapter.encoding !== "base64") bodyEncoding = adapter.encoding;
804
+ }
805
+ return { knownEncodings, defaultEncoding, bodyEncoding };
806
+ }
807
+
808
+ // src/wrapper/assert-wiring.ts
809
+ var BODY_ENCODINGS = ["multipart", "graphql-upload"];
810
+ function assertFileStorageWiring(app, provider) {
811
+ const registry = app.getService(FILE_STORAGE_SERVICE);
812
+ if (!registry) return;
813
+ if (registry.hasFused()) {
814
+ const stamped = provider != null && (typeof provider === "object" || typeof provider === "function") && FILE_STORAGE_WRAPPED in provider;
815
+ if (!stamped) {
816
+ throw new Error(
817
+ "A fused file storage is registered, but the data provider is not wrapped. Files ride inside the record mutation for this pipeline, so an unwrapped provider will JSON.stringify them into `{}` and lose them silently. Wrap it once: dataProvider: withFileStorage(myDataProvider, app)"
818
+ );
819
+ }
820
+ }
821
+ for (const def of app.registry.list()) {
822
+ const seen = /* @__PURE__ */ new Map();
823
+ for (const [fieldName, field] of Object.entries(def.fields ?? {})) {
824
+ const ui = field.ui;
825
+ if (ui?.component !== "file") continue;
826
+ if (!registry.has(ui.fileStorage ?? "default")) continue;
827
+ const adapter = registry.get(ui.fileStorage);
828
+ if (adapter.pipeline === "fused") {
829
+ const mapper = ui.mapper ?? adapter.defaultMapper;
830
+ if (!mapper.carriesPending) {
831
+ throw new Error(
832
+ `Resource '${def.name}' field '${fieldName}' pairs a fused file storage with a mapper that cannot carry a pending file forward. A ref on a fused pipeline never has a \`handle\` before the record mutation runs \u2014 only \`pending\` \u2014 and this mapper's \`write\` has no \`carriesPending\` marker, meaning it drops any ref with no handle. That silently discards every file added through this field, with no error. Use a mapper built for this pipeline (scalarHandle, pocketbaseDiff), or give this field one that sets \`carriesPending: true\` via \`f.file({ mapper })\`.`
833
+ );
834
+ }
835
+ }
836
+ if (adapter.pipeline !== "fused" || !adapter.encoding) continue;
837
+ if (!BODY_ENCODINGS.includes(adapter.encoding)) continue;
838
+ const previous = seen.get(adapter.encoding);
839
+ if (previous == null) seen.set(adapter.encoding, fieldName);
840
+ if (seen.size > 1) {
841
+ const entries = [...seen.entries()];
842
+ const [encA, fieldA] = entries[0];
843
+ const [encB, fieldB] = entries[1];
844
+ throw new Error(
845
+ `Resource '${def.name}' mixes two request-body encodings: field '${fieldA}' wants ${encA} and field '${fieldB}' wants ${encB}. One request has one body \u2014 move one of these fields to a storage using the same encoding, or to base64, which transforms values rather than claiming the body.`
846
+ );
847
+ }
848
+ }
849
+ }
850
+ }
851
+
852
+ // src/baseline-carrier.ts
853
+ var FILE_MAPPER_BASELINE_CARRIER = /* @__PURE__ */ Symbol.for("buro.fileMapper.baselineCarrier");
854
+ function inheritFileMapperBaseline(source, target) {
855
+ let descriptor;
856
+ try {
857
+ descriptor = Object.getOwnPropertyDescriptor(source, FILE_MAPPER_BASELINE_CARRIER);
858
+ } catch {
859
+ return target;
860
+ }
861
+ if (descriptor === void 0 || !("value" in descriptor) || descriptor.value === null || typeof descriptor.value !== "object") {
862
+ return target;
863
+ }
864
+ Object.defineProperty(target, FILE_MAPPER_BASELINE_CARRIER, {
865
+ value: descriptor.value,
866
+ enumerable: false,
867
+ configurable: true,
868
+ writable: false
869
+ });
870
+ return target;
871
+ }
872
+
873
+ // src/index.ts
874
+ var PACKAGE_NAME = "@burojs/file-storage";
875
+
876
+ export { COLUMN_PATCH, FILE_STORAGE_SERVICE, FILE_STORAGE_WRAPPED, PACKAGE_NAME, assertFileStorageWiring, columnPatch, containsColumnPatch, containsFile, createFakeAdapter, createFileStorageRegistry, encodePayload, fileStoragePlugin, inheritFileMapperBaseline, inlineFileStorage, isColumnPatch, isImage, junction, newRef, nhostFileStorage, pocketbaseDiff, pocketbaseFileStorage, richImage, runFileStorageContract, s3FileStorage, scalarHandle, splicePatches, supabaseFileStorage, withFileStorage };
877
+ //# sourceMappingURL=index.js.map
878
+ //# sourceMappingURL=index.js.map