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