zerobucket 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +0 -0
- package/README.md +342 -3
- package/dist/chunk-W2C37XYV.js +68 -0
- package/dist/chunk-W2C37XYV.js.map +1 -0
- package/dist/client-WZ4VRCPi.d.cts +425 -0
- package/dist/client-WZ4VRCPi.d.ts +425 -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 +2559 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +307 -0
- package/dist/index.d.ts +307 -0
- package/dist/index.js +2458 -0
- package/dist/index.js.map +1 -0
- package/package.json +106 -6
package/LICENSE
ADDED
|
File without changes
|
package/README.md
CHANGED
|
@@ -1,3 +1,342 @@
|
|
|
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: 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
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install zerobucket pg sharp # PostgreSQL
|
|
20
|
+
npm install zerobucket better-sqlite3 sharp # or SQLite (see below): no database server at all
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`pg` (the Postgres driver), `better-sqlite3` (SQLite) and `sharp` (image decoding/optimizing) are peer dependencies, so you
|
|
24
|
+
control their versions. If you only store PDFs through a custom validator, `sharp` is not needed.
|
|
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.
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { ZeroBucket } from "zerobucket";
|
|
32
|
+
|
|
33
|
+
const images = await ZeroBucket.connect({
|
|
34
|
+
connectionString: process.env.DATABASE_URL,
|
|
35
|
+
});
|
|
36
|
+
// connect() runs the migration up front, so a bad DATABASE_URL fails at startup, not on the first request.
|
|
37
|
+
// `new ZeroBucket({...})` also works and connects lazily on first use.
|
|
38
|
+
|
|
39
|
+
const id = await images.put(fileBuffer, { filename: "avatar.jpg" }); // returns a UUID
|
|
40
|
+
const image = await images.get(id); // { data: Buffer, mimeType, width, height, checksumSha256, ... }
|
|
41
|
+
const meta = await images.metadata(id); // no bytes pulled
|
|
42
|
+
await images.delete(id);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The format is detected from the **bytes** (decoded with libvips), never from the filename or a
|
|
46
|
+
client-supplied MIME type. Truncated files, decompression bombs (`maxPixels`), and oversized
|
|
47
|
+
uploads (`maxBytes`, default 8 MiB) are rejected with specific error classes.
|
|
48
|
+
|
|
49
|
+
## Serve images over HTTP (full-stack use)
|
|
50
|
+
|
|
51
|
+
`zerobucket/http` turns a bucket into a standard `(Request) => Response` handler with
|
|
52
|
+
**ETag / 304, Range / 206, If-Range, 416, HEAD**, streamed from the database in chunks.
|
|
53
|
+
|
|
54
|
+
### Next.js (App Router)
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// app/images/[id]/route.ts
|
|
58
|
+
import { createImageHandler } from "zerobucket/http";
|
|
59
|
+
import { images } from "@/lib/images";
|
|
60
|
+
|
|
61
|
+
const handler = createImageHandler(images, {
|
|
62
|
+
getContext: async (req) => ({ userId: await currentUserId(req) }), // for your beforeGet hook
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export async function GET(
|
|
66
|
+
req: Request,
|
|
67
|
+
{ params }: { params: Promise<{ id: string }> },
|
|
68
|
+
) {
|
|
69
|
+
return handler(req, { id: (await params).id });
|
|
70
|
+
}
|
|
71
|
+
export { GET as HEAD };
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Then `<img src="/images/{id}">`. Browsers get proper caching and video-style seeking for free.
|
|
75
|
+
|
|
76
|
+
### Express / Fastify / `node:http`
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { createImageHandler, toNodeHandler } from "zerobucket/http";
|
|
80
|
+
const serve = toNodeHandler(createImageHandler(images));
|
|
81
|
+
app.get("/images/:id", (req, res) => serve(req, res, { id: req.params.id }));
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Uploads
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
// Next.js route handler
|
|
88
|
+
export async function POST(req: Request) {
|
|
89
|
+
const form = await req.formData();
|
|
90
|
+
const file = form.get("file") as File; // File/Blob accepted directly; size-checked before reading
|
|
91
|
+
const id = await images.put(file, {
|
|
92
|
+
optimize: { maxWidth: 1600, format: "webp" },
|
|
93
|
+
});
|
|
94
|
+
return Response.json({ id });
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`put()` accepts a `Buffer`/`Uint8Array`, a file **path** string, a `Blob`/`File`, or a Node/web
|
|
99
|
+
stream (read in bounded chunks and rejected as soon as it passes `maxBytes`).
|
|
100
|
+
|
|
101
|
+
**Cache-Control** defaults to `private, no-cache` (always revalidate; the ETag makes that a cheap
|
|
102
|
+
304), which is safe for access-controlled images. For public images pass
|
|
103
|
+
`cacheControl: "public, max-age=31536000, immutable"`.
|
|
104
|
+
|
|
105
|
+
## Access control
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const images = new ZeroBucket<{ userId: string }>({
|
|
109
|
+
connectionString,
|
|
110
|
+
beforeGet: async (imageId, ctx) => (await owner(imageId)) === ctx?.userId,
|
|
111
|
+
beforePut: (ctx) => !!ctx?.userId,
|
|
112
|
+
});
|
|
113
|
+
await images.get(id, { context: { userId } }); // throws AccessDeniedError if the hook returns false
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**A hook that throws fails closed**: the original exception propagates; it is never treated as
|
|
117
|
+
"allow". Denied calls never reach the database. `exists()` is deliberately not gated.
|
|
118
|
+
|
|
119
|
+
## Streaming and ranges
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
for await (const chunk of await images.getStream(id, { chunkSize: 256 * 1024 })) { ... }
|
|
123
|
+
await images.streamTo(id, res); // honours backpressure
|
|
124
|
+
const web = await images.toWebStream(id); // → new Response(web)
|
|
125
|
+
await images.getStream(id, { range: { start: 0, end: 1023 } }); // server-side byte range
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Not-found is checked eagerly (the promise rejects before any chunk). A row deleted mid-stream makes
|
|
129
|
+
iteration throw `StorageError` rather than silently truncating. Pass your own transaction as
|
|
130
|
+
`connection` for a snapshot-consistent read. This lowers **Node-side** memory; Postgres still
|
|
131
|
+
handles the whole `BYTEA` value per chunk request.
|
|
132
|
+
|
|
133
|
+
## Transactions
|
|
134
|
+
|
|
135
|
+
Pass any `pg` client to make a call part of **your** transaction. ZeroBucket then does not commit,
|
|
136
|
+
roll back or retry:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
const client = await pool.connect();
|
|
140
|
+
try {
|
|
141
|
+
await client.query("BEGIN");
|
|
142
|
+
const avatarId = await images.put(buf, { connection: client });
|
|
143
|
+
await client.query("UPDATE users SET avatar_id = $1 WHERE id = $2", [
|
|
144
|
+
avatarId,
|
|
145
|
+
userId,
|
|
146
|
+
]);
|
|
147
|
+
await client.query("COMMIT"); // row + image commit (or roll back) together
|
|
148
|
+
} catch (e) {
|
|
149
|
+
await client.query("ROLLBACK");
|
|
150
|
+
throw e;
|
|
151
|
+
} finally {
|
|
152
|
+
client.release();
|
|
153
|
+
}
|
|
154
|
+
```
|
|
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
|
+
|
|
160
|
+
## Batches
|
|
161
|
+
|
|
162
|
+
`putMany` / `getMany` / `deleteMany` are best-effort per item (one bad file doesn't abort the rest):
|
|
163
|
+
check each result's `success` / `error`. `putMany` inserts all valid items in one transaction.
|
|
164
|
+
|
|
165
|
+
## Optimize pipeline, custom validators
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
await images.put(buf, {
|
|
169
|
+
optimize: { maxWidth: 1200, format: "webp", quality: 80 },
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
import { PDFValidator } from "zerobucket";
|
|
173
|
+
await images.put(pdfBuf, { validator: new PDFValidator() }); // any ContentValidator works
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Optimize strips metadata, applies EXIF orientation first, never upscales, and re-validates its own
|
|
177
|
+
output. Implement `ContentValidator` (`validate(data, { maxBytes })`) to store other content types.
|
|
178
|
+
|
|
179
|
+
## Reliability and observability
|
|
180
|
+
|
|
181
|
+
- Pooling: `poolMinSize` (1), `poolMaxSize` (5), `poolTimeoutMs` (10000), or pass your own `pool`
|
|
182
|
+
(e.g. a tiny pool for serverless).
|
|
183
|
+
- Automatic retry (`maxRetries` 3, `retryBaseDelayMs` 100, exponential backoff with jitter) for
|
|
184
|
+
transient errors only (deadlocks, serialization failures, connection loss). A retry **re-runs the
|
|
185
|
+
whole transaction** on a fresh connection; permanent errors are never retried.
|
|
186
|
+
Caveat shared with the Python package: if a connection drops _after_ the server committed a plain
|
|
187
|
+
`put()` but before the reply arrived, a retry can store a duplicate row.
|
|
188
|
+
- `onOperation(event)` receives `{ operation, durationMs, success, error, retryCount }` for every
|
|
189
|
+
operation (and once per chunk when streaming). Exceptions in your callback are swallowed.
|
|
190
|
+
- The schema migration is serialized with an advisory lock, so many processes starting at once is safe.
|
|
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
|
+
|
|
295
|
+
## Shared database with the Python package
|
|
296
|
+
|
|
297
|
+
Same table (`zerobucket_images`), same checksum (SHA-256 hex of the stored bytes), same tiering
|
|
298
|
+
columns and CHECK constraint. A repeatable [conformance harness](./conformance) checks both
|
|
299
|
+
directions (Node writes → Python reads, and the reverse) and that both packages accept/reject the
|
|
300
|
+
same corpus of valid and corrupt files identically.
|
|
301
|
+
|
|
302
|
+
## Differences from the Python package
|
|
303
|
+
|
|
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 |
|
|
321
|
+
|
|
322
|
+
## What's not here yet
|
|
323
|
+
|
|
324
|
+
Planned, in order: **MySQL/MariaDB**, the `zerobucket` **CLI**, and **HEIC**.
|
|
325
|
+
Tiering and dedup cannot be combined (same rule as the Python package).
|
|
326
|
+
|
|
327
|
+
## Runtime notes
|
|
328
|
+
|
|
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
|
|
330
|
+
Postgres TCP connections through `pg` without a proxy such as Hyperdrive; use the Node runtime for these routes.
|
|
331
|
+
In serverless, keep `poolMaxSize` small (1-2) or inject a pool shared across invocations.
|
|
332
|
+
|
|
333
|
+
## Security
|
|
334
|
+
|
|
335
|
+
Content is validated by decoding, never by trusting names or headers; responses carry
|
|
336
|
+
`X-Content-Type-Options: nosniff`; 500 responses never leak internals (use `onError` to log). The
|
|
337
|
+
`PDFValidator` is a sanity gate (magic bytes + size), not a PDF sanitizer. See the main repo's
|
|
338
|
+
`SECURITY.md`.
|
|
339
|
+
|
|
340
|
+
## License
|
|
341
|
+
|
|
342
|
+
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":[]}
|