zerobucket 0.7.0 → 0.8.1
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 +81 -35
- package/dist/bin.js +1010 -17
- package/dist/{client-XQ22b1l3.d.cts → client-l4jPIFJI.d.cts} +21 -8
- package/dist/{client-XQ22b1l3.d.ts → client-l4jPIFJI.d.ts} +21 -8
- package/dist/http.d.cts +1 -1
- package/dist/http.d.ts +1 -1
- package/dist/index.cjs +294 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +28 -14
- package/dist/index.d.ts +28 -14
- package/dist/index.js +294 -24
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -191,6 +191,24 @@ interface StorageInfo {
|
|
|
191
191
|
storedBytes: number;
|
|
192
192
|
};
|
|
193
193
|
}
|
|
194
|
+
interface MigrateToDedupOptions {
|
|
195
|
+
/**
|
|
196
|
+
* Classic rows already tiered to object storage cannot be migrated (their bytes are not in the
|
|
197
|
+
* database, and dedup mode has no tiering). By default the migration REFUSES, changing nothing.
|
|
198
|
+
* With `skipTiered: true` it skips them instead, leaves them untouched in the classic table, and
|
|
199
|
+
* reports the count in `tieredSkipped`. Skipped images will not exist in the dedup tables.
|
|
200
|
+
*/
|
|
201
|
+
skipTiered?: boolean;
|
|
202
|
+
}
|
|
203
|
+
interface MigrateToDedupResult {
|
|
204
|
+
imagesMigrated: number;
|
|
205
|
+
distinctBlobsCreated: number;
|
|
206
|
+
duplicateReferencesFound: number;
|
|
207
|
+
/** Ids already present in the dedup tables (a re-run): skipped, never double-counted. */
|
|
208
|
+
alreadyMigrated: number;
|
|
209
|
+
/** Tiered classic rows skipped because `skipTiered` was set; always 0 otherwise. */
|
|
210
|
+
tieredSkipped: number;
|
|
211
|
+
}
|
|
194
212
|
interface StorageBackend {
|
|
195
213
|
/** Resolve once the backend is connected and migrated. Safe to call repeatedly. */
|
|
196
214
|
ready(): Promise<void>;
|
|
@@ -213,12 +231,7 @@ interface StorageBackend {
|
|
|
213
231
|
/** null = no such image, false = already tiered (no-op), true = tiered now. Needs object storage configured. */
|
|
214
232
|
tierToObjectStorage(id: string, opts?: CallOptions): Promise<boolean | null>;
|
|
215
233
|
/** Dedup-mode instances only: copy the classic table into the dedup tables (non-destructive). */
|
|
216
|
-
migrateClassicToDedup?(): Promise<
|
|
217
|
-
imagesMigrated: number;
|
|
218
|
-
distinctBlobsCreated: number;
|
|
219
|
-
duplicateReferencesFound: number;
|
|
220
|
-
alreadyMigrated: number;
|
|
221
|
-
}>;
|
|
234
|
+
migrateClassicToDedup?(opts?: MigrateToDedupOptions): Promise<MigrateToDedupResult>;
|
|
222
235
|
/** Maintenance: storage statistics, or null if the tables do not exist yet. */
|
|
223
236
|
getInfo?(): Promise<StorageInfo | null>;
|
|
224
237
|
/** Maintenance: list ids matching the filters (oldest first, or random when sampling). */
|
|
@@ -487,7 +500,7 @@ declare class ZeroBucket<C = unknown> {
|
|
|
487
500
|
* One-time, non-destructive copy of classic-mode images into a dedup instance's tables, keeping every
|
|
488
501
|
* id. Call it on an instance created with `dedup: true`. Safe to re-run. See PostgresBackend for details.
|
|
489
502
|
*/
|
|
490
|
-
migrateClassicToDedup():
|
|
503
|
+
migrateClassicToDedup(opts?: MigrateToDedupOptions): Promise<MigrateToDedupResult>;
|
|
491
504
|
/** Release the pool (if ZeroBucket created it). */
|
|
492
505
|
close(): Promise<void>;
|
|
493
506
|
}
|
|
@@ -502,4 +515,4 @@ declare function readInput(image: ImageInput, filename: string | undefined, maxB
|
|
|
502
515
|
filename: string | undefined;
|
|
503
516
|
}>;
|
|
504
517
|
|
|
505
|
-
export {
|
|
518
|
+
export { type StreamOptions as A, type BatchDeleteResult as B, type CallOptions as C, DEFAULT_MAX_BYTES as D, type ZeroBucketOptions as E, optimizeImage as F, type GetOptions as G, readInput as H, type ImageListOptions as I, type MigrateToDedupOptions as M, type OperationEvent as O, type PreparedRow as P, type Queryable as Q, type StorageBackend as S, type ValidatedContent as V, type WritableLike as W, ZeroBucket as Z, type ObjectStorageLike as a, type StoredRecord as b, type StoredRecordMetadata as c, type StreamOptions$1 as d, type StorageInfo as e, type ImageListEntry as f, type MigrateToDedupResult as g, type MysqlPoolLike as h, type ContentValidator as i, type BatchGetResult as j, type BatchPutResult as k, type BeforeGetHook as l, type BeforePutHook as m, type Connection as n, type Image as o, type ImageInput as p, type ImageMetadata as q, type MysqlPoolConnectionLike as r, type MysqlQueryable as s, ObjectStorage as t, type ObjectStorageOptions as u, type OptimizationResult as v, type OptimizeOptions as w, type PutManyOptions as x, type PutOptions as y, type SqliteDatabaseLike as z };
|
|
@@ -191,6 +191,24 @@ interface StorageInfo {
|
|
|
191
191
|
storedBytes: number;
|
|
192
192
|
};
|
|
193
193
|
}
|
|
194
|
+
interface MigrateToDedupOptions {
|
|
195
|
+
/**
|
|
196
|
+
* Classic rows already tiered to object storage cannot be migrated (their bytes are not in the
|
|
197
|
+
* database, and dedup mode has no tiering). By default the migration REFUSES, changing nothing.
|
|
198
|
+
* With `skipTiered: true` it skips them instead, leaves them untouched in the classic table, and
|
|
199
|
+
* reports the count in `tieredSkipped`. Skipped images will not exist in the dedup tables.
|
|
200
|
+
*/
|
|
201
|
+
skipTiered?: boolean;
|
|
202
|
+
}
|
|
203
|
+
interface MigrateToDedupResult {
|
|
204
|
+
imagesMigrated: number;
|
|
205
|
+
distinctBlobsCreated: number;
|
|
206
|
+
duplicateReferencesFound: number;
|
|
207
|
+
/** Ids already present in the dedup tables (a re-run): skipped, never double-counted. */
|
|
208
|
+
alreadyMigrated: number;
|
|
209
|
+
/** Tiered classic rows skipped because `skipTiered` was set; always 0 otherwise. */
|
|
210
|
+
tieredSkipped: number;
|
|
211
|
+
}
|
|
194
212
|
interface StorageBackend {
|
|
195
213
|
/** Resolve once the backend is connected and migrated. Safe to call repeatedly. */
|
|
196
214
|
ready(): Promise<void>;
|
|
@@ -213,12 +231,7 @@ interface StorageBackend {
|
|
|
213
231
|
/** null = no such image, false = already tiered (no-op), true = tiered now. Needs object storage configured. */
|
|
214
232
|
tierToObjectStorage(id: string, opts?: CallOptions): Promise<boolean | null>;
|
|
215
233
|
/** Dedup-mode instances only: copy the classic table into the dedup tables (non-destructive). */
|
|
216
|
-
migrateClassicToDedup?(): Promise<
|
|
217
|
-
imagesMigrated: number;
|
|
218
|
-
distinctBlobsCreated: number;
|
|
219
|
-
duplicateReferencesFound: number;
|
|
220
|
-
alreadyMigrated: number;
|
|
221
|
-
}>;
|
|
234
|
+
migrateClassicToDedup?(opts?: MigrateToDedupOptions): Promise<MigrateToDedupResult>;
|
|
222
235
|
/** Maintenance: storage statistics, or null if the tables do not exist yet. */
|
|
223
236
|
getInfo?(): Promise<StorageInfo | null>;
|
|
224
237
|
/** Maintenance: list ids matching the filters (oldest first, or random when sampling). */
|
|
@@ -487,7 +500,7 @@ declare class ZeroBucket<C = unknown> {
|
|
|
487
500
|
* One-time, non-destructive copy of classic-mode images into a dedup instance's tables, keeping every
|
|
488
501
|
* id. Call it on an instance created with `dedup: true`. Safe to re-run. See PostgresBackend for details.
|
|
489
502
|
*/
|
|
490
|
-
migrateClassicToDedup():
|
|
503
|
+
migrateClassicToDedup(opts?: MigrateToDedupOptions): Promise<MigrateToDedupResult>;
|
|
491
504
|
/** Release the pool (if ZeroBucket created it). */
|
|
492
505
|
close(): Promise<void>;
|
|
493
506
|
}
|
|
@@ -502,4 +515,4 @@ declare function readInput(image: ImageInput, filename: string | undefined, maxB
|
|
|
502
515
|
filename: string | undefined;
|
|
503
516
|
}>;
|
|
504
517
|
|
|
505
|
-
export {
|
|
518
|
+
export { type StreamOptions as A, type BatchDeleteResult as B, type CallOptions as C, DEFAULT_MAX_BYTES as D, type ZeroBucketOptions as E, optimizeImage as F, type GetOptions as G, readInput as H, type ImageListOptions as I, type MigrateToDedupOptions as M, type OperationEvent as O, type PreparedRow as P, type Queryable as Q, type StorageBackend as S, type ValidatedContent as V, type WritableLike as W, ZeroBucket as Z, type ObjectStorageLike as a, type StoredRecord as b, type StoredRecordMetadata as c, type StreamOptions$1 as d, type StorageInfo as e, type ImageListEntry as f, type MigrateToDedupResult as g, type MysqlPoolLike as h, type ContentValidator as i, type BatchGetResult as j, type BatchPutResult as k, type BeforeGetHook as l, type BeforePutHook as m, type Connection as n, type Image as o, type ImageInput as p, type ImageMetadata as q, type MysqlPoolConnectionLike as r, type MysqlQueryable as s, ObjectStorage as t, type ObjectStorageOptions as u, type OptimizationResult as v, type OptimizeOptions as w, type PutManyOptions as x, type PutOptions as y, type SqliteDatabaseLike as z };
|
package/dist/http.d.cts
CHANGED
package/dist/http.d.ts
CHANGED
package/dist/index.cjs
CHANGED
|
@@ -980,13 +980,13 @@ var PostgresBackend = class {
|
|
|
980
980
|
* dedup instance's tables, preserving every id (external references keep working) and
|
|
981
981
|
* deduplicating identical content on the way. The classic table is never modified.
|
|
982
982
|
*
|
|
983
|
-
*
|
|
984
|
-
*
|
|
985
|
-
*
|
|
986
|
-
*
|
|
983
|
+
* Rows are copied in small batches (bounded memory instead of loading the whole table);
|
|
984
|
+
* re-running is safe (already-migrated ids are skipped and reported, not an error). Classic rows
|
|
985
|
+
* already tiered to object storage are refused up front with a clear message (their bytes are
|
|
986
|
+
* not in the database to copy) unless `skipTiered: true`, which skips and counts them instead.
|
|
987
987
|
* Still all-or-nothing: one transaction.
|
|
988
988
|
*/
|
|
989
|
-
async migrateClassicToDedup() {
|
|
989
|
+
async migrateClassicToDedup(opts = {}) {
|
|
990
990
|
if (!this.dedup) {
|
|
991
991
|
throw new StorageError(
|
|
992
992
|
"migrateClassicToDedup() must be called on an instance created with dedup: true."
|
|
@@ -1005,6 +1005,7 @@ var PostgresBackend = class {
|
|
|
1005
1005
|
"No classic zerobucket_images table found in this database -- nothing to migrate."
|
|
1006
1006
|
);
|
|
1007
1007
|
}
|
|
1008
|
+
let tieredSkipped = 0;
|
|
1008
1009
|
const hasTierCol = (await client.query(
|
|
1009
1010
|
"SELECT 1 FROM information_schema.columns WHERE table_name = 'zerobucket_images' AND column_name = 'storage_backend'"
|
|
1010
1011
|
)).rowCount;
|
|
@@ -1012,12 +1013,14 @@ var PostgresBackend = class {
|
|
|
1012
1013
|
const tiered = (await client.query(
|
|
1013
1014
|
"SELECT count(*)::int AS n FROM zerobucket_images WHERE storage_backend <> 'postgres'"
|
|
1014
1015
|
)).rows[0].n;
|
|
1015
|
-
if (tiered > 0) {
|
|
1016
|
+
if (tiered > 0 && !opts.skipTiered) {
|
|
1016
1017
|
throw new StorageError(
|
|
1017
|
-
`${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,
|
|
1018
|
+
`${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.`
|
|
1018
1019
|
);
|
|
1019
1020
|
}
|
|
1021
|
+
tieredSkipped = tiered;
|
|
1020
1022
|
}
|
|
1023
|
+
const onlyInDb = hasTierCol && tieredSkipped > 0;
|
|
1021
1024
|
const seen = /* @__PURE__ */ new Set();
|
|
1022
1025
|
let migrated = 0;
|
|
1023
1026
|
let already = 0;
|
|
@@ -1026,11 +1029,11 @@ var PostgresBackend = class {
|
|
|
1026
1029
|
for (; ; ) {
|
|
1027
1030
|
const page = cursor ? await client.query(
|
|
1028
1031
|
`SELECT id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, created_at::text AS created_at_txt
|
|
1029
|
-
FROM zerobucket_images WHERE (created_at, id) > ($1::timestamptz, $2::uuid) ORDER BY created_at, id LIMIT ${BATCH}`,
|
|
1032
|
+
FROM zerobucket_images WHERE (created_at, id) > ($1::timestamptz, $2::uuid)${onlyInDb ? " AND storage_backend = 'postgres'" : ""} ORDER BY created_at, id LIMIT ${BATCH}`,
|
|
1030
1033
|
[cursor.createdAt, cursor.id]
|
|
1031
1034
|
) : await client.query(
|
|
1032
1035
|
`SELECT id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, created_at::text AS created_at_txt
|
|
1033
|
-
FROM zerobucket_images ORDER BY created_at, id LIMIT ${BATCH}`
|
|
1036
|
+
FROM zerobucket_images${onlyInDb ? " WHERE storage_backend = 'postgres'" : ""} ORDER BY created_at, id LIMIT ${BATCH}`
|
|
1034
1037
|
);
|
|
1035
1038
|
if (page.rows.length === 0) break;
|
|
1036
1039
|
const last = page.rows[page.rows.length - 1];
|
|
@@ -1080,7 +1083,8 @@ var PostgresBackend = class {
|
|
|
1080
1083
|
imagesMigrated: migrated,
|
|
1081
1084
|
distinctBlobsCreated: seen.size,
|
|
1082
1085
|
duplicateReferencesFound: migrated - seen.size,
|
|
1083
|
-
alreadyMigrated: already
|
|
1086
|
+
alreadyMigrated: already,
|
|
1087
|
+
tieredSkipped
|
|
1084
1088
|
};
|
|
1085
1089
|
} catch (exc) {
|
|
1086
1090
|
try {
|
|
@@ -2014,11 +2018,60 @@ var CHUNK2 = `SELECT SUBSTRING(data FROM ? FOR ?) AS chunk FROM zerobucket_image
|
|
|
2014
2018
|
var EXISTS3 = `SELECT 1 FROM zerobucket_images WHERE id = ?`;
|
|
2015
2019
|
var LOCK_ONE = `SELECT storage_backend, object_storage_key FROM zerobucket_images WHERE id = ? FOR UPDATE`;
|
|
2016
2020
|
var DELETE_ONE2 = `DELETE FROM zerobucket_images WHERE id = ?`;
|
|
2017
|
-
var LOCK_MANY = `SELECT id, storage_backend, object_storage_key FROM zerobucket_images WHERE id IN (?) ORDER BY id FOR UPDATE`;
|
|
2018
|
-
var DELETE_MANY2 = `DELETE FROM zerobucket_images WHERE id IN (?)`;
|
|
2021
|
+
var LOCK_MANY = `SELECT id, storage_backend, object_storage_key FROM zerobucket_images FORCE INDEX (PRIMARY) WHERE id IN (?) ORDER BY id FOR UPDATE`;
|
|
2019
2022
|
var SELECT_FOR_TIERING3 = `SELECT data, mime_type, size_bytes, storage_backend FROM zerobucket_images WHERE id = ? FOR UPDATE`;
|
|
2020
2023
|
var UPDATE_AFTER_TIERING3 = `UPDATE zerobucket_images SET data = NULL, storage_backend = 'object_storage', object_storage_bucket = ?, object_storage_key = ?, updated_at = ? WHERE id = ?`;
|
|
2021
2024
|
var LOCAL = "mysql";
|
|
2025
|
+
var DEDUP_BLOBS_SCHEMA = `
|
|
2026
|
+
CREATE TABLE IF NOT EXISTS zerobucket_blobs (
|
|
2027
|
+
checksum_sha256 CHAR(64) PRIMARY KEY,
|
|
2028
|
+
data LONGBLOB NOT NULL,
|
|
2029
|
+
mime_type VARCHAR(255) NOT NULL,
|
|
2030
|
+
size_bytes INT NOT NULL,
|
|
2031
|
+
width INT,
|
|
2032
|
+
height INT,
|
|
2033
|
+
ref_count INT NOT NULL DEFAULT 0,
|
|
2034
|
+
created_at DATETIME(6) NOT NULL
|
|
2035
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`;
|
|
2036
|
+
var DEDUP_REFS_SCHEMA = `
|
|
2037
|
+
CREATE TABLE IF NOT EXISTS zerobucket_image_refs (
|
|
2038
|
+
id CHAR(36) PRIMARY KEY,
|
|
2039
|
+
checksum_sha256 CHAR(64) NOT NULL,
|
|
2040
|
+
original_filename TEXT,
|
|
2041
|
+
created_at DATETIME(6) NOT NULL,
|
|
2042
|
+
updated_at DATETIME(6) NOT NULL,
|
|
2043
|
+
INDEX idx_zerobucket_image_refs_checksum (checksum_sha256),
|
|
2044
|
+
INDEX idx_zerobucket_image_refs_created_at (created_at),
|
|
2045
|
+
FOREIGN KEY (checksum_sha256) REFERENCES zerobucket_blobs(checksum_sha256)
|
|
2046
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`;
|
|
2047
|
+
function upsertBlobSql(n) {
|
|
2048
|
+
if (!Number.isInteger(n) || n < 1) throw new RangeError("invalid ref count");
|
|
2049
|
+
return `INSERT INTO zerobucket_blobs (checksum_sha256, data, mime_type, size_bytes, width, height, ref_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ${n}, ?) ON DUPLICATE KEY UPDATE ref_count = ref_count + ${n}`;
|
|
2050
|
+
}
|
|
2051
|
+
var D_INSERT_REF3 = `INSERT INTO zerobucket_image_refs (id, checksum_sha256, original_filename, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`;
|
|
2052
|
+
var D_COLS3 = `r.id, b.data, b.mime_type, r.original_filename, b.size_bytes, b.width, b.height, r.checksum_sha256, '${LOCAL}' AS storage_backend, NULL AS object_storage_bucket, NULL AS object_storage_key`;
|
|
2053
|
+
var D_FROM3 = `FROM zerobucket_image_refs r JOIN zerobucket_blobs b ON r.checksum_sha256 = b.checksum_sha256`;
|
|
2054
|
+
var D_LOCK_REF = `SELECT checksum_sha256 FROM zerobucket_image_refs WHERE id = ? FOR UPDATE`;
|
|
2055
|
+
var D_DELETE_REF3 = `DELETE FROM zerobucket_image_refs WHERE id = ?`;
|
|
2056
|
+
var D_LOCK_REFS = `SELECT id, checksum_sha256 FROM zerobucket_image_refs FORCE INDEX (PRIMARY) WHERE id IN (?) ORDER BY id FOR UPDATE`;
|
|
2057
|
+
var D_DECREMENT3 = `UPDATE zerobucket_blobs SET ref_count = ref_count - ? WHERE checksum_sha256 = ?`;
|
|
2058
|
+
var D_DELETE_EMPTY3 = `DELETE FROM zerobucket_blobs WHERE checksum_sha256 = ? AND ref_count <= 0`;
|
|
2059
|
+
var CLASSIC_SQL2 = {
|
|
2060
|
+
selectFull: SELECT_FULL3,
|
|
2061
|
+
selectMany: SELECT_MANY2,
|
|
2062
|
+
selectMetadata: SELECT_METADATA3,
|
|
2063
|
+
streamInfo: STREAM_INFO2,
|
|
2064
|
+
chunk: CHUNK2,
|
|
2065
|
+
exists: EXISTS3
|
|
2066
|
+
};
|
|
2067
|
+
var DEDUP_SQL2 = {
|
|
2068
|
+
selectFull: `SELECT ${D_COLS3} ${D_FROM3} WHERE r.id = ?`,
|
|
2069
|
+
selectMany: `SELECT ${D_COLS3} ${D_FROM3} WHERE r.id IN (?)`,
|
|
2070
|
+
selectMetadata: `SELECT r.id, b.mime_type, r.original_filename, b.size_bytes, b.width, b.height, r.checksum_sha256 ${D_FROM3} WHERE r.id = ?`,
|
|
2071
|
+
streamInfo: `SELECT b.size_bytes, '${LOCAL}' AS storage_backend, NULL AS object_storage_key ${D_FROM3} WHERE r.id = ?`,
|
|
2072
|
+
chunk: `SELECT SUBSTRING(b.data FROM ? FOR ?) AS chunk ${D_FROM3} WHERE r.id = ?`,
|
|
2073
|
+
exists: `SELECT 1 FROM zerobucket_image_refs WHERE id = ?`
|
|
2074
|
+
};
|
|
2022
2075
|
var RETRYABLE_ERRNOS = /* @__PURE__ */ new Set([
|
|
2023
2076
|
1205,
|
|
2024
2077
|
// ER_LOCK_WAIT_TIMEOUT
|
|
@@ -2108,11 +2161,13 @@ var MySQLBackend = class {
|
|
|
2108
2161
|
"MySQLBackend requires either `connectionString` or `pool`"
|
|
2109
2162
|
);
|
|
2110
2163
|
}
|
|
2111
|
-
|
|
2164
|
+
this.dedup = opts.dedup ?? false;
|
|
2165
|
+
if (this.dedup && opts.objectStorage) {
|
|
2112
2166
|
throw new TypeError(
|
|
2113
|
-
"
|
|
2167
|
+
"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."
|
|
2114
2168
|
);
|
|
2115
2169
|
}
|
|
2170
|
+
this.sql = this.dedup ? DEDUP_SQL2 : CLASSIC_SQL2;
|
|
2116
2171
|
this.ownsPool = !opts.pool;
|
|
2117
2172
|
this.pool = opts.pool;
|
|
2118
2173
|
this.maxRetries = opts.maxRetries ?? 3;
|
|
@@ -2129,6 +2184,8 @@ var MySQLBackend = class {
|
|
|
2129
2184
|
retryBaseDelayMs;
|
|
2130
2185
|
poolTimeoutMs;
|
|
2131
2186
|
autoMigrate;
|
|
2187
|
+
dedup;
|
|
2188
|
+
sql;
|
|
2132
2189
|
// ---- lifecycle -------------------------------------------------------
|
|
2133
2190
|
ready() {
|
|
2134
2191
|
if (this.closed)
|
|
@@ -2194,6 +2251,10 @@ var MySQLBackend = class {
|
|
|
2194
2251
|
);
|
|
2195
2252
|
}
|
|
2196
2253
|
try {
|
|
2254
|
+
if (this.dedup) {
|
|
2255
|
+
await this.migrateDedup(q);
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2197
2258
|
const tableExists = (await q.query(
|
|
2198
2259
|
"SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'zerobucket_images'"
|
|
2199
2260
|
))[0].length > 0;
|
|
@@ -2235,6 +2296,16 @@ var MySQLBackend = class {
|
|
|
2235
2296
|
});
|
|
2236
2297
|
}
|
|
2237
2298
|
}
|
|
2299
|
+
/** Dedup tables, created only if missing (same no-needless-DDL rule as the classic table). */
|
|
2300
|
+
async migrateDedup(q) {
|
|
2301
|
+
const have = new Set(
|
|
2302
|
+
(await q.query(
|
|
2303
|
+
"SELECT TABLE_NAME AS t FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ('zerobucket_blobs', 'zerobucket_image_refs')"
|
|
2304
|
+
))[0].map((r) => r.t)
|
|
2305
|
+
);
|
|
2306
|
+
if (!have.has("zerobucket_blobs")) await q.query(DEDUP_BLOBS_SCHEMA);
|
|
2307
|
+
if (!have.has("zerobucket_image_refs")) await q.query(DEDUP_REFS_SCHEMA);
|
|
2308
|
+
}
|
|
2238
2309
|
async close() {
|
|
2239
2310
|
this.closed = true;
|
|
2240
2311
|
const pool = this.pool;
|
|
@@ -2400,6 +2471,19 @@ var MySQLBackend = class {
|
|
|
2400
2471
|
async put(row, { connection } = {}) {
|
|
2401
2472
|
try {
|
|
2402
2473
|
const id = (0, import_node_crypto2.randomUUID)();
|
|
2474
|
+
if (this.dedup) {
|
|
2475
|
+
await this.run(
|
|
2476
|
+
"put",
|
|
2477
|
+
connection,
|
|
2478
|
+
async (q) => {
|
|
2479
|
+
const now = mysqlNow();
|
|
2480
|
+
await q.execute(upsertBlobSql(1), blobParams2(row, now));
|
|
2481
|
+
await q.execute(D_INSERT_REF3, refParams(id, row, now));
|
|
2482
|
+
},
|
|
2483
|
+
{ tx: true }
|
|
2484
|
+
);
|
|
2485
|
+
return id;
|
|
2486
|
+
}
|
|
2403
2487
|
await this.run("put", connection, async (q) => {
|
|
2404
2488
|
await q.execute(INSERT3, rowParams2(id, row, mysqlNow()));
|
|
2405
2489
|
});
|
|
@@ -2417,6 +2501,21 @@ var MySQLBackend = class {
|
|
|
2417
2501
|
connection,
|
|
2418
2502
|
async (q) => {
|
|
2419
2503
|
const now = mysqlNow();
|
|
2504
|
+
if (this.dedup) {
|
|
2505
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2506
|
+
for (const r of rows) {
|
|
2507
|
+
const g = groups.get(r.checksumSha256);
|
|
2508
|
+
if (g) g.n += 1;
|
|
2509
|
+
else groups.set(r.checksumSha256, { row: r, n: 1 });
|
|
2510
|
+
}
|
|
2511
|
+
for (const checksum of [...groups.keys()].sort()) {
|
|
2512
|
+
const g = groups.get(checksum);
|
|
2513
|
+
await q.execute(upsertBlobSql(g.n), blobParams2(g.row, now));
|
|
2514
|
+
}
|
|
2515
|
+
for (let i = 0; i < rows.length; i++)
|
|
2516
|
+
await q.execute(D_INSERT_REF3, refParams(ids[i], rows[i], now));
|
|
2517
|
+
return;
|
|
2518
|
+
}
|
|
2420
2519
|
for (let i = 0; i < rows.length; i++)
|
|
2421
2520
|
await q.execute(INSERT3, rowParams2(ids[i], rows[i], now));
|
|
2422
2521
|
},
|
|
@@ -2434,7 +2533,7 @@ var MySQLBackend = class {
|
|
|
2434
2533
|
row = await this.run(
|
|
2435
2534
|
"get",
|
|
2436
2535
|
connection,
|
|
2437
|
-
async (q) => (await q.query(
|
|
2536
|
+
async (q) => (await q.query(this.sql.selectFull, [id]))[0][0]
|
|
2438
2537
|
);
|
|
2439
2538
|
} catch (exc) {
|
|
2440
2539
|
throw wrap("Failed to retrieve image", exc);
|
|
@@ -2449,7 +2548,7 @@ var MySQLBackend = class {
|
|
|
2449
2548
|
rows = await this.run(
|
|
2450
2549
|
"get_many",
|
|
2451
2550
|
connection,
|
|
2452
|
-
async (q) => (await q.query(
|
|
2551
|
+
async (q) => (await q.query(this.sql.selectMany, [valid]))[0]
|
|
2453
2552
|
);
|
|
2454
2553
|
} catch (exc) {
|
|
2455
2554
|
throw wrap("Failed to retrieve image batch", exc);
|
|
@@ -2463,7 +2562,7 @@ var MySQLBackend = class {
|
|
|
2463
2562
|
row = await this.run(
|
|
2464
2563
|
"get_metadata",
|
|
2465
2564
|
connection,
|
|
2466
|
-
async (q) => (await q.query(
|
|
2565
|
+
async (q) => (await q.query(this.sql.selectMetadata, [id]))[0][0]
|
|
2467
2566
|
);
|
|
2468
2567
|
} catch (exc) {
|
|
2469
2568
|
throw wrap("Failed to retrieve image metadata", exc);
|
|
@@ -2489,7 +2588,7 @@ var MySQLBackend = class {
|
|
|
2489
2588
|
info = await this.run(
|
|
2490
2589
|
"get_metadata",
|
|
2491
2590
|
connection,
|
|
2492
|
-
async (q) => (await q.query(
|
|
2591
|
+
async (q) => (await q.query(this.sql.streamInfo, [id]))[0][0]
|
|
2493
2592
|
);
|
|
2494
2593
|
} catch (exc) {
|
|
2495
2594
|
throw wrap("Failed to retrieve image metadata", exc);
|
|
@@ -2522,7 +2621,7 @@ var MySQLBackend = class {
|
|
|
2522
2621
|
row = await self.run(
|
|
2523
2622
|
"get_stream",
|
|
2524
2623
|
connection,
|
|
2525
|
-
async (q) => (await q.query(
|
|
2624
|
+
async (q) => (await q.query(self.sql.chunk, [offset + 1, length, id]))[0][0]
|
|
2526
2625
|
);
|
|
2527
2626
|
} catch (exc) {
|
|
2528
2627
|
throw wrap("Failed to stream image", exc);
|
|
@@ -2547,6 +2646,7 @@ var MySQLBackend = class {
|
|
|
2547
2646
|
}
|
|
2548
2647
|
async delete(id, { connection } = {}) {
|
|
2549
2648
|
if (!isValidId(id)) return false;
|
|
2649
|
+
if (this.dedup) return this.deleteDedup(id, connection);
|
|
2550
2650
|
try {
|
|
2551
2651
|
const row = await this.run(
|
|
2552
2652
|
"delete",
|
|
@@ -2569,14 +2669,14 @@ var MySQLBackend = class {
|
|
|
2569
2669
|
async deleteMany(ids, { connection } = {}) {
|
|
2570
2670
|
const valid = [...new Set(ids.filter(isValidId))];
|
|
2571
2671
|
if (valid.length === 0) return [];
|
|
2672
|
+
if (this.dedup) return this.deleteManyDedup(valid, connection);
|
|
2572
2673
|
try {
|
|
2573
2674
|
const rows = await this.run(
|
|
2574
2675
|
"delete_many",
|
|
2575
2676
|
connection,
|
|
2576
2677
|
async (q) => {
|
|
2577
2678
|
const found = (await q.query(LOCK_MANY, [valid]))[0];
|
|
2578
|
-
|
|
2579
|
-
await q.query(DELETE_MANY2, [found.map((r) => r.id)]);
|
|
2679
|
+
for (const r of found) await q.query(DELETE_ONE2, [r.id]);
|
|
2580
2680
|
return found;
|
|
2581
2681
|
},
|
|
2582
2682
|
{ tx: true }
|
|
@@ -2596,12 +2696,163 @@ var MySQLBackend = class {
|
|
|
2596
2696
|
return await this.run(
|
|
2597
2697
|
"exists",
|
|
2598
2698
|
connection,
|
|
2599
|
-
async (q) => (await q.query(
|
|
2699
|
+
async (q) => (await q.query(this.sql.exists, [id]))[0].length > 0
|
|
2600
2700
|
);
|
|
2601
2701
|
} catch (exc) {
|
|
2602
2702
|
throw wrap("Failed to check image existence", exc);
|
|
2603
2703
|
}
|
|
2604
2704
|
}
|
|
2705
|
+
// ---- maintenance (CLI) -----------------------------------------------
|
|
2706
|
+
/** Storage statistics, or null if the tables do not exist yet (nothing is created). */
|
|
2707
|
+
async getInfo() {
|
|
2708
|
+
try {
|
|
2709
|
+
return await this.run("info", void 0, async (q) => {
|
|
2710
|
+
const need = this.dedup ? ["zerobucket_image_refs", "zerobucket_blobs"] : ["zerobucket_images"];
|
|
2711
|
+
const present = new Set(
|
|
2712
|
+
(await q.query(
|
|
2713
|
+
"SELECT TABLE_NAME AS t FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (?)",
|
|
2714
|
+
[need]
|
|
2715
|
+
))[0].map((r) => r.t)
|
|
2716
|
+
);
|
|
2717
|
+
if (!need.every((t) => present.has(t))) return null;
|
|
2718
|
+
const j = this.dedup ? "FROM zerobucket_image_refs r JOIN zerobucket_blobs b ON r.checksum_sha256 = b.checksum_sha256" : "FROM zerobucket_images";
|
|
2719
|
+
const size = this.dedup ? "b.size_bytes" : "size_bytes";
|
|
2720
|
+
const created = this.dedup ? "r.created_at" : "created_at";
|
|
2721
|
+
const mime = this.dedup ? "b.mime_type" : "mime_type";
|
|
2722
|
+
const tot = (await q.query(
|
|
2723
|
+
`SELECT COUNT(*) AS n, COALESCE(SUM(${size}), 0) AS bytes, CAST(MIN(${created}) AS CHAR) AS oldest, CAST(MAX(${created}) AS CHAR) AS newest ${j}`
|
|
2724
|
+
))[0][0];
|
|
2725
|
+
const disk = (await q.query(
|
|
2726
|
+
"SELECT COALESCE(SUM(DATA_LENGTH + INDEX_LENGTH), 0) AS s FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (?)",
|
|
2727
|
+
[need]
|
|
2728
|
+
))[0][0];
|
|
2729
|
+
const fmt = (await q.query(
|
|
2730
|
+
`SELECT ${mime} AS m, COUNT(*) AS n, COALESCE(SUM(${size}), 0) AS bytes ${j} GROUP BY ${mime} ORDER BY COUNT(*) DESC, ${mime}`
|
|
2731
|
+
))[0];
|
|
2732
|
+
const utc = (v) => v ? `${v}+00:00` : null;
|
|
2733
|
+
const info = {
|
|
2734
|
+
mode: this.dedup ? "dedup" : "classic",
|
|
2735
|
+
count: Number(tot.n),
|
|
2736
|
+
totalBytes: Number(tot.bytes),
|
|
2737
|
+
oldest: utc(tot.oldest),
|
|
2738
|
+
newest: utc(tot.newest),
|
|
2739
|
+
onDisk: prettySize(Number(disk.s)),
|
|
2740
|
+
byFormat: fmt.map((r) => ({
|
|
2741
|
+
mimeType: r.m,
|
|
2742
|
+
count: Number(r.n),
|
|
2743
|
+
bytes: Number(r.bytes)
|
|
2744
|
+
}))
|
|
2745
|
+
};
|
|
2746
|
+
if (this.dedup) {
|
|
2747
|
+
const b = (await q.query(
|
|
2748
|
+
"SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS bytes FROM zerobucket_blobs"
|
|
2749
|
+
))[0][0];
|
|
2750
|
+
info.dedup = { blobs: Number(b.n), storedBytes: Number(b.bytes) };
|
|
2751
|
+
} else {
|
|
2752
|
+
const t = (await q.query(
|
|
2753
|
+
"SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS bytes FROM zerobucket_images WHERE storage_backend = 'object_storage'"
|
|
2754
|
+
))[0][0];
|
|
2755
|
+
info.tiered = { count: Number(t.n), bytes: Number(t.bytes) };
|
|
2756
|
+
}
|
|
2757
|
+
return info;
|
|
2758
|
+
});
|
|
2759
|
+
} catch (exc) {
|
|
2760
|
+
throw wrap("Failed to read storage info", exc);
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
/** Ids matching the filters: oldest first, or a random sample. Used by the CLI's verify and tier. */
|
|
2764
|
+
async listImages(o = {}) {
|
|
2765
|
+
const where = [];
|
|
2766
|
+
const params = [];
|
|
2767
|
+
const created = this.dedup ? "r.created_at" : "created_at";
|
|
2768
|
+
if (o.minSize !== void 0) {
|
|
2769
|
+
where.push(`${this.dedup ? "b.size_bytes" : "size_bytes"} >= ?`);
|
|
2770
|
+
params.push(o.minSize);
|
|
2771
|
+
}
|
|
2772
|
+
if (o.olderThanDays !== void 0) {
|
|
2773
|
+
where.push(`${created} <= UTC_TIMESTAMP(6) - INTERVAL ? DAY`);
|
|
2774
|
+
params.push(o.olderThanDays);
|
|
2775
|
+
}
|
|
2776
|
+
if (o.onlyUntiered && !this.dedup)
|
|
2777
|
+
where.push(`storage_backend = '${LOCAL}'`);
|
|
2778
|
+
const from = this.dedup ? "FROM zerobucket_image_refs r JOIN zerobucket_blobs b ON r.checksum_sha256 = b.checksum_sha256" : "FROM zerobucket_images";
|
|
2779
|
+
const idCol = this.dedup ? "r.id" : "id";
|
|
2780
|
+
const tierCol = this.dedup ? "0 AS tiered" : "(storage_backend = 'object_storage') AS tiered";
|
|
2781
|
+
const order = o.sample !== void 0 ? "ORDER BY RAND()" : `ORDER BY ${created}, ${idCol}`;
|
|
2782
|
+
const lim = o.sample ?? o.limit;
|
|
2783
|
+
let sql = `SELECT ${idCol} AS id, ${tierCol} ${from}${where.length ? " WHERE " + where.join(" AND ") : ""} ${order}`;
|
|
2784
|
+
if (lim !== void 0) {
|
|
2785
|
+
if (!Number.isInteger(lim) || lim < 0)
|
|
2786
|
+
throw new RangeError("limit/sample must be a non-negative integer");
|
|
2787
|
+
sql += ` LIMIT ${lim}`;
|
|
2788
|
+
}
|
|
2789
|
+
try {
|
|
2790
|
+
const rows = await this.run(
|
|
2791
|
+
"list_images",
|
|
2792
|
+
void 0,
|
|
2793
|
+
async (q) => (await q.query(sql, params))[0]
|
|
2794
|
+
);
|
|
2795
|
+
return rows.map((r) => ({
|
|
2796
|
+
id: String(r.id),
|
|
2797
|
+
tiered: Boolean(Number(r.tiered))
|
|
2798
|
+
}));
|
|
2799
|
+
} catch (exc) {
|
|
2800
|
+
throw wrap("Failed to list images", exc);
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
// ---- dedup deletes ---------------------------------------------------
|
|
2804
|
+
/**
|
|
2805
|
+
* Lock the ref row, delete it, then decrement its blob (the UPDATE takes the blob row lock, so two
|
|
2806
|
+
* deletes of different refs to one blob serialize instead of racing) and remove the blob once nothing
|
|
2807
|
+
* references it. All in one transaction: a failure can never leave a ref without its count or the reverse.
|
|
2808
|
+
*/
|
|
2809
|
+
async deleteDedup(id, connection) {
|
|
2810
|
+
try {
|
|
2811
|
+
return await this.run(
|
|
2812
|
+
"delete",
|
|
2813
|
+
connection,
|
|
2814
|
+
async (q) => {
|
|
2815
|
+
const ref = (await q.query(D_LOCK_REF, [id]))[0][0];
|
|
2816
|
+
if (!ref) return false;
|
|
2817
|
+
const checksum = ref.checksum_sha256;
|
|
2818
|
+
await q.query(D_DELETE_REF3, [id]);
|
|
2819
|
+
await q.query(D_DECREMENT3, [1, checksum]);
|
|
2820
|
+
await q.query(D_DELETE_EMPTY3, [checksum]);
|
|
2821
|
+
return true;
|
|
2822
|
+
},
|
|
2823
|
+
{ tx: true }
|
|
2824
|
+
);
|
|
2825
|
+
} catch (exc) {
|
|
2826
|
+
throw wrap("Failed to delete image", exc);
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
async deleteManyDedup(ids, connection) {
|
|
2830
|
+
try {
|
|
2831
|
+
return await this.run(
|
|
2832
|
+
"delete_many",
|
|
2833
|
+
connection,
|
|
2834
|
+
async (q) => {
|
|
2835
|
+
const found = (await q.query(D_LOCK_REFS, [ids]))[0];
|
|
2836
|
+
if (found.length === 0) return [];
|
|
2837
|
+
for (const r of found) await q.query(D_DELETE_REF3, [r.id]);
|
|
2838
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2839
|
+
for (const r of found)
|
|
2840
|
+
counts.set(
|
|
2841
|
+
r.checksum_sha256,
|
|
2842
|
+
(counts.get(r.checksum_sha256) ?? 0) + 1
|
|
2843
|
+
);
|
|
2844
|
+
for (const checksum of [...counts.keys()].sort()) {
|
|
2845
|
+
await q.query(D_DECREMENT3, [counts.get(checksum), checksum]);
|
|
2846
|
+
await q.query(D_DELETE_EMPTY3, [checksum]);
|
|
2847
|
+
}
|
|
2848
|
+
return found.map((r) => String(r.id));
|
|
2849
|
+
},
|
|
2850
|
+
{ tx: true }
|
|
2851
|
+
);
|
|
2852
|
+
} catch (exc) {
|
|
2853
|
+
throw wrap("Failed to delete image batch", exc);
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2605
2856
|
// ---- helpers ---------------------------------------------------------
|
|
2606
2857
|
hydrate(row, id) {
|
|
2607
2858
|
return hydrateRow(row, id, this.opts.objectStorage);
|
|
@@ -2648,6 +2899,11 @@ var MySQLBackend = class {
|
|
|
2648
2899
|
* id) makes the re-upload an idempotent overwrite.
|
|
2649
2900
|
*/
|
|
2650
2901
|
async tierToObjectStorage(id, { connection } = {}) {
|
|
2902
|
+
if (this.dedup) {
|
|
2903
|
+
throw new StorageError(
|
|
2904
|
+
"tierToObjectStorage() is not supported in dedup mode (a blob may be shared by many images)."
|
|
2905
|
+
);
|
|
2906
|
+
}
|
|
2651
2907
|
const os = this.opts.objectStorage;
|
|
2652
2908
|
if (!os) {
|
|
2653
2909
|
throw new StorageError(
|
|
@@ -2675,6 +2931,20 @@ var MySQLBackend = class {
|
|
|
2675
2931
|
}
|
|
2676
2932
|
}
|
|
2677
2933
|
};
|
|
2934
|
+
function blobParams2(r, now) {
|
|
2935
|
+
return [
|
|
2936
|
+
r.checksumSha256,
|
|
2937
|
+
r.data,
|
|
2938
|
+
r.mimeType,
|
|
2939
|
+
r.sizeBytes,
|
|
2940
|
+
r.width,
|
|
2941
|
+
r.height,
|
|
2942
|
+
now
|
|
2943
|
+
];
|
|
2944
|
+
}
|
|
2945
|
+
function refParams(id, r, now) {
|
|
2946
|
+
return [id, r.checksumSha256, r.originalFilename, now, now];
|
|
2947
|
+
}
|
|
2678
2948
|
function rowParams2(id, r, now) {
|
|
2679
2949
|
return [
|
|
2680
2950
|
id,
|
|
@@ -3473,13 +3743,13 @@ var ZeroBucket = class _ZeroBucket {
|
|
|
3473
3743
|
* One-time, non-destructive copy of classic-mode images into a dedup instance's tables, keeping every
|
|
3474
3744
|
* id. Call it on an instance created with `dedup: true`. Safe to re-run. See PostgresBackend for details.
|
|
3475
3745
|
*/
|
|
3476
|
-
async migrateClassicToDedup() {
|
|
3746
|
+
async migrateClassicToDedup(opts) {
|
|
3477
3747
|
if (!this.backend.migrateClassicToDedup) {
|
|
3478
3748
|
throw new StorageError(
|
|
3479
3749
|
"This backend does not support migrateClassicToDedup()."
|
|
3480
3750
|
);
|
|
3481
3751
|
}
|
|
3482
|
-
return this.backend.migrateClassicToDedup();
|
|
3752
|
+
return this.backend.migrateClassicToDedup(opts);
|
|
3483
3753
|
}
|
|
3484
3754
|
/** Release the pool (if ZeroBucket created it). */
|
|
3485
3755
|
close() {
|