@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/LICENSE +21 -0
- package/README.md +13 -0
- package/dist/index.cjs +907 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +609 -0
- package/dist/index.d.ts +609 -0
- package/dist/index.js +878 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import { Plugin, AppRuntime } from '@burojs/core';
|
|
2
|
+
|
|
3
|
+
/** How a file's bytes travel relative to the record mutation. */
|
|
4
|
+
type UploadPipeline = 'immediate' | 'fused' | 'handshake' | 'resumable';
|
|
5
|
+
/**
|
|
6
|
+
* How a `fused` adapter wants its bytes encoded into the mutation.
|
|
7
|
+
*
|
|
8
|
+
* `base64` is a VALUE transform — the File becomes a string before the body is
|
|
9
|
+
* assembled — so it composes with anything. `multipart` and `graphql-upload`
|
|
10
|
+
* are REQUEST-BODY encodings and are mutually exclusive: one request has one
|
|
11
|
+
* body.
|
|
12
|
+
*/
|
|
13
|
+
type FusedEncoding = 'multipart' | 'base64' | 'graphql-upload';
|
|
14
|
+
/** What to do with bytes that landed but whose form was never saved. */
|
|
15
|
+
type OrphanPolicy = 'best-effort' | 'keep';
|
|
16
|
+
/**
|
|
17
|
+
* A resolved display URL.
|
|
18
|
+
*
|
|
19
|
+
* A plain string means the URL is permanent — cache it, render it on the
|
|
20
|
+
* server. The object form means it is signed: the cache re-resolves on expiry,
|
|
21
|
+
* and the URL must never reach the database or an SSR payload.
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ `expiresAt` (epoch ms) is deliberately REQUIRED on the object arm. An
|
|
24
|
+
* omitted expiry is an accident, not a decision — the same rule the data
|
|
25
|
+
* provider already applies to `getCount`'s `total`
|
|
26
|
+
* (`docs/data-provider-guide.md:92`). Without it, an adapter that signs a URL
|
|
27
|
+
* but stays silent about the deadline gets that URL cached FOREVER, which is
|
|
28
|
+
* precisely the rotting-link failure this port exists to prevent. An adapter
|
|
29
|
+
* that genuinely cannot learn the expiry must pick a conservative one and say
|
|
30
|
+
* so, rather than leaving the consumer to guess.
|
|
31
|
+
*/
|
|
32
|
+
type ResolvedUrl = string | {
|
|
33
|
+
url: string;
|
|
34
|
+
expiresAt: number;
|
|
35
|
+
};
|
|
36
|
+
/** The framework's in-memory normal form for one file. */
|
|
37
|
+
interface FileRef {
|
|
38
|
+
/** Client-side identity. Present from the moment the file is picked. */
|
|
39
|
+
localId: string;
|
|
40
|
+
/**
|
|
41
|
+
* Storage identity — id, key, path or filename, whichever this backend
|
|
42
|
+
* uses. Absent until the bytes have landed, and always absent for `fused`
|
|
43
|
+
* until the mutation succeeds.
|
|
44
|
+
*/
|
|
45
|
+
handle?: string;
|
|
46
|
+
/** Raw bytes not yet sent. Present for `fused` and for `defer: true`. */
|
|
47
|
+
pending?: File;
|
|
48
|
+
name?: string;
|
|
49
|
+
mime?: string;
|
|
50
|
+
size?: number;
|
|
51
|
+
width?: number;
|
|
52
|
+
height?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Metadata that belongs to THIS REFERENCE rather than to the file — a
|
|
55
|
+
* Sanity-style hotspot/crop, an alt text that differs per usage, a sort
|
|
56
|
+
* position. Opaque to the framework; carried through to the renderer.
|
|
57
|
+
*/
|
|
58
|
+
usage?: Record<string, unknown>;
|
|
59
|
+
/**
|
|
60
|
+
* The original column entry this ref was read from. Absent for newly added
|
|
61
|
+
* files.
|
|
62
|
+
*
|
|
63
|
+
* ⚠️ `write` MUST merge onto this rather than rebuild the value. A Strapi
|
|
64
|
+
* media object carries `provider_metadata`, `createdAt`, `caption` and more;
|
|
65
|
+
* reading four known keys and writing four keys back DELETES the rest.
|
|
66
|
+
*/
|
|
67
|
+
raw?: unknown;
|
|
68
|
+
}
|
|
69
|
+
interface MapperCtx {
|
|
70
|
+
/** Registry name of the resource being read or written. */
|
|
71
|
+
resource: string;
|
|
72
|
+
/** Field name this column belongs to. */
|
|
73
|
+
field: string;
|
|
74
|
+
/** Declared cardinality — a single-value mapper must not emit an array. */
|
|
75
|
+
multiple: boolean;
|
|
76
|
+
/** The record the column belongs to. Absent on create. */
|
|
77
|
+
record?: Record<string, unknown>;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Translates between the record's column and the normalised form.
|
|
81
|
+
*
|
|
82
|
+
* Pure — no I/O, no React, no network. This is what makes "any API can be
|
|
83
|
+
* stretched onto this" a testable claim rather than an aspiration.
|
|
84
|
+
*
|
|
85
|
+
* `TRead` and `TWrite` are separate because read and write shapes genuinely
|
|
86
|
+
* differ on real backends: Strapi returns a rich media object and accepts back
|
|
87
|
+
* only an id.
|
|
88
|
+
*/
|
|
89
|
+
interface FileMapper<TRead = unknown, TWrite = TRead> {
|
|
90
|
+
read(column: TRead, ctx: MapperCtx): FileRef[];
|
|
91
|
+
/**
|
|
92
|
+
* `prev` is not decoration: some backends update a multi-value field by
|
|
93
|
+
* DIFF rather than replacement (PocketBase uses `field+` to add and
|
|
94
|
+
* `field-` to remove), and a diff cannot be computed without the old value.
|
|
95
|
+
*/
|
|
96
|
+
write(next: FileRef[], prev: FileRef[], ctx: MapperCtx): TWrite;
|
|
97
|
+
/**
|
|
98
|
+
* Declares that `write` carries a `pending`-only ref (no `handle` yet)
|
|
99
|
+
* forward into its output — typically by embedding the raw `File` itself —
|
|
100
|
+
* rather than silently omitting it.
|
|
101
|
+
*
|
|
102
|
+
* This matters because a ref on a `fused`-pipeline storage NEVER has a
|
|
103
|
+
* `handle` before the record mutation runs: the bytes ride the mutation
|
|
104
|
+
* itself, so `pending` is all a freshly-added file ever has until the
|
|
105
|
+
* backend accepts it. A mapper that only knows how to write a `handle`
|
|
106
|
+
* (the ordinary case — `richImage`, `junction`) silently drops that file
|
|
107
|
+
* with no error the moment it is paired with a `fused` adapter.
|
|
108
|
+
* `assertFileStorageWiring` refuses that pairing at boot rather than
|
|
109
|
+
* discovering it via a vanished upload.
|
|
110
|
+
*
|
|
111
|
+
* Absent/false is the safe default: a mapper written assuming every ref
|
|
112
|
+
* already has a handle by the time `write` sees it should not claim
|
|
113
|
+
* otherwise. `scalarHandle` and `pocketbaseDiff` are the two mappers that
|
|
114
|
+
* set this.
|
|
115
|
+
*/
|
|
116
|
+
carriesPending?: boolean;
|
|
117
|
+
}
|
|
118
|
+
interface UploadCtx {
|
|
119
|
+
signal: AbortSignal;
|
|
120
|
+
onProgress: (loaded: number, total: number) => void;
|
|
121
|
+
}
|
|
122
|
+
interface ResolveCtx {
|
|
123
|
+
variant?: {
|
|
124
|
+
width?: number;
|
|
125
|
+
height?: number;
|
|
126
|
+
};
|
|
127
|
+
disposition?: 'inline' | 'attachment';
|
|
128
|
+
resource?: string;
|
|
129
|
+
record?: Record<string, unknown>;
|
|
130
|
+
}
|
|
131
|
+
interface FileStorageAdapter {
|
|
132
|
+
pipeline: UploadPipeline;
|
|
133
|
+
/** Used by every field that does not name its own mapper. */
|
|
134
|
+
defaultMapper: FileMapper;
|
|
135
|
+
/** Only meaningful when `pipeline === 'fused'`. */
|
|
136
|
+
encoding?: FusedEncoding;
|
|
137
|
+
/** Storage-level default; a field may override. */
|
|
138
|
+
orphans?: OrphanPolicy;
|
|
139
|
+
/** For a `fused` adapter this is a no-op — the bytes have nowhere to go yet. */
|
|
140
|
+
upload(file: File, ctx: UploadCtx): Promise<FileRef>;
|
|
141
|
+
resolveUrl(ref: FileRef, ctx: ResolveCtx): ResolvedUrl | Promise<ResolvedUrl>;
|
|
142
|
+
/**
|
|
143
|
+
* Batched metadata read. Implement only when the column holds a bare handle
|
|
144
|
+
* with no name/mime/size — otherwise the framework never calls it.
|
|
145
|
+
*
|
|
146
|
+
* Honesty rule: a field this backend cannot report is left ABSENT on the
|
|
147
|
+
* returned ref. Never guess, and never substitute a zero.
|
|
148
|
+
*/
|
|
149
|
+
stat?(refs: FileRef[]): Promise<FileRef[]>;
|
|
150
|
+
remove?(ref: FileRef): Promise<void>;
|
|
151
|
+
}
|
|
152
|
+
interface FileStorageRegistry {
|
|
153
|
+
/** `undefined` resolves the `default` entry. Throws on an unknown name. */
|
|
154
|
+
get(name?: string): FileStorageAdapter;
|
|
155
|
+
has(name: string): boolean;
|
|
156
|
+
names(): string[];
|
|
157
|
+
/** True when any registered adapter carries its bytes inside the mutation. */
|
|
158
|
+
hasFused(): boolean;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Service name under which the registry is registered on the app. */
|
|
162
|
+
declare const FILE_STORAGE_SERVICE: "fileStorage";
|
|
163
|
+
declare function createFileStorageRegistry(map: Record<string, FileStorageAdapter>): FileStorageRegistry;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Declare the app's file storages as a named map, mirroring the existing
|
|
167
|
+
* multi-data-provider convention. `default` is required; other keys are free
|
|
168
|
+
* form and are what a field names in `f.file({ fileStorage: '…' })`.
|
|
169
|
+
*/
|
|
170
|
+
declare function fileStoragePlugin(map: Record<string, FileStorageAdapter>): Plugin;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Mint a ref with a fresh client-side identity.
|
|
174
|
+
*
|
|
175
|
+
* Deliberately a counter rather than `crypto.randomUUID()`: ids are
|
|
176
|
+
* process-local and never persisted, and a counter keeps test output
|
|
177
|
+
* readable and snapshots stable.
|
|
178
|
+
*/
|
|
179
|
+
declare function newRef(init?: Partial<FileRef>): FileRef;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Kind by mime; the filename is consulted only when mime is unknown.
|
|
183
|
+
*
|
|
184
|
+
* A pure predicate over `FileRef`, not a UI concern — it lives next to the
|
|
185
|
+
* type it describes so any renderer, in `@burojs/ui-shadcn` or a future UI
|
|
186
|
+
* kit, answers "is this an image?" the same way. Two independently
|
|
187
|
+
* maintained copies (one per renderer) drift the moment one gains a mime
|
|
188
|
+
* type or extension the other lacks — which is exactly the bug this
|
|
189
|
+
* function replaces: the deleted `isLikelyImageUrl` regexed an extension out
|
|
190
|
+
* of the stored string, which can never match a uuid or other opaque handle.
|
|
191
|
+
*/
|
|
192
|
+
declare function isImage(ref: FileRef): boolean;
|
|
193
|
+
|
|
194
|
+
type ScalarColumn = string | string[] | null | undefined;
|
|
195
|
+
type ScalarWriteValue = string | File | (string | File)[] | null;
|
|
196
|
+
/**
|
|
197
|
+
* The column IS the handle: a URL, an id, a key, a path, a filename. One
|
|
198
|
+
* mapper covers all four because the difference between them is meaning, not
|
|
199
|
+
* structure — and three names for one function would be three things to keep
|
|
200
|
+
* in sync for no behavioural gain.
|
|
201
|
+
*/
|
|
202
|
+
declare function scalarHandle(): FileMapper<ScalarColumn, ScalarWriteValue>;
|
|
203
|
+
|
|
204
|
+
interface RichImageOpts {
|
|
205
|
+
/**
|
|
206
|
+
* Who owns the focal point.
|
|
207
|
+
*
|
|
208
|
+
* - `'usage'` — it lives on the reference inside the record, so the same
|
|
209
|
+
* asset crops differently in two records (Sanity-shaped).
|
|
210
|
+
* - `'file'` — it is a property of the file that the storage owns; the
|
|
211
|
+
* mapper leaves it alone (Directus-shaped).
|
|
212
|
+
* - `false` — this column has none.
|
|
213
|
+
*
|
|
214
|
+
* There is no default. Guessing wrong silently corrupts a neighbouring
|
|
215
|
+
* record's crop, which is exactly the failure nobody notices for months.
|
|
216
|
+
*/
|
|
217
|
+
focus: 'usage' | 'file' | false;
|
|
218
|
+
/** Dotted path to the handle inside the object. Defaults to `'id'`. */
|
|
219
|
+
handleKey?: string;
|
|
220
|
+
/** Keys lifted into `usage` when `focus === 'usage'`. */
|
|
221
|
+
usageKeys?: string[];
|
|
222
|
+
}
|
|
223
|
+
declare function richImage(opts: RichImageOpts): FileMapper<unknown, unknown>;
|
|
224
|
+
|
|
225
|
+
interface JunctionOpts {
|
|
226
|
+
/** Column on the junction row holding the file's handle. */
|
|
227
|
+
fileKey: string;
|
|
228
|
+
/** Column holding the 1-based position. Omit when the table is unordered. */
|
|
229
|
+
sortKey?: string;
|
|
230
|
+
}
|
|
231
|
+
type Row = Record<string, unknown>;
|
|
232
|
+
/**
|
|
233
|
+
* A column that is an array of junction ROWS rather than of files — Directus
|
|
234
|
+
* M2M being the canonical case. Each row carries its own primary key, which
|
|
235
|
+
* must survive a reorder: dropping it would delete and recreate every row.
|
|
236
|
+
*/
|
|
237
|
+
declare function junction(opts: JunctionOpts): FileMapper<Row[] | null | undefined, Row[]>;
|
|
238
|
+
|
|
239
|
+
interface ContractOpts {
|
|
240
|
+
/** A FRESH adapter per case — shared state between cases hides bugs. */
|
|
241
|
+
adapter: () => FileStorageAdapter;
|
|
242
|
+
mapper: FileMapper;
|
|
243
|
+
/** Representative column values this mapper reads, for the invariants. */
|
|
244
|
+
sampleColumn: {
|
|
245
|
+
single: unknown;
|
|
246
|
+
multiple: unknown;
|
|
247
|
+
};
|
|
248
|
+
/** Skip upload cases for adapters whose bytes ride the mutation. */
|
|
249
|
+
skipUpload?: boolean;
|
|
250
|
+
/**
|
|
251
|
+
* Set for a mapper whose `write` returns a COLUMN PATCH rather than a column
|
|
252
|
+
* value — `pocketbaseDiff` is the one in this package.
|
|
253
|
+
*
|
|
254
|
+
* Round-trip identity is meaningless for such a mapper: it never returns the
|
|
255
|
+
* value it was given, it returns the keys needed to change it. The patch
|
|
256
|
+
* invariants below replace the round-trip cases rather than sitting beside
|
|
257
|
+
* them, because running both would demand two contradictory things of one
|
|
258
|
+
* `write`.
|
|
259
|
+
*/
|
|
260
|
+
writesColumnPatch?: boolean;
|
|
261
|
+
/**
|
|
262
|
+
* Context every `resolveUrl` case is called with. Defaults to `{}`.
|
|
263
|
+
*
|
|
264
|
+
* Supply it for an adapter whose URL cannot be built from a handle alone.
|
|
265
|
+
* A PocketBase filename is meaningless by itself — the URL is assembled from
|
|
266
|
+
* collection + record + filename — so that adapter correctly THROWS on an
|
|
267
|
+
* empty context, and the contract must hand it a real one rather than
|
|
268
|
+
* treating the throw as a conformance failure.
|
|
269
|
+
*/
|
|
270
|
+
resolveCtx?: ResolveCtx;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* The test runner, injected.
|
|
274
|
+
*
|
|
275
|
+
* `src/` may not import vitest: this package promises zero runtime
|
|
276
|
+
* dependencies, and a module under `src/` importing a test framework ships
|
|
277
|
+
* that import in the published bundle. Injection also lets an adopter on jest
|
|
278
|
+
* or node:test run the same contract.
|
|
279
|
+
*/
|
|
280
|
+
interface ContractRunner {
|
|
281
|
+
it: (name: string, fn: () => void | Promise<void>) => void;
|
|
282
|
+
expect: (actual: never) => unknown;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* The conformance suite every adapter owes. Call inside a `describe`.
|
|
286
|
+
*
|
|
287
|
+
* Exported from the package so adopters can point it at their own adapter —
|
|
288
|
+
* the claim "any API can be stretched onto this port" is only worth something
|
|
289
|
+
* if it is executable by the person doing the stretching.
|
|
290
|
+
*/
|
|
291
|
+
declare function runFileStorageContract(opts: ContractOpts, runner: ContractRunner): void;
|
|
292
|
+
|
|
293
|
+
interface FakeAdapterOpts {
|
|
294
|
+
/** Emit this many progress ticks before resolving. Default 2. */
|
|
295
|
+
progressTicks?: number;
|
|
296
|
+
/** Make every upload reject with this message. */
|
|
297
|
+
failWith?: string;
|
|
298
|
+
/** Return signed URLs that expire this many ms from resolution. */
|
|
299
|
+
signedTtlMs?: number;
|
|
300
|
+
/** Default `'immediate'`. Set `'fused'` to stand in for an inline/PocketBase-shaped storage. */
|
|
301
|
+
pipeline?: 'immediate' | 'fused';
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* In-memory adapter. Exists so the contract suite can be tested against
|
|
305
|
+
* something known-correct, and so adopters have a baseline to diff against
|
|
306
|
+
* when their own adapter fails a case.
|
|
307
|
+
*/
|
|
308
|
+
declare function createFakeAdapter(opts?: FakeAdapterOpts): FileStorageAdapter & {
|
|
309
|
+
stored: Map<string, File>;
|
|
310
|
+
removed: string[];
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
interface InlineFileStorageOpts {
|
|
314
|
+
/**
|
|
315
|
+
* How the data-provider wrapper should encode the bytes into the mutation.
|
|
316
|
+
* `base64` transforms the value; `multipart` and `graphql-upload` set the
|
|
317
|
+
* request body and are mutually exclusive with each other.
|
|
318
|
+
*/
|
|
319
|
+
encoding: FusedEncoding;
|
|
320
|
+
/**
|
|
321
|
+
* How a saved file is read back. `ctx.resource` and `ctx.record` are given
|
|
322
|
+
* because backends of this shape usually address files through the record
|
|
323
|
+
* that owns them.
|
|
324
|
+
*/
|
|
325
|
+
resolveUrl: (ref: FileRef, ctx: ResolveCtx) => ResolvedUrl | Promise<ResolvedUrl>;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* The storage that is not a storage: the backend takes the file inside the
|
|
329
|
+
* record mutation, so there is no upload endpoint and no handle until the
|
|
330
|
+
* mutation succeeds.
|
|
331
|
+
*
|
|
332
|
+
* `upload` deliberately does nothing but wrap the File in a ref. Progress here
|
|
333
|
+
* belongs to the mutation, and it is `withFileStorage` — the only code on the
|
|
334
|
+
* request path — that can report it.
|
|
335
|
+
*/
|
|
336
|
+
declare function inlineFileStorage(opts: InlineFileStorageOpts): FileStorageAdapter;
|
|
337
|
+
|
|
338
|
+
interface NhostFileStorageOpts {
|
|
339
|
+
/** Storage service base URL, e.g. `https://<project>.storage.<region>.nhost.run`. */
|
|
340
|
+
baseUrl: string;
|
|
341
|
+
/**
|
|
342
|
+
* Access token supplier. Injected rather than read from localStorage: the
|
|
343
|
+
* v0.2 implementation hardcoded a storage key and a window global, which
|
|
344
|
+
* meant one app shape was supported and no test could drive it.
|
|
345
|
+
*/
|
|
346
|
+
getToken: () => string | null | Promise<string | null>;
|
|
347
|
+
bucketId?: string;
|
|
348
|
+
orphans?: OrphanPolicy;
|
|
349
|
+
fetchImpl?: typeof fetch;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* nhost storage: bytes go up first, the column keeps the file's UUID, and the
|
|
353
|
+
* display URL is signed and short-lived.
|
|
354
|
+
*
|
|
355
|
+
* ⚠️ The handle is the id, NOT the presigned URL. Storing the presigned URL —
|
|
356
|
+
* as v0.2 did — writes a link with a TTL into the database.
|
|
357
|
+
*/
|
|
358
|
+
declare function nhostFileStorage(opts: NhostFileStorageOpts): FileStorageAdapter;
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The slice of `@supabase/supabase-js` this adapter uses, declared
|
|
362
|
+
* structurally so the package keeps zero runtime dependencies and the adapter
|
|
363
|
+
* can be driven by a plain object in tests.
|
|
364
|
+
*/
|
|
365
|
+
interface SupabaseStorageLike {
|
|
366
|
+
storage: {
|
|
367
|
+
from(bucket: string): {
|
|
368
|
+
upload(path: string, file: File, opts?: {
|
|
369
|
+
contentType?: string;
|
|
370
|
+
upsert?: boolean;
|
|
371
|
+
}): Promise<{
|
|
372
|
+
data: {
|
|
373
|
+
path: string;
|
|
374
|
+
} | null;
|
|
375
|
+
error: {
|
|
376
|
+
message: string;
|
|
377
|
+
} | null;
|
|
378
|
+
}>;
|
|
379
|
+
getPublicUrl(path: string): {
|
|
380
|
+
data: {
|
|
381
|
+
publicUrl: string;
|
|
382
|
+
};
|
|
383
|
+
};
|
|
384
|
+
createSignedUrl(path: string, expiresIn: number): Promise<{
|
|
385
|
+
data: {
|
|
386
|
+
signedUrl: string;
|
|
387
|
+
} | null;
|
|
388
|
+
error: {
|
|
389
|
+
message: string;
|
|
390
|
+
} | null;
|
|
391
|
+
}>;
|
|
392
|
+
remove(paths: string[]): Promise<{
|
|
393
|
+
data: unknown;
|
|
394
|
+
error: {
|
|
395
|
+
message: string;
|
|
396
|
+
} | null;
|
|
397
|
+
}>;
|
|
398
|
+
};
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
interface SupabaseFileStorageOpts {
|
|
402
|
+
client: SupabaseStorageLike;
|
|
403
|
+
bucket: string;
|
|
404
|
+
/** Public buckets yield permanent URLs; signed buckets yield expiring ones. */
|
|
405
|
+
access: 'public' | 'signed';
|
|
406
|
+
/** Lifetime of a signed URL in seconds. Default 3600. */
|
|
407
|
+
signedTtlSec?: number;
|
|
408
|
+
/** Prepended to every generated object path, e.g. `'avatars'`. */
|
|
409
|
+
pathPrefix?: string;
|
|
410
|
+
orphans?: OrphanPolicy;
|
|
411
|
+
}
|
|
412
|
+
declare function supabaseFileStorage(opts: SupabaseFileStorageOpts): FileStorageAdapter;
|
|
413
|
+
|
|
414
|
+
/** What the adopter's signing endpoint hands back. */
|
|
415
|
+
interface SignedTarget {
|
|
416
|
+
/**
|
|
417
|
+
* The object key. **The server chooses it, not the client** — letting the
|
|
418
|
+
* browser name the object is how buckets get overwritten by whoever asks.
|
|
419
|
+
*/
|
|
420
|
+
key: string;
|
|
421
|
+
url: string;
|
|
422
|
+
method?: 'PUT' | 'POST';
|
|
423
|
+
/** Headers that were part of the signature. Sent verbatim. */
|
|
424
|
+
headers?: Record<string, string>;
|
|
425
|
+
/** POST-policy form fields. Sent before the file part. */
|
|
426
|
+
fields?: Record<string, string>;
|
|
427
|
+
}
|
|
428
|
+
interface S3FileStorageOpts {
|
|
429
|
+
/** Ask your backend to sign. Authorization happens there, not here. */
|
|
430
|
+
sign: (file: {
|
|
431
|
+
name: string;
|
|
432
|
+
type: string;
|
|
433
|
+
size: number;
|
|
434
|
+
}) => Promise<SignedTarget>;
|
|
435
|
+
/** CDN or bucket origin for permanent read URLs. */
|
|
436
|
+
publicBaseUrl?: string;
|
|
437
|
+
/** Supply for private buckets; return an expiring URL. */
|
|
438
|
+
signView?: (key: string, ctx: ResolveCtx) => Promise<ResolvedUrl>;
|
|
439
|
+
remove?: (key: string) => Promise<void>;
|
|
440
|
+
orphans?: OrphanPolicy;
|
|
441
|
+
/** Injected for tests. Defaults to `new XMLHttpRequest()`. */
|
|
442
|
+
xhrImpl?: () => XMLHttpRequest;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Direct-to-bucket upload behind a signed handshake: ask the server to sign,
|
|
446
|
+
* send the bytes straight to storage, keep the key.
|
|
447
|
+
*
|
|
448
|
+
* Uses XHR rather than fetch on purpose — browsers expose no upload progress
|
|
449
|
+
* on fetch, and this is the pipeline whose files are large enough for the
|
|
450
|
+
* absence to be felt.
|
|
451
|
+
*/
|
|
452
|
+
declare function s3FileStorage(opts: S3FileStorageOpts): FileStorageAdapter;
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* PocketBase stores a filename per file and updates multi-value fields by
|
|
456
|
+
* DIFF: `field+` appends, `field-` removes by name. Single-value fields are
|
|
457
|
+
* replaced outright, and cleared with an empty string.
|
|
458
|
+
*/
|
|
459
|
+
declare function pocketbaseDiff(): FileMapper<string | string[] | null | undefined, unknown>;
|
|
460
|
+
interface PocketbaseFileStorageOpts {
|
|
461
|
+
/** PocketBase origin, e.g. `http://127.0.0.1:8090`. */
|
|
462
|
+
baseUrl: string;
|
|
463
|
+
/** Set when the file field is marked Protected in the collection schema. */
|
|
464
|
+
protectedFiles?: boolean;
|
|
465
|
+
/** Supplies a short-lived file token — `pb.files.getToken()`. */
|
|
466
|
+
getFileToken?: () => Promise<string>;
|
|
467
|
+
/** Lifetime assumed for a file token, in seconds. Default 120. */
|
|
468
|
+
tokenTtlSec?: number;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* PocketBase: bytes ride the record mutation as multipart, the column keeps a
|
|
472
|
+
* filename that only means anything alongside its record, images get thumbs
|
|
473
|
+
* from a query parameter, and protected files need a short-lived token.
|
|
474
|
+
*
|
|
475
|
+
* It is the hardest of the four reference pipelines, which is why it is the
|
|
476
|
+
* one the example app runs against.
|
|
477
|
+
*/
|
|
478
|
+
declare function pocketbaseFileStorage(opts: PocketbaseFileStorageOpts): FileStorageAdapter;
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* A mapper's answer when the change cannot be expressed as "the new value of
|
|
482
|
+
* this column".
|
|
483
|
+
*
|
|
484
|
+
* PocketBase updates a multi-value file field through SIBLING keys — `docs+`
|
|
485
|
+
* to append, `docs-` to remove named files — so there is no single value to
|
|
486
|
+
* return. Rather than special-casing one backend inside the write path, a
|
|
487
|
+
* mapper may return this marker and `withFileStorage` splices its keys into
|
|
488
|
+
* the payload instead of assigning to the field's own key.
|
|
489
|
+
*/
|
|
490
|
+
declare const COLUMN_PATCH: unique symbol;
|
|
491
|
+
interface ColumnPatch {
|
|
492
|
+
[COLUMN_PATCH]: true;
|
|
493
|
+
keys: Record<string, unknown>;
|
|
494
|
+
}
|
|
495
|
+
declare function columnPatch(keys: Record<string, unknown>): ColumnPatch;
|
|
496
|
+
declare function isColumnPatch(value: unknown): value is ColumnPatch;
|
|
497
|
+
|
|
498
|
+
/** Stamped on a wrapped provider so the wiring assertion can see it. */
|
|
499
|
+
declare const FILE_STORAGE_WRAPPED: unique symbol;
|
|
500
|
+
/**
|
|
501
|
+
* Rewrite outgoing mutations that actually carry bytes.
|
|
502
|
+
*
|
|
503
|
+
* The decision is PER REQUEST, from the payload — never global. Mixing
|
|
504
|
+
* pipelines is the ordinary case (a storage service for most fields, two
|
|
505
|
+
* places that take the file inline), so a payload with no File is handed to
|
|
506
|
+
* the underlying provider untouched, by reference: no FormData, no
|
|
507
|
+
* re-encoding, no surprise.
|
|
508
|
+
*/
|
|
509
|
+
declare function withFileStorage<T>(provider: T, app: AppRuntime): T;
|
|
510
|
+
/**
|
|
511
|
+
* Which encoding each payload key needs, and which body encoding the rest
|
|
512
|
+
* fall back to — for ONE resource.
|
|
513
|
+
*
|
|
514
|
+
* ⚠️ This must be resolved per resource, not once per provider. An earlier
|
|
515
|
+
* version picked a single encoding from the whole registry, so an app with a
|
|
516
|
+
* multipart storage anywhere and a base64 storage anywhere else sent the
|
|
517
|
+
* base64 field's File into FormData as raw binary — never encoded, no boot
|
|
518
|
+
* error, no runtime error, silently wrong data. It also contradicted the
|
|
519
|
+
* type's own promise that base64 "composes with anything", which is exactly
|
|
520
|
+
* why `assertFileStorageWiring` permits that combination.
|
|
521
|
+
*
|
|
522
|
+
* `base64` is a VALUE transform: those fields are converted first and stop
|
|
523
|
+
* being Files. Only what remains decides the request body.
|
|
524
|
+
*/
|
|
525
|
+
interface EncodingPlan {
|
|
526
|
+
/** Encoding EXPLICITLY declared for a field, via `f.file({ fileStorage })`. Authoritative. */
|
|
527
|
+
knownEncodings: Map<string, FusedEncoding>;
|
|
528
|
+
/**
|
|
529
|
+
* Assumed for a payload key with NO declared field to consult — the
|
|
530
|
+
* resource isn't registered, or has no `f.file()` for this key. Mirrors
|
|
531
|
+
* `ui.fileStorage ?? 'default'`, the exact resolution a KNOWN field with no
|
|
532
|
+
* explicit storage name already gets, extended to "no field info at all".
|
|
533
|
+
* This is what keeps a single-storage app working without ever having to
|
|
534
|
+
* declare its fields through `@burojs/field-types`.
|
|
535
|
+
*/
|
|
536
|
+
defaultEncoding: FusedEncoding;
|
|
537
|
+
/** Body encoding for whatever Files remain once base64 keys are converted. */
|
|
538
|
+
bodyEncoding: Exclude<FusedEncoding, 'base64'>;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Three checks that must fail at boot rather than at runtime.
|
|
543
|
+
*
|
|
544
|
+
* 1. A fused storage with an unwrapped provider. Files in `variables` reach
|
|
545
|
+
* `JSON.stringify` and become `{}` — the upload silently does nothing, the
|
|
546
|
+
* request succeeds, and nobody finds out until someone opens the record.
|
|
547
|
+
* 2. A fused storage paired with a mapper that cannot carry a `pending` file
|
|
548
|
+
* forward (`mapper.carriesPending` is not set). A ref on a fused pipeline
|
|
549
|
+
* never has a `handle` before the mutation runs — only `pending` — so a
|
|
550
|
+
* mapper that writes nothing for a handle-less ref (the ordinary case:
|
|
551
|
+
* `richImage`, `junction`) silently drops every file added through that
|
|
552
|
+
* field. This is the same class of failure as #1 — a fused storage whose
|
|
553
|
+
* mapper cannot carry the file forward is exactly as broken as one with no
|
|
554
|
+
* wrapper — so it gets the same treatment: fail loudly before any user
|
|
555
|
+
* touches the form, not discover it when a saved record turns out empty.
|
|
556
|
+
* 3. Two incompatible body encodings on one resource. One request has one
|
|
557
|
+
* body; discovering that during a save is discovering it too late.
|
|
558
|
+
*
|
|
559
|
+
* Called by the host composition layer that owns both the app and its data
|
|
560
|
+
* provider.
|
|
561
|
+
*/
|
|
562
|
+
declare function assertFileStorageWiring(app: AppRuntime, provider: unknown): void;
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Splice column patches into the payload; leave everything else alone.
|
|
566
|
+
*
|
|
567
|
+
* Reference identity is preserved ONLY when the payload carries no column
|
|
568
|
+
* patch at all. The moment even an EMPTY patch (`{ keys: {} }` — "nothing
|
|
569
|
+
* changed for this field") is present, the field's own key must still be
|
|
570
|
+
* dropped: forwarding the raw patch marker as the field's value would
|
|
571
|
+
* overwrite the column with `{ keys: {} }` instead of leaving it alone. So a
|
|
572
|
+
* fresh object is built whenever any patch is present, empty or not — this
|
|
573
|
+
* function does not claim otherwise.
|
|
574
|
+
*/
|
|
575
|
+
declare function splicePatches(variables: Record<string, unknown>): Record<string, unknown>;
|
|
576
|
+
/**
|
|
577
|
+
* Does this payload actually carry bytes? Everything downstream hinges on it.
|
|
578
|
+
*
|
|
579
|
+
* ⚠️ No depth cap. An earlier version stopped at four levels, which meant a
|
|
580
|
+
* File nested deeper was silently declared absent — the payload then took the
|
|
581
|
+
* untouched fast path and the File reached `JSON.stringify` and became `{}`.
|
|
582
|
+
* Today's mappers never nest a File below the first level, so the cap was
|
|
583
|
+
* unreachable; a future rich-content mapper would have made it a silent
|
|
584
|
+
* data-loss bug. Cycles are handled by tracking visited objects rather than by
|
|
585
|
+
* limiting depth.
|
|
586
|
+
*/
|
|
587
|
+
declare function containsFile(value: unknown, seen?: WeakSet<object>): boolean;
|
|
588
|
+
/** Getter-safe recursive check used to reject unsupported bulk column patches. */
|
|
589
|
+
declare function containsColumnPatch(value: unknown, seen?: WeakSet<object>): boolean;
|
|
590
|
+
/**
|
|
591
|
+
* Turn a payload carrying Files into something the provider can send.
|
|
592
|
+
*
|
|
593
|
+
* `base64` is a VALUE transform, so the payload stays a plain object and
|
|
594
|
+
* composes with anything. `multipart` and `graphql-upload` produce a FormData
|
|
595
|
+
* — the provider must send it as the body verbatim and must NOT set a
|
|
596
|
+
* content-type, because the browser writes the boundary.
|
|
597
|
+
*/
|
|
598
|
+
declare function encodePayload(variables: Record<string, unknown>, plan: EncodingPlan): Promise<Record<string, unknown> | FormData>;
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Copy the opaque mapped-file baseline capability from a decoded record to a
|
|
602
|
+
* fresh edit payload. The physical baseline itself remains private to the
|
|
603
|
+
* provider wrapper; this helper can neither inspect nor serialize it.
|
|
604
|
+
*/
|
|
605
|
+
declare function inheritFileMapperBaseline<T extends object>(source: object, target: T): T;
|
|
606
|
+
|
|
607
|
+
declare const PACKAGE_NAME: "@burojs/file-storage";
|
|
608
|
+
|
|
609
|
+
export { COLUMN_PATCH, type ColumnPatch, type ContractOpts, type ContractRunner, FILE_STORAGE_SERVICE, FILE_STORAGE_WRAPPED, type FakeAdapterOpts, type FileMapper, type FileRef, type FileStorageAdapter, type FileStorageRegistry, type FusedEncoding, type InlineFileStorageOpts, type JunctionOpts, type MapperCtx, type NhostFileStorageOpts, type OrphanPolicy, PACKAGE_NAME, type PocketbaseFileStorageOpts, type ResolveCtx, type ResolvedUrl, type RichImageOpts, type S3FileStorageOpts, type SignedTarget, type SupabaseFileStorageOpts, type SupabaseStorageLike, type UploadCtx, type UploadPipeline, assertFileStorageWiring, columnPatch, containsColumnPatch, containsFile, createFakeAdapter, createFileStorageRegistry, encodePayload, fileStoragePlugin, inheritFileMapperBaseline, inlineFileStorage, isColumnPatch, isImage, junction, newRef, nhostFileStorage, pocketbaseDiff, pocketbaseFileStorage, richImage, runFileStorageContract, s3FileStorage, scalarHandle, splicePatches, supabaseFileStorage, withFileStorage };
|