@cloudbitmaps/roaring 0.18.3 → 0.19.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.
@@ -3,7 +3,7 @@
3
3
  // src/bin/export-segments.ts
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { realpathSync } from "node:fs";
6
- import { lstat, mkdir, open, rename, rm, writeFile } from "node:fs/promises";
6
+ import { lstat, mkdir, open, readdir, rename, rm, writeFile } from "node:fs/promises";
7
7
  import { join } from "node:path";
8
8
  import { pathToFileURL } from "node:url";
9
9
  import { encodeNameForPath, namespacePathPart } from "@cloudbitmaps/core/driver-kit";
@@ -28,7 +28,6 @@ import {
28
28
  UnsupportedError,
29
29
  ValidationError as ValidationError2,
30
30
  WriteConflictError,
31
- MIN_EXPIRES_AT_MS,
32
31
  checkBudget,
33
32
  collectWithinBudget,
34
33
  excludingReservedRows,
@@ -36,16 +35,15 @@ import {
36
35
  eraseIdFromSegment,
37
36
  estimateCost,
38
37
  groundedReport,
38
+ judgeLoad,
39
39
  loadSegment,
40
40
  loadSegmentChunks,
41
41
  compileCombineMany,
42
- prepareHeld,
43
42
  rebindCombineMany,
44
43
  runCombineMany,
45
44
  mapWithConcurrency,
46
45
  resolveBudget,
47
46
  resolvePerOpBudget,
48
- reapRegistryTombstones,
49
47
  retireExpired,
50
48
  runConsistencyCheck,
51
49
  runExport,
@@ -620,7 +618,7 @@ import {
620
618
  eraseNamespace,
621
619
  excludingReservedRows as excludingReservedRows2,
622
620
  readRetentionPolicy,
623
- MIN_EXPIRES_AT_MS as MIN_EXPIRES_AT_MS2,
621
+ MIN_EXPIRES_AT_MS,
624
622
  LEASE_SKEW_MS as LEASE_SKEW_MS2,
625
623
  MAX_LEASE_MS as MAX_LEASE_MS2,
626
624
  MAX_LEASES_PER_SEGMENT,
@@ -649,6 +647,10 @@ import {
649
647
  isLeaseExpiredError,
650
648
  isLeaseLimitError,
651
649
  isStaleOperandError,
650
+ isUnsupportedError,
651
+ isCapabilityError,
652
+ isBudgetExceededError,
653
+ isKeyUnavailableError,
652
654
  CrbmReader,
653
655
  BufferReader,
654
656
  CountingMetricsSink,
@@ -672,6 +674,14 @@ function validateConcurrency(concurrency) {
672
674
  }
673
675
  }
674
676
  function requireScope(options, op) {
677
+ if (options.namespace !== void 0) {
678
+ if (typeof options.namespace !== "string") {
679
+ throw new ValidationError2(
680
+ `${op}: namespace must be a string; got ${kindOf(options.namespace)}`
681
+ );
682
+ }
683
+ validateSegmentRef({ segment: "x", namespace: options.namespace });
684
+ }
675
685
  refuseReservedNamespace(options.namespace);
676
686
  if (options.namespace === void 0 && options.allNamespaces !== true) {
677
687
  throw new ValidationError2(
@@ -679,9 +689,154 @@ function requireScope(options, op) {
679
689
  );
680
690
  }
681
691
  }
692
+ var keysOf = () => (keys) => keys;
693
+ var CALL_KEYS = {
694
+ range: keysOf()(["after", "through"]),
695
+ andNot: keysOf()([
696
+ "after",
697
+ "through",
698
+ "concurrency",
699
+ "budget",
700
+ "allowAbsentOperands"
701
+ ]),
702
+ combine: keysOf()([
703
+ "after",
704
+ "through",
705
+ "concurrency",
706
+ "budget",
707
+ "allowAbsentOperands",
708
+ "exclude"
709
+ ]),
710
+ into: keysOf()([
711
+ "after",
712
+ "through",
713
+ "concurrency",
714
+ "budget",
715
+ "allowAbsentOperands",
716
+ "exclude",
717
+ "audit",
718
+ "allowEmpty",
719
+ "guard",
720
+ "metadata",
721
+ "keep"
722
+ ]),
723
+ andNotInto: keysOf()([
724
+ "after",
725
+ "through",
726
+ "concurrency",
727
+ "budget",
728
+ "allowAbsentOperands",
729
+ "audit",
730
+ "allowEmpty",
731
+ "guard",
732
+ "metadata",
733
+ "keep"
734
+ ]),
735
+ load: keysOf()(["allowEmpty", "guard", "keep", "metadata", "audit"]),
736
+ segments: keysOf()(["namespace"]),
737
+ subjectReport: keysOf()([
738
+ "namespace",
739
+ "allNamespaces",
740
+ "concurrency",
741
+ "budget"
742
+ ]),
743
+ eraseSubject: keysOf()([
744
+ "namespace",
745
+ "allNamespaces",
746
+ "audit",
747
+ "concurrency",
748
+ "budget"
749
+ ]),
750
+ rollback: keysOf()([
751
+ "audit",
752
+ "allowForward"
753
+ ]),
754
+ dropSegment: keysOf()([
755
+ "confirmSegment",
756
+ "dryRun",
757
+ "audit"
758
+ ]),
759
+ retireExpired: keysOf()([
760
+ "namespace",
761
+ "now",
762
+ "limit",
763
+ "dryRun",
764
+ "audit",
765
+ "maxScanSegments",
766
+ "scan",
767
+ "shards",
768
+ "totalShards",
769
+ "lookbackBuckets",
770
+ "purgeTombstones",
771
+ "tombstoneGraceMs"
772
+ ]),
773
+ checkConsistency: keysOf()([
774
+ "namespace",
775
+ "concurrency",
776
+ "summaries"
777
+ ]),
778
+ exportSegments: keysOf()(["format", "codec", "namespace", "ndjsonBatchBytes"]),
779
+ costReport: keysOf()(["pricing", "workload"]),
780
+ workload: keysOf()([
781
+ "readsPerSec",
782
+ "intersectsPerSec",
783
+ "cacheHitRate",
784
+ "chunksPerIntersect",
785
+ "operandsPerIntersect",
786
+ "loadsPerMonth",
787
+ "requestsPerLoad",
788
+ "hotSegments",
789
+ "readerProcesses",
790
+ "genTtlMs",
791
+ "retirementsPerMonth",
792
+ "purgesPerMonth",
793
+ "conditionalDelete"
794
+ ])
795
+ };
796
+ var kindOf = (v) => v === null ? "null" : Array.isArray(v) ? "an array" : `a ${typeof v}`;
797
+ function optionsError(options, op, known, booleans = []) {
798
+ if (options === void 0 || options === null) return void 0;
799
+ if (typeof options !== "object" || Array.isArray(options)) {
800
+ return new ValidationError2(`${op}: options must be an object; got ${kindOf(options)}`);
801
+ }
802
+ const bag = options;
803
+ const unknown = Object.keys(bag).filter((k) => !known.includes(k) && bag[k] !== void 0);
804
+ if (unknown.length > 0) {
805
+ return new ValidationError2(
806
+ `${op}: unknown option${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")}; it takes { ${known.join(", ")} }`
807
+ );
808
+ }
809
+ const audit = bag.audit;
810
+ if (audit !== void 0 && audit !== null && typeof audit.onEvent !== "function") {
811
+ return new ValidationError2(
812
+ `${op}: audit must be a sink with an onEvent(event) method, such as a RecordingAuditSink`
813
+ );
814
+ }
815
+ for (const name of booleans) {
816
+ const v = bag[name];
817
+ if (v !== void 0 && typeof v !== "boolean") {
818
+ return new ValidationError2(`${op}: \`${name}\` must be a boolean; got ${kindOf(v)}`);
819
+ }
820
+ }
821
+ return void 0;
822
+ }
823
+ function picked(options, keys) {
824
+ const out = {};
825
+ if (options === void 0 || options === null) return out;
826
+ for (const key of keys) {
827
+ const value = options[key];
828
+ if (value !== void 0) out[key] = value;
829
+ }
830
+ return out;
831
+ }
832
+ function checkOptions(options, op, known, booleans) {
833
+ const err = optionsError(options, op, known, booleans);
834
+ if (err !== void 0) throw err;
835
+ }
682
836
  var viewOf;
683
837
  var MATERIALIZE_MANY_KEYS = [
684
838
  "operands",
839
+ "dryRun",
685
840
  "outputs",
686
841
  "keep",
687
842
  "pin",
@@ -870,8 +1025,29 @@ var CloudRoaring = class _CloudRoaring {
870
1025
  ).join(" ") + " https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/api-reference.md#build-a-store--new-cloudroaringoptions"
871
1026
  );
872
1027
  }
1028
+ /**
1029
+ * A seam the store calls through must have the methods it calls. Without this a `clock` with no `now`, an `rng` with
1030
+ * no `next` or a keystore with no `createDek` builds a store that throws a raw `TypeError` at its first load or read.
1031
+ */
1032
+ static rejectHollowSeams(options) {
1033
+ const seams = [
1034
+ [options.seams?.clock, "seams.clock", ["now", "sleep"]],
1035
+ [options.seams?.rng, "seams.rng", ["next"]],
1036
+ [options.encryption?.keystore, "encryption.keystore", ["createDek", "openDek"]]
1037
+ ];
1038
+ for (const [seam, name, needs] of seams) {
1039
+ if (seam === void 0 || seam === null) continue;
1040
+ const has = (m) => typeof seam[m] === "function";
1041
+ if (typeof seam !== "object" || !needs.every(has)) {
1042
+ throw new ValidationError2(
1043
+ `CloudRoaring's \`${name}\` must be an object with ${needs.map((m) => `\`${m}()\``).join(" and ")}`
1044
+ );
1045
+ }
1046
+ }
1047
+ }
873
1048
  constructor(options) {
874
1049
  _CloudRoaring.rejectUnknownOptions(options);
1050
+ _CloudRoaring.rejectHollowSeams(options);
875
1051
  const clock = options.seams?.clock ?? new SystemClock();
876
1052
  const rng = options.seams?.rng ?? new SystemRng();
877
1053
  const metrics = safeMetrics(options.metrics ?? NOOP_METRICS);
@@ -887,6 +1063,9 @@ var CloudRoaring = class _CloudRoaring {
887
1063
  let storage = resolved.source;
888
1064
  if (options.retry !== false) {
889
1065
  const { onRetry: userOnRetry, ...ov } = options.retry ?? {};
1066
+ if (userOnRetry !== void 0 && typeof userOnRetry !== "function") {
1067
+ throw new ValidationError2("retry.onRetry must be a function");
1068
+ }
890
1069
  const policy = {
891
1070
  maxAttempts: ov.maxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts,
892
1071
  baseDelayMs: ov.baseDelayMs ?? DEFAULT_RETRY_POLICY.baseDelayMs,
@@ -962,28 +1141,43 @@ var CloudRoaring = class _CloudRoaring {
962
1141
  readRetry: this.retryOptions
963
1142
  };
964
1143
  }
965
- /** Get a handle to a segment. Validates the name (non-empty, well-formed, within the encoded-length cap). */
1144
+ /**
1145
+ * Get a handle to a segment. Validates the name (non-empty, well-formed, within the encoded-length cap). The options
1146
+ * are `{ namespace }` and nothing else: anything else is refused with {@link ValidationError}, by name, since a
1147
+ * misspelt `namespace` would otherwise address the default namespace. An `expiresAt` among them is refused with its own
1148
+ * message: a handle carries no deadline, and one left unread would serve the data past it. A key whose value is
1149
+ * `undefined` is read as absent, so a spread of options keeps working.
1150
+ */
966
1151
  segment(name, options) {
967
- const ref = { segment: name, namespace: options?.namespace };
968
- validateSegmentRef(ref);
969
- refuseReservedNamespace(ref.namespace);
970
- const expiresAt = options?.expiresAt;
971
- if (expiresAt !== void 0) {
972
- if (!Number.isFinite(expiresAt) || !Number.isInteger(expiresAt) || expiresAt < MIN_EXPIRES_AT_MS) {
1152
+ if (options !== void 0 && options !== null) {
1153
+ if (typeof options !== "object" || Array.isArray(options)) {
1154
+ throw new ValidationError2("segment: options must be an object such as { namespace }");
1155
+ }
1156
+ if (options.expiresAt !== void 0) {
1157
+ throw new ValidationError2(
1158
+ "segment: `expiresAt` is not an option of a handle; check a deadline where you read, or record it with `store.setRetention(ref, { expiresAt })` and run `store.retireExpired()`"
1159
+ );
1160
+ }
1161
+ const unknown = Object.keys(options).filter(
1162
+ (k) => k !== "namespace" && options[k] !== void 0
1163
+ );
1164
+ if (unknown.length > 0) {
973
1165
  throw new ValidationError2(
974
- `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)`
1166
+ `segment: unknown option${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")}; a handle takes { namespace } only`
975
1167
  );
976
1168
  }
977
1169
  }
1170
+ const ref = { segment: name, namespace: options?.namespace };
1171
+ validateSegmentRef(ref);
1172
+ refuseReservedNamespace(ref.namespace);
978
1173
  return makeSegment({
979
1174
  engine: this.engine,
980
1175
  ref,
981
1176
  clock: this.clock,
982
1177
  metrics: this.metrics,
983
1178
  materialize: (dest, ids, op, options2) => this.materialize(dest, ids, op, options2),
984
- pinned: (r, e, l, at) => this.pinSegment(r, e, l, at),
985
- combineEngine: (handles) => this.engineForCombine(handles),
986
- expiresAt
1179
+ pinned: (r, l, at) => this.pinSegment(r, l, at),
1180
+ combineEngine: (handles) => this.engineForCombine(handles)
987
1181
  });
988
1182
  }
989
1183
  /**
@@ -991,7 +1185,7 @@ var CloudRoaring = class _CloudRoaring {
991
1185
  * materialisation is a load, and publishes the same way (hard invariant 1): every load that finds a row fences its
992
1186
  * publish on the row's token; a guarded load (the default, since the empty refusal needs the size of the current generation)
993
1187
  * also fences on the pointer it judged (`expectFrom`), and one that found no row fences on that absence instead.
994
- * Only an unguarded load (`allowEmpty: true` and no `guard.minRetained`) onto a segment with no row publishes bare
1188
+ * Only an unguarded load (`allowEmpty: true`, with neither `guard.minRetained` nor `guard.maxGrowth`) onto a segment with no row publishes bare
995
1189
  * forward-only. The destination's previous generation stays readable until the publish lands (readers re-resolve
996
1190
  * within `cache.genTtlMs`).
997
1191
  *
@@ -1067,131 +1261,7 @@ var CloudRoaring = class _CloudRoaring {
1067
1261
  collected: result.collected
1068
1262
  };
1069
1263
  }
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, and the fingerprint of the current
1135
- * object where both reads carry one, with what was pinned (a name deleted and created again is a replacement though it
1136
- * is generation 0 again). If one was replaced, each output that subtracts it is
1137
- * not published and carries {@link StaleOperandError}; the others publish. It costs one registry read per such operand per
1138
- * group, and `stats.operands` carries every operand's pinned, start and end generation and whether it moved.
1139
- *
1140
- * **Memory.** Everything the pass holds resident is counted against `maxBufferedBytes` (default 256 MiB): each output's
1141
- * buffered chunks, the operand streams' ranges, the chunks being evaluated, and the plan itself, and a call whose plan alone
1142
- * passes it is refused before any chunk is read. When the outputs do not fit, they run in groups, each reading its operands
1143
- * once (so the operand reads grow with the total output size over the budget), and a group's buffers are released only as
1144
- * its publishes settle. A chunk count the index understated does not grow a group past the budget: the largest buffer is
1145
- * dropped and that output re-runs alone. An output that cannot fit alone has `BudgetExceededError` as its result. The count
1146
- * 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
1147
- * macOS on the in-memory backend (the guide has the shapes and what is not counted).
1148
- *
1149
- * **Untrusted bytes.** Every operand chunk is decoded through the checks on untrusted tier data. A key an operand's index
1150
- * lists whose bytes are missing is an error for the outputs that read the operand, never an empty chunk.
1151
- *
1152
- * **Handles.** An expired or released handle anywhere in the call (an operand or a `dest`) is refused before any request,
1153
- * as on the `*Into` verbs. A leased operand's lease, and an operand's deadline, are checked before each chunk key
1154
- * is read: a lapse fails only the outputs that read that operand, never reads empty. Each `dest`'s lease and deadline
1155
- * are checked again just before its publish.
1156
- *
1157
- * **The chunk cache is bypassed:** the pass reads through the storage source and never writes the decoded-chunk cache, so
1158
- * a batch does not evict other readers' hot chunks. Each publish still invalidates its `dest` as an `*Into` does.
1159
- *
1160
- * **A limit of the form.** An output cannot name another output of the same call: a `dest` that is also an operand is
1161
- * refused. Chain two calls.
1162
- *
1163
- * **Held operands.** An operand may be a {@link MemoryOperand} from {@link CloudRoaring.memory}: ids the caller holds,
1164
- * read beside the stored ones and giving the same output byte for byte. It is read from memory (no request, and the
1165
- * shared chunk cache never sees it), counts against `maxBufferedBytes` while the call runs, and is refused when empty
1166
- * unless its name is in `mayBeEmpty`. A handle from another store, or a released one, is a {@link ValidationError}. An
1167
- * erasure in this store since the handle was made fails the outputs that read it with {@link StaleOperandError}
1168
- * (`reason: 'erased'`).
1169
- *
1170
- * **A feed.** `feed: { names, records, counts }` adds operands that arrive as records in chunk-key order, for conditions
1171
- * too many to hold or store. Each record is checked before the pass sees it (see {@link MaterializeManyFeedRecord}): a bad
1172
- * feed is refused, never read as fewer members, and **a fed output is published only after the whole feed was read and every
1173
- * end-of-feed check passed**, so a bad feed refuses every fed output and publishes none of them; outputs that name only
1174
- * stored operands are unaffected. At the end of the feed `counts` must equal the ids the feed held for each name, and a
1175
- * name that appeared in no record is refused unless it is in `mayBeEmpty`. A feed is read once, so the call runs its outputs
1176
- * as one group and **`maxBufferedBytes` is required**: each record is converted to compressed bitmaps as it arrives, the key
1177
- * in hand and its fed outputs are counted against the budget, and the call fails as soon as the count passes it, with
1178
- * {@link BudgetExceededError} for every fed output. **Erasure:** a fed call is refused at its next record, and before each
1179
- * fed publish, once `eraseSubject` has started in this store ({@link StaleOperandError}, `reason: 'erased'`).
1180
- *
1181
- * ```ts
1182
- * await store.materializeMany({
1183
- * operands: { unsub: store.segment('global-unsub') },
1184
- * feed: {
1185
- * names: ['us', 'engaged'],
1186
- * records: warehouseRecords(), // yields { key, operands: { us: Uint32Array, engaged: Uint32Array } } by key
1187
- * counts: () => warehouseCounts(),
1188
- * },
1189
- * maxBufferedBytes: 512 * 1024 * 1024,
1190
- * outputs: [{ dest: store.segment('send-1'), expr: { and: ['us', 'engaged'] }, exclude: ['unsub'] }],
1191
- * keep: 12,
1192
- * });
1193
- * ```
1194
- */
1264
+ /** The one body of the forms above. */
1195
1265
  async materializeMany(options) {
1196
1266
  if (typeof options !== "object" || options === null) {
1197
1267
  throw new ValidationError2(
@@ -1207,12 +1277,17 @@ var CloudRoaring = class _CloudRoaring {
1207
1277
  `materializeMany: unknown option${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")}`
1208
1278
  );
1209
1279
  }
1280
+ const dryRunIn = options.dryRun;
1281
+ if (dryRunIn !== void 0 && typeof dryRunIn !== "boolean") {
1282
+ throw new ValidationError2("materializeMany: dryRun must be a boolean");
1283
+ }
1284
+ const dryRun = dryRunIn === true;
1210
1285
  const operandsIn = options.operands;
1211
1286
  const outputsIn = options.outputs;
1212
1287
  const keep = options.keep;
1213
1288
  const after = options.after;
1214
1289
  const through = options.through;
1215
- const pinOption = options.pin ?? true;
1290
+ const pinOption = options.pin === void 0 ? true : options.pin;
1216
1291
  const feedIn = options.feed;
1217
1292
  const mayBeEmptyIn = options.mayBeEmpty;
1218
1293
  if (feedIn !== void 0 && options.maxBufferedBytes === void 0) {
@@ -1238,50 +1313,26 @@ var CloudRoaring = class _CloudRoaring {
1238
1313
  }
1239
1314
  const names = Object.keys(operandsIn);
1240
1315
  const handles = /* @__PURE__ */ new Map();
1241
- const heldHandles = /* @__PURE__ */ new Map();
1242
1316
  for (const name of names) {
1243
1317
  const handle = operandsIn[name];
1244
- if (handle instanceof MemoryOperand) {
1245
- const view = heldOf(handle);
1246
- if (view === void 0) {
1247
- throw new ValidationError2(
1248
- `materializeMany: operands["${name}"] is not a memory operand from store.memory()`
1249
- );
1250
- }
1251
- if (view.store !== this) {
1252
- throw new ValidationError2(
1253
- `materializeMany: operands["${name}"] was made by another store: a memory operand belongs to the store that made it`
1254
- );
1255
- }
1256
- if (view.chunks.released) {
1257
- throw new ValidationError2(
1258
- `materializeMany: operands["${name}"] was released: make another with store.memory()`
1259
- );
1260
- }
1261
- heldHandles.set(name, view);
1262
- continue;
1263
- }
1264
1318
  if (!(handle instanceof Segment)) {
1265
1319
  throw new ValidationError2(
1266
- `materializeMany: operands["${name}"] must be a segment from store.segment() or a memory operand from store.memory()`
1320
+ `materializeMany: operands["${name}"] must be a segment from store.segment()`
1267
1321
  );
1268
1322
  }
1269
1323
  handles.set(name, handle);
1270
1324
  }
1271
- const heldMayBeEmpty = /* @__PURE__ */ new Set();
1272
- let fedMayBeEmpty = mayBeEmptyIn;
1273
- if (mayBeEmptyIn !== void 0) {
1274
- if (Array.isArray(mayBeEmptyIn)) {
1275
- const rest = [];
1276
- for (const name of mayBeEmptyIn) {
1277
- if (typeof name === "string" && heldHandles.has(name)) heldMayBeEmpty.add(name);
1278
- else rest.push(name);
1279
- }
1280
- fedMayBeEmpty = rest;
1325
+ if (mayBeEmptyIn !== void 0 && feedIn === void 0) {
1326
+ if (!Array.isArray(mayBeEmptyIn)) {
1327
+ throw new ValidationError2(
1328
+ "materializeMany: mayBeEmpty must be an array of fed operand names"
1329
+ );
1281
1330
  }
1282
- if (feedIn === void 0 && (!Array.isArray(fedMayBeEmpty) || fedMayBeEmpty.length > 0)) {
1331
+ if (mayBeEmptyIn.length > 0) {
1332
+ const first = mayBeEmptyIn[0];
1333
+ const what = typeof first !== "string" ? "something that is not a name" : handles.has(first) ? `"${first}", a stored operand` : `"${first}", which is not an operand of this call`;
1283
1334
  throw new ValidationError2(
1284
- "materializeMany: mayBeEmpty names fed operands, and the call has no feed"
1335
+ `materializeMany: mayBeEmpty names ${what}; it names fed operands, and this call has no feed`
1285
1336
  );
1286
1337
  }
1287
1338
  }
@@ -1321,26 +1372,13 @@ var CloudRoaring = class _CloudRoaring {
1321
1372
  const storeCeiling = this.budget === null || this.budget.maxRequests !== DEFAULT_BUDGET.maxRequests ? this.budget : void 0;
1322
1373
  const budget = budgetOption === void 0 ? storeCeiling : resolvePerOpBudget(budgetOption, this.budget);
1323
1374
  const operandSpecs = (pins2) => names.map((name) => {
1324
- const view = heldHandles.get(name);
1325
- if (view !== void 0) {
1326
- return {
1327
- name,
1328
- ref: { segment: name },
1329
- held: {
1330
- chunks: view.chunks,
1331
- moved: () => this.epochMoved(view.epochAt),
1332
- mayBeEmpty: heldMayBeEmpty.has(name)
1333
- }
1334
- };
1335
- }
1336
1375
  const handle = handles.get(name);
1337
1376
  const seg = viewOf(handle);
1338
1377
  const pinnedAt = pins2.get(handle.key());
1339
- const guarded = seg.expiresAt !== void 0 || handle.lease !== void 0;
1340
1378
  return {
1341
1379
  name,
1342
1380
  ref: seg.ref,
1343
- ...guarded ? { check: () => this.assertReadable("materializeMany", handle) } : {},
1381
+ ...handle.lease === void 0 ? {} : { check: () => this.assertLease(handle) },
1344
1382
  ...pinnedAt === void 0 ? {} : {
1345
1383
  pinnedGeneration: pinnedAt.generation,
1346
1384
  pinnedVersion: pinnedAt.version,
@@ -1351,18 +1389,18 @@ var CloudRoaring = class _CloudRoaring {
1351
1389
  });
1352
1390
  const request = (pins2) => ({
1353
1391
  operands: operandSpecs(pins2),
1354
- outputs: outputs.map(({ spec, dest }, i) => ({
1392
+ outputs: outputs.map(({ spec, dest }) => ({
1355
1393
  expr: spec.expr,
1356
1394
  ...spec.exclude === void 0 ? {} : { exclude: spec.exclude },
1357
1395
  ...spec.allowEmpty === void 0 ? {} : { allowEmpty: spec.allowEmpty },
1358
1396
  ...spec.guard === void 0 ? {} : { guard: spec.guard },
1359
1397
  ...spec.metadata === void 0 ? {} : { metadata: spec.metadata },
1360
1398
  ...spec.keep === void 0 ? {} : { keep: spec.keep },
1361
- beforePublish: () => this.assertWritable(`outputs[${i}]`, dest),
1399
+ beforePublish: () => this.assertLease(dest),
1362
1400
  // What a settled publish proves it sent: a published one wrote its object and moved the pointer, a refused one
1363
- // wrote its object and deleted it.
1364
- requestsOf: (result) => ({ get: 0, put: result.published ? 2 : 1 }),
1365
- publish: (chunks, write) => this.materialize(viewOf(dest).ref, new CombineChunks(chunks), "materializeMany", {
1401
+ // wrote its object and deleted it. A dry run's judgement wrote nothing, and read the row and maybe the object.
1402
+ requestsOf: (result) => "dryRun" in result ? { get: result.reads, put: 0 } : { get: 0, put: result.published ? 2 : 1 },
1403
+ publish: (chunks, write) => dryRun ? this.judgeOutput(viewOf(dest).ref, chunks, write) : this.materialize(viewOf(dest).ref, new CombineChunks(chunks), "materializeMany", {
1366
1404
  ...write,
1367
1405
  ...spec.audit === void 0 ? {} : { audit: spec.audit }
1368
1406
  })
@@ -1375,11 +1413,12 @@ var CloudRoaring = class _CloudRoaring {
1375
1413
  concurrency,
1376
1414
  ...budget === void 0 ? {} : { budget },
1377
1415
  ...allowAbsentOperands === void 0 ? {} : { allowAbsentOperands },
1378
- ...feedIn === void 0 ? {} : { feed: this.feedOf(feedIn, fedMayBeEmpty, epochAt) }
1416
+ ...feedIn === void 0 ? {} : { feed: this.feedOf(feedIn, mayBeEmptyIn, epochAt) },
1417
+ ...dryRun ? { dryRun: true } : {}
1379
1418
  });
1380
1419
  const compiled = compileCombineMany(request(/* @__PURE__ */ new Map()));
1381
- for (const { dest } of outputs) this.assertWritable("materializeMany", dest);
1382
- for (const handle of handles.values()) this.assertReadable("materializeMany", handle);
1420
+ for (const { dest } of outputs) this.assertLease(dest);
1421
+ for (const handle of handles.values()) this.assertLease(handle);
1383
1422
  this.lifecycleDeps("materializeMany");
1384
1423
  const crbm = this.crbmSource;
1385
1424
  if (crbm === void 0) {
@@ -1416,25 +1455,64 @@ var CloudRoaring = class _CloudRoaring {
1416
1455
  else if (pinOption) toPin.push([key, handle]);
1417
1456
  else pins.set(key, void 0);
1418
1457
  }
1419
- const taken = await mapWithConcurrency(
1420
- toPin,
1421
- PIN_PARALLELISM,
1422
- async ([key, handle]) => [key, (await handle.pin()).pinnedAt]
1423
- );
1424
- for (const [key, at] of taken) pins.set(key, at);
1425
- const live = new Map([...pins].filter((e) => e[1] !== void 0));
1426
- const pinnedSource = new PinnedStorageChunkSource(crbm, live);
1427
- const source = this.retryOptions === void 0 ? pinnedSource : new RetryingStorageChunkSource(pinnedSource, this.retryOptions);
1428
- const final = rebindCombineMany(compiled, operandSpecs(pins));
1429
- const run = await runCombineMany(final, { source, codec: roaringCodec, clock: this.clock });
1430
- return {
1431
- outputs: run.outputs.map(
1432
- (o) => o.ok ? o.value : {
1458
+ const metricsOn = this.metrics !== NOOP_METRICS;
1459
+ const work = async () => {
1460
+ const taken = await mapWithConcurrency(
1461
+ toPin,
1462
+ PIN_PARALLELISM,
1463
+ async ([key, handle]) => [key, (await handle.pin()).pinnedAt]
1464
+ );
1465
+ for (const [key, at] of taken) pins.set(key, at);
1466
+ const live = new Map([...pins].filter((e) => e[1] !== void 0));
1467
+ const pinnedSource = new PinnedStorageChunkSource(crbm, live);
1468
+ const source = this.retryOptions === void 0 ? pinnedSource : new RetryingStorageChunkSource(pinnedSource, this.retryOptions);
1469
+ const final = rebindCombineMany(compiled, operandSpecs(pins));
1470
+ return runCombineMany(final, {
1471
+ source,
1472
+ codec: roaringCodec,
1473
+ clock: this.clock,
1474
+ ...metricsOn ? { metrics: this.metrics } : {}
1475
+ });
1476
+ };
1477
+ const run = metricsOn ? await timeOp(this.metrics, this.clock, "materializeMany", work) : await work();
1478
+ const results = run.outputs.map((o) => {
1479
+ if (!o.ok) {
1480
+ return {
1433
1481
  published: false,
1434
1482
  error: o.error instanceof Error ? o.error : new Error(String(o.error))
1435
- }
1436
- ),
1437
- stats: run.stats
1483
+ };
1484
+ }
1485
+ if (!("dryRun" in o.value)) return o.value;
1486
+ const v = o.value;
1487
+ const judged = {
1488
+ dryRun: true,
1489
+ published: false,
1490
+ cardinality: v.cardinality,
1491
+ cardinalityBefore: v.cardinalityBefore,
1492
+ ...v.wouldRefuse === void 0 ? {} : { wouldRefuse: v.wouldRefuse }
1493
+ };
1494
+ return judged;
1495
+ });
1496
+ return dryRun ? { outputs: results, stats: run.stats } : { outputs: results, stats: run.stats };
1497
+ }
1498
+ /**
1499
+ * A dry run's publish of one output: count what the finished chunks hold, and judge that size against `dest` as a
1500
+ * load would, writing nothing.
1501
+ */
1502
+ async judgeOutput(dest, chunks, write) {
1503
+ let cardinality = 0;
1504
+ for await (const chunk of chunks) cardinality += chunk.bitmap.size;
1505
+ const judged = await judgeLoad(dest, cardinality, this.lifecycleDeps("materializeMany"), {
1506
+ ...write.allowEmpty === void 0 ? {} : { allowEmpty: write.allowEmpty },
1507
+ ...write.guard === void 0 ? {} : { guard: write.guard }
1508
+ });
1509
+ return {
1510
+ dryRun: true,
1511
+ published: false,
1512
+ cardinality,
1513
+ cardinalityBefore: judged.cardinalityBefore,
1514
+ ...judged.wouldRefuse === void 0 ? {} : { wouldRefuse: judged.wouldRefuse },
1515
+ reads: judged.reads
1438
1516
  };
1439
1517
  }
1440
1518
  epochNow() {
@@ -1487,20 +1565,10 @@ var CloudRoaring = class _CloudRoaring {
1487
1565
  const row = await this.withRetries(() => registry.get(ref));
1488
1566
  return row === null ? null : { generation: row.currentGen, token: row.token };
1489
1567
  }
1490
- /** Throw what a read of `handle` must throw now: its lease error, or a refusal for a deadline that has passed. */
1491
- assertReadable(op, handle) {
1492
- const view = viewOf(handle);
1493
- const lease = view.leaseError();
1568
+ /** Throw what a read of, or a publish to, `handle` must throw now: its lease error, if it has one. */
1569
+ assertLease(handle) {
1570
+ const lease = viewOf(handle).leaseError();
1494
1571
  if (lease !== void 0) throw lease;
1495
- if (view.expired()) {
1496
- throw new ValidationError2(
1497
- `${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.`
1498
- );
1499
- }
1500
- }
1501
- /** {@link assertReadable} for a destination, named by where it sits in the call. */
1502
- assertWritable(where, dest) {
1503
- this.assertReadable(where, dest);
1504
1572
  }
1505
1573
  /**
1506
1574
  * **Subject access (GDPR Art. 15 / CCPA right-to-know): which segments is this id a member of?**
@@ -1518,6 +1586,8 @@ var CloudRoaring = class _CloudRoaring {
1518
1586
  * generation 0 while this store held its old generation 0 is told apart too.
1519
1587
  */
1520
1588
  async subjectReport(id, options = {}) {
1589
+ checkOptions(options, "subjectReport", CALL_KEYS.subjectReport, ["allNamespaces"]);
1590
+ options ??= {};
1521
1591
  const registry = this.requireRegistry("subjectReport");
1522
1592
  requireScope(options, "subjectReport");
1523
1593
  validateConcurrency(options.concurrency);
@@ -1581,8 +1651,9 @@ var CloudRoaring = class _CloudRoaring {
1581
1651
  * every generation below its new pointer — the one this rewrite is streaming, and the object this rewrite
1582
1652
  * just wrote — so the loser can find its own inputs deleted mid-flight. That surfaces as an outcome rather than
1583
1653
  * an error, read off the row: a moved pointer is a `'superseded'` entry, which a re-run settles, and a segment
1584
- * that a concurrent `dropSegment` tombstoned or a retention sweep purged is left out of the ledger, as a fresh
1585
- * call would leave it out.
1654
+ * that a concurrent `dropSegment` tombstoned or a retention sweep purged is left out of the ledger. A tombstoned
1655
+ * segment is still searched: a cleartext object left under it that holds the id is deleted, with everything under
1656
+ * the tombstone, and the entry reads `erased: true`.
1586
1657
  *
1587
1658
  * **Read `note` on any `erased: false` entry — the two reasons mean different things.** `'superseded'` means
1588
1659
  * another writer (a load, another erasure, or a rollback) moved the pointer mid-call, so **this call** did not
@@ -1611,6 +1682,8 @@ var CloudRoaring = class _CloudRoaring {
1611
1682
  */
1612
1683
  async eraseSubject(id, options = {}) {
1613
1684
  this.erasureEpoch++;
1685
+ checkOptions(options, "eraseSubject", CALL_KEYS.eraseSubject, ["allNamespaces"]);
1686
+ options ??= {};
1614
1687
  this.erasuresRunning++;
1615
1688
  try {
1616
1689
  return await this.eraseSubjectRun(id, options);
@@ -1639,7 +1712,6 @@ var CloudRoaring = class _CloudRoaring {
1639
1712
  recs,
1640
1713
  options.concurrency ?? DEFAULT_ADMIN_CONCURRENCY,
1641
1714
  async (rec) => {
1642
- if (rec.status === "destroyed") return null;
1643
1715
  const ref = { segment: rec.segment, namespace: rec.namespace };
1644
1716
  try {
1645
1717
  const result = await eraseIdFromSegment(
@@ -1703,10 +1775,9 @@ var CloudRoaring = class _CloudRoaring {
1703
1775
  * exists. It is `false` for a segment whose row was minted ahead of its first load (by `setRetention`) and
1704
1776
  * for a `destroyed` tombstone, because a read answers empty in both cases.
1705
1777
  *
1706
- * Two states answer `true` where a read still gives you nothing: a torn restore (a live pointer whose object
1778
+ * One state answers `true` where a read still gives you nothing: a torn restore (a live pointer whose object
1707
1779
  * was deleted) makes reads of the object *throw* rather than answer empty, while a cold `count()` answers the number
1708
- * its row records — `checkConsistency` is the call for that — and
1709
- * a handle carrying an expired `expiresAt` reads empty by a rule that lives on the handle, not the row.
1780
+ * its row records — `checkConsistency` is the call for that.
1710
1781
  *
1711
1782
  * Not a lock: the answer can change the moment it returns. If it has to hold, use the fence built for that —
1712
1783
  * `load`'s `guard`, or `expectFrom`/`expectToken` on a publish. Needs a storage backend.
@@ -1749,6 +1820,8 @@ var CloudRoaring = class _CloudRoaring {
1749
1820
  * ```
1750
1821
  */
1751
1822
  segments(options = {}) {
1823
+ checkOptions(options, "segments", CALL_KEYS.segments);
1824
+ options ??= {};
1752
1825
  if (options.namespace !== void 0) {
1753
1826
  validateSegmentRef({ segment: "x", namespace: options.namespace });
1754
1827
  refuseReservedNamespace(options.namespace);
@@ -1794,6 +1867,8 @@ var CloudRoaring = class _CloudRoaring {
1794
1867
  */
1795
1868
  async rollback(ref, toGeneration, options = {}) {
1796
1869
  validateSegmentRef(ref);
1870
+ checkOptions(options, "rollback", CALL_KEYS.rollback, ["allowForward"]);
1871
+ options ??= {};
1797
1872
  const deps = this.lifecycleDeps("rollback");
1798
1873
  try {
1799
1874
  return await rollbackSegment(ref, toGeneration, deps, options);
@@ -1886,6 +1961,8 @@ var CloudRoaring = class _CloudRoaring {
1886
1961
  */
1887
1962
  async load(ref, input, options = {}) {
1888
1963
  validateSegmentRef(ref);
1964
+ checkOptions(options, "load", CALL_KEYS.load, ["allowEmpty"]);
1965
+ options ??= {};
1889
1966
  const deps = this.lifecycleDeps("load");
1890
1967
  try {
1891
1968
  return await loadSegment(ref, bitmapAsLoadInput(input), deps, options);
@@ -1940,6 +2017,10 @@ var CloudRoaring = class _CloudRoaring {
1940
2017
  */
1941
2018
  async dropSegment(ref, options) {
1942
2019
  validateSegmentRef(ref);
2020
+ if (options === void 0 || options === null) {
2021
+ throw new ValidationError2("dropSegment: options { confirmSegment } are required");
2022
+ }
2023
+ checkOptions(options, "dropSegment", CALL_KEYS.dropSegment, ["dryRun"]);
1943
2024
  const deps = this.lifecycleDeps("dropSegment");
1944
2025
  try {
1945
2026
  return await dropSegment(
@@ -2067,69 +2148,20 @@ var CloudRoaring = class _CloudRoaring {
2067
2148
  * because retiring a segment deletes its storage objects. `now` defaults to the store's clock.
2068
2149
  */
2069
2150
  async retireExpired(options = {}) {
2151
+ checkOptions(options, "retireExpired", CALL_KEYS.retireExpired, ["dryRun", "purgeTombstones"]);
2152
+ const opts = picked(options, CALL_KEYS.retireExpired);
2070
2153
  const deps = this.lifecycleDeps("retireExpired");
2071
2154
  const result = await retireExpired(
2072
2155
  { registry: deps.registry, storage: deps.storage, clock: this.clock, rng: this.rng },
2073
- { ...options, now: options.now ?? this.clock.now() }
2156
+ { ...opts, now: opts.now ?? this.clock.now() }
2074
2157
  );
2075
- if (options.dryRun !== true) {
2158
+ if (opts.dryRun !== true) {
2076
2159
  for (const entry of result.entries) {
2077
2160
  this.engine.invalidate({ segment: entry.segment, namespace: entry.namespace });
2078
2161
  }
2079
2162
  }
2080
2163
  return result;
2081
2164
  }
2082
- /**
2083
- * **Remove the registry rows that releases before 0.12.0 left as `deleted: true` envelopes.** Those releases never
2084
- * removed a row they deleted, and no later call removes it either: it is invisible to every read, and every full
2085
- * listing of the registry still reads it (one GET each). This is the one call that removes them. It is an admin call,
2086
- * not a sweep, and nothing schedules it: run it once after the last process on a release before 0.12.0 is gone.
2087
- *
2088
- * ```ts
2089
- * const preview = await store.reapRegistryTombstones({ dryRun: true });
2090
- * console.log(`would remove ${preview.wouldReap} of ${preview.examined} rows read`);
2091
- *
2092
- * const done = await store.reapRegistryTombstones({ confirmNoLegacyWriters: true });
2093
- * if (done.limited) runAgain(); // `limit` (default 1,000) stopped the run with rows unread
2094
- * ```
2095
- *
2096
- * **What it removes.** Only a row that is `deleted: true` and whose token carries no incarnation id, which is what a
2097
- * 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
2098
- * remove), whatever its `status`. It never touches a live row, a `destroyed` row that is not `deleted` (a crypto-shred's
2099
- * or a `dropSegment`'s tombstone: the attestation of an erasure, which it cannot tell from another), or a deleted row
2100
- * with an incarnation id, and it never touches a generation. **So it does not clean a bucket completely**: the
2101
- * `destroyed` tombstones `dropSegment` leaves stay, so do live rows written before 0.12.0, and so does a tombstone a
2102
- * 0.12.0 or later release wrote while `conditionalDelete` was off (GCS by default, S3 on a custom endpoint): every full
2103
- * listing still reads it, and this call neither removes it nor runs on such a store.
2104
- * It also covers the library's own bookkeeping rows when `namespace` is not set, by the same rule. The default
2105
- * namespace cannot be scoped to: leave `namespace` out to cover it.
2106
- *
2107
- * **The two things it asks.** `confirmNoLegacyWriters: true` is required for a real run (without it, a
2108
- * `ValidationError`, and nothing is requested): your statement that no process on a release before 0.12.0 writes
2109
- * this registry, since one that did would, re-creating a removed name, issue the removed row's tokens again. And the
2110
- * registry must apply a delete under a precondition: where its `conditionalDelete` capability is off (GCS by default,
2111
- * S3 on a custom endpoint) it throws `CapabilityError` before any request, dry run included. A registry that keeps
2112
- * no such rows (the in-memory and local-filesystem ones) throws `UnsupportedError`.
2113
- *
2114
- * **Each removal is fenced** on the version it read, so a `create` that lands over the envelope first wins, the
2115
- * delete is refused, and the row is counted as `skipped.raced` (which also counts a row already gone). That holds where
2116
- * the backend applies the precondition, which auto-detection checks (S3 only on an AWS host). On an endpoint that
2117
- * ignores `If-Match` (MinIO, fake-gcs-server), a `conditionalDelete: true` you set makes this an unfenced delete: run
2118
- * it with every writer stopped. On a versioned bucket a delete leaves a delete marker and the earlier version, record
2119
- * included, so the name is not erased from the bucket. A `create` that had already read the envelope and
2120
- * whose write meets the removal throws `WriteConflictError`, which this library does not retry: retry the create, and
2121
- * it finds no row and writes one.
2122
- *
2123
- * **Cost** over R rows read and E removed: `ceil(R / 1000)` LIST requests, R GETs and E DELETEs (a dry run, no
2124
- * DELETEs). `limit` bounds the removals, and the run stops listing once it is spent. **A run is not resumable**: each
2125
- * lists and reads from the start (R GETs whatever the `limit`), a dry run with a `limit` reports the same first rows
2126
- * every time, so `wouldReap` is a total only when `limited` is `false`, and `limited` is `true` whenever the limit was
2127
- * spent with keys left, removable or not. An unreadable object stops the run with an error naming its key, and
2128
- * removes nothing more: it may be a newer release's row. Scope the run with `namespace` to get past one.
2129
- */
2130
- async reapRegistryTombstones(options = {}) {
2131
- return reapRegistryTombstones(this.requireRegistry("reapRegistryTombstones"), options);
2132
- }
2133
2165
  /**
2134
2166
  * **Forget everything this store has cached about a segment.** Use it after destroying or retiring a segment
2135
2167
  * through a path this store did not perform itself.
@@ -2176,7 +2208,7 @@ var CloudRoaring = class _CloudRoaring {
2176
2208
  * pinned view reports the version captured at pin time, marked as a pin's, so its decoded chunks are never
2177
2209
  * those of a live read that fetched across a publish (see {@link PinnedStorageChunkSource.currentVersion}).
2178
2210
  */
2179
- async pinSegment(ref, expiresAt, leaseUntil, named) {
2211
+ async pinSegment(ref, leaseUntil, named) {
2180
2212
  const crbm = this.crbmSource;
2181
2213
  if (crbm === void 0) {
2182
2214
  throw new UnsupportedError(
@@ -2236,9 +2268,8 @@ var CloudRoaring = class _CloudRoaring {
2236
2268
  clock: this.clock,
2237
2269
  metrics: this.metrics,
2238
2270
  materialize: (dest, ids, op, options) => this.materialize(dest, ids, op, options),
2239
- pinned: (r, e, l, at2) => this.pinSegment(r, e, l, at2),
2271
+ pinned: (r, l, at2) => this.pinSegment(r, l, at2),
2240
2272
  combineEngine: (handles) => this.engineForCombine(handles),
2241
- expiresAt,
2242
2273
  pinnedAt,
2243
2274
  lease: hold
2244
2275
  });
@@ -2344,6 +2375,8 @@ var CloudRoaring = class _CloudRoaring {
2344
2375
  * `storage` and `registry` with a higher `maxScanSegments`.
2345
2376
  */
2346
2377
  async checkConsistency(options = {}) {
2378
+ checkOptions(options, "checkConsistency", CALL_KEYS.checkConsistency, ["summaries"]);
2379
+ options ??= {};
2347
2380
  const deps = this.lifecycleDeps("checkConsistency");
2348
2381
  return runConsistencyCheck(
2349
2382
  { storage: deps.storage, registry: deps.registry, keystore: deps.keystore },
@@ -2363,15 +2396,16 @@ var CloudRoaring = class _CloudRoaring {
2363
2396
  * CLI wraps this with a filesystem sink.
2364
2397
  */
2365
2398
  async exportSegments(sink, options = {}) {
2399
+ checkOptions(options, "exportSegments", CALL_KEYS.exportSegments);
2400
+ const opts = picked(options, CALL_KEYS.exportSegments);
2366
2401
  const registry = this.requireRegistry("exportSegments");
2367
- return runExport(this, registry, sink, {
2368
- ...options,
2369
- codec: options.codec ?? roaringCodec
2370
- });
2402
+ return runExport(this, registry, sink, { ...opts, codec: opts.codec ?? roaringCodec });
2371
2403
  }
2372
2404
  /**
2373
2405
  * Planning cost estimate — pure, no instance/data needed: sizing, sales, what-if. For a real, grounded report
2374
2406
  * from live segment sizes, use `store.segment(name).costReport()`. See {@link CostReport}.
2407
+ * @deprecated Moves to `@cloudbitmaps/tools`, a package of its own, with a `size` on `stat()` so a grounded report
2408
+ * needs nothing internal. It is a planning tool, and its price list is as old as the release that ships it.
2375
2409
  */
2376
2410
  static estimateCost(input) {
2377
2411
  return estimateCost(input);
@@ -2401,24 +2435,17 @@ var readOptions = (options) => ({
2401
2435
  allowAbsentOperands: options.allowAbsentOperands
2402
2436
  });
2403
2437
  var NO_SEGMENTS = [];
2404
- var EMPTY_IDS = {
2405
- async *[Symbol.asyncIterator]() {
2406
- },
2407
- async *batches() {
2408
- }
2409
- };
2438
+ function segmentList(list, what) {
2439
+ if (!Array.isArray(list)) throw new ValidationError2(`${what} must be an array of segments`);
2440
+ return list;
2441
+ }
2410
2442
  var AS_IDS = {
2411
- none: EMPTY_IDS,
2412
2443
  failing,
2413
2444
  guard: guardIds,
2414
2445
  intersect: (engine, refs, opts) => withBatches(engine.intersect(refs, opts), () => engine.intersectBatches(refs, opts)),
2415
2446
  union: (engine, refs, opts) => withBatches(engine.union(refs, opts), () => engine.unionBatches(refs, opts)),
2416
2447
  andNot: (engine, base, refs, opts) => withBatches(engine.andNot(base, refs, opts), () => engine.andNotBatches(base, refs, opts))
2417
2448
  };
2418
- var NO_CHUNKS = {
2419
- async *[Symbol.asyncIterator]() {
2420
- }
2421
- };
2422
2449
  var CombineChunks = class {
2423
2450
  constructor(chunks) {
2424
2451
  this.chunks = chunks;
@@ -2426,7 +2453,6 @@ var CombineChunks = class {
2426
2453
  chunks;
2427
2454
  };
2428
2455
  var AS_CHUNKS = {
2429
- none: NO_CHUNKS,
2430
2456
  guard: guardChunks,
2431
2457
  failing: (err) => ({
2432
2458
  [Symbol.asyncIterator]: () => ({ next: () => Promise.reject(err) })
@@ -2436,39 +2462,14 @@ var AS_CHUNKS = {
2436
2462
  andNot: (engine, base, refs, opts) => engine.andNotChunks(base, refs, opts)
2437
2463
  };
2438
2464
  var KEEP_EVERY_GENERATION = Number.MAX_SAFE_INTEGER;
2439
- var heldOf;
2440
- var makeMemoryOperand;
2441
- var mintingMemory = false;
2442
- var MemoryOperand = class _MemoryOperand {
2443
- view;
2444
- static {
2445
- heldOf = (operand) => operand.view;
2446
- makeMemoryOperand = (view) => {
2447
- mintingMemory = true;
2448
- try {
2449
- return new _MemoryOperand(view);
2450
- } finally {
2451
- mintingMemory = false;
2452
- }
2453
- };
2454
- }
2455
- /** Not constructible: a handle comes from {@link CloudRoaring.memory}. */
2456
- constructor(view) {
2457
- if (!mintingMemory) {
2458
- throw new ValidationError2(
2459
- "a MemoryOperand is not constructed directly; call `await store.memory(ids)`"
2460
- );
2461
- }
2462
- this.view = view;
2463
- }
2464
- /**
2465
- * Zero and drop the bytes it holds. Any later use throws {@link ValidationError}; releasing twice does nothing. A call
2466
- * still reading it fails the outputs that read it.
2467
- */
2468
- release() {
2469
- this.view.chunks.release();
2465
+ async function timeOp(metrics, clock, name, fn) {
2466
+ const startedAt = clock.now();
2467
+ try {
2468
+ return await fn();
2469
+ } finally {
2470
+ metrics.onEvent({ kind: "op", name, ms: Math.max(0, clock.now() - startedAt) });
2470
2471
  }
2471
- };
2472
+ }
2472
2473
  var makeSegment;
2473
2474
  var minting = false;
2474
2475
  var Segment = class _Segment {
@@ -2480,8 +2481,6 @@ var Segment = class _Segment {
2480
2481
  materialize;
2481
2482
  pinned;
2482
2483
  combineEngine;
2483
- /** Absolute epoch-ms deadline from {@link SegmentOptions.expiresAt}; `undefined` ⇒ this handle never expires. */
2484
- expiresAt;
2485
2484
  /**
2486
2485
  * The generation this handle is held at, when it came from {@link Segment.pin}. Read by the store so a
2487
2486
  * pinned handle passed as an **operand** is still read at its pin rather than live.
@@ -2506,9 +2505,7 @@ var Segment = class _Segment {
2506
2505
  viewOf = (segment) => ({
2507
2506
  ref: segment.ref,
2508
2507
  pinnedAt: segment.pinnedAt,
2509
- expiresAt: segment.expiresAt,
2510
- leaseError: () => segment.leaseError(),
2511
- expired: () => segment.expired()
2508
+ leaseError: () => segment.leaseError()
2512
2509
  });
2513
2510
  }
2514
2511
  /**
@@ -2528,7 +2525,6 @@ var Segment = class _Segment {
2528
2525
  this.materialize = parts.materialize;
2529
2526
  this.pinned = parts.pinned;
2530
2527
  this.combineEngine = parts.combineEngine;
2531
- this.expiresAt = parts.expiresAt;
2532
2528
  this.pinnedAt = parts.pinnedAt;
2533
2529
  this.leaseHold = parts.lease;
2534
2530
  if (parts.lease !== void 0) {
@@ -2618,12 +2614,12 @@ var Segment = class _Segment {
2618
2614
  async pin(options) {
2619
2615
  this.assertLeases([this]);
2620
2616
  const leaseUntil = this.leaseUntilOf("pin", options);
2621
- return this.pinned(this.ref, this.expiresAt, leaseUntil);
2617
+ return this.pinned(this.ref, leaseUntil);
2622
2618
  }
2623
2619
  /**
2624
2620
  * Hold what this pin names until `leaseUntil`, or `undefined` for a pin with no lease. Refuses an option it does not
2625
- * know, a value that is not epoch-milliseconds in the future, one past the longest lease, and one past the handle's own
2626
- * `expiresAt`, before any request is made.
2621
+ * know, a value that is not epoch-milliseconds in the future, and one past the longest lease, before any request is
2622
+ * made.
2627
2623
  */
2628
2624
  leaseUntilOf(op, options) {
2629
2625
  if (options === void 0 || options === null) return void 0;
@@ -2654,11 +2650,6 @@ var Segment = class _Segment {
2654
2650
  `${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`
2655
2651
  );
2656
2652
  }
2657
- if (this.expiresAt !== void 0 && until > this.expiresAt) {
2658
- throw new ValidationError2(
2659
- `${op}: leaseUntil outlasts this handle's own expiresAt, which would have it read empty while it still holds the generation`
2660
- );
2661
- }
2662
2653
  return until;
2663
2654
  }
2664
2655
  /**
@@ -2683,7 +2674,7 @@ var Segment = class _Segment {
2683
2674
  }
2684
2675
  /**
2685
2676
  * The error a read of this handle must throw, or `undefined` when it holds no lease or the lease is live. One property
2686
- * read on a handle with no lease. It is not {@link Segment.expired}: that rule reads empty, and a lease must never.
2677
+ * read on a handle with no lease. A read past a lease throws; it never reads empty.
2687
2678
  */
2688
2679
  leaseError() {
2689
2680
  const hold = this.leaseHold;
@@ -2705,9 +2696,21 @@ var Segment = class _Segment {
2705
2696
  }
2706
2697
  return void 0;
2707
2698
  }
2699
+ /**
2700
+ * Refuse with a {@link ValidationError} anything among `handles` that is not a segment, a value a JavaScript caller
2701
+ * passed. It runs first in every combine, before the lease check reads the handles.
2702
+ */
2703
+ assertSegments(handles) {
2704
+ for (const h of handles) {
2705
+ if (h instanceof _Segment || typeof h === "object" && h !== null && typeof h.leaseError === "function") {
2706
+ continue;
2707
+ }
2708
+ throw new ValidationError2("an operand must be a segment from store.segment()");
2709
+ }
2710
+ }
2708
2711
  /**
2709
2712
  * Throw the first lease error among `handles`: this handle and every operand and exclude of a call. It runs before
2710
- * any `expiresAt` rule and before the engine, so no operand, exclude or cached reader can answer past a lease.
2713
+ * the engine, so no operand, exclude or cached reader can answer past a lease.
2711
2714
  */
2712
2715
  assertLeases(handles) {
2713
2716
  for (const h of handles) {
@@ -2752,7 +2755,7 @@ var Segment = class _Segment {
2752
2755
  );
2753
2756
  }
2754
2757
  const leaseUntil = this.leaseUntilOf("pinAt", options);
2755
- return this.pinned(this.ref, this.expiresAt, leaseUntil, { generation, fingerprint });
2758
+ return this.pinned(this.ref, leaseUntil, { generation, fingerprint });
2756
2759
  }
2757
2760
  /**
2758
2761
  * An opaque string that names this handle's segment, namespace included: two handles of one segment have the
@@ -2764,49 +2767,17 @@ var Segment = class _Segment {
2764
2767
  return segmentKey(this.ref);
2765
2768
  }
2766
2769
  /**
2767
- * Has this handle's deadline passed? **The lazy half of expiry** — the whole check is one comparison against
2768
- * the injected clock, so it costs nothing on a handle with no deadline and no I/O on one that has expired.
2769
- *
2770
- * Deliberately evaluated per call rather than cached: a long-lived handle created before its deadline must
2771
- * start reading empty the moment the deadline passes, without the caller re-creating it.
2772
- */
2773
- expired() {
2774
- return this.expiresAt !== void 0 && this.clock.now() >= this.expiresAt;
2775
- }
2776
- /**
2777
- * Refuse a materialisation that involves an **expired** handle. Called by the three `*Into` verbs only.
2778
- *
2779
- * On the read verbs an expired handle answers empty, which is the point of lazy expiry. On a *write* the same
2780
- * rule would be destructive in a way nobody asks for: `a.intersectInto(dest, [b])` where `b`'s deadline has
2781
- * quietly passed would, unrefused, replace `dest` with an **empty generation** — a wipe, reported as a successful
2782
- * write, with the cause (a deadline on a handle somewhere) nowhere in the result. Reads degrade to empty; writes must not.
2783
- *
2784
- * So every handle in the call is checked, `dest` included: an expired `dest` does not change the bytes written,
2785
- * but a caller who put a deadline on the thing they are writing into has said something contradictory and is
2786
- * better told than guessed at. Open a handle without `expiresAt` to write, or drop the deadline.
2787
- *
2788
- * (The broader guard — refusing to publish an empty or implausible generation over a non-empty one, with an
2789
- * `allowEmpty` override — covers these verbs too: they route through the same guarded write path as
2790
- * {@link CloudRoaring.load}. The two stay separate because they differ in kind. That one is a REPORTED
2791
- * refusal a caller may legitimately override; an expired handle is a wiring mistake, so it THROWS, before
2792
- * any object is written — and `allowEmpty: true` does not reach it.)
2770
+ * Refuse an `*Into` whose handles cannot be read, before any object is written: anything that is not a segment
2771
+ * ({@link ValidationError}), and a handle (this one, `dest` or an operand) whose lease has ended or was released
2772
+ * ({@link LeaseExpiredError}). Called by the three `*Into` verbs only.
2793
2773
  *
2794
2774
  * The verbs are `async` so this surfaces as a **rejected promise**, like every other validation in the facade —
2795
2775
  * a synchronous throw out of a promise-returning method escapes a caller who attached `.catch()` instead of
2796
2776
  * awaiting.
2797
2777
  */
2798
- refuseIfExpired(op, dest, operands) {
2778
+ assertIntoHandles(dest, operands) {
2779
+ this.assertSegments([dest, ...operands]);
2799
2780
  this.assertLeases([this, dest, ...operands]);
2800
- const stale = [];
2801
- for (const seg of [this, dest, ...operands]) {
2802
- if (seg.expired())
2803
- stale.push(seg.ref.namespace ? `${seg.ref.namespace}/${seg.ref.segment}` : seg.ref.segment);
2804
- }
2805
- if (stale.length > 0) {
2806
- throw new ValidationError2(
2807
- `${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.`
2808
- );
2809
- }
2810
2781
  }
2811
2782
  /** `result` as is, or, when a handle of the call holds a lease, checking that lease each time it reads a chunk. */
2812
2783
  guarded(out, handles, result) {
@@ -2829,7 +2800,6 @@ var Segment = class _Segment {
2829
2800
  has(id) {
2830
2801
  const lease = this.leaseError();
2831
2802
  if (lease !== void 0) return Promise.reject(lease);
2832
- if (this.expired()) return Promise.resolve(false);
2833
2803
  return this.timed("has", () => this.engine.has(this.ref, id));
2834
2804
  }
2835
2805
  /**
@@ -2853,15 +2823,14 @@ var Segment = class _Segment {
2853
2823
  count() {
2854
2824
  const lease = this.leaseError();
2855
2825
  if (lease !== void 0) return Promise.reject(lease);
2856
- if (this.expired()) return Promise.resolve(0);
2857
2826
  return this.timed("count", () => this.engine.count(this.ref));
2858
2827
  }
2859
2828
  /**
2860
2829
  * What the generation this handle reads is, from one resolution: its number, its id count and the metadata it
2861
2830
  * was loaded with (absent when it has none). It is what answers {@link Segment.count}, so the three describe one
2862
2831
  * generation and cannot straddle a publish. One registry read when cold, none while warm, and none on a pinned
2863
- * handle, which answers for the generation it pinned. A segment with no generation, and an expired handle,
2864
- * answer `{ generation: null, cardinality: 0 }`.
2832
+ * handle, which answers for the generation it pinned. A segment with no generation answers
2833
+ * `{ generation: null, cardinality: 0 }`.
2865
2834
  *
2866
2835
  * Trust is as for `count()`: the registry row's word, not confirmed against the object until the object is
2867
2836
  * opened, when a disagreement makes this process stop using that row's summary.
@@ -2872,7 +2841,6 @@ var Segment = class _Segment {
2872
2841
  */
2873
2842
  async stat() {
2874
2843
  this.assertLeases([this]);
2875
- if (this.expired()) return { generation: null, cardinality: 0 };
2876
2844
  return this.engine.stat(this.ref);
2877
2845
  }
2878
2846
  /**
@@ -2887,13 +2855,13 @@ var Segment = class _Segment {
2887
2855
  * ```
2888
2856
  *
2889
2857
  * Each bound is optional and an integer in `0..4294967295`; a bad one throws {@link ValidationError} when the
2890
- * stream is first read. `after >= through` is an empty range, which reads nothing. An expired handle reads empty
2891
- * without checking its options, as every read of one does.
2858
+ * stream is first read. `after >= through` is an empty range, which reads nothing.
2892
2859
  */
2893
2860
  iterate(options) {
2894
2861
  const lease = this.leaseError();
2895
2862
  if (lease !== void 0) return failing(lease);
2896
- if (this.expired()) return EMPTY_IDS;
2863
+ const bad = optionsError(options, "iterate", CALL_KEYS.range);
2864
+ if (bad !== void 0) return failing(bad);
2897
2865
  const range = options == null ? void 0 : rangeOf(options);
2898
2866
  const none = range === void 0 || range.after === void 0 && range.through === void 0;
2899
2867
  const { engine, ref } = this;
@@ -2926,7 +2894,7 @@ var Segment = class _Segment {
2926
2894
  *
2927
2895
  * A live handle is refused with {@link UnsupportedError} at the first read, because its counts and its chunks could
2928
2896
  * come from two generations and name the wrong id. `n` that is not a positive integer throws
2929
- * {@link ValidationError} at the first read, as does a bad bound. An expired handle reads empty.
2897
+ * {@link ValidationError} at the first read, as does a bad bound.
2930
2898
  *
2931
2899
  * ```ts
2932
2900
  * const audience = await store.segment('audience').pin();
@@ -2937,7 +2905,8 @@ var Segment = class _Segment {
2937
2905
  everyNth(n, options) {
2938
2906
  const lease = this.leaseError();
2939
2907
  if (lease !== void 0) return failing(lease);
2940
- if (this.expired()) return EMPTY_IDS;
2908
+ const bad = optionsError(options, "everyNth", CALL_KEYS.range);
2909
+ if (bad !== void 0) return failing(bad);
2941
2910
  if (this.pinnedAt === void 0) {
2942
2911
  return failing(
2943
2912
  new UnsupportedError(
@@ -2975,30 +2944,8 @@ var Segment = class _Segment {
2975
2944
  */
2976
2945
  excludesOf(options) {
2977
2946
  const exclude = options?.exclude;
2978
- return exclude == null || exclude.length === 0 ? NO_SEGMENTS : exclude;
2979
- }
2980
- /**
2981
- * The refusal for a combine whose **exclusion** has expired, or `undefined` when none has. Unlike an expired
2982
- * operand, which reads empty, an exclusion is a suppression or opt-out list: left out silently, the result
2983
- * would include the very ids it was passed to remove. So it is refused with a {@link ValidationError} naming each
2984
- * expired exclusion, as the `*Into` verbs refuse an expired handle.
2985
- *
2986
- * It is judged against the injected clock when the combine is called, before the engine is asked for anything:
2987
- * no request is made, and an expired exclusion that names a segment that does not exist is refused as expired,
2988
- * not as an absent operand. It runs ahead of the expired-operand shortcuts, so a combine that would read empty
2989
- * anyway still refuses a lapsed exclusion. A stream already being read is not re-checked when an exclusion
2990
- * expires part-way, as an operand expiring part-way does not stop it either.
2991
- */
2992
- expiredExcludes(op, excludes) {
2993
- let stale;
2994
- for (const e of excludes) {
2995
- if (!e.expired()) continue;
2996
- (stale ??= []).push(e.ref.namespace ? `${e.ref.namespace}/${e.ref.segment}` : e.ref.segment);
2997
- }
2998
- if (stale === void 0) return void 0;
2999
- return new ValidationError2(
3000
- `${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.`
3001
- );
2947
+ if (exclude == null) return NO_SEGMENTS;
2948
+ return segmentList(exclude, "`exclude`").length === 0 ? NO_SEGMENTS : exclude;
3002
2949
  }
3003
2950
  /**
3004
2951
  * Chunk-skipping intersection: stream the ids in **this** segment AND every segment in `others`, ascending.
@@ -3010,19 +2957,21 @@ var Segment = class _Segment {
3010
2957
  * override the store's per-op denial-of-wallet budget for this call (or `false` to lift it).
3011
2958
  */
3012
2959
  intersect(others, options) {
3013
- return this.intersectAs(AS_IDS, others, options);
2960
+ const bad = optionsError(options, "intersect", CALL_KEYS.combine, ["allowAbsentOperands"]);
2961
+ if (bad !== void 0) return failing(bad);
2962
+ return this.intersectAs(AS_IDS, others, options ?? void 0);
3014
2963
  }
3015
2964
  intersectAs(out, others, options) {
3016
- const exclude = this.excludesOf(options);
3017
- const handles = [this, ...others, ...exclude];
2965
+ let exclude;
2966
+ let handles;
3018
2967
  try {
2968
+ exclude = this.excludesOf(options);
2969
+ handles = [this, ...segmentList(others, "intersect: `others`"), ...exclude];
2970
+ this.assertSegments(handles);
3019
2971
  this.assertLeases(handles);
3020
2972
  } catch (err) {
3021
2973
  return out.failing(err);
3022
2974
  }
3023
- const refused = this.expiredExcludes("intersect", exclude);
3024
- if (refused) return out.failing(refused);
3025
- if (this.expired() || others.some((o) => o.expired())) return out.none;
3026
2975
  let engine;
3027
2976
  try {
3028
2977
  engine = this.combineEngine([this, ...others, ...exclude]) ?? this.engine;
@@ -3046,16 +2995,21 @@ var Segment = class _Segment {
3046
2995
  * write is **refused and reported**: `published: false`, `reason: 'empty'`, and `dest` keeps what it had.
3047
2996
  * Pass `allowEmpty: true` when emptying the destination is the point.
3048
2997
  *
3049
- * `guard` adds the same plausibility bounds `load()` takes — `minCardinality` and `minRetained` — judged
2998
+ * `guard` adds the same plausibility bounds `load()` takes — `minCardinality`, `minRetained` and `maxGrowth` — judged
3050
2999
  * against what `dest` held before. A refusal is **reported, not thrown**, exactly as on `load()`; branch on
3051
3000
  * `published`. A lost race is the one outcome that still throws ({@link WriteConflictError}), because a
3052
3001
  * materialisation that silently did not take effect is the one thing a caller cannot detect on its own.
3053
3002
  *
3054
- * A call involving an **expired handle** is refused before any of this. See {@link refuseIfExpired}.
3003
+ * A call involving a handle whose lease has ended, or anything that is not a segment, is refused before any of this.
3055
3004
  */
3056
3005
  async intersectInto(dest, others, options) {
3057
- this.refuseIfExpired("intersectInto", dest, [...others, ...options?.exclude ?? []]);
3058
- this.combineEngine([this, ...others, ...options?.exclude ?? []]);
3006
+ checkOptions(options, "intersectInto", CALL_KEYS.into, ["allowEmpty", "allowAbsentOperands"]);
3007
+ const operands = [
3008
+ ...segmentList(others, "intersectInto: `others`"),
3009
+ ...this.excludesOf(options)
3010
+ ];
3011
+ this.assertIntoHandles(dest, operands);
3012
+ this.combineEngine([this, ...operands]);
3059
3013
  return this.timed(
3060
3014
  "intersectInto",
3061
3015
  () => this.materialize(
@@ -3077,27 +3031,21 @@ var Segment = class _Segment {
3077
3031
  * segment once (`unionInto`, or a load) is the cheaper shape.
3078
3032
  */
3079
3033
  union(others, options) {
3080
- return this.unionAs(AS_IDS, others, options);
3034
+ const bad = optionsError(options, "union", CALL_KEYS.combine, ["allowAbsentOperands"]);
3035
+ if (bad !== void 0) return failing(bad);
3036
+ return this.unionAs(AS_IDS, others, options ?? void 0);
3081
3037
  }
3082
3038
  unionAs(out, others, options) {
3083
- const exclude = this.excludesOf(options);
3084
- const handles = [this, ...others, ...exclude];
3039
+ let exclude;
3040
+ let handles;
3085
3041
  try {
3042
+ exclude = this.excludesOf(options);
3043
+ handles = [this, ...segmentList(others, "union: `others`"), ...exclude];
3044
+ this.assertSegments(handles);
3086
3045
  this.assertLeases(handles);
3087
3046
  } catch (err) {
3088
3047
  return out.failing(err);
3089
3048
  }
3090
- const refused = this.expiredExcludes("union", exclude);
3091
- if (refused) return out.failing(refused);
3092
- const live = others.filter((o) => !o.expired());
3093
- if (this.expired()) {
3094
- if (live.length === 0) return out.none;
3095
- return live[0].unionAs(out, live.slice(1), options);
3096
- }
3097
- if (live.length === 0 && others.length > 0) {
3098
- return exclude.length > 0 ? this.andNotAs(out, [...exclude], options) : this.unionAs(out, [], options);
3099
- }
3100
- if (live.length !== others.length) return this.unionAs(out, live, options);
3101
3049
  let engine;
3102
3050
  try {
3103
3051
  engine = this.combineEngine([this, ...others, ...exclude]) ?? this.engine;
@@ -3110,8 +3058,10 @@ var Segment = class _Segment {
3110
3058
  }
3111
3059
  /** Materialize `this ∪ others…` (minus `exclude`) as a **new generation of `dest`** — see {@link intersectInto}. */
3112
3060
  async unionInto(dest, others, options) {
3113
- this.refuseIfExpired("unionInto", dest, [...others, ...options?.exclude ?? []]);
3114
- this.combineEngine([this, ...others, ...options?.exclude ?? []]);
3061
+ checkOptions(options, "unionInto", CALL_KEYS.into, ["allowEmpty", "allowAbsentOperands"]);
3062
+ const operands = [...segmentList(others, "unionInto: `others`"), ...this.excludesOf(options)];
3063
+ this.assertIntoHandles(dest, operands);
3064
+ this.combineEngine([this, ...operands]);
3115
3065
  return this.timed(
3116
3066
  "unionInto",
3117
3067
  () => this.materialize(
@@ -3130,26 +3080,23 @@ var Segment = class _Segment {
3130
3080
  * suppression list: at most one read per surviving key of `this`, so subtracting a 61,000-chunk global
3131
3081
  * opt-out list from a 40-chunk audience costs at most 40 reads, not 61,000.
3132
3082
  *
3133
- * An expired handle in `excludes` is refused, not skipped: the stream throws {@link ValidationError} naming it,
3134
- * before any request is made, because a lapsed suppression list would otherwise let through the ids it was
3135
- * passed to remove. An expired `this` is empty. `exclude` on `intersect` and `union` follows the same rule.
3136
- *
3137
3083
  * To filter the *result of an intersection*, do not chain — pass `exclude` to {@link intersect} instead, so
3138
3084
  * the suppression folds into the same pass rather than materializing an intermediate segment first.
3139
3085
  */
3140
3086
  andNot(excludes, options) {
3141
- return this.andNotAs(AS_IDS, excludes, options);
3087
+ const bad = optionsError(options, "andNot", CALL_KEYS.andNot, ["allowAbsentOperands"]);
3088
+ if (bad !== void 0) return failing(bad);
3089
+ return this.andNotAs(AS_IDS, excludes, options ?? void 0);
3142
3090
  }
3143
3091
  andNotAs(out, excludes, options) {
3144
- const handles = [this, ...excludes];
3092
+ let handles;
3145
3093
  try {
3094
+ handles = [this, ...segmentList(excludes, "andNot: `excludes`")];
3095
+ this.assertSegments(handles);
3146
3096
  this.assertLeases(handles);
3147
3097
  } catch (err) {
3148
3098
  return out.failing(err);
3149
3099
  }
3150
- const refused = this.expiredExcludes("andNot", excludes);
3151
- if (refused) return out.failing(refused);
3152
- if (this.expired()) return out.none;
3153
3100
  let engine;
3154
3101
  try {
3155
3102
  engine = this.combineEngine([this, ...excludes]) ?? this.engine;
@@ -3163,7 +3110,11 @@ var Segment = class _Segment {
3163
3110
  }
3164
3111
  /** Materialize `this \ (excludes…)` as a **new generation of `dest`** — see {@link intersectInto}. */
3165
3112
  async andNotInto(dest, excludes, options) {
3166
- this.refuseIfExpired("andNotInto", dest, excludes);
3113
+ checkOptions(options, "andNotInto", CALL_KEYS.andNotInto, [
3114
+ "allowEmpty",
3115
+ "allowAbsentOperands"
3116
+ ]);
3117
+ this.assertIntoHandles(dest, segmentList(excludes, "andNotInto: `excludes`"));
3167
3118
  this.combineEngine([this, ...excludes]);
3168
3119
  return this.timed(
3169
3120
  "andNotInto",
@@ -3181,13 +3132,21 @@ var Segment = class _Segment {
3181
3132
  * storage. The pointer refresh is priced at the store's own `cache.genTtlMs`, or at none when the store never
3182
3133
  * refreshes, unless the workload sets `genTtlMs`. See {@link CostReport} — it always includes a verdict (incl.
3183
3134
  * the lose-zone).
3135
+ * @deprecated Moves to `@cloudbitmaps/tools`, a package of its own, with a `size` on `stat()` so a grounded report
3136
+ * needs nothing internal. It is a planning tool, and its price list is as old as the release that ships it.
3184
3137
  */
3185
3138
  async costReport(options) {
3139
+ checkOptions(options, "costReport", CALL_KEYS.costReport);
3140
+ const shape = options?.workload;
3141
+ if (shape !== void 0 && shape !== null && (typeof shape !== "object" || Array.isArray(shape))) {
3142
+ throw new ValidationError2("costReport: workload must be an object");
3143
+ }
3186
3144
  this.assertLeases([this]);
3187
3145
  const canMeasure = this.engine.supportsStorageSize;
3188
3146
  const size = canMeasure ? await this.engine.segmentSize(this.ref) : null;
3189
3147
  const refreshMs = this.engine.pointerRefreshMs;
3190
- const workload = refreshMs === void 0 || options?.workload?.genTtlMs !== void 0 ? options?.workload : { ...options?.workload, genTtlMs: refreshMs };
3148
+ const given = options?.workload === void 0 || options.workload === null ? void 0 : picked(options.workload, CALL_KEYS.workload);
3149
+ const workload = refreshMs === void 0 || given?.genTtlMs !== void 0 ? given : { ...given, genTtlMs: refreshMs };
3191
3150
  return groundedReport({
3192
3151
  storageBytes: size?.sizeBytes ?? 0,
3193
3152
  grounded: canMeasure,
@@ -3208,7 +3167,7 @@ function parseConfig(env) {
3208
3167
  if (out === void 0 || out === "") {
3209
3168
  throw new Error("CR_EXPORT_OUT is required (the output directory for the dump)");
3210
3169
  }
3211
- const format = env.CR_EXPORT_FORMAT ?? "roaring";
3170
+ const format = env.CR_EXPORT_FORMAT || "roaring";
3212
3171
  if (format !== "roaring" && format !== "ndjson") {
3213
3172
  throw new Error(`CR_EXPORT_FORMAT must be "roaring" or "ndjson"; got ${format}`);
3214
3173
  }
@@ -3269,14 +3228,29 @@ function fsSink(out) {
3269
3228
  }
3270
3229
  };
3271
3230
  }
3231
+ async function requireStoreRoot(root) {
3232
+ let entries;
3233
+ try {
3234
+ entries = await readdir(root);
3235
+ } catch {
3236
+ throw new ValidationError3("CR_EXPORT_ROOT does not name a directory that can be read");
3237
+ }
3238
+ if (entries.length === 0) {
3239
+ throw new ValidationError3(
3240
+ "CR_EXPORT_ROOT is empty: it holds no store (an unmounted volume, or the wrong directory)"
3241
+ );
3242
+ }
3243
+ }
3272
3244
  var log = (obj) => {
3273
3245
  process.stdout.write(`${JSON.stringify(obj)}
3274
3246
  `);
3275
3247
  };
3276
3248
  async function main(env = process.env, now = () => Date.now()) {
3277
3249
  const config = parseConfig(env);
3250
+ await requireStoreRoot(config.root);
3278
3251
  const storage = new LocalFsStorage(config.root);
3279
3252
  const store = new CloudRoaring({ storage });
3253
+ await rm(join(config.out, "manifest.json"), { force: true });
3280
3254
  const manifest = await store.exportSegments(fsSink(config.out), {
3281
3255
  format: config.format,
3282
3256
  namespace: config.namespace