zerobucket 0.10.1 → 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
@@ -521,8 +527,8 @@ memory, event-loop impact, throughput, and the before and after of each release)
521
527
  files must be backed up with `.backup`, not copied. See the
522
528
  [Operations Guide](https://github.com/KedarGhadyalji/ZeroBucket/blob/main/docs/OPERATIONS.md).
523
529
  - **Dedup and tiering cannot be combined** (a shared blob has many owners): `dedup: true` with `objectStorage` throws.
524
- - **`migrate --to-dedup` is PostgreSQL-only**, as in the Python package. Tiered classic images cannot be migrated (see
525
- 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.
526
532
  - **HEIC can be read and converted from, but not written.** There is no HEVC encoder for Node, so `optimize` cannot
527
533
  produce HEIC.
528
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
@@ -1695,6 +1695,125 @@ var SQLiteBackend = class {
1695
1695
  throw wrap("Failed to check image existence", exc);
1696
1696
  }
1697
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
+ }
1698
1817
  // ---- maintenance (CLI) -----------------------------------------------
1699
1818
  async getInfo() {
1700
1819
  try {
@@ -1905,6 +2024,7 @@ CREATE TABLE IF NOT EXISTS zerobucket_images (
1905
2024
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`;
1906
2025
  var TIERING_CHECK = "chk_zerobucket_storage";
1907
2026
  var MIGRATE_LOCK = "zerobucket_migrate";
2027
+ var MIGRATE_DEDUP_LOCK = "zerobucket_migrate_dedup";
1908
2028
  var COLS2 = `id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, storage_backend, object_storage_bucket, object_storage_key`;
1909
2029
  var INSERT3 = `INSERT INTO zerobucket_images (id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
1910
2030
  var SELECT_FULL3 = `SELECT ${COLS2} FROM zerobucket_images WHERE id = ?`;
@@ -2264,15 +2384,31 @@ var MySQLBackend = class {
2264
2384
  /**
2265
2385
  * Run `work` on a pooled connection. `tx` wraps it in a transaction. Transient failures retry the
2266
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.
2267
2394
  */
2268
- async runPooled(operation, work, tx) {
2395
+ async runPooled(operation, work, tx, lock) {
2269
2396
  const start = performance.now();
2270
2397
  let attempt = 0;
2271
2398
  for (; ; ) {
2272
2399
  let conn;
2273
2400
  let destroy = false;
2401
+ let held = false;
2274
2402
  try {
2275
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
+ }
2276
2412
  if (tx) await conn.beginTransaction();
2277
2413
  const result = await work(conn);
2278
2414
  if (tx) await conn.commit();
@@ -2294,6 +2430,13 @@ var MySQLBackend = class {
2294
2430
  }
2295
2431
  } finally {
2296
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
+ }
2297
2440
  if (destroy) conn.destroy();
2298
2441
  else conn.release();
2299
2442
  }
@@ -2307,7 +2450,10 @@ var MySQLBackend = class {
2307
2450
  * connection given -> run once on it; no retry, and no commit/rollback of the caller's transaction.
2308
2451
  * connection absent -> pooled, retried (see runPooled).
2309
2452
  */
2310
- async run(operation, connection, work, { tx = false } = {}) {
2453
+ async run(operation, connection, work, {
2454
+ tx = false,
2455
+ lock
2456
+ } = {}) {
2311
2457
  if (connection) {
2312
2458
  if (!isMysqlConnection(connection)) {
2313
2459
  throw new TypeError(
@@ -2325,7 +2471,7 @@ var MySQLBackend = class {
2325
2471
  }
2326
2472
  }
2327
2473
  await this.ready();
2328
- return this.runPooled(operation, work, tx);
2474
+ return this.runPooled(operation, work, tx, lock);
2329
2475
  }
2330
2476
  /**
2331
2477
  * Make multi-statement work all-or-nothing on a CALLER-supplied connection.
@@ -2599,6 +2745,139 @@ var MySQLBackend = class {
2599
2745
  throw wrap("Failed to check image existence", exc);
2600
2746
  }
2601
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
+ }
2602
2881
  // ---- maintenance (CLI) -----------------------------------------------
2603
2882
  /** Storage statistics, or null if the tables do not exist yet (nothing is created). */
2604
2883
  async getInfo() {
@@ -3000,7 +3279,7 @@ Usage: zerobucket <command> [options]
3000
3279
  Commands:
3001
3280
  init Create the schema (tables and indexes) if missing.
3002
3281
  migrate Apply schema migrations (currently: same as init).
3003
- --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)
3004
3283
  --skip-tiered with --to-dedup: skip images tiered to object storage instead of refusing
3005
3284
  info Show storage stats: count, size, breakdown by format.
3006
3285
  verify Re-checksum stored images to detect corruption.
@@ -3167,8 +3446,7 @@ async function cmdMigrate(v, env, io) {
3167
3446
  throw usageError("Error: --skip-tiered only applies with --to-dedup.");
3168
3447
  if (v["to-dedup"]) {
3169
3448
  const target = resolveTarget(v, env);
3170
- if (target.kind !== "postgres")
3171
- throw usageError("Error: --to-dedup is only supported for PostgreSQL.");
3449
+ requireSqliteFile(target, "Nothing to migrate.");
3172
3450
  const backend = openBackend(target, { dedup: true, autoMigrate: true });
3173
3451
  await connect(backend, "connect");
3174
3452
  try {