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/dist/index.d.cts CHANGED
@@ -250,6 +250,21 @@ declare class SQLiteBackend implements StorageBackend {
250
250
  delete(id: string, { connection }?: CallOptions): Promise<boolean>;
251
251
  deleteMany(ids: string[], { connection }?: CallOptions): Promise<string[]>;
252
252
  exists(id: string, { connection }?: CallOptions): Promise<boolean>;
253
+ /**
254
+ * One-time, NON-DESTRUCTIVE copy of every row in the classic `zerobucket_images` table into this
255
+ * dedup instance's tables, preserving every id (external references keep working) and
256
+ * deduplicating identical content on the way. The classic table is never modified. Same contract
257
+ * as `PostgresBackend.migrateClassicToDedup()`.
258
+ *
259
+ * Rows are copied in small batches (bounded memory instead of loading the whole table);
260
+ * re-running is safe (already-migrated ids are skipped and reported, not an error). Classic rows
261
+ * already tiered to object storage are refused up front with a clear message (their bytes are
262
+ * not in the database to copy) unless `skipTiered: true`, which skips and counts them instead.
263
+ * All-or-nothing: ONE `BEGIN IMMEDIATE` transaction, so a failure part-way leaves the dedup
264
+ * tables exactly as they were. That transaction holds SQLite's single write lock for the whole
265
+ * copy, so other writers to this database file wait until it finishes (readers are not blocked).
266
+ */
267
+ migrateClassicToDedup(opts?: MigrateToDedupOptions): Promise<MigrateToDedupResult>;
253
268
  getInfo(): Promise<StorageInfo | null>;
254
269
  listImages(o?: ImageListOptions): Promise<ImageListEntry[]>;
255
270
  /**
@@ -327,6 +342,13 @@ declare class MySQLBackend implements StorageBackend {
327
342
  /**
328
343
  * Run `work` on a pooled connection. `tx` wraps it in a transaction. Transient failures retry the
329
344
  * WHOLE closure on a fresh connection after a full rollback.
345
+ *
346
+ * `lock` serializes this work against other callers holding the same named lock (`GET_LOCK`). The
347
+ * ORDER matters and is the whole point: the lock is taken BEFORE the transaction begins (so the
348
+ * transaction's snapshot is taken after any earlier holder has finished) and released AFTER the
349
+ * transaction commits or rolls back (so a later holder can never start while this one's rows are
350
+ * still uncommitted and invisible to it). Releasing inside `work`, before the commit, lets a second
351
+ * holder read a stale snapshot and collide on primary keys.
330
352
  */
331
353
  private runPooled;
332
354
  /**
@@ -354,6 +376,23 @@ declare class MySQLBackend implements StorageBackend {
354
376
  delete(id: string, { connection }?: CallOptions): Promise<boolean>;
355
377
  deleteMany(ids: string[], { connection }?: CallOptions): Promise<string[]>;
356
378
  exists(id: string, { connection }?: CallOptions): Promise<boolean>;
379
+ /**
380
+ * One-time, NON-DESTRUCTIVE copy of every row in the classic `zerobucket_images` table into this
381
+ * dedup instance's tables, preserving every id (external references keep working) and
382
+ * deduplicating identical content on the way. The classic table is never modified. Same contract
383
+ * as `PostgresBackend.migrateClassicToDedup()`.
384
+ *
385
+ * Rows are copied in small batches (bounded memory instead of loading the whole table);
386
+ * re-running is safe (already-migrated ids are skipped and reported, not an error). Classic rows
387
+ * already tiered to object storage are refused up front with a clear message (their bytes are
388
+ * not in the database to copy) unless `skipTiered: true`, which skips and counts them instead.
389
+ * All-or-nothing: ONE transaction, so a failure part-way leaves the dedup tables exactly as they
390
+ * were. A named lock (`GET_LOCK`) serializes two migrations started at once; it is taken before the
391
+ * transaction and released only after it commits (see `runPooled`), so a second migration always sees
392
+ * the first one's rows.
393
+ */
394
+ migrateClassicToDedup(opts?: MigrateToDedupOptions): Promise<MigrateToDedupResult>;
395
+ private copyClassicToDedup;
357
396
  /** Storage statistics, or null if the tables do not exist yet (nothing is created). */
358
397
  getInfo(): Promise<StorageInfo | null>;
359
398
  /** Ids matching the filters: oldest first, or a random sample. Used by the CLI's verify and tier. */
package/dist/index.d.ts CHANGED
@@ -250,6 +250,21 @@ declare class SQLiteBackend implements StorageBackend {
250
250
  delete(id: string, { connection }?: CallOptions): Promise<boolean>;
251
251
  deleteMany(ids: string[], { connection }?: CallOptions): Promise<string[]>;
252
252
  exists(id: string, { connection }?: CallOptions): Promise<boolean>;
253
+ /**
254
+ * One-time, NON-DESTRUCTIVE copy of every row in the classic `zerobucket_images` table into this
255
+ * dedup instance's tables, preserving every id (external references keep working) and
256
+ * deduplicating identical content on the way. The classic table is never modified. Same contract
257
+ * as `PostgresBackend.migrateClassicToDedup()`.
258
+ *
259
+ * Rows are copied in small batches (bounded memory instead of loading the whole table);
260
+ * re-running is safe (already-migrated ids are skipped and reported, not an error). Classic rows
261
+ * already tiered to object storage are refused up front with a clear message (their bytes are
262
+ * not in the database to copy) unless `skipTiered: true`, which skips and counts them instead.
263
+ * All-or-nothing: ONE `BEGIN IMMEDIATE` transaction, so a failure part-way leaves the dedup
264
+ * tables exactly as they were. That transaction holds SQLite's single write lock for the whole
265
+ * copy, so other writers to this database file wait until it finishes (readers are not blocked).
266
+ */
267
+ migrateClassicToDedup(opts?: MigrateToDedupOptions): Promise<MigrateToDedupResult>;
253
268
  getInfo(): Promise<StorageInfo | null>;
254
269
  listImages(o?: ImageListOptions): Promise<ImageListEntry[]>;
255
270
  /**
@@ -327,6 +342,13 @@ declare class MySQLBackend implements StorageBackend {
327
342
  /**
328
343
  * Run `work` on a pooled connection. `tx` wraps it in a transaction. Transient failures retry the
329
344
  * WHOLE closure on a fresh connection after a full rollback.
345
+ *
346
+ * `lock` serializes this work against other callers holding the same named lock (`GET_LOCK`). The
347
+ * ORDER matters and is the whole point: the lock is taken BEFORE the transaction begins (so the
348
+ * transaction's snapshot is taken after any earlier holder has finished) and released AFTER the
349
+ * transaction commits or rolls back (so a later holder can never start while this one's rows are
350
+ * still uncommitted and invisible to it). Releasing inside `work`, before the commit, lets a second
351
+ * holder read a stale snapshot and collide on primary keys.
330
352
  */
331
353
  private runPooled;
332
354
  /**
@@ -354,6 +376,23 @@ declare class MySQLBackend implements StorageBackend {
354
376
  delete(id: string, { connection }?: CallOptions): Promise<boolean>;
355
377
  deleteMany(ids: string[], { connection }?: CallOptions): Promise<string[]>;
356
378
  exists(id: string, { connection }?: CallOptions): Promise<boolean>;
379
+ /**
380
+ * One-time, NON-DESTRUCTIVE copy of every row in the classic `zerobucket_images` table into this
381
+ * dedup instance's tables, preserving every id (external references keep working) and
382
+ * deduplicating identical content on the way. The classic table is never modified. Same contract
383
+ * as `PostgresBackend.migrateClassicToDedup()`.
384
+ *
385
+ * Rows are copied in small batches (bounded memory instead of loading the whole table);
386
+ * re-running is safe (already-migrated ids are skipped and reported, not an error). Classic rows
387
+ * already tiered to object storage are refused up front with a clear message (their bytes are
388
+ * not in the database to copy) unless `skipTiered: true`, which skips and counts them instead.
389
+ * All-or-nothing: ONE transaction, so a failure part-way leaves the dedup tables exactly as they
390
+ * were. A named lock (`GET_LOCK`) serializes two migrations started at once; it is taken before the
391
+ * transaction and released only after it commits (see `runPooled`), so a second migration always sees
392
+ * the first one's rows.
393
+ */
394
+ migrateClassicToDedup(opts?: MigrateToDedupOptions): Promise<MigrateToDedupResult>;
395
+ private copyClassicToDedup;
357
396
  /** Storage statistics, or null if the tables do not exist yet (nothing is created). */
358
397
  getInfo(): Promise<StorageInfo | null>;
359
398
  /** Ids matching the filters: oldest first, or a random sample. Used by the CLI's verify and tier. */
package/dist/index.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() {