@cloudbitmaps/roaring 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6,44 +6,251 @@ import {
6
6
  CrbmStorageChunkSource,
7
7
  DEFAULT_BUDGET,
8
8
  DEFAULT_RETRY_POLICY,
9
+ withRetry,
9
10
  isStorageBackend,
10
11
  NOOP_METRICS,
11
12
  RetryingStorageChunkSource,
12
13
  SegmentEngine,
13
14
  UnsupportedError,
14
- ValidationError,
15
+ ValidationError as ValidationError2,
15
16
  WriteConflictError,
16
17
  MIN_EXPIRES_AT_MS,
18
+ checkBudget,
17
19
  collectWithinBudget,
18
20
  excludingReservedRows,
19
21
  dropSegment,
22
+ eraseIdFromSegment,
20
23
  estimateCost,
21
24
  groundedReport,
25
+ loadSegment,
22
26
  mapWithConcurrency,
23
27
  resolveBudget,
24
28
  resolvePerOpBudget,
25
29
  retireExpired,
26
30
  runConsistencyCheck,
27
- runExport as runExport2,
31
+ runExport,
28
32
  setSegmentRetention,
29
33
  clearSegmentRetention,
30
34
  getSegmentRetention,
31
35
  safeMetrics,
32
- splitId,
33
- validateSegmentRef
36
+ splitId
34
37
  } from "@cloudbitmaps/core";
38
+ import { validateSegmentRef } from "@cloudbitmaps/core/driver-kit";
35
39
 
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";
40
+ // src/open-charging-storage.ts
41
+ var OpenChargingStorage = class {
42
+ /**
43
+ * @param inner the driver the erasure would otherwise use
44
+ * @param current the generation the segment's row named when it was listed, or `null` when it named none
45
+ * @param charge called once per charged open; it throws to refuse
46
+ */
47
+ constructor(inner, current, charge) {
48
+ this.inner = inner;
49
+ this.charge = charge;
50
+ if (current !== null) this.free.add(current);
51
+ }
52
+ inner;
53
+ charge;
54
+ free = /* @__PURE__ */ new Set();
55
+ capabilities() {
56
+ return this.inner.capabilities();
57
+ }
58
+ async putImmutable(key, write) {
59
+ const written = await this.inner.putImmutable(key, write);
60
+ this.free.add(key.generation);
61
+ return written;
62
+ }
63
+ getRange(key, offset, length) {
64
+ return this.inner.getRange(key, offset, length);
65
+ }
66
+ /** One `getTail` is one open: a reader takes the footer and the index from it, and reads chunks with `getRange`. */
67
+ async getTail(key, maxBytes) {
68
+ if (!this.free.has(key.generation)) this.charge();
69
+ return this.inner.getTail(key, maxBytes);
70
+ }
71
+ delete(key) {
72
+ return this.inner.delete(key);
73
+ }
74
+ list(ref) {
75
+ return this.inner.list(ref);
76
+ }
77
+ };
78
+
79
+ // src/index.ts
80
+ import { listGenerations, rollbackSegment } from "@cloudbitmaps/core";
81
+ import { listSegments, segmentExists } from "@cloudbitmaps/core";
82
+
83
+ // src/reserved-namespace.ts
84
+ import { ValidationError } from "@cloudbitmaps/core";
85
+ var RESERVED_NAMESPACE_PREFIX = "cbm.due.";
86
+ function refuseReservedNamespace(namespace) {
87
+ if (namespace !== void 0 && namespace.startsWith(RESERVED_NAMESPACE_PREFIX)) {
88
+ throw new ValidationError(
89
+ `namespace "${namespace}" is reserved: names starting with "${RESERVED_NAMESPACE_PREFIX}" hold the library's own bookkeeping rows, which every fleet-wide scan skips, so a segment there would be invisible to an erasure, a consistency check, an export and a retention sweep. Choose another namespace.`
90
+ );
91
+ }
92
+ }
43
93
 
44
94
  // src/roaring-codec.ts
45
95
  import roaring from "roaring";
96
+ import { IntegrityError as IntegrityError2 } from "@cloudbitmaps/core";
97
+
98
+ // src/portable/layout.ts
46
99
  import { IntegrityError } from "@cloudbitmaps/core";
100
+ var SERIAL_COOKIE_NO_RUNCONTAINER = 12346;
101
+ var SERIAL_COOKIE = 12347;
102
+ var NO_OFFSET_THRESHOLD = 4;
103
+ var ARRAY_MAX_CARDINALITY = 4096;
104
+ var BITMAP_CONTAINER_BYTES = 8192;
105
+ var MAX_LOW = 65535;
106
+ var ARRAY = 0;
107
+ var BITMAP = 1;
108
+ var RUN = 2;
109
+ function overrun(bytes, offset, length, what) {
110
+ return new IntegrityError(
111
+ `portable roaring: ${what} needs bytes [${offset}, ${offset + length}) of a ${bytes.byteLength}-byte buffer`
112
+ );
113
+ }
114
+ function checkPortableLayout(bytes) {
115
+ walk(bytes, null);
116
+ }
117
+ function walk(bytes, out) {
118
+ const length = bytes.byteLength;
119
+ if (length === 0) return;
120
+ const view = new DataView(bytes.buffer, bytes.byteOffset, length);
121
+ if (length < 4) throw overrun(bytes, 0, 4, "the cookie");
122
+ const cookie = view.getUint32(0, true);
123
+ let count;
124
+ let runFlagsAt = -1;
125
+ let pos;
126
+ if (cookie === SERIAL_COOKIE_NO_RUNCONTAINER) {
127
+ if (length < 8) throw overrun(bytes, 4, 4, "the container count");
128
+ count = view.getUint32(4, true);
129
+ pos = 8;
130
+ } else if ((cookie & 65535) === SERIAL_COOKIE) {
131
+ count = (cookie >>> 16) + 1;
132
+ runFlagsAt = 4;
133
+ const flagBytes = count + 7 >>> 3;
134
+ if (runFlagsAt + flagBytes > length) {
135
+ throw overrun(bytes, runFlagsAt, flagBytes, "the run-container bitmap");
136
+ }
137
+ pos = runFlagsAt + flagBytes;
138
+ } else {
139
+ throw new IntegrityError(
140
+ `portable roaring: unrecognized cookie 0x${cookie.toString(16)}, not a portable-format bitmap`
141
+ );
142
+ }
143
+ const descriptiveAt = pos;
144
+ if (pos + count * 4 > length) throw overrun(bytes, pos, count * 4, "the descriptive header");
145
+ pos += count * 4;
146
+ let offsetsAt = -1;
147
+ if (runFlagsAt < 0 || count >= NO_OFFSET_THRESHOLD) {
148
+ if (pos + count * 4 > length) throw overrun(bytes, pos, count * 4, "the offset header");
149
+ offsetsAt = pos;
150
+ pos += count * 4;
151
+ }
152
+ let previousKey = -1;
153
+ for (let i = 0; i < count; i++) {
154
+ const key = view.getUint16(descriptiveAt + i * 4, true);
155
+ if (key <= previousKey) {
156
+ throw new IntegrityError(
157
+ `portable roaring: container keys are not strictly ascending (${previousKey} then ${key} at index ${i})`
158
+ );
159
+ }
160
+ previousKey = key;
161
+ const cardinality = view.getUint16(descriptiveAt + i * 4 + 2, true) + 1;
162
+ const isRun = runFlagsAt >= 0 && (bytes[runFlagsAt + (i >>> 3)] & 1 << (i & 7)) !== 0;
163
+ const kind = isRun ? RUN : cardinality > ARRAY_MAX_CARDINALITY ? BITMAP : ARRAY;
164
+ if (offsetsAt >= 0) {
165
+ const stated = view.getUint32(offsetsAt + i * 4, true);
166
+ if (stated !== pos) {
167
+ throw new IntegrityError(
168
+ `portable roaring: the offset header puts container ${i} at byte ${stated}, but it starts at ${pos}`
169
+ );
170
+ }
171
+ }
172
+ let size;
173
+ if (kind === ARRAY) {
174
+ size = cardinality * 2;
175
+ if (pos + size > length) throw overrun(bytes, pos, size, `container ${i} (array)`);
176
+ checkArray(view, pos, cardinality, i);
177
+ } else if (kind === BITMAP) {
178
+ size = BITMAP_CONTAINER_BYTES;
179
+ if (pos + size > length) throw overrun(bytes, pos, size, `container ${i} (bitmap)`);
180
+ const bits = popcount(view, pos);
181
+ if (bits !== cardinality) {
182
+ throw new IntegrityError(
183
+ `portable roaring: container ${i} (bitmap) holds ${bits} values, but its header says ${cardinality}`
184
+ );
185
+ }
186
+ } else {
187
+ if (pos + 2 > length) throw overrun(bytes, pos, 2, `container ${i} (run count)`);
188
+ const runs = view.getUint16(pos, true);
189
+ size = 2 + runs * 4;
190
+ if (pos + size > length) throw overrun(bytes, pos, size, `container ${i} (run)`);
191
+ const covered = checkRuns(view, pos + 2, runs, i);
192
+ if (covered !== cardinality) {
193
+ throw new IntegrityError(
194
+ `portable roaring: container ${i} (run) covers ${covered} values, but its header says ${cardinality}`
195
+ );
196
+ }
197
+ }
198
+ out?.push({ key, cardinality, kind, offset: pos });
199
+ pos += size;
200
+ }
201
+ }
202
+ function checkArray(view, at, cardinality, index) {
203
+ let previous = -1;
204
+ let p = at;
205
+ const end = at + cardinality * 2;
206
+ for (; p + 4 <= end; p += 4) {
207
+ const pair = view.getUint32(p, true);
208
+ const first = pair & 65535;
209
+ const second = pair >>> 16;
210
+ if (first <= previous || second <= first) throw unordered(index, (p - at) / 2);
211
+ previous = second;
212
+ }
213
+ if (p < end && view.getUint16(p, true) <= previous) throw unordered(index, (p - at) / 2);
214
+ }
215
+ function unordered(index, element) {
216
+ return new IntegrityError(
217
+ `portable roaring: container ${index} (array) is not strictly ascending at or after element ${element}`
218
+ );
219
+ }
220
+ function checkRuns(view, at, runs, index) {
221
+ let covered = 0;
222
+ let lowest = 0;
223
+ for (let r = 0; r < runs; r++) {
224
+ const run = view.getUint32(at + r * 4, true);
225
+ const start = run & 65535;
226
+ const last = start + (run >>> 16);
227
+ if (start < lowest) {
228
+ throw new IntegrityError(
229
+ `portable roaring: container ${index} (run) has run ${r} out of order, overlapping or touching the one before`
230
+ );
231
+ }
232
+ if (last > MAX_LOW) {
233
+ throw new IntegrityError(
234
+ `portable roaring: container ${index} (run) has run ${r} running past the end of the container`
235
+ );
236
+ }
237
+ covered += last - start + 1;
238
+ lowest = last + 2;
239
+ }
240
+ return covered;
241
+ }
242
+ function popcount(view, at) {
243
+ let bits = 0;
244
+ for (let p = at, end = at + BITMAP_CONTAINER_BYTES; p < end; p += 4) {
245
+ let w = view.getUint32(p, true);
246
+ w -= w >>> 1 & 1431655765;
247
+ w = (w & 858993459) + (w >>> 2 & 858993459);
248
+ bits += Math.imul(w + (w >>> 4) & 252645135, 16843009) >>> 24;
249
+ }
250
+ return bits;
251
+ }
252
+
253
+ // src/roaring-codec.ts
47
254
  var { RoaringBitmap32, SerializationFormat, DeserializationFormat } = roaring;
