zerobucket 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -295,7 +295,11 @@ interface MySQLBackendOptions {
295
295
  onOperation?: (event: OperationEvent) => void;
296
296
  /** Enables transparent reads of tiered rows and `tierToObjectStorage()`. */
297
297
  objectStorage?: ObjectStorageLike;
298
- /** Not available for MySQL yet; passing `true` throws. */
298
+ /**
299
+ * Content-addressed storage: identical bytes are stored once and shared by every image that has them
300
+ * (reference counted). Same tables as the Python package's dedup mode. Cannot be combined with
301
+ * `objectStorage` (a blob may be shared by many images).
302
+ */
299
303
  dedup?: boolean;
300
304
  }
301
305
  declare class MySQLBackend implements StorageBackend {
@@ -308,6 +312,8 @@ declare class MySQLBackend implements StorageBackend {
308
312
  private readonly retryBaseDelayMs;
309
313
  private readonly poolTimeoutMs;
310
314
  private readonly autoMigrate;
315
+ private readonly dedup;
316
+ private readonly sql;
311
317
  constructor(opts: MySQLBackendOptions);
312
318
  ready(): Promise<void>;
313
319
  private init;
@@ -317,6 +323,8 @@ declare class MySQLBackend implements StorageBackend {
317
323
  * re-running is a no-op.
318
324
  */
319
325
  private migrate;
326
+ /** Dedup tables, created only if missing (same no-needless-DDL rule as the classic table). */
327
+ private migrateDedup;
320
328
  close(): Promise<void>;
321
329
  private emit;
322
330
  /** Wait for a pool slot, but never longer than poolTimeoutMs. */
@@ -351,6 +359,17 @@ declare class MySQLBackend implements StorageBackend {
351
359
  delete(id: string, { connection }?: CallOptions): Promise<boolean>;
352
360
  deleteMany(ids: string[], { connection }?: CallOptions): Promise<string[]>;
353
361
  exists(id: string, { connection }?: CallOptions): Promise<boolean>;
362
+ /** Storage statistics, or null if the tables do not exist yet (nothing is created). */
363
+ getInfo(): Promise<StorageInfo | null>;
364
+ /** Ids matching the filters: oldest first, or a random sample. Used by the CLI's verify and tier. */
365
+ listImages(o?: ImageListOptions): Promise<ImageListEntry[]>;
366
+ /**
367
+ * Lock the ref row, delete it, then decrement its blob (the UPDATE takes the blob row lock, so two
368
+ * deletes of different refs to one blob serialize instead of racing) and remove the blob once nothing
369
+ * references it. All in one transaction: a failure can never leave a ref without its count or the reverse.
370
+ */
371
+ private deleteDedup;
372
+ private deleteManyDedup;
354
373
  private hydrate;
355
374
  /**
356
375
  * Remove tiered objects AFTER their rows are gone: once the row is deleted the image is correctly
package/dist/index.d.ts CHANGED
@@ -295,7 +295,11 @@ interface MySQLBackendOptions {
295
295
  onOperation?: (event: OperationEvent) => void;
296
296
  /** Enables transparent reads of tiered rows and `tierToObjectStorage()`. */
297
297
  objectStorage?: ObjectStorageLike;
298
- /** Not available for MySQL yet; passing `true` throws. */
298
+ /**
299
+ * Content-addressed storage: identical bytes are stored once and shared by every image that has them
300
+ * (reference counted). Same tables as the Python package's dedup mode. Cannot be combined with
301
+ * `objectStorage` (a blob may be shared by many images).
302
+ */
299
303
  dedup?: boolean;
300
304
  }
301
305
  declare class MySQLBackend implements StorageBackend {
@@ -308,6 +312,8 @@ declare class MySQLBackend implements StorageBackend {
308
312
  private readonly retryBaseDelayMs;
309
313
  private readonly poolTimeoutMs;
310
314
  private readonly autoMigrate;
315
+ private readonly dedup;
316
+ private readonly sql;
311
317
  constructor(opts: MySQLBackendOptions);
312
318
  ready(): Promise<void>;
313
319
  private init;
@@ -317,6 +323,8 @@ declare class MySQLBackend implements StorageBackend {
317
323
  * re-running is a no-op.
318
324
  */
319
325
  private migrate;
326
+ /** Dedup tables, created only if missing (same no-needless-DDL rule as the classic table). */
327
+ private migrateDedup;
320
328
  close(): Promise<void>;
321
329
  private emit;
322
330
  /** Wait for a pool slot, but never longer than poolTimeoutMs. */
@@ -351,6 +359,17 @@ declare class MySQLBackend implements StorageBackend {
351
359
  delete(id: string, { connection }?: CallOptions): Promise<boolean>;
352
360
  deleteMany(ids: string[], { connection }?: CallOptions): Promise<string[]>;
353
361
  exists(id: string, { connection }?: CallOptions): Promise<boolean>;
362
+ /** Storage statistics, or null if the tables do not exist yet (nothing is created). */
363
+ getInfo(): Promise<StorageInfo | null>;
364
+ /** Ids matching the filters: oldest first, or a random sample. Used by the CLI's verify and tier. */
365
+ listImages(o?: ImageListOptions): Promise<ImageListEntry[]>;
366
+ /**
367
+ * Lock the ref row, delete it, then decrement its blob (the UPDATE takes the blob row lock, so two
368
+ * deletes of different refs to one blob serialize instead of racing) and remove the blob once nothing
369
+ * references it. All in one transaction: a failure can never leave a ref without its count or the reverse.
370
+ */
371
+ private deleteDedup;
372
+ private deleteManyDedup;
354
373
  private hydrate;
355
374
  /**
356
375
  * Remove tiered objects AFTER their rows are gone: once the row is deleted the image is correctly
package/dist/index.js CHANGED
@@ -1909,11 +1909,60 @@ var CHUNK2 = `SELECT SUBSTRING(data FROM ? FOR ?) AS chunk FROM zerobucket_image
1909
1909
  var EXISTS3 = `SELECT 1 FROM zerobucket_images WHERE id = ?`;
1910
1910
  var LOCK_ONE = `SELECT storage_backend, object_storage_key FROM zerobucket_images WHERE id = ? FOR UPDATE`;
1911
1911
  var DELETE_ONE2 = `DELETE FROM zerobucket_images WHERE id = ?`;
1912
- var LOCK_MANY = `SELECT id, storage_backend, object_storage_key FROM zerobucket_images WHERE id IN (?) ORDER BY id FOR UPDATE`;
1913
- var DELETE_MANY2 = `DELETE FROM zerobucket_images WHERE id IN (?)`;
1912
+ var LOCK_MANY = `SELECT id, storage_backend, object_storage_key FROM zerobucket_images FORCE INDEX (PRIMARY) WHERE id IN (?) ORDER BY id FOR UPDATE`;
1914
1913
  var SELECT_FOR_TIERING3 = `SELECT data, mime_type, size_bytes, storage_backend FROM zerobucket_images WHERE id = ? FOR UPDATE`;
1915
1914
  var UPDATE_AFTER_TIERING3 = `UPDATE zerobucket_images SET data = NULL, storage_backend = 'object_storage', object_storage_bucket = ?, object_storage_key = ?, updated_at = ? WHERE id = ?`;
1916
1915
  var LOCAL = "mysql";
1916
+ var DEDUP_BLOBS_SCHEMA = `
1917
+ CREATE TABLE IF NOT EXISTS zerobucket_blobs (
1918
+ checksum_sha256 CHAR(64) PRIMARY KEY,
1919
+ data LONGBLOB NOT NULL,
1920
+ mime_type VARCHAR(255) NOT NULL,
1921
+ size_bytes INT NOT NULL,
1922
+ width INT,
1923
+ height INT,
1924
+ ref_count INT NOT NULL DEFAULT 0,
1925
+ created_at DATETIME(6) NOT NULL
1926
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`;
1927
+ var DEDUP_REFS_SCHEMA = `
1928
+ CREATE TABLE IF NOT EXISTS zerobucket_image_refs (
1929
+ id CHAR(36) PRIMARY KEY,
1930
+ checksum_sha256 CHAR(64) NOT NULL,
1931
+ original_filename TEXT,
1932
+ created_at DATETIME(6) NOT NULL,
1933
+ updated_at DATETIME(6) NOT NULL,
1934
+ INDEX idx_zerobucket_image_refs_checksum (checksum_sha256),
1935
+ INDEX idx_zerobucket_image_refs_created_at (created_at),
1936
+ FOREIGN KEY (checksum_sha256) REFERENCES zerobucket_blobs(checksum_sha256)
1937
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`;
1938
+ function upsertBlobSql(n) {
1939
+ if (!Number.isInteger(n) || n < 1) throw new RangeError("invalid ref count");
1940
+ return `INSERT INTO zerobucket_blobs (checksum_sha256, data, mime_type, size_bytes, width, height, ref_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ${n}, ?) ON DUPLICATE KEY UPDATE ref_count = ref_count + ${n}`;
1941
+ }
1942
+ var D_INSERT_REF3 = `INSERT INTO zerobucket_image_refs (id, checksum_sha256, original_filename, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`;
1943
+ var D_COLS3 = `r.id, b.data, b.mime_type, r.original_filename, b.size_bytes, b.width, b.height, r.checksum_sha256, '${LOCAL}' AS storage_backend, NULL AS object_storage_bucket, NULL AS object_storage_key`;
1944
+ var D_FROM3 = `FROM zerobucket_image_refs r JOIN zerobucket_blobs b ON r.checksum_sha256 = b.checksum_sha256`;
1945
+ var D_LOCK_REF = `SELECT checksum_sha256 FROM zerobucket_image_refs WHERE id = ? FOR UPDATE`;
1946
+ var D_DELETE_REF3 = `DELETE FROM zerobucket_image_refs WHERE id = ?`;
1947
+ var D_LOCK_REFS = `SELECT id, checksum_sha256 FROM zerobucket_image_refs FORCE INDEX (PRIMARY) WHERE id IN (?) ORDER BY id FOR UPDATE`;
1948
+ var D_DECREMENT3 = `UPDATE zerobucket_blobs SET ref_count = ref_count - ? WHERE checksum_sha256 = ?`;
1949
+ var D_DELETE_EMPTY3 = `DELETE FROM zerobucket_blobs WHERE checksum_sha256 = ? AND ref_count <= 0`;
1950
+ var CLASSIC_SQL2 = {
1951
+ selectFull: SELECT_FULL3,
1952
+ selectMany: SELECT_MANY2,
1953
+ selectMetadata: SELECT_METADATA3,
1954
+ streamInfo: STREAM_INFO2,
1955
+ chunk: CHUNK2,
1956
+ exists: EXISTS3
1957
+ };
1958
+ var DEDUP_SQL2 = {
1959
+ selectFull: `SELECT ${D_COLS3} ${D_FROM3} WHERE r.id = ?`,
1960
+ selectMany: `SELECT ${D_COLS3} ${D_FROM3} WHERE r.id IN (?)`,
1961
+ selectMetadata: `SELECT r.id, b.mime_type, r.original_filename, b.size_bytes, b.width, b.height, r.checksum_sha256 ${D_FROM3} WHERE r.id = ?`,
1962
+ streamInfo: `SELECT b.size_bytes, '${LOCAL}' AS storage_backend, NULL AS object_storage_key ${D_FROM3} WHERE r.id = ?`,
1963
+ chunk: `SELECT SUBSTRING(b.data FROM ? FOR ?) AS chunk ${D_FROM3} WHERE r.id = ?`,
1964
+ exists: `SELECT 1 FROM zerobucket_image_refs WHERE id = ?`
1965
+ };
1917
1966
  var RETRYABLE_ERRNOS = /* @__PURE__ */ new Set([
1918
1967
  1205,
1919
1968
  // ER_LOCK_WAIT_TIMEOUT
@@ -2003,11 +2052,13 @@ var MySQLBackend = class {
2003
2052
  "MySQLBackend requires either `connectionString` or `pool`"
2004
2053
  );
2005
2054
  }
2006
- if (opts.dedup) {
2055
+ this.dedup = opts.dedup ?? false;
2056
+ if (this.dedup && opts.objectStorage) {
2007
2057
  throw new TypeError(
2008
- "dedup mode is not available for MySQL in the npm package yet (the Python package supports it). Use classic mode, or Postgres/SQLite for dedup."
2058
+ "objectStorage is not supported together with dedup: true (same restriction as the Python package). Tiering moves one image's bytes, but a deduplicated blob can be shared by many images."
2009
2059
  );
2010
2060
  }
2061
+ this.sql = this.dedup ? DEDUP_SQL2 : CLASSIC_SQL2;
2011
2062
  this.ownsPool = !opts.pool;
2012
2063
  this.pool = opts.pool;
2013
2064
  this.maxRetries = opts.maxRetries ?? 3;
@@ -2024,6 +2075,8 @@ var MySQLBackend = class {
2024
2075
  retryBaseDelayMs;
2025
2076
  poolTimeoutMs;
2026
2077
  autoMigrate;
2078
+ dedup;
2079
+ sql;
2027
2080
  // ---- lifecycle -------------------------------------------------------
2028
2081
  ready() {
2029
2082
  if (this.closed)
@@ -2089,6 +2142,10 @@ var MySQLBackend = class {
2089
2142
  );
2090
2143
  }
2091
2144
  try {
2145
+ if (this.dedup) {
2146
+ await this.migrateDedup(q);
2147
+ return;
2148
+ }
2092
2149
  const tableExists = (await q.query(
2093
2150
  "SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'zerobucket_images'"
2094
2151
  ))[0].length > 0;
@@ -2130,6 +2187,16 @@ var MySQLBackend = class {
2130
2187
  });
2131
2188
  }
2132
2189
  }
2190
+ /** Dedup tables, created only if missing (same no-needless-DDL rule as the classic table). */
2191
+ async migrateDedup(q) {
2192
+ const have = new Set(
2193
+ (await q.query(
2194
+ "SELECT TABLE_NAME AS t FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ('zerobucket_blobs', 'zerobucket_image_refs')"
2195
+ ))[0].map((r) => r.t)
2196
+ );
2197
+ if (!have.has("zerobucket_blobs")) await q.query(DEDUP_BLOBS_SCHEMA);
2198
+ if (!have.has("zerobucket_image_refs")) await q.query(DEDUP_REFS_SCHEMA);
2199
+ }
2133
2200
  async close() {
2134
2201
  this.closed = true;
2135
2202
  const pool = this.pool;
@@ -2295,6 +2362,19 @@ var MySQLBackend = class {
2295
2362
  async put(row, { connection } = {}) {
2296
2363
  try {
2297
2364
  const id = randomUUID2();
2365
+ if (this.dedup) {
2366
+ await this.run(
2367
+ "put",
2368
+ connection,
2369
+ async (q) => {
2370
+ const now = mysqlNow();
2371
+ await q.execute(upsertBlobSql(1), blobParams2(row, now));
2372
+ await q.execute(D_INSERT_REF3, refParams(id, row, now));
2373
+ },
2374
+ { tx: true }
2375
+ );
2376
+ return id;
2377
+ }
2298
2378
  await this.run("put", connection, async (q) => {
2299
2379
  await q.execute(INSERT3, rowParams2(id, row, mysqlNow()));
2300
2380
  });
@@ -2312,6 +2392,21 @@ var MySQLBackend = class {
2312
2392
  connection,
2313
2393
  async (q) => {
2314
2394
  const now = mysqlNow();
2395
+ if (this.dedup) {
2396
+ const groups = /* @__PURE__ */ new Map();
2397
+ for (const r of rows) {
2398
+ const g = groups.get(r.checksumSha256);
2399
+ if (g) g.n += 1;
2400
+ else groups.set(r.checksumSha256, { row: r, n: 1 });
2401
+ }
2402
+ for (const checksum of [...groups.keys()].sort()) {
2403
+ const g = groups.get(checksum);
2404
+ await q.execute(upsertBlobSql(g.n), blobParams2(g.row, now));
2405
+ }
2406
+ for (let i = 0; i < rows.length; i++)
2407
+ await q.execute(D_INSERT_REF3, refParams(ids[i], rows[i], now));
2408
+ return;
2409
+ }
2315
2410
  for (let i = 0; i < rows.length; i++)
2316
2411
  await q.execute(INSERT3, rowParams2(ids[i], rows[i], now));
2317
2412
  },
@@ -2329,7 +2424,7 @@ var MySQLBackend = class {
2329
2424
  row = await this.run(
2330
2425
  "get",
2331
2426
  connection,
2332
- async (q) => (await q.query(SELECT_FULL3, [id]))[0][0]
2427
+ async (q) => (await q.query(this.sql.selectFull, [id]))[0][0]
2333
2428
  );
2334
2429
  } catch (exc) {
2335
2430
  throw wrap("Failed to retrieve image", exc);
@@ -2344,7 +2439,7 @@ var MySQLBackend = class {
2344
2439
  rows = await this.run(
2345
2440
  "get_many",
2346
2441
  connection,
2347
- async (q) => (await q.query(SELECT_MANY2, [valid]))[0]
2442
+ async (q) => (await q.query(this.sql.selectMany, [valid]))[0]
2348
2443
  );
2349
2444
  } catch (exc) {
2350
2445
  throw wrap("Failed to retrieve image batch", exc);
@@ -2358,7 +2453,7 @@ var MySQLBackend = class {
2358
2453
  row = await this.run(
2359
2454
  "get_metadata",
2360
2455
  connection,
2361
- async (q) => (await q.query(SELECT_METADATA3, [id]))[0][0]
2456
+ async (q) => (await q.query(this.sql.selectMetadata, [id]))[0][0]
2362
2457
  );
2363
2458
  } catch (exc) {
2364
2459
  throw wrap("Failed to retrieve image metadata", exc);
@@ -2384,7 +2479,7 @@ var MySQLBackend = class {
2384
2479
  info = await this.run(
2385
2480
  "get_metadata",
2386
2481
  connection,
2387
- async (q) => (await q.query(STREAM_INFO2, [id]))[0][0]
2482
+ async (q) => (await q.query(this.sql.streamInfo, [id]))[0][0]
2388
2483
  );
2389
2484
  } catch (exc) {
2390
2485
  throw wrap("Failed to retrieve image metadata", exc);
@@ -2417,7 +2512,7 @@ var MySQLBackend = class {
2417
2512
  row = await self.run(
2418
2513
  "get_stream",
2419
2514
  connection,
2420
- async (q) => (await q.query(CHUNK2, [offset + 1, length, id]))[0][0]
2515
+ async (q) => (await q.query(self.sql.chunk, [offset + 1, length, id]))[0][0]
2421
2516
  );
2422
2517
  } catch (exc) {
2423
2518
  throw wrap("Failed to stream image", exc);
@@ -2442,6 +2537,7 @@ var MySQLBackend = class {
2442
2537
  }
2443
2538
  async delete(id, { connection } = {}) {
2444
2539
  if (!isValidId(id)) return false;
2540
+ if (this.dedup) return this.deleteDedup(id, connection);
2445
2541
  try {
2446
2542
  const row = await this.run(
2447
2543
  "delete",
@@ -2464,14 +2560,14 @@ var MySQLBackend = class {
2464
2560
  async deleteMany(ids, { connection } = {}) {
2465
2561
  const valid = [...new Set(ids.filter(isValidId))];
2466
2562
  if (valid.length === 0) return [];
2563
+ if (this.dedup) return this.deleteManyDedup(valid, connection);
2467
2564
  try {
2468
2565
  const rows = await this.run(
2469
2566
  "delete_many",
2470
2567
  connection,
2471
2568
  async (q) => {
2472
2569
  const found = (await q.query(LOCK_MANY, [valid]))[0];
2473
- if (found.length === 0) return [];
2474
- await q.query(DELETE_MANY2, [found.map((r) => r.id)]);
2570
+ for (const r of found) await q.query(DELETE_ONE2, [r.id]);
2475
2571
  return found;
2476
2572
  },
2477
2573
  { tx: true }
@@ -2491,12 +2587,163 @@ var MySQLBackend = class {
2491
2587
  return await this.run(
2492
2588
  "exists",
2493
2589
  connection,
2494
- async (q) => (await q.query(EXISTS3, [id]))[0].length > 0
2590
+ async (q) => (await q.query(this.sql.exists, [id]))[0].length > 0
2495
2591
  );
2496
2592
  } catch (exc) {
2497
2593
  throw wrap("Failed to check image existence", exc);
2498
2594
  }
2499
2595
  }
2596
+ // ---- maintenance (CLI) -----------------------------------------------
2597
+ /** Storage statistics, or null if the tables do not exist yet (nothing is created). */
2598
+ async getInfo() {
2599
+ try {
2600
+ return await this.run("info", void 0, async (q) => {
2601
+ const need = this.dedup ? ["zerobucket_image_refs", "zerobucket_blobs"] : ["zerobucket_images"];
2602
+ const present = new Set(
2603
+ (await q.query(
2604
+ "SELECT TABLE_NAME AS t FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (?)",
2605
+ [need]
2606
+ ))[0].map((r) => r.t)
2607
+ );
2608
+ if (!need.every((t) => present.has(t))) return null;
2609
+ const j = this.dedup ? "FROM zerobucket_image_refs r JOIN zerobucket_blobs b ON r.checksum_sha256 = b.checksum_sha256" : "FROM zerobucket_images";
2610
+ const size = this.dedup ? "b.size_bytes" : "size_bytes";
2611
+ const created = this.dedup ? "r.created_at" : "created_at";
2612
+ const mime = this.dedup ? "b.mime_type" : "mime_type";
2613
+ const tot = (await q.query(
2614
+ `SELECT COUNT(*) AS n, COALESCE(SUM(${size}), 0) AS bytes, CAST(MIN(${created}) AS CHAR) AS oldest, CAST(MAX(${created}) AS CHAR) AS newest ${j}`
2615
+ ))[0][0];
2616
+ const disk = (await q.query(
2617
+ "SELECT COALESCE(SUM(DATA_LENGTH + INDEX_LENGTH), 0) AS s FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (?)",
2618
+ [need]
2619
+ ))[0][0];
2620
+ const fmt = (await q.query(
2621
+ `SELECT ${mime} AS m, COUNT(*) AS n, COALESCE(SUM(${size}), 0) AS bytes ${j} GROUP BY ${mime} ORDER BY COUNT(*) DESC, ${mime}`
2622
+ ))[0];
2623
+ const utc = (v) => v ? `${v}+00:00` : null;
2624
+ const info = {
2625
+ mode: this.dedup ? "dedup" : "classic",
2626
+ count: Number(tot.n),
2627
+ totalBytes: Number(tot.bytes),
2628
+ oldest: utc(tot.oldest),
2629
+ newest: utc(tot.newest),
2630
+ onDisk: prettySize(Number(disk.s)),
2631
+ byFormat: fmt.map((r) => ({
2632
+ mimeType: r.m,
2633
+ count: Number(r.n),
2634
+ bytes: Number(r.bytes)
2635
+ }))
2636
+ };
2637
+ if (this.dedup) {
2638
+ const b = (await q.query(
2639
+ "SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS bytes FROM zerobucket_blobs"
2640
+ ))[0][0];
2641
+ info.dedup = { blobs: Number(b.n), storedBytes: Number(b.bytes) };
2642
+ } else {
2643
+ const t = (await q.query(
2644
+ "SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS bytes FROM zerobucket_images WHERE storage_backend = 'object_storage'"
2645
+ ))[0][0];
2646
+ info.tiered = { count: Number(t.n), bytes: Number(t.bytes) };
2647
+ }
2648
+ return info;
2649
+ });
2650
+ } catch (exc) {
2651
+ throw wrap("Failed to read storage info", exc);
2652
+ }
2653
+ }
2654
+ /** Ids matching the filters: oldest first, or a random sample. Used by the CLI's verify and tier. */
2655
+ async listImages(o = {}) {
2656
+ const where = [];
2657
+ const params = [];
2658
+ const created = this.dedup ? "r.created_at" : "created_at";
2659
+ if (o.minSize !== void 0) {
2660
+ where.push(`${this.dedup ? "b.size_bytes" : "size_bytes"} >= ?`);
2661
+ params.push(o.minSize);
2662
+ }
2663
+ if (o.olderThanDays !== void 0) {
2664
+ where.push(`${created} <= UTC_TIMESTAMP(6) - INTERVAL ? DAY`);
2665
+ params.push(o.olderThanDays);
2666
+ }
2667
+ if (o.onlyUntiered && !this.dedup)
2668
+ where.push(`storage_backend = '${LOCAL}'`);
2669
+ const from = this.dedup ? "FROM zerobucket_image_refs r JOIN zerobucket_blobs b ON r.checksum_sha256 = b.checksum_sha256" : "FROM zerobucket_images";
2670
+ const idCol = this.dedup ? "r.id" : "id";
2671
+ const tierCol = this.dedup ? "0 AS tiered" : "(storage_backend = 'object_storage') AS tiered";
2672
+ const order = o.sample !== void 0 ? "ORDER BY RAND()" : `ORDER BY ${created}, ${idCol}`;
2673
+ const lim = o.sample ?? o.limit;
2674
+ let sql = `SELECT ${idCol} AS id, ${tierCol} ${from}${where.length ? " WHERE " + where.join(" AND ") : ""} ${order}`;
2675
+ if (lim !== void 0) {
2676
+ if (!Number.isInteger(lim) || lim < 0)
2677
+ throw new RangeError("limit/sample must be a non-negative integer");
2678
+ sql += ` LIMIT ${lim}`;
2679
+ }
2680
+ try {
2681
+ const rows = await this.run(
2682
+ "list_images",
2683
+ void 0,
2684
+ async (q) => (await q.query(sql, params))[0]
2685
+ );
2686
+ return rows.map((r) => ({
2687
+ id: String(r.id),
2688
+ tiered: Boolean(Number(r.tiered))
2689
+ }));
2690
+ } catch (exc) {
2691
+ throw wrap("Failed to list images", exc);
2692
+ }
2693
+ }
2694
+ // ---- dedup deletes ---------------------------------------------------
2695
+ /**
2696
+ * Lock the ref row, delete it, then decrement its blob (the UPDATE takes the blob row lock, so two
2697
+ * deletes of different refs to one blob serialize instead of racing) and remove the blob once nothing
2698
+ * references it. All in one transaction: a failure can never leave a ref without its count or the reverse.
2699
+ */
2700
+ async deleteDedup(id, connection) {
2701
+ try {
2702
+ return await this.run(
2703
+ "delete",
2704
+ connection,
2705
+ async (q) => {
2706
+ const ref = (await q.query(D_LOCK_REF, [id]))[0][0];
2707
+ if (!ref) return false;
2708
+ const checksum = ref.checksum_sha256;
2709
+ await q.query(D_DELETE_REF3, [id]);
2710
+ await q.query(D_DECREMENT3, [1, checksum]);
2711
+ await q.query(D_DELETE_EMPTY3, [checksum]);
2712
+ return true;
2713
+ },
2714
+ { tx: true }
2715
+ );
2716
+ } catch (exc) {
2717
+ throw wrap("Failed to delete image", exc);
2718
+ }
2719
+ }
2720
+ async deleteManyDedup(ids, connection) {
2721
+ try {
2722
+ return await this.run(
2723
+ "delete_many",
2724
+ connection,
2725
+ async (q) => {
2726
+ const found = (await q.query(D_LOCK_REFS, [ids]))[0];
2727
+ if (found.length === 0) return [];
2728
+ for (const r of found) await q.query(D_DELETE_REF3, [r.id]);
2729
+ const counts = /* @__PURE__ */ new Map();
2730
+ for (const r of found)
2731
+ counts.set(
2732
+ r.checksum_sha256,
2733
+ (counts.get(r.checksum_sha256) ?? 0) + 1
2734
+ );
2735
+ for (const checksum of [...counts.keys()].sort()) {
2736
+ await q.query(D_DECREMENT3, [counts.get(checksum), checksum]);
2737
+ await q.query(D_DELETE_EMPTY3, [checksum]);
2738
+ }
2739
+ return found.map((r) => String(r.id));
2740
+ },
2741
+ { tx: true }
2742
+ );
2743
+ } catch (exc) {
2744
+ throw wrap("Failed to delete image batch", exc);
2745
+ }
2746
+ }
2500
2747
  // ---- helpers ---------------------------------------------------------
2501
2748
  hydrate(row, id) {
2502
2749
  return hydrateRow(row, id, this.opts.objectStorage);
@@ -2543,6 +2790,11 @@ var MySQLBackend = class {
2543
2790
  * id) makes the re-upload an idempotent overwrite.
2544
2791
  */
2545
2792
  async tierToObjectStorage(id, { connection } = {}) {
2793
+ if (this.dedup) {
2794
+ throw new StorageError(
2795
+ "tierToObjectStorage() is not supported in dedup mode (a blob may be shared by many images)."
2796
+ );
2797
+ }
2546
2798
  const os = this.opts.objectStorage;
2547
2799
  if (!os) {
2548
2800
  throw new StorageError(
@@ -2570,6 +2822,20 @@ var MySQLBackend = class {
2570
2822
  }
2571
2823
  }
2572
2824
  };
2825
+ function blobParams2(r, now) {
2826
+ return [
2827
+ r.checksumSha256,
2828
+ r.data,
2829
+ r.mimeType,
2830
+ r.sizeBytes,
2831
+ r.width,
2832
+ r.height,
2833
+ now
2834
+ ];
2835
+ }
2836
+ function refParams(id, r, now) {
2837
+ return [id, r.checksumSha256, r.originalFilename, now, now];
2838
+ }
2573
2839
  function rowParams2(id, r, now) {
2574
2840
  return [
2575
2841
  id,