@cloudbitmaps/roaring 0.8.2 → 0.10.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.
Files changed (78) hide show
  1. package/NOTICE +3 -3
  2. package/PRIVACY.md +198 -72
  3. package/README.md +148 -50
  4. package/dist/bin/export-segments.d.ts +21 -0
  5. package/dist/bin/export-segments.js +1508 -30
  6. package/dist/bin/export-segments.js.map +7 -1
  7. package/dist/codec-bound.d.ts +38 -0
  8. package/dist/index.d.ts +875 -425
  9. package/dist/index.js +997 -306
  10. package/dist/index.js.map +7 -1
  11. package/dist/moved-options.d.ts +14 -0
  12. package/dist/portable/decode.d.ts +54 -0
  13. package/dist/roaring-codec.d.ts +61 -0
  14. package/dist/system-clock.d.ts +40 -0
  15. package/dist/testing/conformance.d.ts +34 -0
  16. package/dist/testing/fuzz-codec.d.ts +9 -0
  17. package/package.json +11 -116
  18. package/dist/azure/index.cjs +0 -14
  19. package/dist/azure/index.cjs.map +0 -1
  20. package/dist/azure/index.d.cts +0 -9
  21. package/dist/azure/index.d.ts +0 -9
  22. package/dist/azure/index.js +0 -3
  23. package/dist/azure/index.js.map +0 -1
  24. package/dist/bin/compact-segments.js +0 -134
  25. package/dist/bin/compact-segments.js.map +0 -1
  26. package/dist/cassandra/index.cjs +0 -14
  27. package/dist/cassandra/index.cjs.map +0 -1
  28. package/dist/cassandra/index.d.cts +0 -1
  29. package/dist/cassandra/index.d.ts +0 -1
  30. package/dist/cassandra/index.js +0 -3
  31. package/dist/cassandra/index.js.map +0 -1
  32. package/dist/chunk-4NVOQJGJ.js +0 -801
  33. package/dist/chunk-4NVOQJGJ.js.map +0 -1
  34. package/dist/dynamodb/index.cjs +0 -14
  35. package/dist/dynamodb/index.cjs.map +0 -1
  36. package/dist/dynamodb/index.d.cts +0 -1
  37. package/dist/dynamodb/index.d.ts +0 -1
  38. package/dist/dynamodb/index.js +0 -3
  39. package/dist/dynamodb/index.js.map +0 -1
  40. package/dist/gcs/index.cjs +0 -14
  41. package/dist/gcs/index.cjs.map +0 -1
  42. package/dist/gcs/index.d.cts +0 -1
  43. package/dist/gcs/index.d.ts +0 -1
  44. package/dist/gcs/index.js +0 -3
  45. package/dist/gcs/index.js.map +0 -1
  46. package/dist/index.cjs +0 -789
  47. package/dist/index.cjs.map +0 -1
  48. package/dist/index.d.cts +0 -642
  49. package/dist/mongodb/index.cjs +0 -14
  50. package/dist/mongodb/index.cjs.map +0 -1
  51. package/dist/mongodb/index.d.cts +0 -1
  52. package/dist/mongodb/index.d.ts +0 -1
  53. package/dist/mongodb/index.js +0 -3
  54. package/dist/mongodb/index.js.map +0 -1
  55. package/dist/mysql/index.cjs +0 -14
  56. package/dist/mysql/index.cjs.map +0 -1
  57. package/dist/mysql/index.d.cts +0 -1
  58. package/dist/mysql/index.d.ts +0 -1
  59. package/dist/mysql/index.js +0 -3
  60. package/dist/mysql/index.js.map +0 -1
  61. package/dist/postgres/index.cjs +0 -14
  62. package/dist/postgres/index.cjs.map +0 -1
  63. package/dist/postgres/index.d.cts +0 -1
  64. package/dist/postgres/index.d.ts +0 -1
  65. package/dist/postgres/index.js +0 -3
  66. package/dist/postgres/index.js.map +0 -1
  67. package/dist/redis/index.cjs +0 -14
  68. package/dist/redis/index.cjs.map +0 -1
  69. package/dist/redis/index.d.cts +0 -1
  70. package/dist/redis/index.d.ts +0 -1
  71. package/dist/redis/index.js +0 -3
  72. package/dist/redis/index.js.map +0 -1
  73. package/dist/s3/index.cjs +0 -14
  74. package/dist/s3/index.cjs.map +0 -1
  75. package/dist/s3/index.d.cts +0 -1
  76. package/dist/s3/index.d.ts +0 -1
  77. package/dist/s3/index.js +0 -3
  78. package/dist/s3/index.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,8 +1,49 @@
1
- import { IntegrityError, bulkLoadCrbmGeneration as bulkLoadCrbmGeneration$1, compactSegment as compactSegment$1, runCompactionCycle as runCompactionCycle$1, runExport as runExport$1, safeMetrics, NOOP_METRICS, BoundedLru, RetryingWarmDriver, RetryingColdChunkSource, ValidationError, resolveBudget, DEFAULT_BUDGET, SegmentEngine, UnsupportedError, validateSegmentRef, resolvePerOpBudget, splitId, collectWithinBudget, mapWithConcurrency, validateCompactionOptions, gcOrphanGenerations, dropSegment, runConsistencyCheck, estimateCost, groundedReport, CrbmColdChunkSource } from '@cloudbitmaps/core';
2
- export * from '@cloudbitmaps/core';
3
- import roaring from 'roaring';
4
-
5
1
  // src/index.ts
2
+ import {
3
+ BoundedLru,
4
+ PinnedStorageChunkSource,
5
+ segmentKey,
6
+ CrbmStorageChunkSource,
7
+ DEFAULT_BUDGET,
8
+ DEFAULT_RETRY_POLICY,
9
+ isStorageBackend,
10
+ NOOP_METRICS,
11
+ RetryingStorageChunkSource,
12
+ SegmentEngine,
13
+ UnsupportedError,
14
+ ValidationError,
15
+ WriteConflictError,
16
+ MIN_EXPIRES_AT_MS,
17
+ collectWithinBudget,
18
+ excludingReservedRows,
19
+ dropSegment,
20
+ estimateCost,
21
+ groundedReport,
22
+ mapWithConcurrency,
23
+ resolveBudget,
24
+ resolvePerOpBudget,
25
+ retireExpired,
26
+ runConsistencyCheck,
27
+ runExport as runExport2,
28
+ setSegmentRetention,
29
+ clearSegmentRetention,
30
+ getSegmentRetention,
31
+ safeMetrics,
32
+ splitId,
33
+ validateSegmentRef
34
+ } from "@cloudbitmaps/core";
35
+
36
+ // src/codec-bound.ts
37
+ import {
38
+ bulkLoadCrbmGeneration as coreBulkLoad,
39
+ eraseIdFromSegment as coreEraseIdFromSegment,
40
+ loadSegment as coreLoadSegment,
41
+ runExport as coreRunExport
42
+ } from "@cloudbitmaps/core";
43
+
44
+ // src/roaring-codec.ts
45
+ import roaring from "roaring";
46
+ import { IntegrityError } from "@cloudbitmaps/core";
6
47
  var { RoaringBitmap32, SerializationFormat, DeserializationFormat } = roaring;
7
48
  var SafeBitmap = class _SafeBitmap {
8
49
  bitmap;
@@ -16,7 +57,7 @@ var SafeBitmap = class _SafeBitmap {
16
57
  return new _SafeBitmap(new RoaringBitmap32(values));
17
58
  }
18
59
  /**
19
- * S1: validate size, then deserialize with the **portable** (validated) format.
60
+ * Validate size, then deserialize with the **portable** (validated) format.
20
61
  * Throws `IntegrityError` if the input exceeds `maxBytes` or fails to decode — the
21
62
  * native addon is never handed unbounded or unsafe-format input.
22
63
  */
@@ -48,8 +89,8 @@ var SafeBitmap = class _SafeBitmap {
48
89
  * a different baseline that happens to land near 63× on the run shape too. `pnpm bench:encoding` prints both
49
90
  * columns; don't carry a figure between them without checking which denominator it had.
50
91
  *
51
- * Called by the engine only when writing an immutable cold generation — see {@link CodecBitmap.optimize} for
52
- * why the per-operation warm path deliberately does not.
92
+ * Called only when writing an immutable objects generation — see {@link CodecBitmap.optimize} for why the read
93
+ * path deliberately does not.
53
94
  */
54
95
  optimize() {
55
96
  this.bitmap.runOptimize();
@@ -154,17 +195,68 @@ var SystemClock = class {
154
195
  });
155
196
  }
156
197
  };
