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/dist/index.cjs CHANGED
@@ -1800,6 +1800,125 @@ var SQLiteBackend = class {
1800
1800
  throw wrap("Failed to check image existence", exc);
1801
1801
  }
1802
1802
  }
1803
+ // ---- migration: classic -> dedup --------------------------------------
1804
+ /**
1805
+ * One-time, NON-DESTRUCTIVE copy of every row in the classic `zerobucket_images` table into this
1806
+ * dedup instance's tables, preserving every id (external references keep working) and
1807
+ * deduplicating identical content on the way. The classic table is never modified. Same contract
1808
+ * as `PostgresBackend.migrateClassicToDedup()`.
1809
+ *
1810
+ * Rows are copied in small batches (bounded memory instead of loading the whole table);
1811
+ * re-running is safe (already-migrated ids are skipped and reported, not an error). Classic rows
1812
+ * already tiered to object storage are refused up front with a clear message (their bytes are
1813
+ * not in the database to copy) unless `skipTiered: true`, which skips and counts them instead.
1814
+ * All-or-nothing: ONE `BEGIN IMMEDIATE` transaction, so a failure part-way leaves the dedup
1815
+ * tables exactly as they were. That transaction holds SQLite's single write lock for the whole
1816
+ * copy, so other writers to this database file wait until it finishes (readers are not blocked).
1817
+ */
1818
+ async migrateClassicToDedup(opts = {}) {
1819
+ if (!this.dedup) {
1820
+ throw new StorageError(
1821
+ "migrateClassicToDedup() must be called on an instance created with dedup: true."
1822
+ );
1823
+ }
1824
+ try {
1825
+ return await this.execute(
1826
+ "migrate_to_dedup",
1827
+ void 0,
1828
+ "write",
1829
+ (db) => {
1830
+ if (!db.prepare(
1831
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'zerobucket_images'"
1832
+ ).get()) {
1833
+ throw new StorageError(
1834
+ "No classic zerobucket_images table found in this database -- nothing to migrate."
1835
+ );
1836
+ }
1837
+ let tieredSkipped = 0;
1838
+ const hasTierCol = db.prepare("PRAGMA table_info(zerobucket_images)").all().some((c) => c.name === "storage_backend");
1839
+ if (hasTierCol) {
1840
+ const tiered = db.prepare(
1841
+ "SELECT count(*) AS n FROM zerobucket_images WHERE storage_backend <> 'sqlite'"
1842
+ ).get().n;
1843
+ if (tiered > 0 && !opts.skipTiered) {
1844
+ throw new StorageError(
1845
+ `${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.`
1846
+ );
1847
+ }
1848
+ tieredSkipped = tiered;
1849
+ }
1850
+ const onlyInDb = hasTierCol && tieredSkipped > 0;
1851
+ const seen = /* @__PURE__ */ new Set();
1852
+ let migrated = 0;
1853
+ let already = 0;
1854
+ let cursor = null;
1855
+ const BATCH = 20;
1856
+ const columns = "id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, created_at, updated_at";
1857
+ for (; ; ) {
1858
+ const page = cursor ? db.prepare(
1859
+ `SELECT ${columns} FROM zerobucket_images WHERE (created_at, id) > (?, ?)${onlyInDb ? " AND storage_backend = 'sqlite'" : ""} ORDER BY created_at, id LIMIT ${BATCH}`
1860
+ ).all(cursor.createdAt, cursor.id) : db.prepare(
1861
+ `SELECT ${columns} FROM zerobucket_images${onlyInDb ? " WHERE storage_backend = 'sqlite'" : ""} ORDER BY created_at, id LIMIT ${BATCH}`
1862
+ ).all();
1863
+ if (page.length === 0) break;
1864
+ const last = page[page.length - 1];
1865
+ if (cursor && cursor.createdAt === last.created_at && cursor.id === last.id) {
1866
+ throw new StorageError(
1867
+ "Migration made no progress (keyset cursor did not advance); aborting instead of looping."
1868
+ );
1869
+ }
1870
+ cursor = { createdAt: last.created_at, id: last.id };
1871
+ const have = new Set(
1872
+ db.prepare(
1873
+ `SELECT id FROM zerobucket_image_refs WHERE id IN (${placeholders(page.length)})`
1874
+ ).all(...page.map((r) => r.id)).map((r) => r.id)
1875
+ );
1876
+ const fresh = page.filter((r) => !have.has(r.id));
1877
+ already += page.length - fresh.length;
1878
+ const groups = /* @__PURE__ */ new Map();
1879
+ for (const r of fresh) {
1880
+ const g = groups.get(r.checksum_sha256);
1881
+ if (g) g.n += 1;
1882
+ else groups.set(r.checksum_sha256, { row: r, n: 1 });
1883
+ }
1884
+ for (const checksum of [...groups.keys()].sort()) {
1885
+ const { row, n } = groups.get(checksum);
1886
+ db.prepare(D_UPSERT).run(
1887
+ checksum,
1888
+ row.data,
1889
+ row.mime_type,
1890
+ row.size_bytes,
1891
+ row.width,
1892
+ row.height,
1893
+ n,
1894
+ row.created_at
1895
+ );
1896
+ }
1897
+ for (const r of fresh) {
1898
+ db.prepare(D_INSERT_REF2).run(
1899
+ r.id,
1900
+ r.checksum_sha256,
1901
+ r.original_filename,
1902
+ r.created_at,
1903
+ r.updated_at
1904
+ );
1905
+ seen.add(r.checksum_sha256);
1906
+ migrated += 1;
1907
+ }
1908
+ }
1909
+ return {
1910
+ imagesMigrated: migrated,
1911
+ distinctBlobsCreated: seen.size,
1912
+ duplicateReferencesFound: migrated - seen.size,
1913
+ alreadyMigrated: already,
1914
+ tieredSkipped
1915
+ };
1916
+ }
1917
+ );
1918
+ } catch (exc) {
1919
+ throw wrap("Migration to dedup failed", exc);
1920
+ }
1921
+ }
1803
1922
  // ---- maintenance (CLI) -----------------------------------------------
