zerobucket 0.1.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/README.md CHANGED
@@ -10,18 +10,20 @@ This is the Node.js/TypeScript sibling of the [Python package](https://pypi.org/
10
10
  **It uses the exact same table**, so a Node service and a Python service can share one database
11
11
  and read each other's images.
12
12
 
13
- > **Status: 0.1.0.** PostgreSQL, classic mode. Dedup, object-storage tiering, SQLite and MySQL
14
- > are on the roadmap (see [What's not here yet](#whats-not-here-yet)).
13
+ > **Status: early (0.4.x).** PostgreSQL **and SQLite**, each in classic and dedup modes, with S3-compatible
14
+ > object-storage tiering. MySQL, a CLI and HEIC are on the roadmap (see [What's not here yet](#whats-not-here-yet)).
15
15
 
16
16
  ## Install
17
17
 
18
18
  ```bash
19
- npm install zerobucket pg sharp
19
+ npm install zerobucket pg sharp # PostgreSQL
20
+ npm install zerobucket better-sqlite3 sharp # or SQLite (see below): no database server at all
20
21
  ```
21
22
 
22
- `pg` (the Postgres driver) and `sharp` (image decoding/optimizing) are peer dependencies, so you
23
+ `pg` (the Postgres driver), `better-sqlite3` (SQLite) and `sharp` (image decoding/optimizing) are peer dependencies, so you
23
24
  control their versions. If you only store PDFs through a custom validator, `sharp` is not needed.
24
- Requires Node 20+. Ships ESM and CommonJS with full types.
25
+ For [object-storage tiering](#object-storage-tiering) also install `@aws-sdk/client-s3` (optional; loaded only when used).
26
+ Requires Node 20+ (SQLite needs Node 22+, see below). Ships ESM and CommonJS with full types.
25
27
 
26
28
  ## Quick start
27
29
 
@@ -151,6 +153,10 @@ try {
151
153
  }
152
154
  ```
153
155
 
156
+ Multi-statement operations (`putMany`, dedup `put`/`delete`, tiering) are **atomic even on a bare `pg.Client`
157
+ with no open transaction**: ZeroBucket opens its own, or uses a `SAVEPOINT` inside yours so a failure rolls back only
158
+ its own work and leaves your transaction usable.
159
+
154
160
  ## Batches
155
161
 
156
162
  `putMany` / `getMany` / `deleteMany` are best-effort per item (one bad file doesn't abort the rest):
@@ -183,6 +189,109 @@ output. Implement `ContentValidator` (`validate(data, { maxBytes })`) to store o
183
189
  operation (and once per chunk when streaming). Exceptions in your callback are swallowed.
184
190
  - The schema migration is serialized with an advisory lock, so many processes starting at once is safe.
185
191
 
192
+ ## SQLite
193
+
194
+ No database server at all: one file. Ideal for small apps, single-server deployments, desktop/Electron apps, tests and prototypes.
195
+
196
+ ```ts
197
+ const images = new ZeroBucket({ sqlite: "./images.db" }); // npm install better-sqlite3
198
+ // ZeroBucket({ sqlite: ":memory:" }) for a throwaway in-process database
199
+ ```
200
+
201
+ Everything in this README works on SQLite, **including dedup (`dedup: true`), object-storage tiering, streaming and
202
+ byte ranges, hooks, the HTTP handler, optimize and validators**. The file format is identical to the Python
203
+ package's `SQLiteBackend`, so a Node app and a Python app can share one `.db` file (verified in both directions, including
204
+ tiering and dedup reference counts, by the conformance harness).
205
+
206
+ How it behaves in Node, and why:
207
+
208
+ - **Synchronous driver, event loop kept free.** `better-sqlite3` is synchronous (a local file read is microseconds). Lock
209
+ contention is the exception, and ZeroBucket never lets the driver sleep on a lock, which would freeze your whole server.
210
+ Each attempt waits ~25 ms; a `SQLITE_BUSY` is retried with async sleeps up to `busyTimeoutMs` (default 10 s, set via
211
+ `new ZeroBucket({ backend: new SQLiteBackend({ path, busyTimeoutMs }) })`), then fails with a clear `StorageError`.
212
+ A test holds the write lock from a second process and asserts the event loop keeps ticking while ZeroBucket waits.
213
+ - **One connection, no interleaving.** Each operation is a single synchronous `BEGIN IMMEDIATE ... COMMIT`, so concurrent
214
+ requests can never run inside each other's transaction. WAL mode is set once, and only if the file is not already WAL.
215
+ - **Many processes, one new file** is safe (tested with 8 processes opening a fresh database simultaneously).
216
+ - **Tiering locks the whole database** for the duration of the upload (SQLite has no row locks): other processes' writes
217
+ wait, this process's writes queue, reads are never blocked. It uses its own connection, so `tierToObjectStorage` does not
218
+ accept `connection`, and it needs a real file (not `:memory:`). The safety guarantee is unchanged: a failed upload
219
+ rolls back and leaves the row untouched.
220
+ - **`connection`** takes your own `better-sqlite3` `Database`. Multi-statement operations use a `SAVEPOINT`, so they
221
+ are atomic whether or not you have an open transaction.
222
+ - **Streaming** uses ranged `substr()` reads, so a delete mid-stream raises `StorageError` (like Postgres). SQLite still
223
+ reads the whole blob per chunk, in native memory: this bounds Node-side memory only.
224
+ - **Node 22+ for SQLite.** `better-sqlite3` 13 requires Node 22 or newer and crashes on older versions. It ships its Windows,
225
+ macOS and Linux binaries inside the npm package, so no compiler or Visual Studio is needed. Postgres users can stay on Node 20.
226
+ (`better-sqlite3` 12 declares Node 20 support, but it is untested here.)
227
+ - Not available on SQLite: `migrateClassicToDedup()` (Python has it for Postgres only, too).
228
+
229
+ ## Deduplication
230
+
231
+ Store identical bytes once. In dedup mode every `put()` still returns its own id and keeps its own filename,
232
+ but images with the same content share a single stored blob with a reference count (think: the same logo or default
233
+ avatar uploaded a thousand times).
234
+
235
+ ```ts
236
+ const images = new ZeroBucket({ connectionString, dedup: true });
237
+ const a = await images.put(logo, { filename: "a.png" });
238
+ const b = await images.put(logo, { filename: "b.png" }); // different id, same blob: ref_count = 2
239
+ await images.delete(a); // blob stays: ref_count = 1
240
+ await images.delete(b); // last reference gone: blob removed
241
+ ```
242
+
243
+ - Uses separate tables (`zerobucket_blobs` + `zerobucket_image_refs`, identical to the Python package), so dedup and
244
+ classic data can never be confused. A Node and a Python dedup app share one database and one set of blobs.
245
+ - Reference counts are exact under concurrency: 20 simultaneous `put()`s of the same bytes give `ref_count == 20`, and a
246
+ `put()` racing the delete of the last reference never loses or leaks the blob (both are tested).
247
+ - Batches (`putMany`, `deleteMany`) take their blob locks in sorted checksum order, so overlapping batches in
248
+ opposite orders cannot deadlock each other.
249
+ - Everything else works unchanged: hooks, streaming, ranges, the HTTP handler, optimize, validators, metrics, retry.
250
+ - Dedup **cannot be combined with `objectStorage`** (a shared blob has many owners), same rule as Python.
251
+
252
+ **Moving existing data in:** `await images.migrateClassicToDedup()` copies every classic image into the dedup tables,
253
+ keeping each id, and never modifies the classic table. It runs in small batches (bounded memory), is safe to re-run
254
+ (already-migrated ids are skipped and counted), and refuses up front if any classic image is tiered to object storage.
255
+
256
+ ## Object-storage tiering
257
+
258
+ For the minority of apps whose image volume outgrows what is economical to keep in a database
259
+ (backups, replication lag, per-GB price), move individual images to any S3-compatible store (AWS S3, MinIO,
260
+ Cloudflare R2, Backblaze B2, DigitalOcean Spaces). It is **explicit and opt-in**: `put()` never tiers
261
+ anything by itself, and nothing changes for you until you configure it.
262
+
263
+ ```ts
264
+ import { ZeroBucket, ObjectStorage } from "zerobucket";
265
+
266
+ const images = new ZeroBucket({
267
+ connectionString: process.env.DATABASE_URL,
268
+ objectStorage: new ObjectStorage({ bucket: "my-images" }), // bucket must already exist
269
+ // endpoint: "https://<account>.r2.cloudflarestorage.com", region: "auto" // for non-AWS stores
270
+ });
271
+
272
+ await images.tierToObjectStorage(id); // true = moved now, false = already tiered (safe to re-run a backfill)
273
+ await images.get(id); // unchanged: get / getMany / getStream / HTTP handler all read transparently
274
+ await images.delete(id); // removes the row, then the object
275
+ ```
276
+
277
+ - **Safe by construction.** The upload runs _inside_ the database transaction that locks the row. If it fails,
278
+ the transaction rolls back and the row is left byte-for-byte untouched: there is never a moment when the bytes
279
+ exist nowhere, or when a row claims to be tiered without a finished upload.
280
+ - **Real range requests.** Tiered images stream with genuine S3 `Range` GETs, so a `bytes=1000-1999` request fetches
281
+ exactly those bytes (strictly better than the Postgres `substring()` approach).
282
+ - **Locking tradeoff.** The row lock is held for the whole upload, so concurrent _writes to that one image_ wait. Other
283
+ rows and plain reads are never blocked. Treat tiering as a maintenance operation, not a request-path call.
284
+ - **Integrity checks.** A tiered object whose size no longer matches the database, or a missing/short object, raises
285
+ `StorageError`. It never returns wrong data and never silently truncates a stream.
286
+ - **Same keys as Python.** Objects are named by the image's UUID with no prefix, so the Node and Python packages
287
+ can read, tier and delete each other's objects in one bucket (verified by the conformance harness).
288
+ - **Deleting inside your own transaction** (`delete(id, { connection })`) deliberately does **not** remove the
289
+ object: we cannot know whether your transaction will commit, and deleting early would turn a rollback into data
290
+ loss. After you commit, remove it yourself with `objectStorage.delete(id)`.
291
+ - Failures to remove an object after a normal delete never fail the delete (the row is already gone); they are
292
+ reported through `onOperation` as `object_storage_delete` events.
293
+ - Credentials come from the AWS SDK's standard chain (env vars, shared config, IAM role) or `credentials: {...}`.
294
+
186
295
  ## Shared database with the Python package
187
296
 
188
297
  Same table (`zerobucket_images`), same checksum (SHA-256 hex of the stored bytes), same tiering
@@ -192,27 +301,32 @@ same corpus of valid and corrupt files identically.
192
301
 
193
302
  ## Differences from the Python package
194
303
 
195
- | | Python | npm |
196
- | ---------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------- |
197
- | API | sync + async clients | one async client |
198
- | Malformed id (`"abc"`) | `StorageError` from Postgres | treated as not found (`ImageNotFoundError`, no DB round trip), friendlier for `/images/:id` routes |
199
- | `optimize` | Pillow; EXIF stripped, not applied | sharp; EXIF **applied then stripped**; transparency → **white** for JPEG |
200
- | `putMany` inserts | pipelined | one transaction, statements run sequentially |
201
- | Formats | JPEG, PNG, WebP, HEIC (extra) | JPEG, PNG, WebP (HEIC not yet) |
202
- | Event duration | `duration_seconds` | `durationMs` |
203
- | Options | snake_case kwargs | camelCase options object |
304
+ | | Python | npm |
305
+ | ---------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
306
+ | API | sync + async clients | one async client |
307
+ | Malformed id (`"abc"`) | `StorageError` from Postgres | treated as not found (`ImageNotFoundError`, no DB round trip), friendlier for `/images/:id` routes |
308
+ | `optimize` | Pillow; EXIF stripped, not applied | sharp; EXIF **applied then stripped**; transparency → **white** for JPEG |
309
+ | `putMany` inserts | pipelined | one transaction, statements run sequentially |
310
+ | Formats | JPEG, PNG, WebP, HEIC (extra) | JPEG, PNG, WebP (HEIC not yet) |
311
+ | Event duration | `duration_seconds` | `durationMs` |
312
+ | `migrateClassicToDedup` | loads the whole table into memory; fails if re-run or if any row is tiered | batched, safe to re-run, refuses tiered rows with a clear message |
313
+ | Dedup `putMany` / `deleteMany` locking | arbitrary order (overlapping batches can deadlock) | sorted checksum order (cannot deadlock) |
314
+ | SQLite API | synchronous `SQLiteBackend` + separate async backend | one async client; the sync driver runs on the event loop, lock waits are async (never block it) |
315
+ | SQLite `onOperation` / busy retry | none | `onOperation` events and async `SQLITE_BUSY` retry (`busyTimeoutMs`) |
316
+ | SQLite stream deleted mid-read | sync adapter keeps reading a WAL snapshot (async adapter raises) | raises `StorageError`, like Postgres; use `connection` in a transaction for a snapshot |
317
+ | `connection=` without an open transaction | multi-statement ops rely on the driver's implicit transaction | always atomic (own transaction or savepoint) |
318
+ | `delete(id, connection=...)` on a tiered image | deletes the S3 object immediately | leaves the object until you delete it after commit (a rolled-back transaction can never lose data) |
319
+ | S3 delete failure after `delete()` | raises after the row is gone | never fails the delete; reported via `onOperation` |
320
+ | Options | snake_case kwargs | camelCase options object |
204
321
 
205
322
  ## What's not here yet
206
323
 
207
- Planned, in order: object-storage **tiering** (S3-compatible), **dedup** mode, **SQLite**, **MySQL/MariaDB**.
208
- Until tiering lands: reading a row that the Python package tiered to S3 throws a clear `StorageError`
209
- (never wrong data), and **deleting** such a row from Node removes the database row but leaves the S3
210
- object behind (the same documented orphan behaviour as Python without `object_storage`).
211
- Delete tiered images from Python if you use tiering.
324
+ Planned, in order: **MySQL/MariaDB**, the `zerobucket` **CLI**, and **HEIC**.
325
+ Tiering and dedup cannot be combined (same rule as the Python package).
212
326
 
213
327
  ## Runtime notes
214
328
 
215
- Node 20+. Bun/Deno: expected to work, not yet tested. Edge runtimes (Cloudflare Workers, Vercel Edge) cannot open raw
329
+ Node 20+ for Postgres, Node 22+ for SQLite. Bun/Deno: expected to work, not yet tested. Edge runtimes (Cloudflare Workers, Vercel Edge) cannot open raw
216
330
  Postgres TCP connections through `pg` without a proxy such as Hyperdrive; use the Node runtime for these routes.
217
331
  In serverless, keep `poolMaxSize` small (1-2) or inject a pool shared across invocations.
218
332
 
@@ -63,6 +63,22 @@ interface Queryable {
63
63
  rowCount: number | null;
64
64
  }>;
65
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;
66
82
  /** The row shape a storage backend persists (after validation and checksumming). */
67
83
  interface PreparedRow {
68
84
  data: Buffer;
@@ -99,7 +115,7 @@ interface StoredRecord {
99
115
  }
100
116
  type StoredRecordMetadata = Omit<StoredRecord, "data">;
101
117
  interface CallOptions {
102
- connection?: Queryable;
118
+ connection?: Connection;
103
119
  }
104
120
  interface StreamOptions$1 extends CallOptions {
105
121
  chunkSize: number;
@@ -128,9 +144,65 @@ interface StorageBackend {
128
144
  /** Returns the ids that were actually deleted. */
129
145
  deleteMany(ids: string[], opts?: CallOptions): Promise<string[]>;
130
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
+ }>;
131
156
  close(): Promise<void>;
132
157
  }
133
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
+
134
206
  interface OptimizeOptions {
135
207
  /** Downscale (aspect ratio preserved, Lanczos) if wider than this. Never upscales. */
136
208
  maxWidth?: number;
@@ -188,7 +260,13 @@ interface ZeroBucketOptions<C = unknown> {
188
260
  connectionString?: string;
189
261
  /** Bring your own pg.Pool. It is NOT closed by close(). */
190
262
  pool?: Pool;
191
- /** Advanced: inject a custom StorageBackend instead of building a PostgresBackend. */
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`. */
192
270
  backend?: StorageBackend;
193
271
  /** Max accepted size in bytes. Default 8 MiB. */
194
272
  maxBytes?: number;
@@ -204,6 +282,19 @@ interface ZeroBucketOptions<C = unknown> {
204
282
  poolTimeoutMs?: number;
205
283
  /** Fire-and-forget observability. Exceptions thrown inside it are swallowed. */
206
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;
207
298
  /**
208
299
  * Authorization hook for get / getMany (per id) / getStream / streamTo / metadata.
209
300
  * Return false to deny. A hook that THROWS fails closed: the exception
@@ -222,7 +313,7 @@ interface PutBase<C> {
222
313
  /** Store non-image content through the same machinery. Incompatible with `optimize`. */
223
314
  validator?: ContentValidator;
224
315
  /** Join your own transaction. ZeroBucket then does not commit, roll back or retry. */
225
- connection?: Queryable;
316
+ connection?: Connection;
226
317
  /** Passed to beforePut. */
227
318
  context?: C;
228
319
  }
@@ -232,7 +323,7 @@ interface PutManyOptions<C = unknown> extends Omit<PutBase<C>, "filename"> {
232
323
  filenames?: (string | null | undefined)[];
233
324
  }
234
325
  interface GetOptions<C = unknown> {
235
- connection?: Queryable;
326
+ connection?: Connection;
236
327
  context?: C;
237
328
  }
238
329
  interface StreamOptions<C = unknown> extends GetOptions<C> {
@@ -302,6 +393,21 @@ declare class ZeroBucket<C = unknown> {
302
393
  delete(imageId: string, options?: Pick<CallOptions, "connection">): Promise<boolean>;
303
394
  /** Delete many in one query. Results are in input order; a missing id is `deleted: false`, not an error. */
304
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"]>>;
305
411
  /** Release the pool (if ZeroBucket created it). */
306
412
  close(): Promise<void>;
307
413
  }
@@ -316,4 +422,4 @@ declare function readInput(image: ImageInput, filename: string | undefined, maxB
316
422
  filename: string | undefined;
317
423
  }>;
318
424
 
319
- 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 StoredRecord as a, type StoredRecordMetadata as b, type StreamOptions$1 as c, type ContentValidator as d, type BatchGetResult as e, type BatchPutResult as f, type BeforeGetHook as g, type BeforePutHook as h, type ImageInput as i, type ImageMetadata as j, type OptimizationResult as k, type OptimizeOptions as l, type PutManyOptions as m, type PutOptions as n, type StreamOptions as o, type ZeroBucketOptions as p, optimizeImage as q, readInput as r };
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 };
@@ -63,6 +63,22 @@ interface Queryable {
63
63
  rowCount: number | null;
64
64
  }>;
65
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;
66
82
  /** The row shape a storage backend persists (after validation and checksumming). */
67
83
  interface PreparedRow {
68
84
  data: Buffer;
@@ -99,7 +115,7 @@ interface StoredRecord {
99
115
  }
100
116
  type StoredRecordMetadata = Omit<StoredRecord, "data">;
101
117
  interface CallOptions {
102
- connection?: Queryable;
118
+ connection?: Connection;
103
119
  }
104
120
  interface StreamOptions$1 extends CallOptions {
105
121
  chunkSize: number;
@@ -128,9 +144,65 @@ interface StorageBackend {
128
144
  /** Returns the ids that were actually deleted. */
129
145
  deleteMany(ids: string[], opts?: CallOptions): Promise<string[]>;
130
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
+ }>;
131
156
  close(): Promise<void>;
132
157
  }
133
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
+
134
206
  interface OptimizeOptions {
135
207
  /** Downscale (aspect ratio preserved, Lanczos) if wider than this. Never upscales. */
136
208
  maxWidth?: number;
@@ -188,7 +260,13 @@ interface ZeroBucketOptions<C = unknown> {
188
260
  connectionString?: string;
189
261
  /** Bring your own pg.Pool. It is NOT closed by close(). */
190
262
  pool?: Pool;
191
- /** Advanced: inject a custom StorageBackend instead of building a PostgresBackend. */
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`. */
192
270
  backend?: StorageBackend;
193
271
  /** Max accepted size in bytes. Default 8 MiB. */
194
272
  maxBytes?: number;
@@ -204,6 +282,19 @@ interface ZeroBucketOptions<C = unknown> {
204
282
  poolTimeoutMs?: number;
205
283
  /** Fire-and-forget observability. Exceptions thrown inside it are swallowed. */
206
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;
207
298
  /**
208
299
  * Authorization hook for get / getMany (per id) / getStream / streamTo / metadata.
209
300
  * Return false to deny. A hook that THROWS fails closed: the exception
@@ -222,7 +313,7 @@ interface PutBase<C> {
222
313
  /** Store non-image content through the same machinery. Incompatible with `optimize`. */
223
314
  validator?: ContentValidator;
224
315
  /** Join your own transaction. ZeroBucket then does not commit, roll back or retry. */
225
- connection?: Queryable;
316
+ connection?: Connection;
226
317
  /** Passed to beforePut. */
227
318
  context?: C;
228
319
  }
@@ -232,7 +323,7 @@ interface PutManyOptions<C = unknown> extends Omit<PutBase<C>, "filename"> {
232
323
  filenames?: (string | null | undefined)[];
233
324
  }
234
325
  interface GetOptions<C = unknown> {
235
- connection?: Queryable;
326
+ connection?: Connection;
236
327
  context?: C;
237
328
  }
238
329
  interface StreamOptions<C = unknown> extends GetOptions<C> {
@@ -302,6 +393,21 @@ declare class ZeroBucket<C = unknown> {
302
393
  delete(imageId: string, options?: Pick<CallOptions, "connection">): Promise<boolean>;
303
394
  /** Delete many in one query. Results are in input order; a missing id is `deleted: false`, not an error. */
304
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"]>>;
305
411
  /** Release the pool (if ZeroBucket created it). */
306
412
  close(): Promise<void>;
307
413
  }
@@ -316,4 +422,4 @@ declare function readInput(image: ImageInput, filename: string | undefined, maxB
316
422
  filename: string | undefined;
317
423
  }>;
318
424
 
319
- 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 StoredRecord as a, type StoredRecordMetadata as b, type StreamOptions$1 as c, type ContentValidator as d, type BatchGetResult as e, type BatchPutResult as f, type BeforeGetHook as g, type BeforePutHook as h, type ImageInput as i, type ImageMetadata as j, type OptimizationResult as k, type OptimizeOptions as l, type PutManyOptions as m, type PutOptions as n, type StreamOptions as o, type ZeroBucketOptions as p, optimizeImage as q, readInput as r };
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 };
package/dist/http.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { IncomingMessage, ServerResponse } from 'node:http';
2
- import { Z as ZeroBucket } from './client-C_W0t8My.cjs';
2
+ import { Z as ZeroBucket } from './client-WZ4VRCPi.cjs';
3
3
  import 'pg';
4
4
 
5
5
  /**
package/dist/http.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { IncomingMessage, ServerResponse } from 'node:http';
2
- import { Z as ZeroBucket } from './client-C_W0t8My.js';
2
+ import { Z as ZeroBucket } from './client-WZ4VRCPi.js';
3
3
  import 'pg';
4
4
 
5
5
  /**