@cloudbitmaps/roaring 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,10 @@ import {
21
21
  NOOP_METRICS,
22
22
  RetryingStorageChunkSource,
23
23
  SegmentEngine,
24
+ LEASE_SKEW_MS,
25
+ LeaseExpiredError,
26
+ MAX_LEASE_MS,
27
+ NotFoundError,
24
28
  UnsupportedError,
25
29
  ValidationError as ValidationError2,
26
30
  WriteConflictError,
@@ -34,17 +38,24 @@ import {
34
38
  groundedReport,
35
39
  loadSegment,
36
40
  loadSegmentChunks,
41
+ compileCombineMany,
42
+ prepareHeld,
43
+ rebindCombineMany,
44
+ runCombineMany,
37
45
  mapWithConcurrency,
38
46
  resolveBudget,
39
47
  resolvePerOpBudget,
48
+ reapRegistryTombstones,
40
49
  retireExpired,
41
50
  runConsistencyCheck,
42
51
  runExport,
43
52
  setSegmentRetention,
44
53
  clearSegmentRetention,
45
54
  getSegmentRetention,
55
+ releaseLease,
46
56
  safeMetrics,
47
- splitId
57
+ splitId,
58
+ takeLease
48
59
  } from "@cloudbitmaps/core";
49
60
  import { validateSegmentRef } from "@cloudbitmaps/core/driver-kit";
50
61
 
@@ -104,7 +115,7 @@ function refuseReservedNamespace(namespace) {
104
115
 
105
116
  // src/roaring-codec.ts
106
117
  import roaring from "roaring";
107
- import { IntegrityError as IntegrityError2 } from "@cloudbitmaps/core";
118
+ import { IntegrityError as IntegrityError2, decodeSerialized } from "@cloudbitmaps/core";
108
119
 
109
120
  // src/portable/layout.ts
110
121
  import { IntegrityError } from "@cloudbitmaps/core";
@@ -352,6 +363,10 @@ var SafeBitmap = class _SafeBitmap {
352
363
  throw new IntegrityError2(`failed to deserialize bitmap: ${err.message}`);
353
364
  }
354
365
  }
366
+ /** The `roaring` bitmap this wraps, for a caller that was handed it once and keeps no wrapper. */
367
+ unwrap() {
368
+ return this.bitmap;
369
+ }
355
370
  serialize() {
356
371
  return this.bitmap.serialize(SerializationFormat.portable);
357
372
  }
@@ -516,6 +531,86 @@ var OPTION_KEYS = {
516
531
  seams: ["clock", "rng"]
517
532
  };
518
533
 
534
+ // src/lease-guards.ts
535
+ var guardIdIterable = (inner, check) => ({
536
+ [Symbol.asyncIterator]: () => {
537
+ const it = inner[Symbol.asyncIterator]();
538
+ let chunk = -1;
539
+ let first = true;
540
+ const stop = async (err) => {
541
+ await it.return?.();
542
+ throw err;
543
+ };
544
+ return {
545
+ async next() {
546
+ if (first) {
547
+ first = false;
548
+ try {
549
+ check();
550
+ } catch (err) {
551
+ return stop(err);
552
+ }
553
+ }
554
+ const r = await it.next();
555
+ if (r.done === true) return r;
556
+ const key = r.value >>> 16;
557
+ if (key !== chunk) {
558
+ try {
559
+ check();
560
+ } catch (err) {
561
+ return stop(err);
562
+ }
563
+ chunk = key;
564
+ }
565
+ return r;
566
+ },
567
+ async return() {
568
+ await it.return?.();
569
+ return { done: true, value: void 0 };
570
+ }
571
+ };
572
+ }
573
+ });
574
+ var guardIds = (inner, check) => ({
575
+ [Symbol.asyncIterator]: () => guardIdIterable(inner, check)[Symbol.asyncIterator](),
576
+ batches: () => guardItems(inner.batches(), check)
577
+ });
578
+ var guardItems = (inner, check) => ({
579
+ [Symbol.asyncIterator]: () => {
580
+ const it = inner[Symbol.asyncIterator]();
581
+ let first = true;
582
+ const stop = async (err) => {
583
+ await it.return?.();
584
+ throw err;
585
+ };
586
+ return {
587
+ async next() {
588
+ if (first) {
589
+ first = false;
590
+ try {
591
+ check();
592
+ } catch (err) {
593
+ return stop(err);
594
+ }
595
+ }
596
+ const r = await it.next();
597
+ if (r.done === true) return r;
598
+ try {
599
+ check();
600
+ } catch (err) {
601
+ return stop(err);
602
+ }
603
+ return r;
604
+ },
605
+ async return() {
606
+ await it.return?.();
607
+ return { done: true, value: void 0 };
608
+ }
609
+ };
610
+ }
611
+ });
612
+ var guardChunks = (inner, check) => guardItems(inner, check);
613
+
519
614
  // src/index.ts
520
615
  import {
521
616
  MemoryStorage,
@@ -526,6 +621,9 @@ import {
526
621
  excludingReservedRows as excludingReservedRows2,
527
622
  readRetentionPolicy,
528
623
  MIN_EXPIRES_AT_MS as MIN_EXPIRES_AT_MS2,
624
+ LEASE_SKEW_MS as LEASE_SKEW_MS2,
625
+ MAX_LEASE_MS as MAX_LEASE_MS2,
626
+ MAX_LEASES_PER_SEGMENT,
529
627
  InProcessKeystore,
530
628
  NodeAead,
531
629
  aadFor,
@@ -533,18 +631,24 @@ import {
533
631
  ValidationError as ValidationError3,
534
632
  WriteConflictError as WriteConflictError2,
535
633
  IntegrityError as IntegrityError3,
536
- NotFoundError,
634
+ NotFoundError as NotFoundError2,
537
635
  UnsupportedError as UnsupportedError2,
538
636
  CapabilityError,
539
637
  TransientError,
540
638
  KeyUnavailableError,
541
639
  BudgetExceededError,
640
+ LeaseExpiredError as LeaseExpiredError2,
641
+ LeaseLimitError,
642
+ StaleOperandError,
542
643
  isCloudRoaringError,
543
644
  isWriteConflictError,
544
645
  isTransientError,
545
646
  isNotFoundError,
546
647
  isIntegrityError,
547
648
  isValidationError,
649
+ isLeaseExpiredError,
650
+ isLeaseLimitError,
651
+ isStaleOperandError,
548
652
  CrbmReader,
549
653
  BufferReader,
550
654
  CountingMetricsSink,
@@ -575,6 +679,36 @@ function requireScope(options, op) {
575
679
  );
576
680
  }
577
681
  }
682
+ var viewOf;
683
+ var MATERIALIZE_MANY_KEYS = [
684
+ "operands",
685
+ "outputs",
686
+ "keep",
687
+ "pin",
688
+ "maxBufferedBytes",
689
+ "budget",
690
+ "publishConcurrency",
691
+ "concurrency",
692
+ "allowAbsentOperands",
693
+ "after",
694
+ "through",
695
+ "feed",
696
+ "mayBeEmpty"
697
+ ];
698
+ var MATERIALIZE_MANY_OUTPUT_KEYS = [
699
+ "dest",
700
+ "expr",
701
+ "exclude",
702
+ "guard",
703
+ "allowEmpty",
704
+ "metadata",
705
+ "audit",
706
+ "keep"
707
+ ];
708
+ var DEFAULT_MAX_BUFFERED_BYTES = 256 * 1024 * 1024;
709
+ var DEFAULT_PUBLISH_CONCURRENCY = 8;
710
+ var DEFAULT_BATCH_CONCURRENCY = 1;
711
+ var PIN_PARALLELISM = 16;
578
712
  function resolveStorageSource(options, clock) {
579
713
  const storage = options.storage;
580
714
  if (storage === null || typeof storage !== "object") {
@@ -687,6 +821,13 @@ var CloudRoaring = class _CloudRoaring {
687
821
  requireEncryption;
688
822
  /** Resolved store-level per-op budget (null = disabled); the admin scans use it, with a per-op override. */
689
823
  budget;
824
+ /**
825
+ * Moved at the start and at the end of each `eraseSubject` this store runs. A `materializeMany` call with a feed records
826
+ * it when it begins and is refused once it has moved: it reads caller memory, which no erasure can reach.
827
+ */
828
+ erasureEpoch = 0;
829
+ /** How many `eraseSubject` calls of this store are running: while any is, every check of the counter counts as moved. */
830
+ erasuresRunning = 0;
690
831
  /**
691
832
  * Refuse any option key the store does not take, at the top level or inside a group, naming each one.
692
833
  *
@@ -740,6 +881,9 @@ var CloudRoaring = class _CloudRoaring {
740
881
  clock
741
882
  });
742
883
  const resolved = resolveStorageSource(options, clock);
884
+ if (metrics !== NOOP_METRICS && isStorageBackend(options.storage)) {
885
+ options.storage.attachMetrics?.(metrics);
886
+ }
743
887
  let storage = resolved.source;
744
888
  if (options.retry !== false) {
745
889
  const { onRetry: userOnRetry, ...ov } = options.retry ?? {};
@@ -837,7 +981,7 @@ var CloudRoaring = class _CloudRoaring {
837
981
  clock: this.clock,
838
982
  metrics: this.metrics,
839
983
  materialize: (dest, ids, op, options2) => this.materialize(dest, ids, op, options2),
840
- pinned: (r, e) => this.pinSegment(r, e),
984
+ pinned: (r, e, l, at) => this.pinSegment(r, e, l, at),
841
985
  combineEngine: (handles) => this.engineForCombine(handles),
842
986
  expiresAt
843
987
  });
@@ -923,6 +1067,440 @@ var CloudRoaring = class _CloudRoaring {
923
1067
  collected: result.collected
924
1068
  };
925
1069
  }
1070
+ /**
1071
+ * **Hold a set of ids in memory**, to be an operand of {@link CloudRoaring.materializeMany} and of no other verb.
1072
+ *
1073
+ * ```ts
1074
+ * const vip = await store.memory([7, 42, 99_001]);
1075
+ * await store.materializeMany({ operands: { vip, engaged: store.segment('cond-engaged') }, outputs: [...], keep: 12 });
1076
+ * vip.release();
1077
+ * ```
1078
+ *
1079
+ * It takes what {@link CloudRoaring.load} takes as input (ids as a sync or async iterable or a typed array, `{ bitmap }`
1080
+ * or `{ serialized }`) and checks it the same way, with the same errors: the size cap, the structure, the safe
1081
+ * deserializer and each id's range. **Only a real `Uint32Array` skips the per-id range check**; any other typed array goes
1082
+ * through it, so `Int32Array([-1])` and `Float64Array([1.5])` throw {@link ValidationError}. An object with an `ascending`
1083
+ * key is refused: ids that arrive in key order are a `feed`. An empty set is accepted here, and refused by
1084
+ * `materializeMany` unless its name is in `mayBeEmpty`.
1085
+ *
1086
+ * The ids are held as the chunks a stored generation would hold, until {@link MemoryOperand.release}; between calls that
1087
+ * memory is the caller's, and during a call it counts against `maxBufferedBytes`. There is no size option: the load's
1088
+ * own cap applies to a serialized input.
1089
+ *
1090
+ * **Erasure.** Only `eraseSubject` moves the store's counter (at its start and at its end). One that started in this store
1091
+ * since the handle began to be made, or is still running, makes a call that reads the handle fail the outputs that read it with
1092
+ * {@link StaleOperandError} (`reason: 'erased'`), before any request and again just before each publish. An erasure in
1093
+ * another store or another process is not seen, nor are `dropSegment`, `eraseNamespace`, `destroySegment` and the free
1094
+ * function `eraseIdFromSegment`, which do not move it: the handle is the caller's copy, so build it from the source of truth at
1095
+ * the start of each refresh and release it at the end.
1096
+ *
1097
+ * @throws {ValidationError} where a load of the same input would.
1098
+ */
1099
+ async memory(input) {
1100
+ const epochAt = this.epochNow();
1101
+ const chunks = await prepareHeld(input, roaringCodec, this.clock);
1102
+ return makeMemoryOperand({ store: this, chunks, epochAt });
1103
+ }
1104
+ /**
1105
+ * **Many outputs, each operand read once per group.** Each output is an expression over named stored operands (`and`, `or`, `andNot`, nested
1106
+ * to depth 64) published as a new generation of its own `dest`, exactly as an `*Into` would publish it, but computed
1107
+ * together: each operand's chunks are read once for all the outputs that use them, instead of once per output and
1108
+ * once more for every scratch segment a nested expression would otherwise need.
1109
+ *
1110
+ * ```ts
1111
+ * const run = await store.materializeMany({
1112
+ * operands: { us: store.segment('us'), engaged: store.segment('engaged'), optOut: store.segment('opt-out') },
1113
+ * outputs: [
1114
+ * { dest: store.segment('send-1'), expr: { and: ['us', 'engaged'] }, exclude: ['optOut'] },
1115
+ * { dest: store.segment('send-2'), expr: { andNot: ['us', 'engaged'] } },
1116
+ * ],
1117
+ * keep: 3,
1118
+ * });
1119
+ * run.outputs[0]; // a MaterializeResult, or { published: false, error }
1120
+ * ```
1121
+ *
1122
+ * **Results.** `run.outputs[i]` is index-aligned with `outputs` and is exactly what output `i`'s `*Into` would have
1123
+ * returned (a {@link MaterializeResult}: published, or refused with its `reason`), or `{ published: false, error }` for
1124
+ * what it would have thrown. One output's refusal or error never stops another. The call itself throws only for bad
1125
+ * input ({@link ValidationError} naming the output index and path, before any request), an operand that names no
1126
+ * segment (unless `allowAbsentOperands`), and a `budget` its own plan exceeds, the last two before any chunk is read.
1127
+ *
1128
+ * **One generation per operand.** By default every stored operand is pinned for the call: read at the generation it had
1129
+ * when the call started, however long the call runs and whatever loads land meanwhile. That is one generation per
1130
+ * operand, not one instant across operands (two pins are two registry reads). `pin: false` reads live, as a combine does.
1131
+ *
1132
+ * **Every pinned operand an output subtracts is re-checked.** An output subtracts an operand when it is in its `exclude`
1133
+ * or under any entry of an `andNot` after the first, at any depth. Immediately before the publishes the call re-reads the
1134
+ * registry row of each such operand and compares its generation and incarnation with what was pinned (a name deleted and
1135
+ * created again is a replacement though it is generation 0 again). If one was replaced, each output that subtracts it is
1136
+ * not published and carries {@link StaleOperandError}; the others publish. It costs one registry read per such operand per
1137
+ * group, and `stats.operands` carries every operand's pinned, start and end generation and whether it moved.
1138
+ *
1139
+ * **Memory.** Everything the pass holds resident is counted against `maxBufferedBytes` (default 256 MiB): each output's
1140
+ * buffered chunks, the operand streams' ranges, the chunks being evaluated, and the plan itself, and a call whose plan alone
1141
+ * passes it is refused before any chunk is read. When the outputs do not fit, they run in groups, each reading its operands
1142
+ * once (so the operand reads grow with the total output size over the budget), and a group's buffers are released only as
1143
+ * its publishes settle. A chunk count the index understated does not grow a group past the budget: the largest buffer is
1144
+ * dropped and that output re-runs alone. An output that cannot fit alone has `BudgetExceededError` as its result. The count
1145
+ * is the pass's own: process memory is more, measured at 1.6 times the ledger's high water on Linux and 2.5 to 5 times on
1146
+ * macOS on the in-memory backend (the guide has the shapes and what is not counted).
1147
+ *
1148
+ * **Untrusted bytes.** Every operand chunk is decoded through the checks on untrusted tier data. A key an operand's index
1149
+ * lists whose bytes are missing is an error for the outputs that read the operand, never an empty chunk.
1150
+ *
1151
+ * **Handles.** An expired or released handle anywhere in the call (an operand or a `dest`) is refused before any request,
1152
+ * as on the `*Into` verbs. A leased operand's lease, and an operand's deadline, are checked before each chunk key
1153
+ * is read: a lapse fails only the outputs that read that operand, never reads empty. Each `dest`'s lease and deadline
1154
+ * are checked again just before its publish.
1155
+ *
1156
+ * **The chunk cache is bypassed:** the pass reads through the storage source and never writes the decoded-chunk cache, so
1157
+ * a batch does not evict other readers' hot chunks. Each publish still invalidates its `dest` as an `*Into` does.
1158
+ *
1159
+ * **A limit of the form.** An output cannot name another output of the same call: a `dest` that is also an operand is
1160
+ * refused. Chain two calls.
1161
+ *
1162
+ * **Held operands.** An operand may be a {@link MemoryOperand} from {@link CloudRoaring.memory}: ids the caller holds,
1163
+ * read beside the stored ones and giving the same output byte for byte. It is read from memory (no request, and the
1164
+ * shared chunk cache never sees it), counts against `maxBufferedBytes` while the call runs, and is refused when empty
1165
+ * unless its name is in `mayBeEmpty`. A handle from another store, or a released one, is a {@link ValidationError}. An
1166
+ * erasure in this store since the handle was made fails the outputs that read it with {@link StaleOperandError}
1167
+ * (`reason: 'erased'`).
1168
+ *
1169
+ * **A feed.** `feed: { names, records, counts }` adds operands that arrive as records in chunk-key order, for conditions
1170
+ * too many to hold or store. Each record is checked before the pass sees it (see {@link MaterializeManyFeedRecord}): a bad
1171
+ * feed is refused, never read as fewer members, and **a fed output is published only after the whole feed was read and every
1172
+ * end-of-feed check passed**, so a bad feed refuses every fed output and publishes none of them; outputs that name only
1173
+ * stored operands are unaffected. At the end of the feed `counts` must equal the ids the feed held for each name, and a
1174
+ * name that appeared in no record is refused unless it is in `mayBeEmpty`. A feed is read once, so the call runs its outputs
1175
+ * as one group and **`maxBufferedBytes` is required**: each record is converted to compressed bitmaps as it arrives, the key
1176
+ * in hand and its fed outputs are counted against the budget, and the call fails as soon as the count passes it, with
1177
+ * {@link BudgetExceededError} for every fed output. **Erasure:** a fed call is refused at its next record, and before each
1178
+ * fed publish, once `eraseSubject` has started in this store ({@link StaleOperandError}, `reason: 'erased'`).
1179
+ *
1180
+ * ```ts
1181
+ * await store.materializeMany({
1182
+ * operands: { unsub: store.segment('global-unsub') },
1183
+ * feed: {
1184
+ * names: ['us', 'engaged'],
1185
+ * records: warehouseRecords(), // yields { key, operands: { us: Uint32Array, engaged: Uint32Array } } by key
1186
+ * counts: () => warehouseCounts(),
1187
+ * },
1188
+ * maxBufferedBytes: 512 * 1024 * 1024,
1189
+ * outputs: [{ dest: store.segment('send-1'), expr: { and: ['us', 'engaged'] }, exclude: ['unsub'] }],
1190
+ * keep: 12,
1191
+ * });
1192
+ * ```
1193
+ */
1194
+ async materializeMany(options) {
1195
+ if (typeof options !== "object" || options === null) {
1196
+ throw new ValidationError2(
1197
+ "materializeMany needs an options object with `operands`, `outputs` and `keep`"
1198
+ );
1199
+ }
1200
+ const epochAt = options.feed === void 0 ? 0 : this.epochNow();
1201
+ const unknown = Object.keys(options).filter(
1202
+ (k) => !MATERIALIZE_MANY_KEYS.includes(k)
1203
+ );
1204
+ if (unknown.length > 0) {
1205
+ throw new ValidationError2(
1206
+ `materializeMany: unknown option${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")}`
1207
+ );
1208
+ }
1209
+ const operandsIn = options.operands;
1210
+ const outputsIn = options.outputs;
1211
+ const keep = options.keep;
1212
+ const after = options.after;
1213
+ const through = options.through;
1214
+ const pinOption = options.pin ?? true;
1215
+ const feedIn = options.feed;
1216
+ const mayBeEmptyIn = options.mayBeEmpty;
1217
+ if (feedIn !== void 0 && options.maxBufferedBytes === void 0) {
1218
+ throw new ValidationError2(
1219
+ "materializeMany: maxBufferedBytes is required with a feed: the feed is read once, so the call runs as one group and the budget is what bounds it"
1220
+ );
1221
+ }
1222
+ const maxBufferedBytes = options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
1223
+ const publishConcurrency = options.publishConcurrency ?? DEFAULT_PUBLISH_CONCURRENCY;
1224
+ const concurrency = options.concurrency ?? DEFAULT_BATCH_CONCURRENCY;
1225
+ const budgetOption = options.budget;
1226
+ const allowAbsentOperands = options.allowAbsentOperands;
1227
+ if (typeof pinOption !== "boolean")
1228
+ throw new ValidationError2("materializeMany: pin must be a boolean");
1229
+ if (allowAbsentOperands !== void 0 && typeof allowAbsentOperands !== "boolean") {
1230
+ throw new ValidationError2("materializeMany: allowAbsentOperands must be a boolean");
1231
+ }
1232
+ if (typeof operandsIn !== "object" || operandsIn === null || Array.isArray(operandsIn)) {
1233
+ throw new ValidationError2("materializeMany: operands must be an object of name to segment");
1234
+ }
1235
+ if (!Array.isArray(outputsIn) || outputsIn.length === 0) {
1236
+ throw new ValidationError2("materializeMany: outputs must be a non-empty array");
1237
+ }
1238
+ const names = Object.keys(operandsIn);
1239
+ const handles = /* @__PURE__ */ new Map();
1240
+ const heldHandles = /* @__PURE__ */ new Map();
1241
+ for (const name of names) {
1242
+ const handle = operandsIn[name];
1243
+ if (handle instanceof MemoryOperand) {
1244
+ const view = heldOf(handle);
1245
+ if (view === void 0) {
1246
+ throw new ValidationError2(
1247
+ `materializeMany: operands["${name}"] is not a memory operand from store.memory()`
1248
+ );
1249
+ }
1250
+ if (view.store !== this) {
1251
+ throw new ValidationError2(
1252
+ `materializeMany: operands["${name}"] was made by another store: a memory operand belongs to the store that made it`
1253
+ );
1254
+ }
1255
+ if (view.chunks.released) {
1256
+ throw new ValidationError2(
1257
+ `materializeMany: operands["${name}"] was released: make another with store.memory()`
1258
+ );
1259
+ }
1260
+ heldHandles.set(name, view);
1261
+ continue;
1262
+ }
1263
+ if (!(handle instanceof Segment)) {
1264
+ throw new ValidationError2(
1265
+ `materializeMany: operands["${name}"] must be a segment from store.segment() or a memory operand from store.memory()`
1266
+ );
1267
+ }
1268
+ handles.set(name, handle);
1269
+ }
1270
+ const heldMayBeEmpty = /* @__PURE__ */ new Set();
1271
+ let fedMayBeEmpty = mayBeEmptyIn;
1272
+ if (mayBeEmptyIn !== void 0) {
1273
+ if (Array.isArray(mayBeEmptyIn)) {
1274
+ const rest = [];
1275
+ for (const name of mayBeEmptyIn) {
1276
+ if (typeof name === "string" && heldHandles.has(name)) heldMayBeEmpty.add(name);
1277
+ else rest.push(name);
1278
+ }
1279
+ fedMayBeEmpty = rest;
1280
+ }
1281
+ if (feedIn === void 0 && (heldHandles.size === 0 || !Array.isArray(fedMayBeEmpty) || fedMayBeEmpty.length > 0)) {
1282
+ throw new ValidationError2(
1283
+ "materializeMany: mayBeEmpty names fed operands, and the call has no feed"
1284
+ );
1285
+ }
1286
+ }
1287
+ const operandKeys = new Set([...handles.values()].map((h) => h.key()));
1288
+ const outputs = [];
1289
+ const destKeys = /* @__PURE__ */ new Set();
1290
+ outputsIn.forEach((raw, i) => {
1291
+ if (typeof raw !== "object" || raw === null) {
1292
+ throw new ValidationError2(`outputs[${i}] must be an object`);
1293
+ }
1294
+ const bad = Object.keys(raw).filter(
1295
+ (k) => !MATERIALIZE_MANY_OUTPUT_KEYS.includes(k)
1296
+ );
1297
+ if (bad.length > 0) {
1298
+ throw new ValidationError2(
1299
+ `outputs[${i}]: unknown option${bad.length > 1 ? "s" : ""} ${bad.map((k) => `"${k}"`).join(", ")}`
1300
+ );
1301
+ }
1302
+ const spec = raw;
1303
+ const dest = spec.dest;
1304
+ if (!(dest instanceof Segment)) {
1305
+ throw new ValidationError2(`outputs[${i}].dest must be a segment from store.segment()`);
1306
+ }
1307
+ if (operandKeys.has(dest.key())) {
1308
+ throw new ValidationError2(
1309
+ `outputs[${i}].dest is also an operand of the call: one call is one pass over operands that do not change under it, so chain two calls`
1310
+ );
1311
+ }
1312
+ if (destKeys.has(dest.key())) {
1313
+ throw new ValidationError2(
1314
+ `outputs[${i}].dest names a segment another output of the call writes`
1315
+ );
1316
+ }
1317
+ destKeys.add(dest.key());
1318
+ outputs.push({ spec, dest });
1319
+ });
1320
+ const storeCeiling = this.budget === null || this.budget.maxRequests !== DEFAULT_BUDGET.maxRequests ? this.budget : void 0;
1321
+ const budget = budgetOption === void 0 ? storeCeiling : resolvePerOpBudget(budgetOption, this.budget);
1322
+ const operandSpecs = (pins2) => names.map((name) => {
1323
+ const view = heldHandles.get(name);
1324
+ if (view !== void 0) {
1325
+ return {
1326
+ name,
1327
+ ref: { segment: name },
1328
+ held: {
1329
+ chunks: view.chunks,
1330
+ moved: () => this.epochMoved(view.epochAt),
1331
+ mayBeEmpty: heldMayBeEmpty.has(name)
1332
+ }
1333
+ };
1334
+ }
1335
+ const handle = handles.get(name);
1336
+ const seg = viewOf(handle);
1337
+ const pinnedAt = pins2.get(handle.key());
1338
+ const guarded = seg.expiresAt !== void 0 || handle.lease !== void 0;
1339
+ return {
1340
+ name,
1341
+ ref: seg.ref,
1342
+ ...guarded ? { check: () => this.assertReadable("materializeMany", handle) } : {},
1343
+ ...pinnedAt === void 0 ? {} : {
1344
+ pinnedGeneration: pinnedAt.generation,
1345
+ pinnedVersion: pinnedAt.version,
1346
+ pinnedFingerprint: pinnedAt.fingerprint ?? null
1347
+ },
1348
+ current: (read) => this.currentRowOf(seg.ref, read?.fingerprint === true)
1349
+ };
1350
+ });
1351
+ const request = (pins2) => ({
1352
+ operands: operandSpecs(pins2),
1353
+ outputs: outputs.map(({ spec, dest }, i) => ({
1354
+ expr: spec.expr,
1355
+ ...spec.exclude === void 0 ? {} : { exclude: spec.exclude },
1356
+ ...spec.allowEmpty === void 0 ? {} : { allowEmpty: spec.allowEmpty },
1357
+ ...spec.guard === void 0 ? {} : { guard: spec.guard },
1358
+ ...spec.metadata === void 0 ? {} : { metadata: spec.metadata },
1359
+ ...spec.keep === void 0 ? {} : { keep: spec.keep },
1360
+ beforePublish: () => this.assertWritable(`outputs[${i}]`, dest),
1361
+ // What a settled publish proves it sent: a published one wrote its object and moved the pointer, a refused one
1362
+ // wrote its object and deleted it.
1363
+ requestsOf: (result) => ({ get: 0, put: result.published ? 2 : 1 }),
1364
+ publish: (chunks, write) => this.materialize(viewOf(dest).ref, new CombineChunks(chunks), "materializeMany", {
1365
+ ...write,
1366
+ ...spec.audit === void 0 ? {} : { audit: spec.audit }
1367
+ })
1368
+ })),
1369
+ keep,
1370
+ ...after === void 0 ? {} : { after },
1371
+ ...through === void 0 ? {} : { through },
1372
+ maxBufferedBytes,
1373
+ publishConcurrency,
1374
+ concurrency,
1375
+ ...budget === void 0 ? {} : { budget },
1376
+ ...allowAbsentOperands === void 0 ? {} : { allowAbsentOperands },
1377
+ ...feedIn === void 0 ? {} : { feed: this.feedOf(feedIn, fedMayBeEmpty, epochAt) }
1378
+ });
1379
+ const compiled = compileCombineMany(request(/* @__PURE__ */ new Map()));
1380
+ for (const { dest } of outputs) this.assertWritable("materializeMany", dest);
1381
+ for (const handle of handles.values()) this.assertReadable("materializeMany", handle);
1382
+ this.lifecycleDeps("materializeMany");
1383
+ const crbm = this.crbmSource;
1384
+ if (crbm === void 0) {
1385
+ throw new UnsupportedError(
1386
+ "materializeMany needs the `.crbm` storage source \u2014 pass a backend or a raw IStorageDriver as `storage`"
1387
+ );
1388
+ }
1389
+ const held = /* @__PURE__ */ new Map();
1390
+ for (const [name, handle] of handles) {
1391
+ const key = handle.key();
1392
+ const at = handle.pinnedAt;
1393
+ const prior = held.get(key);
1394
+ if (prior === void 0) {
1395
+ held.set(key, { at, handle });
1396
+ } else if (prior.at !== void 0 && at !== void 0) {
1397
+ if (!samePin(prior.at, at)) {
1398
+ throw new ValidationError2(
1399
+ `materializeMany: operand "${name}" holds a segment another operand holds, ${twoPins(prior.at, at)}: one call reads a segment at one generation`
1400
+ );
1401
+ }
1402
+ } else if (prior.at !== void 0 || at !== void 0) {
1403
+ if (!pinOption) {
1404
+ throw new ValidationError2(
1405
+ `materializeMany: operand "${name}" holds a segment another operand holds, ${twoPins(prior.at, at)}: one call reads a segment at one generation`
1406
+ );
1407
+ }
1408
+ if (at !== void 0) held.set(key, { at, handle });
1409
+ }
1410
+ }
1411
+ const pins = /* @__PURE__ */ new Map();
1412
+ const toPin = [];
1413
+ for (const [key, { at, handle }] of held) {
1414
+ if (at !== void 0) pins.set(key, at);
1415
+ else if (pinOption) toPin.push([key, handle]);
1416
+ else pins.set(key, void 0);
1417
+ }
1418
+ const taken = await mapWithConcurrency(
1419
+ toPin,
1420
+ PIN_PARALLELISM,
1421
+ async ([key, handle]) => [key, (await handle.pin()).pinnedAt]
1422
+ );
1423
+ for (const [key, at] of taken) pins.set(key, at);
1424
+ const live = new Map([...pins].filter((e) => e[1] !== void 0));
1425
+ const pinnedSource = new PinnedStorageChunkSource(crbm, live);
1426
+ const source = this.retryOptions === void 0 ? pinnedSource : new RetryingStorageChunkSource(pinnedSource, this.retryOptions);
1427
+ const final = rebindCombineMany(compiled, operandSpecs(pins));
1428
+ const run = await runCombineMany(final, { source, codec: roaringCodec, clock: this.clock });
1429
+ return {
1430
+ outputs: run.outputs.map(
1431
+ (o) => o.ok ? o.value : {
1432
+ published: false,
1433
+ error: o.error instanceof Error ? o.error : new Error(String(o.error))
1434
+ }
1435
+ ),
1436
+ stats: run.stats
1437
+ };
1438
+ }
1439
+ epochNow() {
1440
+ return this.erasureEpoch;
1441
+ }
1442
+ /**
1443
+ * Whether an erasure has started in this store since `at` was read, or was running when it was: one that is still
1444
+ * running is seen by the count of those running, and one that has ended since by the counter, which its end moved.
1445
+ * The one check every call that reads caller memory uses.
1446
+ */
1447
+ epochMoved(at) {
1448
+ return this.erasuresRunning > 0 || this.epochNow() !== at;
1449
+ }
1450
+ /** A call's feed with the store's erasure counter: read once here, checked by the combine before any record is read. */
1451
+ feedOf(feed, mayBeEmpty, epochAt) {
1452
+ if (typeof feed !== "object" || feed === null || Array.isArray(feed)) {
1453
+ throw new ValidationError2(
1454
+ "materializeMany: feed must be an object with names, records and counts"
1455
+ );
1456
+ }
1457
+ const f = feed;
1458
+ return {
1459
+ names: f.names,
1460
+ records: f.records,
1461
+ counts: f.counts,
1462
+ ...mayBeEmpty === void 0 ? {} : { mayBeEmpty },
1463
+ epoch: { moved: () => this.epochMoved(epochAt) }
1464
+ };
1465
+ }
1466
+ /**
1467
+ * The registry row of `ref`, read straight from the registry, not from a snapshot: a re-check must see a move. With
1468
+ * `fingerprint`, the current generation's object is opened too (one tail read, as a pin of it makes), and its
1469
+ * fingerprint is returned beside the row: a generation's number is identified by its row and its object, since a number
1470
+ * can be taken again once its object is gone.
1471
+ */
1472
+ async currentRowOf(ref, fingerprint) {
1473
+ const registry = this.requireRegistry("materializeMany");
1474
+ if (fingerprint && this.crbmSource !== void 0) {
1475
+ const crbm = this.crbmSource;
1476
+ const at = await this.withRetries(() => crbm.pinGeneration(ref));
1477
+ const colon = at?.version.indexOf(":") ?? -1;
1478
+ if (at !== null && colon >= 0) {
1479
+ return {
1480
+ generation: at.generation,
1481
+ token: at.version.slice(colon + 1),
1482
+ fingerprint: at.fingerprint
1483
+ };
1484
+ }
1485
+ }
1486
+ const row = await this.withRetries(() => registry.get(ref));
1487
+ return row === null ? null : { generation: row.currentGen, token: row.token };
1488
+ }
1489
+ /** Throw what a read of `handle` must throw now: its lease error, or a refusal for a deadline that has passed. */
1490
+ assertReadable(op, handle) {
1491
+ const view = viewOf(handle);
1492
+ const lease = view.leaseError();
1493
+ if (lease !== void 0) throw lease;
1494
+ if (view.expired()) {
1495
+ throw new ValidationError2(
1496
+ `${op}: refusing to read "${view.ref.segment}", whose handle has expired \u2014 an expired handle reads as empty, so a generation built from it would be short. Open the handle without \`expiresAt\` if you meant to materialise.`
1497
+ );
1498
+ }
1499
+ }
1500
+ /** {@link assertReadable} for a destination, named by where it sits in the call. */
1501
+ assertWritable(where, dest) {
1502
+ this.assertReadable(where, dest);
1503
+ }
926
1504
  /**
927
1505
  * **Subject access (GDPR Art. 15 / CCPA right-to-know): which segments is this id a member of?**
928
1506
  *
@@ -1031,6 +1609,16 @@ var CloudRoaring = class _CloudRoaring {
1031
1609
  * is `dropSegment` / the `destroySegment`/`eraseNamespace` free functions.
1032
1610
  */
1033
1611
  async eraseSubject(id, options = {}) {
1612
+ this.erasureEpoch++;
1613
+ this.erasuresRunning++;
1614
+ try {
1615
+ return await this.eraseSubjectRun(id, options);
1616
+ } finally {
1617
+ this.erasuresRunning--;
1618
+ this.erasureEpoch++;
1619
+ }
1620
+ }
1621
+ async eraseSubjectRun(id, options) {
1034
1622
  const deps = this.lifecycleDeps("eraseSubject");
1035
1623
  requireScope(options, "eraseSubject");
1036
1624
  validateConcurrency(options.concurrency);
@@ -1262,24 +1850,26 @@ var CloudRoaring = class _CloudRoaring {
1262
1850
  * A refused load deletes the object it wrote before returning — it sits above `currentGen`, where generation
1263
1851
  * collection deliberately never looks — but only while the segment's registry row is unchanged or gone. Once
1264
1852
  * another write has changed the row, the generation number it holds may name another incarnation's live object,
1265
- * so it leaves the orphan rather than risk deleting live data. The orphan is an ordinary generation once a later
1266
- * one is current above it, and collection counts it within `keep`.
1853
+ * so it leaves the orphan rather than risk deleting live data. No row's list names it, so it takes no place in a
1854
+ * window, and a listing deletes it once a later generation is current above it.
1267
1855
  *
1268
- * **Collection is by name for the default `keep`.** With `keep` of 0 or 1, a load that found nothing above the
1269
- * pointer deletes the one generation its publish pushed out of the window and lists nothing; it lists the segment's
1270
- * objects on every sixteenth generation, and on any load that met an object above the pointer or whose check found
1271
- * the current generation's object gone, to take what the name-only passes leave, such as the generations an
1272
- * earlier, wider `keep` held. `keep` of 2 or more lists on every load. {@link LoadResult.collected} then names what
1273
- * the pass deleted by name, and that generation may have been gone already.
1856
+ * **Collection is by name at any `keep` up to 64.** The segment's row records the generations a load keeps, so a load
1857
+ * that found nothing above the pointer deletes the generations its publish pushed out of the window and lists
1858
+ * nothing. It lists the segment's objects on every sixteenth generation, and on any load that met an object above the
1859
+ * pointer or whose check found the current generation's object gone, to take what the name-only passes leave, such as
1860
+ * an object a crashed load left below the pointer. A `keep` above 64 records no list and lists on every load, and so
1861
+ * does the first load of a row that records none, which then records it. {@link LoadResult.collected} then names
1862
+ * what the pass deleted by name, and one name may have been gone already.
1274
1863
  *
1275
1864
  * What it **throws** for is a fault rather than an outcome: invalid options or ids, and a crypto-shredded
1276
1865
  * segment (`ValidationError`); a key the keystore cannot provide (`KeyUnavailableError`); a current generation
1277
1866
  * that will not open when a guard has to read its size, which it does only when the row has no usable summary of it
1278
- * (`IntegrityError`); a driver failure; and a collection pass
1279
- * by listing that could not prove the segment was still the same one (`WriteConflictError`). That one, and a
1280
- * failure in the collection's own reads or deletes, can be raised **after** the publish already landed, so a throw
1281
- * does not by itself mean the load did not take effect — re-read the pointer rather than assuming. A collection by
1282
- * name that finds the segment changed returns an empty `collected` instead.
1867
+ * (`IntegrityError`); a driver failure; and a fault in the collection's own reads, listing or deletes, or in the write
1868
+ * that records the window (anything but a lost race). A collection fault can be raised **after** the publish already
1869
+ * landed, so a throw does not by itself mean the load did not take effect — re-read the pointer rather than assuming.
1870
+ * A collection, by name or by listing, that finds another writer has moved the row since the publish (a load, a
1871
+ * rollback, a purge) stops and returns what it deleted in `collected`: a load that took effect does not throw for a
1872
+ * race it won.
1283
1873
  *
1284
1874
  * **A `TransientError` from the registry write can leave the publish unsettled, and deletes nothing.** The
1285
1875
  * generation's object is sent again after a throttle where the backend allows it (a write id tells a first send
@@ -1351,7 +1941,11 @@ var CloudRoaring = class _CloudRoaring {
1351
1941
  validateSegmentRef(ref);
1352
1942
  const deps = this.lifecycleDeps("dropSegment");
1353
1943
  try {
1354
- return await dropSegment(ref, { registry: deps.registry, storage: deps.storage }, options);
1944
+ return await dropSegment(
1945
+ ref,
1946
+ { registry: deps.registry, storage: deps.storage, clock: deps.clock, rng: deps.rng },
1947
+ options
1948
+ );
1355
1949
  } finally {
1356
1950
  if (options.dryRun !== true) this.engine.invalidate(ref);
1357
1951
  }
@@ -1384,7 +1978,11 @@ var CloudRoaring = class _CloudRoaring {
1384
1978
  */
1385
1979
  async setRetention(ref, policy) {
1386
1980
  validateSegmentRef(ref);
1387
- return setSegmentRetention(ref, { registry: this.requireRegistry("setRetention") }, policy);
1981
+ return setSegmentRetention(
1982
+ ref,
1983
+ { registry: this.requireRegistry("setRetention"), clock: this.clock, rng: this.rng },
1984
+ policy
1985
+ );
1388
1986
  }
1389
1987
  /**
1390
1988
  * **The stored retention policy**, or `null` if the segment has none (or has no registry row, or is a
@@ -1403,7 +2001,11 @@ var CloudRoaring = class _CloudRoaring {
1403
2001
  */
1404
2002
  async clearRetention(ref) {
1405
2003
  validateSegmentRef(ref);
1406
- return clearSegmentRetention(ref, { registry: this.requireRegistry("clearRetention") });
2004
+ return clearSegmentRetention(ref, {
2005
+ registry: this.requireRegistry("clearRetention"),
2006
+ clock: this.clock,
2007
+ rng: this.rng
2008
+ });
1407
2009
  }
1408
2010
  /**
1409
2011
  * **Run the retention sweep: retire every segment whose `expiresAt` has passed.** This is the call that acts on
@@ -1466,7 +2068,7 @@ var CloudRoaring = class _CloudRoaring {
1466
2068
  async retireExpired(options = {}) {
1467
2069
  const deps = this.lifecycleDeps("retireExpired");
1468
2070
  const result = await retireExpired(
1469
- { registry: deps.registry, storage: deps.storage },
2071
+ { registry: deps.registry, storage: deps.storage, clock: this.clock, rng: this.rng },
1470
2072
  { ...options, now: options.now ?? this.clock.now() }
1471
2073
  );
1472
2074
  if (options.dryRun !== true) {
@@ -1476,6 +2078,57 @@ var CloudRoaring = class _CloudRoaring {
1476
2078
  }
1477
2079
  return result;
1478
2080
  }
2081
+ /**
2082
+ * **Remove the registry rows that releases before 0.12.0 left as `deleted: true` envelopes.** Those releases never
2083
+ * removed a row they deleted, and no later call removes it either: it is invisible to every read, and every full
2084
+ * listing of the registry still reads it (one GET each). This is the one call that removes them. It is an admin call,
2085
+ * not a sweep, and nothing schedules it: run it once after the last process on a release before 0.12.0 is gone.
2086
+ *
2087
+ * ```ts
2088
+ * const preview = await store.reapRegistryTombstones({ dryRun: true });
2089
+ * console.log(`would remove ${preview.wouldReap} of ${preview.examined} rows read`);
2090
+ *
2091
+ * const done = await store.reapRegistryTombstones({ confirmNoLegacyWriters: true });
2092
+ * if (done.limited) runAgain(); // `limit` (default 1,000) stopped the run with rows unread
2093
+ * ```
2094
+ *
2095
+ * **What it removes.** Only a row that is `deleted: true` and whose token carries no incarnation id, which is what a
2096
+ * release before 0.12.0 left, or a 0.12.0 or later release left by deleting a row born before 0.12.0 (both are safe to
2097
+ * remove), whatever its `status`. It never touches a live row, a `destroyed` row that is not `deleted` (a crypto-shred's
2098
+ * or a `dropSegment`'s tombstone: the attestation of an erasure, which it cannot tell from another), or a deleted row
2099
+ * with an incarnation id, and it never touches a generation. **So it does not clean a bucket completely**: the
2100
+ * `destroyed` tombstones `dropSegment` leaves stay, so do live rows written before 0.12.0, and so does a tombstone a
2101
+ * 0.12.0 or later release wrote while `conditionalDelete` was off (GCS by default, S3 on a custom endpoint): every full
2102
+ * listing still reads it, and this call neither removes it nor runs on such a store.
2103
+ * It also covers the library's own bookkeeping rows when `namespace` is not set, by the same rule. The default
2104
+ * namespace cannot be scoped to: leave `namespace` out to cover it.
2105
+ *
2106
+ * **The two things it asks.** `confirmNoLegacyWriters: true` is required for a real run (without it, a
2107
+ * `ValidationError`, and nothing is requested): your statement that no process on a release before 0.12.0 writes
2108
+ * this registry, since one that did would, re-creating a removed name, issue the removed row's tokens again. And the
2109
+ * registry must apply a delete under a precondition: where its `conditionalDelete` capability is off (GCS by default,
2110
+ * S3 on a custom endpoint) it throws `CapabilityError` before any request, dry run included. A registry that keeps
2111
+ * no such rows (the in-memory and local-filesystem ones) throws `UnsupportedError`.
2112
+ *
2113
+ * **Each removal is fenced** on the version it read, so a `create` that lands over the envelope first wins, the
2114
+ * delete is refused, and the row is counted as `skipped.raced` (which also counts a row already gone). That holds where
2115
+ * the backend applies the precondition, which auto-detection checks (S3 only on an AWS host). On an endpoint that
2116
+ * ignores `If-Match` (MinIO, fake-gcs-server), a `conditionalDelete: true` you set makes this an unfenced delete: run
2117
+ * it with every writer stopped. On a versioned bucket a delete leaves a delete marker and the earlier version, record
2118
+ * included, so the name is not erased from the bucket. A `create` that had already read the envelope and
2119
+ * whose write meets the removal throws `WriteConflictError`, which this library does not retry: retry the create, and
2120
+ * it finds no row and writes one.
2121
+ *
2122
+ * **Cost** over R rows read and E removed: `ceil(R / 1000)` LIST requests, R GETs and E DELETEs (a dry run, no
2123
+ * DELETEs). `limit` bounds the removals, and the run stops listing once it is spent. **A run is not resumable**: each
2124
+ * lists and reads from the start (R GETs whatever the `limit`), a dry run with a `limit` reports the same first rows
2125
+ * every time, so `wouldReap` is a total only when `limited` is `false`, and `limited` is `true` whenever the limit was
2126
+ * spent with keys left, removable or not. An unreadable object stops the run with an error naming its key, and
2127
+ * removes nothing more: it may be a newer release's row. Scope the run with `namespace` to get past one.
2128
+ */
2129
+ async reapRegistryTombstones(options = {}) {
2130
+ return reapRegistryTombstones(this.requireRegistry("reapRegistryTombstones"), options);
2131
+ }
1479
2132
  /**
1480
2133
  * **Forget everything this store has cached about a segment.** Use it after destroying or retiring a segment
1481
2134
  * through a path this store did not perform itself.
@@ -1522,14 +2175,54 @@ var CloudRoaring = class _CloudRoaring {
1522
2175
  * pinned view reports the version captured at pin time, marked as a pin's, so its decoded chunks are never
1523
2176
  * those of a live read that fetched across a publish (see {@link PinnedStorageChunkSource.currentVersion}).
1524
2177
  */
1525
- async pinSegment(ref, expiresAt) {
2178
+ async pinSegment(ref, expiresAt, leaseUntil, named) {
1526
2179
  const crbm = this.crbmSource;
1527
2180
  if (crbm === void 0) {
1528
2181
  throw new UnsupportedError(
1529
2182
  "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)"
1530
2183
  );
1531
2184
  }
1532
- const at = await this.withRetries(() => crbm.pinGeneration(ref));
2185
+ let hold;
2186
+ let take;
2187
+ if (leaseUntil !== void 0) {
2188
+ const registry = this.requireRegistry("pin({ leaseUntil })");
2189
+ if (registry.capabilities().canWrite === false) {
2190
+ throw new UnsupportedError(
2191
+ "pin({ leaseUntil }) needs a registry that can write a row, and this store's cannot in this runtime"
2192
+ );
2193
+ }
2194
+ const holder = this.mintHolder();
2195
+ const deps = { registry, clock: this.clock, rng: this.rng };
2196
+ const generationOf = (row) => named?.generation ?? row.currentGen;
2197
+ hold = {
2198
+ holder,
2199
+ until: leaseUntil,
2200
+ released: false,
2201
+ done: false,
2202
+ release: () => releaseLease(ref, deps, holder)
2203
+ };
2204
+ take = {
2205
+ take: (row) => takeLease(ref, deps, {
2206
+ holder,
2207
+ generation: generationOf(row),
2208
+ until: leaseUntil,
2209
+ row,
2210
+ current: named === void 0
2211
+ })
2212
+ };
2213
+ }
2214
+ let at;
2215
+ try {
2216
+ at = await this.withRetries(
2217
+ () => named === void 0 ? crbm.pinGeneration(ref, take) : crbm.pinGenerationAt(ref, named, take)
2218
+ );
2219
+ if (hold !== void 0 && at === null) {
2220
+ throw new NotFoundError(`segment "${ref.segment}" has no generation to lease`);
2221
+ }
2222
+ } catch (err) {
2223
+ if (hold !== void 0) await hold.release().catch(() => void 0);
2224
+ throw err;
2225
+ }
1533
2226
  const pinnedAt = {
1534
2227
  generation: at?.generation ?? null,
1535
2228
  version: at?.version ?? null,
@@ -1542,12 +2235,20 @@ var CloudRoaring = class _CloudRoaring {
1542
2235
  clock: this.clock,
1543
2236
  metrics: this.metrics,
1544
2237
  materialize: (dest, ids, op, options) => this.materialize(dest, ids, op, options),
1545
- pinned: (r, e) => this.pinSegment(r, e),
2238
+ pinned: (r, e, l, at2) => this.pinSegment(r, e, l, at2),
1546
2239
  combineEngine: (handles) => this.engineForCombine(handles),
1547
2240
  expiresAt,
1548
- pinnedAt
2241
+ pinnedAt,
2242
+ lease: hold
1549
2243
  });
1550
2244
  }
2245
+ /** 16 hex digits from the store's `Rng`, which names the holder of one lease: unique, not secret. */
2246
+ mintHolder() {
2247
+ let id = "";
2248
+ for (let i = 0; i < 4; i++)
2249
+ id += Math.floor(this.rng.next() * 65536).toString(16).padStart(4, "0");
2250
+ return id;
2251
+ }
1551
2252
  /**
1552
2253
  * An engine reading every segment in `pins` at its pinned generation and everything else live, with the store's
1553
2254
  * retries, so a transient fault on a pinned read is retried as it would be on a live one.
@@ -1708,6 +2409,7 @@ var EMPTY_IDS = {
1708
2409
  var AS_IDS = {
1709
2410
  none: EMPTY_IDS,
1710
2411
  failing,
2412
+ guard: guardIds,
1711
2413
  intersect: (engine, refs, opts) => withBatches(engine.intersect(refs, opts), () => engine.intersectBatches(refs, opts)),
1712
2414
  union: (engine, refs, opts) => withBatches(engine.union(refs, opts), () => engine.unionBatches(refs, opts)),
1713
2415
  andNot: (engine, base, refs, opts) => withBatches(engine.andNot(base, refs, opts), () => engine.andNotBatches(base, refs, opts))
@@ -1724,6 +2426,7 @@ var CombineChunks = class {
1724
2426
  };
1725
2427
  var AS_CHUNKS = {
1726
2428
  none: NO_CHUNKS,
2429
+ guard: guardChunks,
1727
2430
  failing: (err) => ({
1728
2431
  [Symbol.asyncIterator]: () => ({ next: () => Promise.reject(err) })
1729
2432
  }),
@@ -1732,6 +2435,39 @@ var AS_CHUNKS = {
1732
2435
  andNot: (engine, base, refs, opts) => engine.andNotChunks(base, refs, opts)
1733
2436
  };
1734
2437
  var KEEP_EVERY_GENERATION = Number.MAX_SAFE_INTEGER;
2438
+ var heldOf;
2439
+ var makeMemoryOperand;
2440
+ var mintingMemory = false;
2441
+ var MemoryOperand = class _MemoryOperand {
2442
+ view;
2443
+ static {
2444
+ heldOf = (operand) => operand.view;
2445
+ makeMemoryOperand = (view) => {
2446
+ mintingMemory = true;
2447
+ try {
2448
+ return new _MemoryOperand(view);
2449
+ } finally {
2450
+ mintingMemory = false;
2451
+ }
2452
+ };
2453
+ }
2454
+ /** Not constructible: a handle comes from {@link CloudRoaring.memory}. */
2455
+ constructor(view) {
2456
+ if (!mintingMemory) {
2457
+ throw new ValidationError2(
2458
+ "a MemoryOperand is not constructed directly; call `await store.memory(ids)`"
2459
+ );
2460
+ }
2461
+ this.view = view;
2462
+ }
2463
+ /**
2464
+ * Zero and drop the bytes it holds. Any later use throws {@link ValidationError}; releasing twice does nothing. A call
2465
+ * still reading it fails the outputs that read it.
2466
+ */
2467
+ release() {
2468
+ this.view.chunks.release();
2469
+ }
2470
+ };
1735
2471
  var makeSegment;
1736
2472
  var minting = false;
1737
2473
  var Segment = class _Segment {
@@ -1750,6 +2486,13 @@ var Segment = class _Segment {
1750
2486
  * pinned handle passed as an **operand** is still read at its pin rather than live.
1751
2487
  */
1752
2488
  pinnedAt;
2489
+ /**
2490
+ * The lease this handle holds, when it came from `pin({ leaseUntil })`; `undefined` otherwise. It reports what was
2491
+ * taken and does not change when the lease ends: a read after `lease.until` (or after {@link Segment.release}) throws
2492
+ * {@link LeaseExpiredError}.
2493
+ */
2494
+ lease;
2495
+ leaseHold;
1753
2496
  static {
1754
2497
  makeSegment = (parts) => {
1755
2498
  minting = true;
@@ -1759,6 +2502,13 @@ var Segment = class _Segment {
1759
2502
  minting = false;
1760
2503
  }
1761
2504
  };
2505
+ viewOf = (segment) => ({
2506
+ ref: segment.ref,
2507
+ pinnedAt: segment.pinnedAt,
2508
+ expiresAt: segment.expiresAt,
2509
+ leaseError: () => segment.leaseError(),
2510
+ expired: () => segment.expired()
2511
+ });
1762
2512
  }
1763
2513
  /**
1764
2514
  * Not constructible: a handle comes from {@link CloudRoaring.segment}, which wires it to the store's engine,
@@ -1779,6 +2529,10 @@ var Segment = class _Segment {
1779
2529
  this.combineEngine = parts.combineEngine;
1780
2530
  this.expiresAt = parts.expiresAt;
1781
2531
  this.pinnedAt = parts.pinnedAt;
2532
+ this.leaseHold = parts.lease;
2533
+ if (parts.lease !== void 0) {
2534
+ this.lease = Object.freeze({ holder: parts.lease.holder, until: parts.lease.until });
2535
+ }
1782
2536
  this.metricsOn = parts.metrics !== NOOP_METRICS;
1783
2537
  }
1784
2538
  /**
@@ -1807,11 +2561,20 @@ var Segment = class _Segment {
1807
2561
  * 0, so a pin of the old segment never reads the new one: what it has already read still answers, as the
1808
2562
  * instant it pinned, and anything it would have to fetch fails with `NotFoundError`, as a swept pin's does.
1809
2563
  *
1810
- * **It is a hold, not a lease.** Nothing here stops a collection deleting the generation underneath
1811
- * you: a pinned read deliberately does **not** heal forward, because silently serving a different generation
1812
- * is the one thing a pin exists to prevent, so it fails instead. Size `keep` to cover your longest pinned
1813
- * 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
1814
- * you are not collecting.
2564
+ * **It is a hold, and `pin({ leaseUntil })` makes it a bounded one.** Without a lease nothing stops a collection
2565
+ * deleting the generation underneath you: a pinned read deliberately does **not** heal forward, because silently
2566
+ * serving a different generation is the one thing a pin exists to prevent, so it fails instead. Size `keep` to cover
2567
+ * your longest pinned job — see [Generations and `keep`](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/loading.md#generations-and-keep) — take the pin on a segment
2568
+ * you are not collecting, or lease it.
2569
+ *
2570
+ * **A lease** (`leaseUntil`: epoch **milliseconds**, after now and at most 14 days out) keeps the generation out of
2571
+ * a load's collection until then, from any process, and the pin is taken with it: the lease is written to the segment's
2572
+ * row before the object is opened. A read of the handle after the lease, or after {@link Segment.release}, throws
2573
+ * {@link LeaseExpiredError} at every read site, including when the handle is an operand or an `exclude` of a combine
2574
+ * or the target of an `*Into`, and never reads empty. A stream checks the lease each time it reads a chunk. A segment
2575
+ * holds at most 64 live leases ({@link LeaseLimitError}). A collector holds a lease 60 seconds past its end, which covers
2576
+ * clocks that differ by that much. Erasure, shred, drop and retention expiry ignore a lease. See
2577
+ * [Hold a generation for a job](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/reading.md#hold-a-generation-for-a-job-a-lease).
1815
2578
  *
1816
2579
  * A segment with no current generation pins nothing and reads empty, exactly as it would unpinned. A pinned
1817
2580
  * segment whose row is later dropped or destroyed fails once it must open its object again, rather than go empty
@@ -1851,8 +2614,144 @@ var Segment = class _Segment {
1851
2614
  * Needs a store built on the `.crbm` storage source (the default when you pass a backend or a raw driver). Throws
1852
2615
  * {@link UnsupportedError} on a store wired with a pre-built source that cannot pin.
1853
2616
  */
1854
- async pin() {
1855
- return this.pinned(this.ref, this.expiresAt);
2617
+ async pin(options) {
2618
+ this.assertLeases([this]);
2619
+ const leaseUntil = this.leaseUntilOf("pin", options);
2620
+ return this.pinned(this.ref, this.expiresAt, leaseUntil);
2621
+ }
2622
+ /**
2623
+ * Hold what this pin names until `leaseUntil`, or `undefined` for a pin with no lease. Refuses an option it does not
2624
+ * know, a value that is not epoch-milliseconds in the future, one past the longest lease, and one past the handle's own
2625
+ * `expiresAt`, before any request is made.
2626
+ */
2627
+ leaseUntilOf(op, options) {
2628
+ if (options === void 0 || options === null) return void 0;
2629
+ if (typeof options !== "object" || Array.isArray(options)) {
2630
+ throw new ValidationError2(`${op}: options must be an object such as { leaseUntil }`);
2631
+ }
2632
+ const unknown = Object.keys(options).filter((k) => k !== "leaseUntil");
2633
+ if (unknown.length > 0) {
2634
+ throw new ValidationError2(
2635
+ `${op}: unknown option${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")}; this version takes { leaseUntil } only`
2636
+ );
2637
+ }
2638
+ const until = options.leaseUntil;
2639
+ if (until === void 0) return void 0;
2640
+ const now = this.clock.now();
2641
+ if (typeof until !== "number" || !Number.isSafeInteger(until)) {
2642
+ throw new ValidationError2(
2643
+ `${op}: leaseUntil must be an integer epoch-MILLISECONDS; got ${String(until)}`
2644
+ );
2645
+ }
2646
+ if (until <= now) {
2647
+ throw new ValidationError2(
2648
+ `${op}: leaseUntil (${until}) is not after now (${now}); it is epoch MILLISECONDS, so a value in seconds reads as long past. Use \`Date.now() + hours * 3_600_000\``
2649
+ );
2650
+ }
2651
+ if (until > now + MAX_LEASE_MS) {
2652
+ throw new ValidationError2(
2653
+ `${op}: leaseUntil is more than 14 days away; the longest lease is ${MAX_LEASE_MS} ms. Take a new lease when a job outlasts it`
2654
+ );
2655
+ }
2656
+ if (this.expiresAt !== void 0 && until > this.expiresAt) {
2657
+ throw new ValidationError2(
2658
+ `${op}: leaseUntil outlasts this handle's own expiresAt, which would have it read empty while it still holds the generation`
2659
+ );
2660
+ }
2661
+ return until;
2662
+ }
2663
+ /**
2664
+ * End this handle's lease now, so a load's collection may take its generation. Idempotent: a handle with no lease, or
2665
+ * one already released, does nothing and makes no request. Every read of the handle after it throws
2666
+ * {@link LeaseExpiredError} (reason `'released'`), including one already under way at its next chunk. A lease that has
2667
+ * ended by its own time needs no release: this then makes no request either, and the registry row's entry is pruned
2668
+ * by a later write. Otherwise it is one registry read and one write; a failure of either is thrown as the registry's own
2669
+ * typed error, and the entry then holds until its time ends, `release()` being safe to call again.
2670
+ */
2671
+ async release() {
2672
+ const hold = this.leaseHold;
2673
+ if (hold === void 0) return;
2674
+ hold.released = true;
2675
+ if (hold.done) return;
2676
+ if (this.clock.now() >= hold.until + LEASE_SKEW_MS) {
2677
+ hold.done = true;
2678
+ return;
2679
+ }
2680
+ await hold.release();
2681
+ hold.done = true;
2682
+ }
2683
+ /**
2684
+ * The error a read of this handle must throw, or `undefined` when it holds no lease or the lease is live. One property
2685
+ * read on a handle with no lease. It is not {@link Segment.expired}: that rule reads empty, and a lease must never.
2686
+ */
2687
+ leaseError() {
2688
+ const hold = this.leaseHold;
2689
+ if (hold === void 0) return void 0;
2690
+ const name = this.ref.namespace ? `${this.ref.namespace}/${this.ref.segment}` : this.ref.segment;
2691
+ if (hold.released) {
2692
+ return new LeaseExpiredError(
2693
+ `the lease on segment "${name}" was released`,
2694
+ hold.until,
2695
+ "released"
2696
+ );
2697
+ }
2698
+ if (this.clock.now() >= hold.until) {
2699
+ return new LeaseExpiredError(
2700
+ `the lease on segment "${name}" ended; a read of a handle past its lease throws, never reads empty`,
2701
+ hold.until,
2702
+ "expired"
2703
+ );
2704
+ }
2705
+ return void 0;
2706
+ }
2707
+ /**
2708
+ * Throw the first lease error among `handles`: this handle and every operand and exclude of a call. It runs before
2709
+ * any `expiresAt` rule and before the engine, so no operand, exclude or cached reader can answer past a lease.
2710
+ */
2711
+ assertLeases(handles) {
2712
+ for (const h of handles) {
2713
+ const err = h.leaseError();
2714
+ if (err !== void 0) throw err;
2715
+ }
2716
+ }
2717
+ /**
2718
+ * Reopen a generation a pin named earlier, as a pinned handle like the one {@link Segment.pin} returns, held at
2719
+ * `at.generation` instead of the current one. Pass what the earlier pin recorded, `{ generation, fingerprint }`
2720
+ * from its {@link Segment.pinnedAt}. A generation number alone is not an identity, since a purged and re-created
2721
+ * name starts again at 0, so the fingerprint is required: a bare number, a malformed fingerprint or a key other
2722
+ * than `generation` and `fingerprint` is a {@link ValidationError}. Identify a pin by its `generation` and
2723
+ * `fingerprint`: the handle's `pinnedAt.version` can differ from the earlier pin's.
2724
+ *
2725
+ * Throws {@link NotFoundError} when the generation is gone (collected, purged, or on a crypto-shredded segment),
2726
+ * is above the row's pointer, or is another object than the fingerprint names, and never reads empty. A rollback
2727
+ * deletes nothing, so once a later load moves the pointer past a generation it rolled back from, that one can be
2728
+ * reopened while its object is stored. Nothing keeps a generation alive unless you lease it: a later load's
2729
+ * collection can delete it, which is sized by `keep` on every writer, or held by `options.leaseUntil` (see
2730
+ * {@link PinOptions} and {@link Segment.pin}). A leased `pinAt` writes the lease before it verifies the object, and
2731
+ * releases it if the verify fails; for a generation below the pointer, a collector that read the row before the lease
2732
+ * landed can still delete the generation inside its own round trip, and the handle then fails with `NotFoundError`
2733
+ * like any swept pin. A leased handle is checked at every read site as a leased `pin()` is. With a registry it costs one row read and one tail read,
2734
+ * and a leased `pinAt` one conditional write to the row besides; the handle
2735
+ * then reads as one from `pin()` does, including its failure on a chunk it has not cached once the generation is
2736
+ * swept. Works for an encrypted segment. The first argument is an object so that fields can join it later; the lease
2737
+ * is in `options`, the second.
2738
+ */
2739
+ async pinAt(at, options) {
2740
+ this.assertLeases([this]);
2741
+ const { generation, fingerprint } = at ?? {};
2742
+ const unknown = typeof at === "object" && at !== null ? Object.keys(at).filter((k) => k !== "generation" && k !== "fingerprint") : [];
2743
+ if (unknown.length > 0) {
2744
+ throw new ValidationError2(
2745
+ `pinAt: unknown option${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")}; this version takes { generation, fingerprint } only`
2746
+ );
2747
+ }
2748
+ if (typeof generation !== "number" || !Number.isSafeInteger(generation) || generation < 0 || typeof fingerprint !== "string" || !/^\d+:\d+$/.test(fingerprint)) {
2749
+ throw new ValidationError2(
2750
+ "pinAt: pass { generation, fingerprint } as recorded by an earlier pin (its pinnedAt); a generation number without the fingerprint of its object does not identify it"
2751
+ );
2752
+ }
2753
+ const leaseUntil = this.leaseUntilOf("pinAt", options);
2754
+ return this.pinned(this.ref, this.expiresAt, leaseUntil, { generation, fingerprint });
1856
2755
  }
1857
2756
  /**
1858
2757
  * An opaque string that names this handle's segment, namespace included: two handles of one segment have the
@@ -1896,6 +2795,7 @@ var Segment = class _Segment {
1896
2795
  * awaiting.
1897
2796
  */
1898
2797
  refuseIfExpired(op, dest, operands) {
2798
+ this.assertLeases([this, dest, ...operands]);
1899
2799
  const stale = [];
1900
2800
  for (const seg of [this, dest, ...operands]) {
1901
2801
  if (seg.expired())
@@ -1907,6 +2807,10 @@ var Segment = class _Segment {
1907
2807
  );
1908
2808
  }
1909
2809
  }
2810
+ /** `result` as is, or, when a handle of the call holds a lease, checking that lease each time it reads a chunk. */
2811
+ guarded(out, handles, result) {
2812
+ return handles.some((h) => h.leaseHold !== void 0) ? out.guard(result, () => this.assertLeases(handles)) : result;
2813
+ }
1910
2814
  /**
1911
2815
  * Time an op with the injected clock and emit an `op` metric on completion (success or throw). Skipped
1912
2816
  * entirely when no sink is wired, so the default path pays nothing.
@@ -1922,6 +2826,8 @@ var Segment = class _Segment {
1922
2826
  }
1923
2827
  /** Membership: one chunk — the cache, else one ranged GET. Throws {@link ValidationError} on a bad id. */
1924
2828
  has(id) {
2829
+ const lease = this.leaseError();
2830
+ if (lease !== void 0) return Promise.reject(lease);
1925
2831
  if (this.expired()) return Promise.resolve(false);
1926
2832
  return this.timed("has", () => this.engine.has(this.ref, id));
1927
2833
  }
@@ -1944,6 +2850,8 @@ var Segment = class _Segment {
1944
2850
  * structure is checked.
1945
2851
  */
1946
2852
  count() {
2853
+ const lease = this.leaseError();
2854
+ if (lease !== void 0) return Promise.reject(lease);
1947
2855
  if (this.expired()) return Promise.resolve(0);
1948
2856
  return this.timed("count", () => this.engine.count(this.ref));
1949
2857
  }
@@ -1962,6 +2870,7 @@ var Segment = class _Segment {
1962
2870
  * ```
1963
2871
  */
1964
2872
  async stat() {
2873
+ this.assertLeases([this]);
1965
2874
  if (this.expired()) return { generation: null, cardinality: 0 };
1966
2875
  return this.engine.stat(this.ref);
1967
2876
  }
@@ -1981,16 +2890,70 @@ var Segment = class _Segment {
1981
2890
  * without checking its options, as every read of one does.
1982
2891
  */
1983
2892
  iterate(options) {
2893
+ const lease = this.leaseError();
2894
+ if (lease !== void 0) return failing(lease);
1984
2895
  if (this.expired()) return EMPTY_IDS;
1985
2896
  const range = options == null ? void 0 : rangeOf(options);
1986
2897
  const none = range === void 0 || range.after === void 0 && range.through === void 0;
1987
2898
  const { engine, ref } = this;
1988
2899
  const bounds = none ? void 0 : range;
1989
- return withBatches(engine.iterate(ref, bounds), () => engine.iterateBatches(ref, bounds));
2900
+ const stream = withBatches(
2901
+ engine.iterate(ref, bounds),
2902
+ () => engine.iterateBatches(ref, bounds)
2903
+ );
2904
+ return this.leaseHold === void 0 ? stream : guardIds(stream, () => this.assertLeases([this]));
2905
+ }
2906
+ /**
2907
+ * **Pinned handles only.** The ids at 1-based ranks `n`, `2n`, `3n` …, counted over the ids in `(after, through]`
2908
+ * when a range is given, ascending: `everyNth(1000)` yields the 1,000th id, then the 2,000th, and so on. A last
2909
+ * partial window yields nothing, so a caller cutting a send into windows of `n` appends its own final end.
2910
+ *
2911
+ * It places each boundary from the per-chunk counts the pin already holds and reads only the chunks that hold one,
2912
+ * each once however many boundaries it holds, so it reads at most one chunk per boundary and never more chunks
2913
+ * than the object has. A range with `after` inside a chunk reads that chunk too, to count the ids at or below
2914
+ * `after`, even when fewer than `n` ids remain above it; when the chunks in range hold fewer than `n` ids in all,
2915
+ * nothing is fetched. `through` stops the read and no chunk past it is read, though a `through` inside a chunk may read that last chunk and
2916
+ * yield nothing from it. The reads and the read-ahead window are
2917
+ * those of {@link Segment.iterate}, and the per-op budget is charged before the first fetch, with an upper bound on
2918
+ * the chunks the read can take. A pinned read after the generation is swept throws {@link NotFoundError}.
2919
+ *
2920
+ * The ranks come from the index's counts, which are trusted as {@link Segment.count} trusts them. A chunk that is
2921
+ * read must hold the number of ids its index says, or the read throws {@link IntegrityError}; a chunk that is not
2922
+ * read is not checked, so an index that is wrong about one, and, on an unencrypted object, still adds up to the footer's
2923
+ * total, moves the ids after it to the wrong ranks. An encrypted object's index is authenticated and bound to the
2924
+ * object, so a wrong count there needs the key.
2925
+ *
2926
+ * A live handle is refused with {@link UnsupportedError} at the first read, because its counts and its chunks could
2927
+ * come from two generations and name the wrong id. `n` that is not a positive integer throws
2928
+ * {@link ValidationError} at the first read, as does a bad bound. An expired handle reads empty.
2929
+ *
2930
+ * ```ts
2931
+ * const audience = await store.segment('audience').pin();
2932
+ * const ends: number[] = [];
2933
+ * for await (const id of audience.everyNth(1_000)) ends.push(id); // the end of each window of 1,000
2934
+ * ```
2935
+ */
2936
+ everyNth(n, options) {
2937
+ const lease = this.leaseError();
2938
+ if (lease !== void 0) return failing(lease);
2939
+ if (this.expired()) return EMPTY_IDS;
2940
+ if (this.pinnedAt === void 0) {
2941
+ return failing(
2942
+ new UnsupportedError(
2943
+ "everyNth needs a pinned handle: a live handle can place a rank by one generation and read it from another. Call `pin()` first."
2944
+ )
2945
+ );
2946
+ }
2947
+ const stream = this.engine.everyNth(
2948
+ this.ref,
2949
+ n,
2950
+ options == null ? void 0 : rangeOf(options)
2951
+ );
2952
+ return this.leaseHold === void 0 ? stream : guardIdIterable(stream, () => this.assertLeases([this]));
1990
2953
  }
1991
2954
  /**
1992
2955
  * The options a combine hands the engine, read once, when it is called ({@link readOptions}), with the `exclude`
1993
- * it was given, already reduced to its live handles ({@link liveExcludes}) and mapped down to the plain refs
2956
+ * it was given ({@link excludesOf}) and mapped down to the plain refs
1994
2957
  * `core` takes. A method rather than a module function because `ref` is class-private — the encapsulation is
1995
2958
  * worth more than the free function.
1996
2959
  *
@@ -2006,18 +2969,35 @@ var Segment = class _Segment {
2006
2969
  };
2007
2970
  }
2008
2971
  /**
2009
- * The handles in `options.exclude` that have not expired. **An expired exclusion excludes nothing**, in every
2010
- * combine, so it is dropped here, before the engine is asked for anything: it is never fetched, never
2011
- * checked for absence, and never counted in the pin-consistency check. `exclude` is read once, so a getter sees
2012
- * one call.
2013
- *
2014
- * With no `exclude` this is one property read and a shared empty list, and with none expired it returns the
2015
- * caller's own array, so the common case allocates nothing.
2972
+ * The handles in `options.exclude`, read once so a getter sees one call. With none this is one property read and
2973
+ * a shared empty list; otherwise it is the caller's own array, so the common case allocates nothing.
2016
2974
  */
2017
- liveExcludes(options) {
2975
+ excludesOf(options) {
2018
2976
  const exclude = options?.exclude;
2019
- if (exclude == null || exclude.length === 0) return NO_SEGMENTS;
2020
- return exclude.some((e) => e.expired()) ? exclude.filter((e) => !e.expired()) : exclude;
2977
+ return exclude == null || exclude.length === 0 ? NO_SEGMENTS : exclude;
2978
+ }
2979
+ /**
2980
+ * The refusal for a combine whose **exclusion** has expired, or `undefined` when none has. Unlike an expired
2981
+ * operand, which reads empty, an exclusion is a suppression or opt-out list: left out silently, the result
2982
+ * would include the very ids it was passed to remove. So it is refused with a {@link ValidationError} naming each
2983
+ * expired exclusion, as the `*Into` verbs refuse an expired handle.
2984
+ *
2985
+ * It is judged against the injected clock when the combine is called, before the engine is asked for anything:
2986
+ * no request is made, and an expired exclusion that names a segment that does not exist is refused as expired,
2987
+ * not as an absent operand. It runs ahead of the expired-operand shortcuts, so a combine that would read empty
2988
+ * anyway still refuses a lapsed exclusion. A stream already being read is not re-checked when an exclusion
2989
+ * expires part-way, as an operand expiring part-way does not stop it either.
2990
+ */
2991
+ expiredExcludes(op, excludes) {
2992
+ let stale;
2993
+ for (const e of excludes) {
2994
+ if (!e.expired()) continue;
2995
+ (stale ??= []).push(e.ref.namespace ? `${e.ref.namespace}/${e.ref.segment}` : e.ref.segment);
2996
+ }
2997
+ if (stale === void 0) return void 0;
2998
+ return new ValidationError2(
2999
+ `${op}: refusing to read while these exclusions have expired \u2014 ${[...new Set(stale)].join(", ")}. An expired exclusion would subtract nothing, so the result would include the ids it was passed to remove. Renew the exclusion's \`expiresAt\`, open it without one, or leave it out of the call.`
3000
+ );
2021
3001
  }
2022
3002
  /**
2023
3003
  * Chunk-skipping intersection: stream the ids in **this** segment AND every segment in `others`, ascending.
@@ -2032,8 +3012,16 @@ var Segment = class _Segment {
2032
3012
  return this.intersectAs(AS_IDS, others, options);
2033
3013
  }
2034
3014
  intersectAs(out, others, options) {
3015
+ const exclude = this.excludesOf(options);
3016
+ const handles = [this, ...others, ...exclude];
3017
+ try {
3018
+ this.assertLeases(handles);
3019
+ } catch (err) {
3020
+ return out.failing(err);
3021
+ }
3022
+ const refused = this.expiredExcludes("intersect", exclude);
3023
+ if (refused) return out.failing(refused);
2035
3024
  if (this.expired() || others.some((o) => o.expired())) return out.none;
2036
- const exclude = this.liveExcludes(options);
2037
3025
  let engine;
2038
3026
  try {
2039
3027
  engine = this.combineEngine([this, ...others, ...exclude]) ?? this.engine;
@@ -2042,7 +3030,7 @@ var Segment = class _Segment {
2042
3030
  }
2043
3031
  const refs = [this.ref, ...others.map((o) => o.ref)];
2044
3032
  const opts = this.refsIn(options, exclude);
2045
- return out.intersect(engine, refs, opts);
3033
+ return this.guarded(out, handles, out.intersect(engine, refs, opts));
2046
3034
  }
2047
3035
  /**
2048
3036
  * Materialize `this ∩ others…` (minus `exclude`) as a **new generation of `dest`** — `dest`'s previous contents
@@ -2091,17 +3079,24 @@ var Segment = class _Segment {
2091
3079
  return this.unionAs(AS_IDS, others, options);
2092
3080
  }
2093
3081
  unionAs(out, others, options) {
3082
+ const exclude = this.excludesOf(options);
3083
+ const handles = [this, ...others, ...exclude];
3084
+ try {
3085
+ this.assertLeases(handles);
3086
+ } catch (err) {
3087
+ return out.failing(err);
3088
+ }
3089
+ const refused = this.expiredExcludes("union", exclude);
3090
+ if (refused) return out.failing(refused);
2094
3091
  const live = others.filter((o) => !o.expired());
2095
3092
  if (this.expired()) {
2096
3093
  if (live.length === 0) return out.none;
2097
3094
  return live[0].unionAs(out, live.slice(1), options);
2098
3095
  }
2099
3096
  if (live.length === 0 && others.length > 0) {
2100
- const exclude2 = this.liveExcludes(options);
2101
- return exclude2.length > 0 ? this.andNotAs(out, [...exclude2], options) : this.unionAs(out, [], options);
3097
+ return exclude.length > 0 ? this.andNotAs(out, [...exclude], options) : this.unionAs(out, [], options);
2102
3098
  }
2103
3099
  if (live.length !== others.length) return this.unionAs(out, live, options);
2104
- const exclude = this.liveExcludes(options);
2105
3100
  let engine;
2106
3101
  try {
2107
3102
  engine = this.combineEngine([this, ...others, ...exclude]) ?? this.engine;
@@ -2110,7 +3105,7 @@ var Segment = class _Segment {
2110
3105
  }
2111
3106
  const refs = [this.ref, ...others.map((o) => o.ref)];
2112
3107
  const opts = this.refsIn(options, exclude);
2113
- return out.union(engine, refs, opts);
3108
+ return this.guarded(out, handles, out.union(engine, refs, opts));
2114
3109
  }
2115
3110
  /** Materialize `this ∪ others…` (minus `exclude`) as a **new generation of `dest`** — see {@link intersectInto}. */
2116
3111
  async unionInto(dest, others, options) {
@@ -2134,9 +3129,9 @@ var Segment = class _Segment {
2134
3129
  * suppression list: at most one read per surviving key of `this`, so subtracting a 61,000-chunk global
2135
3130
  * opt-out list from a 40-chunk audience costs at most 40 reads, not 61,000.
2136
3131
  *
2137
- * An expired handle in `excludes` excludes nothing, and is skipped without being read; if every one has
2138
- * expired the result is `this` whole. An expired `this` is empty. `exclude` on `intersect` and `union` follows
2139
- * the same rule.
3132
+ * An expired handle in `excludes` is refused, not skipped: the stream throws {@link ValidationError} naming it,
3133
+ * before any request is made, because a lapsed suppression list would otherwise let through the ids it was
3134
+ * passed to remove. An expired `this` is empty. `exclude` on `intersect` and `union` follows the same rule.
2140
3135
  *
2141
3136
  * To filter the *result of an intersection*, do not chain — pass `exclude` to {@link intersect} instead, so
2142
3137
  * the suppression folds into the same pass rather than materializing an intermediate segment first.
@@ -2145,12 +3140,15 @@ var Segment = class _Segment {
2145
3140
  return this.andNotAs(AS_IDS, excludes, options);
2146
3141
  }
2147
3142
  andNotAs(out, excludes, options) {
2148
- if (this.expired()) return out.none;
2149
- const liveExcludes = excludes.filter((e) => !e.expired());
2150
- if (liveExcludes.length === 0 && excludes.length > 0) {
2151
- return this.unionAs(out, [], options == null ? void 0 : readOptions(options));
3143
+ const handles = [this, ...excludes];
3144
+ try {
3145
+ this.assertLeases(handles);
3146
+ } catch (err) {
3147
+ return out.failing(err);
2152
3148
  }
2153
- if (liveExcludes.length !== excludes.length) return this.andNotAs(out, liveExcludes, options);
3149
+ const refused = this.expiredExcludes("andNot", excludes);
3150
+ if (refused) return out.failing(refused);
3151
+ if (this.expired()) return out.none;
2154
3152
  let engine;
2155
3153
  try {
2156
3154
  engine = this.combineEngine([this, ...excludes]) ?? this.engine;
@@ -2160,7 +3158,7 @@ var Segment = class _Segment {
2160
3158
  const base = this.ref;
2161
3159
  const refs = excludes.map((o) => o.ref);
2162
3160
  const opts = options == null ? void 0 : readOptions(options);
2163
- return out.andNot(engine, base, refs, opts);
3161
+ return this.guarded(out, handles, out.andNot(engine, base, refs, opts));
2164
3162
  }
2165
3163
  /** Materialize `this \ (excludes…)` as a **new generation of `dest`** — see {@link intersectInto}. */
2166
3164
  async andNotInto(dest, excludes, options) {
@@ -2184,6 +3182,7 @@ var Segment = class _Segment {
2184
3182
  * the lose-zone).
2185
3183
  */
2186
3184
  async costReport(options) {
3185
+ this.assertLeases([this]);
2187
3186
  const canMeasure = this.engine.supportsStorageSize;
2188
3187
  const size = canMeasure ? await this.engine.segmentSize(this.ref) : null;
2189
3188
  const refreshMs = this.engine.pointerRefreshMs;