1804
1923
  async getInfo() {
1805
1924
  try {
@@ -2010,6 +2129,7 @@ CREATE TABLE IF NOT EXISTS zerobucket_images (
2010
2129
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`;
2011
2130
  var TIERING_CHECK = "chk_zerobucket_storage";
2012
2131
  var MIGRATE_LOCK = "zerobucket_migrate";
2132
+ var MIGRATE_DEDUP_LOCK = "zerobucket_migrate_dedup";
2013
2133
  var COLS2 = `id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, storage_backend, object_storage_bucket, object_storage_key`;
2014
2134
  var INSERT3 = `INSERT INTO zerobucket_images (id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
2015
2135
  var SELECT_FULL3 = `SELECT ${COLS2} FROM zerobucket_images WHERE id = ?`;
@@ -2369,15 +2489,31 @@ var MySQLBackend = class {
2369
2489
  /**
2370
2490
  * Run `work` on a pooled connection. `tx` wraps it in a transaction. Transient failures retry the
2371
2491
  * WHOLE closure on a fresh connection after a full rollback.
2492
+ *
2493
+ * `lock` serializes this work against other callers holding the same named lock (`GET_LOCK`). The
2494
+ * ORDER matters and is the whole point: the lock is taken BEFORE the transaction begins (so the
2495
+ * transaction's snapshot is taken after any earlier holder has finished) and released AFTER the
2496
+ * transaction commits or rolls back (so a later holder can never start while this one's rows are
2497
+ * still uncommitted and invisible to it). Releasing inside `work`, before the commit, lets a second
2498
+ * holder read a stale snapshot and collide on primary keys.
2372
2499
  */
2373
- async runPooled(operation, work, tx) {
2500
+ async runPooled(operation, work, tx, lock) {
2374
2501
  const start = performance.now();
2375
2502
  let attempt = 0;
2376
2503
  for (; ; ) {
2377
2504
  let conn;
2378
2505
  let destroy = false;
2506
+ let held = false;
2379
2507
  try {
2380
2508
  conn = await this.acquire();
2509
+ if (lock) {
2510
+ const got = (await conn.query("SELECT GET_LOCK(?, ?) AS got", [
2511
+ lock.name,
2512
+ lock.timeoutSec
2513
+ ]))[0][0]?.got;
2514
+ if (got !== 1) throw new StorageError(lock.timeoutMessage);
2515
+ held = true;
2516
+ }
2381
2517
  if (tx) await conn.beginTransaction();
2382
2518
  const result = await work(conn);
2383
2519
  if (tx) await conn.commit();
@@ -2399,6 +2535,13 @@ var MySQLBackend = class {
2399
2535
  }
2400
2536
  } finally {
2401
2537
  if (conn) {
2538
+ if (held && lock && !destroy) {
2539
+ try {
2540
+ await conn.query("SELECT RELEASE_LOCK(?)", [lock.name]);
2541
+ } catch {
2542
+ destroy = true;
2543
+ }
2544
+ }
2402
2545
  if (destroy) conn.destroy();
2403
2546
  else conn.release();
2404
2547
  }
@@ -2412,7 +2555,10 @@ var MySQLBackend = class {
2412
2555
  * connection given -> run once on it; no retry, and no commit/rollback of the caller's transaction.
2413
2556
  * connection absent -> pooled, retried (see runPooled).
2414
2557
  */
2415
- async run(operation, connection, work, { tx = false } = {}) {
2558
+ async run(operation, connection, work, {
2559
+ tx = false,
2560
+ lock
2561
+ } = {}) {
2416
2562
  if (connection) {
2417
2563
  if (!isMysqlConnection(connection)) {
2418
2564
  throw new TypeError(
@@ -2430,7 +2576,7 @@ var MySQLBackend = class {
2430
2576
  }
2431
2577
  }
2432
2578
  await this.ready();
2433
- return this.runPooled(operation, work, tx);
2579
+ return this.runPooled(operation, work, tx, lock);
2434
2580
  }
2435
2581
  /**
2436
2582
  * Make multi-statement work all-or-nothing on a CALLER-supplied connection.
@@ -2704,6 +2850,139 @@ var MySQLBackend = class {
2704
2850
  throw wrap("Failed to check image existence", exc);
2705
2851
  }
2706
2852
  }
2853
+ // ---- migration: classic -> dedup --------------------------------------
2854
+ /**
2855
+ * One-time, NON-DESTRUCTIVE copy of every row in the classic `zerobucket_images` table into this
2856
+ * dedup instance's tables, preserving every id (external references keep working) and
2857
+ * deduplicating identical content on the way. The classic table is never modified. Same contract
2858
+ * as `PostgresBackend.migrateClassicToDedup()`.
2859
+ *
2860
+ * Rows are copied in small batches (bounded memory instead of loading the whole table);
2861
+ * re-running is safe (already-migrated ids are skipped and reported, not an error). Classic rows
2862
+ * already tiered to object storage are refused up front with a clear message (their bytes are
2863
+ * not in the database to copy) unless `skipTiered: true`, which skips and counts them instead.
2864
+ * All-or-nothing: ONE transaction, so a failure part-way leaves the dedup tables exactly as they
2865
+ * were. A named lock (`GET_LOCK`) serializes two migrations started at once; it is taken before the
2866
+ * transaction and released only after it commits (see `runPooled`), so a second migration always sees
2867
+ * the first one's rows.
2868
+ */
2869
+ async migrateClassicToDedup(opts = {}) {
2870
+ if (!this.dedup) {
2871
+ throw new StorageError(
2872
+ "migrateClassicToDedup() must be called on an instance created with dedup: true."
2873
+ );
2874
+ }
2875
+ try {
2876
+ return await this.run(
2877
+ "migrate_to_dedup",
2878
+ void 0,
2879
+ (q) => this.copyClassicToDedup(q, opts),
2880
+ {
2881
+ tx: true,
2882
+ lock: {
2883
+ name: MIGRATE_DEDUP_LOCK,
2884
+ timeoutSec: 30,
2885
+ timeoutMessage: "Timed out waiting for another migration to dedup to finish."
2886
+ }
2887
+ }
2888
+ );
2889
+ } catch (exc) {
2890
+ throw wrap("Migration to dedup failed", exc);
2891
+ }
2892
+ }
2893
+ async copyClassicToDedup(q, opts) {
2894
+ const rowsOf = async (sql, params) => (await q.query(sql, params))[0];
2895
+ if ((await rowsOf(
2896
+ "SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'zerobucket_images'"
2897
+ )).length === 0) {
2898
+ throw new StorageError(
2899
+ "No classic zerobucket_images table found in this database -- nothing to migrate."
2900
+ );
2901
+ }
2902
+ let tieredSkipped = 0;
2903
+ const hasTierCol = (await rowsOf(
2904
+ "SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'zerobucket_images' AND COLUMN_NAME = 'storage_backend'"
2905
+ )).length > 0;
2906
+ if (hasTierCol) {
2907
+ const tiered = Number(
2908
+ (await rowsOf(
2909
+ `SELECT COUNT(*) AS n FROM zerobucket_images WHERE storage_backend <> '${LOCAL}'`
2910
+ ))[0].n
2911
+ );
2912
+ if (tiered > 0 && !opts.skipTiered) {
2913
+ throw new StorageError(
2914
+ `${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.`
2915
+ );
2916
+ }
2917
+ tieredSkipped = tiered;
2918
+ }
2919
+ const onlyInDb = hasTierCol && tieredSkipped > 0;
2920
+ const seen = /* @__PURE__ */ new Set();
2921
+ let migrated = 0;
2922
+ let already = 0;
2923
+ let cursor = null;
2924
+ const BATCH = 20;
2925
+ 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";
2926
+ for (; ; ) {
2927
+ const page = cursor ? await rowsOf(
2928
+ `SELECT ${columns} FROM zerobucket_images WHERE (created_at, id) > (?, ?)${onlyInDb ? ` AND storage_backend = '${LOCAL}'` : ""} ORDER BY created_at, id LIMIT ${BATCH}`,
2929
+ [cursor.createdAt, cursor.id]
2930
+ ) : await rowsOf(
2931
+ `SELECT ${columns} FROM zerobucket_images${onlyInDb ? ` WHERE storage_backend = '${LOCAL}'` : ""} ORDER BY created_at, id LIMIT ${BATCH}`
2932
+ );
2933
+ if (page.length === 0) break;
2934
+ const last = page[page.length - 1];
2935
+ if (cursor && cursor.createdAt === last.created_at_txt && cursor.id === last.id) {
2936
+ throw new StorageError(
2937
+ "Migration made no progress (keyset cursor did not advance); aborting instead of looping."
2938
+ );
2939
+ }
2940
+ cursor = { createdAt: last.created_at_txt, id: last.id };
2941
+ const have = new Set(
2942
+ (await rowsOf("SELECT id FROM zerobucket_image_refs WHERE id IN (?)", [
2943
+ page.map((r) => r.id)
2944
+ ])).map((r) => String(r.id))
2945
+ );
2946
+ const fresh = page.filter((r) => !have.has(String(r.id)));
2947
+ already += page.length - fresh.length;
2948
+ const groups = /* @__PURE__ */ new Map();
2949
+ for (const r of fresh) {
2950
+ const g = groups.get(r.checksum_sha256);
2951
+ if (g) g.n += 1;
2952
+ else groups.set(r.checksum_sha256, { row: r, n: 1 });
2953
+ }
2954
+ for (const checksum of [...groups.keys()].sort()) {
2955
+ const { row, n } = groups.get(checksum);
2956
+ await q.query(upsertBlobSql(n), [
2957
+ checksum,
2958
+ row.data,
2959
+ row.mime_type,
2960
+ row.size_bytes,
2961
+ row.width,
2962
+ row.height,
2963
+ row.created_at_txt
2964
+ ]);
2965
+ }
2966
+ for (const r of fresh) {
2967
+ await q.query(D_INSERT_REF3, [
2968
+ r.id,
2969
+ r.checksum_sha256,
2970
+ r.original_filename,
2971
+ r.created_at_txt,
2972
+ r.updated_at_txt
2973
+ ]);
2974
+ seen.add(r.checksum_sha256);
2975
+ migrated += 1;
2976
+ }
2977
+ }
2978
+ return {
2979
+ imagesMigrated: migrated,
2980
+ distinctBlobsCreated: seen.size,
2981
+ duplicateReferencesFound: migrated - seen.size,
2982
+ alreadyMigrated: already,
2983
+ tieredSkipped
2984
+ };
2985
+ }
2707
2986
  // ---- maintenance (CLI) -----------------------------------------------
2708
2987
  /** Storage statistics, or null if the tables do not exist yet (nothing is created). */
2709
2988
  async getInfo() {