zerobucket 0.0.0-stage → 0.1.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 ADDED
File without changes
package/README.md CHANGED
@@ -1,3 +1,228 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # zerobucket (npm)
2
+
3
+ **Database-native image storage for Node.js. Your database. Your images. Zero buckets.**
4
+
5
+ Store small-to-medium image collections (avatars, product photos, uploads) as ordinary
6
+ Postgres rows, with validation, optimization, streaming, range requests and access
7
+ control built in, and no S3 account to provision for the common case.
8
+
9
+ This is the Node.js/TypeScript sibling of the [Python package](https://pypi.org/project/zerobucket/).
10
+ **It uses the exact same table**, so a Node service and a Python service can share one database
11
+ and read each other's images.
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)).
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install zerobucket pg sharp
20
+ ```
21
+
22
+ `pg` (the Postgres driver) and `sharp` (image decoding/optimizing) are peer dependencies, so you
23
+ 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
+
26
+ ## Quick start
27
+
28
+ ```ts
29
+ import { ZeroBucket } from "zerobucket";
30
+
31
+ const images = await ZeroBucket.connect({
32
+ connectionString: process.env.DATABASE_URL,
33
+ });
34
+ // connect() runs the migration up front, so a bad DATABASE_URL fails at startup, not on the first request.
35
+ // `new ZeroBucket({...})` also works and connects lazily on first use.
36
+
37
+ const id = await images.put(fileBuffer, { filename: "avatar.jpg" }); // returns a UUID
38
+ const image = await images.get(id); // { data: Buffer, mimeType, width, height, checksumSha256, ... }
39
+ const meta = await images.metadata(id); // no bytes pulled
40
+ await images.delete(id);
41
+ ```
42
+
43
+ The format is detected from the **bytes** (decoded with libvips), never from the filename or a
44
+ client-supplied MIME type. Truncated files, decompression bombs (`maxPixels`), and oversized
45
+ uploads (`maxBytes`, default 8 MiB) are rejected with specific error classes.
46
+
47
+ ## Serve images over HTTP (full-stack use)
48
+
49
+ `zerobucket/http` turns a bucket into a standard `(Request) => Response` handler with
50
+ **ETag / 304, Range / 206, If-Range, 416, HEAD**, streamed from the database in chunks.
51
+
52
+ ### Next.js (App Router)
53
+
54
+ ```ts
55
+ // app/images/[id]/route.ts
56
+ import { createImageHandler } from "zerobucket/http";
57
+ import { images } from "@/lib/images";
58
+
59
+ const handler = createImageHandler(images, {
60
+ getContext: async (req) => ({ userId: await currentUserId(req) }), // for your beforeGet hook
61
+ });
62
+
63
+ export async function GET(
64
+ req: Request,
65
+ { params }: { params: Promise<{ id: string }> },
66
+ ) {
67
+ return handler(req, { id: (await params).id });
68
+ }
69
+ export { GET as HEAD };
70
+ ```
71
+
72
+ Then `<img src="/images/{id}">`. Browsers get proper caching and video-style seeking for free.
73
+
74
+ ### Express / Fastify / `node:http`
75
+
76
+ ```ts
77
+ import { createImageHandler, toNodeHandler } from "zerobucket/http";
78
+ const serve = toNodeHandler(createImageHandler(images));
79
+ app.get("/images/:id", (req, res) => serve(req, res, { id: req.params.id }));
80
+ ```
81
+
82
+ ### Uploads
83
+
84
+ ```ts
85
+ // Next.js route handler
86
+ export async function POST(req: Request) {
87
+ const form = await req.formData();
88
+ const file = form.get("file") as File; // File/Blob accepted directly; size-checked before reading
89
+ const id = await images.put(file, {
90
+ optimize: { maxWidth: 1600, format: "webp" },
91
+ });
92
+ return Response.json({ id });
93
+ }
94
+ ```
95
+
96
+ `put()` accepts a `Buffer`/`Uint8Array`, a file **path** string, a `Blob`/`File`, or a Node/web
97
+ stream (read in bounded chunks and rejected as soon as it passes `maxBytes`).
98
+
99
+ **Cache-Control** defaults to `private, no-cache` (always revalidate; the ETag makes that a cheap
100
+ 304), which is safe for access-controlled images. For public images pass
101
+ `cacheControl: "public, max-age=31536000, immutable"`.
102
+
103
+ ## Access control
104
+
105
+ ```ts
106
+ const images = new ZeroBucket<{ userId: string }>({
107
+ connectionString,
108
+ beforeGet: async (imageId, ctx) => (await owner(imageId)) === ctx?.userId,
109
+ beforePut: (ctx) => !!ctx?.userId,
110
+ });
111
+ await images.get(id, { context: { userId } }); // throws AccessDeniedError if the hook returns false
112
+ ```
113
+
114
+ **A hook that throws fails closed**: the original exception propagates; it is never treated as
115
+ "allow". Denied calls never reach the database. `exists()` is deliberately not gated.
116
+
117
+ ## Streaming and ranges
118
+
119
+ ```ts
120
+ for await (const chunk of await images.getStream(id, { chunkSize: 256 * 1024 })) { ... }
121
+ await images.streamTo(id, res); // honours backpressure
122
+ const web = await images.toWebStream(id); // → new Response(web)
123
+ await images.getStream(id, { range: { start: 0, end: 1023 } }); // server-side byte range
124
+ ```
125
+
126
+ Not-found is checked eagerly (the promise rejects before any chunk). A row deleted mid-stream makes
127
+ iteration throw `StorageError` rather than silently truncating. Pass your own transaction as
128
+ `connection` for a snapshot-consistent read. This lowers **Node-side** memory; Postgres still
129
+ handles the whole `BYTEA` value per chunk request.
130
+
131
+ ## Transactions
132
+
133
+ Pass any `pg` client to make a call part of **your** transaction. ZeroBucket then does not commit,
134
+ roll back or retry:
135
+
136
+ ```ts
137
+ const client = await pool.connect();
138
+ try {
139
+ await client.query("BEGIN");
140
+ const avatarId = await images.put(buf, { connection: client });
141
+ await client.query("UPDATE users SET avatar_id = $1 WHERE id = $2", [
142
+ avatarId,
143
+ userId,
144
+ ]);
145
+ await client.query("COMMIT"); // row + image commit (or roll back) together
146
+ } catch (e) {
147
+ await client.query("ROLLBACK");
148
+ throw e;
149
+ } finally {
150
+ client.release();
151
+ }
152
+ ```
153
+
154
+ ## Batches
155
+
156
+ `putMany` / `getMany` / `deleteMany` are best-effort per item (one bad file doesn't abort the rest):
157
+ check each result's `success` / `error`. `putMany` inserts all valid items in one transaction.
158
+
159
+ ## Optimize pipeline, custom validators
160
+
161
+ ```ts
162
+ await images.put(buf, {
163
+ optimize: { maxWidth: 1200, format: "webp", quality: 80 },
164
+ });
165
+
166
+ import { PDFValidator } from "zerobucket";
167
+ await images.put(pdfBuf, { validator: new PDFValidator() }); // any ContentValidator works
168
+ ```
169
+
170
+ Optimize strips metadata, applies EXIF orientation first, never upscales, and re-validates its own
171
+ output. Implement `ContentValidator` (`validate(data, { maxBytes })`) to store other content types.
172
+
173
+ ## Reliability and observability
174
+
175
+ - Pooling: `poolMinSize` (1), `poolMaxSize` (5), `poolTimeoutMs` (10000), or pass your own `pool`
176
+ (e.g. a tiny pool for serverless).
177
+ - Automatic retry (`maxRetries` 3, `retryBaseDelayMs` 100, exponential backoff with jitter) for
178
+ transient errors only (deadlocks, serialization failures, connection loss). A retry **re-runs the
179
+ whole transaction** on a fresh connection; permanent errors are never retried.
180
+ Caveat shared with the Python package: if a connection drops _after_ the server committed a plain
181
+ `put()` but before the reply arrived, a retry can store a duplicate row.
182
+ - `onOperation(event)` receives `{ operation, durationMs, success, error, retryCount }` for every
183
+ operation (and once per chunk when streaming). Exceptions in your callback are swallowed.
184
+ - The schema migration is serialized with an advisory lock, so many processes starting at once is safe.
185
+
186
+ ## Shared database with the Python package
187
+
188
+ Same table (`zerobucket_images`), same checksum (SHA-256 hex of the stored bytes), same tiering
189
+ columns and CHECK constraint. A repeatable [conformance harness](./conformance) checks both
190
+ directions (Node writes → Python reads, and the reverse) and that both packages accept/reject the
191
+ same corpus of valid and corrupt files identically.
192
+
193
+ ## Differences from the Python package
194
+
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 |
204
+
205
+ ## What's not here yet
206
+
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.
212
+
213
+ ## Runtime notes
214
+
215
+ Node 20+. Bun/Deno: expected to work, not yet tested. Edge runtimes (Cloudflare Workers, Vercel Edge) cannot open raw
216
+ Postgres TCP connections through `pg` without a proxy such as Hyperdrive; use the Node runtime for these routes.
217
+ In serverless, keep `poolMaxSize` small (1-2) or inject a pool shared across invocations.
218
+
219
+ ## Security
220
+
221
+ Content is validated by decoding, never by trusting names or headers; responses carry
222
+ `X-Content-Type-Options: nosniff`; 500 responses never leak internals (use `onError` to log). The
223
+ `PDFValidator` is a sanity gate (magic bytes + size), not a PDF sanitizer. See the main repo's
224
+ `SECURITY.md`.
225
+
226
+ ## License
227
+
228
+ MIT
@@ -0,0 +1,68 @@
1
+ // src/errors.ts
2
+ var ZeroBucketError = class extends Error {
3
+ constructor(message, options) {
4
+ super(message, options);
5
+ this.name = new.target.name;
6
+ }
7
+ };
8
+ var ContentValidationError = class extends ZeroBucketError {
9
+ };
10
+ var ImageValidationError = class extends ContentValidationError {
11
+ };
12
+ var ImageTooLargeError = class extends ImageValidationError {
13
+ constructor(sizeBytes, maxBytes) {
14
+ super(
15
+ `Image is ${sizeBytes} bytes, which exceeds the maximum of ${maxBytes} bytes`
16
+ );
17
+ this.sizeBytes = sizeBytes;
18
+ this.maxBytes = maxBytes;
19
+ }
20
+ sizeBytes;
21
+ maxBytes;
22
+ };
23
+ var UnsupportedFormatError = class extends ImageValidationError {
24
+ constructor(detectedFormat, allowed) {
25
+ super(
26
+ `Detected format ${JSON.stringify(detectedFormat)} is not supported. Allowed formats: ${JSON.stringify([...allowed].sort())}`
27
+ );
28
+ this.detectedFormat = detectedFormat;
29
+ this.allowed = allowed;
30
+ }
31
+ detectedFormat;
32
+ allowed;
33
+ };
34
+ var CorruptedImageError = class extends ImageValidationError {
35
+ };
36
+ var ImageNotFoundError = class extends ZeroBucketError {
37
+ constructor(imageId) {
38
+ super(`No image found with id ${JSON.stringify(imageId)}`);
39
+ this.imageId = imageId;
40
+ }
41
+ imageId;
42
+ };
43
+ var StorageError = class extends ZeroBucketError {
44
+ };
45
+ var AccessDeniedError = class extends ZeroBucketError {
46
+ constructor(operation, imageId) {
47
+ super(
48
+ imageId !== void 0 ? `${operation} denied for image ${JSON.stringify(imageId)} by the beforeGet hook` : `${operation} denied by the beforePut hook`
49
+ );
50
+ this.operation = operation;
51
+ this.imageId = imageId;
52
+ }
53
+ operation;
54
+ imageId;
55
+ };
56
+
57
+ export {
58
+ ZeroBucketError,
59
+ ContentValidationError,
60
+ ImageValidationError,
61
+ ImageTooLargeError,
62
+ UnsupportedFormatError,
63
+ CorruptedImageError,
64
+ ImageNotFoundError,
65
+ StorageError,
66
+ AccessDeniedError
67
+ };
68
+ //# sourceMappingURL=chunk-W2C37XYV.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts"],"sourcesContent":["/**\r\n * Error hierarchy for ZeroBucket. Mirrors the Python package's exceptions\r\n * one-to-one so behaviour is portable across languages.\r\n *\r\n * Catch broadly with `instanceof ZeroBucketError`, or narrowly with a\r\n * subclass such as `ImageNotFoundError`.\r\n */\r\n\r\nexport class ZeroBucketError extends Error {\r\n constructor(message: string, options?: { cause?: unknown }) {\r\n super(message, options);\r\n this.name = new.target.name;\r\n }\r\n}\r\n\r\n/** Any content (image or custom-validated) failed validation. */\r\nexport class ContentValidationError extends ZeroBucketError {}\r\n\r\n/** An image failed validation (bad format, too large, corrupted, ...). */\r\nexport class ImageValidationError extends ContentValidationError {}\r\n\r\nexport class ImageTooLargeError extends ImageValidationError {\r\n constructor(\r\n public readonly sizeBytes: number,\r\n public readonly maxBytes: number,\r\n ) {\r\n super(\r\n `Image is ${sizeBytes} bytes, which exceeds the maximum of ${maxBytes} bytes`,\r\n );\r\n }\r\n}\r\n\r\nexport class UnsupportedFormatError extends ImageValidationError {\r\n constructor(\r\n public readonly detectedFormat: string | null,\r\n public readonly allowed: readonly string[],\r\n ) {\r\n super(\r\n `Detected format ${JSON.stringify(detectedFormat)} is not supported. ` +\r\n `Allowed formats: ${JSON.stringify([...allowed].sort())}`,\r\n );\r\n }\r\n}\r\n\r\n/** Image bytes could not be decoded despite a recognizable header. */\r\nexport class CorruptedImageError extends ImageValidationError {}\r\n\r\n/** `get()`, `getStream()` or `metadata()` was called with an id that does not exist. */\r\nexport class ImageNotFoundError extends ZeroBucketError {\r\n constructor(public readonly imageId: string) {\r\n super(`No image found with id ${JSON.stringify(imageId)}`);\r\n }\r\n}\r\n\r\n/** Underlying storage / database failure not covered above. */\r\nexport class StorageError extends ZeroBucketError {}\r\n\r\n/**\r\n * A `beforeGet` / `beforePut` hook denied the operation by returning false.\r\n *\r\n * If the hook itself throws, that exception propagates unchanged: it is NOT\r\n * wrapped in AccessDeniedError and NOT treated as an implicit allow. A broken\r\n * authorization check must fail closed, never open.\r\n */\r\nexport class AccessDeniedError extends ZeroBucketError {\r\n constructor(\r\n public readonly operation: string,\r\n public readonly imageId?: string,\r\n ) {\r\n super(\r\n imageId !== undefined\r\n ? `${operation} denied for image ${JSON.stringify(imageId)} by the beforeGet hook`\r\n : `${operation} denied by the beforePut hook`,\r\n );\r\n }\r\n}\r\n"],"mappings":";AAQO,IAAM,kBAAN,cAA8B,MAAM;AAAA,EACzC,YAAY,SAAiB,SAA+B;AAC1D,UAAM,SAAS,OAAO;AACtB,SAAK,OAAO,WAAW;AAAA,EACzB;AACF;AAGO,IAAM,yBAAN,cAAqC,gBAAgB;AAAC;AAGtD,IAAM,uBAAN,cAAmC,uBAAuB;AAAC;AAE3D,IAAM,qBAAN,cAAiC,qBAAqB;AAAA,EAC3D,YACkB,WACA,UAChB;AACA;AAAA,MACE,YAAY,SAAS,wCAAwC,QAAQ;AAAA,IACvE;AALgB;AACA;AAAA,EAKlB;AAAA,EANkB;AAAA,EACA;AAMpB;AAEO,IAAM,yBAAN,cAAqC,qBAAqB;AAAA,EAC/D,YACkB,gBACA,SAChB;AACA;AAAA,MACE,mBAAmB,KAAK,UAAU,cAAc,CAAC,uCAC3B,KAAK,UAAU,CAAC,GAAG,OAAO,EAAE,KAAK,CAAC,CAAC;AAAA,IAC3D;AANgB;AACA;AAAA,EAMlB;AAAA,EAPkB;AAAA,EACA;AAOpB;AAGO,IAAM,sBAAN,cAAkC,qBAAqB;AAAC;AAGxD,IAAM,qBAAN,cAAiC,gBAAgB;AAAA,EACtD,YAA4B,SAAiB;AAC3C,UAAM,0BAA0B,KAAK,UAAU,OAAO,CAAC,EAAE;AAD/B;AAAA,EAE5B;AAAA,EAF4B;AAG9B;AAGO,IAAM,eAAN,cAA2B,gBAAgB;AAAC;AAS5C,IAAM,oBAAN,cAAgC,gBAAgB;AAAA,EACrD,YACkB,WACA,SAChB;AACA;AAAA,MACE,YAAY,SACR,GAAG,SAAS,qBAAqB,KAAK,UAAU,OAAO,CAAC,2BACxD,GAAG,SAAS;AAAA,IAClB;AAPgB;AACA;AAAA,EAOlB;AAAA,EARkB;AAAA,EACA;AAQpB;","names":[]}
@@ -0,0 +1,319 @@
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
+ /** The row shape a storage backend persists (after validation and checksumming). */
67
+ interface PreparedRow {
68
+ data: Buffer;
69
+ mimeType: string;
70
+ originalFilename: string | null;
71
+ sizeBytes: number;
72
+ width: number | null;
73
+ height: number | null;
74
+ checksumSha256: string;
75
+ }
76
+
77
+ /**
78
+ * Storage backend interface.
79
+ *
80
+ * CRITICAL DESIGN RULE (same as the Python package): backends know nothing about
81
+ * images. They store and retrieve rows of bytes + metadata columns. All
82
+ * image-specific logic (validation, optimization, checksums) lives in client.ts.
83
+ *
84
+ * Every method accepts an optional `connection`. When omitted, the backend uses
85
+ * its own pool: each call commits independently and transient failures are
86
+ * retried. When provided, the backend uses it directly and does NOT commit,
87
+ * roll back or retry. That is what lets a call join the caller's transaction.
88
+ */
89
+
90
+ interface StoredRecord {
91
+ id: string;
92
+ data: Buffer;
93
+ mimeType: string;
94
+ originalFilename: string | null;
95
+ sizeBytes: number;
96
+ width: number | null;
97
+ height: number | null;
98
+ checksumSha256: string;
99
+ }
100
+ type StoredRecordMetadata = Omit<StoredRecord, "data">;
101
+ interface CallOptions {
102
+ connection?: Queryable;
103
+ }
104
+ interface StreamOptions$1 extends CallOptions {
105
+ chunkSize: number;
106
+ /** Inclusive byte range [start, end]; `end` is clamped to the object size. Omit for the whole object. */
107
+ range?: {
108
+ start: number;
109
+ end: number;
110
+ };
111
+ }
112
+ interface StorageBackend {
113
+ /** Resolve once the backend is connected and migrated. Safe to call repeatedly. */
114
+ ready(): Promise<void>;
115
+ put(row: PreparedRow, opts?: CallOptions): Promise<string>;
116
+ putMany(rows: PreparedRow[], opts?: CallOptions): Promise<string[]>;
117
+ get(id: string, opts?: CallOptions): Promise<StoredRecord | null>;
118
+ /** Missing ids are simply absent. Result order is NOT guaranteed to match input. */
119
+ getMany(ids: string[], opts?: CallOptions): Promise<StoredRecord[]>;
120
+ getMetadata(id: string, opts?: CallOptions): Promise<StoredRecordMetadata | null>;
121
+ /**
122
+ * Resolves to null if the id does not exist (checked eagerly, before any
123
+ * chunk is read). A row deleted mid-stream makes iteration throw
124
+ * StorageError rather than silently truncating.
125
+ */
126
+ getStream(id: string, opts: StreamOptions$1): Promise<AsyncIterable<Buffer> | null>;
127
+ delete(id: string, opts?: CallOptions): Promise<boolean>;
128
+ /** Returns the ids that were actually deleted. */
129
+ deleteMany(ids: string[], opts?: CallOptions): Promise<string[]>;
130
+ exists(id: string, opts?: CallOptions): Promise<boolean>;
131
+ close(): Promise<void>;
132
+ }
133
+
134
+ interface OptimizeOptions {
135
+ /** Downscale (aspect ratio preserved, Lanczos) if wider than this. Never upscales. */
136
+ maxWidth?: number;
137
+ /** "jpeg" | "png" | "webp". Defaults to the source format. */
138
+ format?: string;
139
+ /** 1-100, JPEG/WebP only (ignored for PNG). */
140
+ quality?: number;
141
+ }
142
+ interface OptimizationResult {
143
+ data: Buffer;
144
+ mimeType: string;
145
+ width: number;
146
+ height: number;
147
+ sizeBytes: number;
148
+ originalSizeBytes: number;
149
+ }
150
+ declare function optimizeImage(data: Buffer, opts: OptimizeOptions & {
151
+ maxBytes: number;
152
+ maxPixels?: number;
153
+ }): Promise<OptimizationResult>;
154
+
155
+ /** Pluggable content validation (the npm equivalent of Python's ContentValidator). */
156
+ interface ValidatedContent {
157
+ mimeType: string;
158
+ sizeBytes: number;
159
+ width?: number | null;
160
+ height?: number | null;
161
+ }
162
+ /**
163
+ * Implement this to let `put()` / `putMany()` accept a content type ZeroBucket
164
+ * does not natively understand. It must throw (ideally a
165
+ * `ContentValidationError`) when the content is invalid, and must trust nothing
166
+ * but the bytes themselves.
167
+ */
168
+ interface ContentValidator {
169
+ validate(data: Buffer, opts: {
170
+ maxBytes: number;
171
+ }): ValidatedContent | Promise<ValidatedContent>;
172
+ }
173
+
174
+ /** 8 MiB: a practical ceiling for "small app" images. Override with `maxBytes`. */
175
+ declare const DEFAULT_MAX_BYTES: number;
176
+ /**
177
+ * What put() accepts:
178
+ * - Buffer / Uint8Array / ArrayBuffer: the bytes themselves
179
+ * - string: a filesystem PATH (never raw content)
180
+ * - Blob / File (e.g. from `request.formData()`)
181
+ * - Node Readable or web ReadableStream (read in bounded chunks)
182
+ */
183
+ type ImageInput = Buffer | Uint8Array | ArrayBuffer | string | Blob | AsyncIterable<Uint8Array | string>;
184
+ type BeforeGetHook<C = unknown> = (imageId: string, context: C | undefined) => boolean | Promise<boolean>;
185
+ type BeforePutHook<C = unknown> = (context: C | undefined) => boolean | Promise<boolean>;
186
+ interface ZeroBucketOptions<C = unknown> {
187
+ /** PostgreSQL connection string. */
188
+ connectionString?: string;
189
+ /** Bring your own pg.Pool. It is NOT closed by close(). */
190
+ pool?: Pool;
191
+ /** Advanced: inject a custom StorageBackend instead of building a PostgresBackend. */
192
+ backend?: StorageBackend;
193
+ /** Max accepted size in bytes. Default 8 MiB. */
194
+ maxBytes?: number;
195
+ /** Decoded-pixel ceiling that rejects decompression bombs. Default ~89 MP. */
196
+ maxPixels?: number;
197
+ /** Accepted image formats. Default ["jpeg", "png", "webp"]. */
198
+ allowedFormats?: readonly string[];
199
+ autoMigrate?: boolean;
200
+ maxRetries?: number;
201
+ retryBaseDelayMs?: number;
202
+ poolMinSize?: number;
203
+ poolMaxSize?: number;
204
+ poolTimeoutMs?: number;
205
+ /** Fire-and-forget observability. Exceptions thrown inside it are swallowed. */
206
+ onOperation?: (event: OperationEvent) => void;
207
+ /**
208
+ * Authorization hook for get / getMany (per id) / getStream / streamTo / metadata.
209
+ * Return false to deny. A hook that THROWS fails closed: the exception
210
+ * propagates (or is captured per item in getMany) and is never treated as allow.
211
+ * NOT called for exists().
212
+ */
213
+ beforeGet?: BeforeGetHook<C>;
214
+ /** Authorization hook for put / putMany (evaluated once per call). Same fail-closed rule. */
215
+ beforePut?: BeforePutHook<C>;
216
+ }
217
+ interface PutBase<C> {
218
+ /** Display filename stored in metadata (never used for validation). */
219
+ filename?: string;
220
+ /** `true` = strip metadata only; or pass { maxWidth, format, quality } to resize/re-encode. */
221
+ optimize?: boolean | OptimizeOptions;
222
+ /** Store non-image content through the same machinery. Incompatible with `optimize`. */
223
+ validator?: ContentValidator;
224
+ /** Join your own transaction. ZeroBucket then does not commit, roll back or retry. */
225
+ connection?: Queryable;
226
+ /** Passed to beforePut. */
227
+ context?: C;
228
+ }
229
+ type PutOptions<C = unknown> = PutBase<C>;
230
+ interface PutManyOptions<C = unknown> extends Omit<PutBase<C>, "filename"> {
231
+ /** Same length as `images`; use null/undefined entries to fall back to each input's own name. */
232
+ filenames?: (string | null | undefined)[];
233
+ }
234
+ interface GetOptions<C = unknown> {
235
+ connection?: Queryable;
236
+ context?: C;
237
+ }
238
+ interface StreamOptions<C = unknown> extends GetOptions<C> {
239
+ /** Bytes per chunk. Default 1 MiB. */
240
+ chunkSize?: number;
241
+ /** Inclusive byte range, e.g. { start: 0, end: 1023 }. `end` is clamped to the object size. */
242
+ range?: {
243
+ start: number;
244
+ end?: number;
245
+ };
246
+ }
247
+ /** Anything with write(); e.g. fs.WriteStream, http.ServerResponse, or a custom sink. Backpressure is honoured. */
248
+ interface WritableLike {
249
+ write(chunk: Buffer): boolean | void | Promise<void>;
250
+ once?(event: "drain", listener: () => void): unknown;
251
+ }
252
+ declare class ZeroBucket<C = unknown> {
253
+ private readonly backend;
254
+ private readonly maxBytes;
255
+ private readonly maxPixels;
256
+ private readonly allowedFormats;
257
+ private readonly beforeGet;
258
+ private readonly beforePut;
259
+ constructor(options?: ZeroBucketOptions<C>);
260
+ /** Construct AND connect/migrate immediately, so a bad DATABASE_URL fails at startup, not on first request. */
261
+ static connect<C = unknown>(options?: ZeroBucketOptions<C>): Promise<ZeroBucket<C>>;
262
+ /** Resolves once connected and migrated. Called automatically on first use. */
263
+ ready(): Promise<void>;
264
+ private checkBeforeGet;
265
+ private checkBeforePut;
266
+ private prepareRow;
267
+ /** Validate, optionally optimize, checksum and store one image. Returns its id (a UUID). */
268
+ put(image: ImageInput, options?: PutOptions<C>): Promise<string>;
269
+ /**
270
+ * Store many images. Best-effort, not all-or-nothing: one bad image does not
271
+ * abort the rest. Check each result's `success` / `error`. Settings apply to
272
+ * every item. Valid items are inserted in ONE transaction.
273
+ */
274
+ putMany(images: ImageInput[], options?: PutManyOptions<C>): Promise<BatchPutResult[]>;
275
+ /** Retrieve a full image including bytes. Throws ImageNotFoundError if missing. */
276
+ get(imageId: string, options?: GetOptions<C>): Promise<Image>;
277
+ /**
278
+ * Retrieve many images in one query. Results come back in input order; a
279
+ * missing id is `error: "not found"`, not an exception. A hook denial or hook
280
+ * exception affects only that id.
281
+ */
282
+ getMany(imageIds: string[], options?: GetOptions<C>): Promise<BatchGetResult[]>;
283
+ /**
284
+ * Stream an image's bytes in chunks without holding the whole image in memory.
285
+ * Not-found is checked eagerly: the returned promise rejects before any chunk
286
+ * is read. A concurrent delete mid-stream makes iteration throw StorageError
287
+ * rather than silently truncating. Pass `range` for partial reads.
288
+ *
289
+ * Honest limitation: reduces Node-side memory, not Postgres-side (the server
290
+ * still handles the full BYTEA value per chunk request).
291
+ */
292
+ getStream(imageId: string, options?: StreamOptions<C>): Promise<AsyncIterable<Buffer>>;
293
+ /** Same as getStream(), as a Web `ReadableStream` you can hand straight to `new Response(...)`. */
294
+ toWebStream(imageId: string, options?: StreamOptions<C>): Promise<ReadableStream<Uint8Array>>;
295
+ /** Write an image to `destination` chunk by chunk (honouring backpressure). Returns bytes written. Does not end the destination. */
296
+ streamTo(imageId: string, destination: WritableLike, options?: StreamOptions<C>): Promise<number>;
297
+ /** Metadata without pulling the bytes. Gated by the same beforeGet hook as get(). */
298
+ metadata(imageId: string, options?: GetOptions<C>): Promise<ImageMetadata>;
299
+ /** Whether an image exists. Deliberately NOT gated by beforeGet (returns no data or metadata). */
300
+ exists(imageId: string, options?: Pick<CallOptions, "connection">): Promise<boolean>;
301
+ /** Delete an image. Returns true if it existed. */
302
+ delete(imageId: string, options?: Pick<CallOptions, "connection">): Promise<boolean>;
303
+ /** Delete many in one query. Results are in input order; a missing id is `deleted: false`, not an error. */
304
+ deleteMany(imageIds: string[], options?: Pick<CallOptions, "connection">): Promise<BatchDeleteResult[]>;
305
+ /** Release the pool (if ZeroBucket created it). */
306
+ close(): Promise<void>;
307
+ }
308
+ /**
309
+ * Read any supported input into a Buffer. Streams are consumed in bounded
310
+ * chunks and rejected as soon as they pass maxBytes (reported size is then a
311
+ * lower bound), rather than buffering an arbitrarily large upload first.
312
+ * Files and Blobs are size-checked BEFORE their bytes are read.
313
+ */
314
+ declare function readInput(image: ImageInput, filename: string | undefined, maxBytes: number): Promise<{
315
+ data: Buffer;
316
+ filename: string | undefined;
317
+ }>;
318
+
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 };