zerobucket 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -20
- package/dist/{client-C_W0t8My.d.cts → client-WZ4VRCPi.d.cts} +111 -5
- package/dist/{client-C_W0t8My.d.ts → client-WZ4VRCPi.d.ts} +111 -5
- package/dist/http.d.cts +1 -1
- package/dist/http.d.ts +1 -1
- package/dist/index.cjs +1449 -135
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +199 -56
- package/dist/index.d.ts +199 -56
- package/dist/index.js +1445 -133
- package/dist/index.js.map +1 -1
- package/package.json +21 -3
package/dist/index.cjs
CHANGED
|
@@ -39,8 +39,10 @@ __export(src_exports, {
|
|
|
39
39
|
ImageNotFoundError: () => ImageNotFoundError,
|
|
40
40
|
ImageTooLargeError: () => ImageTooLargeError,
|
|
41
41
|
ImageValidationError: () => ImageValidationError,
|
|
42
|
+
ObjectStorage: () => ObjectStorage,
|
|
42
43
|
PDFValidator: () => PDFValidator,
|
|
43
44
|
PostgresBackend: () => PostgresBackend,
|
|
45
|
+
SQLiteBackend: () => SQLiteBackend,
|
|
44
46
|
SUPPORTED_FORMATS: () => SUPPORTED_FORMATS,
|
|
45
47
|
StorageError: () => StorageError,
|
|
46
48
|
UnsupportedFormatError: () => UnsupportedFormatError,
|
|
@@ -53,7 +55,7 @@ __export(src_exports, {
|
|
|
53
55
|
module.exports = __toCommonJS(src_exports);
|
|
54
56
|
|
|
55
57
|
// src/client.ts
|
|
56
|
-
var
|
|
58
|
+
var import_node_crypto2 = require("crypto");
|
|
57
59
|
var import_promises = require("fs/promises");
|
|
58
60
|
var import_node_path = require("path");
|
|
59
61
|
|
|
@@ -113,8 +115,80 @@ var AccessDeniedError = class extends ZeroBucketError {
|
|
|
113
115
|
imageId;
|
|
114
116
|
};
|
|
115
117
|
|
|
116
|
-
// src/adapters/
|
|
118
|
+
// src/adapters/shared.ts
|
|
117
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
|
|
118
192
|
var SCHEMA = `
|
|
119
193
|
CREATE TABLE IF NOT EXISTS zerobucket_images (
|
|
120
194
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
@@ -180,10 +254,59 @@ var SELECT_STREAM_INFO = `SELECT size_bytes, storage_backend, object_storage_key
|
|
|
180
254
|
var DELETE_RETURNING = `DELETE FROM zerobucket_images WHERE id = $1 RETURNING storage_backend, object_storage_key;`;
|
|
181
255
|
var DELETE_MANY = `DELETE FROM zerobucket_images WHERE id = ANY($1::uuid[]) RETURNING id, storage_backend, object_storage_key;`;
|
|
182
256
|
var EXISTS = `SELECT 1 FROM zerobucket_images WHERE id = $1;`;
|
|
183
|
-
var
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
+
};
|
|
187
310
|
var RETRYABLE_SQLSTATES = /* @__PURE__ */ new Set([
|
|
188
311
|
"40001",
|
|
189
312
|
// serialization_failure
|
|
@@ -254,6 +377,13 @@ var PostgresBackend = class {
|
|
|
254
377
|
this.maxRetries = opts.maxRetries ?? 3;
|
|
255
378
|
this.retryBaseDelayMs = opts.retryBaseDelayMs ?? 100;
|
|
256
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;
|
|
257
387
|
}
|
|
258
388
|
opts;
|
|
259
389
|
pool;
|
|
@@ -263,6 +393,8 @@ var PostgresBackend = class {
|
|
|
263
393
|
maxRetries;
|
|
264
394
|
retryBaseDelayMs;
|
|
265
395
|
autoMigrate;
|
|
396
|
+
dedup;
|
|
397
|
+
sql;
|
|
266
398
|
// ---- lifecycle -------------------------------------------------------
|
|
267
399
|
ready() {
|
|
268
400
|
if (this.closed)
|
|
@@ -327,7 +459,7 @@ var PostgresBackend = class {
|
|
|
327
459
|
await client.query(
|
|
328
460
|
"SELECT pg_advisory_xact_lock(hashtext('zerobucket_migrate'))"
|
|
329
461
|
);
|
|
330
|
-
await client.query(
|
|
462
|
+
await client.query(this.sql.schema);
|
|
331
463
|
await client.query("COMMIT");
|
|
332
464
|
client.release();
|
|
333
465
|
} catch (exc) {
|
|
@@ -360,123 +492,1029 @@ var PostgresBackend = class {
|
|
|
360
492
|
}
|
|
361
493
|
}
|
|
362
494
|
/**
|
|
363
|
-
* Run `work` and return its result, emitting exactly one OperationEvent.
|
|
364
|
-
*
|
|
365
|
-
* connection given -> run once on it, no BEGIN/COMMIT, no retry.
|
|
366
|
-
* connection absent -> pooled; `tx` wraps the work in BEGIN/COMMIT; transient
|
|
367
|
-
* failures retry the WHOLE closure on a fresh connection.
|
|
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.
|
|
368
1331
|
*/
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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) {
|
|
372
1339
|
try {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
} catch (exc) {
|
|
377
|
-
this.emit(operation, start2, false, msg(exc), 0);
|
|
378
|
-
throw exc;
|
|
1340
|
+
db.exec("ROLLBACK TO SAVEPOINT zerobucket_atomic");
|
|
1341
|
+
db.exec("RELEASE SAVEPOINT zerobucket_atomic");
|
|
1342
|
+
} catch {
|
|
379
1343
|
}
|
|
1344
|
+
throw exc;
|
|
380
1345
|
}
|
|
381
|
-
|
|
1346
|
+
}
|
|
1347
|
+
async execute(operation, connection, mode, work) {
|
|
382
1348
|
const start = performance.now();
|
|
383
|
-
let
|
|
384
|
-
|
|
385
|
-
let
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
if (tx) await client.query("COMMIT");
|
|
392
|
-
this.emit(operation, start, true, null, attempt);
|
|
393
|
-
return result;
|
|
394
|
-
} catch (exc) {
|
|
395
|
-
if (client && tx) {
|
|
396
|
-
try {
|
|
397
|
-
await client.query("ROLLBACK");
|
|
398
|
-
} catch {
|
|
399
|
-
destroy = true;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
if (isConnectionLevel(exc)) destroy = true;
|
|
403
|
-
attempt += 1;
|
|
404
|
-
if (attempt > this.maxRetries || !isRetryable(exc)) {
|
|
405
|
-
this.emit(operation, start, false, msg(exc), attempt - 1);
|
|
406
|
-
throw exc;
|
|
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
|
+
);
|
|
407
1357
|
}
|
|
408
|
-
|
|
409
|
-
|
|
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);
|
|
410
1367
|
}
|
|
411
|
-
|
|
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;
|
|
412
1373
|
}
|
|
413
1374
|
}
|
|
414
1375
|
// ---- StorageBackend --------------------------------------------------
|
|
415
1376
|
async put(row, { connection } = {}) {
|
|
416
1377
|
try {
|
|
417
|
-
return await this.
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
1378
|
+
return await this.execute(
|
|
1379
|
+
"put",
|
|
1380
|
+
connection,
|
|
1381
|
+
"write",
|
|
1382
|
+
(db) => this.insertOne(db, row)
|
|
1383
|
+
);
|
|
421
1384
|
} catch (exc) {
|
|
422
1385
|
throw wrap("Failed to store image", exc);
|
|
423
1386
|
}
|
|
424
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
|
+
}
|
|
425
1425
|
async putMany(rows, { connection } = {}) {
|
|
426
1426
|
if (rows.length === 0) return [];
|
|
427
1427
|
try {
|
|
428
|
-
return await this.
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
}
|
|
437
|
-
{
|
|
438
|
-
|
|
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
|
+
});
|
|
439
1461
|
} catch (exc) {
|
|
440
1462
|
throw wrap("Failed to store image batch", exc);
|
|
441
1463
|
}
|
|
442
1464
|
}
|
|
443
1465
|
async get(id, { connection } = {}) {
|
|
444
1466
|
if (!isValidId(id)) return null;
|
|
1467
|
+
const norm = id.toLowerCase();
|
|
445
1468
|
let row;
|
|
446
1469
|
try {
|
|
447
|
-
row = await this.
|
|
1470
|
+
row = await this.execute(
|
|
448
1471
|
"get",
|
|
449
1472
|
connection,
|
|
450
|
-
|
|
1473
|
+
"read",
|
|
1474
|
+
(db) => db.prepare(this.dedup ? D_SELECT_FULL : SELECT_FULL2).get(norm)
|
|
451
1475
|
);
|
|
452
1476
|
} catch (exc) {
|
|
453
1477
|
throw wrap("Failed to retrieve image", exc);
|
|
454
1478
|
}
|
|
455
|
-
return row ?
|
|
1479
|
+
return row ? hydrateRow(row, norm, this.opts.objectStorage) : null;
|
|
456
1480
|
}
|
|
457
1481
|
async getMany(ids, { connection } = {}) {
|
|
458
|
-
const valid =
|
|
1482
|
+
const valid = [
|
|
1483
|
+
...new Set(ids.filter(isValidId).map((i) => i.toLowerCase()))
|
|
1484
|
+
];
|
|
459
1485
|
if (valid.length === 0) return [];
|
|
460
1486
|
let rows;
|
|
461
1487
|
try {
|
|
462
|
-
rows = await this.
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
+
});
|
|
467
1500
|
} catch (exc) {
|
|
468
1501
|
throw wrap("Failed to retrieve image batch", exc);
|
|
469
1502
|
}
|
|
470
|
-
return
|
|
1503
|
+
return mapLimit(
|
|
1504
|
+
rows,
|
|
1505
|
+
4,
|
|
1506
|
+
(r) => hydrateRow(r, String(r.id), this.opts.objectStorage)
|
|
1507
|
+
);
|
|
471
1508
|
}
|
|
472
1509
|
async getMetadata(id, { connection } = {}) {
|
|
473
1510
|
if (!isValidId(id)) return null;
|
|
474
1511
|
let row;
|
|
475
1512
|
try {
|
|
476
|
-
row = await this.
|
|
1513
|
+
row = await this.execute(
|
|
477
1514
|
"get_metadata",
|
|
478
1515
|
connection,
|
|
479
|
-
|
|
1516
|
+
"read",
|
|
1517
|
+
(db) => db.prepare(this.dedup ? D_SELECT_METADATA : SELECT_METADATA2).get(id.toLowerCase())
|
|
480
1518
|
);
|
|
481
1519
|
} catch (exc) {
|
|
482
1520
|
throw wrap("Failed to retrieve image metadata", exc);
|
|
@@ -494,25 +1532,37 @@ var PostgresBackend = class {
|
|
|
494
1532
|
}
|
|
495
1533
|
async getStream(id, opts) {
|
|
496
1534
|
if (!isValidId(id)) return null;
|
|
1535
|
+
const norm = id.toLowerCase();
|
|
497
1536
|
const { connection, chunkSize } = opts;
|
|
498
1537
|
if (!Number.isInteger(chunkSize) || chunkSize <= 0)
|
|
499
1538
|
throw new RangeError("chunkSize must be a positive integer");
|
|
500
1539
|
let info;
|
|
501
1540
|
try {
|
|
502
|
-
info = await this.
|
|
1541
|
+
info = await this.execute(
|
|
503
1542
|
"get_metadata",
|
|
504
1543
|
connection,
|
|
505
|
-
|
|
1544
|
+
"read",
|
|
1545
|
+
(db) => db.prepare(this.dedup ? D_STREAM_INFO : STREAM_INFO).get(norm)
|
|
506
1546
|
);
|
|
507
1547
|
} catch (exc) {
|
|
508
1548
|
throw wrap("Failed to retrieve image metadata", exc);
|
|
509
1549
|
}
|
|
510
1550
|
if (!info) return null;
|
|
511
|
-
if (info.storage_backend === "object_storage")
|
|
512
|
-
throw this.tieredWithoutStorage(id, info.object_storage_key);
|
|
513
1551
|
const totalSize = info.size_bytes;
|
|
514
1552
|
const start = opts.range?.start ?? 0;
|
|
515
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
|
+
}
|
|
516
1566
|
const self = this;
|
|
517
1567
|
async function* generate() {
|
|
518
1568
|
let offset = start;
|
|
@@ -522,23 +1572,24 @@ var PostgresBackend = class {
|
|
|
522
1572
|
const length = Math.min(chunkSize, wanted - delivered);
|
|
523
1573
|
let row;
|
|
524
1574
|
try {
|
|
525
|
-
row = await self.
|
|
1575
|
+
row = await self.execute(
|
|
526
1576
|
"get_stream",
|
|
527
1577
|
connection,
|
|
528
|
-
|
|
1578
|
+
"read",
|
|
1579
|
+
(db) => db.prepare(self.dedup ? D_CHUNK : CHUNK).get(offset + 1, length, norm)
|
|
529
1580
|
);
|
|
530
1581
|
} catch (exc) {
|
|
531
1582
|
throw wrap("Failed to stream image", exc);
|
|
532
1583
|
}
|
|
533
1584
|
if (!row) {
|
|
534
1585
|
throw new StorageError(
|
|
535
|
-
`Image ${JSON.stringify(
|
|
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.`
|
|
536
1587
|
);
|
|
537
1588
|
}
|
|
538
1589
|
const chunk = row.chunk;
|
|
539
1590
|
if (!chunk || chunk.length === 0) {
|
|
540
1591
|
throw new StorageError(
|
|
541
|
-
`Image ${JSON.stringify(
|
|
1592
|
+
`Image ${JSON.stringify(norm)} ended early while streaming (delivered ${delivered} of ${wanted} bytes).`
|
|
542
1593
|
);
|
|
543
1594
|
}
|
|
544
1595
|
yield chunk;
|
|
@@ -550,25 +1601,74 @@ var PostgresBackend = class {
|
|
|
550
1601
|
}
|
|
551
1602
|
async delete(id, { connection } = {}) {
|
|
552
1603
|
if (!isValidId(id)) return false;
|
|
1604
|
+
const norm = id.toLowerCase();
|
|
553
1605
|
try {
|
|
554
|
-
|
|
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(
|
|
555
1617
|
"delete",
|
|
556
1618
|
connection,
|
|
557
|
-
|
|
1619
|
+
"write",
|
|
1620
|
+
(db) => db.prepare(DELETE_ONE).get(norm)
|
|
558
1621
|
);
|
|
1622
|
+
if (row?.storage_backend === "object_storage")
|
|
1623
|
+
await this.cleanupObjects([row.object_storage_key], connection);
|
|
559
1624
|
return row !== void 0;
|
|
560
1625
|
} catch (exc) {
|
|
561
1626
|
throw wrap("Failed to delete image", exc);
|
|
562
1627
|
}
|
|
563
1628
|
}
|
|
564
1629
|
async deleteMany(ids, { connection } = {}) {
|
|
565
|
-
const valid =
|
|
1630
|
+
const valid = [
|
|
1631
|
+
...new Set(ids.filter(isValidId).map((i) => i.toLowerCase()))
|
|
1632
|
+
];
|
|
566
1633
|
if (valid.length === 0) return [];
|
|
567
1634
|
try {
|
|
568
|
-
|
|
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(
|
|
569
1657
|
"delete_many",
|
|
570
1658
|
connection,
|
|
571
|
-
|
|
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
|
|
572
1672
|
);
|
|
573
1673
|
return rows.map((r) => String(r.id));
|
|
574
1674
|
} catch (exc) {
|
|
@@ -578,56 +1678,116 @@ var PostgresBackend = class {
|
|
|
578
1678
|
async exists(id, { connection } = {}) {
|
|
579
1679
|
if (!isValidId(id)) return false;
|
|
580
1680
|
try {
|
|
581
|
-
return await this.
|
|
1681
|
+
return await this.execute(
|
|
582
1682
|
"exists",
|
|
583
1683
|
connection,
|
|
584
|
-
|
|
1684
|
+
"read",
|
|
1685
|
+
(db) => db.prepare(this.dedup ? D_EXISTS : EXISTS2).get(id.toLowerCase()) !== void 0
|
|
585
1686
|
);
|
|
586
1687
|
} catch (exc) {
|
|
587
1688
|
throw wrap("Failed to check image existence", exc);
|
|
588
1689
|
}
|
|
589
1690
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
+
})
|
|
608
1709
|
);
|
|
609
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
|
+
}
|
|
610
1790
|
};
|
|
611
|
-
function rowParams(r) {
|
|
612
|
-
return [
|
|
613
|
-
r.data,
|
|
614
|
-
r.mimeType,
|
|
615
|
-
r.originalFilename,
|
|
616
|
-
r.sizeBytes,
|
|
617
|
-
r.width,
|
|
618
|
-
r.height,
|
|
619
|
-
r.checksumSha256
|
|
620
|
-
];
|
|
621
|
-
}
|
|
622
|
-
function wrap(prefix, exc) {
|
|
623
|
-
return exc instanceof StorageError ? exc : new StorageError(`${prefix}: ${msg(exc)}`, { cause: exc });
|
|
624
|
-
}
|
|
625
|
-
function msg(exc) {
|
|
626
|
-
return exc instanceof Error ? exc.message : String(exc);
|
|
627
|
-
}
|
|
628
|
-
function sleep(ms) {
|
|
629
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
630
|
-
}
|
|
631
1791
|
|
|
632
1792
|
// src/validation.ts
|
|
633
1793
|
var FORMAT_TO_MIME = {
|
|
@@ -771,7 +1931,18 @@ var ZeroBucket = class _ZeroBucket {
|
|
|
771
1931
|
beforeGet;
|
|
772
1932
|
beforePut;
|
|
773
1933
|
constructor(options = {}) {
|
|
774
|
-
|
|
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({
|
|
775
1946
|
connectionString: options.connectionString,
|
|
776
1947
|
pool: options.pool,
|
|
777
1948
|
autoMigrate: options.autoMigrate,
|
|
@@ -780,8 +1951,10 @@ var ZeroBucket = class _ZeroBucket {
|
|
|
780
1951
|
poolMinSize: options.poolMinSize,
|
|
781
1952
|
poolMaxSize: options.poolMaxSize,
|
|
782
1953
|
poolTimeoutMs: options.poolTimeoutMs,
|
|
783
|
-
onOperation: options.onOperation
|
|
784
|
-
|
|
1954
|
+
onOperation: options.onOperation,
|
|
1955
|
+
objectStorage: options.objectStorage,
|
|
1956
|
+
dedup: options.dedup
|
|
1957
|
+
}));
|
|
785
1958
|
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
786
1959
|
this.maxPixels = options.maxPixels ?? DEFAULT_MAX_PIXELS;
|
|
787
1960
|
this.allowedFormats = options.allowedFormats ?? SUPPORTED_FORMATS;
|
|
@@ -865,7 +2038,7 @@ var ZeroBucket = class _ZeroBucket {
|
|
|
865
2038
|
sizeBytes,
|
|
866
2039
|
width,
|
|
867
2040
|
height,
|
|
868
|
-
checksumSha256: (0,
|
|
2041
|
+
checksumSha256: (0, import_node_crypto2.createHash)("sha256").update(finalData).digest("hex")
|
|
869
2042
|
};
|
|
870
2043
|
}
|
|
871
2044
|
/** Validate, optionally optimize, checksum and store one image. Returns its id (a UUID). */
|
|
@@ -902,7 +2075,7 @@ var ZeroBucket = class _ZeroBucket {
|
|
|
902
2075
|
}
|
|
903
2076
|
const results = new Array(images.length);
|
|
904
2077
|
const prepared = [];
|
|
905
|
-
await
|
|
2078
|
+
await mapLimit2(images, PREPARE_CONCURRENCY, async (image, i) => {
|
|
906
2079
|
try {
|
|
907
2080
|
const row = await this.prepareRow(image, {
|
|
908
2081
|
...options,
|
|
@@ -1118,6 +2291,32 @@ var ZeroBucket = class _ZeroBucket {
|
|
|
1118
2291
|
success: true
|
|
1119
2292
|
}));
|
|
1120
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
|
+
}
|
|
1121
2320
|
/** Release the pool (if ZeroBucket created it). */
|
|
1122
2321
|
close() {
|
|
1123
2322
|
return this.backend.close();
|
|
@@ -1171,7 +2370,7 @@ async function readInput(image, filename, maxBytes) {
|
|
|
1171
2370
|
"Unsupported image input. Pass a Buffer, Uint8Array, file path string, Blob/File, or a readable stream."
|
|
1172
2371
|
);
|
|
1173
2372
|
}
|
|
1174
|
-
async function
|
|
2373
|
+
async function mapLimit2(items, limit, fn) {
|
|
1175
2374
|
let next = 0;
|
|
1176
2375
|
const workers = Array.from(
|
|
1177
2376
|
{ length: Math.min(limit, items.length) },
|
|
@@ -1188,6 +2387,119 @@ function errText(exc) {
|
|
|
1188
2387
|
return exc instanceof Error ? exc.message : String(exc);
|
|
1189
2388
|
}
|
|
1190
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
|
+
|
|
1191
2503
|
// src/validators/pdf.ts
|
|
1192
2504
|
var DEFAULT_MAX_PDF_BYTES = 20 * 1024 * 1024;
|
|
1193
2505
|
var PDFValidator = class {
|
|
@@ -1231,8 +2543,10 @@ var PDFValidator = class {
|
|
|
1231
2543
|
ImageNotFoundError,
|
|
1232
2544
|
ImageTooLargeError,
|
|
1233
2545
|
ImageValidationError,
|
|
2546
|
+
ObjectStorage,
|
|
1234
2547
|
PDFValidator,
|
|
1235
2548
|
PostgresBackend,
|
|
2549
|
+
SQLiteBackend,
|
|
1236
2550
|
SUPPORTED_FORMATS,
|
|
1237
2551
|
StorageError,
|
|
1238
2552
|
UnsupportedFormatError,
|