zerobucket 0.1.0 → 0.4.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.js CHANGED
@@ -15,8 +15,80 @@ import { createHash } from "crypto";
15
15
  import { readFile, stat } from "fs/promises";
16
16
  import { basename } from "path";
17
17
 
18
- // src/adapters/postgres.ts
18
+ // src/adapters/shared.ts
19
19
  var DEFAULT_STREAM_CHUNK_SIZE = 1024 * 1024;
20
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21
+ function isValidId(id) {
22
+ return typeof id === "string" && UUID_RE.test(id);
23
+ }
24
+ function msg(exc) {
25
+ return exc instanceof Error ? exc.message : String(exc);
26
+ }
27
+ function wrap(prefix, exc) {
28
+ return exc instanceof StorageError ? exc : new StorageError(`${prefix}: ${msg(exc)}`, { cause: exc });
29
+ }
30
+ function sleep(ms) {
31
+ return new Promise((resolve) => setTimeout(resolve, ms));
32
+ }
33
+ async function mapLimit(items, limit, fn) {
34
+ const out = new Array(items.length);
35
+ let next = 0;
36
+ await Promise.all(
37
+ Array.from({ length: Math.min(limit, items.length) }, async () => {
38
+ while (next < items.length) {
39
+ const i = next++;
40
+ out[i] = await fn(items[i]);
41
+ }
42
+ })
43
+ );
44
+ return out;
45
+ }
46
+ function tieredWithoutStorage(id, key) {
47
+ return new StorageError(
48
+ `Image ${JSON.stringify(id)} is stored in object storage (key=${JSON.stringify(key)}) but this ZeroBucket was constructed without \`objectStorage\`. Configure it with the same bucket and credentials that were used to tier this image.`
49
+ );
50
+ }
51
+ async function hydrateRow(row, id, os) {
52
+ let data;
53
+ if (row.storage_backend === "object_storage") {
54
+ if (!os) throw tieredWithoutStorage(id, row.object_storage_key);
55
+ data = await os.download(row.object_storage_key);
56
+ if (data.length !== row.size_bytes) {
57
+ throw new StorageError(
58
+ `Object ${JSON.stringify(row.object_storage_key)} for image ${JSON.stringify(id)} has ${data.length} bytes but the database records ${row.size_bytes}. The stored object is corrupted or was replaced.`
59
+ );
60
+ }
61
+ } else {
62
+ data = row.data;
63
+ }
64
+ return {
65
+ id: String(row.id),
66
+ data,
67
+ mimeType: row.mime_type,
68
+ originalFilename: row.original_filename,
69
+ sizeBytes: row.size_bytes,
70
+ width: row.width,
71
+ height: row.height,
72
+ checksumSha256: row.checksum_sha256
73
+ };
74
+ }
75
+ async function* streamFromObjectStorage(os, id, key, start, end, chunkSize) {
76
+ let offset = start;
77
+ while (offset <= end) {
78
+ const last = Math.min(offset + chunkSize - 1, end);
79
+ const expected = last - offset + 1;
80
+ const chunk = await os.downloadRange(key, offset, last);
81
+ if (chunk.length !== expected) {
82
+ throw new StorageError(
83
+ `Object ${JSON.stringify(key)} for image ${JSON.stringify(id)} returned ${chunk.length} bytes for a ${expected}-byte range; the stored object is shorter than the database records.`
84
+ );
85
+ }
86
+ yield chunk;
87
+ offset = last + 1;
88
+ }
89
+ }
90
+
91
+ // src/adapters/postgres.ts
20
92
  var SCHEMA = `
21
93
  CREATE TABLE IF NOT EXISTS zerobucket_images (
22
94
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -82,10 +154,59 @@ var SELECT_STREAM_INFO = `SELECT size_bytes, storage_backend, object_storage_key
82
154
  var DELETE_RETURNING = `DELETE FROM zerobucket_images WHERE id = $1 RETURNING storage_backend, object_storage_key;`;
83
155
  var DELETE_MANY = `DELETE FROM zerobucket_images WHERE id = ANY($1::uuid[]) RETURNING id, storage_backend, object_storage_key;`;
84
156
  var EXISTS = `SELECT 1 FROM zerobucket_images WHERE id = $1;`;
