zerobucket 0.8.2 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -29
- package/dist/{client-l4jPIFJI.d.cts → client-7ZNu3xMJ.d.cts} +3 -1
- package/dist/{client-l4jPIFJI.d.ts → client-7ZNu3xMJ.d.ts} +3 -1
- package/dist/http.d.cts +1 -1
- package/dist/http.d.ts +1 -1
- package/dist/index.cjs +5 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -123,11 +123,13 @@ await images.get(id, { context: { userId } }); // throws AccessDeniedError if th
|
|
|
123
123
|
**A hook that throws fails closed**: the original exception propagates; it is never treated as
|
|
124
124
|
"allow". Denied calls never reach the database. `exists()` is deliberately not gated.
|
|
125
125
|
|
|
126
|
-
**Your hook
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
126
|
+
**Your hook only ever sees a well-formed UUID.** A malformed id (such as one taken straight from a URL) can never
|
|
127
|
+
exist, so it is "not found" (`ImageNotFoundError`, or `error: "not found"` for that item in `getMany`) and your hook is
|
|
128
|
+
never called with it. You can use the id in SQL (`WHERE id = $1` on a `uuid` column) without validating it first. _(Before
|
|
129
|
+
0.9.0 the hook received the raw string, so on 0.8.x guard against a malformed id yourself: a request for
|
|
130
|
+
`/images/garbage` otherwise became a database error and a `500`.)_ The
|
|
131
|
+
[example app](https://github.com/KedarGhadyalji/ZeroBucket/tree/main/examples/node-integration) shows a working hook, plus
|
|
132
|
+
transactional uploads and serving.
|
|
131
133
|
|
|
132
134
|
## Streaming and ranges
|
|
133
135
|
|
|
@@ -451,33 +453,55 @@ same corpus of valid and corrupt files identically.
|
|
|
451
453
|
|
|
452
454
|
## Differences from the Python package
|
|
453
455
|
|
|
454
|
-
Python column = zerobucket on PyPI **0.
|
|
455
|
-
differ).
|
|
456
|
-
|
|
457
|
-
| | Python | npm
|
|
458
|
-
| -------------------------------------------- | --------------------------------------------------------------------- |
|
|
459
|
-
| API | sync + async clients | one async client
|
|
460
|
-
|
|
|
461
|
-
| `optimize`
|
|
462
|
-
| `putMany` inserts | pipelined | one transaction, statements run sequentially
|
|
463
|
-
| Formats | JPEG, PNG, WebP, HEIC (`[heic]` extra) | JPEG, PNG, WebP, HEIC (optional `libheif-js`)
|
|
464
|
-
| Convert TO HEIC with `optimize` | yes (pillow-heif) | no (no HEVC encoder for Node); converting FROM HEIC works; `optimize: true` on a HEIC needs an explicit `format`
|
|
465
|
-
| Event duration | `duration_seconds` | `durationMs`
|
|
466
|
-
| `migrateClassicToDedup`, tiered classic rows | always **skips** them and reports `tiered_rows_skipped` | **refuses** by default (changes nothing); `skipTiered: true` / `--skip-tiered` skips and reports like Python
|
|
467
|
-
| `migrateClassicToDedup`, other behaviour | batched, safe to re-run (server-side cursor) | batched, safe to re-run (keyset paging, advisory lock so concurrent runs cannot race)
|
|
468
|
-
| 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)
|
|
469
|
-
| SQLite `onOperation` / busy retry | none | `onOperation` events and async `SQLITE_BUSY` retry (`busyTimeoutMs`)
|
|
470
|
-
| 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
|
|
471
|
-
| MySQL `onOperation` / retry | sync `MySQLBackend` yes; `AsyncMySQLBackend` none | yes (same client for everything)
|
|
472
|
-
| CLI | PostgreSQL, classic mode only; `verify` skips tiered rows with a note | PostgreSQL, SQLite **and** MySQL/MariaDB, classic **and** dedup; `verify --bucket` also checks tiered images; `migrate --to-dedup`
|
|
473
|
-
| `connection=` without an open transaction | multi-statement ops rely on the driver's implicit transaction | always atomic (own transaction or savepoint)
|
|
474
|
-
| S3 delete failure after `delete()` | raises after the row is gone | never fails the delete; reported via `onOperation`
|
|
475
|
-
| Options | snake_case kwargs | camelCase options object
|
|
476
|
-
|
|
477
|
-
Both packages
|
|
456
|
+
Python column = zerobucket on PyPI **0.26.0 or later** (several of its behaviours below were fixed in 0.25.1 and 0.26.0; older
|
|
457
|
+
versions differ).
|
|
458
|
+
|
|
459
|
+
| | Python | npm |
|
|
460
|
+
| -------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
461
|
+
| API | sync + async clients | one async client |
|
|
462
|
+
| `optimize` | Pillow; EXIF stripped, not applied | sharp; EXIF **applied then stripped**; transparency → **white** for JPEG |
|
|
463
|
+
| JPEG encoder in `optimize` | Pillow, progressive + optimized | sharp with `mozjpeg`, progressive: on smooth photos roughly **half the size** at about the same SSIM (measured); WebP and PNG match |
|
|
464
|
+
| `putMany` inserts | pipelined | one transaction, statements run sequentially |
|
|
465
|
+
| Formats | JPEG, PNG, WebP, HEIC (`[heic]` extra) | JPEG, PNG, WebP, HEIC (optional `libheif-js`) |
|
|
466
|
+
| Convert TO HEIC with `optimize` | yes (pillow-heif) | no (no HEVC encoder for Node); converting FROM HEIC works; `optimize: true` on a HEIC needs an explicit `format` |
|
|
467
|
+
| Event duration | `duration_seconds` | `durationMs` |
|
|
468
|
+
| `migrateClassicToDedup`, tiered classic rows | always **skips** them and reports `tiered_rows_skipped` | **refuses** by default (changes nothing); `skipTiered: true` / `--skip-tiered` skips and reports like Python |
|
|
469
|
+
| `migrateClassicToDedup`, other behaviour | batched, safe to re-run (server-side cursor) | batched, safe to re-run (keyset paging, advisory lock so concurrent runs cannot race) |
|
|
470
|
+
| 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) |
|
|
471
|
+
| SQLite `onOperation` / busy retry | none | `onOperation` events and async `SQLITE_BUSY` retry (`busyTimeoutMs`) |
|
|
472
|
+
| 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 |
|
|
473
|
+
| MySQL `onOperation` / retry | sync `MySQLBackend` yes; `AsyncMySQLBackend` none | yes (same client for everything) |
|
|
474
|
+
| CLI | PostgreSQL, classic mode only; `verify` skips tiered rows with a note | PostgreSQL, SQLite **and** MySQL/MariaDB, classic **and** dedup; `verify --bucket` also checks tiered images; `migrate --to-dedup` |
|
|
475
|
+
| `connection=` without an open transaction | multi-statement ops rely on the driver's implicit transaction | always atomic (own transaction or savepoint) |
|
|
476
|
+
| S3 delete failure after `delete()` | raises after the row is gone | never fails the delete; reported via `onOperation` |
|
|
477
|
+
| Options | snake_case kwargs | camelCase options object |
|
|
478
|
+
|
|
479
|
+
Both packages treat a malformed id as "not found" on every backend (Python 0.26.0+, npm 0.9.0+) and never pass one to a
|
|
480
|
+
`before_get`/`beforeGet` hook. Both packages now leave a tiered image's object in place on `delete(id, connection=...)` (a rolled-back transaction can
|
|
478
481
|
never lose data), take dedup batch locks in sorted checksum order (overlapping batches cannot deadlock), and skip the
|
|
479
482
|
MySQL `ALTER TABLE` at startup when the schema is current.
|
|
480
483
|
|
|
484
|
+
## Performance
|
|
485
|
+
|
|
486
|
+
Measured on one single-core container, so treat it as directional; the full tables (PostgreSQL, SQLite, MariaDB, per size,
|
|
487
|
+
memory, event-loop impact, throughput) are in the
|
|
488
|
+
[benchmark results](https://github.com/KedarGhadyalji/ZeroBucket/blob/main/benchmarks/node/RESULTS.md).
|
|
489
|
+
|
|
490
|
+
| Image size | `put()` (PostgreSQL) | `get()` | Notes |
|
|
491
|
+
| ------------ | -------------------- | ----------- | ------------------------------------------------------------------- |
|
|
492
|
+
| up to ~500KB | under 60ms | under 5ms | Feels like any other write. |
|
|
493
|
+
| 0.5-1.5MB | about 60-160ms | under 10ms | Fine for uploads. |
|
|
494
|
+
| about 5MB | about 0.6s | about 45ms | About 58MB of transient memory per upload. |
|
|
495
|
+
| about 10MB | about 1.2-1.4s | about 120ms | About 130MB of transient memory per upload. Default limit is 8 MiB. |
|
|
496
|
+
|
|
497
|
+
- `put()` time is mostly image validation (a full decode), so it follows the image's pixel count, not the database you pick.
|
|
498
|
+
- The decode runs off the main thread: the event loop stalled at most about 16ms during a 10MB `put()`.
|
|
499
|
+
- Memory is the decoded image, roughly 11x the file size for a 45-megapixel JPEG. Cap `maxBytes` and `maxPixels` on public endpoints.
|
|
500
|
+
- Stored size equals the file size: JPEG, PNG and WebP are already compressed, so the database cannot shrink them further.
|
|
501
|
+
- Compression quality of `optimize` is measured in the
|
|
502
|
+
[compression results](https://github.com/KedarGhadyalji/ZeroBucket/blob/main/benchmarks/node/COMPRESSION_RESULTS.md).
|
|
503
|
+
Do not convert flat graphics (screenshots, logos) to JPEG; they grow.
|
|
504
|
+
|
|
481
505
|
## Limitations (read before using in production)
|
|
482
506
|
|
|
483
507
|
- **Images live in your database.** That is the point (one backup, one transaction, no extra service), but it means
|
|
@@ -392,7 +392,9 @@ interface ZeroBucketOptions<C = unknown> {
|
|
|
392
392
|
* Authorization hook for get / getMany (per id) / getStream / streamTo / metadata.
|
|
393
393
|
* Return false to deny. A hook that THROWS fails closed: the exception
|
|
394
394
|
* propagates (or is captured per item in getMany) and is never treated as allow.
|
|
395
|
-
* NOT called for exists().
|
|
395
|
+
* NOT called for exists(). Only ever called with a well-formed UUID: a malformed id is
|
|
396
|
+
* "not found" (ImageNotFoundError, or `error: "not found"` per item in getMany) and never
|
|
397
|
+
* reaches the hook.
|
|
396
398
|
*/
|
|
397
399
|
beforeGet?: BeforeGetHook<C>;
|
|
398
400
|
/** Authorization hook for put / putMany (evaluated once per call). Same fail-closed rule. */
|
|
@@ -392,7 +392,9 @@ interface ZeroBucketOptions<C = unknown> {
|
|
|
392
392
|
* Authorization hook for get / getMany (per id) / getStream / streamTo / metadata.
|
|
393
393
|
* Return false to deny. A hook that THROWS fails closed: the exception
|
|
394
394
|
* propagates (or is captured per item in getMany) and is never treated as allow.
|
|
395
|
-
* NOT called for exists().
|
|
395
|
+
* NOT called for exists(). Only ever called with a well-formed UUID: a malformed id is
|
|
396
|
+
* "not found" (ImageNotFoundError, or `error: "not found"` per item in getMany) and never
|
|
397
|
+
* reaches the hook.
|
|
396
398
|
*/
|
|
397
399
|
beforeGet?: BeforeGetHook<C>;
|
|
398
400
|
/** Authorization hook for put / putMany (evaluated once per call). Same fail-closed rule. */
|
package/dist/http.d.cts
CHANGED
package/dist/http.d.ts
CHANGED
package/dist/index.cjs
CHANGED
|
@@ -3407,6 +3407,7 @@ var ZeroBucket = class _ZeroBucket {
|
|
|
3407
3407
|
}
|
|
3408
3408
|
// ---- hooks -----------------------------------------------------------
|
|
3409
3409
|
async checkBeforeGet(imageId, context) {
|
|
3410
|
+
if (!isValidId(imageId)) throw new ImageNotFoundError(imageId);
|
|
3410
3411
|
if (!this.beforeGet) return;
|
|
3411
3412
|
if (!await this.beforeGet(imageId, context))
|
|
3412
3413
|
throw new AccessDeniedError("get", imageId);
|
|
@@ -3570,6 +3571,10 @@ var ZeroBucket = class _ZeroBucket {
|
|
|
3570
3571
|
const allowedIdx = [];
|
|
3571
3572
|
for (let i = 0; i < imageIds.length; i++) {
|
|
3572
3573
|
const imageId = imageIds[i];
|
|
3574
|
+
if (!isValidId(imageId)) {
|
|
3575
|
+
results[i] = { imageId, image: null, error: "not found", success: false };
|
|
3576
|
+
continue;
|
|
3577
|
+
}
|
|
3573
3578
|
if (this.beforeGet) {
|
|
3574
3579
|
try {
|
|
3575
3580
|
if (!await this.beforeGet(imageId, options.context)) {
|