zerobucket 0.7.0 → 0.8.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
@@ -10,8 +10,7 @@ This is the Node.js/TypeScript sibling of the [Python package](https://pypi.org/
10
10
  **It uses the exact same table**, so a Node service and a Python service can share one database
11
11
  and read each other's images.
12
12
 
13
- > **Status: early (0.7.x).** PostgreSQL **and SQLite**, each in classic and dedup modes, plus **MySQL/MariaDB** (classic
14
- > mode; dedup and the CLI are not available for MySQL yet, see [What's not here yet](#whats-not-here-yet)), with
13
+ > **Status: early (0.8.x).** PostgreSQL, SQLite **and MySQL/MariaDB**, each in classic and dedup modes, with
15
14
  > S3-compatible object-storage tiering, HEIC (iPhone photo) support and a `zerobucket` command line tool.
16
15
 
17
16
  ## Install
@@ -220,10 +219,13 @@ const id2 = await images.put(iphonePhoto, {
220
219
 
221
220
  The package installs a `zerobucket` command (`npx zerobucket ...`, or install globally with `npm i -g zerobucket`).
222
221
  It has the same commands, flags, messages and exit codes as the Python package's CLI, and additionally works with
223
- **SQLite** and **dedup mode**, and understands images that were tiered to object storage. It does not know MySQL/MariaDB yet.
222
+ **SQLite**, **MySQL/MariaDB** (a `mysql://` or `mariadb://` `--database-url`, or `ZEROBUCKET_DATABASE_URL`) and **dedup
223
+ mode**, and understands images that were tiered to object storage. On MySQL the on-disk size is read from
224
+ `information_schema` and is approximate (MySQL 8 caches those statistics for up to 24 hours).
224
225
 
225
226
  ```bash
226
227
  export ZEROBUCKET_DATABASE_URL=postgresql://user:pass@localhost/mydb # or: --database-url URL
228
+ # MySQL/MariaDB: export ZEROBUCKET_DATABASE_URL=mysql://user:pass@localhost:3306/mydb # mariadb:// works too
227
229
  # SQLite instead: export ZEROBUCKET_SQLITE_PATH=./images.db # or: --sqlite PATH
228
230
 
229
231
  zerobucket init # create the schema (add --dedup for the dedup tables)
@@ -234,6 +236,7 @@ zerobucket tier IMAGE_ID --bucket my-images # move one i
234
236
  zerobucket tier --older-than 90 --min-size 500000 --bucket my-images --dry-run # preview a bulk move
235
237
  zerobucket tier --all --limit 1000 --bucket my-images --endpoint-url https://<acct>.r2.cloudflarestorage.com
236
238
  zerobucket migrate --to-dedup # PostgreSQL: copy classic images into the dedup tables (non-destructive, re-runnable)
239
+ zerobucket migrate --to-dedup --skip-tiered # ...skipping images tiered to object storage instead of refusing
237
240
  ```
238
241
 
239
242
  - **Exit codes:** `0` success, `1` problems found or an operation failed, `2` usage or connection error. That makes
@@ -308,12 +311,16 @@ const images = new ZeroBucket({
308
311
 
309
312
  Same API as everywhere else: `put`, `get`, `getMany`, `putMany`, `delete`, `deleteMany`, `exists`, `metadata`, streaming
310
313
  and ranges, access-control hooks, the optimize pipeline, custom validators, HEIC, `onOperation` metrics, retry with
311
- backoff, pooling (`poolMaxSize`, `poolTimeoutMs`), **object-storage tiering** and `connection` (your own transaction).
312
- It uses the Python package's table, byte for byte (`SHOW CREATE TABLE` is identical), so a Node app and a Python app can
313
- share one MySQL/MariaDB database (the conformance harness verifies both directions, including tiered rows).
314
+ backoff, pooling (`poolMaxSize`, `poolTimeoutMs`), **dedup mode** (`dedup: true`), **object-storage tiering** and
315
+ `connection` (your own transaction). The [`zerobucket` CLI](#command-line) works on it too: pass a `mysql://` or
316
+ `mariadb://` `--database-url`.
314
317
 
315
- **Not available on MySQL yet:** dedup mode (`dedup: true` throws, rather than being ignored) and the `zerobucket` command
316
- line tool (it knows PostgreSQL and SQLite). The Python package has both for MySQL.
318
+ It uses the Python package's tables, byte for byte (`SHOW CREATE TABLE` is identical for the classic table and for both
319
+ dedup tables), so a Node app and a Python app can share one MySQL/MariaDB database. The conformance harness verifies both
320
+ directions in both modes, including tiered rows and shared dedup blobs.
321
+
322
+ As everywhere, dedup and tiering cannot be combined (`dedup: true` with `objectStorage` throws). Copying an existing
323
+ classic table into the dedup tables (`migrate --to-dedup`) is PostgreSQL-only, as in the Python package.
317
324
 
318
325
  What is different from Postgres, and why (each verified against a real MariaDB 10.11):
319
326
 
@@ -331,6 +338,18 @@ What is different from Postgres, and why (each verified against a real MariaDB 1
331
338
  - **Migration takes a server-side lock** (`GET_LOCK`), so many processes starting at once do not race on the DDL, and it
332
339
  issues **no DDL at all** when the schema is current. (Even a no-op `ALTER TABLE` needs an exclusive metadata lock that
333
340
  waits behind every open transaction, and everything else on that table then queues behind _it_.)
341
+ - **Dedup** stores each distinct image once (`zerobucket_blobs`, with a `ref_count`) and one row per image
342
+ (`zerobucket_image_refs`). Without `RETURNING`, a delete decrements with `UPDATE ... ref_count = ref_count - n` (which
343
+ takes the row lock) and then removes the blob once the count reaches zero; the foreign key makes deleting a blob that is
344
+ still referenced impossible. A batch groups identical images, touches each distinct blob **once, in sorted checksum
345
+ order**, and decrements in the same order, so overlapping batches cannot deadlock.
346
+ - **Batch deletes use one `SELECT ... FOR UPDATE` and then point deletes, never `DELETE ... WHERE id IN (...)`.** On
347
+ MariaDB 10.11, with a small table, the `IN` delete is planned as a full scan (`EXPLAIN` says `type=ALL`), and a scan
348
+ under REPEATABLE READ locks every row it passes, including rows another batch already holds. Two overlapping
349
+ `deleteMany` batches deadlocked in 126 of 300 transactions with an `IN` delete and in 0 with point deletes (also 0 with
350
+ a concurrent writer on the same blobs); `READ COMMITTED` and `FORCE INDEX` alone did not fix it. MySQL 8.0.46 plans the
351
+ same statement as a range over just the listed ids and did **not** deadlock in the same test. Point deletes do not
352
+ depend on the plan, so they are used on both.
334
353
  - **Large images and `max_allowed_packet`.** An image bigger than the server's limit fails with an error that says so and
335
354
  how to raise it (`SET GLOBAL max_allowed_packet = ...`). Images are sent as raw bytes, so the limit applies to the image
336
355
  size, not twice that.
@@ -343,8 +362,9 @@ What is different from Postgres, and why (each verified against a real MariaDB 1
343
362
  skipped on your connection (it cannot know whether you will commit).
344
363
  - **Streaming** uses ranged `SUBSTRING()` reads, so a delete mid-stream raises `StorageError` (like Postgres). Tiered images
345
364
  stream with real S3 `Range` requests.
346
- - **Tested on MariaDB 10.11.** Real MySQL 8.x is expected to behave the same (the SQL avoids MariaDB-only features) but has
347
- not been run here. Use MariaDB 10.2.1+ or MySQL 8.0.16+: older servers accept the `CHECK` constraint syntax but ignore it.
365
+ - **Tested on MariaDB 10.11 and MySQL 8.0.46** (the MySQL, dedup and CLI test suites, the Node/Python conformance harness
366
+ and the schema comparison all pass on both). Other versions are expected to behave the same but have not been run. Use
367
+ MariaDB 10.2.1+ or MySQL 8.0.16+: older servers accept the `CHECK` constraint syntax but ignore it.
348
368
 
349
369
  ## Deduplication
350
370
 
@@ -371,7 +391,11 @@ await images.delete(b); // last reference gone: blob removed
371
391
 
372
392
  **Moving existing data in:** `await images.migrateClassicToDedup()` copies every classic image into the dedup tables,
373
393
  keeping each id, and never modifies the classic table. It runs in small batches (bounded memory), is safe to re-run
374
- (already-migrated ids are skipped and counted), and refuses up front if any classic image is tiered to object storage.
394
+ (already-migrated ids are skipped and counted), and refuses up front if any classic image is tiered to object storage
395
+ (their bytes are not in the database to copy, and dedup mode has no tiering), changing nothing. If you would rather
396
+ migrate everything else, pass `{ skipTiered: true }` (CLI: `--skip-tiered`): tiered images are left untouched in the
397
+ classic table, counted in `tieredSkipped`, and **will not exist in the dedup tables**, so only use it if you can live
398
+ without them there.
375
399
 
376
400
  ## Object-storage tiering
377
401
 
@@ -421,30 +445,52 @@ same corpus of valid and corrupt files identically.
421
445
 
422
446
  ## Differences from the Python package
423
447
 
424
- | | Python | npm |
425
- | ---------------------------------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
426
- | API | sync + async clients | one async client |
427
- | Malformed id (`"abc"`) | `StorageError` from Postgres | treated as not found (`ImageNotFoundError`, no DB round trip), friendlier for `/images/:id` routes |
428
- | `optimize` | Pillow; EXIF stripped, not applied | sharp; EXIF **applied then stripped**; transparency → **white** for JPEG |
429
- | `putMany` inserts | pipelined | one transaction, statements run sequentially |
430
- | Formats | JPEG, PNG, WebP, HEIC (`[heic]` extra) | JPEG, PNG, WebP, HEIC (optional `libheif-js`) |
431
- | 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` |
432
- | Event duration | `duration_seconds` | `durationMs` |
433
- | `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 |
434
- | Dedup `putMany` / `deleteMany` locking | arbitrary order (overlapping batches can deadlock) | sorted checksum order (cannot deadlock) |
435
- | 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) |
436
- | SQLite `onOperation` / busy retry | none | `onOperation` events and async `SQLITE_BUSY` retry (`busyTimeoutMs`) |
437
- | 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 |
438
- | CLI | PostgreSQL, classic mode only; `verify` crashes on tiered rows | PostgreSQL **and** SQLite, classic **and** dedup; `info`/`verify` understand tiered rows; `migrate --to-dedup` |
439
- | `connection=` without an open transaction | multi-statement ops rely on the driver's implicit transaction | always atomic (own transaction or savepoint) |
440
- | `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) |
441
- | S3 delete failure after `delete()` | raises after the row is gone | never fails the delete; reported via `onOperation` |
442
- | Options | snake_case kwargs | camelCase options object |
443
-
444
- ## What's not here yet
445
-
446
- Planned for MySQL/MariaDB: **dedup mode**, the **`zerobucket` CLI**, and a MySQL-specific operations guide.
447
- Tiering and dedup cannot be combined (same rule as the Python package).
448
+ Python column = zerobucket on PyPI **0.25.1 or later** (several of its behaviours below were fixed in 0.25.1; older versions
449
+ differ).
450
+
451
+ | | Python | npm |
452
+ | -------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
453
+ | API | sync + async clients | one async client |
454
+ | Malformed id (`"abc"`) | `StorageError` from Postgres | treated as not found (`ImageNotFoundError`, no DB round trip), friendlier for `/images/:id` routes |
455
+ | `optimize` | Pillow; EXIF stripped, not applied | sharp; EXIF **applied then stripped**; transparency → **white** for JPEG |
456
+ | `putMany` inserts | pipelined | one transaction, statements run sequentially |
457
+ | Formats | JPEG, PNG, WebP, HEIC (`[heic]` extra) | JPEG, PNG, WebP, HEIC (optional `libheif-js`) |
458
+ | 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` |
459
+ | Event duration | `duration_seconds` | `durationMs` |
460
+ | `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 |
461
+ | `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) |
462
+ | 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) |
463
+ | SQLite `onOperation` / busy retry | none | `onOperation` events and async `SQLITE_BUSY` retry (`busyTimeoutMs`) |
464
+ | 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 |
465
+ | MySQL `onOperation` / retry | sync `MySQLBackend` yes; `AsyncMySQLBackend` none | yes (same client for everything) |
466
+ | 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` |
467
+ | `connection=` without an open transaction | multi-statement ops rely on the driver's implicit transaction | always atomic (own transaction or savepoint) |
468
+ | S3 delete failure after `delete()` | raises after the row is gone | never fails the delete; reported via `onOperation` |
469
+ | Options | snake_case kwargs | camelCase options object |
470
+
471
+ Both packages now leave a tiered image's object in place on `delete(id, connection=...)` (a rolled-back transaction can
472
+ never lose data), take dedup batch locks in sorted checksum order (overlapping batches cannot deadlock), and skip the
473
+ MySQL `ALTER TABLE` at startup when the schema is current.
474
+
475
+ ## Limitations (read before using in production)
476
+
477
+ - **Images live in your database.** That is the point (one backup, one transaction, no extra service), but it means
478
+ this is not built for very large files or high-volume media: every image goes through the database and counts toward
479
+ its size, its backups and its replication. The default size limit is 8 MiB (`maxBytes`). For large video or
480
+ millions of big files, use object storage directly.
481
+ - **Backups and space need attention.** A whole-database dump now includes every image, and deleting images does not
482
+ give disk space back on its own (PostgreSQL needs autovacuum, MySQL `OPTIMIZE TABLE`, SQLite `VACUUM`). SQLite
483
+ files must be backed up with `.backup`, not copied. See the
484
+ [Operations Guide](https://github.com/KedarGhadyalji/ZeroBucket/blob/main/docs/OPERATIONS.md).
485
+ - **Dedup and tiering cannot be combined** (a shared blob has many owners): `dedup: true` with `objectStorage` throws.
486
+ - **`migrate --to-dedup` is PostgreSQL-only**, as in the Python package. Tiered classic images cannot be migrated (see
487
+ Deduplication for `skipTiered`).
488
+ - **HEIC can be read and converted from, but not written.** There is no HEVC encoder for Node, so `optimize` cannot
489
+ produce HEIC.
490
+ - **SQLite allows one writer at a time**; lock waits are async and never block the event loop, but write-heavy
491
+ workloads belong on PostgreSQL or MySQL/MariaDB.
492
+ - **Tested versions:** CI runs PostgreSQL 16 and MariaDB 10.11; MySQL 8.0.46 was also run by hand. Other versions are
493
+ expected to behave the same but have not been run.
448
494
 
449
495
  ## Runtime notes
450
496