48
255
  var SafeBitmap = class _SafeBitmap {
49
256
  bitmap;
@@ -57,18 +264,24 @@ var SafeBitmap = class _SafeBitmap {
57
264
  return new _SafeBitmap(new RoaringBitmap32(values));
58
265
  }
59
266
  /**
60
- * Validate size, then deserialize with the **portable** (validated) format.
61
- * Throws `IntegrityError` if the input exceeds `maxBytes` or fails to decode — the
62
- * native addon is never handed unbounded or unsafe-format input.
267
+ * Cap the size, check the structure, then deserialize with the **portable** format.
268
+ * Throws `IntegrityError` if the input exceeds `maxBytes`, is not a well-formed portable bitmap, or fails to
269
+ * decode — the native addon is never handed unbounded, malformed or unsafe-format input.
270
+ *
271
+ * The structural check is what makes the decoded bitmap's answers mean anything. The native deserializer
272
+ * bounds its reads and checks nothing else, so bytes of the right length and the wrong shape (containers or
273
+ * values out of order, overlapping runs, a cardinality that disagrees with the bits) decode into a bitmap
274
+ * whose `maximum()`, `has()` and `size` are wrong, and some of those shapes crash the process when used.
63
275
  */
64
276
  static safeDeserialize(bytes, maxBytes) {
65
277
  if (bytes.length > maxBytes) {
66
- throw new IntegrityError(`serialized bitmap is ${bytes.length}B, exceeds cap ${maxBytes}B`);
278
+ throw new IntegrityError2(`serialized bitmap is ${bytes.length}B, exceeds cap ${maxBytes}B`);
67
279
  }
280
+ checkPortableLayout(bytes);
68
281
  try {
69
282
  return new _SafeBitmap(RoaringBitmap32.deserialize(bytes, DeserializationFormat.portable));
70
283
  } catch (err) {
71
- throw new IntegrityError(`failed to deserialize bitmap: ${err.message}`);
284
+ throw new IntegrityError2(`failed to deserialize bitmap: ${err.message}`);
72
285
  }
73
286
  }
74
287
  serialize() {
@@ -96,8 +309,10 @@ var SafeBitmap = class _SafeBitmap {
96
309
  this.bitmap.runOptimize();
97
310
  }
98
311
  /**
99
- * Largest value, or `undefined` when empty. O(1) — roaring reads it off the container index, so the engine's
100
- * per-chunk range assertion costs one call per chunk rather than a walk per id.
312
+ * Largest value, or `undefined` when empty. O(1) — roaring reads it off the last container, so the engine's
313
+ * per-chunk range assertion costs one call per chunk rather than a walk per id. The last container holds the
314
+ * largest value only because {@link SafeBitmap.safeDeserialize} refuses bytes whose containers or values are
315
+ * out of order.
101
316
  */
102
317
  maximum() {
103
318
  return this.bitmap.isEmpty ? void 0 : this.bitmap.maximum();
@@ -172,7 +387,7 @@ var SystemClock = class {
172
387
  * A long CPU-bound loop in `core/` (bulk-load is the one that matters) periodically hands the loop back so a
173
388
  * co-resident HTTP server keeps answering. Measured on the **synthetic yield-primitive benchmark** — a
174
389
  * 61,035-iteration loop yielding every 1,024, isolating the primitive rather than timing a real load (the
175
- * end-to-end figures are 450 ms → 19 ms; see `cooperative.ts`, which lists all three experiments):
390
+ * end-to-end figures are 519 ms → 24 ms; see `cooperative.ts`, which lists all three experiments):
176
391
  *
177
392
  * ```text
178
393
  * no yield 568 ms wall 568.0 ms worst event-loop gap
@@ -196,65 +411,54 @@ var SystemClock = class {
196
411
  }
197
412
  };
198
413
 
199
- // src/codec-bound.ts
200
- var bulkLoadCrbmGeneration = (driver, key, ids, options = {}) => coreBulkLoad(driver, key, ids, {
201
- ...options,
202
- codec: options.codec ?? roaringCodec,
203
- clock: options.clock ?? new SystemClock()
204
- });
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
- );
218
-
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
- ];
414
+ // src/option-keys.ts
415
+ var OPTION_KEYS = {
416
+ top: ["storage", "cache", "encryption", "retry", "metrics", "budget", "seams"],
417
+ cache: ["maxChunks", "ttlMs", "genTtlMs", "readerMax", "readerMaxBytes"],
418
+ encryption: ["keystore", "required"],
419
+ retry: ["maxAttempts", "baseDelayMs", "maxDelayMs", "backoffFactor", "jitter", "onRetry"],
420
+ budget: ["maxRequests"],
421
+ seams: ["clock", "rng"]
422
+ };
255
423
 
256
424
  // src/index.ts
257
- export * from "@cloudbitmaps/core";
425
+ import {
426
+ MemoryStorage,
427
+ LocalFsStorage,
428
+ CrbmStorageChunkSource as CrbmStorageChunkSource2,
429
+ destroySegment,
430
+ eraseNamespace,
431
+ excludingReservedRows as excludingReservedRows2,
432
+ readRetentionPolicy,
433
+ MIN_EXPIRES_AT_MS as MIN_EXPIRES_AT_MS2,
434
+ InProcessKeystore,
435
+ NodeAead,
436
+ aadFor,
437
+ CloudRoaringError,
438
+ ValidationError as ValidationError3,
439
+ WriteConflictError as WriteConflictError2,
440
+ IntegrityError as IntegrityError3,
441
+ NotFoundError,
442
+ UnsupportedError as UnsupportedError2,
443
+ CapabilityError,
444
+ TransientError,
445
+ KeyUnavailableError,
446
+ BudgetExceededError,
447
+ isCloudRoaringError,
448
+ isWriteConflictError,
449
+ isTransientError,
450
+ isNotFoundError,
451
+ isIntegrityError,
452
+ isValidationError,
453
+ CrbmReader,
454
+ BufferReader,
455
+ CountingMetricsSink,
456
+ RecordingAuditSink,
457
+ DEFAULT_RETRY_POLICY as DEFAULT_RETRY_POLICY2,
458
+ AWS_US_EAST_1_ONDEMAND,
459
+ ELASTICACHE_REDIS_US_EAST_1_ONDEMAND,
460
+ ONE_REDIS_HA_CLUSTER
461
+ } from "@cloudbitmaps/core";
258
462
  var SystemRng = class {
259
463
  /** A float in `[0, 1)`. Jitter only — never key material, never anything a caller can observe. */
260
464
  next() {
@@ -265,12 +469,13 @@ var DEFAULT_CACHE_MAX_CHUNKS = 1024;
265
469
  var DEFAULT_ADMIN_CONCURRENCY = 8;
266
470
  function validateConcurrency(concurrency) {
267
471
  if (concurrency !== void 0 && (!Number.isInteger(concurrency) || concurrency < 1)) {
268
- throw new ValidationError(`concurrency must be a positive integer; got ${concurrency}`);
472
+ throw new ValidationError2(`concurrency must be a positive integer; got ${concurrency}`);
269
473
  }
270
474
  }
271
475
  function requireScope(options, op) {
476
+ refuseReservedNamespace(options.namespace);
272
477
  if (options.namespace === void 0 && options.allNamespaces !== true) {
273
- throw new ValidationError(
478
+ throw new ValidationError2(
274
479
  `${op} scans the global id space across all namespaces \u2014 pass an explicit \`namespace\`, or \`{ allNamespaces: true }\` to intentionally sweep the whole fleet`
275
480
  );
276
481
  }
@@ -278,7 +483,7 @@ function requireScope(options, op) {
278
483
  function resolveStorageSource(options, clock) {
279
484
  const storage = options.storage;
280
485
  if (storage === null || typeof storage !== "object") {
281
- throw new ValidationError(
486
+ throw new ValidationError2(
282
487
  "`storage` must be a StorageBackend, an IStorageDriver, or a StorageChunkSource"
283
488
  );
284
489
  }
@@ -288,15 +493,15 @@ function resolveStorageSource(options, clock) {
288
493
  const hasPutImmutable = typeof storage.putImmutable === "function";
289
494
  const isBackend = isStorageBackend(storage);
290
495
  if (isBackend && (hasGetChunk || hasPutImmutable)) {
291
- throw new ValidationError(
496
+ throw new ValidationError2(
292
497
  `\`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
498
  );
294
499
  }
295
500
  const hasStorageHalf = isObject(asBackend.storage);
296
501
  const hasRegistryHalf = isObject(asBackend.registry);
297
502
  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 })`."
503
+ throw new ValidationError2(
504
+ "`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, use a backend class \u2014 S3Storage, GcsStorage, AzureBlobStorage, LocalFsStorage or MemoryStorage \u2014 or, to pair a driver of your own with a registry, brand the pair with `brandAsBackend` from `@cloudbitmaps/core/driver-kit`."
300
505
  );
301
506
  }
302
507
  if (!isBackend && !hasGetChunk && !hasPutImmutable && hasStorageHalf && hasRegistryHalf) {
@@ -304,12 +509,12 @@ function resolveStorageSource(options, clock) {
304
509
  const registryOk = typeof asBackend.registry.compareAndSwap === "function";
305
510
  if (!storageOk || !registryOk) {
306
511
  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 })`."
512
+ throw new ValidationError2(
513
+ "`storage` looks like a backend but its " + bad + " \u2014 use a backend class, or brand a pair of drivers with `brandAsBackend` from `@cloudbitmaps/core/driver-kit`."
309
514
  );
310
515
  }
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."
516
+ throw new ValidationError2(
517
+ "`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. A driver author who genuinely wants halves of their own \u2014 an instrumented driver, a registry in a database you already run \u2014 brands the pair with `brandAsBackend` from `@cloudbitmaps/core/driver-kit`, which is taking on that they agree."
313
518
  );
314
519
  }
315
520
  if (isBackend) {
@@ -329,18 +534,18 @@ function resolveStorageSource(options, clock) {
329
534
  };
330
535
  }
331
536
  if (hasGetChunk && hasPutImmutable) {
332
- throw new ValidationError(
537
+ throw new ValidationError2(
333
538
  "`storage` exposes both `getChunk` and `putImmutable` \u2014 ambiguous; pass a StorageBackend, an IStorageDriver, or a StorageChunkSource, not a hybrid"
334
539
  );
335
540
  }
336
541
  if (!hasGetChunk && !hasPutImmutable) {
337
- throw new ValidationError(
542
+ throw new ValidationError2(
338
543
  "`storage` must be a StorageBackend, an IStorageDriver, or a StorageChunkSource"
339
544
  );
340
545
  }
341
546
  if (hasGetChunk) {
342
547
  if (options.encryption?.keystore !== void 0 || options.encryption?.required === true) {
343
- throw new ValidationError(
548
+ throw new ValidationError2(
344
549
  "keystore/requireEncryption apply only when the store builds its own read path; configure them on the StorageChunkSource you passed instead"
345
550
  );
346
551
  }
@@ -360,9 +565,20 @@ function resolveStorageSource(options, clock) {
360
565
  registry: void 0
361
566
  };
362
567
  }
568
+ var NOT_A_GROUP = {
569
+ Null: "null",
570
+ Array: "an array",
571
+ Map: "a Map",
572
+ Set: "a Set",
573
+ Boolean: "a boxed boolean",
574
+ Number: "a boxed number",
575
+ String: "a boxed string"
576
+ };
363
577
  var CloudRoaring = class _CloudRoaring {
364
578
  engine;
365
579
  cache;
580
+ /** The store's retry options, for a pinned engine to read through as the store's own does; undefined when off. */
581
+ retryOptions;
366
582
  crbmSource;
367
583
  clock;
368
584
  metrics;
@@ -376,39 +592,49 @@ var CloudRoaring = class _CloudRoaring {
376
592
  /** Resolved store-level per-op budget (null = disabled); the admin scans use it, with a per-op override. */
377
593
  budget;
378
594
  /**
379
- * Refuse an option that moved into a group, naming where it went.
595
+ * Refuse any option key the store does not take, at the top level or inside a group, naming each one.
380
596
  *
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.
597
+ * An option the store ignored would do nothing and look as if it had — a typo'd `cache.maxChunk`, a key from
598
+ * another library's config spread into this one — so every key is checked against {@link OPTION_KEYS}.
383
599
  */
384
- static rejectMovedOptions(options) {
600
+ static rejectUnknownOptions(options) {
385
601
  if (options === null || options === void 0 || typeof options !== "object") {
386
- throw new ValidationError(
602
+ throw new ValidationError2(
387
603
  "CloudRoaring needs an options object with a `storage` key \u2014 got " + (options === null ? "null" : typeof options)
388
604
  );
389
605
  }
390
606
  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`
607
+ const unknown = [];
608
+ for (const key of Object.keys(bag)) {
609
+ if (!OPTION_KEYS.top.includes(key)) unknown.push({ group: null, key });
610
+ }
611
+ const groups = Object.keys(OPTION_KEYS).filter((g) => g !== "top");
612
+ for (const group of groups) {
613
+ const value = bag[group];
614
+ if (value === void 0) continue;
615
+ const offOk = group === "retry" || group === "budget";
616
+ if (value === false && offOk) continue;
617
+ const tag = Object.prototype.toString.call(value).slice(8, -1);
618
+ if (typeof value !== "object" || value === null || NOT_A_GROUP[tag] !== void 0) {
619
+ const got = typeof value !== "object" ? typeof value : NOT_A_GROUP[tag];
620
+ throw new ValidationError2(
621
+ `CloudRoaring's \`${group}\` must be an object${offOk ? " or `false`" : ""} \u2014 got ${got}`
622
+ );
623
+ }
624
+ const known = OPTION_KEYS[group];
625
+ for (const key of Object.keys(value)) if (!known.includes(key)) unknown.push({ group, key });
626
+ }
627
+ if (unknown.length === 0) return;
628
+ const takes = (keys) => keys.map((k) => `\`${k}\``).join(", ");
629
+ const named = [...new Set(unknown.map((u) => u.group))];
630
+ throw new ValidationError2(
631
+ `CloudRoaring does not take ${takes(unknown.map((u) => u.group === null ? u.key : `${u.group}.${u.key}`))}. ` + named.map(
632
+ (g) => g === null ? `The store takes ${takes(OPTION_KEYS.top)}.` : `\`${g}\` takes ${takes(OPTION_KEYS[g])}.`
633
+ ).join(" ") + " https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/api-reference.md#build-a-store--new-cloudroaringoptions"
408
634
  );
409
635
  }
410
636
  constructor(options) {
411
- _CloudRoaring.rejectMovedOptions(options);
637
+ _CloudRoaring.rejectUnknownOptions(options);
412
638
  const clock = options.seams?.clock ?? new SystemClock();
413
639
  const rng = options.seams?.rng ?? new SystemRng();
414
640
  const metrics = safeMetrics(options.metrics ?? NOOP_METRICS);
@@ -444,6 +670,7 @@ var CloudRoaring = class _CloudRoaring {
444
670
  }
445
671
  };
446
672
  storage = new RetryingStorageChunkSource(storage, retryOpts);
673
+ this.retryOptions = retryOpts;
447
674
  }
448
675
  this.budget = resolveBudget(options.budget, DEFAULT_BUDGET);
449
676
  const deps = {
@@ -470,17 +697,16 @@ var CloudRoaring = class _CloudRoaring {
470
697
  * the store to have been constructed with a **backend**, which supplies both halves: an `IStorageDriver` to
471
698
  * write generations through (a pre-built `StorageChunkSource` has none) and the registry holding the pointer
472
699
  * every write publishes.
473
- * Out-of-process callers use the free functions with explicit deps.
474
700
  */
475
701
  lifecycleDeps(op) {
476
702
  if (this.storageDriver === void 0) {
477
703
  throw new UnsupportedError(
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.`
704
+ `${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. Build the store on one of those backend classes to write.`
479
705
  );
480
706
  }
481
707
  if (this.registry === void 0) {
482
708
  throw new UnsupportedError(
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.`
709
+ `${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 separate \`registry\` option: a backend carries it.`
484
710
  );
485
711
  }
486
712
  return {
@@ -497,77 +723,86 @@ var CloudRoaring = class _CloudRoaring {
497
723
  segment(name, options) {
498
724
  const ref = { segment: name, namespace: options?.namespace };
499
725
  validateSegmentRef(ref);
726
+ refuseReservedNamespace(ref.namespace);
500
727
  const expiresAt = options?.expiresAt;
501
728
  if (expiresAt !== void 0) {
502
729
  if (!Number.isFinite(expiresAt) || !Number.isInteger(expiresAt) || expiresAt < MIN_EXPIRES_AT_MS) {
503
- throw new ValidationError(
730
+ throw new ValidationError2(
504
731
  `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
732
  );
506
733
  }
507
734
  }
508
- return new Segment(
509
- this.engine,
735
+ return makeSegment({
736
+ engine: this.engine,
510
737
  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),
738
+ clock: this.clock,
739
+ metrics: this.metrics,
740
+ materialize: (dest, ids, op, options2) => this.materialize(dest, ids, op, options2),
741
+ pinned: (r, e) => this.pinSegment(r, e),
742
+ combineEngine: (handles) => this.engineForCombine(handles),
516
743
  expiresAt
517
- );
744
+ });
518
745
  }
519
746
  /**
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
747
+ * Write `ids` as a **new generation of `dest`** and publish it — the shared body of the `*Into` verbs. An `*Into`
748
+ * materialisation is a load, and publishes the same way (hard invariant 1): every load that finds a row fences its
749
+ * publish on the row's token; a guarded load (the default, since the empty refusal reads the current generation)
750
+ * also fences on the pointer it judged (`expectFrom`), and one that found no row fences on that absence instead.
751
+ * Only an unguarded load (`allowEmpty: true` and no `guard.minRetained`) onto a segment with no row publishes bare
752
+ * forward-only. The destination's previous generation stays readable until the publish lands (readers re-resolve
522
753
  * within `cache.genTtlMs`).
523
754
  *
524
755
  * This routes through `loadSegment` rather than writing the generation itself, and that is the whole point
525
756
  * of it. A materialisation is a load whose ids happen to come from a combine instead of from upstream, so
526
757
  * 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:
758
+ * guard runs while the old generation is still authoritative, the publish is fenced as above, and a refused
759
+ * object is reclaimed only when a re-read finds the row unchanged (the same token) or gone (hard invariant 1:
530
760
  * deleting it after a purge-and-recreate would put a live row over a missing generation).
531
761
  *
532
762
  * 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
763
+ * trips, and `IStorageDriver` has no conditional delete to make them one. the collection a load runs carries
764
+ * the same residual. The failure it leaves is an orphan object, which costs storage until
535
765
  * something collects it — deliberately the cheaper side of the trade.
536
766
  *
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.
767
+ * Written and published in one step, with no guard, an empty combine — a typo'd operand, an `exclude` that
768
+ * swallowed everything, an operand that had not loaded yet — would silently replace `dest` with an empty
769
+ * generation: the same failure `load()`'s guard exists to prevent, on the same data, reachable without passing
770
+ * any option at all.
541
771
  *
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.
772
+ * **A lost race throws.** `loadSegment` reports one as `reason: 'superseded'`; the `*Into` verbs throw
773
+ * {@link WriteConflictError} for it, so a caller can `catch (WriteConflictError)`. That one refusal is
774
+ * translated into the throw, and `MaterializeResult.reason` never carries it.
546
775
  *
547
776
  * **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
777
+ * before you write the retry. `'superseded'` covers five different causes — the write-once PUT collided,
778
+ * the pointer moved, a row appeared where the load found none, the row's token changed, the row was purged —
779
+ * and only the first three are the "somebody beat us" the name suggests. A token can also change on a write
780
+ * that is not a supersession at all, such as a `setRetention` on the destination. On top of that, the collection pass that runs AFTER a
552
781
  * successful publish can raise the same error. So: treat it as "re-read the destination and decide",
553
782
  * never as "the write did not happen".
554
783
  *
555
- * **And it still collects nothing**, unlike `load()`. See the `keep` default below.
784
+ * **And by default it collects nothing**, unlike `load()`. See the `keep` default below.
556
785
  */
557
786
  async materialize(dest, ids, op, options) {
558
787
  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
- });
788
+ let result;
789
+ try {
790
+ result = await loadSegment(dest, ids, deps, {
791
+ ...options?.allowEmpty === void 0 ? {} : { allowEmpty: options.allowEmpty },
792
+ ...options?.guard === void 0 ? {} : { guard: options.guard },
793
+ // COLLECT NOTHING by default, which `loadSegment` does not — it keeps a grace window of 1 and deletes
794
+ // the rest. By default a materialisation collects nothing: the guide states "**It deletes nothing**,
795
+ // unlike `load()`. The destination's previous generations stay in the bucket until you collect them", and
796
+ // the ownership table
797
+ // puts that call on the operator. Inheriting `load()`'s collection would silently delete the
798
+ // generations an operator's recovery story depends on — `rollbackSegment` refuses a collected target —
799
+ // as a side effect of adding a guard whose entire purpose is preventing data loss. Opt in with `keep`.
800
+ keep: options?.keep ?? KEEP_EVERY_GENERATION,
801
+ ...options?.audit === void 0 ? {} : { audit: options.audit }
802
+ });
803
+ } finally {
804
+ this.engine.invalidate(dest);
805
+ }
571
806
  const reason = result.reason;
572
807
  if (reason === "superseded") {
573
808
  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`;
@@ -590,10 +825,16 @@ var CloudRoaring = class _CloudRoaring {
590
825
  * **Subject access (GDPR Art. 15 / CCPA right-to-know): which segments is this id a member of?**
591
826
  *
592
827
  * 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).
828
+ * drivers to re-pass. Complete only over registered segments (every loaded segment has a row, so build the
829
+ * store on the backend the loads used). There is deliberately **no `id → segments` reverse index** — that would
830
+ * tax every load for a rare request; this admin scan is `O(registered segments)` and touches no hot path. Needs
831
+ * a storage backend (throws {@link UnsupportedError} otherwise).
832
+ *
833
+ * **Current as of the registry row, not as of the reader's cache.** Each segment's resolved generation is
834
+ * compared with the row the scan listed, and a segment whose cached generation differs is re-resolved before the
835
+ * read, so a load or an erasure from another process shows at once — whatever `cache.genTtlMs` is, `0` included.
836
+ * The comparison is on the generation **and** the row's token, so a segment retired, purged and loaded again from
837
+ * generation 0 while this store held its old generation 0 is told apart too.
597
838
  */
598
839
  async subjectReport(id, options = {}) {
599
840
  const registry = this.requireRegistry("subjectReport");
@@ -602,7 +843,7 @@ var CloudRoaring = class _CloudRoaring {
602
843
  const budget = resolvePerOpBudget(options.budget, this.budget);
603
844
  splitId(id);
604
845
  const recs = await collectWithinBudget(
605
- // A subject cannot be in a coordination row, and charging this request's budget for them would refuse a
846
+ // A subject cannot be in a due-index row, and charging this request's budget for them would refuse a
606
847
  // GDPR Art. 15 report for a reason unrelated to the subject.
607
848
  excludingReservedRows(registry.list(options.namespace)),
608
849
  budget,
@@ -614,6 +855,11 @@ var CloudRoaring = class _CloudRoaring {
614
855
  async (rec) => {
615
856
  if (rec.status === "destroyed") return null;
616
857
  const ref = { segment: rec.segment, namespace: rec.namespace };
858
+ if (this.crbmSource !== void 0) {
859
+ const held = await this.crbmSource.currentVersion(ref);
860
+ const listed = rec.currentGen === null ? null : `${rec.currentGen}:${String(rec.token)}`;
861
+ if (held !== listed) this.engine.invalidate(ref);
862
+ }
617
863
  return await this.engine.has(ref, id) ? { segment: rec.segment, namespace: rec.namespace } : null;
618
864
  }
619
865
  );
@@ -626,44 +872,59 @@ var CloudRoaring = class _CloudRoaring {
626
872
  *
627
873
  * For each **registered** segment the id is a member of, `eraseIdFromSegment` rewrites the current generation
628
874
  * 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** —
875
+ * generation it was derived from**, and collects the generation that held the bit. A segment whose current
876
+ * generation does not hold the id is searched anyway, every generation in its bucket: a retained superseded one
877
+ * can still hold it, and so can one above the pointer after a {@link CloudRoaring.rollback}, which the rollback
878
+ * could make current again. Each holder is deleted — below the pointer by a `keep: 0` collection, above it one
879
+ * by one, re-proved against the row first — and the generations above the pointer that never held the id stay
880
+ * as rollback targets. An entry says `erased: true` only once the call has listed the bucket and read what is
881
+ * left: **no generation of the segment holds the id**. The returned per-segment record is your **erasure ledger** —
631
882
  * persist it / route it to your audit sink as the proof of deletion (a `segment.rewrite` audit event is also
632
883
  * emitted per rewrite when you pass `audit`).
633
884
  *
634
885
  * Uses the backend's **own** two halves, so the membership check and the rewrite provably run
635
886
  * over the same generation. Requires the store built with a **backend** (throws
636
887
  * {@link UnsupportedError} otherwise; a pre-built `StorageChunkSource` store has no `IStorageDriver` to write
637
- * through — use the `eraseIdFromSegment` free function there).
888
+ * through; build the store on a backend instead).
638
889
  *
639
890
  * **One contract remains** (an integrator obligation the library cannot check): **do not load the segment
640
891
  * while erasing from it.** A load that lands after the rewrite carries whatever its source held, and the
641
892
  * library cannot know that source was meant to exclude the id. Quiesce loads of the affected segments for the
642
893
  * 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
894
+ * rewrite's publish is refused **by the fence** — the publish lands its compare-and-swap only
644
895
  * 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,
896
+ * so re-run. Forward-only alone would NOT refuse it: the rewrite's number goes above everything in the bucket,
646
897
  * so the rewrite would out-rank the newer generation and then collect it.
647
898
  *
648
899
  * A racing **erasure** is caught before that, and reported the same way. It collects with `keep: 0`, taking
649
900
  * 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.
901
+ * just wrote — so the loser can find its own inputs deleted mid-flight. That surfaces as an outcome rather than
902
+ * an error, read off the row: a moved pointer is a `'superseded'` entry, which a re-run settles, and a segment
903
+ * that a concurrent `dropSegment` tombstoned or a retention sweep purged is left out of the ledger, as a fresh
904
+ * call would leave it out.
653
905
  *
654
906
  * **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
907
+ * another writer (a load, another erasure, or a rollback) moved the pointer mid-call, so **this call** did not
908
+ * erase the id. Re-run: it erases the id if it is still there, and lists nothing for the segment if a racing
657
909
  * erasure of the same id already removed it. Do not read `'superseded'` as "the id is still present" —
658
910
  * read it as "not done by this call, and the re-run settles it".
659
911
  * `` `error: …` `` is a per-segment fault (caught so one segment can't discard the whole ledger) and it can land
660
912
  * on either side of the publish: if the rewrite had not published, the id is still there and a re-run erases
661
913
  * 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;
666
- * `O(registered segments)`, no hot-path cost. Per-subject crypto-shred is infeasible (a subject's bit is
914
+ * absent from every read and what remains is an object in the bucket that still contains the bit. The same
915
+ * note, after a publish, can mean a rollback moved the pointer back onto a generation that still holds the id
916
+ * while the collection ran — then the id is served again, and the entry says so rather than `erased: true`. A
917
+ * re-run searches every generation in the bucket, so it rewrites or collects what is left and usually reports
918
+ * `erased: true`; {@link SubjectErasureEntry.note} says what else a re-run can report. Re-running is otherwise safe
919
+ * and idempotent: a segment the id is no longer in is not listed. Admin-only path;
920
+ * `O(registered segments + superseded generations)`, no hot-path cost: a segment whose current generation lacks
921
+ * the id is searched generation by generation, one open for each other generation in its bucket, and with the
922
+ * keep-everything default of the `*Into` verbs that is one per generation the segment ever had. The per-op
923
+ * `budget` is charged one unit for each segment and one for each generation opened beyond the one its row names,
924
+ * so a fleet with long histories can exhaust it where the segment count alone would not. A segment that does is
925
+ * reported `erased: false` with an `error:` note, before it deletes anything (the one refusal that can come after a
926
+ * rewrite is the last check for a generation a concurrent writer left behind), and the
927
+ * rest of the scan continues: the call itself does not throw for it. Per-subject crypto-shred is infeasible (a subject's bit is
667
928
  * co-mingled in a shared container), so this is the single-subject erasure route; whole-segment/tenant erasure
668
929
  * is `dropSegment` / the `destroySegment`/`eraseNamespace` free functions.
669
930
  */
@@ -678,6 +939,11 @@ var CloudRoaring = class _CloudRoaring {
678
939
  budget,
679
940
  "eraseSubject"
680
941
  );
942
+ let opens = 0;
943
+ const charge = () => {
944
+ opens += 1;
945
+ checkBudget(budget, recs.length + opens, "eraseSubject");
946
+ };
681
947
  const entries = await mapWithConcurrency(
682
948
  recs,
683
949
  options.concurrency ?? DEFAULT_ADMIN_CONCURRENCY,
@@ -685,7 +951,12 @@ var CloudRoaring = class _CloudRoaring {
685
951
  if (rec.status === "destroyed") return null;
686
952
  const ref = { segment: rec.segment, namespace: rec.namespace };
687
953
  try {
688
- const result = await eraseIdFromSegment(ref, id, deps, { audit: options.audit });
954
+ const result = await eraseIdFromSegment(
955
+ ref,
956
+ id,
957
+ budget === null ? deps : { ...deps, storage: new OpenChargingStorage(deps.storage, rec.currentGen, charge) },
958
+ { audit: options.audit }
959
+ );
689
960
  if (result.reason === "not-member" || result.reason === "absent") return null;
690
961
  if (result.reason === "no-generation") return null;
691
962
  if (result.reason === "destroyed") return null;
@@ -720,7 +991,7 @@ var CloudRoaring = class _CloudRoaring {
720
991
  *
721
992
  * What the bucket holds, not what the segment has ever been — collection deletes superseded objects, so this is
722
993
  * 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.
994
+ * choose from, which is the reason to look at it. One registry read and one listing; it does not open the objects.
724
995
  *
725
996
  * Needs a backend.
726
997
  */
@@ -746,7 +1017,7 @@ var CloudRoaring = class _CloudRoaring {
746
1017
  * a handle carrying an expired `expiresAt` reads empty by a rule that lives on the handle, not the row.
747
1018
  *
748
1019
  * 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`.
1020
+ * `load`'s `guard`, or `expectFrom`/`expectToken` on a publish. Needs a storage backend.
750
1021
  *
751
1022
  * ```ts
752
1023
  * if (!(await store.exists({ segment: 'users' }))) {
@@ -771,13 +1042,13 @@ var CloudRoaring = class _CloudRoaring {
771
1042
  * Scoping to a namespace narrows the LIST prefix, so it really is the difference between reading one tenant
772
1043
  * and reading all of them.
773
1044
  *
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.
1045
+ * It streams, and stopping the iteration stops the scan. It reads the registry directly and is not retried: a
1046
+ * transient fault part-way through ends the loop with that error, and calling `segments()` again scans from the
1047
+ * start.
777
1048
  *
778
1049
  * Yields `destroyed` tombstones and rows with `currentGen: null`, because a filtered enumeration that looks
779
1050
  * complete is worse than an honest one — filter on `status`/`currentGen` yourself, or ask
780
- * {@link CloudRoaring.exists} the narrower question. Needs a `registry`.
1051
+ * {@link CloudRoaring.exists} the narrower question. Needs a storage backend.
781
1052
  *
782
1053
  * ```ts
783
1054
  * for await (const s of store.segments({ namespace: 'active-daily' })) {
@@ -788,6 +1059,7 @@ var CloudRoaring = class _CloudRoaring {
788
1059
  segments(options = {}) {
789
1060
  if (options.namespace !== void 0) {
790
1061
  validateSegmentRef({ segment: "x", namespace: options.namespace });
1062
+ refuseReservedNamespace(options.namespace);
791
1063
  }
792
1064
  return listSegments(this.requireRegistry("segments"), options);
793
1065
  }
@@ -808,9 +1080,16 @@ var CloudRoaring = class _CloudRoaring {
808
1080
  * current is a no-op that reports itself.
809
1081
  *
810
1082
  * 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.
1083
+ * and are then *above* `currentGen`, where collection never looks. They remain until a load numbers above them
1084
+ * (collection then keeps the newest `keep` of what is below its pointer), {@link CloudRoaring.dropSegment}
1085
+ * deletes them, or {@link CloudRoaring.eraseSubject} does: all of those present when it rewrites, and only those holding
1086
+ * the id when the current generation does not. An operator who has just undone a bad load should not have the
1087
+ * evidence collected out from under them, while a rollback target that still holds erased data would make the
1088
+ * erasure undoable.
1089
+ *
1090
+ * A target **above** the pointer needs `{ allowForward: true }`, and is refused with {@link ValidationError}
1091
+ * without it: that is also where objects live that were never published, such as a load that wrote its object
1092
+ * and died before the publish. Undoing an earlier rollback is what the opt-in is for.
814
1093
  *
815
1094
  * Needs a backend.
816
1095
  */
@@ -832,8 +1111,8 @@ var CloudRoaring = class _CloudRoaring {
832
1111
  * attention to and everybody pays for.
833
1112
  *
834
1113
  * ```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
1114
+ * const r = await store.load({ segment: 'audience:active' }, idsFromWarehouse, {
1115
+ * guard: { minRetained: 0.5 }, // refuse a load that would drop more than half the segment
837
1116
  * });
838
1117
  * if (!r.published) console.warn(`load refused: ${r.reason}`);
839
1118
  * ```
@@ -851,15 +1130,22 @@ var CloudRoaring = class _CloudRoaring {
851
1130
  * `eraseIdFromSegment` instead, because three of its four refusals are expected guard outcomes rather than
852
1131
  * faults.
853
1132
  *
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.
1133
+ * `'superseded'` means another writer got there first: another load took the same generation number, so this
1134
+ * one wrote nothing (`size: 0`), or the segment's registry row changed while the load was writing — another load
1135
+ * published, a retention change, a rollback or an erasure wrote the row, or the row was deleted.
1136
+ *
1137
+ * A refused load deletes the object it wrote before returning — it sits above `currentGen`, where generation
1138
+ * collection deliberately never looks — but only while the segment's registry row is unchanged or gone. Once
1139
+ * another write has changed the row, the generation number it holds may name another incarnation's live object,
1140
+ * so it leaves the orphan rather than risk deleting live data. The orphan is an ordinary generation once a later
1141
+ * one is current above it, and collection counts it within `keep`.
858
1142
  *
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.
1143
+ * What it **throws** for is a fault rather than an outcome: invalid options or ids, and a crypto-shredded
1144
+ * segment (`ValidationError`); a key the keystore cannot provide (`KeyUnavailableError`); a current generation
1145
+ * that will not open when a guard reads its size (`IntegrityError`); a driver failure; and a collection pass
1146
+ * that could not prove the segment was still the same one (`WriteConflictError`). The last can be raised
1147
+ * **after** the publish already landed, so a throw does not by itself mean the load did not take effect —
1148
+ * re-read the pointer rather than assuming.
863
1149
  *
864
1150
  * Needs a backend (throws {@link UnsupportedError} otherwise).
865
1151
  */
@@ -902,10 +1188,14 @@ var CloudRoaring = class _CloudRoaring {
902
1188
  * could not remove rather than returning a result that looks like a clean drop.
903
1189
  *
904
1190
  * 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
908
- * keep answering `true` for a dropped segment indefinitely; restart it.
1191
+ * segment may answer from its cached generation + cached chunks until that window lapses, or, while the registry
1192
+ * cannot be read because of a transient fault, until a retry 500 ms apart reaches it again. A reader that never
1193
+ * touched it sees empty at once. **That bound needs a registry and `cache.genTtlMs > 0`.** A store with no registry
1194
+ * (a bare `IStorageDriver`), with `cache.genTtlMs: 0`, or on a storage source built with no clock, has no timed
1195
+ * refresh. It notices the drop only when a read has to fetch
1196
+ * from a deleted generation or its reader cache evicts the segment, and until then a chunk it has cached
1197
+ * answers without reaching storage, so it can keep answering `true` for a dropped segment indefinitely. Tell
1198
+ * it with {@link invalidate}, or restart it.
909
1199
  *
910
1200
  * Needs the store built with a **backend** (throws {@link UnsupportedError} otherwise),
911
1201
  * because it has to enumerate and delete generations — a pre-built `StorageChunkSource` only reads.
@@ -942,8 +1232,8 @@ var CloudRoaring = class _CloudRoaring {
942
1232
  *
943
1233
  * A value in the past is legal and means "eligible on the next sweep" — backfilling a policy onto existing
944
1234
  * 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.
1235
+ * **seconds**, which would read as long-expired and retire the segment on the next pass. Needs a storage
1236
+ * backend (throws {@link UnsupportedError} otherwise), and refuses a crypto-shredded segment.
947
1237
  */
948
1238
  async setRetention(ref, policy) {
949
1239
  validateSegmentRef(ref);
@@ -982,7 +1272,7 @@ var CloudRoaring = class _CloudRoaring {
982
1272
  * ```ts
983
1273
  * // In your scheduled handler. Start with a preview in a new deployment.
984
1274
  * const preview = await store.retireExpired({ namespace: 'active-daily', dryRun: true });
985
- * console.log(`would retire ${preview.retired} of ${preview.scanned} (limited: ${preview.limited})`);
1275
+ * console.log(`would retire ${preview.wouldRetire} of ${preview.scanned} (limited: ${preview.limited})`);
986
1276
  *
987
1277
  * const swept = await store.retireExpired({ namespace: 'active-daily' });
988
1278
  * for (const e of swept.entries) {
@@ -1008,8 +1298,9 @@ var CloudRoaring = class _CloudRoaring {
1008
1298
  * first, since nothing else ever would for a tombstoned segment. Attribution is a **positive marker the sweep
1009
1299
  * stamps on its own retirements**, not an inference from "destroyed + an expired policy": a crypto-shred leaves
1010
1300
  * `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.
1301
+ * exactly that row, and deleting it would destroy the Art. 17 attestation and un-fence the name. By default a
1302
+ * segment that held nothing has its row deleted in the pass that retires it, since that row would only fence the name.
1303
+ * Pass `purgeTombstones: false` to keep every tombstone, that row included.
1013
1304
  *
1014
1305
  * Needs the store built with a **backend** (throws {@link UnsupportedError} otherwise),
1015
1306
  * because retiring a segment deletes its storage objects. `now` defaults to the store's clock.
@@ -1036,37 +1327,42 @@ var CloudRoaring = class _CloudRoaring {
1036
1327
  * that advances `currentGen`**, which the snapshot TTL notices and the generation-keyed cache misses on.
1037
1328
  * Neither notices an event that *destroys* what they were derived from.
1038
1329
  *
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:
1330
+ * The store's own writes handle themselves: `load`, `rollback`, `eraseSubject`, `dropSegment` and `retireExpired`
1331
+ * here, and a segment's `*Into` verbs, invalidate what they touch. This method is for the cases they cannot see:
1041
1332
  *
1042
1333
  * - **`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.
1334
+ * here, so a crypto-shred performed beside this store invalidates nothing in it. It keeps its open reader and
1335
+ * the unwrapped DEK, and decrypts with them, chunks it had never fetched before the shred included, until its
1336
+ * snapshot re-resolves: within `cache.genTtlMs` when the store has a registry and a `genTtlMs` above 0, and
1337
+ * otherwise only when its caches let the segment go or a read has to fetch from a generation a sweep collected.
1338
+ * A pin answers with no timed bound, until it is told or both caches let it go.
1045
1339
  * - **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.
1340
+ * staleness by `cache.genTtlMs`, and a store with no registry (a bare `IStorageDriver`), with
1341
+ * `cache.genTtlMs: 0`, or on a storage source built with no clock has no bound at all: it converges only when a read happens to miss its caches. If a compliance deadline depends on every
1342
+ * reader converging, you need to signal them — this is the call to make when your own fan-out delivers.
1343
+ *
1344
+ * It also forgets any replacement this store found of a pin's object, so once it is called after a restore puts that
1345
+ * object back, the pin reads it again.
1049
1346
  *
1050
1347
  * Synchronous, best-effort, and safe to call for a segment this store has never read.
1051
1348
  *
1052
1349
  * ```ts
1053
- * await destroySegment(ref, { storage, registry, keystore }, { confirmSegment: ref.segment });
1350
+ * await destroySegment(ref, { registry: backend.registry }, { confirmSegment: ref.segment });
1054
1351
  * store.invalidate(ref); // this process
1055
1352
  * await bus.publish('cloudbitmaps.invalidate', ref); // and every other one
1056
1353
  * ```
1057
1354
  */
1058
1355
  invalidate(ref) {
1059
1356
  validateSegmentRef(ref);
1357
+ refuseReservedNamespace(ref.namespace);
1060
1358
  this.engine.invalidate(ref);
1061
1359
  }
1062
1360
  /**
1063
1361
  * Build a handle held at the generation `ref` resolves to right now. See {@link Segment.pin}.
1064
1362
  *
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.
1363
+ * The pinned handle gets its own engine but **shares the store's chunk cache**, under keys of its own: the
1364
+ * pinned view reports the version captured at pin time, marked as a pin's, so its decoded chunks are never
1365
+ * those of a live read that fetched across a publish (see {@link PinnedStorageChunkSource.currentVersion}).
1070
1366
  */
1071
1367
  async pinSegment(ref, expiresAt) {
1072
1368
  const crbm = this.crbmSource;
@@ -1075,27 +1371,30 @@ var CloudRoaring = class _CloudRoaring {
1075
1371
  "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
1372
  );
1077
1373
  }
1078
- const at = await crbm.pinGeneration(ref);
1079
- const pinnedAt = { generation: at?.generation ?? null, version: at?.version ?? null };
1374
+ const at = await this.withRetries(() => crbm.pinGeneration(ref));
1375
+ const pinnedAt = {
1376
+ generation: at?.generation ?? null,
1377
+ version: at?.version ?? null,
1378
+ fingerprint: at?.fingerprint ?? null
1379
+ };
1080
1380
  const pins = /* @__PURE__ */ new Map([[segmentKey(ref), pinnedAt]]);
1081
- return new Segment(
1082
- this.engineWithPins(pins),
1381
+ return makeSegment({
1382
+ engine: this.engineWithPins(pins),
1083
1383
  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),
1384
+ clock: this.clock,
1385
+ metrics: this.metrics,
1386
+ materialize: (dest, ids, op, options) => this.materialize(dest, ids, op, options),
1387
+ pinned: (r, e) => this.pinSegment(r, e),
1388
+ combineEngine: (handles) => this.engineForCombine(handles),
1089
1389
  expiresAt,
1090
1390
  pinnedAt
1091
- );
1391
+ });
1092
1392
  }
1093
1393
  /**
1094
- * An engine reading every segment in `pins` at its pinned generation and everything else live.
1394
+ * An engine reading every segment in `pins` at its pinned generation and everything else live, with the store's
1395
+ * retries, so a transient fault on a pinned read is retried as it would be on a live one.
1095
1396
  *
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.
1397
+ * It **shares the store's chunk cache and its memory bound**, under keys of its own for each pinned segment.
1099
1398
  */
1100
1399
  engineWithPins(pins) {
1101
1400
  const crbm = this.crbmSource;
@@ -1104,8 +1403,9 @@ var CloudRoaring = class _CloudRoaring {
1104
1403
  "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
1404
  );
1106
1405
  }
1406
+ const pinned = new PinnedStorageChunkSource(crbm, pins);
1107
1407
  return new SegmentEngine({
1108
- storage: new PinnedStorageChunkSource(crbm, pins),
1408
+ storage: this.retryOptions === void 0 ? pinned : new RetryingStorageChunkSource(pinned, this.retryOptions),
1109
1409
  cache: this.cache,
1110
1410
  codec: roaringCodec,
1111
1411
  clock: this.clock,
@@ -1121,20 +1421,42 @@ var CloudRoaring = class _CloudRoaring {
1121
1421
  * `other.intersect([snap])` silently does not, and the two are the same question. So the combine collects
1122
1422
  * every pin in play and runs on an engine that honours all of them. With no pins anywhere this is the
1123
1423
  * store's own engine and costs nothing.
1424
+ *
1425
+ * One engine reads a segment at one generation, so a combine that holds the same segment at two — two pins of
1426
+ * it, or a pin and a live handle — is refused. Keeping one of them would answer the question for the wrong
1427
+ * instant, and say nothing: diffing two snapshots of a segment returned no ids at all.
1124
1428
  */
1125
1429
  engineForCombine(handles) {
1430
+ if (!handles.some((h) => h.pinnedAt !== void 0)) return void 0;
1126
1431
  const pins = /* @__PURE__ */ new Map();
1432
+ const held = /* @__PURE__ */ new Map();
1127
1433
  for (const h of handles) {
1434
+ const key = h.key();
1128
1435
  const at = h.pinnedAt;
1129
- if (at !== void 0) pins.set(h.key(), at);
1436
+ if (held.has(key)) {
1437
+ const before = held.get(key);
1438
+ if (!samePin(before, at)) {
1439
+ throw new ValidationError2(
1440
+ `the same segment is in this combine twice, ${twoPins(before, at)}: one call reads a segment at one generation, so materialise one of them into a segment of its own first, with \`intersectInto(dest, [])\``
1441
+ );
1442
+ }
1443
+ } else {
1444
+ held.set(key, at);
1445
+ }
1446
+ if (at !== void 0) pins.set(key, at);
1130
1447
  }
1131
- return pins.size === 0 ? void 0 : this.engineWithPins(pins);
1448
+ return this.engineWithPins(pins);
1449
+ }
1450
+ /** `op` under the store's retries, as the store's own reads run; bare when the store has retries off. */
1451
+ withRetries(op) {
1452
+ const r = this.retryOptions;
1453
+ return r === void 0 ? op() : withRetry(op, r.policy ?? DEFAULT_RETRY_POLICY, r);
1132
1454
  }
1133
1455
  /** The store's registry, or a typed error naming the operation that needs one. */
1134
1456
  requireRegistry(op) {
1135
1457
  if (this.registry === void 0) {
1136
1458
  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.`
1459
+ `${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 separate \`registry\` option: a backend carries it.`
1138
1460
  );
1139
1461
  }
1140
1462
  return this.registry;
@@ -1143,11 +1465,16 @@ var CloudRoaring = class _CloudRoaring {
1143
1465
  * **Cross-tier DR consistency check.** After a restore/failover, verify every registered segment's `currentGen`
1144
1466
  * actually has its `.crbm` present in Storage — catching a **torn restore** where the registry (`currentGen`) came
1145
1467
  * 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**
1468
+ * throw). Read-only, bounded fan-out; run it at startup after a restore. Returns `{ checked, inconsistent,
1469
+ * errored }` — `inconsistent` empty ⇒ coherent; otherwise it names the segments to recover (restore the object
1470
+ * store, or roll the registry back to a coherent point). Needs the store built with a **backend**
1149
1471
  * (throws {@link UnsupportedError} otherwise). `destroyed` (crypto-shredded) segments are skipped. Pair it with
1150
1472
  * the DR runbook (docs/guide/disaster-recovery.md).
1473
+ *
1474
+ * It holds the registry rows it enumerates resident, at most 250,000 of them, and throws
1475
+ * `BudgetExceededError` past that rather than report a partial scan as a whole one. This method takes no
1476
+ * ceiling of its own: narrow the scan with `namespace`, or call `runConsistencyCheck` over the backend's
1477
+ * `storage` and `registry` with a higher `maxScanSegments`.
1151
1478
  */
1152
1479
  async checkConsistency(options = {}) {
1153
1480
  const deps = this.lifecycleDeps("checkConsistency");
@@ -1167,7 +1494,7 @@ var CloudRoaring = class _CloudRoaring {
1167
1494
  */
1168
1495
  async exportSegments(sink, options = {}) {
1169
1496
  const registry = this.requireRegistry("exportSegments");
1170
- return runExport2(this, registry, sink, {
1497
+ return runExport(this, registry, sink, {
1171
1498
  ...options,
1172
1499
  codec: options.codec ?? roaringCodec
1173
1500
  });
@@ -1180,24 +1507,34 @@ var CloudRoaring = class _CloudRoaring {
1180
1507
  return estimateCost(input);
1181
1508
  }
1182
1509
  };
1510
+ var failing = (err) => ({
1511
+ [Symbol.asyncIterator]: () => ({ next: () => Promise.reject(err) })
1512
+ });
1513
+ var samePin = (a, b) => a === void 0 || b === void 0 ? a === b : a.generation === b.generation && a.fingerprint === b.fingerprint;
1514
+ var twoPins = (a, b) => {
1515
+ const one = (p) => p === void 0 ? "live" : `pinned at generation ${p.generation ?? "none"}`;
1516
+ return a !== void 0 && b !== void 0 && a.generation === b.generation ? `pinned twice at generation ${a.generation ?? "none"}, as two different objects` : `${one(a)} and ${one(b)}`;
1517
+ };
1518
+ var rangeOf = (options) => ({
1519
+ after: options.after,
1520
+ through: options.through
1521
+ });
1522
+ var readOptions = (options) => ({
1523
+ ...rangeOf(options),
1524
+ concurrency: options.concurrency,
1525
+ budget: options.budget,
1526
+ allowAbsentOperands: options.allowAbsentOperands
1527
+ });
1528
+ var NO_SEGMENTS = [];
1183
1529
  var EMPTY_IDS = {
1184
1530
  async *[Symbol.asyncIterator]() {
1185
1531
  }
1186
1532
  };
1187
1533
  var KEEP_EVERY_GENERATION = Number.MAX_SAFE_INTEGER;
1188
- var Segment = class {
1189
- constructor(engine, ref, clock, metrics, materialize, pinned, combineEngine, expiresAt, pinnedAt) {
1190
- this.engine = engine;
1191
- this.ref = ref;
1192
- this.clock = clock;
1193
- this.metrics = metrics;
1194
- this.materialize = materialize;
1195
- this.pinned = pinned;
1196
- this.combineEngine = combineEngine;
1197
- this.expiresAt = expiresAt;
1198
- this.pinnedAt = pinnedAt;
1199
- this.metricsOn = metrics !== NOOP_METRICS;
1200
- }
1534
+ var makeSegment;
1535
+ var minting = false;
1536
+ var Segment = class _Segment {
1537
+ metricsOn;
1201
1538
  engine;
1202
1539
  ref;
1203
1540
  clock;
@@ -1205,9 +1542,44 @@ var Segment = class {
1205
1542
  materialize;
1206
1543
  pinned;
1207
1544
  combineEngine;
1545
+ /** Absolute epoch-ms deadline from {@link SegmentOptions.expiresAt}; `undefined` ⇒ this handle never expires. */
1208
1546
  expiresAt;
1547
+ /**
1548
+ * The generation this handle is held at, when it came from {@link Segment.pin}. Read by the store so a
1549
+ * pinned handle passed as an **operand** is still read at its pin rather than live.
1550
+ */
1209
1551
  pinnedAt;
1210
- metricsOn;
1552
+ static {
1553
+ makeSegment = (parts) => {
1554
+ minting = true;
1555
+ try {
1556
+ return new _Segment(parts);
1557
+ } finally {
1558
+ minting = false;
1559
+ }
1560
+ };
1561
+ }
1562
+ /**
1563
+ * Not constructible: a handle comes from {@link CloudRoaring.segment}, which wires it to the store's engine,
1564
+ * caches and write path. The constructor takes those internals, so it is not part of the surface.
1565
+ */
1566
+ constructor(parts) {
1567
+ if (!minting) {
1568
+ throw new ValidationError2(
1569
+ "a Segment is not constructed directly; call `store.segment(name)`"
1570
+ );
1571
+ }
1572
+ this.engine = parts.engine;
1573
+ this.ref = parts.ref;
1574
+ this.clock = parts.clock;
1575
+ this.metrics = parts.metrics;
1576
+ this.materialize = parts.materialize;
1577
+ this.pinned = parts.pinned;
1578
+ this.combineEngine = parts.combineEngine;
1579
+ this.expiresAt = parts.expiresAt;
1580
+ this.pinnedAt = parts.pinnedAt;
1581
+ this.metricsOn = parts.metrics !== NOOP_METRICS;
1582
+ }
1211
1583
  /**
1212
1584
  * **Hold this segment at the generation that is current right now**, for as long as you keep the handle.
1213
1585
  *
@@ -1226,15 +1598,54 @@ var Segment = class {
1226
1598
  * `other` at whatever is current — pin each segment if you want the whole query held. And a pinned handle
1227
1599
  * used as an *operand* is still read at its pin, never live.
1228
1600
  *
1229
- * **It is a hold, not a lease.** Nothing here stops `gcOrphanGenerations` deleting the generation underneath
1601
+ * **One call reads a segment at one generation**, so a combine that holds this segment at two —
1602
+ * `snap0.andNot([snap1])`, or `live.intersect([snap])` — throws {@link ValidationError} when it is read, and an
1603
+ * `*Into` of one throws before it reads anything. Materialise one side first, with `intersectInto(dest, [])`.
1604
+ *
1605
+ * **A pin knows its object, not only its number.** A name purged and loaded again starts again at generation
1606
+ * 0, so a pin of the old segment never reads the new one: what it has already read still answers, as the
1607
+ * instant it pinned, and anything it would have to fetch fails with `NotFoundError`, as a swept pin's does.
1608
+ *
1609
+ * **It is a hold, not a lease.** Nothing here stops a collection deleting the generation underneath
1230
1610
  * you: a pinned read deliberately does **not** heal forward, because silently serving a different generation
1231
1611
  * 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
1612
+ * job — see [Generations and `keep`](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/loading.md#generations-and-keep) — or take the pin on a segment
1233
1613
  * you are not collecting.
1234
1614
  *
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.
1615
+ * A segment with no current generation pins nothing and reads empty, exactly as it would unpinned. A pinned
1616
+ * segment whose row is later dropped or destroyed fails once it must open its object again, rather than go empty
1617
+ * part-way through a call. **A pin
1618
+ * keeps the key its reader unwrapped for as long as that reader stays open, and answers from the chunks it
1619
+ * decoded for as long as they stay cached.** This store invalidates a pin:
1620
+ *
1621
+ * - a `load`, a `rollback` or an `*Into` invalidates a pin of the segment it writes;
1622
+ * - `dropSegment` invalidates a pin of the segment it drops, and `retireExpired` a pin of each segment its ledger
1623
+ * lists, retired or not, neither on a dry run;
1624
+ * - `eraseSubject` invalidates a pin of each segment it scans that is not already destroyed.
1625
+ *
1626
+ * An invalidated pin opens its object again, and fails if that object is gone or replaced, or its row is gone or
1627
+ * destroyed.
1628
+ * Anything else leaves the pin as it is: after a `destroySegment` beside this store, or an erasure, a drop or a
1629
+ * retirement through another store, in this process or another, the pin answers from what it holds until this
1630
+ * store's reader cache evicts the pin's reader and its chunk cache evicts the chunks the pin decoded, or
1631
+ * {@link CloudRoaring.invalidate} is called on this store. Where the
1632
+ * object the pin reads has been deleted, by an erasure, a drop or a sweep, a chunk the pin has not cached fails at
1633
+ * once. No timed refresh bounds any of that, as none bounds anything else a pin holds.
1634
+ *
1635
+ * A replacement a pin has found is remembered, so its later chunk reads fail with no request, until the store
1636
+ * forgets it: an invalidation of the segment does, as does a later `pin()` of the same version that opens the object
1637
+ * again, and the store remembers at most `cache.readerMax` of them. So once a restore puts the pinned object back,
1638
+ * call {@link CloudRoaring.invalidate} on this store: the pin then reads the object again.
1639
+ *
1640
+ * `pin()` reads the registry row and opens the generation at once, so the pin knows its object before its first
1641
+ * read. With a registry, pins of one generation taken while its row is unchanged share one reader while the store
1642
+ * keeps it open, pins taken at the same moment included: the first costs a tail read, and a key unwrap for an
1643
+ * encrypted segment, whether or not it is read, and the rest cost the row read alone. Without a registry every
1644
+ * `pin()` lists the segment's objects and makes the tail read, since only the object can tell two incarnations of
1645
+ * a name apart there. It retries a
1646
+ * transient fault as the store's reads do, and heals a generation swept between the two, but it fails where the
1647
+ * generation cannot be opened: `NotFoundError` for a pointer at a missing object, `IntegrityError` for a damaged
1648
+ * or misfiled one.
1238
1649
  *
1239
1650
  * Needs a store built on the `.crbm` storage source (the default when you pass a backend or a raw driver). Throws
1240
1651
  * {@link UnsupportedError} on a store wired with a pre-built source that cannot pin.
@@ -1242,7 +1653,12 @@ var Segment = class {
1242
1653
  async pin() {
1243
1654
  return this.pinned(this.ref, this.expiresAt);
1244
1655
  }
1245
- /** This handle's segment, as the cache/pin key — `ref` stays private; the encapsulation is worth the method. */
1656
+ /**
1657
+ * An opaque string that names this handle's segment, namespace included: two handles of one segment have the
1658
+ * same key, a live handle and its pins among them. Use it as a `Map` key or a log field when you track handles
1659
+ * and have not kept the name you made them with. The format is not specified and is not a storage key, so
1660
+ * compare keys, never parse one.
1661
+ */
1246
1662
  key() {
1247
1663
  return segmentKey(this.ref);
1248
1664
  }
@@ -1261,15 +1677,15 @@ var Segment = class {
1261
1677
  *
1262
1678
  * On the read verbs an expired handle answers empty, which is the point of lazy expiry. On a *write* the same
1263
1679
  * 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.
1680
+ * quietly passed would, unrefused, replace `dest` with an **empty generation** — a wipe, reported as a successful
1681
+ * write, with the cause (a deadline on a handle somewhere) nowhere in the result. Reads degrade to empty; writes must not.
1266
1682
  *
1267
1683
  * So every handle in the call is checked, `dest` included: an expired `dest` does not change the bytes written,
1268
1684
  * but a caller who put a deadline on the thing they are writing into has said something contradictory and is
1269
1685
  * better told than guessed at. Open a handle without `expiresAt` to write, or drop the deadline.
1270
1686
  *
1271
1687
  * (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
1688
+ * `allowEmpty` override — covers these verbs too: they route through the same guarded write path as
1273
1689
  * {@link CloudRoaring.load}. The two stay separate because they differ in kind. That one is a REPORTED
1274
1690
  * refusal a caller may legitimately override; an expired handle is a wiring mistake, so it THROWS, before
1275
1691
  * any object is written — and `allowEmpty: true` does not reach it.)
@@ -1285,7 +1701,7 @@ var Segment = class {
1285
1701
  stale.push(seg.ref.namespace ? `${seg.ref.namespace}/${seg.ref.segment}` : seg.ref.segment);
1286
1702
  }
1287
1703
  if (stale.length > 0) {
1288
- throw new ValidationError(
1704
+ throw new ValidationError2(
1289
1705
  `${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
1706
  );
1291
1707
  }
@@ -1308,24 +1724,71 @@ var Segment = class {
1308
1724
  if (this.expired()) return Promise.resolve(false);
1309
1725
  return this.timed("has", () => this.engine.has(this.ref, id));
1310
1726
  }
1311
- /** Cardinality — summed from the `.crbm` index with **zero payload reads** on a loaded segment. */
1727
+ /**
1728
+ * Cardinality — summed from the `.crbm` index with **zero payload reads** on a loaded segment.
1729
+ *
1730
+ * What this trusts: the answer is the sum of the per-chunk cardinalities the index records, and no payload is
1731
+ * decoded to confirm it. The index is checked for internal consistency when the object is opened (each key in
1732
+ * range and ascending, each cardinality in `1..65536`, each payload inside the payload region, and, on an
1733
+ * unencrypted object, the footer's chunk count and total agreeing with the index), and a corrupt index that is
1734
+ * still internally consistent yields a wrong count. `iterate()` and the combines decode the payloads, whose
1735
+ * structure is checked.
1736
+ */
1312
1737
  count() {
1313
1738
  if (this.expired()) return Promise.resolve(0);
1314
1739
  return this.timed("count", () => this.engine.count(this.ref));
1315
1740
  }
1316
- /** Every id, ascending, streamed one chunk at a time. */
1317
- iterate() {
1741
+ /**
1742
+ * Every id, ascending, streamed one chunk at a time. Pass a range to read part of the segment: the ids in
1743
+ * `(after, through]`, fetching only the chunks the range overlaps. The per-op budget is charged for every chunk in
1744
+ * the range, so give a page `through` as well as `after`. On a pinned handle it reads the pinned generation, as a
1745
+ * full read does.
1746
+ *
1747
+ * ```ts
1748
+ * // Keyset paging: each page resumes where the one before ended, and stops at its window's end.
1749
+ * for await (const id of seg.iterate({ after: previousEnd, through: windowEnd })) await send(id);
1750
+ * ```
1751
+ *
1752
+ * Each bound is optional and an integer in `0..4294967295`; a bad one throws {@link ValidationError} when the
1753
+ * stream is first read. `after >= through` is an empty range, which reads nothing. An expired handle reads empty
1754
+ * without checking its options, as every read of one does.
1755
+ */
1756
+ iterate(options) {
1318
1757
  if (this.expired()) return EMPTY_IDS;
1319
- return this.engine.iterate(this.ref);
1758
+ const range = options == null ? void 0 : rangeOf(options);
1759
+ const none = range === void 0 || range.after === void 0 && range.through === void 0;
1760
+ return this.engine.iterate(this.ref, none ? void 0 : range);
1320
1761
  }
1321
1762
  /**
1322
- * Map the facade's `Segment` handles in `exclude` down to the plain refs `core` takes. A method rather than
1323
- * a module function because `ref` is class-private — the encapsulation is worth more than the free function.
1763
+ * The options a combine hands the engine, read once, when it is called ({@link readOptions}), with the `exclude`
1764
+ * it was given, already reduced to its live handles ({@link liveExcludes}) and mapped down to the plain refs
1765
+ * `core` takes. A method rather than a module function because `ref` is class-private — the encapsulation is
1766
+ * worth more than the free function.
1767
+ *
1768
+ * Each field is named rather than copied with a rest spread: a rest copy takes only own enumerable properties, so
1769
+ * a bound held in a getter or inherited from a prototype was dropped, and the read silently widened to the whole
1770
+ * segment.
1324
1771
  */
1325
- refsIn(options) {
1326
- if (options === void 0) return void 0;
1327
- const { exclude, ...rest } = options;
1328
- return exclude === void 0 ? rest : { ...rest, exclude: exclude.map((o) => o.ref) };
1772
+ refsIn(options, exclude) {
1773
+ if (options == null) return void 0;
1774
+ return {
1775
+ ...readOptions(options),
1776
+ exclude: exclude.length > 0 ? exclude.map((o) => o.ref) : void 0
1777
+ };
1778
+ }
1779
+ /**
1780
+ * The handles in `options.exclude` that have not expired. **An expired exclusion excludes nothing**, in every
1781
+ * combine, so it is dropped here, before the engine is asked for anything: it is never fetched, never
1782
+ * checked for absence, and never counted in the pin-consistency check. `exclude` is read once, so a getter sees
1783
+ * one call.
1784
+ *
1785
+ * With no `exclude` this is one property read and a shared empty list, and with none expired it returns the
1786
+ * caller's own array, so the common case allocates nothing.
1787
+ */
1788
+ liveExcludes(options) {
1789
+ const exclude = options?.exclude;
1790
+ if (exclude == null || exclude.length === 0) return NO_SEGMENTS;
1791
+ return exclude.some((e) => e.expired()) ? exclude.filter((e) => !e.expired()) : exclude;
1329
1792
  }
1330
1793
  /**
1331
1794
  * Chunk-skipping intersection: stream the ids in **this** segment AND every segment in `others`, ascending.
@@ -1337,13 +1800,20 @@ var Segment = class {
1337
1800
  */
1338
1801
  intersect(others, options) {
1339
1802
  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));
1803
+ const exclude = this.liveExcludes(options);
1804
+ let engine;
1805
+ try {
1806
+ engine = this.combineEngine([this, ...others, ...exclude]) ?? this.engine;
1807
+ } catch (err) {
1808
+ return failing(err);
1809
+ }
1810
+ return engine.intersect([this.ref, ...others.map((o) => o.ref)], this.refsIn(options, exclude));
1342
1811
  }
1343
1812
  /**
1344
1813
  * Materialize `this ∩ others…` (minus `exclude`) as a **new generation of `dest`** — `dest`'s previous contents
1345
1814
  * 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
1815
+ * as a load is (the pointer advances to it, fenced on the row), so readers of `dest` see either the old generation
1816
+ * or the new one, never a partial. Needs the
1347
1817
  * store built with a backend (throws {@link UnsupportedError} otherwise).
1348
1818
  *
1349
1819
  * **An empty result does NOT overwrite a non-empty destination.** A combine that comes out empty is far more
@@ -1361,6 +1831,7 @@ var Segment = class {
1361
1831
  */
1362
1832
  async intersectInto(dest, others, options) {
1363
1833
  this.refuseIfExpired("intersectInto", dest, [...others, ...options?.exclude ?? []]);
1834
+ this.combineEngine([this, ...others, ...options?.exclude ?? []]);
1364
1835
  return this.timed(
1365
1836
  "intersectInto",
1366
1837
  () => this.materialize(dest.ref, this.intersect(others, options), "intersectInto", options)
@@ -1383,16 +1854,23 @@ var Segment = class {
1383
1854
  return live[0].union(live.slice(1), options);
1384
1855
  }
1385
1856
  if (live.length === 0 && others.length > 0) {
1386
- const exclude = options?.exclude ?? [];
1387
- return exclude.length > 0 ? this.andNot([...exclude], options) : this.iterate();
1857
+ const exclude2 = this.liveExcludes(options);
1858
+ return exclude2.length > 0 ? this.andNot([...exclude2], options) : this.union([], options);
1388
1859
  }
1389
1860
  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));
1861
+ const exclude = this.liveExcludes(options);
1862
+ let engine;
1863
+ try {
1864
+ engine = this.combineEngine([this, ...others, ...exclude]) ?? this.engine;
1865
+ } catch (err) {
1866
+ return failing(err);
1867
+ }
1868
+ return engine.union([this.ref, ...others.map((o) => o.ref)], this.refsIn(options, exclude));
1392
1869
  }
1393
1870
  /** Materialize `this ∪ others…` (minus `exclude`) as a **new generation of `dest`** — see {@link intersectInto}. */
1394
1871
  async unionInto(dest, others, options) {
1395
1872
  this.refuseIfExpired("unionInto", dest, [...others, ...options?.exclude ?? []]);
1873
+ this.combineEngine([this, ...others, ...options?.exclude ?? []]);
1396
1874
  return this.timed(
1397
1875
  "unionInto",
1398
1876
  () => this.materialize(dest.ref, this.union(others, options), "unionInto", options)
@@ -1406,24 +1884,36 @@ var Segment = class {
1406
1884
  * suppression list: at most one read per surviving key of `this`, so subtracting a 61,000-chunk global
1407
1885
  * opt-out list from a 40-chunk audience costs at most 40 reads, not 61,000.
1408
1886
  *
1887
+ * An expired handle in `excludes` excludes nothing, and is skipped without being read; if every one has
1888
+ * expired the result is `this` whole. An expired `this` is empty. `exclude` on `intersect` and `union` follows
1889
+ * the same rule.
1890
+ *
1409
1891
  * To filter the *result of an intersection*, do not chain — pass `exclude` to {@link intersect} instead, so
1410
1892
  * the suppression folds into the same pass rather than materializing an intermediate segment first.
1411
1893
  */
1412
1894
  andNot(excludes, options) {
1413
1895
  if (this.expired()) return EMPTY_IDS;
1414
1896
  const liveExcludes = excludes.filter((e) => !e.expired());
1415
- if (liveExcludes.length === 0 && excludes.length > 0) return this.iterate();
1897
+ if (liveExcludes.length === 0 && excludes.length > 0) {
1898
+ return this.union([], options == null ? void 0 : readOptions(options));
1899
+ }
1416
1900
  if (liveExcludes.length !== excludes.length) return this.andNot(liveExcludes, options);
1417
- const engine = this.combineEngine([this, ...excludes]) ?? this.engine;
1901
+ let engine;
1902
+ try {
1903
+ engine = this.combineEngine([this, ...excludes]) ?? this.engine;
1904
+ } catch (err) {
1905
+ return failing(err);
1906
+ }
1418
1907
  return engine.andNot(
1419
1908
  this.ref,
1420
1909
  excludes.map((o) => o.ref),
1421
- options
1910
+ options == null ? void 0 : readOptions(options)
1422
1911
  );
1423
1912
  }
1424
1913
  /** Materialize `this \ (excludes…)` as a **new generation of `dest`** — see {@link intersectInto}. */
1425
1914
  async andNotInto(dest, excludes, options) {
1426
1915
  this.refuseIfExpired("andNotInto", dest, excludes);
1916
+ this.combineEngine([this, ...excludes]);
1427
1917
  return this.timed(
1428
1918
  "andNotInto",
1429
1919
  () => this.materialize(dest.ref, this.andNot(excludes, options), "andNotInto", options)
@@ -1432,30 +1922,61 @@ var Segment = class {
1432
1922
  /**
1433
1923
  * Grounded cost report for this segment: storage cost from its **real** `.crbm` size (exact, no payload
1434
1924
  * 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).
1925
+ * storage. The pointer refresh is priced at the store's own `cache.genTtlMs`, or at none when the store never
1926
+ * refreshes, unless the workload sets `genTtlMs`. See {@link CostReport} — it always includes a verdict (incl.
1927
+ * the lose-zone).
1436
1928
  */
1437
1929
  async costReport(options) {
1438
1930
  const canMeasure = this.engine.supportsStorageSize;
1439
1931
  const size = canMeasure ? await this.engine.segmentSize(this.ref) : null;
1932
+ const refreshMs = this.engine.pointerRefreshMs;
1933
+ const workload = refreshMs === void 0 || options?.workload?.genTtlMs !== void 0 ? options?.workload : { ...options?.workload, genTtlMs: refreshMs };
1440
1934
  return groundedReport({
1441
1935
  storageBytes: size?.sizeBytes ?? 0,
1442
1936
  grounded: canMeasure,
1443
- workload: options?.workload,
1937
+ workload,
1444
1938
  pricing: options?.pricing,
1445
1939
  extraNotes: canMeasure ? void 0 : ["storage source has no sizeOf() \u2014 storage not measured, reported as $0."]
1446
1940
  });
1447
1941
  }
1448
1942
  };
1449
- var VERSION = "0.10.0";
1450
1943
  export {
1944
+ AWS_US_EAST_1_ONDEMAND,
1945
+ BudgetExceededError,
1946
+ BufferReader,
1947
+ CapabilityError,
1451
1948
  CloudRoaring,
1452
- SafeBitmap,
1949
+ CloudRoaringError,
1950
+ CountingMetricsSink,
1951
+ CrbmReader,
1952
+ CrbmStorageChunkSource2 as CrbmStorageChunkSource,
1953
+ DEFAULT_RETRY_POLICY2 as DEFAULT_RETRY_POLICY,
1954
+ ELASTICACHE_REDIS_US_EAST_1_ONDEMAND,
1955
+ InProcessKeystore,
1956
+ IntegrityError3 as IntegrityError,
1957
+ KeyUnavailableError,
1958
+ LocalFsStorage,
1959
+ MIN_EXPIRES_AT_MS2 as MIN_EXPIRES_AT_MS,
1960
+ MemoryStorage,
1961
+ NodeAead,
1962
+ NotFoundError,
1963
+ ONE_REDIS_HA_CLUSTER,
1964
+ RecordingAuditSink,
1453
1965
  Segment,
1454
- VERSION,
1455
- bulkLoadCrbmGeneration,
1456
- eraseIdFromSegment,
1457
- loadSegment,
1458
- roaringCodec,
1459
- runExport
1966
+ TransientError,
1967
+ UnsupportedError2 as UnsupportedError,
1968
+ ValidationError3 as ValidationError,
1969
+ WriteConflictError2 as WriteConflictError,
1970
+ aadFor,
1971
+ destroySegment,
1972
+ eraseNamespace,
1973
+ excludingReservedRows2 as excludingReservedRows,
1974
+ isCloudRoaringError,
1975
+ isIntegrityError,
1976
+ isNotFoundError,
1977
+ isTransientError,
1978
+ isValidationError,
1979
+ isWriteConflictError,
1980
+ readRetentionPolicy
1460
1981
  };
1461
1982
  //# sourceMappingURL=index.js.map