@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.
@@ -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 };