85
- var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
86
- function isValidId(id) {
87
- return typeof id === "string" && UUID_RE.test(id);
88
- }
157
+ var SELECT_FOR_TIERING = `SELECT data, mime_type, size_bytes, storage_backend FROM zerobucket_images WHERE id = $1 FOR UPDATE;`;
158
+ var UPDATE_AFTER_TIERING = `UPDATE zerobucket_images SET data = NULL, storage_backend = 'object_storage', object_storage_bucket = $1, object_storage_key = $2, updated_at = now() WHERE id = $3;`;
159
+ var DEDUP_SCHEMA = `
160
+ CREATE TABLE IF NOT EXISTS zerobucket_blobs (
161
+ checksum_sha256 CHAR(64) PRIMARY KEY,
162
+ data BYTEA NOT NULL,
163
+ mime_type TEXT NOT NULL,
164
+ size_bytes INTEGER NOT NULL,
165
+ width INTEGER,
166
+ height INTEGER,
167
+ ref_count INTEGER NOT NULL DEFAULT 0,
168
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
169
+ );
170
+ CREATE TABLE IF NOT EXISTS zerobucket_image_refs (
171
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
172
+ checksum_sha256 CHAR(64) NOT NULL REFERENCES zerobucket_blobs(checksum_sha256),
173
+ original_filename TEXT,
174
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
175
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
176
+ );
177
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_image_refs_checksum ON zerobucket_image_refs (checksum_sha256);
178
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_image_refs_created_at ON zerobucket_image_refs (created_at);
179
+ `;
180
+ var D_UPSERT_BLOB = `
181
+ INSERT INTO zerobucket_blobs (checksum_sha256, data, mime_type, size_bytes, width, height, ref_count)
182
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
183
+ ON CONFLICT (checksum_sha256) DO UPDATE SET ref_count = zerobucket_blobs.ref_count + EXCLUDED.ref_count;`;
184
+ var D_INSERT_REF = `INSERT INTO zerobucket_image_refs (checksum_sha256, original_filename) VALUES ($1, $2) RETURNING id;`;
185
+ var D_COLS = `r.id, b.data, b.mime_type, r.original_filename, b.size_bytes, b.width, b.height, r.checksum_sha256,
186
+ 'postgres'::text AS storage_backend, NULL::text AS object_storage_bucket, NULL::text AS object_storage_key`;
187
+ var D_FROM = `FROM zerobucket_image_refs r JOIN zerobucket_blobs b ON r.checksum_sha256 = b.checksum_sha256`;
188
+ var D_DELETE_REF = `DELETE FROM zerobucket_image_refs WHERE id = $1 RETURNING checksum_sha256;`;
189
+ var D_DELETE_REFS = `DELETE FROM zerobucket_image_refs WHERE id = ANY($1::uuid[]) RETURNING id, checksum_sha256;`;
190
+ var D_DECREMENT = `UPDATE zerobucket_blobs SET ref_count = ref_count - $1 WHERE checksum_sha256 = $2 RETURNING ref_count;`;
191
+ var D_DELETE_EMPTY = `DELETE FROM zerobucket_blobs WHERE checksum_sha256 = ANY($1::text[]) AND ref_count <= 0;`;
192
+ var CLASSIC_SQL = {
193
+ schema: SCHEMA,
194
+ selectFull: SELECT_FULL,
195
+ selectMany: SELECT_MANY,
196
+ selectMetadata: SELECT_METADATA,
197
+ selectStreamInfo: SELECT_STREAM_INFO,
198
+ selectChunk: SELECT_CHUNK,
199
+ exists: EXISTS
200
+ };
201
+ var DEDUP_SQL = {
202
+ schema: DEDUP_SCHEMA,
203
+ selectFull: `SELECT ${D_COLS} ${D_FROM} WHERE r.id = $1;`,
204
+ selectMany: `SELECT ${D_COLS} ${D_FROM} WHERE r.id = ANY($1::uuid[]);`,
205
+ 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;`,
206
+ selectStreamInfo: `SELECT b.size_bytes, 'postgres'::text AS storage_backend, NULL::text AS object_storage_key ${D_FROM} WHERE r.id = $1;`,
207
+ selectChunk: `SELECT substring(b.data FROM $1::int FOR $2::int) AS chunk ${D_FROM} WHERE r.id = $3;`,
208
+ exists: `SELECT 1 FROM zerobucket_image_refs WHERE id = $1;`
209
+ };
89
210
  var RETRYABLE_SQLSTATES = /* @__PURE__ */ new Set([
90
211
  "40001",
91
212
  // serialization_failure
@@ -156,6 +277,13 @@ var PostgresBackend = class {
156
277
  this.maxRetries = opts.maxRetries ?? 3;
157
278
  this.retryBaseDelayMs = opts.retryBaseDelayMs ?? 100;
158
279
  this.autoMigrate = opts.autoMigrate ?? true;
280
+ this.dedup = opts.dedup ?? false;
281
+ if (this.dedup && opts.objectStorage) {
282
+ throw new TypeError(
283
+ "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."
284
+ );
285
+ }
286
+ this.sql = this.dedup ? DEDUP_SQL : CLASSIC_SQL;
159
287
  }
160
288
  opts;
161
289
  pool;
@@ -165,6 +293,8 @@ var PostgresBackend = class {
165
293
  maxRetries;
166
294
  retryBaseDelayMs;
167
295
  autoMigrate;
296
+ dedup;
297
+ sql;
168
298
  // ---- lifecycle -------------------------------------------------------
169
299
  ready() {
170
300
  if (this.closed)
@@ -229,7 +359,7 @@ var PostgresBackend = class {
229
359
  await client.query(
230
360
  "SELECT pg_advisory_xact_lock(hashtext('zerobucket_migrate'))"
231
361
  );
232
- await client.query(SCHEMA);
362
+ await client.query(this.sql.schema);
233
363
  await client.query("COMMIT");
234
364
  client.release();
235
365
  } catch (exc) {
@@ -262,123 +392,1029 @@ var PostgresBackend = class {
262
392
  }
263
393
  }
264
394
  /**
265
- * Run `work` and return its result, emitting exactly one OperationEvent.
266
- *
267
- * connection given -> run once on it, no BEGIN/COMMIT, no retry.
268
- * connection absent -> pooled; `tx` wraps the work in BEGIN/COMMIT; transient
269
- * failures retry the WHOLE closure on a fresh connection.
395
+ * Run `work` and return its result, emitting exactly one OperationEvent.
396
+ *
397
+ * connection given -> run once on it, no BEGIN/COMMIT, no retry.
398
+ * connection absent -> pooled; `tx` wraps the work in BEGIN/COMMIT; transient
399
+ * failures retry the WHOLE closure on a fresh connection.
400
+ */
401
+ async run(operation, connection, work, { tx = false } = {}) {
402
+ if (connection) {
403
+ if (!isQueryable(connection)) {
404
+ throw new TypeError(
405
+ "PostgresBackend expects a pg client/pool client as `connection` (an object with query()), not a SQLite Database."
406
+ );
407
+ }
408
+ const start2 = performance.now();
409
+ try {
410
+ const result = tx ? await this.atomic(connection, work) : await work(connection);
411
+ this.emit(operation, start2, true, null, 0);
412
+ return result;
413
+ } catch (exc) {
414
+ this.emit(operation, start2, false, msg(exc), 0);
415
+ throw exc;
416
+ }
417
+ }
418
+ await this.ready();
419
+ const start = performance.now();
420
+ let attempt = 0;
421
+ for (; ; ) {
422
+ let client;
423
+ let destroy = false;
424
+ try {
425
+ client = await this.pool.connect();
426
+ if (tx) await client.query("BEGIN");
427
+ const result = await work(client);
428
+ if (tx) await client.query("COMMIT");
429
+ this.emit(operation, start, true, null, attempt);
430
+ return result;
431
+ } catch (exc) {
432
+ if (client && tx) {
433
+ try {
434
+ await client.query("ROLLBACK");
435
+ } catch {
436
+ destroy = true;
437
+ }
438
+ }
439
+ if (isConnectionLevel(exc)) destroy = true;
440
+ attempt += 1;
441
+ if (attempt > this.maxRetries || !isRetryable(exc)) {
442
+ this.emit(operation, start, false, msg(exc), attempt - 1);
443
+ throw exc;
444
+ }
445
+ } finally {
446
+ client?.release(destroy);
447
+ }
448
+ await sleep(backoffDelayMs(attempt, this.retryBaseDelayMs));
449
+ }
450
+ }
451
+ /**
452
+ * Make multi-statement work all-or-nothing on a CALLER-supplied connection.
453
+ * - Caller has an open transaction -> SAVEPOINT: a failure rolls back only our work and the
454
+ * caller's transaction stays usable; success joins their transaction.
455
+ * - No open transaction (an autocommit pg.Client) -> open and commit our own, so a failure
456
+ * can never leave half-applied state (e.g. an inflated dedup ref_count).
457
+ */
458
+ async atomic(conn, work) {
459
+ let ownTx = false;
460
+ try {
461
+ await conn.query("SAVEPOINT zerobucket_atomic");
462
+ } catch (exc) {
463
+ if (exc.code !== "25P01") throw exc;
464
+ ownTx = true;
465
+ await conn.query("BEGIN");
466
+ }
467
+ try {
468
+ const result = await work(conn);
469
+ await conn.query(
470
+ ownTx ? "COMMIT" : "RELEASE SAVEPOINT zerobucket_atomic"
471
+ );
472
+ return result;
473
+ } catch (exc) {
474
+ try {
475
+ await conn.query(
476
+ ownTx ? "ROLLBACK" : "ROLLBACK TO SAVEPOINT zerobucket_atomic"
477
+ );
478
+ } catch {
479
+ }
480
+ throw exc;
481
+ }
482
+ }
483
+ // ---- StorageBackend --------------------------------------------------
484
+ async put(row, { connection } = {}) {
485
+ try {
486
+ if (this.dedup) {
487
+ return await this.run(
488
+ "put",
489
+ connection,
490
+ async (q) => {
491
+ await q.query(D_UPSERT_BLOB, blobParams(row, 1));
492
+ return String(
493
+ (await q.query(D_INSERT_REF, [
494
+ row.checksumSha256,
495
+ row.originalFilename
496
+ ])).rows[0].id
497
+ );
498
+ },
499
+ { tx: true }
500
+ );
501
+ }
502
+ return await this.run("put", connection, async (q) => {
503
+ const res = await q.query(INSERT, rowParams(row));
504
+ return String(res.rows[0].id);
505
+ });
506
+ } catch (exc) {
507
+ throw wrap("Failed to store image", exc);
508
+ }
509
+ }
510
+ async putMany(rows, { connection } = {}) {
511
+ if (rows.length === 0) return [];
512
+ try {
513
+ if (this.dedup) {
514
+ return await this.run(
515
+ "put_many",
516
+ connection,
517
+ async (q) => {
518
+ const groups = /* @__PURE__ */ new Map();
519
+ for (const r of rows) {
520
+ const g = groups.get(r.checksumSha256);
521
+ if (g) g.n += 1;
522
+ else groups.set(r.checksumSha256, { row: r, n: 1 });
523
+ }
524
+ for (const checksum of [...groups.keys()].sort()) {
525
+ const g = groups.get(checksum);
526
+ await q.query(D_UPSERT_BLOB, blobParams(g.row, g.n));
527
+ }
528
+ const ids = [];
529
+ for (const r of rows) {
530
+ ids.push(
531
+ String(
532
+ (await q.query(D_INSERT_REF, [
533
+ r.checksumSha256,
534
+ r.originalFilename
535
+ ])).rows[0].id
536
+ )
537
+ );
538
+ }
539
+ return ids;
540
+ },
541
+ { tx: true }
542
+ );
543
+ }
544
+ return await this.run(
545
+ "put_many",
546
+ connection,
547
+ async (q) => {
548
+ const ids = [];
549
+ for (const r of rows)
550
+ ids.push(String((await q.query(INSERT, rowParams(r))).rows[0].id));
551
+ return ids;
552
+ },
553
+ { tx: true }
554
+ );
555
+ } catch (exc) {
556
+ throw wrap("Failed to store image batch", exc);
557
+ }
558
+ }
559
+ async get(id, { connection } = {}) {
560
+ if (!isValidId(id)) return null;
561
+ let row;
562
+ try {
563
+ row = await this.run(
564
+ "get",
565
+ connection,
566
+ async (q) => (await q.query(this.sql.selectFull, [id])).rows[0]
567
+ );
568
+ } catch (exc) {
569
+ throw wrap("Failed to retrieve image", exc);
570
+ }
571
+ return row ? this.hydrate(row, id) : null;
572
+ }
573
+ async getMany(ids, { connection } = {}) {
574
+ const valid = ids.filter(isValidId);
575
+ if (valid.length === 0) return [];
576
+ let rows;
577
+ try {
578
+ rows = await this.run(
579
+ "get_many",
580
+ connection,
581
+ async (q) => (await q.query(this.sql.selectMany, [valid])).rows
582
+ );
583
+ } catch (exc) {
584
+ throw wrap("Failed to retrieve image batch", exc);
585
+ }
586
+ return mapLimit(rows, 4, (r) => this.hydrate(r, String(r.id)));
587
+ }
588
+ async getMetadata(id, { connection } = {}) {
589
+ if (!isValidId(id)) return null;
590
+ let row;
591
+ try {
592
+ row = await this.run(
593
+ "get_metadata",
594
+ connection,
595
+ async (q) => (await q.query(this.sql.selectMetadata, [id])).rows[0]
596
+ );
597
+ } catch (exc) {
598
+ throw wrap("Failed to retrieve image metadata", exc);
599
+ }
600
+ if (!row) return null;
601
+ return {
602
+ id: String(row.id),
603
+ mimeType: row.mime_type,
604
+ originalFilename: row.original_filename,
605
+ sizeBytes: row.size_bytes,
606
+ width: row.width,
607
+ height: row.height,
608
+ checksumSha256: row.checksum_sha256
609
+ };
610
+ }
611
+ async getStream(id, opts) {
612
+ if (!isValidId(id)) return null;
613
+ const { connection, chunkSize } = opts;
614
+ if (!Number.isInteger(chunkSize) || chunkSize <= 0)
615
+ throw new RangeError("chunkSize must be a positive integer");
616
+ let info;
617
+ try {
618
+ info = await this.run(
619
+ "get_metadata",
620
+ connection,
621
+ async (q) => (await q.query(this.sql.selectStreamInfo, [id])).rows[0]
622
+ );
623
+ } catch (exc) {
624
+ throw wrap("Failed to retrieve image metadata", exc);
625
+ }
626
+ if (!info) return null;
627
+ const totalSize = info.size_bytes;
628
+ const start = opts.range?.start ?? 0;
629
+ const end = Math.min(opts.range?.end ?? totalSize - 1, totalSize - 1);
630
+ if (info.storage_backend === "object_storage") {
631
+ const os = this.opts.objectStorage;
632
+ if (!os) throw tieredWithoutStorage(id, info.object_storage_key);
633
+ return streamFromObjectStorage(
634
+ os,
635
+ id,
636
+ info.object_storage_key,
637
+ start,
638
+ end,
639
+ chunkSize
640
+ );
641
+ }
642
+ const self = this;
643
+ async function* generate() {
644
+ let offset = start;
645
+ let delivered = 0;
646
+ const wanted = Math.max(0, end - start + 1);
647
+ while (delivered < wanted) {
648
+ const length = Math.min(chunkSize, wanted - delivered);
649
+ let row;
650
+ try {
651
+ row = await self.run(
652
+ "get_stream",
653
+ connection,
654
+ async (q) => (await q.query(self.sql.selectChunk, [offset + 1, length, id])).rows[0]
655
+ );
656
+ } catch (exc) {
657
+ throw wrap("Failed to stream image", exc);
658
+ }
659
+ if (!row) {
660
+ throw new StorageError(
661
+ `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.`
662
+ );
663
+ }
664
+ const chunk = row.chunk;
665
+ if (!chunk || chunk.length === 0) {
666
+ throw new StorageError(
667
+ `Image ${JSON.stringify(id)} ended early while streaming (delivered ${delivered} of ${wanted} bytes).`
668
+ );
669
+ }
670
+ yield chunk;
671
+ offset += chunk.length;
672
+ delivered += chunk.length;
673
+ }
674
+ }
675
+ return generate();
676
+ }
677
+ async delete(id, { connection } = {}) {
678
+ if (!isValidId(id)) return false;
679
+ if (this.dedup) return this.deleteDedup(id, connection);
680
+ try {
681
+ const row = await this.run(
682
+ "delete",
683
+ connection,
684
+ async (q) => (await q.query(DELETE_RETURNING, [id])).rows[0]
685
+ );
686
+ if (row?.storage_backend === "object_storage")
687
+ await this.cleanupObject([row.object_storage_key], connection);
688
+ return row !== void 0;
689
+ } catch (exc) {
690
+ throw wrap("Failed to delete image", exc);
691
+ }
692
+ }
693
+ async deleteMany(ids, { connection } = {}) {
694
+ const valid = ids.filter(isValidId);
695
+ if (valid.length === 0) return [];
696
+ if (this.dedup) return this.deleteManyDedup(valid, connection);
697
+ try {
698
+ const rows = await this.run(
699
+ "delete_many",
700
+ connection,
701
+ async (q) => (await q.query(DELETE_MANY, [valid])).rows
702
+ );
703
+ await this.cleanupObject(
704
+ rows.filter((r) => r.storage_backend === "object_storage").map((r) => r.object_storage_key),
705
+ connection
706
+ );
707
+ return rows.map((r) => String(r.id));
708
+ } catch (exc) {
709
+ throw wrap("Failed to delete image batch", exc);
710
+ }
711
+ }
712
+ async exists(id, { connection } = {}) {
713
+ if (!isValidId(id)) return false;
714
+ try {
715
+ return await this.run(
716
+ "exists",
717
+ connection,
718
+ async (q) => ((await q.query(this.sql.exists, [id])).rowCount ?? 0) > 0
719
+ );
720
+ } catch (exc) {
721
+ throw wrap("Failed to check image existence", exc);
722
+ }
723
+ }
724
+ // ---- dedup deletes / migration ---------------------------------------
725
+ async deleteDedup(id, connection) {
726
+ try {
727
+ return await this.run(
728
+ "delete",
729
+ connection,
730
+ async (q) => {
731
+ const ref = (await q.query(D_DELETE_REF, [id])).rows[0];
732
+ if (!ref) return false;
733
+ const checksum = ref.checksum_sha256;
734
+ const dec = (await q.query(D_DECREMENT, [1, checksum])).rows[0];
735
+ if (dec && dec.ref_count <= 0)
736
+ await q.query(D_DELETE_EMPTY, [[checksum]]);
737
+ return true;
738
+ },
739
+ { tx: true }
740
+ );
741
+ } catch (exc) {
742
+ throw wrap("Failed to delete image", exc);
743
+ }
744
+ }
745
+ async deleteManyDedup(ids, connection) {
746
+ try {
747
+ return await this.run(
748
+ "delete_many",
749
+ connection,
750
+ async (q) => {
751
+ const deleted = (await q.query(D_DELETE_REFS, [ids])).rows;
752
+ if (deleted.length === 0) return [];
753
+ const counts = /* @__PURE__ */ new Map();
754
+ for (const r of deleted)
755
+ counts.set(
756
+ r.checksum_sha256,
757
+ (counts.get(r.checksum_sha256) ?? 0) + 1
758
+ );
759
+ const emptied = [];
760
+ for (const checksum of [...counts.keys()].sort()) {
761
+ const dec = (await q.query(D_DECREMENT, [counts.get(checksum), checksum])).rows[0];
762
+ if (dec && dec.ref_count <= 0) emptied.push(checksum);
763
+ }
764
+ if (emptied.length > 0) await q.query(D_DELETE_EMPTY, [emptied]);
765
+ return deleted.map((r) => String(r.id));
766
+ },
767
+ { tx: true }
768
+ );
769
+ } catch (exc) {
770
+ throw wrap("Failed to delete image batch", exc);
771
+ }
772
+ }
773
+ /**
774
+ * One-time, NON-DESTRUCTIVE copy of every row in the classic `zerobucket_images` table into this
775
+ * dedup instance's tables, preserving every id (external references keep working) and
776
+ * deduplicating identical content on the way. The classic table is never modified.
777
+ *
778
+ * Differences from the Python version, all deliberate: rows are copied in small batches (bounded
779
+ * memory instead of loading the whole table); re-running is safe (already-migrated ids are
780
+ * skipped and reported, not an error); and classic rows already tiered to object storage are
781
+ * refused up front with a clear message (their bytes are not in the database to copy).
782
+ * Still all-or-nothing: one transaction.
783
+ */
784
+ async migrateClassicToDedup() {
785
+ if (!this.dedup) {
786
+ throw new StorageError(
787
+ "migrateClassicToDedup() must be called on an instance created with dedup: true."
788
+ );
789
+ }
790
+ await this.ready();
791
+ const client = await this.pool.connect();
792
+ let destroy = false;
793
+ try {
794
+ await client.query("BEGIN");
795
+ await client.query(
796
+ "SELECT pg_advisory_xact_lock(hashtext('zerobucket_migrate_dedup'))"
797
+ );
798
+ if (!(await client.query("SELECT to_regclass('zerobucket_images') AS t")).rows[0].t) {
799
+ throw new StorageError(
800
+ "No classic zerobucket_images table found in this database -- nothing to migrate."
801
+ );
802
+ }
803
+ const hasTierCol = (await client.query(
804
+ "SELECT 1 FROM information_schema.columns WHERE table_name = 'zerobucket_images' AND column_name = 'storage_backend'"
805
+ )).rowCount;
806
+ if (hasTierCol) {
807
+ const tiered = (await client.query(
808
+ "SELECT count(*)::int AS n FROM zerobucket_images WHERE storage_backend <> 'postgres'"
809
+ )).rows[0].n;
810
+ if (tiered > 0) {
811
+ throw new StorageError(
812
+ `${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, or bring those images back first.`
813
+ );
814
+ }
815
+ }
816
+ const seen = /* @__PURE__ */ new Set();
817
+ let migrated = 0;
818
+ let already = 0;
819
+ let cursor = null;
820
+ const BATCH = 20;
821
+ for (; ; ) {
822
+ const page = cursor ? await client.query(
823
+ `SELECT id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, created_at::text AS created_at_txt
824
+ FROM zerobucket_images WHERE (created_at, id) > ($1::timestamptz, $2::uuid) ORDER BY created_at, id LIMIT ${BATCH}`,
825
+ [cursor.createdAt, cursor.id]
826
+ ) : await client.query(
827
+ `SELECT id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, created_at::text AS created_at_txt
828
+ FROM zerobucket_images ORDER BY created_at, id LIMIT ${BATCH}`
829
+ );
830
+ if (page.rows.length === 0) break;
831
+ const last = page.rows[page.rows.length - 1];
832
+ if (cursor && cursor.createdAt === last.created_at_txt && cursor.id === last.id) {
833
+ throw new StorageError(
834
+ "Migration made no progress (keyset cursor did not advance); aborting instead of looping."
835
+ );
836
+ }
837
+ cursor = { createdAt: last.created_at_txt, id: last.id };
838
+ const have = new Set(
839
+ (await client.query(
840
+ "SELECT id FROM zerobucket_image_refs WHERE id = ANY($1::uuid[])",
841
+ [page.rows.map((r) => r.id)]
842
+ )).rows.map((r) => String(r.id))
843
+ );
844
+ const fresh = page.rows.filter((r) => !have.has(String(r.id)));
845
+ already += page.rows.length - fresh.length;
846
+ const groups = /* @__PURE__ */ new Map();
847
+ for (const r of fresh) {
848
+ const g = groups.get(r.checksum_sha256);
849
+ if (g) g.n += 1;
850
+ else groups.set(r.checksum_sha256, { row: r, n: 1 });
851
+ }
852
+ for (const checksum of [...groups.keys()].sort()) {
853
+ const { row, n } = groups.get(checksum);
854
+ await client.query(D_UPSERT_BLOB, [
855
+ checksum,
856
+ row.data,
857
+ row.mime_type,
858
+ row.size_bytes,
859
+ row.width,
860
+ row.height,
861
+ n
862
+ ]);
863
+ }
864
+ for (const r of fresh) {
865
+ await client.query(
866
+ "INSERT INTO zerobucket_image_refs (id, checksum_sha256, original_filename, created_at) VALUES ($1, $2, $3, $4::timestamptz)",
867
+ [r.id, r.checksum_sha256, r.original_filename, r.created_at_txt]
868
+ );
869
+ seen.add(r.checksum_sha256);
870
+ migrated += 1;
871
+ }
872
+ }
873
+ await client.query("COMMIT");
874
+ return {
875
+ imagesMigrated: migrated,
876
+ distinctBlobsCreated: seen.size,
877
+ duplicateReferencesFound: migrated - seen.size,
878
+ alreadyMigrated: already
879
+ };
880
+ } catch (exc) {
881
+ try {
882
+ await client.query("ROLLBACK");
883
+ } catch {
884
+ destroy = true;
885
+ }
886
+ throw exc instanceof StorageError ? exc : new StorageError(`Migration to dedup failed: ${msg(exc)}`, {
887
+ cause: exc
888
+ });
889
+ } finally {
890
+ client.release(destroy);
891
+ }
892
+ }
893
+ // ---- helpers ---------------------------------------------------------
894
+ hydrate(row, id) {
895
+ return hydrateRow(row, id, this.opts.objectStorage);
896
+ }
897
+ /**
898
+ * Remove tiered objects AFTER their rows are gone. Ordering is deliberate:
899
+ * once the row is deleted the image is correctly "not found" to everyone, so
900
+ * a failed object delete only leaves a harmless orphan, whereas deleting the
901
+ * object first and then failing the row delete would leave a row pointing at nothing.
902
+ *
903
+ * Never runs inside a caller-owned transaction (`connection`): we cannot know
904
+ * whether it will commit, and destroying data before the commit outcome is known
905
+ * could leave a rolled-back row pointing at a deleted object. In that case the
906
+ * object stays until you remove it yourself after committing (objectStorage.delete(id)).
907
+ * Failures are reported through onOperation and never fail the delete itself.
908
+ */
909
+ async cleanupObject(keys, connection) {
910
+ const os = this.opts.objectStorage;
911
+ if (!os || connection || keys.length === 0) return;
912
+ await Promise.all(
913
+ keys.map(async (key) => {
914
+ const start = performance.now();
915
+ try {
916
+ await os.delete(key);
917
+ this.emit("object_storage_delete", start, true, null, 0);
918
+ } catch (exc) {
919
+ this.emit("object_storage_delete", start, false, msg(exc), 0);
920
+ }
921
+ })
922
+ );
923
+ }
924
+ /**
925
+ * Move an image's bytes out of Postgres into object storage, replacing the
926
+ * row's `data` with a pointer. Returns:
927
+ * null - no such image
928
+ * false - already tiered (a safe no-op, so backfills can be re-run)
929
+ * true - tiered just now
930
+ *
931
+ * SAFETY: the upload happens INSIDE the same transaction as the row lock
932
+ * (SELECT ... FOR UPDATE) and the UPDATE that flips the row. If the upload
933
+ * fails, the transaction rolls back and the row is untouched: there is no
934
+ * moment when the bytes exist nowhere, or when a row claims to be tiered
935
+ * without a completed upload.
936
+ *
937
+ * TRADEOFF: the row lock is held for the whole upload. Concurrent writes
938
+ * to THIS image (delete, tier) wait; every other row is unaffected, and plain
939
+ * reads are never blocked (Postgres MVCC). Meant for explicit maintenance, not hot paths.
940
+ *
941
+ * Retry is safe: a transient error replays the whole closure, and the
942
+ * deterministic key (the image id) makes the re-upload an idempotent overwrite.
943
+ */
944
+ async tierToObjectStorage(id, { connection } = {}) {
945
+ if (this.dedup) {
946
+ throw new StorageError(
947
+ "tierToObjectStorage() is not supported in dedup mode (a blob may be shared by many images)."
948
+ );
949
+ }
950
+ const os = this.opts.objectStorage;
951
+ if (!os) {
952
+ throw new StorageError(
953
+ "tierToObjectStorage() requires objectStorage to be configured (new ZeroBucket({ objectStorage: new ObjectStorage({ bucket }) }))."
954
+ );
955
+ }
956
+ if (!isValidId(id)) return null;
957
+ const key = id.toLowerCase();
958
+ try {
959
+ return await this.run(
960
+ "tier_to_object_storage",
961
+ connection,
962
+ async (q) => {
963
+ const row = (await q.query(SELECT_FOR_TIERING, [id])).rows[0];
964
+ if (!row) return null;
965
+ if (row.storage_backend !== "postgres") return false;
966
+ await os.upload(key, row.data, { mimeType: row.mime_type });
967
+ await q.query(UPDATE_AFTER_TIERING, [os.bucket, key, id]);
968
+ return true;
969
+ },
970
+ { tx: true }
971
+ );
972
+ } catch (exc) {
973
+ throw wrap("Failed to tier image to object storage", exc);
974
+ }
975
+ }
976
+ };
977
+ function blobParams(r, refs) {
978
+ return [
979
+ r.checksumSha256,
980
+ r.data,
981
+ r.mimeType,
982
+ r.sizeBytes,
983
+ r.width,
984
+ r.height,
985
+ refs
986
+ ];
987
+ }
988
+ function rowParams(r) {
989
+ return [
990
+ r.data,
991
+ r.mimeType,
992
+ r.originalFilename,
993
+ r.sizeBytes,
994
+ r.width,
995
+ r.height,
996
+ r.checksumSha256
997
+ ];
998
+ }
999
+ function isQueryable(c) {
1000
+ return typeof c.query === "function";
1001
+ }
1002
+
1003
+ // src/adapters/sqlite.ts
1004
+ import { randomUUID } from "crypto";
1005
+ var SCHEMA2 = `
1006
+ CREATE TABLE IF NOT EXISTS zerobucket_images (
1007
+ id TEXT PRIMARY KEY,
1008
+ data BLOB,
1009
+ mime_type TEXT NOT NULL,
1010
+ original_filename TEXT,
1011
+ size_bytes INTEGER NOT NULL,
1012
+ width INTEGER,
1013
+ height INTEGER,
1014
+ checksum_sha256 TEXT NOT NULL,
1015
+ created_at TEXT NOT NULL,
1016
+ updated_at TEXT NOT NULL,
1017
+ storage_backend TEXT NOT NULL DEFAULT 'sqlite',
1018
+ object_storage_bucket TEXT,
1019
+ object_storage_key TEXT,
1020
+ CHECK (
1021
+ (storage_backend = 'sqlite'
1022
+ AND data IS NOT NULL
1023
+ AND object_storage_key IS NULL
1024
+ AND object_storage_bucket IS NULL)
1025
+ OR
1026
+ (storage_backend = 'object_storage'
1027
+ AND data IS NULL
1028
+ AND object_storage_key IS NOT NULL
1029
+ AND object_storage_bucket IS NOT NULL)
1030
+ )
1031
+ );
1032
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_checksum ON zerobucket_images (checksum_sha256);
1033
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_created_at ON zerobucket_images (created_at);
1034
+ `;
1035
+ var DEDUP_SCHEMA2 = `
1036
+ CREATE TABLE IF NOT EXISTS zerobucket_blobs (
1037
+ checksum_sha256 TEXT PRIMARY KEY,
1038
+ data BLOB NOT NULL,
1039
+ mime_type TEXT NOT NULL,
1040
+ size_bytes INTEGER NOT NULL,
1041
+ width INTEGER,
1042
+ height INTEGER,
1043
+ ref_count INTEGER NOT NULL DEFAULT 0,
1044
+ created_at TEXT NOT NULL
1045
+ );
1046
+ CREATE TABLE IF NOT EXISTS zerobucket_image_refs (
1047
+ id TEXT PRIMARY KEY,
1048
+ checksum_sha256 TEXT NOT NULL REFERENCES zerobucket_blobs(checksum_sha256),
1049
+ original_filename TEXT,
1050
+ created_at TEXT NOT NULL,
1051
+ updated_at TEXT NOT NULL
1052
+ );
1053
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_image_refs_checksum ON zerobucket_image_refs (checksum_sha256);
1054
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_image_refs_created_at ON zerobucket_image_refs (created_at);
1055
+ `;
1056
+ var COLS = `id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, storage_backend, object_storage_bucket, object_storage_key`;
1057
+ var INSERT2 = `INSERT INTO zerobucket_images (id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
1058
+ var SELECT_FULL2 = `SELECT ${COLS} FROM zerobucket_images WHERE id = ?`;
1059
+ var selectMany = (n) => `SELECT ${COLS} FROM zerobucket_images WHERE id IN (${placeholders(n)})`;
1060
+ var SELECT_METADATA2 = `SELECT id, mime_type, original_filename, size_bytes, width, height, checksum_sha256 FROM zerobucket_images WHERE id = ?`;
1061
+ var STREAM_INFO = `SELECT size_bytes, storage_backend, object_storage_key FROM zerobucket_images WHERE id = ?`;
1062
+ var CHUNK = `SELECT substr(data, ?, ?) AS chunk FROM zerobucket_images WHERE id = ?`;
1063
+ var EXISTS2 = `SELECT 1 FROM zerobucket_images WHERE id = ?`;
1064
+ var DELETE_ONE = `DELETE FROM zerobucket_images WHERE id = ? RETURNING storage_backend, object_storage_key`;
1065
+ var deleteMany = (n) => `DELETE FROM zerobucket_images WHERE id IN (${placeholders(n)}) RETURNING id, storage_backend, object_storage_key`;
1066
+ var SELECT_FOR_TIERING2 = `SELECT data, mime_type, size_bytes, storage_backend FROM zerobucket_images WHERE id = ?`;
1067
+ var UPDATE_AFTER_TIERING2 = `UPDATE zerobucket_images SET data = NULL, storage_backend = 'object_storage', object_storage_bucket = ?, object_storage_key = ?, updated_at = ? WHERE id = ?`;
1068
+ var D_FROM2 = `FROM zerobucket_image_refs r JOIN zerobucket_blobs b ON r.checksum_sha256 = b.checksum_sha256`;
1069
+ var D_COLS2 = `r.id AS id, b.data AS data, b.mime_type AS mime_type, r.original_filename AS original_filename, b.size_bytes AS size_bytes, b.width AS width, b.height AS height, r.checksum_sha256 AS checksum_sha256, 'sqlite' AS storage_backend, NULL AS object_storage_bucket, NULL AS object_storage_key`;
1070
+ var D_UPSERT = `INSERT INTO zerobucket_blobs (checksum_sha256, data, mime_type, size_bytes, width, height, ref_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (checksum_sha256) DO UPDATE SET ref_count = ref_count + excluded.ref_count`;
1071
+ var D_INSERT_REF2 = `INSERT INTO zerobucket_image_refs (id, checksum_sha256, original_filename, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`;
1072
+ var D_SELECT_FULL = `SELECT ${D_COLS2} ${D_FROM2} WHERE r.id = ?`;
1073
+ var dSelectMany = (n) => `SELECT ${D_COLS2} ${D_FROM2} WHERE r.id IN (${placeholders(n)})`;
1074
+ var D_SELECT_METADATA = `SELECT r.id AS id, b.mime_type AS mime_type, r.original_filename AS original_filename, b.size_bytes AS size_bytes, b.width AS width, b.height AS height, r.checksum_sha256 AS checksum_sha256 ${D_FROM2} WHERE r.id = ?`;
1075
+ var D_STREAM_INFO = `SELECT b.size_bytes AS size_bytes, 'sqlite' AS storage_backend, NULL AS object_storage_key ${D_FROM2} WHERE r.id = ?`;
1076
+ var D_CHUNK = `SELECT substr(b.data, ?, ?) AS chunk ${D_FROM2} WHERE r.id = ?`;
1077
+ var D_EXISTS = `SELECT 1 FROM zerobucket_image_refs WHERE id = ?`;
1078
+ var D_DELETE_REF2 = `DELETE FROM zerobucket_image_refs WHERE id = ? RETURNING checksum_sha256`;
1079
+ var dDeleteRefs = (n) => `DELETE FROM zerobucket_image_refs WHERE id IN (${placeholders(n)}) RETURNING id, checksum_sha256`;
1080
+ var D_DECREMENT2 = `UPDATE zerobucket_blobs SET ref_count = ref_count - ? WHERE checksum_sha256 = ? RETURNING ref_count`;
1081
+ var D_DELETE_EMPTY2 = `DELETE FROM zerobucket_blobs WHERE checksum_sha256 = ? AND ref_count <= 0`;
1082
+ var IN_CHUNK = 500;
1083
+ var ATTEMPT_BUSY_MS = 25;
1084
+ var DEFAULT_BUSY_TIMEOUT_MS = 1e4;
1085
+ function placeholders(n) {
1086
+ return Array.from({ length: n }, () => "?").join(", ");
1087
+ }
1088
+ function isoNow() {
1089
+ return (/* @__PURE__ */ new Date()).toISOString().replace("Z", "000+00:00");
1090
+ }
1091
+ function isBusy(exc) {
1092
+ const code = exc?.code;
1093
+ return typeof code === "string" && (code.startsWith("SQLITE_BUSY") || code.startsWith("SQLITE_LOCKED"));
1094
+ }
1095
+ var Mutex = class {
1096
+ tail = Promise.resolve();
1097
+ run(fn) {
1098
+ const prev = this.tail;
1099
+ let release;
1100
+ this.tail = new Promise((r) => release = r);
1101
+ return prev.then(fn).finally(release);
1102
+ }
1103
+ };
1104
+ var SQLiteBackend = class {
1105
+ constructor(opts) {
1106
+ this.opts = opts;
1107
+ if (!opts.path)
1108
+ throw new TypeError(
1109
+ 'SQLiteBackend requires a `path` (a file path or ":memory:")'
1110
+ );
1111
+ this.dedup = opts.dedup ?? false;
1112
+ this.busyTimeoutMs = opts.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS;
1113
+ if (this.dedup && opts.objectStorage) {
1114
+ throw new TypeError(
1115
+ "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."
1116
+ );
1117
+ }
1118
+ }
1119
+ opts;
1120
+ db;
1121
+ Database;
1122
+ readyPromise;
1123
+ closed = false;
1124
+ dedup;
1125
+ busyTimeoutMs;
1126
+ writeLock = new Mutex();
1127
+ // ---- lifecycle -------------------------------------------------------
1128
+ ready() {
1129
+ if (this.closed)
1130
+ return Promise.reject(
1131
+ new StorageError("This ZeroBucket instance has been closed")
1132
+ );
1133
+ this.readyPromise ??= this.init().catch((exc) => {
1134
+ this.readyPromise = void 0;
1135
+ throw exc;
1136
+ });
1137
+ return this.readyPromise;
1138
+ }
1139
+ async init() {
1140
+ try {
1141
+ const mod = await import("better-sqlite3");
1142
+ this.Database = mod.default ?? mod;
1143
+ } catch (cause) {
1144
+ throw new StorageError(
1145
+ "SQLiteBackend requires the 'better-sqlite3' package. Install it with: npm install better-sqlite3",
1146
+ { cause }
1147
+ );
1148
+ }
1149
+ const db = new this.Database(this.opts.path, { timeout: ATTEMPT_BUSY_MS });
1150
+ try {
1151
+ await this.retrying(() => {
1152
+ if (this.opts.path !== ":memory:" && db.pragma("journal_mode", { simple: true }) !== "wal") {
1153
+ db.pragma("journal_mode = WAL");
1154
+ }
1155
+ db.pragma("foreign_keys = ON");
1156
+ if (this.opts.autoMigrate ?? true)
1157
+ db.exec(this.dedup ? DEDUP_SCHEMA2 : SCHEMA2);
1158
+ });
1159
+ } catch (exc) {
1160
+ try {
1161
+ db.close();
1162
+ } catch {
1163
+ }
1164
+ throw new StorageError(
1165
+ `Could not open SQLite database ${JSON.stringify(this.opts.path)}: ${msg(exc)}`,
1166
+ { cause: exc }
1167
+ );
1168
+ }
1169
+ this.db = db;
1170
+ }
1171
+ async close() {
1172
+ this.closed = true;
1173
+ const db = this.db;
1174
+ this.db = void 0;
1175
+ this.readyPromise = void 0;
1176
+ await this.writeLock.run(async () => {
1177
+ db?.close();
1178
+ });
1179
+ }
1180
+ // ---- execution core --------------------------------------------------
1181
+ emit(operation, start, success, error, retryCount) {
1182
+ const cb = this.opts.onOperation;
1183
+ if (!cb) return;
1184
+ try {
1185
+ cb({
1186
+ operation,
1187
+ durationMs: performance.now() - start,
1188
+ success,
1189
+ error,
1190
+ retryCount
1191
+ });
1192
+ } catch {
1193
+ }
1194
+ }
1195
+ /** Re-run `fn` while another connection holds a lock, sleeping asynchronously between attempts. `fn` must be atomic. */
1196
+ async retrying(fn, onRetry) {
1197
+ const deadline = performance.now() + this.busyTimeoutMs;
1198
+ let attempt = 0;
1199
+ for (; ; ) {
1200
+ try {
1201
+ return fn();
1202
+ } catch (exc) {
1203
+ if (!isBusy(exc) || performance.now() >= deadline) throw exc;
1204
+ attempt += 1;
1205
+ onRetry?.(attempt);
1206
+ await sleep(
1207
+ Math.min(5 * 2 ** Math.min(attempt, 5), 100) + Math.random() * 5
1208
+ );
1209
+ }
1210
+ }
1211
+ }
1212
+ /** BEGIN IMMEDIATE takes the write lock up front, so a BUSY comes before any work and the whole unit is safely re-runnable. */
1213
+ tx(db, work) {
1214
+ db.exec("BEGIN IMMEDIATE");
1215
+ try {
1216
+ const result = work(db);
1217
+ db.exec("COMMIT");
1218
+ return result;
1219
+ } catch (exc) {
1220
+ try {
1221
+ db.exec("ROLLBACK");
1222
+ } catch {
1223
+ }
1224
+ throw exc;
1225
+ }
1226
+ }
1227
+ /**
1228
+ * All-or-nothing work on a CALLER-supplied handle. SAVEPOINT works whether or not the caller has an open
1229
+ * transaction: inside one it nests (a failure rolls back only our work); outside one it begins and, on
1230
+ * RELEASE, commits.
270
1231
  */
271
- async run(operation, connection, work, { tx = false } = {}) {
272
- if (connection) {
273
- const start2 = performance.now();
1232
+ atomic(db, work) {
1233
+ db.exec("SAVEPOINT zerobucket_atomic");
1234
+ try {
1235
+ const result = work(db);
1236
+ db.exec("RELEASE SAVEPOINT zerobucket_atomic");
1237
+ return result;
1238
+ } catch (exc) {
274
1239
  try {
275
- const result = await work(connection);
276
- this.emit(operation, start2, true, null, 0);
277
- return result;
278
- } catch (exc) {
279
- this.emit(operation, start2, false, msg(exc), 0);
280
- throw exc;
1240
+ db.exec("ROLLBACK TO SAVEPOINT zerobucket_atomic");
1241
+ db.exec("RELEASE SAVEPOINT zerobucket_atomic");
1242
+ } catch {
281
1243
  }
1244
+ throw exc;
282
1245
  }
283
- await this.ready();
1246
+ }
1247
+ async execute(operation, connection, mode, work) {
284
1248
  const start = performance.now();
285
- let attempt = 0;
286
- for (; ; ) {
287
- let client;
288
- let destroy = false;
289
- try {
290
- client = await this.pool.connect();
291
- if (tx) await client.query("BEGIN");
292
- const result = await work(client);
293
- if (tx) await client.query("COMMIT");
294
- this.emit(operation, start, true, null, attempt);
295
- return result;
296
- } catch (exc) {
297
- if (client && tx) {
298
- try {
299
- await client.query("ROLLBACK");
300
- } catch {
301
- destroy = true;
302
- }
303
- }
304
- if (isConnectionLevel(exc)) destroy = true;
305
- attempt += 1;
306
- if (attempt > this.maxRetries || !isRetryable(exc)) {
307
- this.emit(operation, start, false, msg(exc), attempt - 1);
308
- throw exc;
1249
+ let retries = 0;
1250
+ try {
1251
+ let result;
1252
+ if (connection) {
1253
+ if (typeof connection.prepare !== "function") {
1254
+ throw new TypeError(
1255
+ "SQLiteBackend expects a better-sqlite3 Database as `connection` (an object with prepare()), not a pg client."
1256
+ );
309
1257
  }
310
- } finally {
311
- client?.release(destroy);
1258
+ const db = connection;
1259
+ result = mode === "write" ? this.atomic(db, work) : work(db);
1260
+ } else {
1261
+ await this.ready();
1262
+ const db = this.db;
1263
+ const onRetry = (n) => retries = n;
1264
+ result = mode === "write" ? await this.writeLock.run(
1265
+ () => this.retrying(() => this.tx(db, work), onRetry)
1266
+ ) : await this.retrying(() => work(db), onRetry);
312
1267
  }
313
- await sleep(backoffDelayMs(attempt, this.retryBaseDelayMs));
1268
+ this.emit(operation, start, true, null, retries);
1269
+ return result;
1270
+ } catch (exc) {
1271
+ this.emit(operation, start, false, msg(exc), retries);
1272
+ throw exc;
314
1273
  }
315
1274
  }
316
1275
  // ---- StorageBackend --------------------------------------------------
317
1276
  async put(row, { connection } = {}) {
318
1277
  try {
319
- return await this.run("put", connection, async (q) => {
320
- const res = await q.query(INSERT, rowParams(row));
321
- return String(res.rows[0].id);
322
- });
1278
+ return await this.execute(
1279
+ "put",
1280
+ connection,
1281
+ "write",
1282
+ (db) => this.insertOne(db, row)
1283
+ );
323
1284
  } catch (exc) {
324
1285
  throw wrap("Failed to store image", exc);
325
1286
  }
326
1287
  }
1288
+ insertOne(db, r) {
1289
+ const id = randomUUID();
1290
+ const now = isoNow();
1291
+ if (this.dedup) {
1292
+ db.prepare(D_UPSERT).run(
1293
+ r.checksumSha256,
1294
+ r.data,
1295
+ r.mimeType,
1296
+ r.sizeBytes,
1297
+ r.width,
1298
+ r.height,
1299
+ 1,
1300
+ now
1301
+ );
1302
+ db.prepare(D_INSERT_REF2).run(
1303
+ id,
1304
+ r.checksumSha256,
1305
+ r.originalFilename,
1306
+ now,
1307
+ now
1308
+ );
1309
+ } else {
1310
+ db.prepare(INSERT2).run(
1311
+ id,
1312
+ r.data,
1313
+ r.mimeType,
1314
+ r.originalFilename,
1315
+ r.sizeBytes,
1316
+ r.width,
1317
+ r.height,
1318
+ r.checksumSha256,
1319
+ now,
1320
+ now
1321
+ );
1322
+ }
1323
+ return id;
1324
+ }
327
1325
  async putMany(rows, { connection } = {}) {
328
1326
  if (rows.length === 0) return [];
329
1327
  try {
330
- return await this.run(
331
- "put_many",
332
- connection,
333
- async (q) => {
334
- const ids = [];
335
- for (const r of rows)
336
- ids.push(String((await q.query(INSERT, rowParams(r))).rows[0].id));
337
- return ids;
338
- },
339
- { tx: true }
340
- );
1328
+ return await this.execute("put_many", connection, "write", (db) => {
1329
+ if (!this.dedup) return rows.map((r) => this.insertOne(db, r));
1330
+ const now = isoNow();
1331
+ const groups = /* @__PURE__ */ new Map();
1332
+ for (const r of rows) {
1333
+ const g = groups.get(r.checksumSha256);
1334
+ if (g) g.n += 1;
1335
+ else groups.set(r.checksumSha256, { row: r, n: 1 });
1336
+ }
1337
+ for (const [checksum, { row, n }] of groups) {
1338
+ db.prepare(D_UPSERT).run(
1339
+ checksum,
1340
+ row.data,
1341
+ row.mimeType,
1342
+ row.sizeBytes,
1343
+ row.width,
1344
+ row.height,
1345
+ n,
1346
+ now
1347
+ );
1348
+ }
1349
+ return rows.map((r) => {
1350
+ const id = randomUUID();
1351
+ db.prepare(D_INSERT_REF2).run(
1352
+ id,
1353
+ r.checksumSha256,
1354
+ r.originalFilename,
1355
+ now,
1356
+ now
1357
+ );
1358
+ return id;
1359
+ });
1360
+ });
341
1361
  } catch (exc) {
342
1362
  throw wrap("Failed to store image batch", exc);
343
1363
  }
344
1364
  }
345
1365
  async get(id, { connection } = {}) {
346
1366
  if (!isValidId(id)) return null;
1367
+ const norm = id.toLowerCase();
347
1368
  let row;
348
1369
  try {
349
- row = await this.run(
1370
+ row = await this.execute(
350
1371
  "get",
351
1372
  connection,
352
- async (q) => (await q.query(SELECT_FULL, [id])).rows[0]
1373
+ "read",
1374
+ (db) => db.prepare(this.dedup ? D_SELECT_FULL : SELECT_FULL2).get(norm)
353
1375
  );
354
1376
  } catch (exc) {
355
1377
  throw wrap("Failed to retrieve image", exc);
356
1378
  }
357
- return row ? this.toRecord(row, id) : null;
1379
+ return row ? hydrateRow(row, norm, this.opts.objectStorage) : null;
358
1380
  }
359
1381
  async getMany(ids, { connection } = {}) {
360
- const valid = ids.filter(isValidId);
1382
+ const valid = [
1383
+ ...new Set(ids.filter(isValidId).map((i) => i.toLowerCase()))
1384
+ ];
361
1385
  if (valid.length === 0) return [];
362
1386
  let rows;
363
1387
  try {
364
- rows = await this.run(
365
- "get_many",
366
- connection,
367
- async (q) => (await q.query(SELECT_MANY, [valid])).rows
368
- );
1388
+ rows = await this.execute("get_many", connection, "read", (db) => {
1389
+ const out = [];
1390
+ for (let i = 0; i < valid.length; i += IN_CHUNK) {
1391
+ const part = valid.slice(i, i + IN_CHUNK);
1392
+ out.push(
1393
+ ...db.prepare(
1394
+ this.dedup ? dSelectMany(part.length) : selectMany(part.length)
1395
+ ).all(...part)
1396
+ );
1397
+ }
1398
+ return out;
1399
+ });
369
1400
  } catch (exc) {
370
1401
  throw wrap("Failed to retrieve image batch", exc);
371
1402
  }
372
- return rows.map((r) => this.toRecord(r, String(r.id)));
1403
+ return mapLimit(
1404
+ rows,
1405
+ 4,
1406
+ (r) => hydrateRow(r, String(r.id), this.opts.objectStorage)
1407
+ );
373
1408
  }
374
1409
  async getMetadata(id, { connection } = {}) {
375
1410
  if (!isValidId(id)) return null;
376
1411
  let row;
377
1412
  try {
378
- row = await this.run(
1413
+ row = await this.execute(
379
1414
  "get_metadata",
380
1415
  connection,
381
- async (q) => (await q.query(SELECT_METADATA, [id])).rows[0]
1416
+ "read",
1417
+ (db) => db.prepare(this.dedup ? D_SELECT_METADATA : SELECT_METADATA2).get(id.toLowerCase())
382
1418
  );
383
1419
  } catch (exc) {
384
1420
  throw wrap("Failed to retrieve image metadata", exc);
@@ -396,25 +1432,37 @@ var PostgresBackend = class {
396
1432
  }
397
1433
  async getStream(id, opts) {
398
1434
  if (!isValidId(id)) return null;
1435
+ const norm = id.toLowerCase();
399
1436
  const { connection, chunkSize } = opts;
400
1437
  if (!Number.isInteger(chunkSize) || chunkSize <= 0)
401
1438
  throw new RangeError("chunkSize must be a positive integer");
402
1439
  let info;
403
1440
  try {
404
- info = await this.run(
1441
+ info = await this.execute(
405
1442
  "get_metadata",
406
1443
  connection,
407
- async (q) => (await q.query(SELECT_STREAM_INFO, [id])).rows[0]
1444
+ "read",
1445
+ (db) => db.prepare(this.dedup ? D_STREAM_INFO : STREAM_INFO).get(norm)
408
1446
  );
409
1447
  } catch (exc) {
410
1448
  throw wrap("Failed to retrieve image metadata", exc);
411
1449
  }
412
1450
  if (!info) return null;
413
- if (info.storage_backend === "object_storage")
414
- throw this.tieredWithoutStorage(id, info.object_storage_key);
415
1451
  const totalSize = info.size_bytes;
416
1452
  const start = opts.range?.start ?? 0;
417
1453
  const end = Math.min(opts.range?.end ?? totalSize - 1, totalSize - 1);
1454
+ if (info.storage_backend === "object_storage") {
1455
+ const os = this.opts.objectStorage;
1456
+ if (!os) throw tieredWithoutStorage(norm, info.object_storage_key);
1457
+ return streamFromObjectStorage(
1458
+ os,
1459
+ norm,
1460
+ info.object_storage_key,
1461
+ start,
1462
+ end,
1463
+ chunkSize
1464
+ );
1465
+ }
418
1466
  const self = this;
419
1467
  async function* generate() {
420
1468
  let offset = start;
@@ -424,23 +1472,24 @@ var PostgresBackend = class {
424
1472
  const length = Math.min(chunkSize, wanted - delivered);
425
1473
  let row;
426
1474
  try {
427
- row = await self.run(
1475
+ row = await self.execute(
428
1476
  "get_stream",
429
1477
  connection,
430
- async (q) => (await q.query(SELECT_CHUNK, [offset + 1, length, id])).rows[0]
1478
+ "read",
1479
+ (db) => db.prepare(self.dedup ? D_CHUNK : CHUNK).get(offset + 1, length, norm)
431
1480
  );
432
1481
  } catch (exc) {
433
1482
  throw wrap("Failed to stream image", exc);
434
1483
  }
435
1484
  if (!row) {
436
1485
  throw new StorageError(
437
- `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.`
1486
+ `Image ${JSON.stringify(norm)} 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.`
438
1487
  );
439
1488
  }
440
1489
  const chunk = row.chunk;
441
1490
  if (!chunk || chunk.length === 0) {
442
1491
  throw new StorageError(
443
- `Image ${JSON.stringify(id)} ended early while streaming (delivered ${delivered} of ${wanted} bytes).`
1492
+ `Image ${JSON.stringify(norm)} ended early while streaming (delivered ${delivered} of ${wanted} bytes).`
444
1493
  );
445
1494
  }
446
1495
  yield chunk;
@@ -452,25 +1501,74 @@ var PostgresBackend = class {
452
1501
  }
453
1502
  async delete(id, { connection } = {}) {
454
1503
  if (!isValidId(id)) return false;
1504
+ const norm = id.toLowerCase();
455
1505
  try {
456
- const row = await this.run(
1506
+ if (this.dedup) {
1507
+ return await this.execute("delete", connection, "write", (db) => {
1508
+ const ref = db.prepare(D_DELETE_REF2).get(norm);
1509
+ if (!ref) return false;
1510
+ const dec = db.prepare(D_DECREMENT2).get(1, ref.checksum_sha256);
1511
+ if (dec && dec.ref_count <= 0)
1512
+ db.prepare(D_DELETE_EMPTY2).run(ref.checksum_sha256);
1513
+ return true;
1514
+ });
1515
+ }
1516
+ const row = await this.execute(
457
1517
  "delete",
458
1518
  connection,
459
- async (q) => (await q.query(DELETE_RETURNING, [id])).rows[0]
1519
+ "write",
1520
+ (db) => db.prepare(DELETE_ONE).get(norm)
460
1521
  );
1522
+ if (row?.storage_backend === "object_storage")
1523
+ await this.cleanupObjects([row.object_storage_key], connection);
461
1524
  return row !== void 0;
462
1525
  } catch (exc) {
463
1526
  throw wrap("Failed to delete image", exc);
464
1527
  }
465
1528
  }
466
1529
  async deleteMany(ids, { connection } = {}) {
467
- const valid = ids.filter(isValidId);
1530
+ const valid = [
1531
+ ...new Set(ids.filter(isValidId).map((i) => i.toLowerCase()))
1532
+ ];
468
1533
  if (valid.length === 0) return [];
469
1534
  try {
470
- const rows = await this.run(
1535
+ if (this.dedup) {
1536
+ return await this.execute("delete_many", connection, "write", (db) => {
1537
+ const deleted = [];
1538
+ for (let i = 0; i < valid.length; i += IN_CHUNK) {
1539
+ const part = valid.slice(i, i + IN_CHUNK);
1540
+ deleted.push(...db.prepare(dDeleteRefs(part.length)).all(...part));
1541
+ }
1542
+ const counts = /* @__PURE__ */ new Map();
1543
+ for (const r of deleted)
1544
+ counts.set(
1545
+ r.checksum_sha256,
1546
+ (counts.get(r.checksum_sha256) ?? 0) + 1
1547
+ );
1548
+ for (const [checksum, n] of counts) {
1549
+ const dec = db.prepare(D_DECREMENT2).get(n, checksum);
1550
+ if (dec && dec.ref_count <= 0)
1551
+ db.prepare(D_DELETE_EMPTY2).run(checksum);
1552
+ }
1553
+ return deleted.map((r) => String(r.id));
1554
+ });
1555
+ }
1556
+ const rows = await this.execute(
471
1557
  "delete_many",
472
1558
  connection,
473
- async (q) => (await q.query(DELETE_MANY, [valid])).rows
1559
+ "write",
1560
+ (db) => {
1561
+ const out = [];
1562
+ for (let i = 0; i < valid.length; i += IN_CHUNK) {
1563
+ const part = valid.slice(i, i + IN_CHUNK);
1564
+ out.push(...db.prepare(deleteMany(part.length)).all(...part));
1565
+ }
1566
+ return out;
1567
+ }
1568
+ );
1569
+ await this.cleanupObjects(
1570
+ rows.filter((r) => r.storage_backend === "object_storage").map((r) => r.object_storage_key),
1571
+ connection
474
1572
  );
475
1573
  return rows.map((r) => String(r.id));
476
1574
  } catch (exc) {
@@ -480,56 +1578,116 @@ var PostgresBackend = class {
480
1578
  async exists(id, { connection } = {}) {
481
1579
  if (!isValidId(id)) return false;
482
1580
  try {
483
- return await this.run(
1581
+ return await this.execute(
484
1582
  "exists",
485
1583
  connection,
486
- async (q) => ((await q.query(EXISTS, [id])).rowCount ?? 0) > 0
1584
+ "read",
1585
+ (db) => db.prepare(this.dedup ? D_EXISTS : EXISTS2).get(id.toLowerCase()) !== void 0
487
1586
  );
488
1587
  } catch (exc) {
489
1588
  throw wrap("Failed to check image existence", exc);
490
1589
  }
491
1590
  }
492
- // ---- helpers ---------------------------------------------------------
493
- toRecord(row, id) {
494
- if (row.storage_backend === "object_storage")
495
- throw this.tieredWithoutStorage(id, row.object_storage_key);
496
- return {
497
- id: String(row.id),
498
- data: row.data,
499
- mimeType: row.mime_type,
500
- originalFilename: row.original_filename,
501
- sizeBytes: row.size_bytes,
502
- width: row.width,
503
- height: row.height,
504
- checksumSha256: row.checksum_sha256
505
- };
506
- }
507
- tieredWithoutStorage(id, key) {
508
- return new StorageError(
509
- `Image ${JSON.stringify(id)} is stored in object storage (key=${JSON.stringify(key)}) but object-storage tiering is not available in this version of the npm package yet. Read it from the Python package (configured with the same bucket), or wait for npm tiering support.`
1591
+ /**
1592
+ * Remove tiered objects AFTER their rows are gone (a failed object delete leaves a harmless orphan; the
1593
+ * reverse order could leave a row pointing at nothing). Skipped inside a caller-owned transaction, since the
1594
+ * commit outcome is unknown. Failures never fail the delete; they are reported through onOperation.
1595
+ */
1596
+ async cleanupObjects(keys, connection) {
1597
+ const os = this.opts.objectStorage;
1598
+ if (!os || connection || keys.length === 0) return;
1599
+ await Promise.all(
1600
+ keys.map(async (key) => {
1601
+ const start = performance.now();
1602
+ try {
1603
+ await os.delete(key);
1604
+ this.emit("object_storage_delete", start, true, null, 0);
1605
+ } catch (exc) {
1606
+ this.emit("object_storage_delete", start, false, msg(exc), 0);
1607
+ }
1608
+ })
510
1609
  );
511
1610
  }
1611
+ /**
1612
+ * Move an image's bytes into object storage. null = no such image, false = already tiered, true = tiered now.
1613
+ *
1614
+ * SAFETY (same guarantee as Postgres): the upload happens INSIDE a BEGIN IMMEDIATE transaction; if it fails the
1615
+ * transaction rolls back and the row is untouched.
1616
+ *
1617
+ * TRADEOFF (coarser than Postgres): SQLite has no row locks, so BEGIN IMMEDIATE locks the WHOLE DATABASE for the
1618
+ * upload. Other processes' writes wait; this process's writes queue behind an async lock; reads are not blocked.
1619
+ */
1620
+ async tierToObjectStorage(id, { connection } = {}) {
1621
+ if (this.dedup)
1622
+ throw new StorageError(
1623
+ "tierToObjectStorage() is not supported in dedup mode (a blob may be shared by many images)."
1624
+ );
1625
+ const os = this.opts.objectStorage;
1626
+ if (!os) {
1627
+ throw new StorageError(
1628
+ "tierToObjectStorage() requires objectStorage to be configured (new ZeroBucket({ sqlite, objectStorage: new ObjectStorage({ bucket }) }))."
1629
+ );
1630
+ }
1631
+ if (connection) {
1632
+ throw new StorageError(
1633
+ "tierToObjectStorage() does not accept `connection` on SQLite: it needs its own dedicated connection for the BEGIN IMMEDIATE transaction."
1634
+ );
1635
+ }
1636
+ if (this.opts.path === ":memory:") {
1637
+ throw new StorageError(
1638
+ "tierToObjectStorage() needs a database file: an in-memory SQLite database cannot be shared with its dedicated tiering connection."
1639
+ );
1640
+ }
1641
+ if (!isValidId(id)) return null;
1642
+ const norm = id.toLowerCase();
1643
+ const start = performance.now();
1644
+ let retries = 0;
1645
+ try {
1646
+ await this.ready();
1647
+ const result = await this.writeLock.run(async () => {
1648
+ const tdb = new this.Database(this.opts.path, {
1649
+ timeout: ATTEMPT_BUSY_MS
1650
+ });
1651
+ try {
1652
+ await this.retrying(
1653
+ () => tdb.exec("BEGIN IMMEDIATE"),
1654
+ (n) => retries = n
1655
+ );
1656
+ try {
1657
+ const row = tdb.prepare(SELECT_FOR_TIERING2).get(norm);
1658
+ if (!row) {
1659
+ tdb.exec("ROLLBACK");
1660
+ return null;
1661
+ }
1662
+ if (row.storage_backend !== "sqlite") {
1663
+ tdb.exec("ROLLBACK");
1664
+ return false;
1665
+ }
1666
+ await os.upload(norm, row.data, {
1667
+ mimeType: row.mime_type
1668
+ });
1669
+ tdb.prepare(UPDATE_AFTER_TIERING2).run(os.bucket, norm, isoNow(), norm);
1670
+ tdb.exec("COMMIT");
1671
+ return true;
1672
+ } catch (exc) {
1673
+ try {
1674
+ tdb.exec("ROLLBACK");
1675
+ } catch {
1676
+ }
1677
+ throw exc;
1678
+ }
1679
+ } finally {
1680
+ tdb.close();
1681
+ }
1682
+ });
1683
+ this.emit("tier_to_object_storage", start, true, null, retries);
1684
+ return result;
1685
+ } catch (exc) {
1686
+ this.emit("tier_to_object_storage", start, false, msg(exc), retries);
1687
+ throw wrap("Failed to tier image to object storage", exc);
1688
+ }
1689
+ }
512
1690
  };
513
- function rowParams(r) {
514
- return [
515
- r.data,
516
- r.mimeType,
517
- r.originalFilename,
518
- r.sizeBytes,
519
- r.width,
520
- r.height,
521
- r.checksumSha256
522
- ];
523
- }
524
- function wrap(prefix, exc) {
525
- return exc instanceof StorageError ? exc : new StorageError(`${prefix}: ${msg(exc)}`, { cause: exc });
526
- }
527
- function msg(exc) {
528
- return exc instanceof Error ? exc.message : String(exc);
529
- }
530
- function sleep(ms) {
531
- return new Promise((resolve) => setTimeout(resolve, ms));
532
- }
533
1691
 
534
1692
  // src/validation.ts
535
1693
  var FORMAT_TO_MIME = {
@@ -673,7 +1831,18 @@ var ZeroBucket = class _ZeroBucket {
673
1831
  beforeGet;
674
1832
  beforePut;
675
1833
  constructor(options = {}) {
676
- this.backend = options.backend ?? new PostgresBackend({
1834
+ if (options.sqlite !== void 0 && (options.connectionString || options.pool)) {
1835
+ throw new TypeError(
1836
+ "Choose one database: `sqlite` OR `connectionString`/`pool`, not both."
1837
+ );
1838
+ }
1839
+ this.backend = options.backend ?? (options.sqlite !== void 0 ? new SQLiteBackend({
1840
+ path: options.sqlite,
1841
+ autoMigrate: options.autoMigrate,
1842
+ dedup: options.dedup,
1843
+ objectStorage: options.objectStorage,
1844
+ onOperation: options.onOperation
1845
+ }) : new PostgresBackend({
677
1846
  connectionString: options.connectionString,
678
1847
  pool: options.pool,
679
1848
  autoMigrate: options.autoMigrate,
@@ -682,8 +1851,10 @@ var ZeroBucket = class _ZeroBucket {
682
1851
  poolMinSize: options.poolMinSize,
683
1852
  poolMaxSize: options.poolMaxSize,
684
1853
  poolTimeoutMs: options.poolTimeoutMs,
685
- onOperation: options.onOperation
686
- });
1854
+ onOperation: options.onOperation,
1855
+ objectStorage: options.objectStorage,
1856
+ dedup: options.dedup
1857
+ }));
687
1858
  this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
688
1859
  this.maxPixels = options.maxPixels ?? DEFAULT_MAX_PIXELS;
689
1860
  this.allowedFormats = options.allowedFormats ?? SUPPORTED_FORMATS;
@@ -804,7 +1975,7 @@ var ZeroBucket = class _ZeroBucket {
804
1975
  }
805
1976
  const results = new Array(images.length);
806
1977
  const prepared = [];
807
- await mapLimit(images, PREPARE_CONCURRENCY, async (image, i) => {
1978
+ await mapLimit2(images, PREPARE_CONCURRENCY, async (image, i) => {
808
1979
  try {
809
1980
  const row = await this.prepareRow(image, {
810
1981
  ...options,
@@ -1020,6 +2191,32 @@ var ZeroBucket = class _ZeroBucket {
1020
2191
  success: true
1021
2192
  }));
1022
2193
  }
2194
+ /**
2195
+ * Move an image's bytes from Postgres into object storage; reads, streams and
2196
+ * deletes keep working transparently. Returns true if tiered now, false if it was
2197
+ * already tiered (safe to re-run in a backfill). Throws ImageNotFoundError if missing.
2198
+ *
2199
+ * The upload runs inside the database transaction: if it fails, the row is left
2200
+ * completely untouched. Explicit and opt-in: put() never tiers automatically.
2201
+ * Not gated by beforeGet/beforePut (it is a maintenance operation, as in the Python package).
2202
+ */
2203
+ async tierToObjectStorage(imageId, options = {}) {
2204
+ const result = await this.backend.tierToObjectStorage(imageId, options);
2205
+ if (result === null) throw new ImageNotFoundError(imageId);
2206
+ return result;
2207
+ }
2208
+ /**
2209
+ * One-time, non-destructive copy of classic-mode images into a dedup instance's tables, keeping every
2210
+ * id. Call it on an instance created with `dedup: true`. Safe to re-run. See PostgresBackend for details.
2211
+ */
2212
+ async migrateClassicToDedup() {
2213
+ if (!this.backend.migrateClassicToDedup) {
2214
+ throw new StorageError(
2215
+ "This backend does not support migrateClassicToDedup()."
2216
+ );
2217
+ }
2218
+ return this.backend.migrateClassicToDedup();
2219
+ }
1023
2220
  /** Release the pool (if ZeroBucket created it). */
1024
2221
  close() {
1025
2222
  return this.backend.close();
@@ -1073,7 +2270,7 @@ async function readInput(image, filename, maxBytes) {
1073
2270
  "Unsupported image input. Pass a Buffer, Uint8Array, file path string, Blob/File, or a readable stream."
1074
2271
  );
1075
2272
  }
1076
- async function mapLimit(items, limit, fn) {
2273
+ async function mapLimit2(items, limit, fn) {
1077
2274
  let next = 0;
1078
2275
  const workers = Array.from(
1079
2276
  { length: Math.min(limit, items.length) },
@@ -1090,6 +2287,119 @@ function errText(exc) {
1090
2287
  return exc instanceof Error ? exc.message : String(exc);
1091
2288
  }
1092
2289
 
2290
+ // src/object-storage.ts
2291
+ var ObjectStorage = class {
2292
+ constructor(opts) {
2293
+ this.opts = opts;
2294
+ if (!opts.bucket)
2295
+ throw new TypeError("ObjectStorage requires a bucket name");
2296
+ this.bucket = opts.bucket;
2297
+ }
2298
+ opts;
2299
+ bucket;
2300
+ clientPromise;
2301
+ connection() {
2302
+ this.clientPromise ??= (async () => {
2303
+ let sdk;
2304
+ try {
2305
+ sdk = await import("@aws-sdk/client-s3");
2306
+ } catch (cause) {
2307
+ throw new StorageError(
2308
+ "Object-storage tiering requires the optional dependency '@aws-sdk/client-s3'. Install it with: npm install @aws-sdk/client-s3",
2309
+ { cause }
2310
+ );
2311
+ }
2312
+ const o = this.opts;
2313
+ const client = o.client ?? new sdk.S3Client({
2314
+ endpoint: o.endpoint,
2315
+ region: o.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1",
2316
+ credentials: o.credentials,
2317
+ forcePathStyle: o.forcePathStyle ?? o.endpoint !== void 0
2318
+ });
2319
+ return { sdk, client };
2320
+ })().catch((exc) => {
2321
+ this.clientPromise = void 0;
2322
+ throw exc;
2323
+ });
2324
+ return this.clientPromise;
2325
+ }
2326
+ async upload(key, data, { mimeType }) {
2327
+ const { sdk, client } = await this.connection();
2328
+ try {
2329
+ await client.send(
2330
+ new sdk.PutObjectCommand({
2331
+ Bucket: this.bucket,
2332
+ Key: key,
2333
+ Body: data,
2334
+ ContentType: mimeType
2335
+ })
2336
+ );
2337
+ } catch (exc) {
2338
+ throw new StorageError(
2339
+ `Failed to upload ${JSON.stringify(key)} to bucket ${JSON.stringify(this.bucket)}: ${msg2(exc)}`,
2340
+ { cause: exc }
2341
+ );
2342
+ }
2343
+ }
2344
+ async download(key) {
2345
+ return this.get(key, void 0);
2346
+ }
2347
+ async downloadRange(key, start, endInclusive) {
2348
+ return this.get(key, `bytes=${start}-${endInclusive}`);
2349
+ }
2350
+ async get(key, range) {
2351
+ const { sdk, client } = await this.connection();
2352
+ try {
2353
+ const res = await client.send(
2354
+ new sdk.GetObjectCommand({
2355
+ Bucket: this.bucket,
2356
+ Key: key,
2357
+ Range: range
2358
+ })
2359
+ );
2360
+ return Buffer.from(await res.Body.transformToByteArray());
2361
+ } catch (exc) {
2362
+ throw new StorageError(
2363
+ `Failed to download ${JSON.stringify(key)} from bucket ${JSON.stringify(this.bucket)}: ${msg2(exc)}`,
2364
+ { cause: exc }
2365
+ );
2366
+ }
2367
+ }
2368
+ async delete(key) {
2369
+ const { sdk, client } = await this.connection();
2370
+ try {
2371
+ await client.send(
2372
+ new sdk.DeleteObjectCommand({ Bucket: this.bucket, Key: key })
2373
+ );
2374
+ } catch (exc) {
2375
+ throw new StorageError(
2376
+ `Failed to delete ${JSON.stringify(key)} from bucket ${JSON.stringify(this.bucket)}: ${msg2(exc)}`,
2377
+ { cause: exc }
2378
+ );
2379
+ }
2380
+ }
2381
+ async exists(key) {
2382
+ const { sdk, client } = await this.connection();
2383
+ try {
2384
+ await client.send(
2385
+ new sdk.HeadObjectCommand({ Bucket: this.bucket, Key: key })
2386
+ );
2387
+ return true;
2388
+ } catch (exc) {
2389
+ const e = exc;
2390
+ if (e.name === "NotFound" || e.name === "NoSuchKey" || e.$metadata?.httpStatusCode === 404)
2391
+ return false;
2392
+ throw new StorageError(
2393
+ `Failed to check ${JSON.stringify(key)} in bucket ${JSON.stringify(this.bucket)}: ${msg2(exc)}`,
2394
+ { cause: exc }
2395
+ );
2396
+ }
2397
+ }
2398
+ };
2399
+ function msg2(exc) {
2400
+ return exc instanceof Error ? exc.message : String(exc);
2401
+ }
2402
+
1093
2403
  // src/validators/pdf.ts
1094
2404
  var DEFAULT_MAX_PDF_BYTES = 20 * 1024 * 1024;
1095
2405
  var PDFValidator = class {
@@ -1132,8 +2442,10 @@ export {
1132
2442
  ImageNotFoundError,
1133
2443
  ImageTooLargeError,
1134
2444
  ImageValidationError,
2445
+ ObjectStorage,
1135
2446
  PDFValidator,
1136
2447
  PostgresBackend,
2448
+ SQLiteBackend,
1137
2449
  SUPPORTED_FORMATS,
1138
2450
  StorageError,
1139
2451
  UnsupportedFormatError,