zerobucket 0.0.0-stage → 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.cjs ADDED
@@ -0,0 +1,2559 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var src_exports = {};
32
+ __export(src_exports, {
33
+ AccessDeniedError: () => AccessDeniedError,
34
+ ContentValidationError: () => ContentValidationError,
35
+ CorruptedImageError: () => CorruptedImageError,
36
+ DEFAULT_MAX_BYTES: () => DEFAULT_MAX_BYTES,
37
+ DEFAULT_MAX_PIXELS: () => DEFAULT_MAX_PIXELS,
38
+ DEFAULT_STREAM_CHUNK_SIZE: () => DEFAULT_STREAM_CHUNK_SIZE,
39
+ ImageNotFoundError: () => ImageNotFoundError,
40
+ ImageTooLargeError: () => ImageTooLargeError,
41
+ ImageValidationError: () => ImageValidationError,
42
+ ObjectStorage: () => ObjectStorage,
43
+ PDFValidator: () => PDFValidator,
44
+ PostgresBackend: () => PostgresBackend,
45
+ SQLiteBackend: () => SQLiteBackend,
46
+ SUPPORTED_FORMATS: () => SUPPORTED_FORMATS,
47
+ StorageError: () => StorageError,
48
+ UnsupportedFormatError: () => UnsupportedFormatError,
49
+ ZeroBucket: () => ZeroBucket,
50
+ ZeroBucketError: () => ZeroBucketError,
51
+ optimizeImage: () => optimizeImage,
52
+ readInput: () => readInput,
53
+ validateImage: () => validateImage
54
+ });
55
+ module.exports = __toCommonJS(src_exports);
56
+
57
+ // src/client.ts
58
+ var import_node_crypto2 = require("crypto");
59
+ var import_promises = require("fs/promises");
60
+ var import_node_path = require("path");
61
+
62
+ // src/errors.ts
63
+ var ZeroBucketError = class extends Error {
64
+ constructor(message, options) {
65
+ super(message, options);
66
+ this.name = new.target.name;
67
+ }
68
+ };
69
+ var ContentValidationError = class extends ZeroBucketError {
70
+ };
71
+ var ImageValidationError = class extends ContentValidationError {
72
+ };
73
+ var ImageTooLargeError = class extends ImageValidationError {
74
+ constructor(sizeBytes, maxBytes) {
75
+ super(
76
+ `Image is ${sizeBytes} bytes, which exceeds the maximum of ${maxBytes} bytes`
77
+ );
78
+ this.sizeBytes = sizeBytes;
79
+ this.maxBytes = maxBytes;
80
+ }
81
+ sizeBytes;
82
+ maxBytes;
83
+ };
84
+ var UnsupportedFormatError = class extends ImageValidationError {
85
+ constructor(detectedFormat, allowed) {
86
+ super(
87
+ `Detected format ${JSON.stringify(detectedFormat)} is not supported. Allowed formats: ${JSON.stringify([...allowed].sort())}`
88
+ );
89
+ this.detectedFormat = detectedFormat;
90
+ this.allowed = allowed;
91
+ }
92
+ detectedFormat;
93
+ allowed;
94
+ };
95
+ var CorruptedImageError = class extends ImageValidationError {
96
+ };
97
+ var ImageNotFoundError = class extends ZeroBucketError {
98
+ constructor(imageId) {
99
+ super(`No image found with id ${JSON.stringify(imageId)}`);
100
+ this.imageId = imageId;
101
+ }
102
+ imageId;
103
+ };
104
+ var StorageError = class extends ZeroBucketError {
105
+ };
106
+ var AccessDeniedError = class extends ZeroBucketError {
107
+ constructor(operation, imageId) {
108
+ super(
109
+ imageId !== void 0 ? `${operation} denied for image ${JSON.stringify(imageId)} by the beforeGet hook` : `${operation} denied by the beforePut hook`
110
+ );
111
+ this.operation = operation;
112
+ this.imageId = imageId;
113
+ }
114
+ operation;
115
+ imageId;
116
+ };
117
+
118
+ // src/adapters/shared.ts
119
+ var DEFAULT_STREAM_CHUNK_SIZE = 1024 * 1024;
120
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
121
+ function isValidId(id) {
122
+ return typeof id === "string" && UUID_RE.test(id);
123
+ }
124
+ function msg(exc) {
125
+ return exc instanceof Error ? exc.message : String(exc);
126
+ }
127
+ function wrap(prefix, exc) {
128
+ return exc instanceof StorageError ? exc : new StorageError(`${prefix}: ${msg(exc)}`, { cause: exc });
129
+ }
130
+ function sleep(ms) {
131
+ return new Promise((resolve) => setTimeout(resolve, ms));
132
+ }
133
+ async function mapLimit(items, limit, fn) {
134
+ const out = new Array(items.length);
135
+ let next = 0;
136
+ await Promise.all(
137
+ Array.from({ length: Math.min(limit, items.length) }, async () => {
138
+ while (next < items.length) {
139
+ const i = next++;
140
+ out[i] = await fn(items[i]);
141
+ }
142
+ })
143
+ );
144
+ return out;
145
+ }
146
+ function tieredWithoutStorage(id, key) {
147
+ return new StorageError(
148
+ `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.`
149
+ );
150
+ }
151
+ async function hydrateRow(row, id, os) {
152
+ let data;
153
+ if (row.storage_backend === "object_storage") {
154
+ if (!os) throw tieredWithoutStorage(id, row.object_storage_key);
155
+ data = await os.download(row.object_storage_key);
156
+ if (data.length !== row.size_bytes) {
157
+ throw new StorageError(
158
+ `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.`
159
+ );
160
+ }
161
+ } else {
162
+ data = row.data;
163
+ }
164
+ return {
165
+ id: String(row.id),
166
+ data,
167
+ mimeType: row.mime_type,
168
+ originalFilename: row.original_filename,
169
+ sizeBytes: row.size_bytes,
170
+ width: row.width,
171
+ height: row.height,
172
+ checksumSha256: row.checksum_sha256
173
+ };
174
+ }
175
+ async function* streamFromObjectStorage(os, id, key, start, end, chunkSize) {
176
+ let offset = start;
177
+ while (offset <= end) {
178
+ const last = Math.min(offset + chunkSize - 1, end);
179
+ const expected = last - offset + 1;
180
+ const chunk = await os.downloadRange(key, offset, last);
181
+ if (chunk.length !== expected) {
182
+ throw new StorageError(
183
+ `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.`
184
+ );
185
+ }
186
+ yield chunk;
187
+ offset = last + 1;
188
+ }
189
+ }
190
+
191
+ // src/adapters/postgres.ts
192
+ var SCHEMA = `
193
+ CREATE TABLE IF NOT EXISTS zerobucket_images (
194
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
195
+ data BYTEA NOT NULL,
196
+ mime_type TEXT NOT NULL,
197
+ original_filename TEXT,
198
+ size_bytes INTEGER NOT NULL,
199
+ width INTEGER,
200
+ height INTEGER,
201
+ checksum_sha256 CHAR(64) NOT NULL,
202
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
203
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
204
+ );
205
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_checksum ON zerobucket_images (checksum_sha256);
206
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_created_at ON zerobucket_images (created_at);
207
+
208
+ -- Object-storage tiering columns (additive; identical to the Python package).
209
+ ALTER TABLE zerobucket_images ALTER COLUMN data DROP NOT NULL;
210
+ ALTER TABLE zerobucket_images
211
+ ADD COLUMN IF NOT EXISTS storage_backend TEXT NOT NULL DEFAULT 'postgres';
212
+ ALTER TABLE zerobucket_images ADD COLUMN IF NOT EXISTS object_storage_bucket TEXT;
213
+ ALTER TABLE zerobucket_images ADD COLUMN IF NOT EXISTS object_storage_key TEXT;
214
+ DO $$
215
+ BEGIN
216
+ IF NOT EXISTS (
217
+ SELECT 1 FROM pg_constraint WHERE conname = 'zerobucket_storage_location_check'
218
+ ) THEN
219
+ ALTER TABLE zerobucket_images ADD CONSTRAINT zerobucket_storage_location_check
220
+ CHECK (
221
+ (storage_backend = 'postgres'
222
+ AND data IS NOT NULL
223
+ AND object_storage_key IS NULL
224
+ AND object_storage_bucket IS NULL)
225
+ OR
226
+ (storage_backend = 'object_storage'
227
+ AND data IS NULL
228
+ AND object_storage_key IS NOT NULL
229
+ AND object_storage_bucket IS NOT NULL)
230
+ );
231
+ END IF;
232
+ END $$;
233
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_storage_backend
234
+ ON zerobucket_images (storage_backend) WHERE storage_backend != 'postgres';
235
+ `;
236
+ var INSERT = `
237
+ INSERT INTO zerobucket_images
238
+ (data, mime_type, original_filename, size_bytes, width, height, checksum_sha256)
239
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
240
+ RETURNING id;`;
241
+ var SELECT_FULL = `
242
+ SELECT id, data, mime_type, original_filename, size_bytes, width, height,
243
+ checksum_sha256, storage_backend, object_storage_bucket, object_storage_key
244
+ FROM zerobucket_images WHERE id = $1;`;
245
+ var SELECT_MANY = `
246
+ SELECT id, data, mime_type, original_filename, size_bytes, width, height,
247
+ checksum_sha256, storage_backend, object_storage_bucket, object_storage_key
248
+ FROM zerobucket_images WHERE id = ANY($1::uuid[]);`;
249
+ var SELECT_METADATA = `
250
+ SELECT id, mime_type, original_filename, size_bytes, width, height, checksum_sha256
251
+ FROM zerobucket_images WHERE id = $1;`;
252
+ var SELECT_CHUNK = `SELECT substring(data FROM $1::int FOR $2::int) AS chunk FROM zerobucket_images WHERE id = $3;`;
253
+ var SELECT_STREAM_INFO = `SELECT size_bytes, storage_backend, object_storage_key FROM zerobucket_images WHERE id = $1;`;
254
+ var DELETE_RETURNING = `DELETE FROM zerobucket_images WHERE id = $1 RETURNING storage_backend, object_storage_key;`;
255
+ var DELETE_MANY = `DELETE FROM zerobucket_images WHERE id = ANY($1::uuid[]) RETURNING id, storage_backend, object_storage_key;`;
256
+ var EXISTS = `SELECT 1 FROM zerobucket_images WHERE id = $1;`;
257
+ var SELECT_FOR_TIERING = `SELECT data, mime_type, size_bytes, storage_backend FROM zerobucket_images WHERE id = $1 FOR UPDATE;`;
258
+ 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;`;
259
+ var DEDUP_SCHEMA = `
260
+ CREATE TABLE IF NOT EXISTS zerobucket_blobs (
261
+ checksum_sha256 CHAR(64) PRIMARY KEY,
262
+ data BYTEA NOT NULL,
263
+ mime_type TEXT NOT NULL,
264
+ size_bytes INTEGER NOT NULL,
265
+ width INTEGER,
266
+ height INTEGER,
267
+ ref_count INTEGER NOT NULL DEFAULT 0,
268
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
269
+ );
270
+ CREATE TABLE IF NOT EXISTS zerobucket_image_refs (
271
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
272
+ checksum_sha256 CHAR(64) NOT NULL REFERENCES zerobucket_blobs(checksum_sha256),
273
+ original_filename TEXT,
274
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
275
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
276
+ );
277
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_image_refs_checksum ON zerobucket_image_refs (checksum_sha256);
278
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_image_refs_created_at ON zerobucket_image_refs (created_at);
279
+ `;
280
+ var D_UPSERT_BLOB = `
281
+ INSERT INTO zerobucket_blobs (checksum_sha256, data, mime_type, size_bytes, width, height, ref_count)
282
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
283
+ ON CONFLICT (checksum_sha256) DO UPDATE SET ref_count = zerobucket_blobs.ref_count + EXCLUDED.ref_count;`;
284
+ var D_INSERT_REF = `INSERT INTO zerobucket_image_refs (checksum_sha256, original_filename) VALUES ($1, $2) RETURNING id;`;
285
+ var D_COLS = `r.id, b.data, b.mime_type, r.original_filename, b.size_bytes, b.width, b.height, r.checksum_sha256,
286
+ 'postgres'::text AS storage_backend, NULL::text AS object_storage_bucket, NULL::text AS object_storage_key`;
287
+ var D_FROM = `FROM zerobucket_image_refs r JOIN zerobucket_blobs b ON r.checksum_sha256 = b.checksum_sha256`;
288
+ var D_DELETE_REF = `DELETE FROM zerobucket_image_refs WHERE id = $1 RETURNING checksum_sha256;`;
289
+ var D_DELETE_REFS = `DELETE FROM zerobucket_image_refs WHERE id = ANY($1::uuid[]) RETURNING id, checksum_sha256;`;
290
+ var D_DECREMENT = `UPDATE zerobucket_blobs SET ref_count = ref_count - $1 WHERE checksum_sha256 = $2 RETURNING ref_count;`;
291
+ var D_DELETE_EMPTY = `DELETE FROM zerobucket_blobs WHERE checksum_sha256 = ANY($1::text[]) AND ref_count <= 0;`;
292
+ var CLASSIC_SQL = {
293
+ schema: SCHEMA,
294
+ selectFull: SELECT_FULL,
295
+ selectMany: SELECT_MANY,
296
+ selectMetadata: SELECT_METADATA,
297
+ selectStreamInfo: SELECT_STREAM_INFO,
298
+ selectChunk: SELECT_CHUNK,
299
+ exists: EXISTS
300
+ };
301
+ var DEDUP_SQL = {
302
+ schema: DEDUP_SCHEMA,
303
+ selectFull: `SELECT ${D_COLS} ${D_FROM} WHERE r.id = $1;`,
304
+ selectMany: `SELECT ${D_COLS} ${D_FROM} WHERE r.id = ANY($1::uuid[]);`,
305
+ 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;`,
306
+ selectStreamInfo: `SELECT b.size_bytes, 'postgres'::text AS storage_backend, NULL::text AS object_storage_key ${D_FROM} WHERE r.id = $1;`,
307
+ selectChunk: `SELECT substring(b.data FROM $1::int FOR $2::int) AS chunk ${D_FROM} WHERE r.id = $3;`,
308
+ exists: `SELECT 1 FROM zerobucket_image_refs WHERE id = $1;`
309
+ };
310
+ var RETRYABLE_SQLSTATES = /* @__PURE__ */ new Set([
311
+ "40001",
312
+ // serialization_failure
313
+ "40P01",
314
+ // deadlock_detected
315
+ "08000",
316
+ // connection_exception
317
+ "08003",
318
+ // connection_does_not_exist
319
+ "08006",
320
+ // connection_failure
321
+ "08001",
322
+ // sqlclient_unable_to_establish_sqlconnection
323
+ "08004",
324
+ // sqlserver_rejected_establishment_of_sqlconnection
325
+ "57P01",
326
+ // admin_shutdown
327
+ "57P02",
328
+ // crash_shutdown
329
+ "57P03",
330
+ // cannot_connect_now
331
+ "53000",
332
+ // insufficient_resources
333
+ "53300"
334
+ // too_many_connections
335
+ ]);
336
+ var RETRYABLE_NODE_CODES = /* @__PURE__ */ new Set([
337
+ "ECONNRESET",
338
+ "ECONNREFUSED",
339
+ "ETIMEDOUT",
340
+ "EPIPE",
341
+ "ECONNABORTED"
342
+ ]);
343
+ var CONNECTION_MESSAGE_RE = /connection terminated|connection timeout|timeout exceeded when trying to connect|client has encountered a connection error/i;
344
+ function isRetryable(exc) {
345
+ if (!(exc instanceof Error)) return false;
346
+ const code = exc.code;
347
+ if (typeof code === "string") {
348
+ if (RETRYABLE_SQLSTATES.has(code) || RETRYABLE_NODE_CODES.has(code))
349
+ return true;
350
+ }
351
+ return CONNECTION_MESSAGE_RE.test(exc.message);
352
+ }
353
+ function isConnectionLevel(exc) {
354
+ if (!(exc instanceof Error)) return false;
355
+ const code = exc.code;
356
+ if (typeof code === "string" && (RETRYABLE_NODE_CODES.has(code) || code.startsWith("08") || code.startsWith("57"))) {
357
+ return true;
358
+ }
359
+ return CONNECTION_MESSAGE_RE.test(exc.message);
360
+ }
361
+ var MAX_BACKOFF_MS = 2e3;
362
+ function backoffDelayMs(attempt, baseDelayMs) {
363
+ const exponential = baseDelayMs * 2 ** (attempt - 1);
364
+ const jitter = Math.random() * baseDelayMs;
365
+ return Math.min(exponential + jitter, MAX_BACKOFF_MS);
366
+ }
367
+ var PostgresBackend = class {
368
+ constructor(opts) {
369
+ this.opts = opts;
370
+ if (!opts.connectionString && !opts.pool) {
371
+ throw new TypeError(
372
+ "PostgresBackend requires either `connectionString` or `pool`"
373
+ );
374
+ }
375
+ this.ownsPool = !opts.pool;
376
+ this.pool = opts.pool;
377
+ this.maxRetries = opts.maxRetries ?? 3;
378
+ this.retryBaseDelayMs = opts.retryBaseDelayMs ?? 100;
379
+ this.autoMigrate = opts.autoMigrate ?? true;
380
+ this.dedup = opts.dedup ?? false;
381
+ if (this.dedup && opts.objectStorage) {
382
+ throw new TypeError(
383
+ "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."
384
+ );
385
+ }
386
+ this.sql = this.dedup ? DEDUP_SQL : CLASSIC_SQL;
387
+ }
388
+ opts;
389
+ pool;
390
+ ownsPool;
391
+ readyPromise;
392
+ closed = false;
393
+ maxRetries;
394
+ retryBaseDelayMs;
395
+ autoMigrate;
396
+ dedup;
397
+ sql;
398
+ // ---- lifecycle -------------------------------------------------------
399
+ ready() {
400
+ if (this.closed)
401
+ return Promise.reject(
402
+ new StorageError("This ZeroBucket instance has been closed")
403
+ );
404
+ this.readyPromise ??= this.init().catch((exc) => {
405
+ this.readyPromise = void 0;
406
+ throw exc;
407
+ });
408
+ return this.readyPromise;
409
+ }
410
+ async init() {
411
+ if (!this.pool) {
412
+ let pg;
413
+ try {
414
+ const mod = await import("pg");
415
+ pg = mod.default ?? mod;
416
+ } catch (cause) {
417
+ throw new StorageError(
418
+ "PostgresBackend requires the 'pg' package. Install it with: npm install pg",
419
+ { cause }
420
+ );
421
+ }
422
+ const config = {
423
+ connectionString: this.opts.connectionString,
424
+ min: this.opts.poolMinSize ?? 1,
425
+ max: this.opts.poolMaxSize ?? 5,
426
+ connectionTimeoutMillis: this.opts.poolTimeoutMs ?? 1e4
427
+ };
428
+ this.pool = new pg.Pool(config);
429
+ this.pool.on("error", () => {
430
+ });
431
+ }
432
+ try {
433
+ if (this.autoMigrate) await this.migrate();
434
+ else await this.pool.query("SELECT 1");
435
+ } catch (exc) {
436
+ if (this.ownsPool) {
437
+ await this.pool.end().catch(() => {
438
+ });
439
+ this.pool = void 0;
440
+ }
441
+ throw exc instanceof StorageError ? exc : new StorageError(`Could not connect to PostgreSQL: ${msg(exc)}`, {
442
+ cause: exc
443
+ });
444
+ }
445
+ }
446
+ /** Create the schema if missing. Serialized across processes with an advisory lock. */
447
+ async migrate() {
448
+ const pool = this.pool;
449
+ let client;
450
+ try {
451
+ client = await pool.connect();
452
+ } catch (exc) {
453
+ throw new StorageError(`Could not connect to PostgreSQL: ${msg(exc)}`, {
454
+ cause: exc
455
+ });
456
+ }
457
+ try {
458
+ await client.query("BEGIN");
459
+ await client.query(
460
+ "SELECT pg_advisory_xact_lock(hashtext('zerobucket_migrate'))"
461
+ );
462
+ await client.query(this.sql.schema);
463
+ await client.query("COMMIT");
464
+ client.release();
465
+ } catch (exc) {
466
+ await client.query("ROLLBACK").catch(() => {
467
+ });
468
+ client.release(true);
469
+ throw new StorageError(`Migration failed: ${msg(exc)}`, { cause: exc });
470
+ }
471
+ }
472
+ async close() {
473
+ this.closed = true;
474
+ const pool = this.pool;
475
+ this.pool = void 0;
476
+ this.readyPromise = void 0;
477
+ if (pool && this.ownsPool) await pool.end();
478
+ }
479
+ // ---- execution core --------------------------------------------------
480
+ emit(operation, start, success, error, retryCount) {
481
+ const cb = this.opts.onOperation;
482
+ if (!cb) return;
483
+ try {
484
+ cb({
485
+ operation,
486
+ durationMs: performance.now() - start,
487
+ success,
488
+ error,
489
+ retryCount
490
+ });
491
+ } catch {
492
+ }
493
+ }
494
+ /**
495
+ * Run `work` and return its result, emitting exactly one OperationEvent.
496
+ *
497
+ * connection given -> run once on it, no BEGIN/COMMIT, no retry.
498
+ * connection absent -> pooled; `tx` wraps the work in BEGIN/COMMIT; transient
499
+ * failures retry the WHOLE closure on a fresh connection.
500
+ */
501
+ async run(operation, connection, work, { tx = false } = {}) {
502
+ if (connection) {
503
+ if (!isQueryable(connection)) {
504
+ throw new TypeError(
505
+ "PostgresBackend expects a pg client/pool client as `connection` (an object with query()), not a SQLite Database."
506
+ );
507
+ }
508
+ const start2 = performance.now();
509
+ try {
510
+ const result = tx ? await this.atomic(connection, work) : await work(connection);
511
+ this.emit(operation, start2, true, null, 0);
512
+ return result;
513
+ } catch (exc) {
514
+ this.emit(operation, start2, false, msg(exc), 0);
515
+ throw exc;
516
+ }
517
+ }
518
+ await this.ready();
519
+ const start = performance.now();
520
+ let attempt = 0;
521
+ for (; ; ) {
522
+ let client;
523
+ let destroy = false;
524
+ try {
525
+ client = await this.pool.connect();
526
+ if (tx) await client.query("BEGIN");
527
+ const result = await work(client);
528
+ if (tx) await client.query("COMMIT");
529
+ this.emit(operation, start, true, null, attempt);
530
+ return result;
531
+ } catch (exc) {
532
+ if (client && tx) {
533
+ try {
534
+ await client.query("ROLLBACK");
535
+ } catch {
536
+ destroy = true;
537
+ }
538
+ }
539
+ if (isConnectionLevel(exc)) destroy = true;
540
+ attempt += 1;
541
+ if (attempt > this.maxRetries || !isRetryable(exc)) {
542
+ this.emit(operation, start, false, msg(exc), attempt - 1);
543
+ throw exc;
544
+ }
545
+ } finally {
546
+ client?.release(destroy);
547
+ }
548
+ await sleep(backoffDelayMs(attempt, this.retryBaseDelayMs));
549
+ }
550
+ }
551
+ /**
552
+ * Make multi-statement work all-or-nothing on a CALLER-supplied connection.
553
+ * - Caller has an open transaction -> SAVEPOINT: a failure rolls back only our work and the
554
+ * caller's transaction stays usable; success joins their transaction.
555
+ * - No open transaction (an autocommit pg.Client) -> open and commit our own, so a failure
556
+ * can never leave half-applied state (e.g. an inflated dedup ref_count).
557
+ */
558
+ async atomic(conn, work) {
559
+ let ownTx = false;
560
+ try {
561
+ await conn.query("SAVEPOINT zerobucket_atomic");
562
+ } catch (exc) {
563
+ if (exc.code !== "25P01") throw exc;
564
+ ownTx = true;
565
+ await conn.query("BEGIN");
566
+ }
567
+ try {
568
+ const result = await work(conn);
569
+ await conn.query(
570
+ ownTx ? "COMMIT" : "RELEASE SAVEPOINT zerobucket_atomic"
571
+ );
572
+ return result;
573
+ } catch (exc) {
574
+ try {
575
+ await conn.query(
576
+ ownTx ? "ROLLBACK" : "ROLLBACK TO SAVEPOINT zerobucket_atomic"
577
+ );
578
+ } catch {
579
+ }
580
+ throw exc;
581
+ }
582
+ }
583
+ // ---- StorageBackend --------------------------------------------------
584
+ async put(row, { connection } = {}) {
585
+ try {
586
+ if (this.dedup) {
587
+ return await this.run(
588
+ "put",
589
+ connection,
590
+ async (q) => {
591
+ await q.query(D_UPSERT_BLOB, blobParams(row, 1));
592
+ return String(
593
+ (await q.query(D_INSERT_REF, [
594
+ row.checksumSha256,
595
+ row.originalFilename
596
+ ])).rows[0].id
597
+ );
598
+ },
599
+ { tx: true }
600
+ );
601
+ }
602
+ return await this.run("put", connection, async (q) => {
603
+ const res = await q.query(INSERT, rowParams(row));
604
+ return String(res.rows[0].id);
605
+ });
606
+ } catch (exc) {
607
+ throw wrap("Failed to store image", exc);
608
+ }
609
+ }
610
+ async putMany(rows, { connection } = {}) {
611
+ if (rows.length === 0) return [];
612
+ try {
613
+ if (this.dedup) {
614
+ return await this.run(
615
+ "put_many",
616
+ connection,
617
+ async (q) => {
618
+ const groups = /* @__PURE__ */ new Map();
619
+ for (const r of rows) {
620
+ const g = groups.get(r.checksumSha256);
621
+ if (g) g.n += 1;
622
+ else groups.set(r.checksumSha256, { row: r, n: 1 });
623
+ }
624
+ for (const checksum of [...groups.keys()].sort()) {
625
+ const g = groups.get(checksum);
626
+ await q.query(D_UPSERT_BLOB, blobParams(g.row, g.n));
627
+ }
628
+ const ids = [];
629
+ for (const r of rows) {
630
+ ids.push(
631
+ String(
632
+ (await q.query(D_INSERT_REF, [
633
+ r.checksumSha256,
634
+ r.originalFilename
635
+ ])).rows[0].id
636
+ )
637
+ );
638
+ }
639
+ return ids;
640
+ },
641
+ { tx: true }
642
+ );
643
+ }
644
+ return await this.run(
645
+ "put_many",
646
+ connection,
647
+ async (q) => {
648
+ const ids = [];
649
+ for (const r of rows)
650
+ ids.push(String((await q.query(INSERT, rowParams(r))).rows[0].id));
651
+ return ids;
652
+ },
653
+ { tx: true }
654
+ );
655
+ } catch (exc) {
656
+ throw wrap("Failed to store image batch", exc);
657
+ }
658
+ }
659
+ async get(id, { connection } = {}) {
660
+ if (!isValidId(id)) return null;
661
+ let row;
662
+ try {
663
+ row = await this.run(
664
+ "get",
665
+ connection,
666
+ async (q) => (await q.query(this.sql.selectFull, [id])).rows[0]
667
+ );
668
+ } catch (exc) {
669
+ throw wrap("Failed to retrieve image", exc);
670
+ }
671
+ return row ? this.hydrate(row, id) : null;
672
+ }
673
+ async getMany(ids, { connection } = {}) {
674
+ const valid = ids.filter(isValidId);
675
+ if (valid.length === 0) return [];
676
+ let rows;
677
+ try {
678
+ rows = await this.run(
679
+ "get_many",
680
+ connection,
681
+ async (q) => (await q.query(this.sql.selectMany, [valid])).rows
682
+ );
683
+ } catch (exc) {
684
+ throw wrap("Failed to retrieve image batch", exc);
685
+ }
686
+ return mapLimit(rows, 4, (r) => this.hydrate(r, String(r.id)));
687
+ }
688
+ async getMetadata(id, { connection } = {}) {
689
+ if (!isValidId(id)) return null;
690
+ let row;
691
+ try {
692
+ row = await this.run(
693
+ "get_metadata",
694
+ connection,
695
+ async (q) => (await q.query(this.sql.selectMetadata, [id])).rows[0]
696
+ );
697
+ } catch (exc) {
698
+ throw wrap("Failed to retrieve image metadata", exc);
699
+ }
700
+ if (!row) return null;
701
+ return {
702
+ id: String(row.id),
703
+ mimeType: row.mime_type,
704
+ originalFilename: row.original_filename,
705
+ sizeBytes: row.size_bytes,
706
+ width: row.width,
707
+ height: row.height,
708
+ checksumSha256: row.checksum_sha256
709
+ };
710
+ }
711
+ async getStream(id, opts) {
712
+ if (!isValidId(id)) return null;
713
+ const { connection, chunkSize } = opts;
714
+ if (!Number.isInteger(chunkSize) || chunkSize <= 0)
715
+ throw new RangeError("chunkSize must be a positive integer");
716
+ let info;
717
+ try {
718
+ info = await this.run(
719
+ "get_metadata",
720
+ connection,
721
+ async (q) => (await q.query(this.sql.selectStreamInfo, [id])).rows[0]
722
+ );
723
+ } catch (exc) {
724
+ throw wrap("Failed to retrieve image metadata", exc);
725
+ }
726
+ if (!info) return null;
727
+ const totalSize = info.size_bytes;
728
+ const start = opts.range?.start ?? 0;
729
+ const end = Math.min(opts.range?.end ?? totalSize - 1, totalSize - 1);
730
+ if (info.storage_backend === "object_storage") {
731
+ const os = this.opts.objectStorage;
732
+ if (!os) throw tieredWithoutStorage(id, info.object_storage_key);
733
+ return streamFromObjectStorage(
734
+ os,
735
+ id,
736
+ info.object_storage_key,
737
+ start,
738
+ end,
739
+ chunkSize
740
+ );
741
+ }
742
+ const self = this;
743
+ async function* generate() {
744
+ let offset = start;
745
+ let delivered = 0;
746
+ const wanted = Math.max(0, end - start + 1);
747
+ while (delivered < wanted) {
748
+ const length = Math.min(chunkSize, wanted - delivered);
749
+ let row;
750
+ try {
751
+ row = await self.run(
752
+ "get_stream",
753
+ connection,
754
+ async (q) => (await q.query(self.sql.selectChunk, [offset + 1, length, id])).rows[0]
755
+ );
756
+ } catch (exc) {
757
+ throw wrap("Failed to stream image", exc);
758
+ }
759
+ if (!row) {
760
+ throw new StorageError(
761
+ `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.`
762
+ );
763
+ }
764
+ const chunk = row.chunk;
765
+ if (!chunk || chunk.length === 0) {
766
+ throw new StorageError(
767
+ `Image ${JSON.stringify(id)} ended early while streaming (delivered ${delivered} of ${wanted} bytes).`
768
+ );
769
+ }
770
+ yield chunk;
771
+ offset += chunk.length;
772
+ delivered += chunk.length;
773
+ }
774
+ }
775
+ return generate();
776
+ }
777
+ async delete(id, { connection } = {}) {
778
+ if (!isValidId(id)) return false;
779
+ if (this.dedup) return this.deleteDedup(id, connection);
780
+ try {
781
+ const row = await this.run(
782
+ "delete",
783
+ connection,
784
+ async (q) => (await q.query(DELETE_RETURNING, [id])).rows[0]
785
+ );
786
+ if (row?.storage_backend === "object_storage")
787
+ await this.cleanupObject([row.object_storage_key], connection);
788
+ return row !== void 0;
789
+ } catch (exc) {
790
+ throw wrap("Failed to delete image", exc);
791
+ }
792
+ }
793
+ async deleteMany(ids, { connection } = {}) {
794
+ const valid = ids.filter(isValidId);
795
+ if (valid.length === 0) return [];
796
+ if (this.dedup) return this.deleteManyDedup(valid, connection);
797
+ try {
798
+ const rows = await this.run(
799
+ "delete_many",
800
+ connection,
801
+ async (q) => (await q.query(DELETE_MANY, [valid])).rows
802
+ );
803
+ await this.cleanupObject(
804
+ rows.filter((r) => r.storage_backend === "object_storage").map((r) => r.object_storage_key),
805
+ connection
806
+ );
807
+ return rows.map((r) => String(r.id));
808
+ } catch (exc) {
809
+ throw wrap("Failed to delete image batch", exc);
810
+ }
811
+ }
812
+ async exists(id, { connection } = {}) {
813
+ if (!isValidId(id)) return false;
814
+ try {
815
+ return await this.run(
816
+ "exists",
817
+ connection,
818
+ async (q) => ((await q.query(this.sql.exists, [id])).rowCount ?? 0) > 0
819
+ );
820
+ } catch (exc) {
821
+ throw wrap("Failed to check image existence", exc);
822
+ }
823
+ }
824
+ // ---- dedup deletes / migration ---------------------------------------
825
+ async deleteDedup(id, connection) {
826
+ try {
827
+ return await this.run(
828
+ "delete",
829
+ connection,
830
+ async (q) => {
831
+ const ref = (await q.query(D_DELETE_REF, [id])).rows[0];
832
+ if (!ref) return false;
833
+ const checksum = ref.checksum_sha256;
834
+ const dec = (await q.query(D_DECREMENT, [1, checksum])).rows[0];
835
+ if (dec && dec.ref_count <= 0)
836
+ await q.query(D_DELETE_EMPTY, [[checksum]]);
837
+ return true;
838
+ },
839
+ { tx: true }
840
+ );
841
+ } catch (exc) {
842
+ throw wrap("Failed to delete image", exc);
843
+ }
844
+ }
845
+ async deleteManyDedup(ids, connection) {
846
+ try {
847
+ return await this.run(
848
+ "delete_many",
849
+ connection,
850
+ async (q) => {
851
+ const deleted = (await q.query(D_DELETE_REFS, [ids])).rows;
852
+ if (deleted.length === 0) return [];
853
+ const counts = /* @__PURE__ */ new Map();
854
+ for (const r of deleted)
855
+ counts.set(
856
+ r.checksum_sha256,
857
+ (counts.get(r.checksum_sha256) ?? 0) + 1
858
+ );
859
+ const emptied = [];
860
+ for (const checksum of [...counts.keys()].sort()) {
861
+ const dec = (await q.query(D_DECREMENT, [counts.get(checksum), checksum])).rows[0];
862
+ if (dec && dec.ref_count <= 0) emptied.push(checksum);
863
+ }
864
+ if (emptied.length > 0) await q.query(D_DELETE_EMPTY, [emptied]);
865
+ return deleted.map((r) => String(r.id));
866
+ },
867
+ { tx: true }
868
+ );
869
+ } catch (exc) {
870
+ throw wrap("Failed to delete image batch", exc);
871
+ }
872
+ }
873
+ /**
874
+ * One-time, NON-DESTRUCTIVE copy of every row in the classic `zerobucket_images` table into this
875
+ * dedup instance's tables, preserving every id (external references keep working) and
876
+ * deduplicating identical content on the way. The classic table is never modified.
877
+ *
878
+ * Differences from the Python version, all deliberate: rows are copied in small batches (bounded
879
+ * memory instead of loading the whole table); re-running is safe (already-migrated ids are
880
+ * skipped and reported, not an error); and classic rows already tiered to object storage are
881
+ * refused up front with a clear message (their bytes are not in the database to copy).
882
+ * Still all-or-nothing: one transaction.
883
+ */
884
+ async migrateClassicToDedup() {
885
+ if (!this.dedup) {
886
+ throw new StorageError(
887
+ "migrateClassicToDedup() must be called on an instance created with dedup: true."
888
+ );
889
+ }
890
+ await this.ready();
891
+ const client = await this.pool.connect();
892
+ let destroy = false;
893
+ try {
894
+ await client.query("BEGIN");
895
+ await client.query(
896
+ "SELECT pg_advisory_xact_lock(hashtext('zerobucket_migrate_dedup'))"
897
+ );
898
+ if (!(await client.query("SELECT to_regclass('zerobucket_images') AS t")).rows[0].t) {
899
+ throw new StorageError(
900
+ "No classic zerobucket_images table found in this database -- nothing to migrate."
901
+ );
902
+ }
903
+ const hasTierCol = (await client.query(
904
+ "SELECT 1 FROM information_schema.columns WHERE table_name = 'zerobucket_images' AND column_name = 'storage_backend'"
905
+ )).rowCount;
906
+ if (hasTierCol) {
907
+ const tiered = (await client.query(
908
+ "SELECT count(*)::int AS n FROM zerobucket_images WHERE storage_backend <> 'postgres'"
909
+ )).rows[0].n;
910
+ if (tiered > 0) {
911
+ throw new StorageError(
912
+ `${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.`
913
+ );
914
+ }
915
+ }
916
+ const seen = /* @__PURE__ */ new Set();
917
+ let migrated = 0;
918
+ let already = 0;
919
+ let cursor = null;
920
+ const BATCH = 20;
921
+ for (; ; ) {
922
+ const page = cursor ? await client.query(
923
+ `SELECT id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, created_at::text AS created_at_txt
924
+ FROM zerobucket_images WHERE (created_at, id) > ($1::timestamptz, $2::uuid) ORDER BY created_at, id LIMIT ${BATCH}`,
925
+ [cursor.createdAt, cursor.id]
926
+ ) : await client.query(
927
+ `SELECT id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, created_at::text AS created_at_txt
928
+ FROM zerobucket_images ORDER BY created_at, id LIMIT ${BATCH}`
929
+ );
930
+ if (page.rows.length === 0) break;
931
+ const last = page.rows[page.rows.length - 1];
932
+ if (cursor && cursor.createdAt === last.created_at_txt && cursor.id === last.id) {
933
+ throw new StorageError(
934
+ "Migration made no progress (keyset cursor did not advance); aborting instead of looping."
935
+ );
936
+ }
937
+ cursor = { createdAt: last.created_at_txt, id: last.id };
938
+ const have = new Set(
939
+ (await client.query(
940
+ "SELECT id FROM zerobucket_image_refs WHERE id = ANY($1::uuid[])",
941
+ [page.rows.map((r) => r.id)]
942
+ )).rows.map((r) => String(r.id))
943
+ );
944
+ const fresh = page.rows.filter((r) => !have.has(String(r.id)));
945
+ already += page.rows.length - fresh.length;
946
+ const groups = /* @__PURE__ */ new Map();
947
+ for (const r of fresh) {
948
+ const g = groups.get(r.checksum_sha256);
949
+ if (g) g.n += 1;
950
+ else groups.set(r.checksum_sha256, { row: r, n: 1 });
951
+ }
952
+ for (const checksum of [...groups.keys()].sort()) {
953
+ const { row, n } = groups.get(checksum);
954
+ await client.query(D_UPSERT_BLOB, [
955
+ checksum,
956
+ row.data,
957
+ row.mime_type,
958
+ row.size_bytes,
959
+ row.width,
960
+ row.height,
961
+ n
962
+ ]);
963
+ }
964
+ for (const r of fresh) {
965
+ await client.query(
966
+ "INSERT INTO zerobucket_image_refs (id, checksum_sha256, original_filename, created_at) VALUES ($1, $2, $3, $4::timestamptz)",
967
+ [r.id, r.checksum_sha256, r.original_filename, r.created_at_txt]
968
+ );
969
+ seen.add(r.checksum_sha256);
970
+ migrated += 1;
971
+ }
972
+ }
973
+ await client.query("COMMIT");
974
+ return {
975
+ imagesMigrated: migrated,
976
+ distinctBlobsCreated: seen.size,
977
+ duplicateReferencesFound: migrated - seen.size,
978
+ alreadyMigrated: already
979
+ };
980
+ } catch (exc) {
981
+ try {
982
+ await client.query("ROLLBACK");
983
+ } catch {
984
+ destroy = true;
985
+ }
986
+ throw exc instanceof StorageError ? exc : new StorageError(`Migration to dedup failed: ${msg(exc)}`, {
987
+ cause: exc
988
+ });
989
+ } finally {
990
+ client.release(destroy);
991
+ }
992
+ }
993
+ // ---- helpers ---------------------------------------------------------
994
+ hydrate(row, id) {
995
+ return hydrateRow(row, id, this.opts.objectStorage);
996
+ }
997
+ /**
998
+ * Remove tiered objects AFTER their rows are gone. Ordering is deliberate:
999
+ * once the row is deleted the image is correctly "not found" to everyone, so
1000
+ * a failed object delete only leaves a harmless orphan, whereas deleting the
1001
+ * object first and then failing the row delete would leave a row pointing at nothing.
1002
+ *
1003
+ * Never runs inside a caller-owned transaction (`connection`): we cannot know
1004
+ * whether it will commit, and destroying data before the commit outcome is known
1005
+ * could leave a rolled-back row pointing at a deleted object. In that case the
1006
+ * object stays until you remove it yourself after committing (objectStorage.delete(id)).
1007
+ * Failures are reported through onOperation and never fail the delete itself.
1008
+ */
1009
+ async cleanupObject(keys, connection) {
1010
+ const os = this.opts.objectStorage;
1011
+ if (!os || connection || keys.length === 0) return;
1012
+ await Promise.all(
1013
+ keys.map(async (key) => {
1014
+ const start = performance.now();
1015
+ try {
1016
+ await os.delete(key);
1017
+ this.emit("object_storage_delete", start, true, null, 0);
1018
+ } catch (exc) {
1019
+ this.emit("object_storage_delete", start, false, msg(exc), 0);
1020
+ }
1021
+ })
1022
+ );
1023
+ }
1024
+ /**
1025
+ * Move an image's bytes out of Postgres into object storage, replacing the
1026
+ * row's `data` with a pointer. Returns:
1027
+ * null - no such image
1028
+ * false - already tiered (a safe no-op, so backfills can be re-run)
1029
+ * true - tiered just now
1030
+ *
1031
+ * SAFETY: the upload happens INSIDE the same transaction as the row lock
1032
+ * (SELECT ... FOR UPDATE) and the UPDATE that flips the row. If the upload
1033
+ * fails, the transaction rolls back and the row is untouched: there is no
1034
+ * moment when the bytes exist nowhere, or when a row claims to be tiered
1035
+ * without a completed upload.
1036
+ *
1037
+ * TRADEOFF: the row lock is held for the whole upload. Concurrent writes
1038
+ * to THIS image (delete, tier) wait; every other row is unaffected, and plain
1039
+ * reads are never blocked (Postgres MVCC). Meant for explicit maintenance, not hot paths.
1040
+ *
1041
+ * Retry is safe: a transient error replays the whole closure, and the
1042
+ * deterministic key (the image id) makes the re-upload an idempotent overwrite.
1043
+ */
1044
+ async tierToObjectStorage(id, { connection } = {}) {
1045
+ if (this.dedup) {
1046
+ throw new StorageError(
1047
+ "tierToObjectStorage() is not supported in dedup mode (a blob may be shared by many images)."
1048
+ );
1049
+ }
1050
+ const os = this.opts.objectStorage;
1051
+ if (!os) {
1052
+ throw new StorageError(
1053
+ "tierToObjectStorage() requires objectStorage to be configured (new ZeroBucket({ objectStorage: new ObjectStorage({ bucket }) }))."
1054
+ );
1055
+ }
1056
+ if (!isValidId(id)) return null;
1057
+ const key = id.toLowerCase();
1058
+ try {
1059
+ return await this.run(
1060
+ "tier_to_object_storage",
1061
+ connection,
1062
+ async (q) => {
1063
+ const row = (await q.query(SELECT_FOR_TIERING, [id])).rows[0];
1064
+ if (!row) return null;
1065
+ if (row.storage_backend !== "postgres") return false;
1066
+ await os.upload(key, row.data, { mimeType: row.mime_type });
1067
+ await q.query(UPDATE_AFTER_TIERING, [os.bucket, key, id]);
1068
+ return true;
1069
+ },
1070
+ { tx: true }
1071
+ );
1072
+ } catch (exc) {
1073
+ throw wrap("Failed to tier image to object storage", exc);
1074
+ }
1075
+ }
1076
+ };
1077
+ function blobParams(r, refs) {
1078
+ return [
1079
+ r.checksumSha256,
1080
+ r.data,
1081
+ r.mimeType,
1082
+ r.sizeBytes,
1083
+ r.width,
1084
+ r.height,
1085
+ refs
1086
+ ];
1087
+ }
1088
+ function rowParams(r) {
1089
+ return [
1090
+ r.data,
1091
+ r.mimeType,
1092
+ r.originalFilename,
1093
+ r.sizeBytes,
1094
+ r.width,
1095
+ r.height,
1096
+ r.checksumSha256
1097
+ ];
1098
+ }
1099
+ function isQueryable(c) {
1100
+ return typeof c.query === "function";
1101
+ }
1102
+
1103
+ // src/adapters/sqlite.ts
1104
+ var import_node_crypto = require("crypto");
1105
+ var SCHEMA2 = `
1106
+ CREATE TABLE IF NOT EXISTS zerobucket_images (
1107
+ id TEXT PRIMARY KEY,
1108
+ data BLOB,
1109
+ mime_type TEXT NOT NULL,
1110
+ original_filename TEXT,
1111
+ size_bytes INTEGER NOT NULL,
1112
+ width INTEGER,
1113
+ height INTEGER,
1114
+ checksum_sha256 TEXT NOT NULL,
1115
+ created_at TEXT NOT NULL,
1116
+ updated_at TEXT NOT NULL,
1117
+ storage_backend TEXT NOT NULL DEFAULT 'sqlite',
1118
+ object_storage_bucket TEXT,
1119
+ object_storage_key TEXT,
1120
+ CHECK (
1121
+ (storage_backend = 'sqlite'
1122
+ AND data IS NOT NULL
1123
+ AND object_storage_key IS NULL
1124
+ AND object_storage_bucket IS NULL)
1125
+ OR
1126
+ (storage_backend = 'object_storage'
1127
+ AND data IS NULL
1128
+ AND object_storage_key IS NOT NULL
1129
+ AND object_storage_bucket IS NOT NULL)
1130
+ )
1131
+ );
1132
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_checksum ON zerobucket_images (checksum_sha256);
1133
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_created_at ON zerobucket_images (created_at);
1134
+ `;
1135
+ var DEDUP_SCHEMA2 = `
1136
+ CREATE TABLE IF NOT EXISTS zerobucket_blobs (
1137
+ checksum_sha256 TEXT PRIMARY KEY,
1138
+ data BLOB NOT NULL,
1139
+ mime_type TEXT NOT NULL,
1140
+ size_bytes INTEGER NOT NULL,
1141
+ width INTEGER,
1142
+ height INTEGER,
1143
+ ref_count INTEGER NOT NULL DEFAULT 0,
1144
+ created_at TEXT NOT NULL
1145
+ );
1146
+ CREATE TABLE IF NOT EXISTS zerobucket_image_refs (
1147
+ id TEXT PRIMARY KEY,
1148
+ checksum_sha256 TEXT NOT NULL REFERENCES zerobucket_blobs(checksum_sha256),
1149
+ original_filename TEXT,
1150
+ created_at TEXT NOT NULL,
1151
+ updated_at TEXT NOT NULL
1152
+ );
1153
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_image_refs_checksum ON zerobucket_image_refs (checksum_sha256);
1154
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_image_refs_created_at ON zerobucket_image_refs (created_at);
1155
+ `;
1156
+ var COLS = `id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, storage_backend, object_storage_bucket, object_storage_key`;
1157
+ var INSERT2 = `INSERT INTO zerobucket_images (id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
1158
+ var SELECT_FULL2 = `SELECT ${COLS} FROM zerobucket_images WHERE id = ?`;
1159
+ var selectMany = (n) => `SELECT ${COLS} FROM zerobucket_images WHERE id IN (${placeholders(n)})`;
1160
+ var SELECT_METADATA2 = `SELECT id, mime_type, original_filename, size_bytes, width, height, checksum_sha256 FROM zerobucket_images WHERE id = ?`;
1161
+ var STREAM_INFO = `SELECT size_bytes, storage_backend, object_storage_key FROM zerobucket_images WHERE id = ?`;
1162
+ var CHUNK = `SELECT substr(data, ?, ?) AS chunk FROM zerobucket_images WHERE id = ?`;
1163
+ var EXISTS2 = `SELECT 1 FROM zerobucket_images WHERE id = ?`;
1164
+ var DELETE_ONE = `DELETE FROM zerobucket_images WHERE id = ? RETURNING storage_backend, object_storage_key`;
1165
+ var deleteMany = (n) => `DELETE FROM zerobucket_images WHERE id IN (${placeholders(n)}) RETURNING id, storage_backend, object_storage_key`;
1166
+ var SELECT_FOR_TIERING2 = `SELECT data, mime_type, size_bytes, storage_backend FROM zerobucket_images WHERE id = ?`;
1167
+ var UPDATE_AFTER_TIERING2 = `UPDATE zerobucket_images SET data = NULL, storage_backend = 'object_storage', object_storage_bucket = ?, object_storage_key = ?, updated_at = ? WHERE id = ?`;
1168
+ var D_FROM2 = `FROM zerobucket_image_refs r JOIN zerobucket_blobs b ON r.checksum_sha256 = b.checksum_sha256`;
1169
+ 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`;
1170
+ 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`;
1171
+ var D_INSERT_REF2 = `INSERT INTO zerobucket_image_refs (id, checksum_sha256, original_filename, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`;
1172
+ var D_SELECT_FULL = `SELECT ${D_COLS2} ${D_FROM2} WHERE r.id = ?`;
1173
+ var dSelectMany = (n) => `SELECT ${D_COLS2} ${D_FROM2} WHERE r.id IN (${placeholders(n)})`;
1174
+ 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 = ?`;
1175
+ 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 = ?`;
1176
+ var D_CHUNK = `SELECT substr(b.data, ?, ?) AS chunk ${D_FROM2} WHERE r.id = ?`;
1177
+ var D_EXISTS = `SELECT 1 FROM zerobucket_image_refs WHERE id = ?`;
1178
+ var D_DELETE_REF2 = `DELETE FROM zerobucket_image_refs WHERE id = ? RETURNING checksum_sha256`;
1179
+ var dDeleteRefs = (n) => `DELETE FROM zerobucket_image_refs WHERE id IN (${placeholders(n)}) RETURNING id, checksum_sha256`;
1180
+ var D_DECREMENT2 = `UPDATE zerobucket_blobs SET ref_count = ref_count - ? WHERE checksum_sha256 = ? RETURNING ref_count`;
1181
+ var D_DELETE_EMPTY2 = `DELETE FROM zerobucket_blobs WHERE checksum_sha256 = ? AND ref_count <= 0`;
1182
+ var IN_CHUNK = 500;
1183
+ var ATTEMPT_BUSY_MS = 25;
1184
+ var DEFAULT_BUSY_TIMEOUT_MS = 1e4;
1185
+ function placeholders(n) {
1186
+ return Array.from({ length: n }, () => "?").join(", ");
1187
+ }
1188
+ function isoNow() {
1189
+ return (/* @__PURE__ */ new Date()).toISOString().replace("Z", "000+00:00");
1190
+ }
1191
+ function isBusy(exc) {
1192
+ const code = exc?.code;
1193
+ return typeof code === "string" && (code.startsWith("SQLITE_BUSY") || code.startsWith("SQLITE_LOCKED"));
1194
+ }
1195
+ var Mutex = class {
1196
+ tail = Promise.resolve();
1197
+ run(fn) {
1198
+ const prev = this.tail;
1199
+ let release;
1200
+ this.tail = new Promise((r) => release = r);
1201
+ return prev.then(fn).finally(release);
1202
+ }
1203
+ };
1204
+ var SQLiteBackend = class {
1205
+ constructor(opts) {
1206
+ this.opts = opts;
1207
+ if (!opts.path)
1208
+ throw new TypeError(
1209
+ 'SQLiteBackend requires a `path` (a file path or ":memory:")'
1210
+ );
1211
+ this.dedup = opts.dedup ?? false;
1212
+ this.busyTimeoutMs = opts.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS;
1213
+ if (this.dedup && opts.objectStorage) {
1214
+ throw new TypeError(
1215
+ "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."
1216
+ );
1217
+ }
1218
+ }
1219
+ opts;
1220
+ db;
1221
+ Database;
1222
+ readyPromise;
1223
+ closed = false;
1224
+ dedup;
1225
+ busyTimeoutMs;
1226
+ writeLock = new Mutex();
1227
+ // ---- lifecycle -------------------------------------------------------
1228
+ ready() {
1229
+ if (this.closed)
1230
+ return Promise.reject(
1231
+ new StorageError("This ZeroBucket instance has been closed")
1232
+ );
1233
+ this.readyPromise ??= this.init().catch((exc) => {
1234
+ this.readyPromise = void 0;
1235
+ throw exc;
1236
+ });
1237
+ return this.readyPromise;
1238
+ }
1239
+ async init() {
1240
+ try {
1241
+ const mod = await import("better-sqlite3");
1242
+ this.Database = mod.default ?? mod;
1243
+ } catch (cause) {
1244
+ throw new StorageError(
1245
+ "SQLiteBackend requires the 'better-sqlite3' package. Install it with: npm install better-sqlite3",
1246
+ { cause }
1247
+ );
1248
+ }
1249
+ const db = new this.Database(this.opts.path, { timeout: ATTEMPT_BUSY_MS });
1250
+ try {
1251
+ await this.retrying(() => {
1252
+ if (this.opts.path !== ":memory:" && db.pragma("journal_mode", { simple: true }) !== "wal") {
1253
+ db.pragma("journal_mode = WAL");
1254
+ }
1255
+ db.pragma("foreign_keys = ON");
1256
+ if (this.opts.autoMigrate ?? true)
1257
+ db.exec(this.dedup ? DEDUP_SCHEMA2 : SCHEMA2);
1258
+ });
1259
+ } catch (exc) {
1260
+ try {
1261
+ db.close();
1262
+ } catch {
1263
+ }
1264
+ throw new StorageError(
1265
+ `Could not open SQLite database ${JSON.stringify(this.opts.path)}: ${msg(exc)}`,
1266
+ { cause: exc }
1267
+ );
1268
+ }
1269
+ this.db = db;
1270
+ }
1271
+ async close() {
1272
+ this.closed = true;
1273
+ const db = this.db;
1274
+ this.db = void 0;
1275
+ this.readyPromise = void 0;
1276
+ await this.writeLock.run(async () => {
1277
+ db?.close();
1278
+ });
1279
+ }
1280
+ // ---- execution core --------------------------------------------------
1281
+ emit(operation, start, success, error, retryCount) {
1282
+ const cb = this.opts.onOperation;
1283
+ if (!cb) return;
1284
+ try {
1285
+ cb({
1286
+ operation,
1287
+ durationMs: performance.now() - start,
1288
+ success,
1289
+ error,
1290
+ retryCount
1291
+ });
1292
+ } catch {
1293
+ }
1294
+ }
1295
+ /** Re-run `fn` while another connection holds a lock, sleeping asynchronously between attempts. `fn` must be atomic. */
1296
+ async retrying(fn, onRetry) {
1297
+ const deadline = performance.now() + this.busyTimeoutMs;
1298
+ let attempt = 0;
1299
+ for (; ; ) {
1300
+ try {
1301
+ return fn();
1302
+ } catch (exc) {
1303
+ if (!isBusy(exc) || performance.now() >= deadline) throw exc;
1304
+ attempt += 1;
1305
+ onRetry?.(attempt);
1306
+ await sleep(
1307
+ Math.min(5 * 2 ** Math.min(attempt, 5), 100) + Math.random() * 5
1308
+ );
1309
+ }
1310
+ }
1311
+ }
1312
+ /** BEGIN IMMEDIATE takes the write lock up front, so a BUSY comes before any work and the whole unit is safely re-runnable. */
1313
+ tx(db, work) {
1314
+ db.exec("BEGIN IMMEDIATE");
1315
+ try {
1316
+ const result = work(db);
1317
+ db.exec("COMMIT");
1318
+ return result;
1319
+ } catch (exc) {
1320
+ try {
1321
+ db.exec("ROLLBACK");
1322
+ } catch {
1323
+ }
1324
+ throw exc;
1325
+ }
1326
+ }
1327
+ /**
1328
+ * All-or-nothing work on a CALLER-supplied handle. SAVEPOINT works whether or not the caller has an open
1329
+ * transaction: inside one it nests (a failure rolls back only our work); outside one it begins and, on
1330
+ * RELEASE, commits.
1331
+ */
1332
+ atomic(db, work) {
1333
+ db.exec("SAVEPOINT zerobucket_atomic");
1334
+ try {
1335
+ const result = work(db);
1336
+ db.exec("RELEASE SAVEPOINT zerobucket_atomic");
1337
+ return result;
1338
+ } catch (exc) {
1339
+ try {
1340
+ db.exec("ROLLBACK TO SAVEPOINT zerobucket_atomic");
1341
+ db.exec("RELEASE SAVEPOINT zerobucket_atomic");
1342
+ } catch {
1343
+ }
1344
+ throw exc;
1345
+ }
1346
+ }
1347
+ async execute(operation, connection, mode, work) {
1348
+ const start = performance.now();
1349
+ let retries = 0;
1350
+ try {
1351
+ let result;
1352
+ if (connection) {
1353
+ if (typeof connection.prepare !== "function") {
1354
+ throw new TypeError(
1355
+ "SQLiteBackend expects a better-sqlite3 Database as `connection` (an object with prepare()), not a pg client."
1356
+ );
1357
+ }
1358
+ const db = connection;
1359
+ result = mode === "write" ? this.atomic(db, work) : work(db);
1360
+ } else {
1361
+ await this.ready();
1362
+ const db = this.db;
1363
+ const onRetry = (n) => retries = n;
1364
+ result = mode === "write" ? await this.writeLock.run(
1365
+ () => this.retrying(() => this.tx(db, work), onRetry)
1366
+ ) : await this.retrying(() => work(db), onRetry);
1367
+ }
1368
+ this.emit(operation, start, true, null, retries);
1369
+ return result;
1370
+ } catch (exc) {
1371
+ this.emit(operation, start, false, msg(exc), retries);
1372
+ throw exc;
1373
+ }
1374
+ }
1375
+ // ---- StorageBackend --------------------------------------------------
1376
+ async put(row, { connection } = {}) {
1377
+ try {
1378
+ return await this.execute(
1379
+ "put",
1380
+ connection,
1381
+ "write",
1382
+ (db) => this.insertOne(db, row)
1383
+ );
1384
+ } catch (exc) {
1385
+ throw wrap("Failed to store image", exc);
1386
+ }
1387
+ }
1388
+ insertOne(db, r) {
1389
+ const id = (0, import_node_crypto.randomUUID)();
1390
+ const now = isoNow();
1391
+ if (this.dedup) {
1392
+ db.prepare(D_UPSERT).run(
1393
+ r.checksumSha256,
1394
+ r.data,
1395
+ r.mimeType,
1396
+ r.sizeBytes,
1397
+ r.width,
1398
+ r.height,
1399
+ 1,
1400
+ now
1401
+ );
1402
+ db.prepare(D_INSERT_REF2).run(
1403
+ id,
1404
+ r.checksumSha256,
1405
+ r.originalFilename,
1406
+ now,
1407
+ now
1408
+ );
1409
+ } else {
1410
+ db.prepare(INSERT2).run(
1411
+ id,
1412
+ r.data,
1413
+ r.mimeType,
1414
+ r.originalFilename,
1415
+ r.sizeBytes,
1416
+ r.width,
1417
+ r.height,
1418
+ r.checksumSha256,
1419
+ now,
1420
+ now
1421
+ );
1422
+ }
1423
+ return id;
1424
+ }
1425
+ async putMany(rows, { connection } = {}) {
1426
+ if (rows.length === 0) return [];
1427
+ try {
1428
+ return await this.execute("put_many", connection, "write", (db) => {
1429
+ if (!this.dedup) return rows.map((r) => this.insertOne(db, r));
1430
+ const now = isoNow();
1431
+ const groups = /* @__PURE__ */ new Map();
1432
+ for (const r of rows) {
1433
+ const g = groups.get(r.checksumSha256);
1434
+ if (g) g.n += 1;
1435
+ else groups.set(r.checksumSha256, { row: r, n: 1 });
1436
+ }
1437
+ for (const [checksum, { row, n }] of groups) {
1438
+ db.prepare(D_UPSERT).run(
1439
+ checksum,
1440
+ row.data,
1441
+ row.mimeType,
1442
+ row.sizeBytes,
1443
+ row.width,
1444
+ row.height,
1445
+ n,
1446
+ now
1447
+ );
1448
+ }
1449
+ return rows.map((r) => {
1450
+ const id = (0, import_node_crypto.randomUUID)();
1451
+ db.prepare(D_INSERT_REF2).run(
1452
+ id,
1453
+ r.checksumSha256,
1454
+ r.originalFilename,
1455
+ now,
1456
+ now
1457
+ );
1458
+ return id;
1459
+ });
1460
+ });
1461
+ } catch (exc) {
1462
+ throw wrap("Failed to store image batch", exc);
1463
+ }
1464
+ }
1465
+ async get(id, { connection } = {}) {
1466
+ if (!isValidId(id)) return null;
1467
+ const norm = id.toLowerCase();
1468
+ let row;
1469
+ try {
1470
+ row = await this.execute(
1471
+ "get",
1472
+ connection,
1473
+ "read",
1474
+ (db) => db.prepare(this.dedup ? D_SELECT_FULL : SELECT_FULL2).get(norm)
1475
+ );
1476
+ } catch (exc) {
1477
+ throw wrap("Failed to retrieve image", exc);
1478
+ }
1479
+ return row ? hydrateRow(row, norm, this.opts.objectStorage) : null;
1480
+ }
1481
+ async getMany(ids, { connection } = {}) {
1482
+ const valid = [
1483
+ ...new Set(ids.filter(isValidId).map((i) => i.toLowerCase()))
1484
+ ];
1485
+ if (valid.length === 0) return [];
1486
+ let rows;
1487
+ try {
1488
+ rows = await this.execute("get_many", connection, "read", (db) => {
1489
+ const out = [];
1490
+ for (let i = 0; i < valid.length; i += IN_CHUNK) {
1491
+ const part = valid.slice(i, i + IN_CHUNK);
1492
+ out.push(
1493
+ ...db.prepare(
1494
+ this.dedup ? dSelectMany(part.length) : selectMany(part.length)
1495
+ ).all(...part)
1496
+ );
1497
+ }
1498
+ return out;
1499
+ });
1500
+ } catch (exc) {
1501
+ throw wrap("Failed to retrieve image batch", exc);
1502
+ }
1503
+ return mapLimit(
1504
+ rows,
1505
+ 4,
1506
+ (r) => hydrateRow(r, String(r.id), this.opts.objectStorage)
1507
+ );
1508
+ }
1509
+ async getMetadata(id, { connection } = {}) {
1510
+ if (!isValidId(id)) return null;
1511
+ let row;
1512
+ try {
1513
+ row = await this.execute(
1514
+ "get_metadata",
1515
+ connection,
1516
+ "read",
1517
+ (db) => db.prepare(this.dedup ? D_SELECT_METADATA : SELECT_METADATA2).get(id.toLowerCase())
1518
+ );
1519
+ } catch (exc) {
1520
+ throw wrap("Failed to retrieve image metadata", exc);
1521
+ }
1522
+ if (!row) return null;
1523
+ return {
1524
+ id: String(row.id),
1525
+ mimeType: row.mime_type,
1526
+ originalFilename: row.original_filename,
1527
+ sizeBytes: row.size_bytes,
1528
+ width: row.width,
1529
+ height: row.height,
1530
+ checksumSha256: row.checksum_sha256
1531
+ };
1532
+ }
1533
+ async getStream(id, opts) {
1534
+ if (!isValidId(id)) return null;
1535
+ const norm = id.toLowerCase();
1536
+ const { connection, chunkSize } = opts;
1537
+ if (!Number.isInteger(chunkSize) || chunkSize <= 0)
1538
+ throw new RangeError("chunkSize must be a positive integer");
1539
+ let info;
1540
+ try {
1541
+ info = await this.execute(
1542
+ "get_metadata",
1543
+ connection,
1544
+ "read",
1545
+ (db) => db.prepare(this.dedup ? D_STREAM_INFO : STREAM_INFO).get(norm)
1546
+ );
1547
+ } catch (exc) {
1548
+ throw wrap("Failed to retrieve image metadata", exc);
1549
+ }
1550
+ if (!info) return null;
1551
+ const totalSize = info.size_bytes;
1552
+ const start = opts.range?.start ?? 0;
1553
+ const end = Math.min(opts.range?.end ?? totalSize - 1, totalSize - 1);
1554
+ if (info.storage_backend === "object_storage") {
1555
+ const os = this.opts.objectStorage;
1556
+ if (!os) throw tieredWithoutStorage(norm, info.object_storage_key);
1557
+ return streamFromObjectStorage(
1558
+ os,
1559
+ norm,
1560
+ info.object_storage_key,
1561
+ start,
1562
+ end,
1563
+ chunkSize
1564
+ );
1565
+ }
1566
+ const self = this;
1567
+ async function* generate() {
1568
+ let offset = start;
1569
+ let delivered = 0;
1570
+ const wanted = Math.max(0, end - start + 1);
1571
+ while (delivered < wanted) {
1572
+ const length = Math.min(chunkSize, wanted - delivered);
1573
+ let row;
1574
+ try {
1575
+ row = await self.execute(
1576
+ "get_stream",
1577
+ connection,
1578
+ "read",
1579
+ (db) => db.prepare(self.dedup ? D_CHUNK : CHUNK).get(offset + 1, length, norm)
1580
+ );
1581
+ } catch (exc) {
1582
+ throw wrap("Failed to stream image", exc);
1583
+ }
1584
+ if (!row) {
1585
+ throw new StorageError(
1586
+ `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.`
1587
+ );
1588
+ }
1589
+ const chunk = row.chunk;
1590
+ if (!chunk || chunk.length === 0) {
1591
+ throw new StorageError(
1592
+ `Image ${JSON.stringify(norm)} ended early while streaming (delivered ${delivered} of ${wanted} bytes).`
1593
+ );
1594
+ }
1595
+ yield chunk;
1596
+ offset += chunk.length;
1597
+ delivered += chunk.length;
1598
+ }
1599
+ }
1600
+ return generate();
1601
+ }
1602
+ async delete(id, { connection } = {}) {
1603
+ if (!isValidId(id)) return false;
1604
+ const norm = id.toLowerCase();
1605
+ try {
1606
+ if (this.dedup) {
1607
+ return await this.execute("delete", connection, "write", (db) => {
1608
+ const ref = db.prepare(D_DELETE_REF2).get(norm);
1609
+ if (!ref) return false;
1610
+ const dec = db.prepare(D_DECREMENT2).get(1, ref.checksum_sha256);
1611
+ if (dec && dec.ref_count <= 0)
1612
+ db.prepare(D_DELETE_EMPTY2).run(ref.checksum_sha256);
1613
+ return true;
1614
+ });
1615
+ }
1616
+ const row = await this.execute(
1617
+ "delete",
1618
+ connection,
1619
+ "write",
1620
+ (db) => db.prepare(DELETE_ONE).get(norm)
1621
+ );
1622
+ if (row?.storage_backend === "object_storage")
1623
+ await this.cleanupObjects([row.object_storage_key], connection);
1624
+ return row !== void 0;
1625
+ } catch (exc) {
1626
+ throw wrap("Failed to delete image", exc);
1627
+ }
1628
+ }
1629
+ async deleteMany(ids, { connection } = {}) {
1630
+ const valid = [
1631
+ ...new Set(ids.filter(isValidId).map((i) => i.toLowerCase()))
1632
+ ];
1633
+ if (valid.length === 0) return [];
1634
+ try {
1635
+ if (this.dedup) {
1636
+ return await this.execute("delete_many", connection, "write", (db) => {
1637
+ const deleted = [];
1638
+ for (let i = 0; i < valid.length; i += IN_CHUNK) {
1639
+ const part = valid.slice(i, i + IN_CHUNK);
1640
+ deleted.push(...db.prepare(dDeleteRefs(part.length)).all(...part));
1641
+ }
1642
+ const counts = /* @__PURE__ */ new Map();
1643
+ for (const r of deleted)
1644
+ counts.set(
1645
+ r.checksum_sha256,
1646
+ (counts.get(r.checksum_sha256) ?? 0) + 1
1647
+ );
1648
+ for (const [checksum, n] of counts) {
1649
+ const dec = db.prepare(D_DECREMENT2).get(n, checksum);
1650
+ if (dec && dec.ref_count <= 0)
1651
+ db.prepare(D_DELETE_EMPTY2).run(checksum);
1652
+ }
1653
+ return deleted.map((r) => String(r.id));
1654
+ });
1655
+ }
1656
+ const rows = await this.execute(
1657
+ "delete_many",
1658
+ connection,
1659
+ "write",
1660
+ (db) => {
1661
+ const out = [];
1662
+ for (let i = 0; i < valid.length; i += IN_CHUNK) {
1663
+ const part = valid.slice(i, i + IN_CHUNK);
1664
+ out.push(...db.prepare(deleteMany(part.length)).all(...part));
1665
+ }
1666
+ return out;
1667
+ }
1668
+ );
1669
+ await this.cleanupObjects(
1670
+ rows.filter((r) => r.storage_backend === "object_storage").map((r) => r.object_storage_key),
1671
+ connection
1672
+ );
1673
+ return rows.map((r) => String(r.id));
1674
+ } catch (exc) {
1675
+ throw wrap("Failed to delete image batch", exc);
1676
+ }
1677
+ }
1678
+ async exists(id, { connection } = {}) {
1679
+ if (!isValidId(id)) return false;
1680
+ try {
1681
+ return await this.execute(
1682
+ "exists",
1683
+ connection,
1684
+ "read",
1685
+ (db) => db.prepare(this.dedup ? D_EXISTS : EXISTS2).get(id.toLowerCase()) !== void 0
1686
+ );
1687
+ } catch (exc) {
1688
+ throw wrap("Failed to check image existence", exc);
1689
+ }
1690
+ }
1691
+ /**
1692
+ * Remove tiered objects AFTER their rows are gone (a failed object delete leaves a harmless orphan; the
1693
+ * reverse order could leave a row pointing at nothing). Skipped inside a caller-owned transaction, since the
1694
+ * commit outcome is unknown. Failures never fail the delete; they are reported through onOperation.
1695
+ */
1696
+ async cleanupObjects(keys, connection) {
1697
+ const os = this.opts.objectStorage;
1698
+ if (!os || connection || keys.length === 0) return;
1699
+ await Promise.all(
1700
+ keys.map(async (key) => {
1701
+ const start = performance.now();
1702
+ try {
1703
+ await os.delete(key);
1704
+ this.emit("object_storage_delete", start, true, null, 0);
1705
+ } catch (exc) {
1706
+ this.emit("object_storage_delete", start, false, msg(exc), 0);
1707
+ }
1708
+ })
1709
+ );
1710
+ }
1711
+ /**
1712
+ * Move an image's bytes into object storage. null = no such image, false = already tiered, true = tiered now.
1713
+ *
1714
+ * SAFETY (same guarantee as Postgres): the upload happens INSIDE a BEGIN IMMEDIATE transaction; if it fails the
1715
+ * transaction rolls back and the row is untouched.
1716
+ *
1717
+ * TRADEOFF (coarser than Postgres): SQLite has no row locks, so BEGIN IMMEDIATE locks the WHOLE DATABASE for the
1718
+ * upload. Other processes' writes wait; this process's writes queue behind an async lock; reads are not blocked.
1719
+ */
1720
+ async tierToObjectStorage(id, { connection } = {}) {
1721
+ if (this.dedup)
1722
+ throw new StorageError(
1723
+ "tierToObjectStorage() is not supported in dedup mode (a blob may be shared by many images)."
1724
+ );
1725
+ const os = this.opts.objectStorage;
1726
+ if (!os) {
1727
+ throw new StorageError(
1728
+ "tierToObjectStorage() requires objectStorage to be configured (new ZeroBucket({ sqlite, objectStorage: new ObjectStorage({ bucket }) }))."
1729
+ );
1730
+ }
1731
+ if (connection) {
1732
+ throw new StorageError(
1733
+ "tierToObjectStorage() does not accept `connection` on SQLite: it needs its own dedicated connection for the BEGIN IMMEDIATE transaction."
1734
+ );
1735
+ }
1736
+ if (this.opts.path === ":memory:") {
1737
+ throw new StorageError(
1738
+ "tierToObjectStorage() needs a database file: an in-memory SQLite database cannot be shared with its dedicated tiering connection."
1739
+ );
1740
+ }
1741
+ if (!isValidId(id)) return null;
1742
+ const norm = id.toLowerCase();
1743
+ const start = performance.now();
1744
+ let retries = 0;
1745
+ try {
1746
+ await this.ready();
1747
+ const result = await this.writeLock.run(async () => {
1748
+ const tdb = new this.Database(this.opts.path, {
1749
+ timeout: ATTEMPT_BUSY_MS
1750
+ });
1751
+ try {
1752
+ await this.retrying(
1753
+ () => tdb.exec("BEGIN IMMEDIATE"),
1754
+ (n) => retries = n
1755
+ );
1756
+ try {
1757
+ const row = tdb.prepare(SELECT_FOR_TIERING2).get(norm);
1758
+ if (!row) {
1759
+ tdb.exec("ROLLBACK");
1760
+ return null;
1761
+ }
1762
+ if (row.storage_backend !== "sqlite") {
1763
+ tdb.exec("ROLLBACK");
1764
+ return false;
1765
+ }
1766
+ await os.upload(norm, row.data, {
1767
+ mimeType: row.mime_type
1768
+ });
1769
+ tdb.prepare(UPDATE_AFTER_TIERING2).run(os.bucket, norm, isoNow(), norm);
1770
+ tdb.exec("COMMIT");
1771
+ return true;
1772
+ } catch (exc) {
1773
+ try {
1774
+ tdb.exec("ROLLBACK");
1775
+ } catch {
1776
+ }
1777
+ throw exc;
1778
+ }
1779
+ } finally {
1780
+ tdb.close();
1781
+ }
1782
+ });
1783
+ this.emit("tier_to_object_storage", start, true, null, retries);
1784
+ return result;
1785
+ } catch (exc) {
1786
+ this.emit("tier_to_object_storage", start, false, msg(exc), retries);
1787
+ throw wrap("Failed to tier image to object storage", exc);
1788
+ }
1789
+ }
1790
+ };
1791
+
1792
+ // src/validation.ts
1793
+ var FORMAT_TO_MIME = {
1794
+ jpeg: "image/jpeg",
1795
+ png: "image/png",
1796
+ webp: "image/webp"
1797
+ };
1798
+ var SUPPORTED_FORMATS = Object.keys(FORMAT_TO_MIME);
1799
+ var DEFAULT_MAX_PIXELS = 89e6;
1800
+ var sharpPromise;
1801
+ function loadSharp() {
1802
+ sharpPromise ??= import("sharp").then(
1803
+ // ESM gives { default: sharp }; some CJS interop gives sharp directly.
1804
+ (m) => m.default ?? m,
1805
+ (cause) => {
1806
+ sharpPromise = void 0;
1807
+ throw new StorageError(
1808
+ "Image validation requires the optional dependency 'sharp'. Install it with: npm install sharp",
1809
+ { cause }
1810
+ );
1811
+ }
1812
+ );
1813
+ return sharpPromise;
1814
+ }
1815
+ async function validateImage(data, opts) {
1816
+ const {
1817
+ maxBytes,
1818
+ maxPixels = DEFAULT_MAX_PIXELS,
1819
+ allowedFormats = SUPPORTED_FORMATS
1820
+ } = opts;
1821
+ const sizeBytes = data.length;
1822
+ if (sizeBytes > maxBytes) throw new ImageTooLargeError(sizeBytes, maxBytes);
1823
+ if (sizeBytes === 0) throw new CorruptedImageError("Image data is empty");
1824
+ const sharp = await loadSharp();
1825
+ const make = () => sharp(data, { limitInputPixels: maxPixels, failOn: "error" });
1826
+ let format;
1827
+ let width;
1828
+ let height;
1829
+ try {
1830
+ const meta = await make().metadata();
1831
+ format = meta.format;
1832
+ width = meta.width;
1833
+ height = meta.height;
1834
+ } catch (exc) {
1835
+ if (isPixelLimit(exc)) throw new ImageTooLargeError(sizeBytes, maxBytes);
1836
+ throw new CorruptedImageError(
1837
+ `Could not decode image: ${errMessage(exc)}`,
1838
+ { cause: exc }
1839
+ );
1840
+ }
1841
+ if (!format || !allowedFormats.includes(format) || !(format in FORMAT_TO_MIME)) {
1842
+ throw new UnsupportedFormatError(format ?? null, allowedFormats);
1843
+ }
1844
+ if (!width || !height)
1845
+ throw new CorruptedImageError("Image has no readable dimensions");
1846
+ try {
1847
+ await make().stats();
1848
+ } catch (exc) {
1849
+ if (isPixelLimit(exc)) throw new ImageTooLargeError(sizeBytes, maxBytes);
1850
+ throw new CorruptedImageError(
1851
+ `Image data is truncated or corrupted: ${errMessage(exc)}`,
1852
+ {
1853
+ cause: exc
1854
+ }
1855
+ );
1856
+ }
1857
+ return { mimeType: FORMAT_TO_MIME[format], width, height, sizeBytes };
1858
+ }
1859
+ function isPixelLimit(exc) {
1860
+ return /pixel limit/i.test(errMessage(exc));
1861
+ }
1862
+ function errMessage(exc) {
1863
+ return exc instanceof Error ? exc.message : String(exc);
1864
+ }
1865
+
1866
+ // src/optimize.ts
1867
+ var DEFAULT_JPEG_QUALITY = 90;
1868
+ var DEFAULT_WEBP_QUALITY = 88;
1869
+ var FORMAT_ALIASES = { jpg: "jpeg" };
1870
+ async function optimizeImage(data, opts) {
1871
+ const { maxWidth, quality, maxBytes, maxPixels = DEFAULT_MAX_PIXELS } = opts;
1872
+ const sharp = await loadSharp();
1873
+ const originalSizeBytes = data.length;
1874
+ const base = sharp(data, { limitInputPixels: maxPixels, failOn: "error" });
1875
+ const meta = await base.metadata();
1876
+ const requested = (opts.format ?? meta.format ?? "jpeg").toLowerCase();
1877
+ const target = FORMAT_ALIASES[requested] ?? requested;
1878
+ if (!SUPPORTED_FORMATS.includes(target)) {
1879
+ throw new ImageValidationError(
1880
+ `Cannot re-encode to unsupported format ${JSON.stringify(target)}. Supported: ${JSON.stringify(
1881
+ [...SUPPORTED_FORMATS].sort()
1882
+ )}`
1883
+ );
1884
+ }
1885
+ let pipeline = sharp(data, {
1886
+ limitInputPixels: maxPixels,
1887
+ failOn: "error"
1888
+ }).rotate();
1889
+ if (maxWidth !== void 0) {
1890
+ pipeline = pipeline.resize({
1891
+ width: maxWidth,
1892
+ fit: "inside",
1893
+ withoutEnlargement: true,
1894
+ kernel: "lanczos3"
1895
+ });
1896
+ }
1897
+ if (target === "jpeg") {
1898
+ pipeline = pipeline.flatten({ background: "#ffffff" }).jpeg({
1899
+ quality: quality ?? DEFAULT_JPEG_QUALITY,
1900
+ mozjpeg: true,
1901
+ progressive: true
1902
+ });
1903
+ } else if (target === "webp") {
1904
+ pipeline = pipeline.webp({
1905
+ quality: quality ?? DEFAULT_WEBP_QUALITY,
1906
+ effort: 6
1907
+ });
1908
+ } else {
1909
+ pipeline = pipeline.png({ compressionLevel: 9 });
1910
+ }
1911
+ const out = await pipeline.toBuffer();
1912
+ const revalidated = await validateImage(out, { maxBytes, maxPixels });
1913
+ return {
1914
+ data: out,
1915
+ mimeType: revalidated.mimeType,
1916
+ width: revalidated.width,
1917
+ height: revalidated.height,
1918
+ sizeBytes: revalidated.sizeBytes,
1919
+ originalSizeBytes
1920
+ };
1921
+ }
1922
+
1923
+ // src/client.ts
1924
+ var DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
1925
+ var PREPARE_CONCURRENCY = 4;
1926
+ var ZeroBucket = class _ZeroBucket {
1927
+ backend;
1928
+ maxBytes;
1929
+ maxPixels;
1930
+ allowedFormats;
1931
+ beforeGet;
1932
+ beforePut;
1933
+ constructor(options = {}) {
1934
+ if (options.sqlite !== void 0 && (options.connectionString || options.pool)) {
1935
+ throw new TypeError(
1936
+ "Choose one database: `sqlite` OR `connectionString`/`pool`, not both."
1937
+ );
1938
+ }
1939
+ this.backend = options.backend ?? (options.sqlite !== void 0 ? new SQLiteBackend({
1940
+ path: options.sqlite,
1941
+ autoMigrate: options.autoMigrate,
1942
+ dedup: options.dedup,
1943
+ objectStorage: options.objectStorage,
1944
+ onOperation: options.onOperation
1945
+ }) : new PostgresBackend({
1946
+ connectionString: options.connectionString,
1947
+ pool: options.pool,
1948
+ autoMigrate: options.autoMigrate,
1949
+ maxRetries: options.maxRetries,
1950
+ retryBaseDelayMs: options.retryBaseDelayMs,
1951
+ poolMinSize: options.poolMinSize,
1952
+ poolMaxSize: options.poolMaxSize,
1953
+ poolTimeoutMs: options.poolTimeoutMs,
1954
+ onOperation: options.onOperation,
1955
+ objectStorage: options.objectStorage,
1956
+ dedup: options.dedup
1957
+ }));
1958
+ this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
1959
+ this.maxPixels = options.maxPixels ?? DEFAULT_MAX_PIXELS;
1960
+ this.allowedFormats = options.allowedFormats ?? SUPPORTED_FORMATS;
1961
+ this.beforeGet = options.beforeGet;
1962
+ this.beforePut = options.beforePut;
1963
+ }
1964
+ /** Construct AND connect/migrate immediately, so a bad DATABASE_URL fails at startup, not on first request. */
1965
+ static async connect(options = {}) {
1966
+ const zb = new _ZeroBucket(options);
1967
+ await zb.ready();
1968
+ return zb;
1969
+ }
1970
+ /** Resolves once connected and migrated. Called automatically on first use. */
1971
+ ready() {
1972
+ return this.backend.ready();
1973
+ }
1974
+ // ---- hooks -----------------------------------------------------------
1975
+ async checkBeforeGet(imageId, context) {
1976
+ if (!this.beforeGet) return;
1977
+ if (!await this.beforeGet(imageId, context))
1978
+ throw new AccessDeniedError("get", imageId);
1979
+ }
1980
+ async checkBeforePut(context) {
1981
+ if (!this.beforePut) return;
1982
+ if (!await this.beforePut(context)) throw new AccessDeniedError("put");
1983
+ }
1984
+ // ---- write path ------------------------------------------------------
1985
+ async prepareRow(image, o) {
1986
+ const { data, filename } = await readInput(
1987
+ image,
1988
+ o.filename ?? void 0,
1989
+ this.maxBytes
1990
+ );
1991
+ let finalData;
1992
+ let mimeType;
1993
+ let width;
1994
+ let height;
1995
+ let sizeBytes;
1996
+ if (o.validator) {
1997
+ if (o.optimize) {
1998
+ throw new ImageValidationError(
1999
+ "optimize is not supported together with a custom validator: the optimize pipeline (resize/re-encode) is image-specific. Validate/transform custom content types yourself before put()."
2000
+ );
2001
+ }
2002
+ const v = await o.validator.validate(data, { maxBytes: this.maxBytes });
2003
+ finalData = data;
2004
+ mimeType = v.mimeType;
2005
+ width = v.width ?? null;
2006
+ height = v.height ?? null;
2007
+ sizeBytes = v.sizeBytes;
2008
+ } else {
2009
+ const validated = await validateImage(data, {
2010
+ maxBytes: this.maxBytes,
2011
+ maxPixels: this.maxPixels,
2012
+ allowedFormats: this.allowedFormats
2013
+ });
2014
+ if (o.optimize) {
2015
+ const opts = o.optimize === true ? {} : o.optimize;
2016
+ const r = await optimizeImage(data, {
2017
+ ...opts,
2018
+ maxBytes: this.maxBytes,
2019
+ maxPixels: this.maxPixels
2020
+ });
2021
+ finalData = r.data;
2022
+ mimeType = r.mimeType;
2023
+ width = r.width;
2024
+ height = r.height;
2025
+ sizeBytes = r.sizeBytes;
2026
+ } else {
2027
+ finalData = data;
2028
+ mimeType = validated.mimeType;
2029
+ width = validated.width;
2030
+ height = validated.height;
2031
+ sizeBytes = validated.sizeBytes;
2032
+ }
2033
+ }
2034
+ return {
2035
+ data: finalData,
2036
+ mimeType,
2037
+ originalFilename: filename ?? null,
2038
+ sizeBytes,
2039
+ width,
2040
+ height,
2041
+ checksumSha256: (0, import_node_crypto2.createHash)("sha256").update(finalData).digest("hex")
2042
+ };
2043
+ }
2044
+ /** Validate, optionally optimize, checksum and store one image. Returns its id (a UUID). */
2045
+ async put(image, options = {}) {
2046
+ await this.checkBeforePut(options.context);
2047
+ const row = await this.prepareRow(image, options);
2048
+ return this.backend.put(row, { connection: options.connection });
2049
+ }
2050
+ /**
2051
+ * Store many images. Best-effort, not all-or-nothing: one bad image does not
2052
+ * abort the rest. Check each result's `success` / `error`. Settings apply to
2053
+ * every item. Valid items are inserted in ONE transaction.
2054
+ */
2055
+ async putMany(images, options = {}) {
2056
+ const { filenames } = options;
2057
+ if (filenames && filenames.length !== images.length) {
2058
+ throw new RangeError(
2059
+ "filenames must be the same length as images if provided"
2060
+ );
2061
+ }
2062
+ const fail = (index, error) => ({
2063
+ index,
2064
+ imageId: null,
2065
+ error,
2066
+ success: false
2067
+ });
2068
+ if (this.beforePut) {
2069
+ try {
2070
+ if (!await this.beforePut(options.context))
2071
+ return images.map((_, i) => fail(i, "access denied"));
2072
+ } catch (exc) {
2073
+ return images.map((_, i) => fail(i, errText(exc)));
2074
+ }
2075
+ }
2076
+ const results = new Array(images.length);
2077
+ const prepared = [];
2078
+ await mapLimit2(images, PREPARE_CONCURRENCY, async (image, i) => {
2079
+ try {
2080
+ const row = await this.prepareRow(image, {
2081
+ ...options,
2082
+ filename: filenames?.[i] ?? void 0
2083
+ });
2084
+ prepared.push({ index: i, row });
2085
+ } catch (exc) {
2086
+ results[i] = fail(i, errText(exc));
2087
+ }
2088
+ });
2089
+ prepared.sort((a, b) => a.index - b.index);
2090
+ if (prepared.length > 0) {
2091
+ try {
2092
+ const ids = await this.backend.putMany(
2093
+ prepared.map((p) => p.row),
2094
+ { connection: options.connection }
2095
+ );
2096
+ prepared.forEach((p, n) => {
2097
+ results[p.index] = {
2098
+ index: p.index,
2099
+ imageId: ids[n],
2100
+ error: null,
2101
+ success: true
2102
+ };
2103
+ });
2104
+ } catch (exc) {
2105
+ for (const p of prepared)
2106
+ results[p.index] = fail(p.index, errText(exc));
2107
+ }
2108
+ }
2109
+ return results;
2110
+ }
2111
+ // ---- read path -------------------------------------------------------
2112
+ /** Retrieve a full image including bytes. Throws ImageNotFoundError if missing. */
2113
+ async get(imageId, options = {}) {
2114
+ await this.checkBeforeGet(imageId, options.context);
2115
+ const record = await this.backend.get(imageId, {
2116
+ connection: options.connection
2117
+ });
2118
+ if (!record) throw new ImageNotFoundError(imageId);
2119
+ return {
2120
+ data: record.data,
2121
+ mimeType: record.mimeType,
2122
+ filename: record.originalFilename,
2123
+ sizeBytes: record.sizeBytes,
2124
+ width: record.width,
2125
+ height: record.height,
2126
+ checksumSha256: record.checksumSha256
2127
+ };
2128
+ }
2129
+ /**
2130
+ * Retrieve many images in one query. Results come back in input order; a
2131
+ * missing id is `error: "not found"`, not an exception. A hook denial or hook
2132
+ * exception affects only that id.
2133
+ */
2134
+ async getMany(imageIds, options = {}) {
2135
+ const results = new Array(imageIds.length);
2136
+ const allowedIdx = [];
2137
+ for (let i = 0; i < imageIds.length; i++) {
2138
+ const imageId = imageIds[i];
2139
+ if (this.beforeGet) {
2140
+ try {
2141
+ if (!await this.beforeGet(imageId, options.context)) {
2142
+ results[i] = {
2143
+ imageId,
2144
+ image: null,
2145
+ error: "access denied",
2146
+ success: false
2147
+ };
2148
+ continue;
2149
+ }
2150
+ } catch (exc) {
2151
+ results[i] = {
2152
+ imageId,
2153
+ image: null,
2154
+ error: errText(exc),
2155
+ success: false
2156
+ };
2157
+ continue;
2158
+ }
2159
+ }
2160
+ allowedIdx.push(i);
2161
+ }
2162
+ if (allowedIdx.length > 0) {
2163
+ const records = await this.backend.getMany(
2164
+ allowedIdx.map((i) => imageIds[i]),
2165
+ { connection: options.connection }
2166
+ );
2167
+ const byId = new Map(records.map((r) => [r.id.toLowerCase(), r]));
2168
+ for (const i of allowedIdx) {
2169
+ const imageId = imageIds[i];
2170
+ const r = byId.get(imageId.toLowerCase());
2171
+ results[i] = r ? {
2172
+ imageId,
2173
+ image: {
2174
+ data: r.data,
2175
+ mimeType: r.mimeType,
2176
+ filename: r.originalFilename,
2177
+ sizeBytes: r.sizeBytes,
2178
+ width: r.width,
2179
+ height: r.height,
2180
+ checksumSha256: r.checksumSha256
2181
+ },
2182
+ error: null,
2183
+ success: true
2184
+ } : { imageId, image: null, error: "not found", success: false };
2185
+ }
2186
+ }
2187
+ return results;
2188
+ }
2189
+ /**
2190
+ * Stream an image's bytes in chunks without holding the whole image in memory.
2191
+ * Not-found is checked eagerly: the returned promise rejects before any chunk
2192
+ * is read. A concurrent delete mid-stream makes iteration throw StorageError
2193
+ * rather than silently truncating. Pass `range` for partial reads.
2194
+ *
2195
+ * Honest limitation: reduces Node-side memory, not Postgres-side (the server
2196
+ * still handles the full BYTEA value per chunk request).
2197
+ */
2198
+ async getStream(imageId, options = {}) {
2199
+ await this.checkBeforeGet(imageId, options.context);
2200
+ const { range } = options;
2201
+ if (range) {
2202
+ if (!Number.isInteger(range.start) || range.start < 0 || range.end !== void 0 && (!Number.isInteger(range.end) || range.end < range.start)) {
2203
+ throw new RangeError(
2204
+ "range must be { start >= 0, end >= start } with integer values"
2205
+ );
2206
+ }
2207
+ }
2208
+ const stream = await this.backend.getStream(imageId, {
2209
+ chunkSize: options.chunkSize ?? DEFAULT_STREAM_CHUNK_SIZE,
2210
+ connection: options.connection,
2211
+ range: range ? { start: range.start, end: range.end ?? Number.MAX_SAFE_INTEGER } : void 0
2212
+ });
2213
+ if (!stream) throw new ImageNotFoundError(imageId);
2214
+ return stream;
2215
+ }
2216
+ /** Same as getStream(), as a Web `ReadableStream` you can hand straight to `new Response(...)`. */
2217
+ async toWebStream(imageId, options = {}) {
2218
+ const iterable = await this.getStream(imageId, options);
2219
+ const iterator = iterable[Symbol.asyncIterator]();
2220
+ return new ReadableStream({
2221
+ async pull(controller) {
2222
+ try {
2223
+ const { value, done } = await iterator.next();
2224
+ if (done) controller.close();
2225
+ else
2226
+ controller.enqueue(
2227
+ new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
2228
+ );
2229
+ } catch (exc) {
2230
+ controller.error(exc);
2231
+ }
2232
+ },
2233
+ async cancel() {
2234
+ await iterator.return?.();
2235
+ }
2236
+ });
2237
+ }
2238
+ /** Write an image to `destination` chunk by chunk (honouring backpressure). Returns bytes written. Does not end the destination. */
2239
+ async streamTo(imageId, destination, options = {}) {
2240
+ let total = 0;
2241
+ for await (const chunk of await this.getStream(imageId, options)) {
2242
+ const result = destination.write(chunk);
2243
+ if (result === false && typeof destination.once === "function") {
2244
+ await new Promise(
2245
+ (resolve) => destination.once("drain", resolve)
2246
+ );
2247
+ } else if (result && typeof result.then === "function") {
2248
+ await result;
2249
+ }
2250
+ total += chunk.length;
2251
+ }
2252
+ return total;
2253
+ }
2254
+ /** Metadata without pulling the bytes. Gated by the same beforeGet hook as get(). */
2255
+ async metadata(imageId, options = {}) {
2256
+ await this.checkBeforeGet(imageId, options.context);
2257
+ const r = await this.backend.getMetadata(imageId, {
2258
+ connection: options.connection
2259
+ });
2260
+ if (!r) throw new ImageNotFoundError(imageId);
2261
+ return {
2262
+ imageId: r.id,
2263
+ mimeType: r.mimeType,
2264
+ filename: r.originalFilename,
2265
+ sizeBytes: r.sizeBytes,
2266
+ width: r.width,
2267
+ height: r.height,
2268
+ checksumSha256: r.checksumSha256
2269
+ };
2270
+ }
2271
+ /** Whether an image exists. Deliberately NOT gated by beforeGet (returns no data or metadata). */
2272
+ exists(imageId, options = {}) {
2273
+ return this.backend.exists(imageId, options);
2274
+ }
2275
+ /** Delete an image. Returns true if it existed. */
2276
+ delete(imageId, options = {}) {
2277
+ return this.backend.delete(imageId, options);
2278
+ }
2279
+ /** Delete many in one query. Results are in input order; a missing id is `deleted: false`, not an error. */
2280
+ async deleteMany(imageIds, options = {}) {
2281
+ if (imageIds.length === 0) return [];
2282
+ const deleted = new Set(
2283
+ (await this.backend.deleteMany(imageIds, options)).map(
2284
+ (id) => id.toLowerCase()
2285
+ )
2286
+ );
2287
+ return imageIds.map((imageId) => ({
2288
+ imageId,
2289
+ deleted: deleted.has(imageId.toLowerCase()),
2290
+ error: null,
2291
+ success: true
2292
+ }));
2293
+ }
2294
+ /**
2295
+ * Move an image's bytes from Postgres into object storage; reads, streams and
2296
+ * deletes keep working transparently. Returns true if tiered now, false if it was
2297
+ * already tiered (safe to re-run in a backfill). Throws ImageNotFoundError if missing.
2298
+ *
2299
+ * The upload runs inside the database transaction: if it fails, the row is left
2300
+ * completely untouched. Explicit and opt-in: put() never tiers automatically.
2301
+ * Not gated by beforeGet/beforePut (it is a maintenance operation, as in the Python package).
2302
+ */
2303
+ async tierToObjectStorage(imageId, options = {}) {
2304
+ const result = await this.backend.tierToObjectStorage(imageId, options);
2305
+ if (result === null) throw new ImageNotFoundError(imageId);
2306
+ return result;
2307
+ }
2308
+ /**
2309
+ * One-time, non-destructive copy of classic-mode images into a dedup instance's tables, keeping every
2310
+ * id. Call it on an instance created with `dedup: true`. Safe to re-run. See PostgresBackend for details.
2311
+ */
2312
+ async migrateClassicToDedup() {
2313
+ if (!this.backend.migrateClassicToDedup) {
2314
+ throw new StorageError(
2315
+ "This backend does not support migrateClassicToDedup()."
2316
+ );
2317
+ }
2318
+ return this.backend.migrateClassicToDedup();
2319
+ }
2320
+ /** Release the pool (if ZeroBucket created it). */
2321
+ close() {
2322
+ return this.backend.close();
2323
+ }
2324
+ };
2325
+ async function readInput(image, filename, maxBytes) {
2326
+ if (typeof image === "string") {
2327
+ const size = (await (0, import_promises.stat)(image)).size;
2328
+ if (size > maxBytes) throw new ImageTooLargeError(size, maxBytes);
2329
+ return {
2330
+ data: await (0, import_promises.readFile)(image),
2331
+ filename: filename ?? (0, import_node_path.basename)(image)
2332
+ };
2333
+ }
2334
+ if (Buffer.isBuffer(image)) return { data: image, filename };
2335
+ if (image instanceof Uint8Array)
2336
+ return {
2337
+ data: Buffer.from(image.buffer, image.byteOffset, image.byteLength),
2338
+ filename
2339
+ };
2340
+ if (image instanceof ArrayBuffer)
2341
+ return { data: Buffer.from(image), filename };
2342
+ if (typeof Blob !== "undefined" && image instanceof Blob) {
2343
+ if (image.size > maxBytes)
2344
+ throw new ImageTooLargeError(image.size, maxBytes);
2345
+ const name = image.name;
2346
+ return {
2347
+ data: Buffer.from(await image.arrayBuffer()),
2348
+ filename: filename ?? (typeof name === "string" ? name : void 0)
2349
+ };
2350
+ }
2351
+ if (image && typeof image[Symbol.asyncIterator] === "function") {
2352
+ const chunks = [];
2353
+ let total = 0;
2354
+ for await (const chunk of image) {
2355
+ const buf = typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
2356
+ total += buf.length;
2357
+ if (total > maxBytes) {
2358
+ image.destroy?.();
2359
+ throw new ImageTooLargeError(total, maxBytes);
2360
+ }
2361
+ chunks.push(buf);
2362
+ }
2363
+ const p = image.path;
2364
+ return {
2365
+ data: Buffer.concat(chunks, total),
2366
+ filename: filename ?? (typeof p === "string" ? (0, import_node_path.basename)(p) : void 0)
2367
+ };
2368
+ }
2369
+ throw new TypeError(
2370
+ "Unsupported image input. Pass a Buffer, Uint8Array, file path string, Blob/File, or a readable stream."
2371
+ );
2372
+ }
2373
+ async function mapLimit2(items, limit, fn) {
2374
+ let next = 0;
2375
+ const workers = Array.from(
2376
+ { length: Math.min(limit, items.length) },
2377
+ async () => {
2378
+ while (next < items.length) {
2379
+ const i = next++;
2380
+ await fn(items[i], i);
2381
+ }
2382
+ }
2383
+ );
2384
+ await Promise.all(workers);
2385
+ }
2386
+ function errText(exc) {
2387
+ return exc instanceof Error ? exc.message : String(exc);
2388
+ }
2389
+
2390
+ // src/object-storage.ts
2391
+ var ObjectStorage = class {
2392
+ constructor(opts) {
2393
+ this.opts = opts;
2394
+ if (!opts.bucket)
2395
+ throw new TypeError("ObjectStorage requires a bucket name");
2396
+ this.bucket = opts.bucket;
2397
+ }
2398
+ opts;
2399
+ bucket;
2400
+ clientPromise;
2401
+ connection() {
2402
+ this.clientPromise ??= (async () => {
2403
+ let sdk;
2404
+ try {
2405
+ sdk = await import("@aws-sdk/client-s3");
2406
+ } catch (cause) {
2407
+ throw new StorageError(
2408
+ "Object-storage tiering requires the optional dependency '@aws-sdk/client-s3'. Install it with: npm install @aws-sdk/client-s3",
2409
+ { cause }
2410
+ );
2411
+ }
2412
+ const o = this.opts;
2413
+ const client = o.client ?? new sdk.S3Client({
2414
+ endpoint: o.endpoint,
2415
+ region: o.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1",
2416
+ credentials: o.credentials,
2417
+ forcePathStyle: o.forcePathStyle ?? o.endpoint !== void 0
2418
+ });
2419
+ return { sdk, client };
2420
+ })().catch((exc) => {
2421
+ this.clientPromise = void 0;
2422
+ throw exc;
2423
+ });
2424
+ return this.clientPromise;
2425
+ }
2426
+ async upload(key, data, { mimeType }) {
2427
+ const { sdk, client } = await this.connection();
2428
+ try {
2429
+ await client.send(
2430
+ new sdk.PutObjectCommand({
2431
+ Bucket: this.bucket,
2432
+ Key: key,
2433
+ Body: data,
2434
+ ContentType: mimeType
2435
+ })
2436
+ );
2437
+ } catch (exc) {
2438
+ throw new StorageError(
2439
+ `Failed to upload ${JSON.stringify(key)} to bucket ${JSON.stringify(this.bucket)}: ${msg2(exc)}`,
2440
+ { cause: exc }
2441
+ );
2442
+ }
2443
+ }
2444
+ async download(key) {
2445
+ return this.get(key, void 0);
2446
+ }
2447
+ async downloadRange(key, start, endInclusive) {
2448
+ return this.get(key, `bytes=${start}-${endInclusive}`);
2449
+ }
2450
+ async get(key, range) {
2451
+ const { sdk, client } = await this.connection();
2452
+ try {
2453
+ const res = await client.send(
2454
+ new sdk.GetObjectCommand({
2455
+ Bucket: this.bucket,
2456
+ Key: key,
2457
+ Range: range
2458
+ })
2459
+ );
2460
+ return Buffer.from(await res.Body.transformToByteArray());
2461
+ } catch (exc) {
2462
+ throw new StorageError(
2463
+ `Failed to download ${JSON.stringify(key)} from bucket ${JSON.stringify(this.bucket)}: ${msg2(exc)}`,
2464
+ { cause: exc }
2465
+ );
2466
+ }
2467
+ }
2468
+ async delete(key) {
2469
+ const { sdk, client } = await this.connection();
2470
+ try {
2471
+ await client.send(
2472
+ new sdk.DeleteObjectCommand({ Bucket: this.bucket, Key: key })
2473
+ );
2474
+ } catch (exc) {
2475
+ throw new StorageError(
2476
+ `Failed to delete ${JSON.stringify(key)} from bucket ${JSON.stringify(this.bucket)}: ${msg2(exc)}`,
2477
+ { cause: exc }
2478
+ );
2479
+ }
2480
+ }
2481
+ async exists(key) {
2482
+ const { sdk, client } = await this.connection();
2483
+ try {
2484
+ await client.send(
2485
+ new sdk.HeadObjectCommand({ Bucket: this.bucket, Key: key })
2486
+ );
2487
+ return true;
2488
+ } catch (exc) {
2489
+ const e = exc;
2490
+ if (e.name === "NotFound" || e.name === "NoSuchKey" || e.$metadata?.httpStatusCode === 404)
2491
+ return false;
2492
+ throw new StorageError(
2493
+ `Failed to check ${JSON.stringify(key)} in bucket ${JSON.stringify(this.bucket)}: ${msg2(exc)}`,
2494
+ { cause: exc }
2495
+ );
2496
+ }
2497
+ }
2498
+ };
2499
+ function msg2(exc) {
2500
+ return exc instanceof Error ? exc.message : String(exc);
2501
+ }
2502
+
2503
+ // src/validators/pdf.ts
2504
+ var DEFAULT_MAX_PDF_BYTES = 20 * 1024 * 1024;
2505
+ var PDFValidator = class {
2506
+ constructor(opts = {}) {
2507
+ this.opts = opts;
2508
+ }
2509
+ opts;
2510
+ validate(data, { maxBytes }) {
2511
+ const effectiveMax = Math.min(
2512
+ maxBytes,
2513
+ this.opts.maxBytes ?? DEFAULT_MAX_PDF_BYTES
2514
+ );
2515
+ if (data.length === 0)
2516
+ throw new ContentValidationError("PDF data is empty");
2517
+ if (data.length > effectiveMax) {
2518
+ throw new ContentValidationError(
2519
+ `PDF is ${data.length} bytes, exceeds the maximum of ${effectiveMax} bytes`
2520
+ );
2521
+ }
2522
+ if (!data.subarray(0, 1024).includes("%PDF-")) {
2523
+ throw new ContentValidationError(
2524
+ "This does not look like a valid PDF (missing '%PDF-' header). Content is checked by inspecting the actual bytes, not the filename or any client-supplied content type."
2525
+ );
2526
+ }
2527
+ return {
2528
+ mimeType: "application/pdf",
2529
+ sizeBytes: data.length,
2530
+ width: null,
2531
+ height: null
2532
+ };
2533
+ }
2534
+ };
2535
+ // Annotate the CommonJS export names for ESM import in node:
2536
+ 0 && (module.exports = {
2537
+ AccessDeniedError,
2538
+ ContentValidationError,
2539
+ CorruptedImageError,
2540
+ DEFAULT_MAX_BYTES,
2541
+ DEFAULT_MAX_PIXELS,
2542
+ DEFAULT_STREAM_CHUNK_SIZE,
2543
+ ImageNotFoundError,
2544
+ ImageTooLargeError,
2545
+ ImageValidationError,
2546
+ ObjectStorage,
2547
+ PDFValidator,
2548
+ PostgresBackend,
2549
+ SQLiteBackend,
2550
+ SUPPORTED_FORMATS,
2551
+ StorageError,
2552
+ UnsupportedFormatError,
2553
+ ZeroBucket,
2554
+ ZeroBucketError,
2555
+ optimizeImage,
2556
+ readInput,
2557
+ validateImage
2558
+ });
2559
+ //# sourceMappingURL=index.cjs.map