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 +38 -30
- package/dist/bin.js +8 -6
- package/dist/index.cjs +19 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +19 -11
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
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
|
-
|
|
|
460
|
-
|
|
|
461
|
-
| API
|
|
462
|
-
| `optimize`
|
|
463
|
-
| JPEG encoder in `optimize`
|
|
464
|
-
|
|
|
465
|
-
|
|
|
466
|
-
|
|
|
467
|
-
|
|
|
468
|
-
|
|
|
469
|
-
| `migrateClassicToDedup`,
|
|
470
|
-
|
|
|
471
|
-
| SQLite `
|
|
472
|
-
| SQLite
|
|
473
|
-
|
|
|
474
|
-
|
|
|
475
|
-
| `
|
|
476
|
-
|
|
|
477
|
-
|
|
|
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()`
|
|
491
|
-
| ------------ | -------------------- |
|
|
492
|
-
| up to ~500KB | under
|
|
493
|
-
| 0.5-1.5MB | about
|
|
494
|
-
| about 5MB | about
|
|
495
|
-
| about 10MB | about
|
|
496
|
-
|
|
497
|
-
- `put()`
|
|
498
|
-
|
|
499
|
-
-
|
|
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
|
|
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: ${
|
|
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
|
}
|