zerobucket 0.9.1 → 0.10.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 CHANGED
@@ -456,25 +456,26 @@ same corpus of valid and corrupt files identically.
456
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
457
  versions differ).
458
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 |
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
+ | JPEG missing only its last 1-2 bytes (the end-of-image marker) | accepted (Pillow reads every pixel) | **rejected** (libvips treats a premature end as an error); truncated interlaced PNGs are rejected by both since npm 0.10.0 |
465
+ | `putMany` inserts | pipelined | one transaction, statements run sequentially |
466
+ | Formats | JPEG, PNG, WebP, HEIC (`[heic]` extra) | JPEG, PNG, WebP, HEIC (optional `libheif-js`) |
467
+ | 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` |
468
+ | Event duration | `duration_seconds` | `durationMs` |
469
+ | `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 |
470
+ | `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) |
471
+ | 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) |
472
+ | SQLite `onOperation` / busy retry | none | `onOperation` events and async `SQLITE_BUSY` retry (`busyTimeoutMs`) |
473
+ | 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 |
474
+ | MySQL `onOperation` / retry | sync `MySQLBackend` yes; `AsyncMySQLBackend` none | yes (same client for everything) |
475
+ | 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` |
476
+ | `connection=` without an open transaction | multi-statement ops rely on the driver's implicit transaction | always atomic (own transaction or savepoint) |
477
+ | S3 delete failure after `delete()` | raises after the row is gone | never fails the delete; reported via `onOperation` |
478
+ | Options | snake_case kwargs | camelCase options object |
478
479
 
479
480
  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
481
  `before_get`/`beforeGet` hook. Both packages now leave a tiered image's object in place on `delete(id, connection=...)` (a rolled-back transaction can
@@ -484,19 +485,26 @@ MySQL `ALTER TABLE` at startup when the schema is current.
484
485
  ## Performance
485
486
 
486
487
  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
