zerobucket 0.10.0 → 0.11.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
@@ -243,7 +243,7 @@ zerobucket verify --sample 200 # ...or just a random sample (good for a nightly
243
243
  zerobucket tier IMAGE_ID --bucket my-images # move one image to S3
244
244
  zerobucket tier --older-than 90 --min-size 500000 --bucket my-images --dry-run # preview a bulk move
245
245
  zerobucket tier --all --limit 1000 --bucket my-images --endpoint-url https://<acct>.r2.cloudflarestorage.com
246
- zerobucket migrate --to-dedup # PostgreSQL: copy classic images into the dedup tables (non-destructive, re-runnable)
246
+ zerobucket migrate --to-dedup # copy classic images into the dedup tables (PostgreSQL, SQLite and MySQL; non-destructive, re-runnable)
247
247
  zerobucket migrate --to-dedup --skip-tiered # ...skipping images tiered to object storage instead of refusing
248
248
  ```
249
249
 
@@ -306,7 +306,8 @@ How it behaves in Node, and why:
306
306
  - **Node 22+ for SQLite.** `better-sqlite3` 13 requires Node 22 or newer and crashes on older versions. It ships its Windows,
307
307
  macOS and Linux binaries inside the npm package, so no compiler or Visual Studio is needed. Postgres users can stay on Node 20.
308
308
  (`better-sqlite3` 12 declares Node 20 support, but it is untested here.)
309
- - Not available on SQLite: `migrateClassicToDedup()` (Python has it for Postgres only, too).
309
+ - `migrateClassicToDedup()` works on SQLite (since 0.11.0): one `BEGIN IMMEDIATE` transaction, so it is all-or-nothing, and it
310
+ holds SQLite's single write lock for the whole copy, so other writers to the file wait until it finishes (readers do not).
310
311
 
311
312
  ## MySQL / MariaDB
312
313
 
@@ -328,7 +329,9 @@ dedup tables), so a Node app and a Python app can share one MySQL/MariaDB databa
328
329
  directions in both modes, including tiered rows and shared dedup blobs.
329
330
 
330
331
  As everywhere, dedup and tiering cannot be combined (`dedup: true` with `objectStorage` throws). Copying an existing
331
- classic table into the dedup tables (`migrate --to-dedup`) is PostgreSQL-only, as in the Python package.
332
+ classic table into the dedup tables (`migrate --to-dedup`) works on MySQL/MariaDB since 0.11.0: one transaction (all-or-nothing)
333
+ and a named lock (`GET_LOCK`) so two migrations started at once cannot race. Timestamps are copied as text, never as JS dates,
334
+ because `DATETIME(6)` keeps microseconds and a JS date does not.
332
335
 
333
336
  What is different from Postgres, and why (each verified against a real MariaDB 10.11):
334
337
 
@@ -400,7 +403,8 @@ await images.delete(b); // last reference gone: blob removed
400
403
  **Moving existing data in:** `await images.migrateClassicToDedup()` copies every classic image into the dedup tables,
401
404
  keeping each id, and never modifies the classic table. It runs in small batches (bounded memory), is safe to re-run
402
405
  (already-migrated ids are skipped and counted), and refuses up front if any classic image is tiered to object storage
403
- (their bytes are not in the database to copy, and dedup mode has no tiering), changing nothing. If you would rather
406
+ (their bytes are not in the database to copy, and dedup mode has no tiering), changing nothing. Works on PostgreSQL, SQLite
407
+ and MySQL/MariaDB (SQLite and MySQL since 0.11.0). If you would rather
404
408
  migrate everything else, pass `{ skipTiered: true }` (CLI: `--skip-tiered`): tiered images are left untouched in the
405
409
  classic table, counted in `tieredSkipped`, and **will not exist in the dedup tables**, so only use it if you can live
406
410
  without them there.
@@ -453,29 +457,31 @@ same corpus of valid and corrupt files identically.
453
457
 
454
458
  ## Differences from the Python package
455
459
 
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
- | 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 |
460
+ Python column = zerobucket on PyPI **0.30.0 or later** (several of its behaviours below were fixed or added in 0.25.1, 0.26.0 and
461
+ 0.27.0 to 0.30.0; older versions differ).
462
+
463
+ | | Python | npm |
464
+ | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
465
+ | API | sync + async clients | one async client |
466
+ | Async clients: dedup and tiering | every async adapter (since 0.30.0) | one async client, always |
467
+ | `optimize` | Pillow; EXIF stripped, not applied | sharp; EXIF **applied then stripped**; transparency → **white** for JPEG |
468
+ | 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 |
469
+ | 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 |
470
+ | `putMany` inserts | pipelined | one transaction, statements run sequentially |
471
+ | Formats | JPEG, PNG, WebP, HEIC (`[heic]` extra) | JPEG, PNG, WebP, HEIC (optional `libheif-js`) |
472
+ | 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` |
473
+ | Event duration | `duration_seconds` | `durationMs` |
474
+ | `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 |
475
+ | `migrateClassicToDedup`, other behaviour | batched, safe to re-run (server-side cursor) | batched, safe to re-run (keyset paging); concurrent runs cannot race (PostgreSQL advisory lock, MySQL named lock, SQLite write lock) |
476
+ | `migrateClassicToDedup`, databases | PostgreSQL only | PostgreSQL, SQLite **and** MySQL/MariaDB (SQLite and MySQL since 0.11.0) |
477
+ | 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) |
478
+ | SQLite `onOperation` / busy retry | `on_operation` events and busy retry, sync and async (since 0.27.0) | `onOperation` events and async `SQLITE_BUSY` retry (`busyTimeoutMs`) |
479
+ | 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 |
480
+ | MySQL `onOperation` / retry | yes, sync and async (since 0.27.0) | yes (same client for everything) |
481
+ | CLI | PostgreSQL, SQLite **and** MySQL/MariaDB, classic **and** dedup; `verify --bucket` checks tiered images (since 0.28.0); `migrate --to-dedup` on PostgreSQL only | PostgreSQL, SQLite **and** MySQL/MariaDB, classic **and** dedup; `verify --bucket` also checks tiered images; `migrate --to-dedup` on all three |
482
+ | `connection=` without an open transaction | multi-statement ops rely on the driver's implicit transaction | always atomic (own transaction or savepoint) |
483
+ | S3 delete failure after `delete()` | never fails the delete; reported as an `object_storage_delete` event (since 0.29.0) | never fails the delete; reported via `onOperation` |
484
+ | Options | snake_case kwargs | camelCase options object |
479
485
 
480
486
  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
481
487
  `before_get`/`beforeGet` hook. Both packages now leave a tiered image's object in place on `delete(id, connection=...)` (a rolled-back transaction can
@@ -485,18 +491,22 @@ MySQL `ALTER TABLE` at startup when the schema is current.
485
491
  ## Performance
486
492
 
487
493
  Measured on one single-core container, so treat it as directional; the full tables (PostgreSQL, SQLite, MariaDB, per size,
488
- memory, event-loop impact, throughput, and the 0.9.0 to 0.10.0 before and after) are in the
494
+ memory, event-loop impact, throughput, and the before and after of each release) are in the
489
495
  [benchmark results](https://github.com/KedarGhadyalji/ZeroBucket/blob/main/benchmarks/node/RESULTS.md).
490
496
 
491
- | Image size | `put()` (PostgreSQL) | `get()` | 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 50ms | A few MB of transient memory for baseline JPEG and PNG. |
496
- | about 10MB | about 350ms | about 135ms | Default limit is 8 MiB. Memory depends on the format (see below). |
497
+ | Image size | `put()` (PostgreSQL) | `get()` (PostgreSQL) | Notes |
498
+ | ------------ | -------------------- | -------------------- | ----------------------------------------------------------------- |
499
+ | up to ~500KB | under 20ms | under 5ms | Feels like any other write. |
500
+ | 0.5-1.5MB | about 20-45ms | under 10ms | Fine for uploads, including in a loop. |
501
+ | about 5MB | about 170ms | about 35ms | A few MB of transient memory to write a baseline JPEG or PNG. |
502
+ | about 10MB | about 350ms | about 70ms | Default limit is 8 MiB. Memory depends on the format (see below). |
497
503
 
498
504
  - 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
505
  roughly half of `put()` on PostgreSQL.
506
+ - Since 0.10.1, reading from PostgreSQL asks for base64 instead of PostgreSQL's default hex text: about 30% faster for images over
507
+ 2MB, and `get()` needs 37-47% less memory (about 6.5 times the image size, down from about 10). Streaming, which is how
508
+ `createImageHandler` serves images, is bounded in memory either way. Between roughly 0.5MB and 1.3MB there is no reliable
509
+ difference (a cost of under a millisecond may exist). SQLite and MySQL/MariaDB were already fast readers and are unchanged.
500
510
  - The decode runs off the main thread: the event loop stalled at most about 14ms during a 10MB `put()`.
501
511
  - Transient memory depends on the format. A baseline JPEG or non-interlaced PNG validates in streaming fashion (about 11MB for a
502
512
  45-megapixel JPEG, down from about 148MB in 0.9.x). WebP, progressive JPEG and interlaced PNG still need roughly the decoded
@@ -517,8 +527,8 @@ memory, event-loop impact, throughput, and the 0.9.0 to 0.10.0 before and after)
517
527
  files must be backed up with `.backup`, not copied. See the
518
528
  [Operations Guide](https://github.com/KedarGhadyalji/ZeroBucket/blob/main/docs/OPERATIONS.md).
519
529
  - **Dedup and tiering cannot be combined** (a shared blob has many owners): `dedup: true` with `objectStorage` throws.
520
- - **`migrate --to-dedup` is PostgreSQL-only**, as in the Python package. Tiered classic images cannot be migrated (see
521
- Deduplication for `skipTiered`).
530
+ - **Tiered classic images cannot be migrated** by `migrate --to-dedup` (see Deduplication for `skipTiered`). The migration itself
531
+ works on PostgreSQL, SQLite and MySQL/MariaDB since 0.11.0; the Python package still has it for PostgreSQL only.
522
532
  - **HEIC can be read and converted from, but not written.** There is no HEVC encoder for Node, so `optimize` cannot
523
533
  produce HEIC.
524
534
  - **SQLite allows one writer at a time**; lock waits are async and never block the event loop, but write-heavy
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
  /**
@@ -1693,6 +1695,125 @@ var SQLiteBackend = class {
1693
1695
  throw wrap("Failed to check image existence", exc);
1694
1696
  }
1695
1697
  }
1698
+ // ---- migration: classic -> dedup --------------------------------------
1699
+ /**
1700
+ * One-time, NON-DESTRUCTIVE copy of every row in the classic `zerobucket_images` table into this
1701
+ * dedup instance's tables, preserving every id (external references keep working) and
1702
+ * deduplicating identical content on the way. The classic table is never modified. Same contract
1703
+ * as `PostgresBackend.migrateClassicToDedup()`.
1704
+ *
1705
+ * Rows are copied in small batches (bounded memory instead of loading the whole table);
1706
+ * re-running is safe (already-migrated ids are skipped and reported, not an error). Classic rows
1707
+ * already tiered to object storage are refused up front with a clear message (their bytes are
1708
+ * not in the database to copy) unless `skipTiered: true`, which skips and counts them instead.
1709
+ * All-or-nothing: ONE `BEGIN IMMEDIATE` transaction, so a failure part-way leaves the dedup
1710
+ * tables exactly as they were. That transaction holds SQLite's single write lock for the whole
1711
+ * copy, so other writers to this database file wait until it finishes (readers are not blocked).
1712
+ */
1713
+ async migrateClassicToDedup(opts = {}) {
1714
+ if (!this.dedup) {
1715
+ throw new StorageError(
1716
+ "migrateClassicToDedup() must be called on an instance created with dedup: true."
1717
+ );
1718
+ }
1719
+ try {
1720
+ return await this.execute(
1721
+ "migrate_to_dedup",
1722
+ void 0,
1723
+ "write",
1724
+ (db) => {
1725
+ if (!db.prepare(
1726
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'zerobucket_images'"
1727
+ ).get()) {
1728
+ throw new StorageError(
1729
+ "No classic zerobucket_images table found in this database -- nothing to migrate."
1730
+ );
1731
+ }
1732
+ let tieredSkipped = 0;
1733
+ const hasTierCol = db.prepare("PRAGMA table_info(zerobucket_images)").all().some((c) => c.name === "storage_backend");
1734
+ if (hasTierCol) {
1735
+ const tiered = db.prepare(
1736
+ "SELECT count(*) AS n FROM zerobucket_images WHERE storage_backend <> 'sqlite'"
1737
+ ).get().n;
1738
+ if (tiered > 0 && !opts.skipTiered) {
1739
+ throw new StorageError(
1740
+ `${tiered} classic image(s) are tiered to object storage, so their bytes are not in the database to migrate. Dedup mode does not support tiering. Migrate only an un-tiered table, bring those images back first, or pass skipTiered: true (CLI: --skip-tiered) to skip them and migrate the rest.`
1741
+ );
1742
+ }
1743
+ tieredSkipped = tiered;
1744
+ }
1745
+ const onlyInDb = hasTierCol && tieredSkipped > 0;
1746
+ const seen = /* @__PURE__ */ new Set();
1747
+ let migrated = 0;
1748
+ let already = 0;
1749
+ let cursor = null;
1750
+ const BATCH = 20;
1751
+ const columns = "id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, created_at, updated_at";
1752
+ for (; ; ) {
1753
+ const page = cursor ? db.prepare(
1754
+ `SELECT ${columns} FROM zerobucket_images WHERE (created_at, id) > (?, ?)${onlyInDb ? " AND storage_backend = 'sqlite'" : ""} ORDER BY created_at, id LIMIT ${BATCH}`
1755
+ ).all(cursor.createdAt, cursor.id) : db.prepare(
1756
+ `SELECT ${columns} FROM zerobucket_images${onlyInDb ? " WHERE storage_backend = 'sqlite'" : ""} ORDER BY created_at, id LIMIT ${BATCH}`
1757
+ ).all();
1758
+ if (page.length === 0) break;
1759
+ const last = page[page.length - 1];
1760
+ if (cursor && cursor.createdAt === last.created_at && cursor.id === last.id) {
1761
+ throw new StorageError(
1762
+ "Migration made no progress (keyset cursor did not advance); aborting instead of looping."
1763
+ );
1764
+ }
1765
+ cursor = { createdAt: last.created_at, id: last.id };
1766
+ const have = new Set(
1767
+ db.prepare(
1768
+ `SELECT id FROM zerobucket_image_refs WHERE id IN (${placeholders(page.length)})`
1769
+ ).all(...page.map((r) => r.id)).map((r) => r.id)
1770
+ );
1771
+ const fresh = page.filter((r) => !have.has(r.id));
1772
+ already += page.length - fresh.length;
1773
+ const groups = /* @__PURE__ */ new Map();
1774
+ for (const r of fresh) {
1775
+ const g = groups.get(r.checksum_sha256);
1776
+ if (g) g.n += 1;
1777
+ else groups.set(r.checksum_sha256, { row: r, n: 1 });
1778
+ }
1779
+ for (const checksum of [...groups.keys()].sort()) {
1780
+ const { row, n } = groups.get(checksum);
1781
+ db.prepare(D_UPSERT).run(
1782
+ checksum,
1783
+ row.data,
1784
+ row.mime_type,
1785
+ row.size_bytes,
1786
+ row.width,
1787
+ row.height,
1788
+ n,
1789
+ row.created_at
1790
+ );
1791
+ }
1792
+ for (const r of fresh) {
1793
+ db.prepare(D_INSERT_REF2).run(
1794
+ r.id,
1795
+ r.checksum_sha256,
1796
+ r.original_filename,
1797
+ r.created_at,
1798
+ r.updated_at
1799
+ );
1800
+ seen.add(r.checksum_sha256);
1801
+ migrated += 1;
1802
+ }
1803
+ }
1804
+ return {
1805
+ imagesMigrated: migrated,
1806
+ distinctBlobsCreated: seen.size,
1807
+ duplicateReferencesFound: migrated - seen.size,
1808
+ alreadyMigrated: already,
1809
+ tieredSkipped
1810
+ };
1811
+ }
1812
+ );
1813
+ } catch (exc) {
1814
+ throw wrap("Migration to dedup failed", exc);
1815
+ }
1816
+ }
1696
1817
  // ---- maintenance (CLI) -----------------------------------------------
1697
1818
  async getInfo() {
1698
1819
  try {
@@ -1903,6 +2024,7 @@ CREATE TABLE IF NOT EXISTS zerobucket_images (
1903
2024
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`;
1904
2025
  var TIERING_CHECK = "chk_zerobucket_storage";