157
- var bulkLoadCrbmGeneration = (driver, key, ids, options = {}) => bulkLoadCrbmGeneration$1(driver, key, ids, {
198
+
199
+ // src/codec-bound.ts
200
+ var bulkLoadCrbmGeneration = (driver, key, ids, options = {}) => coreBulkLoad(driver, key, ids, {
158
201
  ...options,
159
202
  codec: options.codec ?? roaringCodec,
160
203
  clock: options.clock ?? new SystemClock()
161
204
  });
162
- var compactSegment = (ref, deps, options) => compactSegment$1(ref, { ...deps, codec: deps.codec ?? roaringCodec }, options);
163
- var runCompactionCycle = (deps, options) => runCompactionCycle$1({ ...deps, codec: deps.codec ?? roaringCodec }, options);
164
- var runExport = (reader, registry, sink, options = {}) => runExport$1(reader, registry, sink, { ...options, codec: options.codec ?? roaringCodec });
205
+ var eraseIdFromSegment = (ref, id, deps, options) => coreEraseIdFromSegment(
206
+ ref,
207
+ id,
208
+ { ...deps, codec: deps.codec ?? roaringCodec, clock: deps.clock ?? new SystemClock() },
209
+ options
210
+ );
211
+ var runExport = (reader, registry, sink, options = {}) => coreRunExport(reader, registry, sink, { ...options, codec: options.codec ?? roaringCodec });
212
+ var loadSegment = (ref, ids, deps, options = {}) => coreLoadSegment(
213
+ ref,
214
+ ids,
215
+ { ...deps, codec: deps.codec ?? roaringCodec, clock: deps.clock ?? new SystemClock() },
216
+ options
217
+ );
165
218
 
166
219
  // src/index.ts
220
+ import { listGenerations, rollbackSegment } from "@cloudbitmaps/core";
221
+ import { listSegments, segmentExists } from "@cloudbitmaps/core";
222
+
223
+ // src/moved-options.ts
224
+ var MOVED_OPTIONS = [
225
+ ["cacheMaxChunks", "cache.maxChunks", "group"],
226
+ ["cacheTtlMs", "cache.ttlMs", "group"],
227
+ // The `cold*` spellings are what `0.9.x` ACTUALLY had. The `storage*` ones below never appeared in any
228
+ // release — they existed between two unreleased commits of this cycle — and for a while they were the only
229
+ // ones here, so the guard written to catch an upgrader missed every real upgrader and caught nobody.
230
+ ["coldGenTtlMs", "cache.genTtlMs", "group"],
231
+ ["coldReaderCacheMax", "cache.readerMax", "group"],
232
+ ["coldReaderCacheMaxBytes", "cache.readerMaxBytes", "group"],
233
+ ["storageGenTtlMs", "cache.genTtlMs", "group"],
234
+ ["storageReaderCacheMax", "cache.readerMax", "group"],
235
+ ["storageReaderCacheMaxBytes", "cache.readerMaxBytes", "group"],
236
+ ["keystore", "encryption.keystore", "group"],
237
+ ["requireEncryption", "encryption.required", "group"],
238
+ ["onRetry", "retry.onRetry", "group"],
239
+ ["clock", "seams.clock", "group"],
240
+ ["rng", "seams.rng", "group"],
241
+ [
242
+ "registry",
243
+ "the backend passed as `storage` (S3Storage, GcsStorage, \u2026), which carries it",
244
+ "renamed"
245
+ ],
246
+ ["cold", "storage", "renamed"],
247
+ // The live (warm) tier and its knobs. `warm` was REQUIRED in `0.9.x`, so every upgrader passes one — and
248
+ // an upgrader who fixes `cold` first meets this next. None has a successor, so none is offered.
249
+ ["warm", "the live tier is gone", "gone"],
250
+ ["warmReadConsistency", "it tuned the live tier", "gone"],
251
+ ["maxWarmScanBytes", "it tuned the live tier", "gone"],
252
+ ["writeConcurrency", "it bounded the live tier\u2019s flusher", "gone"],
253
+ ["occBackoff", "it tuned the live tier\u2019s read-modify-write", "gone"]
254
+ ];
255
+
256
+ // src/index.ts
257
+ export * from "@cloudbitmaps/core";
167
258
  var SystemRng = class {
259
+ /** A float in `[0, 1)`. Jitter only — never key material, never anything a caller can observe. */
168
260
  next() {
169
261
  return Math.random();
170
262
  }
@@ -183,74 +275,163 @@ function requireScope(options, op) {
183
275
  );
184
276
  }
185
277
  }
186
- function resolveColdSource(options, clock) {
187
- const cold = options.cold;
188
- if (cold === null || typeof cold !== "object") {
189
- throw new ValidationError("`cold` must be an IColdDriver or a ColdChunkSource");
278
+ function resolveStorageSource(options, clock) {
279
+ const storage = options.storage;
280
+ if (storage === null || typeof storage !== "object") {
281
+ throw new ValidationError(
282
+ "`storage` must be a StorageBackend, an IStorageDriver, or a StorageChunkSource"
283
+ );
284
+ }
285
+ const asBackend = storage;
286
+ const isObject = (v) => v !== null && typeof v === "object";
287
+ const hasGetChunk = typeof storage.getChunk === "function";
288
+ const hasPutImmutable = typeof storage.putImmutable === "function";
289
+ const isBackend = isStorageBackend(storage);
290
+ if (isBackend && (hasGetChunk || hasPutImmutable)) {
291
+ throw new ValidationError(
292
+ `\`storage\` is a StorageBackend that also exposes ${hasGetChunk ? "`getChunk`" : "`putImmutable`"} \u2014 ambiguous. A backend must not also be a driver or a source; pass whichever one you mean.`
293
+ );
294
+ }
295
+ const hasStorageHalf = isObject(asBackend.storage);
296
+ const hasRegistryHalf = isObject(asBackend.registry);
297
+ if (!isBackend && hasPutImmutable && hasRegistryHalf) {
298
+ throw new ValidationError(
299
+ "`storage` looks like a driver that also carries a `registry`. Passed as a bare driver it would have no pointer at all \u2014 generations would resolve by list-scan, so reads could serve a generation that was written but never published. If you meant a backend, say so: `createBackend({ storage: <your driver>, registry })`."
300
+ );
301
+ }
302
+ if (!isBackend && !hasGetChunk && !hasPutImmutable && hasStorageHalf && hasRegistryHalf) {
303
+ const storageOk = typeof asBackend.storage.putImmutable === "function";
304
+ const registryOk = typeof asBackend.registry.compareAndSwap === "function";
305
+ if (!storageOk || !registryOk) {
306
+ const bad = !storageOk ? "`storage` half is not an IStorageDriver (no `putImmutable`)" : "`registry` half is not an IRegistryDriver (no `compareAndSwap`)";
307
+ throw new ValidationError(
308
+ "`storage` looks like a backend but its " + bad + " \u2014 build one with a backend class, or with `createBackend({ storage, registry })`."
309
+ );
310
+ }
311
+ throw new ValidationError(
312
+ "`storage` must be a backend \u2014 S3Storage, GcsStorage, AzureBlobStorage, LocalFsStorage or MemoryStorage. An object with `.storage` and `.registry` is not one: a backend builds both halves from a single bucket and prefix, so they cannot disagree, and hand-assembling them re-opens exactly that mismatch \u2014 a store whose pointer and generations live in different places reads as empty rather than failing. If you genuinely want halves of your own \u2014 an instrumented driver, a registry in a database you already run \u2014 say so with `createBackend({ storage, registry })`, which is you taking on that they agree."
313
+ );
314
+ }
315
+ if (isBackend) {
316
+ const backend = storage;
317
+ return {
318
+ source: new CrbmStorageChunkSource(backend.storage, {
319
+ registry: backend.registry,
320
+ keystore: options.encryption?.keystore,
321
+ requireEncryption: options.encryption?.required,
322
+ clock,
323
+ currentGenTtlMs: options.cache?.genTtlMs,
324
+ maxOpenSegments: options.cache?.readerMax,
325
+ maxOpenIndexBytes: options.cache?.readerMaxBytes
326
+ }),
327
+ driver: backend.storage,
328
+ registry: backend.registry
329
+ };
190
330
  }
191
- const hasGetChunk = typeof cold.getChunk === "function";
192
- const hasPutImmutable = typeof cold.putImmutable === "function";
193
331
  if (hasGetChunk && hasPutImmutable) {
194
332
  throw new ValidationError(
195
- "`cold` exposes both `getChunk` and `putImmutable` \u2014 ambiguous; pass an IColdDriver or a ColdChunkSource, not a hybrid"
333
+ "`storage` exposes both `getChunk` and `putImmutable` \u2014 ambiguous; pass a StorageBackend, an IStorageDriver, or a StorageChunkSource, not a hybrid"
196
334
  );
197
335
  }
198
336
  if (!hasGetChunk && !hasPutImmutable) {
199
- throw new ValidationError("`cold` must be an IColdDriver or a ColdChunkSource");
337
+ throw new ValidationError(
338
+ "`storage` must be a StorageBackend, an IStorageDriver, or a StorageChunkSource"
339
+ );
200
340
  }
201
341
  if (hasGetChunk) {
202
- if (options.registry !== void 0 || options.keystore !== void 0 || options.requireEncryption === true) {
342
+ if (options.encryption?.keystore !== void 0 || options.encryption?.required === true) {
203
343
  throw new ValidationError(
204
- "registry/keystore/requireEncryption apply only when `cold` is a raw IColdDriver; configure them on the ColdChunkSource you passed instead"
344
+ "keystore/requireEncryption apply only when the store builds its own read path; configure them on the StorageChunkSource you passed instead"
205
345
  );
206
346
  }
207
- return { source: cold, driver: void 0 };
347
+ return { source: storage, driver: void 0, registry: void 0 };
208
348
  }
209
- const driver = cold;
349
+ const driver = storage;
210
350
  return {
211
- source: new CrbmColdChunkSource(driver, {
212
- registry: options.registry,
213
- keystore: options.keystore,
214
- requireEncryption: options.requireEncryption,
351
+ source: new CrbmStorageChunkSource(driver, {
352
+ keystore: options.encryption?.keystore,
353
+ requireEncryption: options.encryption?.required,
215
354
  clock,
216
- currentGenTtlMs: options.coldGenTtlMs,
217
- maxOpenSegments: options.coldReaderCacheMax,
218
- maxOpenIndexBytes: options.coldReaderCacheMaxBytes
355
+ currentGenTtlMs: options.cache?.genTtlMs,
356
+ maxOpenSegments: options.cache?.readerMax,
357
+ maxOpenIndexBytes: options.cache?.readerMaxBytes
219
358
  }),
220
- driver
359
+ driver,
360
+ registry: void 0
221
361
  };
222
362
  }
223
- var CloudRoaring = class {
363
+ var CloudRoaring = class _CloudRoaring {
224
364
  engine;
365
+ cache;
366
+ crbmSource;
225
367
  clock;
226
368
  metrics;
227
- // The store's own drivers, kept so the in-process lifecycle helpers (`compact`/`eraseSubject`/`subjectReport`)
228
- // reuse them instead of making you re-pass a CompactionDeps. `coldDriver` is set only when `cold` was a raw
229
- // IColdDriver (a pre-built ColdChunkSource has no underlying driver to compact through).
230
- coldDriver;
231
- warmDriver;
369
+ // The store's own drivers, kept so the lifecycle helpers and the `*Into` verbs reuse them instead of making
370
+ // you re-pass deps. `storageDriver` is set only when `storage` was a raw IStorageDriver (a pre-built StorageChunkSource has
371
+ // no underlying driver to write through).
372
+ storageDriver;
232
373
  registry;
233
374
  keystore;
234
375
  requireEncryption;
235
376
  /** Resolved store-level per-op budget (null = disabled); the admin scans use it, with a per-op override. */
236
377
  budget;
378
+ /**
379
+ * Refuse an option that moved into a group, naming where it went.
380
+ *
381
+ * Silently ignoring one would be the exact failure this release exists to remove — see {@link MOVED_OPTIONS}
382
+ * for why each of these is unsafe to drop rather than merely untidy.
383
+ */
384
+ static rejectMovedOptions(options) {
385
+ if (options === null || options === void 0 || typeof options !== "object") {
386
+ throw new ValidationError(
387
+ "CloudRoaring needs an options object with a `storage` key \u2014 got " + (options === null ? "null" : typeof options)
388
+ );
389
+ }
390
+ const bag = options;
391
+ const moved = MOVED_OPTIONS.filter(([from]) => bag[from] !== void 0);
392
+ if (moved.length === 0) return;
393
+ const clause = (kind, one, many) => {
394
+ const hits = moved.filter(([, , k]) => k === kind);
395
+ if (hits.length === 0) return null;
396
+ const body = hits.map(
397
+ ([from, to]) => kind === "gone" ? `\`${from}\` (${to})` : `\`${from}\` \u2192 ${/^[\w.]+$/.test(to) ? `\`${to}\`` : to}`
398
+ ).join(" \xB7 ");
399
+ return `${hits.length > 1 ? many : one}: ${body}`;
400
+ };
401
+ const parts = [
402
+ clause("group", "option moved into a group", "options moved into groups"),
403
+ clause("renamed", "option renamed", "options renamed"),
404
+ clause("gone", "option removed", "options removed")
405
+ ].filter((c) => c !== null);
406
+ throw new ValidationError(
407
+ `CloudRoaring ${parts.join("; ")}. Options are now one required \`storage\` plus four optional groups \u2014 \`cache\`, \`encryption\`, \`retry\` and \`seams\`. \`metrics\` and \`budget\` are unchanged flat options; leave them as they are. Full guide: https://github.com/cloudbitmaps/cloudbitmaps/blob/main/MIGRATING.md`
408
+ );
409
+ }
237
410
  constructor(options) {
238
- const clock = options.clock ?? new SystemClock();
239
- const rng = options.rng ?? new SystemRng();
411
+ _CloudRoaring.rejectMovedOptions(options);
412
+ const clock = options.seams?.clock ?? new SystemClock();
413
+ const rng = options.seams?.rng ?? new SystemRng();
240
414
  const metrics = safeMetrics(options.metrics ?? NOOP_METRICS);
241
415
  const cache = new BoundedLru({
242
- maxEntries: options.cacheMaxChunks ?? DEFAULT_CACHE_MAX_CHUNKS,
243
- ttlMs: options.cacheTtlMs,
416
+ maxEntries: options.cache?.maxChunks ?? DEFAULT_CACHE_MAX_CHUNKS,
417
+ ttlMs: options.cache?.ttlMs,
244
418
  clock
245
419
  });
246
- const resolved = resolveColdSource(options, clock);
247
- let cold = resolved.source;
248
- let warm = options.warm;
420
+ const resolved = resolveStorageSource(options, clock);
421
+ let storage = resolved.source;
249
422
  if (options.retry !== false) {
423
+ const { onRetry: userOnRetry, ...ov } = options.retry ?? {};
424
+ const policy = {
425
+ maxAttempts: ov.maxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts,
426
+ baseDelayMs: ov.baseDelayMs ?? DEFAULT_RETRY_POLICY.baseDelayMs,
427
+ maxDelayMs: ov.maxDelayMs ?? DEFAULT_RETRY_POLICY.maxDelayMs,
428
+ backoffFactor: ov.backoffFactor ?? DEFAULT_RETRY_POLICY.backoffFactor,
429
+ jitter: ov.jitter ?? DEFAULT_RETRY_POLICY.jitter
430
+ };
250
431
  const retryOpts = {
251
432
  clock,
252
433
  rng,
253
- policy: options.retry,
434
+ policy,
254
435
  // Bridge transient-fault retries into the metrics stream, then call the user's own hook.
255
436
  onRetry: (info) => {
256
437
  metrics.onEvent({
@@ -259,100 +440,171 @@ var CloudRoaring = class {
259
440
  attempt: info.attempt,
260
441
  delayMs: info.delayMs
261
442
  });
262
- options.onRetry?.(info);
443
+ userOnRetry?.(info);
263
444
  }
264
445
  };
265
- warm = new RetryingWarmDriver(options.warm, retryOpts);
266
- cold = new RetryingColdChunkSource(cold, retryOpts);
267
- }
268
- if (options.writeConcurrency !== void 0 && (!Number.isInteger(options.writeConcurrency) || options.writeConcurrency < 1)) {
269
- throw new ValidationError(
270
- `writeConcurrency must be a positive integer; got ${options.writeConcurrency}`
271
- );
446
+ storage = new RetryingStorageChunkSource(storage, retryOpts);
272
447
  }
273
448
  this.budget = resolveBudget(options.budget, DEFAULT_BUDGET);
274
449
  const deps = {
275
- warm,
276
- cold,
450
+ storage,
277
451
  cache,
278
452
  codec: roaringCodec,
279
453
  // the facade injects the flagship codec; core stays codec-agnostic
280
454
  clock,
281
- rng,
282
- occBackoff: options.occBackoff,
283
455
  metrics,
284
- warmReadConsistency: options.warmReadConsistency,
285
- writeConcurrency: options.writeConcurrency,
286
- maxWarmScanBytes: options.maxWarmScanBytes,
287
456
  budget: this.budget
288
457
  };
289
458
  this.engine = new SegmentEngine(deps);
459
+ this.cache = cache;
460
+ this.crbmSource = resolved.source instanceof CrbmStorageChunkSource ? resolved.source : void 0;
290
461
  this.clock = clock;
291
462
  this.metrics = metrics;
292
- this.coldDriver = resolved.driver;
293
- this.warmDriver = options.warm;
294
- this.registry = options.registry;
295
- this.keystore = options.keystore;
296
- this.requireEncryption = options.requireEncryption ?? false;
463
+ this.storageDriver = resolved.driver;
464
+ this.registry = resolved.registry;
465
+ this.keystore = options.encryption?.keystore;
466
+ this.requireEncryption = options.encryption?.required ?? false;
297
467
  }
298
468
  /**
299
- * Build {@link CompactionDeps} from the store's own drivers, for the in-process lifecycle helpers
300
- * ({@link compact} / {@link eraseSubject}). Requires the store to have been constructed with a **raw cold
301
- * driver** (a pre-built `ColdChunkSource` has no underlying `IColdDriver` to compact through) and a `registry`
302
- * (the authoritative `currentGen` pointer compaction swaps). Out-of-process callers use the `compactSegment`
303
- * free function with explicit deps.
469
+ * The write-side deps, from the store's own drivers, for the lifecycle helpers and the `*Into` verbs. Requires
470
+ * the store to have been constructed with a **backend**, which supplies both halves: an `IStorageDriver` to
471
+ * write generations through (a pre-built `StorageChunkSource` has none) and the registry holding the pointer
472
+ * every write publishes.
473
+ * Out-of-process callers use the free functions with explicit deps.
304
474
  */
305
- compactionDeps() {
306
- if (this.coldDriver === void 0) {
475
+ lifecycleDeps(op) {
476
+ if (this.storageDriver === void 0) {
307
477
  throw new UnsupportedError(
308
- "compact/dropSegment/eraseSubject/checkConsistency need the store built with a raw cold driver (IColdDriver), not a pre-built ColdChunkSource \u2014 or call the compactSegment/runConsistencyCheck free functions with explicit deps"
478
+ `${op} needs the store built with a storage backend \u2014 S3Storage, GcsStorage, AzureBlobStorage, LocalFsStorage or MemoryStorage. A pre-built StorageChunkSource is read-only: it has no IStorageDriver underneath to write generations through. Out of process, call the equivalent free function with explicit deps instead.`
309
479
  );
310
480
  }
311
481
  if (this.registry === void 0) {
312
482
  throw new UnsupportedError(
313
- "compact/eraseSubject/checkConsistency need a `registry` in the store config"
483
+ `${op} needs a storage backend \u2014 S3Storage, GcsStorage, AzureBlobStorage, LocalFsStorage or MemoryStorage. A bare IStorageDriver has no generation pointer to publish through, and there is no longer a separate \`registry\` option to add.`
314
484
  );
315
485
  }
316
486
  return {
317
- cold: this.coldDriver,
318
- warm: this.warmDriver,
487
+ storage: this.storageDriver,
319
488
  registry: this.registry,
320
489
  clock: this.clock,
321
490
  codec: roaringCodec,
322
491
  // facade injects the flagship codec
323
492
  keystore: this.keystore,
324
- requireEncryption: this.requireEncryption,
325
- metrics: this.metrics
326
- // safe-wrapped sink → store.compact/eraseSubject emit `compaction` events (gap #2)
493
+ requireEncryption: this.requireEncryption
327
494
  };
328
495
  }
329
- /** Get a handle to a segment. Validates the name/namespace grammar (finding S2). */
496
+ /** Get a handle to a segment. Validates the name (non-empty, well-formed, within the encoded-length cap). */
330
497
  segment(name, options) {
331
498
  const ref = { segment: name, namespace: options?.namespace };
332
499
  validateSegmentRef(ref);
333
- return new Segment(this.engine, ref, this.clock, this.metrics);
500
+ const expiresAt = options?.expiresAt;
501
+ if (expiresAt !== void 0) {
502
+ if (!Number.isFinite(expiresAt) || !Number.isInteger(expiresAt) || expiresAt < MIN_EXPIRES_AT_MS) {
503
+ throw new ValidationError(
504
+ `segment: expiresAt must be an integer epoch-MILLISECONDS >= ${MIN_EXPIRES_AT_MS}; got ${expiresAt} (a value in seconds lands in 1970 and would make the segment read as permanently empty)`
505
+ );
506
+ }
507
+ }
508
+ return new Segment(
509
+ this.engine,
510
+ ref,
511
+ this.clock,
512
+ this.metrics,
513
+ (dest, ids, op, options2) => this.materialize(dest, ids, op, options2),
514
+ (r, e) => this.pinSegment(r, e),
515
+ (handles) => this.engineForCombine(handles),
516
+ expiresAt
517
+ );
518
+ }
519
+ /**
520
+ * Write `ids` as a **new generation of `dest`** and publish it forward-only — the shared body of the `*Into`
521
+ * verbs. The destination's previous generation stays readable until the publish lands (readers re-resolve
522
+ * within `cache.genTtlMs`).
523
+ *
524
+ * This routes through `loadSegment` rather than writing the generation itself, and that is the whole point
525
+ * of it. A materialisation is a load whose ids happen to come from a combine instead of from upstream, so
526
+ * everything `load()` learned the hard way applies unchanged: the generation is written UNPUBLISHED, the
527
+ * guard runs while the old generation is still authoritative, the publish is fenced (on the pointer it
528
+ * judged, on the row's identity, and — where it judged an ABSENT segment — on that absence), and a refused
529
+ * object is reclaimed only after re-reading the row and finding the same incarnation (hard invariant 1:
530
+ * deleting it after a purge-and-recreate would put a live row over a missing generation).
531
+ *
532
+ * That last check narrows the window rather than closing it: the row read and the delete are two round
533
+ * trips, and `IStorageDriver` has no conditional delete to make them one. `gcOrphanGenerations` carries
534
+ * the same residual and says so. The failure it leaves is an orphan object, which costs storage until
535
+ * something collects it — deliberately the cheaper side of the trade.
536
+ *
537
+ * Materialising used to do none of that. It wrote and published in one step, so an empty combine — a typo'd
538
+ * operand, an `exclude` that swallowed everything, an operand that had not loaded yet — silently replaced
539
+ * `dest` with an empty generation. That is the same failure `load()`'s guard exists to prevent, on the same
540
+ * data, and it was reachable without passing any option at all.
541
+ *
542
+ * **A lost race still throws.** `loadSegment` reports one as `reason: 'superseded'`; the `*Into` verbs have
543
+ * always thrown {@link WriteConflictError} for it, and a caller who wrote `catch (WriteConflictError)` must
544
+ * keep working. So that one refusal is translated back into the throw, and `MaterializeResult.reason` never
545
+ * carries it.
546
+ *
547
+ * **A `WriteConflictError` does not by itself mean nothing was published**, and that is worth knowing
548
+ * before you write the retry. `'superseded'` covers four different causes — the write-once PUT collided,
549
+ * the pointer moved, the row's token changed, the row was purged — and only the first two are the
550
+ * "somebody beat us" the name suggests. A token can also change on a write that is not a supersession at
551
+ * all, such as a `setRetention` on the destination. On top of that, the collection pass that runs AFTER a
552
+ * successful publish can raise the same error. So: treat it as "re-read the destination and decide",
553
+ * never as "the write did not happen".
554
+ *
555
+ * **And it still collects nothing**, unlike `load()`. See the `keep` default below.
556
+ */
557
+ async materialize(dest, ids, op, options) {
558
+ const deps = this.lifecycleDeps(op);
559
+ const result = await loadSegment(dest, ids, deps, {
560
+ ...options?.allowEmpty === void 0 ? {} : { allowEmpty: options.allowEmpty },
561
+ ...options?.guard === void 0 ? {} : { guard: options.guard },
562
+ // COLLECT NOTHING by default, which `loadSegment` does not — it keeps a grace window of 1 and deletes
563
+ // the rest. A materialisation has never collected: the guide states "**It deletes nothing.** The
564
+ // destination's previous generation stays in the bucket until you collect it", and the ownership table
565
+ // puts that call on the operator. Inheriting `load()`'s collection would have silently deleted the
566
+ // generations an operator's recovery story depends on — `rollbackSegment` refuses a collected target —
567
+ // as a side effect of adding a guard whose entire purpose is preventing data loss. Opt in with `keep`.
568
+ keep: options?.keep ?? KEEP_EVERY_GENERATION,
569
+ ...options?.audit === void 0 ? {} : { audit: options.audit }
570
+ });
571
+ const reason = result.reason;
572
+ if (reason === "superseded") {
573
+ const wrote = result.size > 0 ? `generation ${result.generation} was written and did not become current` : `nothing was written \u2014 another writer took generation ${result.generation} first`;
574
+ throw new WriteConflictError(
575
+ `${op}: the destination "${dest.segment}" changed while this materialisation was in flight, so it never became current: ${wrote}. The pointer may have moved, the row may have been rewritten (a retention policy does this) or purged. Re-read the destination and re-run.`
576
+ );
577
+ }
578
+ return {
579
+ generation: result.generation,
580
+ published: result.published,
581
+ ...reason === void 0 ? {} : { reason },
582
+ cardinality: result.cardinality,
583
+ cardinalityBefore: result.cardinalityBefore,
584
+ chunkCount: result.chunkCount,
585
+ size: result.size,
586
+ collected: result.collected
587
+ };
334
588
  }
335
589
  /**
336
- * **Subject access (GDPR Art. 15 / CCPA right-to-know): which segments is this id a member of?** (Phase 6b.)
590
+ * **Subject access (GDPR Art. 15 / CCPA right-to-know): which segments is this id a member of?**
337
591
  *
338
- * Enumerates the **registered** segments (via the store's own `registry`) and does a tier-merging `has(id)` on
339
- * each — no drivers to re-pass. Complete only over registered segments (register your segments if you claim
340
- * SAR support). There is deliberately **no `id → segments` reverse index** — that would tax every write for a
341
- * rare request; this admin scan is `O(registered segments)` and touches no hot path.
342
- * Requires a `registry` in the store config
343
- * (throws {@link UnsupportedError} otherwise).
592
+ * Enumerates the **registered** segments (via the store's own `registry`) and does a `has(id)` on each — no
593
+ * drivers to re-pass. Complete only over registered segments (every loaded segment has a row, so register the
594
+ * registry the loads used). There is deliberately **no `id → segments` reverse index** — that would tax every
595
+ * load for a rare request; this admin scan is `O(registered segments)` and touches no hot path. Requires a
596
+ * `registry` in the store config (throws {@link UnsupportedError} otherwise).
344
597
  */
345
598
  async subjectReport(id, options = {}) {
346
- if (this.registry === void 0) {
347
- throw new UnsupportedError("subjectReport needs a `registry` in the store config");
348
- }
349
- const registry = this.registry;
599
+ const registry = this.requireRegistry("subjectReport");
350
600
  requireScope(options, "subjectReport");
351
601
  validateConcurrency(options.concurrency);
352
602
  const budget = resolvePerOpBudget(options.budget, this.budget);
353
603
  splitId(id);
354
604
  const recs = await collectWithinBudget(
355
- registry.list(options.namespace),
605
+ // A subject cannot be in a coordination row, and charging this request's budget for them would refuse a
606
+ // GDPR Art. 15 report for a reason unrelated to the subject.
607
+ excludingReservedRows(registry.list(options.namespace)),
356
608
  budget,
357
609
  "subjectReport"
358
610
  );
@@ -362,56 +614,67 @@ var CloudRoaring = class {
362
614
  async (rec) => {
363
615
  if (rec.status === "destroyed") return null;
364
616
  const ref = { segment: rec.segment, namespace: rec.namespace };
365
- return await this.engine.has(ref, id, { consistent: true }) ? { segment: rec.segment, namespace: rec.namespace } : null;
617
+ return await this.engine.has(ref, id) ? { segment: rec.segment, namespace: rec.namespace } : null;
366
618
  }
367
619
  );
368
620
  const segments = membership.filter((m) => m !== null);
369
621
  return { id, segments, scannedSegments: recs.length };
370
622
  }
371
623
  /**
372
- * **Subject erasure (GDPR Art. 17): remove an id from every segment it's in, with a physical-deletion
373
- * guarantee.** (Phase 6b.)
374
- *
375
- * For each **registered** segment the id is a member of: writes a logical `remove` tombstone (immediate) and
376
- * then **force-compacts that segment now** — folding the tombstone into a fresh immutable generation so the
377
- * bit is *physically* gone from Cold on return, even for an otherwise-idle/archival segment that organic
378
- * compaction would never touch (the P13 fix). The returned per-segment record is your **erasure ledger** —
379
- * persist it / route it to your audit sink as the proof of deletion; the physical-purge proof is
380
- * compaction's own VERIFY step (a re-`has()` here would be unsound — a pinned cold source can still read the
381
- * old generation; take a fresh generation view after erasing, exactly as with the compaction daemon).
382
- *
383
- * Uses the store's **own** drivers (raw cold + warm + registry), so the old integrator obligation to wire a
384
- * matching `CompactionDeps` is gone — the `remove()` and the force-compaction provably run over the same tiers.
385
- * Requires the store built with a **raw cold driver + registry** (throws {@link UnsupportedError} otherwise;
386
- * a pre-built `ColdChunkSource` store has no `IColdDriver` to compact through — use the `compactSegment` free
387
- * function there). **One contract remains** (an integrator obligation the library cannot check): **do not
388
- * concurrently re-add the same id while erasing it.** A normal `add(id)` landing between the `remove` and the
389
- * compaction clears the tombstone and folds the id back into the new generation
390
- * (`effective = (cold ∪ adds) \ removes`); `physicallyPurged` attests only that compaction committed a
391
- * generation with the tombstones applied at pin time — quiesce writes for the subject during erasure.
392
- *
393
- * **A `physicallyPurged:false` entry means the logical removal holds but the physical purge didn't run this
394
- * call** — either a daemon held a live lease (`note:'leased-by-other'`; the daemon finishes it), or the
395
- * force-compaction hit an isolated fault (`note:'error: …'`; per-segment faults are caught so one segment
396
- * can't discard the whole ledger). `physicallyPurged:false` is **conservative** (a concurrent daemon may
397
- * already have purged the bit — `'clean'`). **Recovery:** follow up any `physicallyPurged:false` entry with
398
- * `store.compact(ref)` (or let the daemon do it) — **re-running `eraseSubject` will NOT re-purge it**, because
399
- * `has()` now reads false (the tombstone) so the segment is skipped as a non-member. The physical-purge proof
400
- * is compaction's own VERIFY step (a re-`has()` here would be unsound: a pinned cold source can still read the
401
- * old generation — take a fresh generation view after erasing, as with the daemon). Admin-only path;
624
+ * **Subject erasure (GDPR Art. 17): remove an id from every segment it's in, with the bit physically gone
625
+ * from the bucket on return.**
626
+ *
627
+ * For each **registered** segment the id is a member of, `eraseIdFromSegment` rewrites the current generation
628
+ * without the id — every chunk streamed through, one bit cleared — publishes the rewrite **fenced on the
629
+ * generation it was derived from**, and
630
+ * collects the generation that held the bit. The returned per-segment record is your **erasure ledger** —
631
+ * persist it / route it to your audit sink as the proof of deletion (a `segment.rewrite` audit event is also
632
+ * emitted per rewrite when you pass `audit`).
633
+ *
634
+ * Uses the backend's **own** two halves, so the membership check and the rewrite provably run
635
+ * over the same generation. Requires the store built with a **backend** (throws
636
+ * {@link UnsupportedError} otherwise; a pre-built `StorageChunkSource` store has no `IStorageDriver` to write
637
+ * through — use the `eraseIdFromSegment` free function there).
638
+ *
639
+ * **One contract remains** (an integrator obligation the library cannot check): **do not load the segment
640
+ * while erasing from it.** A load that lands after the rewrite carries whatever its source held, and the
641
+ * library cannot know that source was meant to exclude the id. Quiesce loads of the affected segments for the
642
+ * duration, or fix the source first and load after. A writer that lands *during* the rewrite is caught: the
643
+ * rewrite's publish is refused **by the fence** — `publishGeneration`'s `expectFrom`, which lands the CAS only
644
+ * while the pointer is still on the generation the rewrite streamed — and the entry says `note: 'superseded'`,
645
+ * so re-run. Forward-only alone would NOT refuse it: `nextGeneration` numbers above everything in the bucket,
646
+ * so the rewrite would out-rank the newer generation and then collect it.
647
+ *
648
+ * A racing **erasure** is caught before that, and reported the same way. It collects with `keep: 0`, taking
649
+ * every generation below its new pointer — the one this rewrite is streaming, and the object this rewrite
650
+ * just wrote — so the loser can find its own inputs deleted mid-flight. That surfaces as a reason rather than
651
+ * an error, read off the row: a moved pointer is `'superseded'`, a concurrent `dropSegment` `'destroyed'`,
652
+ * a retention sweep that purged the row `'absent'`. Re-running is the fix in every case.
653
+ *
654
+ * **Read `note` on any `erased: false` entry — the two reasons mean different things.** `'superseded'` means
655
+ * another writer (a load, or another erasure) moved the pointer mid-rewrite, so **this call** did not erase
656
+ * the id. Re-run: it erases the id if it is still there, and lists nothing for the segment if a racing
657
+ * erasure of the same id already removed it. Do not read `'superseded'` as "the id is still present" —
658
+ * read it as "not done by this call, and the re-run settles it".
659
+ * `` `error: …` `` is a per-segment fault (caught so one segment can't discard the whole ledger) and it can land
660
+ * on either side of the publish: if the rewrite had not published, the id is still there and a re-run erases
661
+ * it; if the publish succeeded and only the **collection** of the old generation failed, the id is already
662
+ * absent from every read and what remains is an object in the bucket that still contains the bit. A re-run
663
+ * then reports nothing for that segment (the id is not in the current generation), so **that residual is
664
+ * collected by `gcOrphanGenerations(ref, deps, { keep: 0 })` or the next retention sweep**, not by another
665
+ * `eraseSubject`. Re-running is otherwise safe and idempotent: a segment the id is no longer in is not listed. Admin-only path;
402
666
  * `O(registered segments)`, no hot-path cost. Per-subject crypto-shred is infeasible (a subject's bit is
403
667
  * co-mingled in a shared container), so this is the single-subject erasure route; whole-segment/tenant erasure
404
- * is the `destroySegment`/`eraseNamespace` free functions.
668
+ * is `dropSegment` / the `destroySegment`/`eraseNamespace` free functions.
405
669
  */
406
- async eraseSubject(id, options) {
407
- const compaction = this.compactionDeps();
408
- validateCompactionOptions({ owner: options.owner });
670
+ async eraseSubject(id, options = {}) {
671
+ const deps = this.lifecycleDeps("eraseSubject");
409
672
  requireScope(options, "eraseSubject");
410
673
  validateConcurrency(options.concurrency);
411
674
  const budget = resolvePerOpBudget(options.budget, this.budget);
412
675
  splitId(id);
413
676
  const recs = await collectWithinBudget(
414
- compaction.registry.list(options.namespace),
677
+ excludingReservedRows(deps.registry.list(options.namespace)),
415
678
  budget,
416
679
  "eraseSubject"
417
680
  );
@@ -421,31 +684,31 @@ var CloudRoaring = class {
421
684
  async (rec) => {
422
685
  if (rec.status === "destroyed") return null;
423
686
  const ref = { segment: rec.segment, namespace: rec.namespace };
424
- let removed = false;
425
687
  try {
426
- if (!await this.engine.has(ref, id, { consistent: true })) return null;
427
- await this.engine.remove(ref, id);
428
- removed = true;
429
- const result = await compactSegment$1(ref, compaction, {
430
- owner: options.owner,
431
- audit: options.audit
432
- });
688
+ const result = await eraseIdFromSegment(ref, id, deps, { audit: options.audit });
689
+ if (result.reason === "not-member" || result.reason === "absent") return null;
690
+ if (result.reason === "no-generation") return null;
691
+ if (result.reason === "destroyed") return null;
433
692
  return {
434
693
  segment: rec.segment,
435
694
  namespace: rec.namespace,
436
- removed: true,
437
- physicallyPurged: result.compacted,
438
- toGen: result.toGen,
439
- note: result.compacted ? void 0 : result.reason
695
+ erased: result.erased,
696
+ fromGeneration: result.fromGeneration,
697
+ generation: result.generation,
698
+ note: result.erased ? void 0 : result.reason
440
699
  };
441
700
  } catch (err) {
442
701
  return {
443
702
  segment: rec.segment,
444
703
  namespace: rec.namespace,
445
- removed,
446
- physicallyPurged: false,
704
+ erased: false,
447
705
  note: `error: ${err instanceof Error ? err.message : String(err)}`
448
706
  };
707
+ } finally {
708
+ try {
709
+ this.engine.invalidate(ref);
710
+ } catch {
711
+ }
449
712
  }
450
713
  }
451
714
  );
@@ -453,30 +716,164 @@ var CloudRoaring = class {
453
716
  return { id, erasedFrom, scannedSegments: recs.length };
454
717
  }
455
718
  /**
456
- * **Compact one segment now**, in-process, using the store's own drivers — a convenience over the
457
- * {@link compactSegment} free function. Merges `(cold ∪ adds) \ removes` into a fresh immutable generation,
458
- * swaps `currentGen`, then version-fenced-purges the archived Warm rows. A no-op (`compacted:false`) when the
459
- * segment has no dirty rows or a daemon holds a live lease — never throws on the normal contention/lease paths.
460
- * Requires the store built with a **raw cold driver + registry** (throws {@link UnsupportedError} otherwise);
461
- * like the daemon, it uses the raw drivers directly (a transient fault surfaces to you rather than retrying
462
- * under the hood). **Not for the request/hot path** — it reads and rewrites a whole Cold generation (streaming,
463
- * constant-memory, but full-segment I/O). Use the `compact-segments` daemon (`runCompactionCycle`) for routine
464
- * background compaction; reach for `store.compact()` for occasional/manual one-shots — a maintenance endpoint,
465
- * a small daemon-less deployment, or right after a targeted `remove`.
719
+ * Every generation still in the bucket for this segment, ascending, with the current one marked.
720
+ *
721
+ * What the bucket holds, not what the segment has ever been — collection deletes superseded objects, so this is
722
+ * the grace window plus whatever has not been collected yet. It is the set {@link CloudRoaring.rollback} can
723
+ * choose from, which is the reason to look at it. One `list` call; it does not open the objects.
724
+ *
725
+ * Needs a backend.
466
726
  */
467
- async compact(ref, options) {
727
+ async generations(ref) {
468
728
  validateSegmentRef(ref);
469
- const deps = this.compactionDeps();
470
- const result = await compactSegment$1(ref, deps, options);
471
- if (result.compacted) {
472
- await gcOrphanGenerations(ref, { cold: deps.cold, registry: deps.registry }).catch(
473
- () => void 0
474
- );
729
+ return listGenerations(ref, this.lifecycleDeps("generations"));
730
+ }
731
+ /**
732
+ * Whether a read of this segment would find anything — the "do I already have this?" question, as one
733
+ * registry point read.
734
+ *
735
+ * There is no `create` in this library: {@link CloudRoaring.segment} is a validated address and does no I/O,
736
+ * so naming a segment can never collide with an existing one. A segment starts existing when something is
737
+ * first loaded into it, and this is how you ask whether that has happened.
738
+ *
739
+ * **Not the same as `count() > 0`.** A segment loaded with no ids exists and counts zero. Distinguishing
740
+ * "never loaded" from "loaded, and genuinely empty" is the thing `count()` cannot do and the reason this
741
+ * exists. It is `false` for a segment whose row was minted ahead of its first load (by `setRetention`) and
742
+ * for a `destroyed` tombstone, because a read answers empty in both cases.
743
+ *
744
+ * Two states answer `true` where a read still gives you nothing: a torn restore (a live pointer whose object
745
+ * was deleted) makes reads *throw* rather than answer empty — `checkConsistency` is the call for that — and
746
+ * a handle carrying an expired `expiresAt` reads empty by a rule that lives on the handle, not the row.
747
+ *
748
+ * Not a lock: the answer can change the moment it returns. If it has to hold, use the fence built for that —
749
+ * `load`'s `guard`, or `expectFrom`/`expectToken` on a publish. Needs a `registry`.
750
+ *
751
+ * ```ts
752
+ * if (!(await store.exists({ segment: 'users' }))) {
753
+ * await store.load({ segment: 'users' }, idsFromUpstream);
754
+ * }
755
+ * ```
756
+ */
757
+ async exists(ref) {
758
+ validateSegmentRef(ref);
759
+ return segmentExists(ref, this.requireRegistry("exists"));
760
+ }
761
+ /**
762
+ * Every segment the registry knows about, streamed — optionally scoped to one namespace.
763
+ *
764
+ * The registry is already the list of your segments, which is why you should not keep a second one beside it:
765
+ * a hand-maintained list is a source of truth that drifts from this one the first time a load fails halfway.
766
+ *
767
+ * **An admin/discovery call, not a request-path one.** This is the registry's own enumeration — a paged
768
+ * LIST over the `registry/` prefix — so its cost grows with the size of the fleet rather than with what you
769
+ * are looking for.
770
+ *
771
+ * Scoping to a namespace narrows the LIST prefix, so it really is the difference between reading one tenant
772
+ * and reading all of them.
773
+ *
774
+ * It streams, and stopping the iteration stops the scan — except behind a driver that buffers its
775
+ * enumeration to retry it as a unit, which `RetryingRegistryDriver` does: wrapped in that, the whole scan is
776
+ * paid for and resident before the first row arrives.
777
+ *
778
+ * Yields `destroyed` tombstones and rows with `currentGen: null`, because a filtered enumeration that looks
779
+ * complete is worse than an honest one — filter on `status`/`currentGen` yourself, or ask
780
+ * {@link CloudRoaring.exists} the narrower question. Needs a `registry`.
781
+ *
782
+ * ```ts
783
+ * for await (const s of store.segments({ namespace: 'active-daily' })) {
784
+ * console.log(s.segment, s.currentGen, s.status);
785
+ * }
786
+ * ```
787
+ */
788
+ segments(options = {}) {
789
+ if (options.namespace !== void 0) {
790
+ validateSegmentRef({ segment: "x", namespace: options.namespace });
475
791
  }
476
- return result;
792
+ return listSegments(this.requireRegistry("segments"), options);
477
793
  }
478
794
  /**
479
- * **Dispose of a segment — tombstone it, delete its Warm rows, delete its Cold objects.** Irreversible.
795
+ * Move this segment's pointer **back** to a generation still in the bucket — the one write in the library that
796
+ * is not forward-only.
797
+ *
798
+ * Forward-only is right for a writer: a load whose ids came from upstream loses nothing by being out-raced, and
799
+ * letting it regress would let a slow loader silently undo a fast one. It is wrong for an operator who has
800
+ * looked at the segment, decided the current generation is wrong, and knows which one they want. So this is
801
+ * reachable only by asking for it by name — no sweep, retry or reconciliation calls it — and it is audited
802
+ * (`segment.rollback`), because every other pointer move can be reconstructed from "a load happened" and this
803
+ * one cannot.
804
+ *
805
+ * It refuses rather than guesses: a generation not in the bucket (collected, or never written) throws
806
+ * `NotFoundError` naming what *is* available, and a crypto-shredded segment throws
807
+ * {@link ValidationError} because every generation of it is unreadable. Rolling to the generation already
808
+ * current is a no-op that reports itself.
809
+ *
810
+ * It deletes nothing. The generations above the new pointer stay put — which is what makes this reversible —
811
+ * and are then *above* `currentGen`, where collection never looks, so they remain until a later load raises the
812
+ * pointer past them. An operator who has just undone a bad load should not have the evidence collected out from
813
+ * under them.
814
+ *
815
+ * Needs a backend.
816
+ */
817
+ async rollback(ref, toGeneration, options = {}) {
818
+ validateSegmentRef(ref);
819
+ const deps = this.lifecycleDeps("rollback");
820
+ try {
821
+ return await rollbackSegment(ref, toGeneration, deps, options);
822
+ } finally {
823
+ this.engine.invalidate(ref);
824
+ }
825
+ }
826
+ /**
827
+ * **Replace this segment's contents** with `ids`, as one new immutable generation, and make it current.
828
+ *
829
+ * The whole write path in one call: take the next generation number, write the object, check the result is
830
+ * plausible, move the pointer, collect what the move superseded. Composed by hand those are four functions and
831
+ * the one that gets left out is the last, so segments quietly accumulate superseded generations nobody pays
832
+ * attention to and everybody pays for.
833
+ *
834
+ * ```ts
835
+ * const r = await store.load('audience:active', idsFromWarehouse, {
836
+ * guard: { maxShrink: 0.5 }, // refuse a load that drops more than half the segment
837
+ * });
838
+ * if (!r.published) console.warn(`load refused: ${r.reason}`);
839
+ * ```
840
+ *
841
+ * **A load REPLACES.** Whatever the stream contains is what the segment contains afterwards, so an upstream
842
+ * query that returns fewer rows than usual is a shrink nobody asked for and an empty one is a wipe — both
843
+ * reported as a successful write, because at the storage layer they are one. That is what `guard` and the
844
+ * default empty refusal are for, and why they run **between** the write and the publish: the only moment where
845
+ * the new content is known and the old one is still authoritative.
846
+ *
847
+ * **Branch on `published`.** A refusal is a normal outcome, not an exception — `published: false` with a
848
+ * `reason`, in the same shape as a success — so a discarded result is a load that silently did nothing, and
849
+ * with the empty guard on by default this verb refuses more readily than any other. Note the sibling `*Into`
850
+ * verbs *throw* on the same superseded condition rather than reporting it; this one follows
851
+ * `eraseIdFromSegment` instead, because three of its four refusals are expected guard outcomes rather than
852
+ * faults.
853
+ *
854
+ * The object written for a refused load is deleted again before returning — it sits above `currentGen`, where
855
+ * generation collection deliberately never looks, so nothing else would reclaim it. The exception is a load
856
+ * that finds the segment **re-created** underneath it: the generation number it holds may then name the new
857
+ * incarnation's live object, so it leaves the orphan rather than risk deleting live data.
858
+ *
859
+ * Two things it does **throw** for, rather than report: a crypto-shredded segment (`ValidationError` — there
860
+ * is no key to write under), and a collection pass that could not prove the segment was still the same one
861
+ * (`WriteConflictError`). The second can be raised **after** the publish already landed, so a throw does not
862
+ * by itself mean the load did not take effect — re-read the pointer rather than assuming.
863
+ *
864
+ * Needs a backend (throws {@link UnsupportedError} otherwise).
865
+ */
866
+ async load(ref, ids, options = {}) {
867
+ validateSegmentRef(ref);
868
+ const deps = this.lifecycleDeps("load");
869
+ try {
870
+ return await loadSegment(ref, ids, deps, options);
871
+ } finally {
872
+ this.engine.invalidate(ref);
873
+ }
874
+ }
875
+ /**
876
+ * **Dispose of a segment — tombstone it, then delete its storage objects.** Irreversible.
480
877
  *
481
878
  * The operation a rolling window needs: `destroySegment` crypto-shreds (bytes unreadable everywhere including
482
879
  * backups, but still sitting in your bucket and still billed, and it requires encryption), while this one
@@ -488,7 +885,8 @@ var CloudRoaring = class {
488
885
  *
489
886
  * ```ts
490
887
  * for (const day of expiredDays) {
491
- * // A colon is NOT legal in a name — the family goes in the namespace, the date in the segment.
888
+ * // The family in the namespace, the date in the segment — `registry.list(namespace)` then enumerates
889
+ * // exactly this family's buckets, which a flat `active:2026-08-01` name cannot do without string-matching.
492
890
  * const ref = { namespace: 'active-daily', segment: day };
493
891
  * await store.dropSegment(ref, { confirmSegment: ref.segment });
494
892
  * }
@@ -498,173 +896,428 @@ var CloudRoaring = class {
498
896
  * `{ dropped: false, reason: 'absent' }`, not a throw, so a retention loop with that mistake deletes nothing
499
897
  * forever and quietly. Branch on `dropped`, and treat `reason: 'absent'` as the alert.
500
898
  *
501
- * **Works on an accumulator too.** A segment you created by writing to it — never bulk-loaded, never compacted,
502
- * so it has no registry row and no Cold objects — is retired by deleting its Warm rows alone. That reports
503
- * `{ dropped: true, reason: 'warm-only' }`: there is no tombstone to write, and deliberately none is written,
504
- * because a `destroyed` row per retired daily bucket would be registry litter. The data really is gone.
505
- *
506
899
  * **Inspect `generationsRemaining`.** Empty is the normal outcome; non-empty means the storage was NOT fully
507
- * reclaimed and the drop should be re-run. A compaction that was already in flight when the tombstone landed
508
- * still finishes staging one more object, so a single sweep can miss it — this call re-sweeps and then reports
509
- * whatever it still could not remove rather than returning a result that looks like a clean drop.
510
- *
511
- * Reads become empty within `coldGenTtlMs` (default 2 s), not instantly: a store that had already read this
512
- * segment may answer from its cached generation + hot chunks until that window lapses. A reader that never
513
- * touched it sees empty at once. **That bound needs a clock and `coldGenTtlMs > 0`** — a store built without a
514
- * clock, or with `coldGenTtlMs: 0` ("pin forever"), holds its resolved snapshot for its own lifetime and can
900
+ * reclaimed and the drop should be re-run. A load that was already writing when the tombstone landed still
901
+ * finishes its object, so a single sweep can miss it — this call re-sweeps and then reports whatever it still
902
+ * could not remove rather than returning a result that looks like a clean drop.
903
+ *
904
+ * Reads become empty within `cache.genTtlMs` (default 2 s), not instantly: a store that had already read this
905
+ * segment may answer from its cached generation + cached chunks until that window lapses. A reader that never
906
+ * touched it sees empty at once. **That bound needs a clock and `cache.genTtlMs > 0`** — a store built without a
907
+ * clock, or with `cache.genTtlMs: 0` ("pin forever"), holds its resolved snapshot for its own lifetime and can
515
908
  * keep answering `true` for a dropped segment indefinitely; restart it.
516
909
  *
517
- * Needs the store built with a **raw cold driver + a registry** (throws {@link UnsupportedError} otherwise),
518
- * because it has to enumerate and delete generations — a pre-built `ColdChunkSource` only reads.
910
+ * Needs the store built with a **backend** (throws {@link UnsupportedError} otherwise),
911
+ * because it has to enumerate and delete generations — a pre-built `StorageChunkSource` only reads.
519
912
  */
520
913
  async dropSegment(ref, options) {
521
914
  validateSegmentRef(ref);
522
- const deps = this.compactionDeps();
523
- return dropSegment(ref, { registry: deps.registry, warm: deps.warm, cold: deps.cold }, options);
915
+ const deps = this.lifecycleDeps("dropSegment");
916
+ try {
917
+ return await dropSegment(ref, { registry: deps.registry, storage: deps.storage }, options);
918
+ } finally {
919
+ if (options.dryRun !== true) this.engine.invalidate(ref);
920
+ }
921
+ }
922
+ /**
923
+ * **Record when this segment becomes eligible for retirement.** One registry write; nothing is deleted here,
924
+ * and nothing starts running. `retireExpired` is what acts on the policy, and **you** decide when that runs —
925
+ * an EventBridge rule, a CronJob, a queue consumer, whatever your deployment already has. This library starts
926
+ * no background thread (it has to work identically in a Lambda, an edge isolate and a long-lived server).
927
+ *
928
+ * `expiresAt` is an **absolute epoch-ms you compute**, not a duration the library derives. A relative TTL would
929
+ * have to be anchored to something the library knows — `updatedAt`, or the current generation — and every load
930
+ * rewrites both, so "expire 30 days after the last write" would keep a busy daily bucket alive forever
931
+ * precisely because it is being reloaded.
932
+ *
933
+ * ```ts
934
+ * const DAY = 86_400_000;
935
+ * const ref = { namespace: 'active-daily', segment: '2026-08-05' };
936
+ * await store.setRetention(ref, { expiresAt: Date.now() + 30 * DAY });
937
+ * ```
938
+ *
939
+ * **Works before the first load**: a segment with no registry row yet gets one (`createdRow: true` in the
940
+ * result) with **no Storage generation**, so the policy is recorded ahead of the data and the segment is already
941
+ * enumerable by the sweep; the first load then publishes onto that row.
942
+ *
943
+ * A value in the past is legal and means "eligible on the next sweep" — backfilling a policy onto existing
944
+ * buckets is normal. A value below `MIN_EXPIRES_AT_MS` (2001-09-09) is rejected: it is almost certainly epoch
945
+ * **seconds**, which would read as long-expired and retire the segment on the next pass. Needs a `registry`
946
+ * in the store config (throws {@link UnsupportedError} otherwise), and refuses a crypto-shredded segment.
947
+ */
948
+ async setRetention(ref, policy) {
949
+ validateSegmentRef(ref);
950
+ return setSegmentRetention(ref, { registry: this.requireRegistry("setRetention") }, policy);
951
+ }
952
+ /**
953
+ * **The stored retention policy**, or `null` if the segment has none (or has no registry row, or is a
954
+ * tombstone). Returns the string `'invalid'` for a row whose `expiresAt` is present but unusable — a
955
+ * hand-edited row, or one from a restore — so a malformed policy is visible rather than silently reading as
956
+ * "never expires" on a segment someone believes is expiring.
957
+ */
958
+ async getRetention(ref) {
959
+ validateSegmentRef(ref);
960
+ return getSegmentRetention(ref, { registry: this.requireRegistry("getRetention") });
961
+ }
962
+ /**
963
+ * **Cancel a segment's expiry** so no sweep retires it. Returns whether a policy was actually removed (`false`
964
+ * when there was none). A separate verb from `setRetention` on purpose: "never expire" as a magic value passed
965
+ * to the setter is how a typo becomes a deletion.
966
+ */
967
+ async clearRetention(ref) {
968
+ validateSegmentRef(ref);
969
+ return clearSegmentRetention(ref, { registry: this.requireRegistry("clearRetention") });
970
+ }
971
+ /**
972
+ * **Run the retention sweep: retire every segment whose `expiresAt` has passed.** This is the call that acts on
973
+ * the policies `setRetention` records — and it is a **call, not a daemon**. Nothing schedules it; you run it from
974
+ * whatever heartbeat your deployment already has (an EventBridge rule, a Kubernetes CronJob, a queue consumer,
975
+ * the job that runs your loads). A library that started a timer would behave differently in a Lambda, an edge
976
+ * isolate and a long-lived server, which is worse than not having one.
977
+ *
978
+ * Each retirement goes through `dropSegment`, so the registry → Storage ordering, the re-sweep for an object a
979
+ * load was still writing, and the `generationsRemaining` report all come from one implementation rather than
980
+ * two.
981
+ *
982
+ * ```ts
983
+ * // In your scheduled handler. Start with a preview in a new deployment.
984
+ * const preview = await store.retireExpired({ namespace: 'active-daily', dryRun: true });
985
+ * console.log(`would retire ${preview.retired} of ${preview.scanned} (limited: ${preview.limited})`);
986
+ *
987
+ * const swept = await store.retireExpired({ namespace: 'active-daily' });
988
+ * for (const e of swept.entries) {
989
+ * if (e.action === 'skipped') console.warn(`${e.segment}: ${e.reason}`); // invalid-policy / limit / failed: …
990
+ * if (e.action === 'retired' && e.result.generationsRemaining.length > 0) {
991
+ * console.warn(`${e.segment}: storage not fully reclaimed — re-run`);
992
+ * }
993
+ * }
994
+ * if (swept.limited) scheduleAnotherPassSoon(); // more are still eligible
995
+ * ```
996
+ *
997
+ * **Read the ledger.** A per-segment *fault* is an entry, not an exception — a throw from the middle of a fleet
998
+ * sweep would leave the caller unable to say which segments were retired, having already retired some. (A bad
999
+ * argument does throw, and so does a fleet larger than `maxScanSegments`.) `limited: true` means the per-cycle
1000
+ * `limit` (default 100) cut the pass short and more are eligible. That cap is charged on **attempts**, not
1001
+ * successes, which is what makes it a real bound: `dropSegment` writes the tombstone before sweeping Storage, so a
1002
+ * fault in the Storage phase is a segment that is already retired. Retirements are sequential, so `limit` is a
1003
+ * wall-clock knob too, and `retired` counts deletions only — a dry run reports `wouldRetire` instead, so a
1004
+ * dashboard summing `retired` can never show a phantom deletion.
1005
+ *
1006
+ * It also **deletes the tombstone rows its own past retirements left**, after `tombstoneGraceMs` (default 24 h)
1007
+ * and only once that segment's Storage generations are provably gone — collecting a straggler generation itself
1008
+ * first, since nothing else ever would for a tombstoned segment. Attribution is a **positive marker the sweep
1009
+ * stamps on its own retirements**, not an inference from "destroyed + an expired policy": a crypto-shred leaves
1010
+ * `retention` untouched, so setting a policy and then honouring a right-to-erasure request mid-window produces
1011
+ * exactly that row, and deleting it would destroy the Art. 17 attestation and un-fence the name. Pass
1012
+ * `purgeTombstones: false` to keep every tombstone.
1013
+ *
1014
+ * Needs the store built with a **backend** (throws {@link UnsupportedError} otherwise),
1015
+ * because retiring a segment deletes its storage objects. `now` defaults to the store's clock.
1016
+ */
1017
+ async retireExpired(options = {}) {
1018
+ const deps = this.lifecycleDeps("retireExpired");
1019
+ const result = await retireExpired(
1020
+ { registry: deps.registry, storage: deps.storage },
1021
+ { ...options, now: options.now ?? this.clock.now() }
1022
+ );
1023
+ if (options.dryRun !== true) {
1024
+ for (const entry of result.entries) {
1025
+ this.engine.invalidate({ segment: entry.segment, namespace: entry.namespace });
1026
+ }
1027
+ }
1028
+ return result;
1029
+ }
1030
+ /**
1031
+ * **Forget everything this store has cached about a segment.** Use it after destroying or retiring a segment
1032
+ * through a path this store did not perform itself.
1033
+ *
1034
+ * A store keeps two layers of derived state — a resolved snapshot per segment (an open `.crbm` reader, plus
1035
+ * the DEK it unwrapped) and decoded chunks keyed by generation. Both are built for one event: **a publish
1036
+ * that advances `currentGen`**, which the snapshot TTL notices and the generation-keyed cache misses on.
1037
+ * Neither notices an event that *destroys* what they were derived from.
1038
+ *
1039
+ * The verbs on this class handle themselves — `eraseSubject`, `dropSegment` and `retireExpired` invalidate
1040
+ * what they touch. This method is for the cases they cannot see:
1041
+ *
1042
+ * - **`destroySegment` / `eraseNamespace`**, which are free functions over raw drivers rather than methods
1043
+ * here, so a crypto-shred performed beside this store leaves it holding an open reader and an unwrapped
1044
+ * DEK. Until it is told, it keeps decrypting — including chunks it had never fetched before the shred.
1045
+ * - **Another process.** Erasing on one box invalidates nothing on the others; each store bounds its own
1046
+ * staleness by `cache.genTtlMs`, and a store built with no clock or `cache.genTtlMs: 0` ("pin forever") never
1047
+ * converges at all. If a compliance deadline depends on every reader converging, you need to signal them —
1048
+ * this is the call to make when your own fan-out delivers.
1049
+ *
1050
+ * Synchronous, best-effort, and safe to call for a segment this store has never read.
1051
+ *
1052
+ * ```ts
1053
+ * await destroySegment(ref, { storage, registry, keystore }, { confirmSegment: ref.segment });
1054
+ * store.invalidate(ref); // this process
1055
+ * await bus.publish('cloudbitmaps.invalidate', ref); // and every other one
1056
+ * ```
1057
+ */
1058
+ invalidate(ref) {
1059
+ validateSegmentRef(ref);
1060
+ this.engine.invalidate(ref);
1061
+ }
1062
+ /**
1063
+ * Build a handle held at the generation `ref` resolves to right now. See {@link Segment.pin}.
1064
+ *
1065
+ * The pinned handle gets its own engine but **shares the store's chunk cache**, which is safe precisely
1066
+ * because the cache is keyed by the source's version rather than the generation number: the pinned view
1067
+ * reports the version captured at pin time, so its decoded chunks cannot collide with the live generation's.
1068
+ * Sharing it on a generation-only key was how a pinned read could resurrect an id already reported
1069
+ * physically gone.
1070
+ */
1071
+ async pinSegment(ref, expiresAt) {
1072
+ const crbm = this.crbmSource;
1073
+ if (crbm === void 0) {
1074
+ throw new UnsupportedError(
1075
+ "pin() needs the `.crbm` storage source \u2014 pass a backend or a raw IStorageDriver as `storage` (a pre-built StorageChunkSource that cannot resolve a generation has nothing to pin)"
1076
+ );
1077
+ }
1078
+ const at = await crbm.pinGeneration(ref);
1079
+ const pinnedAt = { generation: at?.generation ?? null, version: at?.version ?? null };
1080
+ const pins = /* @__PURE__ */ new Map([[segmentKey(ref), pinnedAt]]);
1081
+ return new Segment(
1082
+ this.engineWithPins(pins),
1083
+ ref,
1084
+ this.clock,
1085
+ this.metrics,
1086
+ (dest, ids, op, options) => this.materialize(dest, ids, op, options),
1087
+ (r, e) => this.pinSegment(r, e),
1088
+ (handles) => this.engineForCombine(handles),
1089
+ expiresAt,
1090
+ pinnedAt
1091
+ );
1092
+ }
1093
+ /**
1094
+ * An engine reading every segment in `pins` at its pinned generation and everything else live.
1095
+ *
1096
+ * It **shares the store's chunk cache**, which is safe only because that cache is keyed by the source's
1097
+ * version rather than the generation number: a pinned view reports the version captured at pin time, so its
1098
+ * decoded chunks cannot collide with the live generation's.
1099
+ */
1100
+ engineWithPins(pins) {
1101
+ const crbm = this.crbmSource;
1102
+ if (crbm === void 0) {
1103
+ throw new UnsupportedError(
1104
+ "pin() needs the `.crbm` storage source \u2014 pass a backend or a raw IStorageDriver as `storage` (a pre-built StorageChunkSource that cannot resolve a generation has nothing to pin)"
1105
+ );
1106
+ }
1107
+ return new SegmentEngine({
1108
+ storage: new PinnedStorageChunkSource(crbm, pins),
1109
+ cache: this.cache,
1110
+ codec: roaringCodec,
1111
+ clock: this.clock,
1112
+ metrics: this.metrics,
1113
+ budget: this.budget
1114
+ });
1115
+ }
1116
+ /**
1117
+ * The engine a combine should run on, given the handles involved.
1118
+ *
1119
+ * A pinned handle passed as an **operand** must still be read at its pin. Routing by "which handle the call
1120
+ * was made on" reads it live instead — `snap.intersect([other])` honours the pin while
1121
+ * `other.intersect([snap])` silently does not, and the two are the same question. So the combine collects
1122
+ * every pin in play and runs on an engine that honours all of them. With no pins anywhere this is the
1123
+ * store's own engine and costs nothing.
1124
+ */
1125
+ engineForCombine(handles) {
1126
+ const pins = /* @__PURE__ */ new Map();
1127
+ for (const h of handles) {
1128
+ const at = h.pinnedAt;
1129
+ if (at !== void 0) pins.set(h.key(), at);
1130
+ }
1131
+ return pins.size === 0 ? void 0 : this.engineWithPins(pins);
1132
+ }
1133
+ /** The store's registry, or a typed error naming the operation that needs one. */
1134
+ requireRegistry(op) {
1135
+ if (this.registry === void 0) {
1136
+ throw new UnsupportedError(
1137
+ `${op} needs a storage backend \u2014 S3Storage, GcsStorage, AzureBlobStorage, LocalFsStorage or MemoryStorage. A bare IStorageDriver has no generation pointer to publish through, and there is no longer a separate \`registry\` option to add.`
1138
+ );
1139
+ }
1140
+ return this.registry;
524
1141
  }
525
1142
  /**
526
- * **Cross-tier DR consistency check (audit gap #11).** After a restore/failover, verify every registered
527
- * segment's `currentGen` actually has its `.crbm` present in Cold — catching a **torn restore** where the
528
- * registry (`currentGen`) came back ahead of the object store, so a pointer references a generation that
529
- * isn't there (reads would then throw). Read-only, bounded fan-out; run it at startup after a restore.
530
- * Returns `{ checked, inconsistent }` — `inconsistent` empty ⇒ coherent; otherwise it names the segments to
531
- * recover (restore the object store, or roll the registry back to a coherent point). Needs the store built
532
- * with a **raw cold driver + a registry** (throws {@link UnsupportedError} otherwise). `destroyed`
533
- * (crypto-shredded) segments are skipped. Pair it with the DR runbook (docs/guide/disaster-recovery.md).
1143
+ * **Cross-tier DR consistency check.** After a restore/failover, verify every registered segment's `currentGen`
1144
+ * actually has its `.crbm` present in Storage — catching a **torn restore** where the registry (`currentGen`) came
1145
+ * back ahead of the object store, so a pointer references a generation that isn't there (reads would then
1146
+ * throw). Read-only, bounded fan-out; run it at startup after a restore. Returns `{ checked, inconsistent }` —
1147
+ * `inconsistent` empty ⇒ coherent; otherwise it names the segments to recover (restore the object store, or
1148
+ * roll the registry back to a coherent point). Needs the store built with a **backend**
1149
+ * (throws {@link UnsupportedError} otherwise). `destroyed` (crypto-shredded) segments are skipped. Pair it with
1150
+ * the DR runbook (docs/guide/disaster-recovery.md).
534
1151
  */
535
1152
  async checkConsistency(options = {}) {
536
- const deps = this.compactionDeps();
537
- return runConsistencyCheck({ cold: deps.cold, registry: deps.registry }, options);
1153
+ const deps = this.lifecycleDeps("checkConsistency");
1154
+ return runConsistencyCheck({ storage: deps.storage, registry: deps.registry }, options);
538
1155
  }
539
1156
  /**
540
- * **Export ("eject") every registered segment's current effective set** through the injected `sink`, using
541
- * only public read APIs — so your data is readable **without CloudRoaring** (the exit path; see the README's
542
- * "Your data stays yours"). `format: 'roaring'` (default) writes one **portable RoaringBitmap32** per segment
1157
+ * **Export ("eject") every registered segment's current generation** through the injected `sink`, using only
1158
+ * public read APIs — so your data is readable **without CloudRoaring** (the exit path; see the README's "Your
1159
+ * data stays yours"). `format: 'roaring'` (default) writes one **portable RoaringBitmap32** per segment
543
1160
  * (loadable by any roaring library); `'ndjson'` writes newline-delimited ids (zero-dependency, streaming).
544
1161
  * Enumerates via the store's **own** registry (needs one — throws {@link UnsupportedError} otherwise) so the
545
- * enumeration and the read path provably share one registry; an all-warm segment not yet in the registry can be
546
- * named via `options.candidates`. Encrypted segments are decrypted transparently **iff** this store was wired
547
- * with their keystore — the export is therefore **cleartext**; protect it. Crypto-shredded segments are skipped.
548
- * A segment that can't be read is isolated into the manifest's `failed[]` (the run continues), so "a manifest
549
- * exists" means the run finished — check `failed`. The `export-segments` CLI wraps this with a filesystem sink.
1162
+ * enumeration and the read path provably share one registry. Encrypted segments are decrypted transparently
1163
+ * **iff** this store was wired with their keystore — the export is therefore **cleartext**; protect it.
1164
+ * Crypto-shredded segments are skipped. A segment that can't be read is isolated into the manifest's `failed[]`
1165
+ * (the run continues), so "a manifest exists" means the run finished — check `failed`. The `export-segments`
1166
+ * CLI wraps this with a filesystem sink.
550
1167
  */
551
1168
  async exportSegments(sink, options = {}) {
552
- if (this.registry === void 0) {
553
- throw new UnsupportedError("exportSegments needs a `registry` in the store config");
554
- }
555
- return runExport$1(this, this.registry, sink, {
1169
+ const registry = this.requireRegistry("exportSegments");
1170
+ return runExport2(this, registry, sink, {
556
1171
  ...options,
557
1172
  codec: options.codec ?? roaringCodec
558
1173
  });
559
1174
  }
560
1175
  /**
561
- * Planning cost estimate (Phase 5b) — pure, no instance/data needed: sizing, sales, what-if. For a real,
562
- * grounded report from live segment sizes, use `store.segment(name).costReport()`. See {@link CostReport}.
1176
+ * Planning cost estimate — pure, no instance/data needed: sizing, sales, what-if. For a real, grounded report
1177
+ * from live segment sizes, use `store.segment(name).costReport()`. See {@link CostReport}.
563
1178
  */
564
1179
  static estimateCost(input) {
565
1180
  return estimateCost(input);
566
1181
  }
567
1182
  };
1183
+ var EMPTY_IDS = {
1184
+ async *[Symbol.asyncIterator]() {
1185
+ }
1186
+ };
1187
+ var KEEP_EVERY_GENERATION = Number.MAX_SAFE_INTEGER;
568
1188
  var Segment = class {
569
- constructor(engine, ref, clock, metrics) {
1189
+ constructor(engine, ref, clock, metrics, materialize, pinned, combineEngine, expiresAt, pinnedAt) {
570
1190
  this.engine = engine;
571
1191
  this.ref = ref;
572
1192
  this.clock = clock;
573
1193
  this.metrics = metrics;
1194
+ this.materialize = materialize;
1195
+ this.pinned = pinned;
1196
+ this.combineEngine = combineEngine;
1197
+ this.expiresAt = expiresAt;
1198
+ this.pinnedAt = pinnedAt;
574
1199
  this.metricsOn = metrics !== NOOP_METRICS;
575
1200
  }
576
1201
  engine;
577
1202
  ref;
578
1203
  clock;
579
1204
  metrics;
1205
+ materialize;
1206
+ pinned;
1207
+ combineEngine;
1208
+ expiresAt;
1209
+ pinnedAt;
580
1210
  metricsOn;
581
1211
  /**
582
- * Time an op with the injected clock and emit an `op` metric on completion (success or throw). Skipped
583
- * entirely when no sink is wired, so the default path pays nothing.
1212
+ * **Hold this segment at the generation that is current right now**, for as long as you keep the handle.
1213
+ *
1214
+ * An ordinary handle re-resolves on `cache.genTtlMs`, so a publish part-way through a long job means its second
1215
+ * half describes a different instant than its first — every chunk whole and verified, but the answer covering
1216
+ * two moments, with nothing in the result saying so. That is fine for a dashboard and wrong for an export, a
1217
+ * reconciliation, or a send that has to match the count you reported. A pin is how you get one instant.
1218
+ *
1219
+ * ```ts
1220
+ * const snap = await store.segment('active-30d').pin();
1221
+ * const total = await snap.count(); // the number you report
1222
+ * for await (const id of snap.iterate()) { … } // …and the ids it counted, however long this takes
1223
+ * ```
1224
+ *
1225
+ * **Only this segment is pinned.** `snap.intersect([other])` reads `snap` at its pinned generation and
1226
+ * `other` at whatever is current — pin each segment if you want the whole query held. And a pinned handle
1227
+ * used as an *operand* is still read at its pin, never live.
1228
+ *
1229
+ * **It is a hold, not a lease.** Nothing here stops `gcOrphanGenerations` deleting the generation underneath
1230
+ * you: a pinned read deliberately does **not** heal forward, because silently serving a different generation
1231
+ * is the one thing a pin exists to prevent, so it fails instead. Size `keep` to cover your longest pinned
1232
+ * job — see [Sizing `keep`](../../docs/guide/getting-started.md#sizing-keep) — or take the pin on a segment
1233
+ * you are not collecting.
1234
+ *
1235
+ * A segment with no current generation pins nothing and reads empty, exactly as it would unpinned. A pin
1236
+ * taken before a crypto-shred stops reading when the shred lands: the destroyed row is re-checked every time
1237
+ * the pinned reader opens, so a pin cannot outlive the key it was using.
1238
+ *
1239
+ * Needs a store built on the `.crbm` storage source (the default when you pass a backend or a raw driver). Throws
1240
+ * {@link UnsupportedError} on a store wired with a pre-built source that cannot pin.
584
1241
  */
585
- async timed(name, fn) {
586
- if (!this.metricsOn) return fn();
587
- const startedAt = this.clock.now();
588
- try {
589
- return await fn();
590
- } finally {
591
- this.metrics.onEvent({ kind: "op", name, ms: Math.max(0, this.clock.now() - startedAt) });
592
- }
1242
+ async pin() {
1243
+ return this.pinned(this.ref, this.expiresAt);
593
1244
  }
594
- /** Add an id (integer in `[0, 2^32)`). Throws {@link ValidationError} on a bad id. */
595
- add(id) {
596
- return this.timed("add", () => this.engine.add(this.ref, id));
1245
+ /** This handle's segment, as the cache/pin key — `ref` stays private; the encapsulation is worth the method. */
1246
+ key() {
1247
+ return segmentKey(this.ref);
597
1248
  }
598
1249
  /**
599
- * Add many ids, from a **sync or async** iterable. Not atomic across chunks — see the class note.
1250
+ * Has this handle's deadline passed? **The lazy half of expiry** — the whole check is one comparison against
1251
+ * the injected clock, so it costs nothing on a handle with no deadline and no I/O on one that has expired.
600
1252
  *
601
- * An async source means a database cursor streams straight in — `addMany(athenaCursor())` rather than
602
- * hand-batching `page → addMany(page)`. Ids are grouped by chunk and each chunk is written **exactly once**,
603
- * however long the stream: pending ids are held compressed, not buffered-and-flushed, so a long stream costs
604
- * no more backend writes than a short one. Peak memory is the roaring representation of the ids you pass.
605
- *
606
- * **For a very large set, reach for `bulkLoadCrbmGeneration` instead.** `addMany` expresses a *delta* and
607
- * writes one warm row per touched chunk — around 61,000 of them for a set spread across the whole id space.
608
- * Bulk-load *replaces* the segment with one immutable Cold object. See the guide's cost comparison; picking
609
- * the wrong one of these is the most expensive mistake available in this library.
1253
+ * Deliberately evaluated per call rather than cached: a long-lived handle created before its deadline must
1254
+ * start reading empty the moment the deadline passes, without the caller re-creating it.
610
1255
  */
611
- addMany(ids) {
612
- return this.timed("addMany", () => this.engine.addMany(this.ref, ids));
613
- }
614
- /** Remove an id (integer in `[0, 2^32)`). Throws {@link ValidationError} on a bad id. */
615
- remove(id) {
616
- return this.timed("remove", () => this.engine.remove(this.ref, id));
617
- }
618
- /** Remove many ids, from a **sync or async** iterable. Not atomic across chunks — see {@link addMany}. */
619
- removeMany(ids) {
620
- return this.timed("removeMany", () => this.engine.removeMany(this.ref, ids));
1256
+ expired() {
1257
+ return this.expiresAt !== void 0 && this.clock.now() >= this.expiresAt;
621
1258
  }
622
1259
  /**
623
- * **Claim ids atomically — add them, and get back only the ones that were not already there.**
624
- *
625
- * The durable analogue of Redis `SETBIT` returning the prior bit, which is what an exactly-once *"have I
626
- * already sent to / already processed this id?"* check needs. `has()` then `add()` cannot give you this: two
627
- * workers can both read absent and both proceed.
1260
+ * Refuse a materialisation that involves an **expired** handle. Called by the three `*Into` verbs only.
628
1261
  *
629
- * ```ts
630
- * const sent = store.segment(day, { namespace: 'sent-daily' });
631
- * const toSend = await sent.claimMany(candidateIds); // only the ids this worker won
632
- * for (const id of toSend) enqueue(id); // ...so nobody else will send them
633
- * ```
1262
+ * On the read verbs an expired handle answers empty, which is the point of lazy expiry. On a *write* the same
1263
+ * rule would be destructive in a way nobody asks for: `a.intersectInto(dest, [b])` where `b`'s deadline has
1264
+ * quietly passed publishes an **empty generation over `dest`** — a wipe, reported as a successful write, with
1265
+ * the cause (a deadline on a handle somewhere) nowhere in the result. Reads degrade to empty; writes must not.
634
1266
  *
635
- * **Pass a batch, not one id.** A Warm write rewrites a whole 64K-id chunk bitmap, so claiming ids one at a
636
- * time is the single most expensive way to use this library — measured at ~5,000 writes and ~23 MB for 5,000
637
- * ids, against 1 write and ~8 KB for the same ids in one call. This method does one OCC read-modify-write per
638
- * distinct chunk, giving you Redis's *semantics* without Redis's per-id *cost shape*.
1267
+ * So every handle in the call is checked, `dest` included: an expired `dest` does not change the bytes written,
1268
+ * but a caller who put a deadline on the thing they are writing into has said something contradictory and is
1269
+ * better told than guessed at. Open a handle without `expiresAt` to write, or drop the deadline.
639
1270
  *
640
- * **Exactly-once holds per id**, which is the guarantee that matters: each id lives in one chunk, a chunk is
641
- * one OCC row, so exactly one concurrent claimer sees any given id as new. Like `addMany` it is **not** atomic
642
- * across chunks — a mid-flight failure can leave some chunks claimed — but re-running is safe, because
643
- * already-claimed ids simply come back as not-new.
1271
+ * (The broader guard — refusing to publish an empty or implausible generation over a non-empty one, with an
1272
+ * `allowEmpty` override — now covers these verbs too: they route through the same guarded write path as
1273
+ * {@link CloudRoaring.load}. The two stay separate because they differ in kind. That one is a REPORTED
1274
+ * refusal a caller may legitimately override; an expired handle is a wiring mistake, so it THROWS, before
1275
+ * any object is written — and `allowEmpty: true` does not reach it.)
644
1276
  *
645
- * Order of the returned ids is unspecified.
1277
+ * The verbs are `async` so this surfaces as a **rejected promise**, like every other validation in the facade —
1278
+ * a synchronous throw out of a promise-returning method escapes a caller who attached `.catch()` instead of
1279
+ * awaiting.
646
1280
  */
647
- claimMany(ids) {
648
- return this.timed("claimMany", () => this.engine.claimMany(this.ref, ids));
1281
+ refuseIfExpired(op, dest, operands) {
1282
+ const stale = [];
1283
+ for (const seg of [this, dest, ...operands]) {
1284
+ if (seg.expired())
1285
+ stale.push(seg.ref.namespace ? `${seg.ref.namespace}/${seg.ref.segment}` : seg.ref.segment);
1286
+ }
1287
+ if (stale.length > 0) {
1288
+ throw new ValidationError(
1289
+ `${op}: refusing to publish a generation while these handles have expired \u2014 ${[...new Set(stale)].join(", ")}. An expired handle reads as empty, so this would write an empty (or short) generation over "${dest.ref.segment}". Open the handles without \`expiresAt\` if you meant to materialise, or drop the deadline.`
1290
+ );
1291
+ }
1292
+ }
1293
+ /**
1294
+ * Time an op with the injected clock and emit an `op` metric on completion (success or throw). Skipped
1295
+ * entirely when no sink is wired, so the default path pays nothing.
1296
+ */
1297
+ async timed(name, fn) {
1298
+ if (!this.metricsOn) return fn();
1299
+ const startedAt = this.clock.now();
1300
+ try {
1301
+ return await fn();
1302
+ } finally {
1303
+ this.metrics.onEvent({ kind: "op", name, ms: Math.max(0, this.clock.now() - startedAt) });
1304
+ }
649
1305
  }
1306
+ /** Membership: one chunk — the cache, else one ranged GET. Throws {@link ValidationError} on a bad id. */
650
1307
  has(id) {
1308
+ if (this.expired()) return Promise.resolve(false);
651
1309
  return this.timed("has", () => this.engine.has(this.ref, id));
652
1310
  }
1311
+ /** Cardinality — summed from the `.crbm` index with **zero payload reads** on a loaded segment. */
653
1312
  count() {
1313
+ if (this.expired()) return Promise.resolve(0);
654
1314
  return this.timed("count", () => this.engine.count(this.ref));
655
1315
  }
1316
+ /** Every id, ascending, streamed one chunk at a time. */
656
1317
  iterate() {
1318
+ if (this.expired()) return EMPTY_IDS;
657
1319
  return this.engine.iterate(this.ref);
658
1320
  }
659
- /**
660
- * Chunk-skipping intersection: stream the ids in **this** segment AND every segment in `others`, ascending.
661
- * Fetches only the Cold chunks present in *all* operands (a key absent from any operand contributes nothing
662
- * and is never downloaded), streaming under a bounded in-flight window — so the Cold footprint stays small
663
- * (Lambda-friendly) regardless of segment size. Pass `concurrency` to tune that window (a positive integer).
664
- * AND is commutative, so `a.intersect([b])` and `b.intersect([a])` yield the same ids. (Warm state is read
665
- * up front, so total memory also carries each operand's Warm size — negligible under Topology-A.) Pass
666
- * `budget` to override the store's per-op denial-of-wallet budget for this call (or `false` to lift it).
667
- */
668
1321
  /**
669
1322
  * Map the facade's `Segment` handles in `exclude` down to the plain refs `core` takes. A method rather than
670
1323
  * a module function because `ref` is class-private — the encapsulation is worth more than the free function.
@@ -674,21 +1327,43 @@ var Segment = class {
674
1327
  const { exclude, ...rest } = options;
675
1328
  return exclude === void 0 ? rest : { ...rest, exclude: exclude.map((o) => o.ref) };
676
1329
  }
1330
+ /**
1331
+ * Chunk-skipping intersection: stream the ids in **this** segment AND every segment in `others`, ascending.
1332
+ * Fetches only the Storage chunks present in *all* operands (a key absent from any operand contributes nothing
1333
+ * and is never downloaded), streaming under a bounded in-flight window — so the Storage footprint stays small
1334
+ * (Lambda-friendly) regardless of segment size. Pass `concurrency` to tune that window (a positive integer).
1335
+ * AND is commutative, so `a.intersect([b])` and `b.intersect([a])` yield the same ids. Pass `budget` to
1336
+ * override the store's per-op denial-of-wallet budget for this call (or `false` to lift it).
1337
+ */
677
1338
  intersect(others, options) {
678
- return this.engine.intersect([this.ref, ...others.map((o) => o.ref)], this.refsIn(options));
1339
+ if (this.expired() || others.some((o) => o.expired())) return EMPTY_IDS;
1340
+ const engine = this.combineEngine([this, ...others, ...options?.exclude ?? []]) ?? this.engine;
1341
+ return engine.intersect([this.ref, ...others.map((o) => o.ref)], this.refsIn(options));
679
1342
  }
680
1343
  /**
681
- * Materialize `this ∩ others…` (minus `exclude`) **into** `dest` (added, not replaced). Streaming +
682
- * bounded-memory, but **not atomic** across chunks — see {@link addMany}.
1344
+ * Materialize `this ∩ others…` (minus `exclude`) as a **new generation of `dest`** — `dest`'s previous contents
1345
+ * are superseded, not added to. Streaming + bounded-memory; the result is one immutable object published
1346
+ * forward-only, so readers of `dest` see either the old generation or the new one, never a partial. Needs the
1347
+ * store built with a backend (throws {@link UnsupportedError} otherwise).
1348
+ *
1349
+ * **An empty result does NOT overwrite a non-empty destination.** A combine that comes out empty is far more
1350
+ * often a mistake upstream — a typo'd operand, an `exclude` that swallowed everything, an operand that has
1351
+ * not loaded yet — than an intent, and once it publishes it is indistinguishable from a correct run. So the
1352
+ * write is **refused and reported**: `published: false`, `reason: 'empty'`, and `dest` keeps what it had.
1353
+ * Pass `allowEmpty: true` when emptying the destination is the point.
1354
+ *
1355
+ * `guard` adds the same plausibility bounds `load()` takes — `minCardinality` and `minRetained` — judged
1356
+ * against what `dest` held before. A refusal is **reported, not thrown**, exactly as on `load()`; branch on
1357
+ * `published`. A lost race is the one outcome that still throws ({@link WriteConflictError}), because a
1358
+ * materialisation that silently did not take effect is the one thing a caller cannot detect on its own.
1359
+ *
1360
+ * A call involving an **expired handle** is refused before any of this. See {@link refuseIfExpired}.
683
1361
  */
684
- intersectInto(dest, others, options) {
1362
+ async intersectInto(dest, others, options) {
1363
+ this.refuseIfExpired("intersectInto", dest, [...others, ...options?.exclude ?? []]);
685
1364
  return this.timed(
686
1365
  "intersectInto",
687
- () => this.engine.intersectInto(
688
- dest.ref,
689
- [this.ref, ...others.map((o) => o.ref)],
690
- this.refsIn(options)
691
- )
1366
+ () => this.materialize(dest.ref, this.intersect(others, options), "intersectInto", options)
692
1367
  );
693
1368
  }
694
1369
  /**
@@ -699,20 +1374,28 @@ var Segment = class {
699
1374
  * `intersect` prunes any key missing from any operand; union has nothing to prune. It is charged against the
700
1375
  * same per-op budget, so a wide union is refused rather than quietly billed — pass `budget` to raise it
701
1376
  * deliberately. If you find yourself unioning the same segments on every read, materializing the combined
702
- * segment once (`unionInto`, or a bulk-load rebuild) is the cheaper shape.
1377
+ * segment once (`unionInto`, or a load) is the cheaper shape.
703
1378
  */
704
1379
  union(others, options) {
705
- return this.engine.union([this.ref, ...others.map((o) => o.ref)], this.refsIn(options));
1380
+ const live = others.filter((o) => !o.expired());
1381
+ if (this.expired()) {
1382
+ if (live.length === 0) return EMPTY_IDS;
1383
+ return live[0].union(live.slice(1), options);
1384
+ }
1385
+ if (live.length === 0 && others.length > 0) {
1386
+ const exclude = options?.exclude ?? [];
1387
+ return exclude.length > 0 ? this.andNot([...exclude], options) : this.iterate();
1388
+ }
1389
+ if (live.length !== others.length) return this.union(live, options);
1390
+ const engine = this.combineEngine([this, ...others, ...options?.exclude ?? []]) ?? this.engine;
1391
+ return engine.union([this.ref, ...others.map((o) => o.ref)], this.refsIn(options));
706
1392
  }
707
- /** Materialize `this ∪ others…` (minus `exclude`) **into** `dest`. **Not atomic** — see {@link addMany}. */
708
- unionInto(dest, others, options) {
1393
+ /** Materialize `this ∪ others…` (minus `exclude`) as a **new generation of `dest`** — see {@link intersectInto}. */
1394
+ async unionInto(dest, others, options) {
1395
+ this.refuseIfExpired("unionInto", dest, [...others, ...options?.exclude ?? []]);
709
1396
  return this.timed(
710
1397
  "unionInto",
711
- () => this.engine.unionInto(
712
- dest.ref,
713
- [this.ref, ...others.map((o) => o.ref)],
714
- this.refsIn(options)
715
- )
1398
+ () => this.materialize(dest.ref, this.union(others, options), "unionInto", options)
716
1399
  );
717
1400
  }
718
1401
  /**
@@ -727,44 +1410,52 @@ var Segment = class {
727
1410
  * the suppression folds into the same pass rather than materializing an intermediate segment first.
728
1411
  */
729
1412
  andNot(excludes, options) {
730
- return this.engine.andNot(
1413
+ if (this.expired()) return EMPTY_IDS;
1414
+ const liveExcludes = excludes.filter((e) => !e.expired());
1415
+ if (liveExcludes.length === 0 && excludes.length > 0) return this.iterate();
1416
+ if (liveExcludes.length !== excludes.length) return this.andNot(liveExcludes, options);
1417
+ const engine = this.combineEngine([this, ...excludes]) ?? this.engine;
1418
+ return engine.andNot(
731
1419
  this.ref,
732
1420
  excludes.map((o) => o.ref),
733
1421
  options
734
1422
  );
735
1423
  }
736
- /** Materialize `this \ (excludes…)` **into** `dest`. **Not atomic** — see {@link addMany}. */
737
- andNotInto(dest, excludes, options) {
1424
+ /** Materialize `this \ (excludes…)` as a **new generation of `dest`** — see {@link intersectInto}. */
1425
+ async andNotInto(dest, excludes, options) {
1426
+ this.refuseIfExpired("andNotInto", dest, excludes);
738
1427
  return this.timed(
739
1428
  "andNotInto",
740
- () => this.engine.andNotInto(
741
- dest.ref,
742
- this.ref,
743
- excludes.map((o) => o.ref),
744
- options
745
- )
1429
+ () => this.materialize(dest.ref, this.andNot(excludes, options), "andNotInto", options)
746
1430
  );
747
1431
  }
748
1432
  /**
749
- * Grounded cost report for this segment (Phase 5b): storage cost from its **real** `.crbm` size (exact,
750
- * no payload reads); request cost from the supplied `workload` rates. A segment with no Cold generation
751
- * reports zero storage. See {@link CostReport} — it always includes a verdict (incl. the lose-zone).
1433
+ * Grounded cost report for this segment: storage cost from its **real** `.crbm` size (exact, no payload
1434
+ * reads); request cost from the supplied `workload` rates. A segment with no Storage generation reports zero
1435
+ * storage. See {@link CostReport} — it always includes a verdict (incl. the lose-zone).
752
1436
  */
753
1437
  async costReport(options) {
754
- const canMeasure = this.engine.supportsColdSize;
1438
+ const canMeasure = this.engine.supportsStorageSize;
755
1439
  const size = canMeasure ? await this.engine.segmentSize(this.ref) : null;
756
1440
  return groundedReport({
757
- coldBytes: size?.sizeBytes ?? 0,
1441
+ storageBytes: size?.sizeBytes ?? 0,
758
1442
  grounded: canMeasure,
759
1443
  workload: options?.workload,
760
- topology: options?.topology,
761
1444
  pricing: options?.pricing,
762
- extraNotes: canMeasure ? void 0 : ["cold source has no sizeOf() \u2014 storage not measured, reported as $0."]
1445
+ extraNotes: canMeasure ? void 0 : ["storage source has no sizeOf() \u2014 storage not measured, reported as $0."]
763
1446
  });
764
1447
  }
765
1448
  };
766
- var VERSION = "0.8.2";
767
-
768
- export { CloudRoaring, SafeBitmap, Segment, VERSION, bulkLoadCrbmGeneration, compactSegment, roaringCodec, runCompactionCycle, runExport };
1449
+ var VERSION = "0.10.0";
1450
+ export {
1451
+ CloudRoaring,
1452
+ SafeBitmap,
1453
+ Segment,
1454
+ VERSION,
1455
+ bulkLoadCrbmGeneration,
1456
+ eraseIdFromSegment,
1457
+ loadSegment,
1458
+ roaringCodec,
1459
+ runExport
1460
+ };
769
1461
  //# sourceMappingURL=index.js.map
770
- //# sourceMappingURL=index.js.map