zerobucket 0.8.0 → 0.8.2

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 CHANGED
@@ -123,6 +123,12 @@ await images.get(id, { context: { userId } }); // throws AccessDeniedError if th
123
123
  **A hook that throws fails closed**: the original exception propagates; it is never treated as
124
124
  "allow". Denied calls never reach the database. `exists()` is deliberately not gated.
125
125
 
126
+ **Your hook receives the id exactly as the caller supplied it**, before ZeroBucket validates it, so it can be a
127
+ malformed string such as one taken straight from a URL. Check the format before using it in SQL (for example against a
128
+ UUID pattern, returning `true` for a malformed id so ZeroBucket answers "not found"); otherwise a request for
129
+ `/images/garbage` becomes a database error and a `500`. The [example app](https://github.com/KedarGhadyalji/ZeroBucket/tree/main/examples/node-integration)
130
+ shows a working hook, plus transactional uploads and serving.
131
+
126
132
  ## Streaming and ranges
127
133
 
128
134
  ```ts
@@ -236,6 +242,7 @@ zerobucket tier IMAGE_ID --bucket my-images # move one i
236
242
  zerobucket tier --older-than 90 --min-size 500000 --bucket my-images --dry-run # preview a bulk move
237
243
  zerobucket tier --all --limit 1000 --bucket my-images --endpoint-url https://<acct>.r2.cloudflarestorage.com
238
244
  zerobucket migrate --to-dedup # PostgreSQL: copy classic images into the dedup tables (non-destructive, re-runnable)
245
+ zerobucket migrate --to-dedup --skip-tiered # ...skipping images tiered to object storage instead of refusing
239
246
  ```
240
247
 
241
248
  - **Exit codes:** `0` success, `1` problems found or an operation failed, `2` usage or connection error. That makes
@@ -390,7 +397,11 @@ await images.delete(b); // last reference gone: blob removed
390
397
 
391
398
  **Moving existing data in:** `await images.migrateClassicToDedup()` copies every classic image into the dedup tables,
392
399
  keeping each id, and never modifies the classic table. It runs in small batches (bounded memory), is safe to re-run
393
- (already-migrated ids are skipped and counted), and refuses up front if any classic image is tiered to object storage.
400
+ (already-migrated ids are skipped and counted), and refuses up front if any classic image is tiered to object storage
401
+ (their bytes are not in the database to copy, and dedup mode has no tiering), changing nothing. If you would rather
402
+ migrate everything else, pass `{ skipTiered: true }` (CLI: `--skip-tiered`): tiered images are left untouched in the
403
+ classic table, counted in `tieredSkipped`, and **will not exist in the dedup tables**, so only use it if you can live
404
+ without them there.
394
405
 
395
406
  ## Object-storage tiering
396
407
 
@@ -440,30 +451,52 @@ same corpus of valid and corrupt files identically.
440
451
 
441
452
  ## Differences from the Python package
442
453
 
443
- | | Python | npm |
444
- | ---------------------------------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
445
- | API | sync + async clients | one async client |
446
- | Malformed id (`"abc"`) | `StorageError` from Postgres | treated as not found (`ImageNotFoundError`, no DB round trip), friendlier for `/images/:id` routes |
447
- | `optimize` | Pillow; EXIF stripped, not applied | sharp; EXIF **applied then stripped**; transparency → **white** for JPEG |
448
- | `putMany` inserts | pipelined | one transaction, statements run sequentially |
449
- | Formats | JPEG, PNG, WebP, HEIC (`[heic]` extra) | JPEG, PNG, WebP, HEIC (optional `libheif-js`) |
450
- | Convert TO HEIC with `optimize` | yes (pillow-heif) | no (no HEVC encoder for Node); converting FROM HEIC works; `optimize: true` on a HEIC needs an explicit `format` |
451
- | Event duration | `duration_seconds` | `durationMs` |
452
- | `migrateClassicToDedup` | loads the whole table into memory; fails if re-run or if any row is tiered | batched, safe to re-run, refuses tiered rows with a clear message |
453
- | Dedup `putMany` / `deleteMany` locking | arbitrary order (overlapping batches can deadlock) | sorted checksum order (cannot deadlock) |
454
- | SQLite API | synchronous `SQLiteBackend` + separate async backend | one async client; the sync driver runs on the event loop, lock waits are async (never block it) |
455
- | SQLite `onOperation` / busy retry | none | `onOperation` events and async `SQLITE_BUSY` retry (`busyTimeoutMs`) |
456
- | SQLite stream deleted mid-read | sync adapter keeps reading a WAL snapshot (async adapter raises) | raises `StorageError`, like Postgres; use `connection` in a transaction for a snapshot |
457
- | CLI | PostgreSQL, classic mode only; `verify` crashes on tiered rows | PostgreSQL **and** SQLite, classic **and** dedup; `info`/`verify` understand tiered rows; `migrate --to-dedup` |
458
- | `connection=` without an open transaction | multi-statement ops rely on the driver's implicit transaction | always atomic (own transaction or savepoint) |
459
- | `delete(id, connection=...)` on a tiered image | deletes the S3 object immediately | leaves the object until you delete it after commit (a rolled-back transaction can never lose data) |
460
- | S3 delete failure after `delete()` | raises after the row is gone | never fails the delete; reported via `onOperation` |
461
- | Options | snake_case kwargs | camelCase options object |
462
-
463
- ## What's not here yet
464
-
465
- Planned for MySQL/MariaDB: a MySQL-specific operations guide, and `migrate --to-dedup` (PostgreSQL-only today, as in the
466
- Python package). Tiering and dedup cannot be combined (same rule as the Python package).
454
+ Python column = zerobucket on PyPI **0.25.1 or later** (several of its behaviours below were fixed in 0.25.1; older versions
455
+ differ).
456
+
457
+ | | Python | npm |
458
+ | -------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
459
+ | API | sync + async clients | one async client |
460
+ | Malformed id (`"abc"`) | `StorageError` from Postgres | treated as not found (`ImageNotFoundError`, no DB round trip), friendlier for `/images/:id` routes |
461
+ | `optimize` | Pillow; EXIF stripped, not applied | sharp; EXIF **applied then stripped**; transparency → **white** for JPEG |
462
+ | `putMany` inserts | pipelined | one transaction, statements run sequentially |
463
+ | Formats | JPEG, PNG, WebP, HEIC (`[heic]` extra) | JPEG, PNG, WebP, HEIC (optional `libheif-js`) |
464
+ | Convert TO HEIC with `optimize` | yes (pillow-heif) | no (no HEVC encoder for Node); converting FROM HEIC works; `optimize: true` on a HEIC needs an explicit `format` |
465
+ | Event duration | `duration_seconds` | `durationMs` |
466
+ | `migrateClassicToDedup`, tiered classic rows | always **skips** them and reports `tiered_rows_skipped` | **refuses** by default (changes nothing); `skipTiered: true` / `--skip-tiered` skips and reports like Python |
467
+ | `migrateClassicToDedup`, other behaviour | batched, safe to re-run (server-side cursor) | batched, safe to re-run (keyset paging, advisory lock so concurrent runs cannot race) |
468
+ | SQLite API | synchronous `SQLiteBackend` + separate async backend | one async client; the sync driver runs on the event loop, lock waits are async (never block it) |
469
+ | SQLite `onOperation` / busy retry | none | `onOperation` events and async `SQLITE_BUSY` retry (`busyTimeoutMs`) |
470
+ | SQLite stream deleted mid-read | sync adapter keeps reading a WAL snapshot (async adapter raises) | raises `StorageError`, like Postgres; use `connection` in a transaction for a snapshot |
471
+ | MySQL `onOperation` / retry | sync `MySQLBackend` yes; `AsyncMySQLBackend` none | yes (same client for everything) |
472
+ | CLI | PostgreSQL, classic mode only; `verify` skips tiered rows with a note | PostgreSQL, SQLite **and** MySQL/MariaDB, classic **and** dedup; `verify --bucket` also checks tiered images; `migrate --to-dedup` |
473
+ | `connection=` without an open transaction | multi-statement ops rely on the driver's implicit transaction | always atomic (own transaction or savepoint) |
474
+ | S3 delete failure after `delete()` | raises after the row is gone | never fails the delete; reported via `onOperation` |
475
+ | Options | snake_case kwargs | camelCase options object |
476
+
477
+ Both packages now leave a tiered image's object in place on `delete(id, connection=...)` (a rolled-back transaction can
478
+ never lose data), take dedup batch locks in sorted checksum order (overlapping batches cannot deadlock), and skip the
479
+ MySQL `ALTER TABLE` at startup when the schema is current.
480
+
481
+ ## Limitations (read before using in production)
482
+
483
+ - **Images live in your database.** That is the point (one backup, one transaction, no extra service), but it means
484
+ this is not built for very large files or high-volume media: every image goes through the database and counts toward
485
+ its size, its backups and its replication. The default size limit is 8 MiB (`maxBytes`). For large video or
486
+ millions of big files, use object storage directly.
487
+ - **Backups and space need attention.** A whole-database dump now includes every image, and deleting images does not
488
+ give disk space back on its own (PostgreSQL needs autovacuum, MySQL `OPTIMIZE TABLE`, SQLite `VACUUM`). SQLite
489
+ files must be backed up with `.backup`, not copied. See the
490
+ [Operations Guide](https://github.com/KedarGhadyalji/ZeroBucket/blob/main/docs/OPERATIONS.md).
491
+ - **Dedup and tiering cannot be combined** (a shared blob has many owners): `dedup: true` with `objectStorage` throws.
492
+ - **`migrate --to-dedup` is PostgreSQL-only**, as in the Python package. Tiered classic images cannot be migrated (see
493
+ Deduplication for `skipTiered`).
494
+ - **HEIC can be read and converted from, but not written.** There is no HEVC encoder for Node, so `optimize` cannot
495
+ produce HEIC.
496
+ - **SQLite allows one writer at a time**; lock waits are async and never block the event loop, but write-heavy
497
+ workloads belong on PostgreSQL or MySQL/MariaDB.
498
+ - **Tested versions:** CI runs PostgreSQL 16 and MariaDB 10.11; MySQL 8.0.46 was also run by hand. Other versions are
499
+ expected to behave the same but have not been run.
467
500
 
468
501
  ## Runtime notes
469
502
 
package/dist/bin.js CHANGED
@@ -875,13 +875,13 @@ var PostgresBackend = class {
875
875
  * dedup instance's tables, preserving every id (external references keep working) and
876
876
  * deduplicating identical content on the way. The classic table is never modified.
877
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).
878
+ * Rows are copied in small batches (bounded memory instead of loading the whole table);
879
+ * re-running is safe (already-migrated ids are skipped and reported, not an error). Classic rows
880
+ * already tiered to object storage are refused up front with a clear message (their bytes are
881
+ * not in the database to copy) unless `skipTiered: true`, which skips and counts them instead.
882
882
  * Still all-or-nothing: one transaction.
883
883
  */
884
- async migrateClassicToDedup() {
884
+ async migrateClassicToDedup(opts = {}) {
885
885
  if (!this.dedup) {
886
886
  throw new StorageError(
887
887
  "migrateClassicToDedup() must be called on an instance created with dedup: true."
@@ -900,6 +900,7 @@ var PostgresBackend = class {
900
900
  "No classic zerobucket_images table found in this database -- nothing to migrate."
901
901
  );
902
902
  }
903
+ let tieredSkipped = 0;
903
904
  const hasTierCol = (await client.query(
904
905
  "SELECT 1 FROM information_schema.columns WHERE table_name = 'zerobucket_images' AND column_name = 'storage_backend'"
905
906
  )).rowCount;
@@ -907,12 +908,14 @@ var PostgresBackend = class {
907
908
  const tiered = (await client.query(
908
909
  "SELECT count(*)::int AS n FROM zerobucket_images WHERE storage_backend <> 'postgres'"
909
910
  )).rows[0].n;
910
- if (tiered > 0) {
911
+ if (tiered > 0 && !opts.skipTiered) {
911
912
  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
+ `${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.`
913
914
  );
914
915
  }
916
+ tieredSkipped = tiered;
915
917
  }
918
+ const onlyInDb = hasTierCol && tieredSkipped > 0;
916
919
  const seen = /* @__PURE__ */ new Set();
917
920
  let migrated = 0;
918
921
  let already = 0;
@@ -921,11 +924,11 @@ var PostgresBackend = class {
921
924
  for (; ; ) {
922
925
  const page = cursor ? await client.query(
923
926
  `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}`,
927
+ FROM zerobucket_images WHERE (created_at, id) > ($1::timestamptz, $2::uuid)${onlyInDb ? " AND storage_backend = 'postgres'" : ""} ORDER BY created_at, id LIMIT ${BATCH}`,
925
928
  [cursor.createdAt, cursor.id]
926
929
  ) : await client.query(
927
930
  `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}`
931
+ FROM zerobucket_images${onlyInDb ? " WHERE storage_backend = 'postgres'" : ""} ORDER BY created_at, id LIMIT ${BATCH}`
929
932
  );
930
933
  if (page.rows.length === 0) break;
931
934
  const last = page.rows[page.rows.length - 1];
@@ -975,7 +978,8 @@ var PostgresBackend = class {
975
978
  imagesMigrated: migrated,
976
979
  distinctBlobsCreated: seen.size,
977
980
  duplicateReferencesFound: migrated - seen.size,
978
- alreadyMigrated: already
981
+ alreadyMigrated: already,
982
+ tieredSkipped
979
983
  };
980
984
  } catch (exc) {
981
985
  try {
@@ -2995,6 +2999,7 @@ Commands:
2995
2999
  init Create the schema (tables and indexes) if missing.
2996
3000
  migrate Apply schema migrations (currently: same as init).
2997
3001
  --to-dedup copy every classic image into the dedup tables (PostgreSQL only; non-destructive)
3002
+ --skip-tiered with --to-dedup: skip images tiered to object storage instead of refusing
2998
3003
  info Show storage stats: count, size, breakdown by format.
2999
3004
  verify Re-checksum stored images to detect corruption.
3000
3005
  --sample N check a random sample of N images instead of every image
@@ -3024,6 +3029,7 @@ var OPTIONS = {
3024
3029
  help: { type: "boolean", short: "h" },
3025
3030
  version: { type: "boolean", short: "v" },
3026
3031
  "to-dedup": { type: "boolean" },
3032
+ "skip-tiered": { type: "boolean" },
3027
3033
  sample: { type: "string" },
3028
3034
  bucket: { type: "string" },
3029
3035
  "endpoint-url": { type: "string" },
@@ -3038,7 +3044,7 @@ var OPTIONS = {
3038
3044
  };
3039
3045
  var COMMAND_OPTIONS = {
3040
3046
  init: [],
3041
- migrate: ["to-dedup"],
3047
+ migrate: ["to-dedup", "skip-tiered"],
3042
3048
  info: [],
3043
3049
  verify: [
3044
3050
  "sample",
@@ -3155,6 +3161,8 @@ async function cmdInit(v, env, io) {
3155
3161
  return 0;
3156
3162
  }
3157
3163
  async function cmdMigrate(v, env, io) {
3164
+ if (v["skip-tiered"] && !v["to-dedup"])
3165
+ throw usageError("Error: --skip-tiered only applies with --to-dedup.");
3158
3166
  if (v["to-dedup"]) {
3159
3167
  const target = resolveTarget(v, env);
3160
3168
  if (target.kind !== "postgres")
@@ -3162,7 +3170,9 @@ async function cmdMigrate(v, env, io) {
3162
3170
  const backend = openBackend(target, { dedup: true, autoMigrate: true });
3163
3171
  await connect(backend, "connect");
3164
3172
  try {
3165
- const r = await backend.migrateClassicToDedup();
3173
+ const r = await backend.migrateClassicToDedup({
3174
+ skipTiered: Boolean(v["skip-tiered"])
3175
+ });
3166
3176
  io.out(
3167
3177
  `Migrated ${num(r.imagesMigrated)} image(s) into ${num(r.distinctBlobsCreated)} distinct blob(s).`
3168
3178
  );
@@ -3170,6 +3180,10 @@ async function cmdMigrate(v, env, io) {
3170
3180
  ` Duplicate references found: ${num(r.duplicateReferencesFound)}`
3171
3181
  );
3172
3182
  io.out(` Already migrated (skipped): ${num(r.alreadyMigrated)}`);
3183
+ if (r.tieredSkipped > 0)
3184
+ io.out(
3185
+ ` Tiered to object storage (skipped, NOT in the dedup tables): ${num(r.tieredSkipped)}`
3186
+ );
3173
3187
  io.out("The classic table was not modified.");
3174
3188
  return 0;
3175
3189
  } catch (exc) {
@@ -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(): ReturnType<NonNullable<StorageBackend["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 { optimizeImage as A, type BatchDeleteResult as B, type CallOptions as C, DEFAULT_MAX_BYTES as D, readInput as E, type GetOptions as G, type ImageListOptions as I, type MysqlPoolLike 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 ContentValidator as g, type BatchGetResult as h, type BatchPutResult as i, type BeforeGetHook as j, type BeforePutHook as k, type Connection as l, type Image as m, type ImageInput as n, type ImageMetadata as o, type MysqlPoolConnectionLike as p, type MysqlQueryable as q, ObjectStorage as r, type ObjectStorageOptions as s, type OptimizationResult as t, type OptimizeOptions as u, type PutManyOptions as v, type PutOptions as w, type SqliteDatabaseLike as x, type StreamOptions as y, type ZeroBucketOptions as z };
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(): ReturnType<NonNullable<StorageBackend["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 { optimizeImage as A, type BatchDeleteResult as B, type CallOptions as C, DEFAULT_MAX_BYTES as D, readInput as E, type GetOptions as G, type ImageListOptions as I, type MysqlPoolLike 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 ContentValidator as g, type BatchGetResult as h, type BatchPutResult as i, type BeforeGetHook as j, type BeforePutHook as k, type Connection as l, type Image as m, type ImageInput as n, type ImageMetadata as o, type MysqlPoolConnectionLike as p, type MysqlQueryable as q, ObjectStorage as r, type ObjectStorageOptions as s, type OptimizationResult as t, type OptimizeOptions as u, type PutManyOptions as v, type PutOptions as w, type SqliteDatabaseLike as x, type StreamOptions as y, type ZeroBucketOptions as z };
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
@@ -1,5 +1,5 @@
1
1
  import { IncomingMessage, ServerResponse } from 'node:http';
2
- import { Z as ZeroBucket } from './client-XQ22b1l3.cjs';
2
+ import { Z as ZeroBucket } from './client-l4jPIFJI.cjs';
3
3
  import 'pg';
4
4
 
5
5
  /**
package/dist/http.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { IncomingMessage, ServerResponse } from 'node:http';
2
- import { Z as ZeroBucket } from './client-XQ22b1l3.js';
2
+ import { Z as ZeroBucket } from './client-l4jPIFJI.js';
3
3
  import 'pg';
4
4
 
5
5
  /**
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
- * Differences from the Python version, all deliberate: rows are copied in small batches (bounded
984
- * memory instead of loading the whole table); re-running is safe (already-migrated ids are
985
- * skipped and reported, not an error); and classic rows already tiered to object storage are
986
- * refused up front with a clear message (their bytes are not in the database to copy).
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, or bring those images back first.`
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 {
@@ -3739,13 +3743,13 @@ var ZeroBucket = class _ZeroBucket {
3739
3743
  * One-time, non-destructive copy of classic-mode images into a dedup instance's tables, keeping every
3740
3744
  * id. Call it on an instance created with `dedup: true`. Safe to re-run. See PostgresBackend for details.
3741
3745
  */
3742
- async migrateClassicToDedup() {
3746
+ async migrateClassicToDedup(opts) {
3743
3747
  if (!this.backend.migrateClassicToDedup) {
3744
3748
  throw new StorageError(
3745
3749
  "This backend does not support migrateClassicToDedup()."
3746
3750
  );
3747
3751
  }
3748
- return this.backend.migrateClassicToDedup();
3752
+ return this.backend.migrateClassicToDedup(opts);
3749
3753
  }
3750
3754
  /** Release the pool (if ZeroBucket created it). */
3751
3755
  close() {