1905
2026
  var MIGRATE_LOCK = "zerobucket_migrate";
2027
+ var MIGRATE_DEDUP_LOCK = "zerobucket_migrate_dedup";
1906
2028
  var COLS2 = `id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, storage_backend, object_storage_bucket, object_storage_key`;
1907
2029
  var INSERT3 = `INSERT INTO zerobucket_images (id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
1908
2030
  var SELECT_FULL3 = `SELECT ${COLS2} FROM zerobucket_images WHERE id = ?`;
@@ -2262,15 +2384,31 @@ var MySQLBackend = class {
2262
2384
  /**
2263
2385
  * Run `work` on a pooled connection. `tx` wraps it in a transaction. Transient failures retry the
2264
2386
  * WHOLE closure on a fresh connection after a full rollback.
2387
+ *
2388
+ * `lock` serializes this work against other callers holding the same named lock (`GET_LOCK`). The
2389
+ * ORDER matters and is the whole point: the lock is taken BEFORE the transaction begins (so the
2390
+ * transaction's snapshot is taken after any earlier holder has finished) and released AFTER the
2391
+ * transaction commits or rolls back (so a later holder can never start while this one's rows are
2392
+ * still uncommitted and invisible to it). Releasing inside `work`, before the commit, lets a second
2393
+ * holder read a stale snapshot and collide on primary keys.
2265
2394
  */
2266
- async runPooled(operation, work, tx) {
2395
+ async runPooled(operation, work, tx, lock) {
2267
2396
  const start = performance.now();
2268
2397
  let attempt = 0;
2269
2398
  for (; ; ) {
2270
2399
  let conn;
2271
2400
  let destroy = false;
2401
+ let held = false;
2272
2402
  try {
2273
2403
  conn = await this.acquire();
2404
+ if (lock) {
2405
+ const got = (await conn.query("SELECT GET_LOCK(?, ?) AS got", [
2406
+ lock.name,
2407
+ lock.timeoutSec
2408
+ ]))[0][0]?.got;
2409
+ if (got !== 1) throw new StorageError(lock.timeoutMessage);
2410
+ held = true;
2411
+ }
2274
2412
  if (tx) await conn.beginTransaction();
2275
2413
  const result = await work(conn);
2276
2414
  if (tx) await conn.commit();
@@ -2292,6 +2430,13 @@ var MySQLBackend = class {
2292
2430
  }
2293
2431
  } finally {
2294
2432
  if (conn) {
2433
+ if (held && lock && !destroy) {
2434
+ try {
2435
+ await conn.query("SELECT RELEASE_LOCK(?)", [lock.name]);
2436
+ } catch {
2437
+ destroy = true;
2438
+ }
2439
+ }
2295
2440
  if (destroy) conn.destroy();
2296
2441
  else conn.release();
2297
2442
  }
@@ -2305,7 +2450,10 @@ var MySQLBackend = class {
2305
2450
  * connection given -> run once on it; no retry, and no commit/rollback of the caller's transaction.
2306
2451
  * connection absent -> pooled, retried (see runPooled).
2307
2452
  */
2308
- async run(operation, connection, work, { tx = false } = {}) {
2453
+ async run(operation, connection, work, {
2454
+ tx = false,
2455
+ lock
2456
+ } = {}) {
2309
2457
  if (connection) {
2310
2458
  if (!isMysqlConnection(connection)) {
2311
2459
  throw new TypeError(
@@ -2323,7 +2471,7 @@ var MySQLBackend = class {
2323
2471
  }
2324
2472
  }
2325
2473
  await this.ready();
2326
- return this.runPooled(operation, work, tx);
2474
+ return this.runPooled(operation, work, tx, lock);
2327
2475
  }
2328
2476
  /**
2329
2477
  * Make multi-statement work all-or-nothing on a CALLER-supplied connection.
@@ -2597,6 +2745,139 @@ var MySQLBackend = class {
2597
2745
  throw wrap("Failed to check image existence", exc);
2598
2746
  }
2599
2747
  }
2748
+ // ---- migration: classic -> dedup --------------------------------------
2749
+ /**
2750
+ * One-time, NON-DESTRUCTIVE copy of every row in the classic `zerobucket_images` table into this
2751
+ * dedup instance's tables, preserving every id (external references keep working) and
2752
+ * deduplicating identical content on the way. The classic table is never modified. Same contract
2753
+ * as `PostgresBackend.migrateClassicToDedup()`.
2754
+ *
2755
+ * Rows are copied in small batches (bounded memory instead of loading the whole table);
2756
+ * re-running is safe (already-migrated ids are skipped and reported, not an error). Classic rows
2757
+ * already tiered to object storage are refused up front with a clear message (their bytes are
2758
+ * not in the database to copy) unless `skipTiered: true`, which skips and counts them instead.
2759
+ * All-or-nothing: ONE transaction, so a failure part-way leaves the dedup tables exactly as they
2760
+ * were. A named lock (`GET_LOCK`) serializes two migrations started at once; it is taken before the
2761
+ * transaction and released only after it commits (see `runPooled`), so a second migration always sees
2762
+ * the first one's rows.
2763
+ */
2764
+ async migrateClassicToDedup(opts = {}) {
2765
+ if (!this.dedup) {
2766
+ throw new StorageError(
2767
+ "migrateClassicToDedup() must be called on an instance created with dedup: true."
2768
+ );
2769
+ }
2770
+ try {
2771
+ return await this.run(
2772
+ "migrate_to_dedup",
2773
+ void 0,
2774
+ (q) => this.copyClassicToDedup(q, opts),
2775
+ {
2776
+ tx: true,
2777
+ lock: {
2778
+ name: MIGRATE_DEDUP_LOCK,
2779
+ timeoutSec: 30,
2780
+ timeoutMessage: "Timed out waiting for another migration to dedup to finish."
2781
+ }
2782
+ }
2783
+ );
2784
+ } catch (exc) {
2785
+ throw wrap("Migration to dedup failed", exc);
2786
+ }
2787
+ }
2788
+ async copyClassicToDedup(q, opts) {
2789
+ const rowsOf = async (sql, params) => (await q.query(sql, params))[0];
2790
+ if ((await rowsOf(
2791
+ "SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'zerobucket_images'"
2792
+ )).length === 0) {
2793
+ throw new StorageError(
2794
+ "No classic zerobucket_images table found in this database -- nothing to migrate."
2795
+ );
2796
+ }
2797
+ let tieredSkipped = 0;
2798
+ const hasTierCol = (await rowsOf(
2799
+ "SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'zerobucket_images' AND COLUMN_NAME = 'storage_backend'"
2800
+ )).length > 0;
2801
+ if (hasTierCol) {
2802
+ const tiered = Number(
2803
+ (await rowsOf(
2804
+ `SELECT COUNT(*) AS n FROM zerobucket_images WHERE storage_backend <> '${LOCAL}'`
2805
+ ))[0].n
2806
+ );
2807
+ if (tiered > 0 && !opts.skipTiered) {
2808
+ throw new StorageError(
2809
+ `${tiered} classic image(s) are tiered to object storage, so their bytes are not in the database to migrate. Dedup mode does not support tiering. Migrate only an un-tiered table, bring those images back first, or pass skipTiered: true (CLI: --skip-tiered) to skip them and migrate the rest.`
2810
+ );
2811
+ }
2812
+ tieredSkipped = tiered;
2813
+ }
2814
+ const onlyInDb = hasTierCol && tieredSkipped > 0;
2815
+ const seen = /* @__PURE__ */ new Set();
2816
+ let migrated = 0;
2817
+ let already = 0;
2818
+ let cursor = null;
2819
+ const BATCH = 20;
2820
+ const columns = "id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, CAST(created_at AS CHAR) AS created_at_txt, CAST(updated_at AS CHAR) AS updated_at_txt";
2821
+ for (; ; ) {
2822
+ const page = cursor ? await rowsOf(
2823
+ `SELECT ${columns} FROM zerobucket_images WHERE (created_at, id) > (?, ?)${onlyInDb ? ` AND storage_backend = '${LOCAL}'` : ""} ORDER BY created_at, id LIMIT ${BATCH}`,
2824
+ [cursor.createdAt, cursor.id]
2825
+ ) : await rowsOf(
2826
+ `SELECT ${columns} FROM zerobucket_images${onlyInDb ? ` WHERE storage_backend = '${LOCAL}'` : ""} ORDER BY created_at, id LIMIT ${BATCH}`
2827
+ );
2828
+ if (page.length === 0) break;
2829
+ const last = page[page.length - 1];
2830
+ if (cursor && cursor.createdAt === last.created_at_txt && cursor.id === last.id) {
2831
+ throw new StorageError(
2832
+ "Migration made no progress (keyset cursor did not advance); aborting instead of looping."
2833
+ );
2834
+ }
2835
+ cursor = { createdAt: last.created_at_txt, id: last.id };
2836
+ const have = new Set(
2837
+ (await rowsOf("SELECT id FROM zerobucket_image_refs WHERE id IN (?)", [
2838
+ page.map((r) => r.id)
2839
+ ])).map((r) => String(r.id))
2840
+ );
2841
+ const fresh = page.filter((r) => !have.has(String(r.id)));
2842
+ already += page.length - fresh.length;
2843
+ const groups = /* @__PURE__ */ new Map();
2844
+ for (const r of fresh) {
2845
+ const g = groups.get(r.checksum_sha256);
2846
+ if (g) g.n += 1;
2847
+ else groups.set(r.checksum_sha256, { row: r, n: 1 });
2848
+ }
2849
+ for (const checksum of [...groups.keys()].sort()) {
2850
+ const { row, n } = groups.get(checksum);
2851
+ await q.query(upsertBlobSql(n), [
2852
+ checksum,
2853
+ row.data,
2854
+ row.mime_type,
2855
+ row.size_bytes,
2856
+ row.width,
2857
+ row.height,
2858
+ row.created_at_txt
2859
+ ]);
2860
+ }
2861
+ for (const r of fresh) {
2862
+ await q.query(D_INSERT_REF3, [
2863
+ r.id,
2864
+ r.checksum_sha256,
2865
+ r.original_filename,
2866
+ r.created_at_txt,
2867
+ r.updated_at_txt
2868
+ ]);
2869
+ seen.add(r.checksum_sha256);
2870
+ migrated += 1;
2871
+ }
2872
+ }
2873
+ return {
2874
+ imagesMigrated: migrated,
2875
+ distinctBlobsCreated: seen.size,
2876
+ duplicateReferencesFound: migrated - seen.size,
2877
+ alreadyMigrated: already,
2878
+ tieredSkipped
2879
+ };
2880
+ }
2600
2881
  // ---- maintenance (CLI) -----------------------------------------------
2601
2882
  /** Storage statistics, or null if the tables do not exist yet (nothing is created). */
2602
2883
  async getInfo() {
@@ -2998,7 +3279,7 @@ Usage: zerobucket <command> [options]
2998
3279
  Commands:
2999
3280
  init Create the schema (tables and indexes) if missing.
3000
3281
  migrate Apply schema migrations (currently: same as init).
3001
- --to-dedup copy every classic image into the dedup tables (PostgreSQL only; non-destructive)
3282
+ --to-dedup copy every classic image into the dedup tables (PostgreSQL, SQLite and MySQL; non-destructive)
3002
3283
  --skip-tiered with --to-dedup: skip images tiered to object storage instead of refusing
3003
3284
  info Show storage stats: count, size, breakdown by format.
3004
3285
  verify Re-checksum stored images to detect corruption.
@@ -3165,8 +3446,7 @@ async function cmdMigrate(v, env, io) {
3165
3446
  throw usageError("Error: --skip-tiered only applies with --to-dedup.");
3166
3447
  if (v["to-dedup"]) {
3167
3448
  const target = resolveTarget(v, env);
3168
- if (target.kind !== "postgres")
3169
- throw usageError("Error: --to-dedup is only supported for PostgreSQL.");
3449
+ requireSqliteFile(target, "Nothing to migrate.");
3170
3450
  const backend = openBackend(target, { dedup: true, autoMigrate: true });
3171
3451
  await connect(backend, "connect");
3172
3452
  try {