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