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 +0 -0
- package/README.md +228 -3
- package/dist/chunk-W2C37XYV.js +68 -0
- package/dist/chunk-W2C37XYV.js.map +1 -0
- package/dist/client-C_W0t8My.d.cts +319 -0
- package/dist/client-C_W0t8My.d.ts +319 -0
- package/dist/http.cjs +200 -0
- package/dist/http.cjs.map +1 -0
- package/dist/http.d.cts +64 -0
- package/dist/http.d.ts +64 -0
- package/dist/http.js +149 -0
- package/dist/http.js.map +1 -0
- package/dist/index.cjs +1245 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +164 -0
- package/dist/index.d.ts +164 -0
- package/dist/index.js +1146 -0
- package/dist/index.js.map +1 -0
- package/package.json +88 -6
package/LICENSE
ADDED
|
File without changes
|
package/README.md
CHANGED
|
@@ -1,3 +1,228 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
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 };
|