zerobucket 0.0.0-stage → 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.
@@ -0,0 +1,425 @@
1
+ import { Pool } from 'pg';
2
+
3
+ /** Public data types returned by the ZeroBucket SDK. */
4
+ /** A retrieved image, ready to be served or written to disk. `data` is raw bytes, never Base64. */
5
+ interface Image {
6
+ data: Buffer;
7
+ mimeType: string;
8
+ filename: string | null;
9
+ sizeBytes: number;
10
+ width: number | null;
11
+ height: number | null;
12
+ checksumSha256: string;
13
+ }
14
+ /** Metadata about a stored image, without the pixel data. */
15
+ interface ImageMetadata {
16
+ imageId: string;
17
+ mimeType: string;
18
+ filename: string | null;
19
+ sizeBytes: number;
20
+ width: number | null;
21
+ height: number | null;
22
+ checksumSha256: string;
23
+ }
24
+ /** Outcome of one item in a `putMany()` call. Batches are best-effort, not all-or-nothing. */
25
+ interface BatchPutResult {
26
+ /** Position in the input array. */
27
+ index: number;
28
+ imageId: string | null;
29
+ error: string | null;
30
+ success: boolean;
31
+ }
32
+ /** Outcome of one id in a `getMany()` call. A missing id is not an exception here. */
33
+ interface BatchGetResult {
34
+ imageId: string;
35
+ image: Image | null;
36
+ error: string | null;
37
+ success: boolean;
38
+ }
39
+ /** Outcome of one id in a `deleteMany()` call. `deleted: false` with no error means "did not exist". */
40
+ interface BatchDeleteResult {
41
+ imageId: string;
42
+ deleted: boolean;
43
+ error: string | null;
44
+ success: boolean;
45
+ }
46
+ /** Emitted to `onOperation` after every storage operation (success or failure). */
47
+ interface OperationEvent {
48
+ operation: string;
49
+ durationMs: number;
50
+ success: boolean;
51
+ error: string | null;
52
+ retryCount: number;
53
+ }
54
+ /**
55
+ * Anything with a pg-style `query()`: a `pg.Client`, a `pg.PoolClient`, or a
56
+ * transaction handle from a wrapper. Pass one as `connection` to make a
57
+ * ZeroBucket call participate in YOUR transaction. ZeroBucket then neither
58
+ * commits, rolls back, nor retries.
59
+ */
60
+ interface Queryable {
61
+ query(text: string, values?: unknown[]): Promise<{
62
+ rows: any[];
63
+ rowCount: number | null;
64
+ }>;
65
+ }
66
+ /**
67
+ * The slice of a `better-sqlite3` Database that ZeroBucket's SQLite backend uses. Pass your own
68
+ * Database handle as `connection` to make a call part of YOUR SQLite transaction.
69
+ */
70
+ interface SqliteDatabaseLike {
71
+ prepare(sql: string): {
72
+ all(...params: unknown[]): any[];
73
+ get(...params: unknown[]): any;
74
+ run(...params: unknown[]): {
75
+ changes: number;
76
+ };
77
+ };
78
+ exec(sql: string): unknown;
79
+ }
80
+ /** What `connection` accepts: a `pg` client for the Postgres backend, a `better-sqlite3` Database for SQLite. */
81
+ type Connection = Queryable | SqliteDatabaseLike;
82
+ /** The row shape a storage backend persists (after validation and checksumming). */
83
+ interface PreparedRow {
84
+ data: Buffer;
85
+ mimeType: string;
86
+ originalFilename: string | null;
87
+ sizeBytes: number;
88
+ width: number | null;
89
+ height: number | null;
90
+ checksumSha256: string;
91
+ }
92
+
93
+ /**
94
+ * Storage backend interface.
95
+ *
96
+ * CRITICAL DESIGN RULE (same as the Python package): backends know nothing about
97
+ * images. They store and retrieve rows of bytes + metadata columns. All
98
+ * image-specific logic (validation, optimization, checksums) lives in client.ts.
99
+ *
100
+ * Every method accepts an optional `connection`. When omitted, the backend uses
101
+ * its own pool: each call commits independently and transient failures are
102
+ * retried. When provided, the backend uses it directly and does NOT commit,
103
+ * roll back or retry. That is what lets a call join the caller's transaction.
104
+ */
105
+
106
+ interface StoredRecord {
107
+ id: string;
108
+ data: Buffer;
109
+ mimeType: string;
110
+ originalFilename: string | null;
111
+ sizeBytes: number;
112
+ width: number | null;
113
+ height: number | null;
114
+ checksumSha256: string;
115
+ }
116
+ type StoredRecordMetadata = Omit<StoredRecord, "data">;
117
+ interface CallOptions {
118
+ connection?: Connection;
119
+ }
120
+ interface StreamOptions$1 extends CallOptions {
121
+ chunkSize: number;
122
+ /** Inclusive byte range [start, end]; `end` is clamped to the object size. Omit for the whole object. */
123
+ range?: {
124
+ start: number;
125
+ end: number;
126
+ };
127
+ }
128
+ interface StorageBackend {
129
+ /** Resolve once the backend is connected and migrated. Safe to call repeatedly. */
130
+ ready(): Promise<void>;
131
+ put(row: PreparedRow, opts?: CallOptions): Promise<string>;
132
+ putMany(rows: PreparedRow[], opts?: CallOptions): Promise<string[]>;
133
+ get(id: string, opts?: CallOptions): Promise<StoredRecord | null>;
134
+ /** Missing ids are simply absent. Result order is NOT guaranteed to match input. */
135
+ getMany(ids: string[], opts?: CallOptions): Promise<StoredRecord[]>;
136
+ getMetadata(id: string, opts?: CallOptions): Promise<StoredRecordMetadata | null>;
137
+ /**
138
+ * Resolves to null if the id does not exist (checked eagerly, before any
139
+ * chunk is read). A row deleted mid-stream makes iteration throw
140
+ * StorageError rather than silently truncating.
141
+ */
142
+ getStream(id: string, opts: StreamOptions$1): Promise<AsyncIterable<Buffer> | null>;
143
+ delete(id: string, opts?: CallOptions): Promise<boolean>;
144
+ /** Returns the ids that were actually deleted. */
145
+ deleteMany(ids: string[], opts?: CallOptions): Promise<string[]>;
146
+ exists(id: string, opts?: CallOptions): Promise<boolean>;
147
+ /** null = no such image, false = already tiered (no-op), true = tiered now. Needs object storage configured. */
148
+ tierToObjectStorage(id: string, opts?: CallOptions): Promise<boolean | null>;
149
+ /** Dedup-mode instances only: copy the classic table into the dedup tables (non-destructive). */
150
+ migrateClassicToDedup?(): Promise<{
151
+ imagesMigrated: number;
152
+ distinctBlobsCreated: number;
153
+ duplicateReferencesFound: number;
154
+ alreadyMigrated: number;
155
+ }>;
156
+ close(): Promise<void>;
157
+ }
158
+
159
+ /** The surface the Postgres adapter needs. Implement this to plug in a non-S3 store (or a test double). */
160
+ interface ObjectStorageLike {
161
+ readonly bucket: string;
162
+ upload(key: string, data: Buffer, opts: {
163
+ mimeType: string;
164
+ }): Promise<void>;
165
+ download(key: string): Promise<Buffer>;
166
+ /** Inclusive byte range, fetched with a real HTTP Range request. */
167
+ downloadRange(key: string, start: number, endInclusive: number): Promise<Buffer>;
168
+ /** Idempotent: deleting a missing key is not an error. */
169
+ delete(key: string): Promise<void>;
170
+ exists(key: string): Promise<boolean>;
171
+ }
172
+ interface ObjectStorageOptions {
173
+ /** Must already exist. */
174
+ bucket: string;
175
+ /** Set for anything that is not AWS S3 itself (MinIO, R2, B2, Spaces, ...). */
176
+ endpoint?: string;
177
+ /** Default: AWS_REGION / AWS_DEFAULT_REGION, else "us-east-1". Many S3-compatible services ignore it but the SDK requires one. */
178
+ region?: string;
179
+ /** Default: the SDK's standard chain (env vars, shared config, IAM role, ...). */
180
+ credentials?: {
181
+ accessKeyId: string;
182
+ secretAccessKey: string;
183
+ sessionToken?: string;
184
+ };
185
+ /** Path-style addressing (`endpoint/bucket/key`). Default: true when `endpoint` is set, else false. */
186
+ forcePathStyle?: boolean;
187
+ /** Bring your own configured S3Client (advanced). */
188
+ client?: unknown;
189
+ }
190
+ declare class ObjectStorage implements ObjectStorageLike {
191
+ private readonly opts;
192
+ readonly bucket: string;
193
+ private clientPromise;
194
+ constructor(opts: ObjectStorageOptions);
195
+ private connection;
196
+ upload(key: string, data: Buffer, { mimeType }: {
197
+ mimeType: string;
198
+ }): Promise<void>;
199
+ download(key: string): Promise<Buffer>;
200
+ downloadRange(key: string, start: number, endInclusive: number): Promise<Buffer>;
201
+ private get;
202
+ delete(key: string): Promise<void>;
203
+ exists(key: string): Promise<boolean>;
204
+ }
205
+
206
+ interface OptimizeOptions {
207
+ /** Downscale (aspect ratio preserved, Lanczos) if wider than this. Never upscales. */
208
+ maxWidth?: number;
209
+ /** "jpeg" | "png" | "webp". Defaults to the source format. */
210
+ format?: string;
211
+ /** 1-100, JPEG/WebP only (ignored for PNG). */
212
+ quality?: number;
213
+ }
214
+ interface OptimizationResult {
215
+ data: Buffer;
216
+ mimeType: string;
217
+ width: number;
218
+ height: number;
219
+ sizeBytes: number;
220
+ originalSizeBytes: number;
221
+ }
222
+ declare function optimizeImage(data: Buffer, opts: OptimizeOptions & {
223
+ maxBytes: number;
224
+ maxPixels?: number;
225
+ }): Promise<OptimizationResult>;
226
+
227
+ /** Pluggable content validation (the npm equivalent of Python's ContentValidator). */
228
+ interface ValidatedContent {
229
+ mimeType: string;
230
+ sizeBytes: number;
231
+ width?: number | null;
232
+ height?: number | null;
233
+ }
234
+ /**
235
+ * Implement this to let `put()` / `putMany()` accept a content type ZeroBucket
236
+ * does not natively understand. It must throw (ideally a
237
+ * `ContentValidationError`) when the content is invalid, and must trust nothing
238
+ * but the bytes themselves.
239
+ */
240
+ interface ContentValidator {
241
+ validate(data: Buffer, opts: {
242
+ maxBytes: number;
243
+ }): ValidatedContent | Promise<ValidatedContent>;
244
+ }
245
+
246
+ /** 8 MiB: a practical ceiling for "small app" images. Override with `maxBytes`. */
247
+ declare const DEFAULT_MAX_BYTES: number;
248
+ /**
249
+ * What put() accepts:
250
+ * - Buffer / Uint8Array / ArrayBuffer: the bytes themselves
251
+ * - string: a filesystem PATH (never raw content)
252
+ * - Blob / File (e.g. from `request.formData()`)
253
+ * - Node Readable or web ReadableStream (read in bounded chunks)
254
+ */
255
+ type ImageInput = Buffer | Uint8Array | ArrayBuffer | string | Blob | AsyncIterable<Uint8Array | string>;
256
+ type BeforeGetHook<C = unknown> = (imageId: string, context: C | undefined) => boolean | Promise<boolean>;
257
+ type BeforePutHook<C = unknown> = (context: C | undefined) => boolean | Promise<boolean>;
258
+ interface ZeroBucketOptions<C = unknown> {
259
+ /** PostgreSQL connection string. */
260
+ connectionString?: string;
261
+ /** Bring your own pg.Pool. It is NOT closed by close(). */
262
+ pool?: Pool;
263
+ /**
264
+ * Use SQLite instead of PostgreSQL: a database file path (or ":memory:"). Needs `npm install better-sqlite3`.
265
+ * `dedup`, `objectStorage`, `autoMigrate` and `onOperation` apply to it too. Same file format as the Python
266
+ * package's SQLiteBackend, so both can share one `.db` file.
267
+ */
268
+ sqlite?: string;
269
+ /** Advanced: inject a custom StorageBackend instead of building one from `connectionString` / `pool` / `sqlite`. */
270
+ backend?: StorageBackend;
271
+ /** Max accepted size in bytes. Default 8 MiB. */
272
+ maxBytes?: number;
273
+ /** Decoded-pixel ceiling that rejects decompression bombs. Default ~89 MP. */
274
+ maxPixels?: number;
275
+ /** Accepted image formats. Default ["jpeg", "png", "webp"]. */
276
+ allowedFormats?: readonly string[];
277
+ autoMigrate?: boolean;
278
+ maxRetries?: number;
279
+ retryBaseDelayMs?: number;
280
+ poolMinSize?: number;
281
+ poolMaxSize?: number;
282
+ poolTimeoutMs?: number;
283
+ /** Fire-and-forget observability. Exceptions thrown inside it are swallowed. */
284
+ onOperation?: (event: OperationEvent) => void;
285
+ /**
286
+ * S3-compatible object storage, enabling `tierToObjectStorage()` and transparent
287
+ * reads of images the Python or Node package tiered. Configure it with the same
288
+ * bucket and credentials used to tier. Without it, reading a tiered image throws
289
+ * a clear StorageError.
290
+ */
291
+ objectStorage?: ObjectStorageLike;
292
+ /**
293
+ * Content-addressed mode: identical bytes are stored once and shared by reference, each image
294
+ * keeping its own id and filename. Cannot be combined with `objectStorage`. Uses separate tables
295
+ * (zerobucket_blobs / zerobucket_image_refs); see `migrateClassicToDedup()` for existing data.
296
+ */
297
+ dedup?: boolean;
298
+ /**
299
+ * Authorization hook for get / getMany (per id) / getStream / streamTo / metadata.
300
+ * Return false to deny. A hook that THROWS fails closed: the exception
301
+ * propagates (or is captured per item in getMany) and is never treated as allow.
302
+ * NOT called for exists().
303
+ */
304
+ beforeGet?: BeforeGetHook<C>;
305
+ /** Authorization hook for put / putMany (evaluated once per call). Same fail-closed rule. */
306
+ beforePut?: BeforePutHook<C>;
307
+ }
308
+ interface PutBase<C> {
309
+ /** Display filename stored in metadata (never used for validation). */
310
+ filename?: string;
311
+ /** `true` = strip metadata only; or pass { maxWidth, format, quality } to resize/re-encode. */
312
+ optimize?: boolean | OptimizeOptions;
313
+ /** Store non-image content through the same machinery. Incompatible with `optimize`. */
314
+ validator?: ContentValidator;
315
+ /** Join your own transaction. ZeroBucket then does not commit, roll back or retry. */
316
+ connection?: Connection;
317
+ /** Passed to beforePut. */
318
+ context?: C;
319
+ }
320
+ type PutOptions<C = unknown> = PutBase<C>;
321
+ interface PutManyOptions<C = unknown> extends Omit<PutBase<C>, "filename"> {
322
+ /** Same length as `images`; use null/undefined entries to fall back to each input's own name. */
323
+ filenames?: (string | null | undefined)[];
324
+ }
325
+ interface GetOptions<C = unknown> {
326
+ connection?: Connection;
327
+ context?: C;
328
+ }
329
+ interface StreamOptions<C = unknown> extends GetOptions<C> {
330
+ /** Bytes per chunk. Default 1 MiB. */
331
+ chunkSize?: number;
332
+ /** Inclusive byte range, e.g. { start: 0, end: 1023 }. `end` is clamped to the object size. */
333
+ range?: {
334
+ start: number;
335
+ end?: number;
336
+ };
337
+ }
338
+ /** Anything with write(); e.g. fs.WriteStream, http.ServerResponse, or a custom sink. Backpressure is honoured. */
339
+ interface WritableLike {
340
+ write(chunk: Buffer): boolean | void | Promise<void>;
341
+ once?(event: "drain", listener: () => void): unknown;
342
+ }
343
+ declare class ZeroBucket<C = unknown> {
344
+ private readonly backend;
345
+ private readonly maxBytes;
346
+ private readonly maxPixels;
347
+ private readonly allowedFormats;
348
+ private readonly beforeGet;
349
+ private readonly beforePut;
350
+ constructor(options?: ZeroBucketOptions<C>);
351
+ /** Construct AND connect/migrate immediately, so a bad DATABASE_URL fails at startup, not on first request. */
352
+ static connect<C = unknown>(options?: ZeroBucketOptions<C>): Promise<ZeroBucket<C>>;
353
+ /** Resolves once connected and migrated. Called automatically on first use. */
354
+ ready(): Promise<void>;
355
+ private checkBeforeGet;
356
+ private checkBeforePut;
357
+ private prepareRow;
358
+ /** Validate, optionally optimize, checksum and store one image. Returns its id (a UUID). */
359
+ put(image: ImageInput, options?: PutOptions<C>): Promise<string>;
360
+ /**
361
+ * Store many images. Best-effort, not all-or-nothing: one bad image does not
362
+ * abort the rest. Check each result's `success` / `error`. Settings apply to
363
+ * every item. Valid items are inserted in ONE transaction.
364
+ */
365
+ putMany(images: ImageInput[], options?: PutManyOptions<C>): Promise<BatchPutResult[]>;
366
+ /** Retrieve a full image including bytes. Throws ImageNotFoundError if missing. */
367
+ get(imageId: string, options?: GetOptions<C>): Promise<Image>;
368
+ /**
369
+ * Retrieve many images in one query. Results come back in input order; a
370
+ * missing id is `error: "not found"`, not an exception. A hook denial or hook
371
+ * exception affects only that id.
372
+ */
373
+ getMany(imageIds: string[], options?: GetOptions<C>): Promise<BatchGetResult[]>;
374
+ /**
375
+ * Stream an image's bytes in chunks without holding the whole image in memory.
376
+ * Not-found is checked eagerly: the returned promise rejects before any chunk
377
+ * is read. A concurrent delete mid-stream makes iteration throw StorageError
378
+ * rather than silently truncating. Pass `range` for partial reads.
379
+ *
380
+ * Honest limitation: reduces Node-side memory, not Postgres-side (the server
381
+ * still handles the full BYTEA value per chunk request).
382
+ */
383
+ getStream(imageId: string, options?: StreamOptions<C>): Promise<AsyncIterable<Buffer>>;
384
+ /** Same as getStream(), as a Web `ReadableStream` you can hand straight to `new Response(...)`. */
385
+ toWebStream(imageId: string, options?: StreamOptions<C>): Promise<ReadableStream<Uint8Array>>;
386
+ /** Write an image to `destination` chunk by chunk (honouring backpressure). Returns bytes written. Does not end the destination. */
387
+ streamTo(imageId: string, destination: WritableLike, options?: StreamOptions<C>): Promise<number>;
388
+ /** Metadata without pulling the bytes. Gated by the same beforeGet hook as get(). */
389
+ metadata(imageId: string, options?: GetOptions<C>): Promise<ImageMetadata>;
390
+ /** Whether an image exists. Deliberately NOT gated by beforeGet (returns no data or metadata). */
391
+ exists(imageId: string, options?: Pick<CallOptions, "connection">): Promise<boolean>;
392
+ /** Delete an image. Returns true if it existed. */
393
+ delete(imageId: string, options?: Pick<CallOptions, "connection">): Promise<boolean>;
394
+ /** Delete many in one query. Results are in input order; a missing id is `deleted: false`, not an error. */
395
+ deleteMany(imageIds: string[], options?: Pick<CallOptions, "connection">): Promise<BatchDeleteResult[]>;
396
+ /**
397
+ * Move an image's bytes from Postgres into object storage; reads, streams and
398
+ * deletes keep working transparently. Returns true if tiered now, false if it was
399
+ * already tiered (safe to re-run in a backfill). Throws ImageNotFoundError if missing.
400
+ *
401
+ * The upload runs inside the database transaction: if it fails, the row is left
402
+ * completely untouched. Explicit and opt-in: put() never tiers automatically.
403
+ * Not gated by beforeGet/beforePut (it is a maintenance operation, as in the Python package).
404
+ */
405
+ tierToObjectStorage(imageId: string, options?: Pick<CallOptions, "connection">): Promise<boolean>;
406
+ /**
407
+ * One-time, non-destructive copy of classic-mode images into a dedup instance's tables, keeping every
408
+ * id. Call it on an instance created with `dedup: true`. Safe to re-run. See PostgresBackend for details.
409
+ */
410
+ migrateClassicToDedup(): ReturnType<NonNullable<StorageBackend["migrateClassicToDedup"]>>;
411
+ /** Release the pool (if ZeroBucket created it). */
412
+ close(): Promise<void>;
413
+ }
414
+ /**
415
+ * Read any supported input into a Buffer. Streams are consumed in bounded
416
+ * chunks and rejected as soon as they pass maxBytes (reported size is then a
417
+ * lower bound), rather than buffering an arbitrarily large upload first.
418
+ * Files and Blobs are size-checked BEFORE their bytes are read.
419
+ */
420
+ declare function readInput(image: ImageInput, filename: string | undefined, maxBytes: number): Promise<{
421
+ data: Buffer;
422
+ filename: string | undefined;
423
+ }>;
424
+
425
+ export { type BatchDeleteResult as B, type CallOptions as C, DEFAULT_MAX_BYTES as D, type GetOptions as G, type Image as I, type OperationEvent as O, type PreparedRow as P, type Queryable as Q, type StorageBackend as S, type ValidatedContent as V, type WritableLike as W, ZeroBucket as Z, type ObjectStorageLike as a, type StoredRecord as b, type StoredRecordMetadata as c, type StreamOptions$1 as d, type ContentValidator as e, type BatchGetResult as f, type BatchPutResult as g, type BeforeGetHook as h, type BeforePutHook as i, type Connection as j, type ImageInput as k, type ImageMetadata as l, ObjectStorage as m, type ObjectStorageOptions as n, type OptimizationResult as o, type OptimizeOptions as p, type PutManyOptions as q, type PutOptions as r, type SqliteDatabaseLike as s, type StreamOptions as t, type ZeroBucketOptions as u, optimizeImage as v, readInput as w };