+ memory, event-loop impact, throughput, and the before and after of each release) are in the
488
489
  [benchmark results](https://github.com/KedarGhadyalji/ZeroBucket/blob/main/benchmarks/node/RESULTS.md).
489
490
 
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.
491
+ | Image size | `put()` (PostgreSQL) | `get()` (PostgreSQL) | Notes |
492
+ | ------------ | -------------------- | -------------------- | ----------------------------------------------------------------- |
493
+ | up to ~500KB | under 20ms | under 5ms | Feels like any other write. |
494
+ | 0.5-1.5MB | about 20-45ms | under 10ms | Fine for uploads, including in a loop. |
495
+ | about 5MB | about 170ms | about 35ms | A few MB of transient memory to write a baseline JPEG or PNG. |
496
+ | about 10MB | about 350ms | about 70ms | Default limit is 8 MiB. Memory depends on the format (see below). |
497
+
498
+ - Since 0.10.0, `put()` is 2.5-4x faster than 0.9.x and about as fast as the Python package for large images. Validation is now
499
+ roughly half of `put()` on PostgreSQL.
500
+ - Since 0.10.1, reading from PostgreSQL asks for base64 instead of PostgreSQL's default hex text: about 30% faster for images over
501
+ 2MB, and `get()` needs 37-47% less memory (about 6.5 times the image size, down from about 10). Streaming, which is how
502
+ `createImageHandler` serves images, is bounded in memory either way. Between roughly 0.5MB and 1.3MB there is no reliable
503
+ difference (a cost of under a millisecond may exist). SQLite and MySQL/MariaDB were already fast readers and are unchanged.
504
+ - The decode runs off the main thread: the event loop stalled at most about 14ms during a 10MB `put()`.
505
+ - Transient memory depends on the format. A baseline JPEG or non-interlaced PNG validates in streaming fashion (about 11MB for a
506
+ 45-megapixel JPEG, down from about 148MB in 0.9.x). WebP, progressive JPEG and interlaced PNG still need roughly the decoded
507
+ image in memory, so cap `maxBytes` and `maxPixels` on public endpoints.
500
508
  - Stored size equals the file size: JPEG, PNG and WebP are already compressed, so the database cannot shrink them further.
501
509
  - Compression quality of `optimize` is measured in the
502
510
  [compression results](https://github.com/KedarGhadyalji/ZeroBucket/blob/main/benchmarks/node/COMPRESSION_RESULTS.md).
package/dist/bin.js CHANGED
@@ -150,17 +150,17 @@ INSERT INTO zerobucket_images
150
150
  VALUES ($1, $2, $3, $4, $5, $6, $7)
151
151
  RETURNING id;`;
152
152
  var SELECT_FULL = `
153
- SELECT id, data, mime_type, original_filename, size_bytes, width, height,
153
+ SELECT id, encode(data, 'base64') AS data_b64, mime_type, original_filename, size_bytes, width, height,
154
154
  checksum_sha256, storage_backend, object_storage_bucket, object_storage_key
155
155
  FROM zerobucket_images WHERE id = $1;`;
156
156
  var SELECT_MANY = `
157
- SELECT id, data, mime_type, original_filename, size_bytes, width, height,
157
+ SELECT id, encode(data, 'base64') AS data_b64, mime_type, original_filename, size_bytes, width, height,
158
158
  checksum_sha256, storage_backend, object_storage_bucket, object_storage_key
159
159
  FROM zerobucket_images WHERE id = ANY($1::uuid[]);`;
160
160
  var SELECT_METADATA = `
161
161
  SELECT id, mime_type, original_filename, size_bytes, width, height, checksum_sha256
162
162
  FROM zerobucket_images WHERE id = $1;`;
163
- var SELECT_CHUNK = `SELECT substring(data FROM $1::int FOR $2::int) AS chunk FROM zerobucket_images WHERE id = $3;`;
163
+ var SELECT_CHUNK = `SELECT encode(substring(data FROM $1::int FOR $2::int), 'base64') AS chunk FROM zerobucket_images WHERE id = $3;`;
164
164
  var SELECT_STREAM_INFO = `SELECT size_bytes, storage_backend, object_storage_key FROM zerobucket_images WHERE id = $1;`;
165
165
  var DELETE_RETURNING = `DELETE FROM zerobucket_images WHERE id = $1 RETURNING storage_backend, object_storage_key;`;
166
166
  var DELETE_MANY = `DELETE FROM zerobucket_images WHERE id = ANY($1::uuid[]) RETURNING id, storage_backend, object_storage_key;`;
@@ -193,7 +193,7 @@ INSERT INTO zerobucket_blobs (checksum_sha256, data, mime_type, size_bytes, widt
193
193
  VALUES ($1, $2, $3, $4, $5, $6, $7)
194
194
  ON CONFLICT (checksum_sha256) DO UPDATE SET ref_count = zerobucket_blobs.ref_count + EXCLUDED.ref_count;`;
195
195
  var D_INSERT_REF = `INSERT INTO zerobucket_image_refs (checksum_sha256, original_filename) VALUES ($1, $2) RETURNING id;`;
196
- var D_COLS = `r.id, b.data, b.mime_type, r.original_filename, b.size_bytes, b.width, b.height, r.checksum_sha256,
196
+ var D_COLS = `r.id, encode(b.data, 'base64') AS data_b64, b.mime_type, r.original_filename, b.size_bytes, b.width, b.height, r.checksum_sha256,
197
197
  'postgres'::text AS storage_backend, NULL::text AS object_storage_bucket, NULL::text AS object_storage_key`;
198
198
  var D_FROM = `FROM zerobucket_image_refs r JOIN zerobucket_blobs b ON r.checksum_sha256 = b.checksum_sha256`;
199
199
  var D_DELETE_REF = `DELETE FROM zerobucket_image_refs WHERE id = $1 RETURNING checksum_sha256;`;
@@ -215,7 +215,7 @@ var DEDUP_SQL = {
215
215
  selectMany: `SELECT ${D_COLS} ${D_FROM} WHERE r.id = ANY($1::uuid[]);`,
216
216
  selectMetadata: `SELECT r.id, b.mime_type, r.original_filename, b.size_bytes, b.width, b.height, r.checksum_sha256 ${D_FROM} WHERE r.id = $1;`,
217
217
  selectStreamInfo: `SELECT b.size_bytes, 'postgres'::text AS storage_backend, NULL::text AS object_storage_key ${D_FROM} WHERE r.id = $1;`,
218
- selectChunk: `SELECT substring(b.data FROM $1::int FOR $2::int) AS chunk ${D_FROM} WHERE r.id = $3;`,
218
+ selectChunk: `SELECT encode(substring(b.data FROM $1::int FOR $2::int), 'base64') AS chunk ${D_FROM} WHERE r.id = $3;`,
219
219
  exists: `SELECT 1 FROM zerobucket_image_refs WHERE id = $1;`
220
220
  };
221
221
  var RETRYABLE_SQLSTATES = /* @__PURE__ */ new Set([
@@ -672,7 +672,7 @@ var PostgresBackend = class {
672
672
  `Image ${JSON.stringify(id)} was deleted while streaming (delivered ${delivered} of ${wanted} bytes). Pass connection with your own open transaction if you need a consistent read across concurrent writers.`
673
673
  );
674
674
  }
675
- const chunk = row.chunk;
675
+ const chunk = row.chunk == null ? null : Buffer.from(row.chunk, "base64");
676
676
  if (!chunk || chunk.length === 0) {
677
677
  throw new StorageError(
678
678
  `Image ${JSON.stringify(id)} ended early while streaming (delivered ${delivered} of ${wanted} bytes).`
@@ -996,6 +996,8 @@ var PostgresBackend = class {
996
996
  }
997
997
  // ---- helpers ---------------------------------------------------------
998
998
  hydrate(row, id) {
999
+ row.data = row.data_b64 == null ? null : Buffer.from(row.data_b64, "base64");
1000
+ delete row.data_b64;
999
1001
  return hydrateRow(row, id, this.opts.objectStorage);
1000
1002
  }
1001
1003
  /**
package/dist/index.cjs CHANGED
@@ -255,17 +255,17 @@ INSERT INTO zerobucket_images
255
255
  VALUES ($1, $2, $3, $4, $5, $6, $7)
256
256
  RETURNING id;`;
257
257
  var SELECT_FULL = `
258
- SELECT id, data, mime_type, original_filename, size_bytes, width, height,
258
+ SELECT id, encode(data, 'base64') AS data_b64, mime_type, original_filename, size_bytes, width, height,
259
259
  checksum_sha256, storage_backend, object_storage_bucket, object_storage_key
260
260
  FROM zerobucket_images WHERE id = $1;`;
261
261
  var SELECT_MANY = `
262
- SELECT id, data, mime_type, original_filename, size_bytes, width, height,
262
+ SELECT id, encode(data, 'base64') AS data_b64, mime_type, original_filename, size_bytes, width, height,
263
263
  checksum_sha256, storage_backend, object_storage_bucket, object_storage_key
264
264
  FROM zerobucket_images WHERE id = ANY($1::uuid[]);`;
265
265
  var SELECT_METADATA = `
266
266
  SELECT id, mime_type, original_filename, size_bytes, width, height, checksum_sha256
267
267
  FROM zerobucket_images WHERE id = $1;`;
268
- var SELECT_CHUNK = `SELECT substring(data FROM $1::int FOR $2::int) AS chunk FROM zerobucket_images WHERE id = $3;`;
268
+ var SELECT_CHUNK = `SELECT encode(substring(data FROM $1::int FOR $2::int), 'base64') AS chunk FROM zerobucket_images WHERE id = $3;`;
269
269
  var SELECT_STREAM_INFO = `SELECT size_bytes, storage_backend, object_storage_key FROM zerobucket_images WHERE id = $1;`;
270
270
  var DELETE_RETURNING = `DELETE FROM zerobucket_images WHERE id = $1 RETURNING storage_backend, object_storage_key;`;
271
271
  var DELETE_MANY = `DELETE FROM zerobucket_images WHERE id = ANY($1::uuid[]) RETURNING id, storage_backend, object_storage_key;`;
@@ -298,7 +298,7 @@ INSERT INTO zerobucket_blobs (checksum_sha256, data, mime_type, size_bytes, widt
298
298
  VALUES ($1, $2, $3, $4, $5, $6, $7)
299
299
  ON CONFLICT (checksum_sha256) DO UPDATE SET ref_count = zerobucket_blobs.ref_count + EXCLUDED.ref_count;`;
300
300
  var D_INSERT_REF = `INSERT INTO zerobucket_image_refs (checksum_sha256, original_filename) VALUES ($1, $2) RETURNING id;`;
301
- var D_COLS = `r.id, b.data, b.mime_type, r.original_filename, b.size_bytes, b.width, b.height, r.checksum_sha256,
301
+ var D_COLS = `r.id, encode(b.data, 'base64') AS data_b64, b.mime_type, r.original_filename, b.size_bytes, b.width, b.height, r.checksum_sha256,
302
302
  'postgres'::text AS storage_backend, NULL::text AS object_storage_bucket, NULL::text AS object_storage_key`;
303
303
  var D_FROM = `FROM zerobucket_image_refs r JOIN zerobucket_blobs b ON r.checksum_sha256 = b.checksum_sha256`;
304
304
  var D_DELETE_REF = `DELETE FROM zerobucket_image_refs WHERE id = $1 RETURNING checksum_sha256;`;
@@ -320,7 +320,7 @@ var DEDUP_SQL = {
320
320
  selectMany: `SELECT ${D_COLS} ${D_FROM} WHERE r.id = ANY($1::uuid[]);`,
321
321
  selectMetadata: `SELECT r.id, b.mime_type, r.original_filename, b.size_bytes, b.width, b.height, r.checksum_sha256 ${D_FROM} WHERE r.id = $1;`,
322
322
  selectStreamInfo: `SELECT b.size_bytes, 'postgres'::text AS storage_backend, NULL::text AS object_storage_key ${D_FROM} WHERE r.id = $1;`,
323
- selectChunk: `SELECT substring(b.data FROM $1::int FOR $2::int) AS chunk ${D_FROM} WHERE r.id = $3;`,
323
+ selectChunk: `SELECT encode(substring(b.data FROM $1::int FOR $2::int), 'base64') AS chunk ${D_FROM} WHERE r.id = $3;`,
324
324
  exists: `SELECT 1 FROM zerobucket_image_refs WHERE id = $1;`
325
325
  };
326
326
  var RETRYABLE_SQLSTATES = /* @__PURE__ */ new Set([
@@ -777,7 +777,7 @@ var PostgresBackend = class {
777
777
  `Image ${JSON.stringify(id)} was deleted while streaming (delivered ${delivered} of ${wanted} bytes). Pass connection with your own open transaction if you need a consistent read across concurrent writers.`
778
778
  );
779
779
  }
780
- const chunk = row.chunk;
780
+ const chunk = row.chunk == null ? null : Buffer.from(row.chunk, "base64");
781
781
  if (!chunk || chunk.length === 0) {
782
782
  throw new StorageError(
783
783
  `Image ${JSON.stringify(id)} ended early while streaming (delivered ${delivered} of ${wanted} bytes).`
@@ -1101,6 +1101,8 @@ var PostgresBackend = class {
1101
1101
  }
1102
1102
  // ---- helpers ---------------------------------------------------------
1103
1103
  hydrate(row, id) {
1104
+ row.data = row.data_b64 == null ? null : Buffer.from(row.data_b64, "base64");
1105
+ delete row.data_b64;
1104
1106
  return hydrateRow(row, id, this.opts.objectStorage);
1105
1107
  }
1106
1108
  /**
@@ -3197,14 +3199,16 @@ async function validateImage(data, opts) {
3197
3199
  if (!width || !height)
3198
3200
  throw new CorruptedImageError("Image has no readable dimensions");
3199
3201
  try {
3200
- await make().stats();
3202
+ await sharp(data, {
3203
+ limitInputPixels: maxPixels,
3204
+ failOn: "error",
3205
+ sequentialRead: true
3206
+ }).extract({ left: 0, top: 0, width: 1, height }).raw().toBuffer();
3201
3207
  } catch (exc) {
3202
3208
  if (isPixelLimit(exc)) throw new ImageTooLargeError(sizeBytes, maxBytes);
3203
3209
  throw new CorruptedImageError(
3204
- `Image data is truncated or corrupted: ${errMessage(exc)}`,
3205
- {
3206
- cause: exc
3207
- }
3210
+ `Image data is truncated or corrupted: ${decodeDetail(exc)}`,
3211
+ { cause: exc }
3208
3212
  );
3209
3213
  }
3210
3214
  return { mimeType: FORMAT_TO_MIME[format], width, height, sizeBytes };
@@ -3248,6 +3252,10 @@ async function validateHeic(data, {
3248
3252
  }
3249
3253
  return { mimeType: FORMAT_TO_MIME.heic, width, height, sizeBytes };
3250
3254
  }
3255
+ function decodeDetail(exc) {
3256
+ const msg3 = errMessage(exc).trim();
3257
+ return /^unknown error$/i.test(msg3) ? "the decoder stopped before the end of the image" : msg3;
3258
+ }
3251
3259
  function isPixelLimit(exc) {
3252
3260
  return /pixel limit/i.test(errMessage(exc));
3253
3261
  }