zerobucket 0.6.1 → 0.8.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/README.md CHANGED
@@ -10,23 +10,25 @@ 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.6.x).** PostgreSQL **and SQLite**, each in classic and dedup modes, with S3-compatible
14
- > object-storage tiering, HEIC (iPhone photo) support and a `zerobucket` command line tool. MySQL is on the
15
- > roadmap (see [What's not here yet](#whats-not-here-yet)).
13
+ > **Status: early (0.8.x).** PostgreSQL, SQLite **and MySQL/MariaDB**, each in classic and dedup modes, with
14
+ > S3-compatible object-storage tiering, HEIC (iPhone photo) support and a `zerobucket` command line tool.
16
15
 
17
16
  ## Install
18
17
 
19
18
  ```bash
20
19
  npm install zerobucket pg sharp # PostgreSQL
21
20
  npm install zerobucket better-sqlite3 sharp # or SQLite (see below): no database server at all
21
+ npm install zerobucket mysql2 sharp # or MySQL / MariaDB (see below)
22
22
  npm install libheif-js # optional: accept HEIC (iPhone) photos, see below
23
23
  ```
24
24
 
25
- `pg` (the Postgres driver), `better-sqlite3` (SQLite) and `sharp` (image decoding/optimizing) are peer dependencies, so you
26
- control their versions. If you only store PDFs through a custom validator, `sharp` is not needed.
25
+ `pg` (the Postgres driver), `better-sqlite3` (SQLite), `mysql2` (MySQL/MariaDB) and `sharp` (image decoding/optimizing) are
26
+ peer dependencies, so you control their versions. If you only store PDFs through a custom validator, `sharp` is not needed.
27
27
  `sharp` must be **0.35.5 or newer**: older versions bundle libvips/libheif/librsvg builds with published high-severity
28
28
  advisories, and this package decodes untrusted uploads with it. A plain `npm install sharp` already gets a fixed version;
29
29
  if you pinned an older one, upgrade with `npm install sharp@latest`.
30
+ `mysql2` must be **3.23.1 or newer** for the same reason: every earlier release has a published advisory (the oldest range
31
+ includes critical ones), and 3.23.1 is the first that `npm audit` reports clean.
30
32
  For [object-storage tiering](#object-storage-tiering) also install `@aws-sdk/client-s3` (optional; loaded only when used).
31
33
  Requires Node 20+ (SQLite needs Node 22+, see below). Ships ESM and CommonJS with full types.
32
34
 
@@ -217,10 +219,13 @@ const id2 = await images.put(iphonePhoto, {
217
219
 
218
220
  The package installs a `zerobucket` command (`npx zerobucket ...`, or install globally with `npm i -g zerobucket`).
219
221
  It has the same commands, flags, messages and exit codes as the Python package's CLI, and additionally works with
220
- **SQLite** and **dedup mode**, and understands images that were tiered to object storage.
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).
221
225
 
222
226
  ```bash
223
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
224
229
  # SQLite instead: export ZEROBUCKET_SQLITE_PATH=./images.db # or: --sqlite PATH
225
230
 
226
231
  zerobucket init # create the schema (add --dedup for the dedup tables)
@@ -294,6 +299,72 @@ How it behaves in Node, and why:
294
299
  (`better-sqlite3` 12 declares Node 20 support, but it is untested here.)
295
300
  - Not available on SQLite: `migrateClassicToDedup()` (Python has it for Postgres only, too).
296
301
 
302
+ ## MySQL / MariaDB
303
+
304
+ ```ts
305
+ const images = new ZeroBucket({
306
+ mysql: "mysql://user:password@localhost:3306/mydb",
307
+ }); // npm install mysql2
308
+ // "mariadb://..." works too. Bring your own pool with ZeroBucket({ mysqlPool }) (never closed by close()).
309
+ ```
310
+
311
+ Same API as everywhere else: `put`, `get`, `getMany`, `putMany`, `delete`, `deleteMany`, `exists`, `metadata`, streaming
312
+ and ranges, access-control hooks, the optimize pipeline, custom validators, HEIC, `onOperation` metrics, retry with
313
+ backoff, pooling (`poolMaxSize`, `poolTimeoutMs`), **dedup mode** (`dedup: true`), **object-storage tiering** and
314
+ `connection` (your own transaction). The [`zerobucket` CLI](#command-line) works on it too: pass a `mysql://` or
315
+ `mariadb://` `--database-url`.
316
+
317
+ It uses the Python package's tables, byte for byte (`SHOW CREATE TABLE` is identical for the classic table and for both
318
+ dedup tables), so a Node app and a Python app can share one MySQL/MariaDB database. The conformance harness verifies both
319
+ directions in both modes, including tiered rows and shared dedup blobs.
320
+
321
+ As everywhere, dedup and tiering cannot be combined (`dedup: true` with `objectStorage` throws). Copying an existing
322
+ classic table into the dedup tables (`migrate --to-dedup`) is PostgreSQL-only, as in the Python package.
323
+
324
+ What is different from Postgres, and why (each verified against a real MariaDB 10.11):
325
+
326
+ - **No `RETURNING`.** MySQL 8.0 does not have it (MariaDB 10.5+ does, but one code path serves both), so `delete()` and
327
+ `deleteMany()` run `SELECT ... FOR UPDATE` and then `DELETE` in one transaction. That is also how a tiered row's object
328
+ gets cleaned up.
329
+ - **Timestamps are written as UTC strings** (`DATETIME(6)`, naive UTC, like the Python package), so your server's or your
330
+ process's timezone setting cannot change what is stored.
331
+ - **Pooling.** `mysql2` opens connections on demand, so `poolMinSize` is accepted for parity but there is no minimum to hold.
332
+ `mysql2` has no acquire timeout, so `poolTimeoutMs` is implemented here: waiting longer than that for a free connection
333
+ throws a clear `StorageError`, which is **not** retried (retrying would only wait again).
334
+ - **Retry** covers connection loss, lock-wait timeout (1205), deadlock (1213) and "too many connections". Every retry
335
+ re-runs the **whole** operation on a fresh connection after a full rollback, never a single statement, so a multi-statement
336
+ operation can never be half-applied. A connection that fails is destroyed, never returned to the pool.
337
+ - **Migration takes a server-side lock** (`GET_LOCK`), so many processes starting at once do not race on the DDL, and it
338
+ issues **no DDL at all** when the schema is current. (Even a no-op `ALTER TABLE` needs an exclusive metadata lock that
339
+ waits behind every open transaction, and everything else on that table then queues behind _it_.)
340
+ - **Dedup** stores each distinct image once (`zerobucket_blobs`, with a `ref_count`) and one row per image
341
+ (`zerobucket_image_refs`). Without `RETURNING`, a delete decrements with `UPDATE ... ref_count = ref_count - n` (which
342
+ takes the row lock) and then removes the blob once the count reaches zero; the foreign key makes deleting a blob that is
343
+ still referenced impossible. A batch groups identical images, touches each distinct blob **once, in sorted checksum
344
+ order**, and decrements in the same order, so overlapping batches cannot deadlock.
345
+ - **Batch deletes use one `SELECT ... FOR UPDATE` and then point deletes, never `DELETE ... WHERE id IN (...)`.** On
346
+ MariaDB 10.11, with a small table, the `IN` delete is planned as a full scan (`EXPLAIN` says `type=ALL`), and a scan
347
+ under REPEATABLE READ locks every row it passes, including rows another batch already holds. Two overlapping
348
+ `deleteMany` batches deadlocked in 126 of 300 transactions with an `IN` delete and in 0 with point deletes (also 0 with
349
+ a concurrent writer on the same blobs); `READ COMMITTED` and `FORCE INDEX` alone did not fix it. MySQL 8.0.46 plans the
350
+ same statement as a range over just the listed ids and did **not** deadlock in the same test. Point deletes do not
351
+ depend on the plan, so they are used on both.
352
+ - **Large images and `max_allowed_packet`.** An image bigger than the server's limit fails with an error that says so and
353
+ how to raise it (`SET GLOBAL max_allowed_packet = ...`). Images are sent as raw bytes, so the limit applies to the image
354
+ size, not twice that.
355
+ - **Tiering** locks only the image being moved (InnoDB row lock) for the duration of the upload: writes to _that_ image wait
356
+ (up to `innodb_lock_wait_timeout`, 50 s by default), every other row and every plain read is unaffected. Postgres behaves
357
+ the same way; SQLite has to lock the whole file.
358
+ - **`connection`** takes your own `mysql2/promise` connection. Multi-statement operations first check whether a transaction
359
+ is open: inside one they use a `SAVEPOINT` (a failure undoes only this call's work), in autocommit mode they open and
360
+ commit their own. The adapter never commits, rolls back or retries your transaction. Object cleanup after a `delete` is
361
+ skipped on your connection (it cannot know whether you will commit).
362
+ - **Streaming** uses ranged `SUBSTRING()` reads, so a delete mid-stream raises `StorageError` (like Postgres). Tiered images
363
+ stream with real S3 `Range` requests.
364
+ - **Tested on MariaDB 10.11 and MySQL 8.0.46** (the MySQL, dedup and CLI test suites, the Node/Python conformance harness
365
+ and the schema comparison all pass on both). Other versions are expected to behave the same but have not been run. Use
366
+ MariaDB 10.2.1+ or MySQL 8.0.16+: older servers accept the `CHECK` constraint syntax but ignore it.
367
+
297
368
  ## Deduplication
298
369
 
299
370
  Store identical bytes once. In dedup mode every `put()` still returns its own id and keeps its own filename,
@@ -391,12 +462,12 @@ same corpus of valid and corrupt files identically.
391
462
 
392
463
  ## What's not here yet
393
464
 
394
- Planned: **MySQL/MariaDB**.
395
- Tiering and dedup cannot be combined (same rule as the Python package).
465
+ Planned for MySQL/MariaDB: a MySQL-specific operations guide, and `migrate --to-dedup` (PostgreSQL-only today, as in the
466
+ Python package). Tiering and dedup cannot be combined (same rule as the Python package).
396
467
 
397
468
  ## Runtime notes
398
469
 
399
- 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
470
+ Node 20+ for Postgres and MySQL/MariaDB, Node 22+ for SQLite. Bun/Deno: expected to work, not yet tested. Edge runtimes (Cloudflare Workers, Vercel Edge) cannot open raw
400
471
  Postgres TCP connections through `pg` without a proxy such as Hyperdrive; use the Node runtime for these routes.
401
472
  In serverless, keep `poolMaxSize` small (1-2) or inject a pool shared across invocations.
402
473