@cloudbitmaps/core 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.
Files changed (38) hide show
  1. package/dist/{chunk-ML6AT53O.js → chunk-TNWTIAON.js} +50 -9
  2. package/dist/chunk-TNWTIAON.js.map +7 -0
  3. package/dist/core/audit.d.ts +13 -3
  4. package/dist/core/bit-route.d.ts +5 -5
  5. package/dist/core/blob.d.ts +4 -1
  6. package/dist/core/budget.d.ts +7 -2
  7. package/dist/core/combine-feed.d.ts +3 -7
  8. package/dist/core/combine-many.d.ts +22 -29
  9. package/dist/core/consistency.d.ts +0 -1
  10. package/dist/core/cost.d.ts +13 -2
  11. package/dist/core/crbm/reader.d.ts +4 -3
  12. package/dist/core/crbm-storage-source.d.ts +86 -13
  13. package/dist/core/discover.d.ts +3 -5
  14. package/dist/core/engine.d.ts +15 -0
  15. package/dist/core/erase-id.d.ts +7 -5
  16. package/dist/core/erasure.d.ts +22 -2
  17. package/dist/core/errors.d.ts +10 -2
  18. package/dist/core/load-input.d.ts +1 -2
  19. package/dist/core/load.d.ts +48 -3
  20. package/dist/core/metrics.d.ts +19 -7
  21. package/dist/core/ports.d.ts +21 -62
  22. package/dist/core/retention-sweep.d.ts +9 -4
  23. package/dist/core/retention.d.ts +17 -0
  24. package/dist/core/retry.d.ts +6 -0
  25. package/dist/driver-kit.d.ts +1 -1
  26. package/dist/driver-kit.js +8 -101
  27. package/dist/driver-kit.js.map +2 -2
  28. package/dist/drivers/_shared/object-registry.d.ts +1 -19
  29. package/dist/drivers/_shared/registry.d.ts +5 -2
  30. package/dist/drivers/localfs/fs-util.d.ts +7 -0
  31. package/dist/drivers/localfs/registry.d.ts +1 -0
  32. package/dist/index.d.ts +5 -9
  33. package/dist/index.js +864 -600
  34. package/dist/index.js.map +4 -4
  35. package/package.json +1 -1
  36. package/dist/chunk-ML6AT53O.js.map +0 -7
  37. package/dist/core/combine-held.d.ts +0 -43
  38. package/dist/core/registry-reap.d.ts +0 -22
package/dist/index.js CHANGED
@@ -34,8 +34,11 @@ import {
34
34
  entropyIsAvailable,
35
35
  heldGenerations,
36
36
  incarnationOf,
37
+ isBudgetExceededError,
38
+ isCapabilityError,
37
39
  isCloudRoaringError,
38
40
  isIntegrityError,
41
+ isKeyUnavailableError,
39
42
  isLeaseExpiredError,
40
43
  isLeaseLimitError,
41
44
  isLive,
@@ -44,6 +47,7 @@ import {
44
47
  isStaleOperandError,
45
48
  isStorageBackend,
46
49
  isTransientError,
50
+ isUnsupportedError,
47
51
  isValidationError,
48
52
  isWriteConflictError,
49
53
  leaseChurn,
@@ -70,7 +74,7 @@ import {
70
74
  validateUserNamespace,
71
75
  validateUserRef,
72
76
  webCryptoEntropy
73
- } from "./chunk-ML6AT53O.js";
77
+ } from "./chunk-TNWTIAON.js";
74
78
 
75
79
  // src/core/bit-route.ts
76
80
  var U32_MAX = 4294967295;
@@ -91,6 +95,20 @@ function joinId(chunkKey, remainder) {
91
95
  var DEFAULT_BUDGET = { maxRequests: 1e6 };
92
96
  function resolveBudget(opt, fallback) {
93
97
  if (opt === false) return null;
98
+ if (opt !== void 0 && opt !== null) {
99
+ if (typeof opt !== "object" || Array.isArray(opt)) {
100
+ const got = Array.isArray(opt) ? "an array" : `a ${typeof opt}`;
101
+ throw new ValidationError(`budget must be { maxRequests } or false; got ${got}`);
102
+ }
103
+ const unknown = Object.keys(opt).filter(
104
+ (k) => k !== "maxRequests" && opt[k] !== void 0
105
+ );
106
+ if (unknown.length > 0) {
107
+ throw new ValidationError(
108
+ `budget: unknown option${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")}; it takes { maxRequests } or false`
109
+ );
110
+ }
111
+ }
94
112
  const maxRequests = opt?.maxRequests ?? fallback.maxRequests;
95
113
  if (!Number.isInteger(maxRequests) || maxRequests < 1) {
96
114
  throw new ValidationError(`budget.maxRequests must be a positive integer; got ${maxRequests}`);
@@ -98,7 +116,7 @@ function resolveBudget(opt, fallback) {
98
116
  return { maxRequests };
99
117
  }
100
118
  function resolvePerOpBudget(opt, storeBudget) {
101
- if (opt === void 0) return storeBudget;
119
+ if (opt === void 0 || opt === null) return storeBudget;
102
120
  return resolveBudget(opt, storeBudget ?? DEFAULT_BUDGET);
103
121
  }
104
122
  function checkBudget(budget, projectedRequests, op) {
@@ -326,6 +344,50 @@ function shardOf(key, totalShards) {
326
344
  return (h >>> 0) % totalShards;
327
345
  }
328
346
 
347
+ // src/core/audit.ts
348
+ var NOOP_AUDIT = {
349
+ onEvent() {
350
+ }
351
+ };
352
+ function safeAudit(sink) {
353
+ if (sink === NOOP_AUDIT) return sink;
354
+ return {
355
+ onEvent(event) {
356
+ try {
357
+ ignoreRejection(sink.onEvent(event));
358
+ } catch {
359
+ }
360
+ }
361
+ };
362
+ }
363
+ function checkedAuditSink(sink, op) {
364
+ if (sink === void 0 || sink === null) return;
365
+ if (typeof sink.onEvent !== "function") {
366
+ throw new ValidationError(
367
+ `${op}: audit must be a sink with an onEvent(event) method, such as a RecordingAuditSink`
368
+ );
369
+ }
370
+ }
371
+ function ignoreRejection(returned) {
372
+ if (typeof returned?.then === "function") {
373
+ returned.then(void 0, () => {
374
+ });
375
+ }
376
+ }
377
+ var RecordingAuditSink = class {
378
+ recorded = [];
379
+ onEvent(event) {
380
+ this.recorded.push(event);
381
+ }
382
+ /** An independent copy of the recorded events, in emission order. */
383
+ snapshot() {
384
+ return this.recorded.map((e) => ({ ...e }));
385
+ }
386
+ reset() {
387
+ this.recorded.length = 0;
388
+ }
389
+ };
390
+
329
391
  // src/core/metrics.ts
330
392
  var NOOP_METRICS = {
331
393
  onEvent() {
@@ -333,10 +395,15 @@ var NOOP_METRICS = {
333
395
  };
334
396
  function safeMetrics(sink) {
335
397
  if (sink === NOOP_METRICS) return sink;
398
+ if (typeof sink?.onEvent !== "function") {
399
+ throw new ValidationError(
400
+ "metrics must be a sink with an onEvent(event) method, such as a CountingMetricsSink"
401
+ );
402
+ }
336
403
  return {
337
404
  onEvent(event) {
338
405
  try {
339
- sink.onEvent(event);
406
+ ignoreRejection(sink.onEvent(event));
340
407
  } catch {
341
408
  }
342
409
  }
@@ -347,7 +414,8 @@ var OP_NAMES = [
347
414
  "count",
348
415
  "intersectInto",
349
416
  "unionInto",
350
- "andNotInto"
417
+ "andNotInto",
418
+ "materializeMany"
351
419
  ];
352
420
  var CountingMetricsSink = class _CountingMetricsSink {
353
421
  storage = { gets: 0, bytes: 0, totalMs: 0 };
@@ -586,6 +654,7 @@ var SegmentEngine = class {
586
654
  let total2 = 0;
587
655
  for (const [k, n] of cardinalities) {
588
656
  assertChunkKeyInRange(k);
657
+ if (n !== 0) assertChunkCardinalityInRange(n);
589
658
  total2 += n;
590
659
  }
591
660
  return total2;
@@ -625,7 +694,7 @@ var SegmentEngine = class {
625
694
  chunkWindow(seg, chunkKeys, gen, ramp) {
626
695
  return new ChunkWindow(
627
696
  chunkKeys,
628
- (chunkKey) => this.storageChunk({ ...seg, chunkKey }, gen),
697
+ (chunkKey) => this.storageChunk({ ...seg, chunkKey }, gen, true),
629
698
  DEFAULT_INTERSECT_CONCURRENCY,
630
699
  ramp
631
700
  );
@@ -1092,7 +1161,9 @@ var SegmentEngine = class {
1092
1161
  }
1093
1162
  if (acc.isEmpty) return null;
1094
1163
  if (relevant.length > 0) {
1095
- const cuts = together ? fetched.slice(present.length) : await Promise.all(relevant.map((e) => this.storageChunk({ ...e.seg, chunkKey }, e.gen)));
1164
+ const cuts = together ? fetched.slice(present.length) : await Promise.all(
1165
+ relevant.map((e) => this.storageChunk({ ...e.seg, chunkKey }, e.gen, true))
1166
+ );
1096
1167
  for (const cut of cuts) {
1097
1168
  if (cut === null) continue;
1098
1169
  acc.andNotInPlace(cut);
@@ -1106,7 +1177,7 @@ var SegmentEngine = class {
1106
1177
  * so a stream's take is made before anything is awaited: concurrent takes of one stream must be made in key order.
1107
1178
  */
1108
1179
  operandChunk(op, chunkKey) {
1109
- return op.streamed === void 0 ? this.storageChunk({ ...op.seg, chunkKey }, op.gen) : this.streamedChunk(op.streamed, chunkKey);
1180
+ return op.streamed === void 0 ? this.storageChunk({ ...op.seg, chunkKey }, op.gen, true) : this.streamedChunk(op.streamed, chunkKey);
1110
1181
  }
1111
1182
  /**
1112
1183
  * `keys` of `seg` as a stream of coalesced ranges, or `undefined` when the read is per key: a source with no
@@ -1180,15 +1251,16 @@ var SegmentEngine = class {
1180
1251
  if (!streamed.opened) {
1181
1252
  const cache = this.cache;
1182
1253
  if (cache) {
1183
- const hit = cache.get(this.chunkCacheKey({ ...streamed.seg, chunkKey }, streamed.gen));
1184
- if (this.metricsOn) this.metrics.onEvent({ kind: "cache", hit: hit !== void 0 });
1185
- if (hit) return Promise.resolve(hit);
1254
+ const ref = { ...streamed.seg, chunkKey };
1255
+ const hit = cache.get(this.chunkCacheKey(ref, streamed.gen));
1256
+ if (hit) return this.cachedIfCurrent(ref, streamed.gen, hit, true, streamed);
1257
+ if (this.metricsOn) this.metrics.onEvent({ kind: "cache", hit: false });
1186
1258
  }
1187
1259
  this.startStream(streamed, chunkKey);
1188
1260
  } else if (streamed.inStream !== void 0 && !streamed.inStream.has(chunkKey)) {
1189
1261
  const ref = { ...streamed.seg, chunkKey };
1190
1262
  const hit = this.cache?.get(this.chunkCacheKey(ref, streamed.gen));
1191
- return hit ? Promise.resolve(hit) : this.storageChunk(ref, streamed.gen);
1263
+ return hit ? this.cachedIfCurrent(ref, streamed.gen, hit, false, streamed) : this.storageChunk(ref, streamed.gen, true, false);
1192
1264
  }
1193
1265
  return streamed.stream.take(chunkKey).then((read) => {
1194
1266
  if (read.bytes === null) return null;
@@ -1210,6 +1282,28 @@ var SegmentEngine = class {
1210
1282
  streamedVersion(planned, read) {
1211
1283
  return planned === void 0 || this.storage.currentVersion === void 0 ? planned : read;
1212
1284
  }
1285
+ /**
1286
+ * `hit`, a chunk cached under the version a multi-chunk read planned under, if that is still the segment's version;
1287
+ * else the chunk as it is now. A read resolves the segment again before it serves each chunk, which the source's
1288
+ * stream does for the chunks it delivers; a chunk served from the cache is checked here, or a read whose chunks are
1289
+ * all cached would go on serving the generation it planned under, an erased id included, for as long as it is pulled.
1290
+ * Within `cache.genTtlMs` the source answers from its snapshot, so the check is a lookup, not a request. `report`:
1291
+ * emit the `cache` event for this lookup, which a key counted when its stream opened does not. `streamed`: the read
1292
+ * this chunk belongs to, which a move carries to the version now current, so the rest of it looks there: a read whose
1293
+ * stream is not yet open reads the rest as one stream, not a chunk at a time.
1294
+ */
1295
+ async cachedIfCurrent(ref, planned, hit, report, streamed) {
1296
+ const now = planned === void 0 ? void 0 : await this.cacheVersion(ref);
1297
+ if (now === planned) {
1298
+ if (report && this.metricsOn) this.metrics.onEvent({ kind: "cache", hit: true });
1299
+ return hit;
1300
+ }
1301
+ if (streamed !== void 0 && streamed.gen === planned && now !== null) {
1302
+ streamed.gen = now;
1303
+ if (streamed.opened && this.storage.currentVersion === void 0) streamed.invalidated = true;
1304
+ }
1305
+ return this.storageChunk(ref, now, false, report);
1306
+ }
1213
1307
  /** The cache key of a chunk: by the version it was read under, or by segment and key alone for a source with none. */
1214
1308
  chunkCacheKey(ref, version) {
1215
1309
  return version === void 0 ? chunkRefKey(ref) : chunkGenKey(ref, version);
@@ -1249,7 +1343,7 @@ var SegmentEngine = class {
1249
1343
  if (absent.length === 0) return;
1250
1344
  const named = absent.map((s) => s.namespace === void 0 ? `"${s.segment}"` : `"${s.namespace}/${s.segment}"`).join(", ");
1251
1345
  throw new ValidationError(
1252
- `${op}: operand ${named} names a segment that does not exist, so it would contribute nothing \u2014 an exclude would suppress no ids and an include would contribute none. Check the name and the namespace (a segment addressed without its \`namespace\` is a DIFFERENT segment). Pass \`allowAbsentOperands: true\` if you meant it.`
1346
+ `${op}: operand ${named} names a segment that does not exist or was dropped, so it would contribute nothing \u2014 an exclude would suppress no ids and an include would contribute none. Check the name and the namespace (a segment addressed without its \`namespace\` is a DIFFERENT segment). Pass \`allowAbsentOperands: true\` if you meant it.`
1253
1347
  );
1254
1348
  }
1255
1349
  /**
@@ -1307,17 +1401,22 @@ var SegmentEngine = class {
1307
1401
  *
1308
1402
  * The returned instance is **shared** (it may be the cached one, and every caller that joined a read gets the same
1309
1403
  * one): callers read it or clone it, never mutate it.
1404
+ *
1405
+ * `recheck`: `gen` was planned for a read of many chunks, so a cached chunk is served only while it is still the
1406
+ * segment's version ({@link cachedIfCurrent}). A point read resolves `gen` just before it asks, and passes none.
1407
+ * `report`: emit the `cache` event for this lookup; a key already counted for this read does not.
1310
1408
  */
1311
- async storageChunk(ref, gen) {
1409
+ async storageChunk(ref, gen, recheck = false, report = true) {
1312
1410
  if (gen === null) return null;
1313
1411
  const cacheKey = this.chunkCacheKey(ref, gen);
1314
1412
  if (this.cache) {
1315
1413
  const cached = this.cache.get(cacheKey);
1316
1414
  if (cached) {
1317
- if (this.metricsOn) this.metrics.onEvent({ kind: "cache", hit: true });
1415
+ if (recheck) return this.cachedIfCurrent(ref, gen, cached, report);
1416
+ if (report && this.metricsOn) this.metrics.onEvent({ kind: "cache", hit: true });
1318
1417
  return cached;
1319
1418
  }
1320
- if (this.metricsOn) this.metrics.onEvent({ kind: "cache", hit: false });
1419
+ if (report && this.metricsOn) this.metrics.onEvent({ kind: "cache", hit: false });
1321
1420
  }
1322
1421
  const open4 = this.openReads.get(cacheKey);
1323
1422
  if (open4) return open4.read;
@@ -1657,7 +1756,7 @@ async function reconcile(ref, deps, options) {
1657
1756
  try {
1658
1757
  await deps.registry.compareAndSwap(ref, token, { keptGens: present });
1659
1758
  } catch (err) {
1660
- if (!(err instanceof WriteConflictError)) throw err;
1759
+ if (!isWriteConflictError(err)) throw err;
1661
1760
  }
1662
1761
  }
1663
1762
  return deleted;
@@ -1741,11 +1840,11 @@ var STORE_LOAD_POINTER_READS = 2 + 2 / LIST_COLLECTION_CADENCE;
1741
1840
  var STORE_LOAD_EXISTENCE_CHECKS = 2 - 1 / LIST_COLLECTION_CADENCE;
1742
1841
  var RETENTION_SWEEP_REQUESTS = deepFreeze({
1743
1842
  conditionalDelete: {
1744
- retirement: { reads: 9, writes: 3, deletes: 1 },
1843
+ retirement: { reads: 7, writes: 2, deletes: 1 },
1745
1844
  purge: { reads: 4, writes: 0, deletes: 2 }
1746
1845
  },
1747
1846
  tombstoning: {
1748
- retirement: { reads: 8, writes: 3, deletes: 0 },
1847
+ retirement: { reads: 6, writes: 2, deletes: 0 },
1749
1848
  purge: { reads: 3, writes: 1, deletes: 0 }
1750
1849
  }
1751
1850
  });
@@ -1926,6 +2025,11 @@ function buildReport(input) {
1926
2025
  const storedGiB = storageBytes / GIB;
1927
2026
  const baseline = resolveBaseline(redis, storedGiB);
1928
2027
  const baselineUSD = baseline.monthlyUSD;
2028
+ if ((input.workload.cacheHitRate ?? 0) > 1) {
2029
+ throw new ValidationError(
2030
+ `cacheHitRate must be a fraction from 0 to 1; got ${String(input.workload.cacheHitRate)}`
2031
+ );
2032
+ }
1929
2033
  const cacheHitRate = clamp01(
1930
2034
  requireFiniteNonNeg(input.workload.cacheHitRate ?? 0, "cacheHitRate")
1931
2035
  );
@@ -2060,7 +2164,31 @@ function buildReport(input) {
2060
2164
  assumptions: { cacheHitRate, pricingName: pricing.name, grounded, notes }
2061
2165
  };
2062
2166
  }
2167
+ function checkModelInputs(op, pricing, workload) {
2168
+ const storage = pricing !== null && typeof pricing === "object" ? pricing.storage : void 0;
2169
+ if (pricing !== void 0 && (storage === null || typeof storage !== "object")) {
2170
+ throw new ValidationError(`${op}: pricing must be a profile with a storage price list`);
2171
+ }
2172
+ if (workload !== void 0 && workload !== null && (typeof workload !== "object" || Array.isArray(workload))) {
2173
+ throw new ValidationError(`${op}: workload must be an object`);
2174
+ }
2175
+ }
2063
2176
  function estimateCost(input) {
2177
+ if (input === null || typeof input !== "object") {
2178
+ throw new ValidationError("estimateCost: input must be an object such as { segments }");
2179
+ }
2180
+ if (!Array.isArray(input.segments)) {
2181
+ throw new ValidationError(
2182
+ "estimateCost: segments must be an array of { sizeBytes } or { cardinality }"
2183
+ );
2184
+ }
2185
+ for (let i = 0; i < input.segments.length; i++) {
2186
+ const spec = input.segments[i];
2187
+ if (spec === null || typeof spec !== "object") {
2188
+ throw new ValidationError(`estimateCost: segments[${i}] must be an object`);
2189
+ }
2190
+ }
2191
+ checkModelInputs("estimateCost", input.pricing, input.workload);
2064
2192
  const pricing = input.pricing ?? AWS_US_EAST_1_ONDEMAND;
2065
2193
  const workload = input.workload ?? {};
2066
2194
  let storageBytes = 0;
@@ -2075,6 +2203,7 @@ function estimateCost(input) {
2075
2203
  return buildReport({ storageBytes, workload, pricing, grounded: false, sizesFromCardinality });
2076
2204
  }
2077
2205
  function groundedReport(input) {
2206
+ checkModelInputs("costReport", input.pricing, input.workload);
2078
2207
  return buildReport({
2079
2208
  storageBytes: input.storageBytes,
2080
2209
  workload: input.workload ?? {},
@@ -2185,7 +2314,22 @@ function requireCodec(codec, api) {
2185
2314
  var DEFAULT_NDJSON_BATCH_BYTES = 64 * 1024;
2186
2315
  var errMessage = (err) => err instanceof Error ? err.message : String(err);
2187
2316
  async function runExport(reader, registry, sink, options = {}) {
2317
+ if (sink === null || typeof sink !== "object" || typeof sink.open !== "function") {
2318
+ throw new ValidationError(
2319
+ "runExport: the sink must be an object with an open(ref, ext) method"
2320
+ );
2321
+ }
2322
+ options ??= {};
2188
2323
  if (options.namespace !== void 0) validateUserNamespace(options.namespace);
2324
+ if (options.format !== void 0 && options.format !== "roaring" && options.format !== "ndjson") {
2325
+ throw new ValidationError(`runExport: format must be 'roaring' or 'ndjson'`);
2326
+ }
2327
+ const batch = options.ndjsonBatchBytes;
2328
+ if (batch !== void 0 && (typeof batch !== "number" || !Number.isSafeInteger(batch) || batch < 1)) {
2329
+ throw new ValidationError(
2330
+ "runExport: ndjsonBatchBytes must be a whole number of bytes, 1 or more"
2331
+ );
2332
+ }
2189
2333
  const format = options.format ?? "roaring";
2190
2334
  const ext = format === "roaring" ? ".roaring" : ".ndjson";
2191
2335
  const batchCap = options.ndjsonBatchBytes ?? DEFAULT_NDJSON_BATCH_BYTES;
@@ -2389,6 +2533,7 @@ var MemoryRegistryDriver = class {
2389
2533
  return { token };
2390
2534
  }
2391
2535
  async *list(namespace) {
2536
+ if (namespace !== void 0) validateSegmentRef({ segment: "x", namespace });
2392
2537
  for (const row of this.rows.values()) {
2393
2538
  if (namespace === void 0 || row.namespace === namespace) yield structuredClone(row);
2394
2539
  }
@@ -2579,6 +2724,18 @@ async function fsyncDir(dir) {
2579
2724
  } catch {
2580
2725
  }
2581
2726
  }
2727
+ async function writeAll(handle, bytes) {
2728
+ let offset = 0;
2729
+ while (offset < bytes.length) {
2730
+ const { bytesWritten } = await handle.write(bytes, offset, bytes.length - offset);
2731
+ if (bytesWritten <= 0) {
2732
+ throw Object.assign(new Error("a write made no progress: the device is full"), {
2733
+ code: "ENOSPC"
2734
+ });
2735
+ }
2736
+ offset += bytesWritten;
2737
+ }
2738
+ }
2582
2739
 
2583
2740
  // src/drivers/localfs/exact-case.ts
2584
2741
  function firstCaseDifference(expected, actual) {
@@ -2718,7 +2875,7 @@ var LocalFsStorageDriver = class {
2718
2875
  let size = 0;
2719
2876
  const sink = {
2720
2877
  async write(bytes) {
2721
- await handle.write(bytes);
2878
+ await writeAll(handle, bytes);
2722
2879
  hash.update(bytes);
2723
2880
  size += bytes.length;
2724
2881
  }
@@ -2830,8 +2987,8 @@ var LocalFsStorageDriver = class {
2830
2987
  // src/drivers/localfs/registry.ts
2831
2988
  import { constants as FS3 } from "node:fs";
2832
2989
  import { randomUUID as randomUUID2 } from "node:crypto";
2833
- import { mkdir as mkdir2, open as open3, readdir as readdir3, realpath as realpath2, rename, unlink as unlink2 } from "node:fs/promises";
2834
- import { basename, dirname as dirname3, join as join3, resolve as resolve2 } from "node:path";
2990
+ import { mkdir as mkdir2, open as open3, opendir, readdir as readdir3, realpath as realpath2, rename, unlink as unlink2 } from "node:fs/promises";
2991
+ import { basename, dirname as dirname3, join as join3, relative as relative2, resolve as resolve2 } from "node:path";
2835
2992
  var DEFAULT_MAX_ROW_BYTES = 1 * 1024 * 1024;
2836
2993
  var rowChains = /* @__PURE__ */ new Map();
2837
2994
  async function rowLockKey(path) {
@@ -2868,7 +3025,7 @@ var LocalFsRegistryDriver = class {
2868
3025
  return entropyIsAvailable(this.entropy) ? { strongRead: true, conditionalDelete: true } : { strongRead: true, canWrite: false, conditionalDelete: true };
2869
3026
  }
2870
3027
  async get(ref) {
2871
- const env = await this.readRow(registryRowPath(this.root, ref));
3028
+ const env = await this.readRow(registryRowPath(this.root, ref), ref);
2872
3029
  return env && !env.deleted ? env.record : null;
2873
3030
  }
2874
3031
  async create(ref, record) {
@@ -2876,7 +3033,7 @@ var LocalFsRegistryDriver = class {
2876
3033
  const path = registryRowPath(this.root, ref);
2877
3034
  assertRegistryNamesFit(ref);
2878
3035
  return this.withRowLock(path, async () => {
2879
- const current = await this.readRow(path);
3036
+ const current = await this.readRow(path, ref);
2880
3037
  if (current !== null && !current.deleted) {
2881
3038
  throw new WriteConflictError(`registry row already exists for segment ${ref.segment}`);
2882
3039
  }
@@ -2894,7 +3051,7 @@ var LocalFsRegistryDriver = class {
2894
3051
  const path = registryRowPath(this.root, ref);
2895
3052
  assertRegistryNamesFit(ref);
2896
3053
  return this.withRowLock(path, async () => {
2897
- const current = await this.readRow(path);
3054
+ const current = await this.readRow(path, ref);
2898
3055
  if (current === null || current.deleted || current.record.token !== expected) {
2899
3056
  throw new WriteConflictError(`OCC token mismatch for registry row ${ref.segment}`);
2900
3057
  }
@@ -2911,17 +3068,18 @@ var LocalFsRegistryDriver = class {
2911
3068
  for (const ns of await this.namespaceDirs(namespace)) {
2912
3069
  const dir = registryDir(this.root, ns);
2913
3070
  if (await this.exactCase.differs(dir)) continue;
2914
- let names;
3071
+ let entries;
2915
3072
  try {
2916
- names = await readdir3(dir);
3073
+ entries = await opendir(dir);
2917
3074
  } catch (err) {
2918
3075
  if (isAbsent(err)) continue;
2919
3076
  throw mapFsError(err);
2920
3077
  }
2921
- for (const name of names) {
2922
- const segment = parseRegistryRow(name);
3078
+ for await (const entry of entries) {
3079
+ const segment = parseRegistryRow(entry.name);
2923
3080
  if (segment === null) continue;
2924
- const env = await this.readRow(registryRowPath(this.root, { namespace: ns, segment }));
3081
+ const ref = { namespace: ns, segment };
3082
+ const env = await this.readRow(registryRowPath(this.root, ref), ref);
2925
3083
  if (env && !env.deleted) yield env.record;
2926
3084
  }
2927
3085
  }
@@ -2929,7 +3087,7 @@ var LocalFsRegistryDriver = class {
2929
3087
  async delete(ref, expected) {
2930
3088
  const path = registryRowPath(this.root, ref);
2931
3089
  return this.withRowLock(path, async () => {
2932
- const current = await this.readRow(path);
3090
+ const current = await this.readRow(path, ref);
2933
3091
  if (expected !== void 0) {
2934
3092
  if (current === null || current.deleted || current.record.token !== expected) {
2935
3093
  throw new WriteConflictError(`OCC token mismatch for registry row ${ref.segment}`);
@@ -2971,7 +3129,8 @@ var LocalFsRegistryDriver = class {
2971
3129
  }
2972
3130
  return namespaces;
2973
3131
  }
2974
- async readRow(path) {
3132
+ /** Read the row at `path`, which must be the row of `ref`: one that names another segment is refused. */
3133
+ async readRow(path, ref) {
2975
3134
  let handle;
2976
3135
  try {
2977
3136
  if (await this.exactCase.differs(path)) return null;
@@ -2986,7 +3145,7 @@ var LocalFsRegistryDriver = class {
2986
3145
  throw new IntegrityError(`registry row ${size}B exceeds cap ${DEFAULT_MAX_ROW_BYTES}B`);
2987
3146
  }
2988
3147
  const text = (await handle.readFile()).toString("utf8");
2989
- return parseRegistryEnvelope(text, path);
3148
+ return parseRegistryEnvelope(text, relative2(this.root, path), ref);
2990
3149
  } finally {
2991
3150
  await handle.close();
2992
3151
  }
@@ -3008,11 +3167,16 @@ var LocalFsRegistryDriver = class {
3008
3167
  throw mapFsError(err);
3009
3168
  }
3010
3169
  try {
3011
- await handle.write(out);
3170
+ await writeAll(handle, out);
3012
3171
  await handle.sync();
3013
- } finally {
3014
- await handle.close();
3172
+ } catch (err) {
3173
+ await handle.close().catch(() => {
3174
+ });
3175
+ await unlink2(tmp).catch(() => {
3176
+ });
3177
+ throw mapFsError(err);
3015
3178
  }
3179
+ await handle.close();
3016
3180
  await rename(tmp, path).catch(async (err) => {
3017
3181
  await unlink2(tmp).catch(() => {
3018
3182
  });
@@ -3063,36 +3227,6 @@ var LocalFsStorage = class {
3063
3227
  registry;
3064
3228
  };
3065
3229
 
3066
- // src/core/audit.ts
3067
- var NOOP_AUDIT = {
3068
- onEvent() {
3069
- }
3070
- };
3071
- function safeAudit(sink) {
3072
- if (sink === NOOP_AUDIT) return sink;
3073
- return {
3074
- onEvent(event) {
3075
- try {
3076
- sink.onEvent(event);
3077
- } catch {
3078
- }
3079
- }
3080
- };
3081
- }
3082
- var RecordingAuditSink = class {
3083
- recorded = [];
3084
- onEvent(event) {
3085
- this.recorded.push(event);
3086
- }
3087
- /** An independent copy of the recorded events, in emission order. */
3088
- snapshot() {
3089
- return this.recorded.map((e) => ({ ...e }));
3090
- }
3091
- reset() {
3092
- this.recorded.length = 0;
3093
- }
3094
- };
3095
-
3096
3230
  // src/core/cooperative.ts
3097
3231
  var YIELD_EVERY = 1024;
3098
3232
  function yieldEvery(clock, everyN = YIELD_EVERY) {
@@ -3593,8 +3727,8 @@ var CrbmReader = class _CrbmReader {
3593
3727
  /**
3594
3728
  * Range-GET one chunk's payload, verifying its CRC32C before returning. Returns `null` if the chunk is
3595
3729
  * absent. Throws `IntegrityError` on a CRC mismatch (the bytes must never reach the native
3596
- * deserializer). The returned buffer is a **read-only view** owned by the reader/driver — callers must
3597
- * not mutate it.
3730
+ * deserializer). The returned bytes may be shared (a small generation's chunks are kept by the reader and handed out
3731
+ * again), so callers must not change them.
3598
3732
  */
3599
3733
  async getChunk(chunkKey) {
3600
3734
  const slot = this.slotOf(chunkKey);
@@ -3682,7 +3816,8 @@ var CrbmReader = class _CrbmReader {
3682
3816
  * others; `options.now` times each request for `options.onRequest`, which hears of every range request that was sent, once, when
3683
3817
  * it settles: taken, abandoned after the consumer stopped, or failed (a reader has no clock of its own, so `ms` is 0
3684
3818
  * without one).
3685
- * A plain chunk is a writable view into the range it was read in: copy a chunk to keep it.
3819
+ * A plain chunk is a view into the range it was read in, so keeping it keeps that whole range: copy a chunk to keep it
3820
+ * alone. Callers must not change it, as with {@link getChunk}.
3686
3821
  *
3687
3822
  * A stream that fails (a range that errors, a chunk that fails its check) raises at once and sends nothing further:
3688
3823
  * the ranges it still had in flight finish in the background, are not retried and never raise. Its `settled()`
@@ -3745,7 +3880,7 @@ var CrbmReader = class _CrbmReader {
3745
3880
  moved = bytes.length;
3746
3881
  if (bytes.length !== read.length) {
3747
3882
  throw new IntegrityError(
3748
- `.crbm range [${read.offset}, +${read.length}) read short (${bytes.length} bytes)`
3883
+ `.crbm range [${read.offset}, +${read.length}) answered ${bytes.length} bytes, not the ${read.length} asked for`
3749
3884
  );
3750
3885
  }
3751
3886
  return { bytes };
@@ -3953,7 +4088,7 @@ var CrbmWriter = class {
3953
4088
  constructor(sink, options) {
3954
4089
  this.sink = sink;
3955
4090
  this.options = options;
3956
- if (!Number.isInteger(options.generation) || options.generation < 0) {
4091
+ if (!Number.isSafeInteger(options.generation) || options.generation < 0) {
3957
4092
  throw new ValidationError(
3958
4093
  `generation must be a non-negative integer; got ${options.generation}`
3959
4094
  );
@@ -3995,6 +4130,11 @@ var CrbmWriter = class {
3995
4130
  );
3996
4131
  }
3997
4132
  if (payload.length === 0) throw new ValidationError("chunk payload must be non-empty");
4133
+ if (payload.length > DEFAULT_MAX_BITMAP_BYTES) {
4134
+ throw new ValidationError(
4135
+ `chunk payload of ${payload.length} bytes is over the ${DEFAULT_MAX_BITMAP_BYTES}-byte cap a reader opens`
4136
+ );
4137
+ }
3998
4138
  await this.ensurePreamble();
3999
4139
  const crypto = this.options.crypto;
4000
4140
  const stored = crypto ? framed(crypto.aead.seal(payload, crypto.aadFor(chunkKey))) : payload;
@@ -4190,14 +4330,14 @@ function overCap(what, length) {
4190
4330
  `${what} is ${length} bytes, more than any canonical 32-bit bitmap serializes to (${MAX_SERIALIZED_LOAD_BYTES}). If it was serialized from a bitmap, call runOptimize() on the bitmap first.`
4191
4331
  );
4192
4332
  }
4193
- function prepareLoadInput(input, codec, subject = "a load") {
4333
+ function prepareLoadInput(input, codec) {
4194
4334
  if (typeof input !== "object" || input === null) {
4195
- throw new ValidationError(`${subject} takes ids, { serialized } or { bitmap }`);
4335
+ throw new ValidationError("a load takes ids, { serialized } or { bitmap }");
4196
4336
  }
4197
4337
  const tag = tagOf(input);
4198
4338
  if (tag === "Uint8Array" || tag === "Uint8ClampedArray") {
4199
4339
  throw new ValidationError(
4200
- `${subject}'s ids cannot be a ${tag} or Buffer: each byte would be loaded as an id. Pass portable Roaring bytes as { serialized }, and ids as a Uint32Array or an array of numbers.`
4340
+ `a load's ids cannot be a ${tag} or Buffer: each byte would be loaded as an id. Pass portable Roaring bytes as { serialized }, and ids as a Uint32Array or an array of numbers.`
4201
4341
  );
4202
4342
  }
4203
4343
  if (Symbol.iterator in input || Symbol.asyncIterator in input) return input;
@@ -4220,7 +4360,7 @@ function prepareLoadInput(input, codec, subject = "a load") {
4220
4360
  return new DecodedLoadInput(decodeSerialized(bitmap.serialize("portable"), codec, what));
4221
4361
  }
4222
4362
  throw new ValidationError(
4223
- `${subject} takes ids (an iterable of integers), { serialized } or { bitmap }, as the only key; got an object with keys [${keys.join(", ")}].${subject === "a load" ? " Load options go in the next argument." : ""}`
4363
+ `a load takes ids (an iterable of integers), { serialized } or { bitmap }, as the only key; got an object with keys [${keys.join(", ")}]. Load options go in the next argument.`
4224
4364
  );
4225
4365
  }
4226
4366
  function decodeSerialized(bytes, codec, what = "the buffer") {
@@ -4395,10 +4535,16 @@ function once(fn) {
4395
4535
  };
4396
4536
  }
4397
4537
  var readerIsOf = (reader, live) => reader.generation === live.target.generation && reader.lineage === live.target.lineage;
4538
+ async function versionNamed(snap) {
4539
+ if (snap.target === void 0) {
4540
+ const reader = await snap.reader;
4541
+ return reader === null ? null : versionOf(reader.generation, reader.lineage);
4542
+ }
4543
+ const live = await snap.target;
4544
+ return live === null ? null : versionOf(live.target.generation, live.target.lineage);
4545
+ }
4398
4546
  function describe(reader) {
4399
- let cardinality = 0;
4400
- for (const n of reader.cardinalities().values()) cardinality += n;
4401
- return { cardinality, metadata: reader.metadata };
4547
+ return { cardinality: reader.count(), metadata: reader.metadata };
4402
4548
  }
4403
4549
  var Snapshot = class _Snapshot {
4404
4550
  constructor(target, open4, prior) {
@@ -4460,6 +4606,7 @@ var Snapshot = class _Snapshot {
4460
4606
  var SNAPSHOT_BASE_BYTES = 256;
4461
4607
  var BULK_FLUSH_IDS = 1 << 20;
4462
4608
  var YIELD_EVERY_IDS = 1 << 14;
4609
+ var RECENT_KEYS = 8;
4463
4610
  var CrbmStorageChunkSource = class {
4464
4611
  constructor(driver, options = {}) {
4465
4612
  this.driver = driver;
@@ -4488,8 +4635,13 @@ var CrbmStorageChunkSource = class {
4488
4635
  this.keystore = keystore;
4489
4636
  this.requireEncryption = requireEncryption ?? false;
4490
4637
  this.readerOptions = readerOptions;
4491
- const refreshes = registry !== void 0 && clock !== void 0 && (currentGenTtlMs ?? DEFAULT_CURRENT_GEN_TTL_MS) > 0;
4492
- this.keepChunkBytesUpTo = refreshes ? Math.floor(
4638
+ if (currentGenTtlMs !== void 0 && currentGenTtlMs !== null && (typeof currentGenTtlMs !== "number" || !Number.isFinite(currentGenTtlMs) || currentGenTtlMs < 0)) {
4639
+ throw new ValidationError(
4640
+ `cache.genTtlMs must be a finite number of milliseconds, 0 or more (0: no timed refresh); got ${typeof currentGenTtlMs === "number" ? String(currentGenTtlMs) : `a ${typeof currentGenTtlMs}`}`
4641
+ );
4642
+ }
4643
+ this.timedRefresh = registry !== void 0 && clock !== void 0 && (currentGenTtlMs ?? DEFAULT_CURRENT_GEN_TTL_MS) > 0;
4644
+ this.keepChunkBytesUpTo = this.timedRefresh ? Math.floor(
4493
4645
  (maxOpenIndexBytes ?? DEFAULT_MAX_OPEN_INDEX_BYTES) / (maxOpenSegments ?? DEFAULT_MAX_OPEN_SEGMENTS)
4494
4646
  ) : 0;
4495
4647
  this.clock = clock;
@@ -4559,12 +4711,33 @@ var CrbmStorageChunkSource = class {
4559
4711
  keepChunkBytesUpTo;
4560
4712
  clock;
4561
4713
  currentGenTtlMs;
4714
+ /**
4715
+ * Whether the segment's pointer is re-read on a timer: it needs a clock (the TTL), a registry (the cheap row read; with
4716
+ * none, resolving is a listing of the bucket) and a TTL above 0. It decides the keeping of chunk bytes, the TTL lapse
4717
+ * and the refresh interval the cost report prices, so it is decided once.
4718
+ */
4719
+ timedRefresh;
4720
+ /**
4721
+ * The last few segments' keys, newest first. A stream asks for its segment's snapshot before every cached chunk it
4722
+ * serves, and a combine alternates between a few operands, so comparing two names beats encoding them again, which
4723
+ * cost about a microsecond a chunk.
4724
+ */
4725
+ recentKeys = [];
4726
+ keyOf(ref) {
4727
+ for (const k of this.recentKeys) {
4728
+ if (k.segment === ref.segment && k.namespace === ref.namespace) return k.key;
4729
+ }
4730
+ const key = segmentKey(ref);
4731
+ this.recentKeys.unshift({ namespace: ref.namespace, segment: ref.segment, key });
4732
+ if (this.recentKeys.length > RECENT_KEYS) this.recentKeys.pop();
4733
+ return key;
4734
+ }
4562
4735
  /**
4563
4736
  * The segment's live snapshot, refreshing on the TTL. Cheap within the TTL window. The snapshot holds the resolved
4564
4737
  * target, and its reader once a read has asked for one.
4565
4738
  */
4566
4739
  liveSnapshot(ref) {
4567
- const key = segmentKey(ref);
4740
+ const key = this.keyOf(ref);
4568
4741
  const existing = this.snapshots.get(key);
4569
4742
  if (existing === void 0) {
4570
4743
  return this.install(
@@ -4656,10 +4829,19 @@ var CrbmStorageChunkSource = class {
4656
4829
  * this.
4657
4830
  */
4658
4831
  get pointerRefreshMs() {
4659
- return this.clock !== void 0 && this.registry !== void 0 ? this.currentGenTtlMs : 0;
4832
+ return this.timedRefresh ? this.currentGenTtlMs : 0;
4833
+ }
4834
+ /**
4835
+ * Whether a stream reading from `snap` must resolve its segment again before its next chunk, as a read of one chunk
4836
+ * would: the snapshot's TTL has lapsed, an invalidation has happened since `epoch`, or the snapshot is no longer the
4837
+ * one the reader cache holds for the segment (it let it go, or another read installed a newer one). Else nothing can
4838
+ * have moved it, and the check costs a compare and a lookup.
4839
+ */
4840
+ mayHaveMoved(key, snap, epoch) {
4841
+ return this.invalidations !== epoch || this.expired(snap.installedAtMs) || this.snapshots.get(key) !== snap;
4660
4842
  }
4661
4843
  expired(installedAtMs) {
4662
- return this.clock !== void 0 && this.registry !== void 0 && this.currentGenTtlMs > 0 && this.clock.now() - installedAtMs >= this.currentGenTtlMs;
4844
+ return this.timedRefresh && this.now() - installedAtMs >= this.currentGenTtlMs;
4663
4845
  }
4664
4846
  /**
4665
4847
  * The segment's current generation number — the engine keys its chunk cache by this so a generation bump
@@ -4743,6 +4925,7 @@ var CrbmStorageChunkSource = class {
4743
4925
  * once the swept one is gone is exactly what a pin taken now should hold. One retry, then propagate.
4744
4926
  */
4745
4927
  async pinGeneration(ref, lease) {
4928
+ validateUserRef(ref);
4746
4929
  for (let moved = 0; ; moved++) {
4747
4930
  try {
4748
4931
  try {
@@ -4774,6 +4957,7 @@ var CrbmStorageChunkSource = class {
4774
4957
  * of the row's summary against it, since that check is made where the row's own generation is read.
4775
4958
  */
4776
4959
  async pinGenerationAt(ref, at, lease) {
4960
+ validateUserRef(ref);
4777
4961
  const { generation, fingerprint } = at;
4778
4962
  const gone = (why) => new NotFoundError(
4779
4963
  `segment "${ref.segment}" generation ${generation} cannot be pinned: ${why}`
@@ -5060,7 +5244,11 @@ var CrbmStorageChunkSource = class {
5060
5244
  * tell apart.
5061
5245
  */
5062
5246
  async exists(ref) {
5063
- if (this.registry !== void 0) return await this.registry.get(ref) !== null;
5247
+ validateUserRef(ref);
5248
+ if (this.registry !== void 0) {
5249
+ const row = await this.registry.get(ref);
5250
+ return row !== null && row.status !== "destroyed";
5251
+ }
5064
5252
  for await (const key of this.driver.list(ref)) {
5065
5253
  void key;
5066
5254
  return true;
@@ -5169,7 +5357,7 @@ var CrbmStorageChunkSource = class {
5169
5357
  const keystore = this.keystore;
5170
5358
  if (keystore === void 0) {
5171
5359
  throw new KeyUnavailableError(
5172
- `segment "${ref.segment}" is encrypted but this CrbmStorageChunkSource has no keystore`
5360
+ `segment "${ref.segment}" is encrypted, and the store reading it was built with no keystore (encryption.keystore)`
5173
5361
  );
5174
5362
  }
5175
5363
  const aead = await (unwrap ?? (() => keystore.openDek(wrappedDeks)))();
@@ -5193,10 +5381,12 @@ var CrbmStorageChunkSource = class {
5193
5381
  for (const chunkKey of keys) validateChunkRef({ ...ref, chunkKey });
5194
5382
  validateChunkKeyOrder(keys);
5195
5383
  const retry = options?.retry;
5384
+ const key = segmentKey(ref);
5196
5385
  let yielded = 0;
5197
5386
  let healed = false;
5198
5387
  for (; ; ) {
5199
5388
  let snap = this.liveSnapshot(ref);
5389
+ let epoch = this.invalidations;
5200
5390
  let pending = snap.reader;
5201
5391
  let chunks;
5202
5392
  try {
@@ -5227,14 +5417,24 @@ var CrbmStorageChunkSource = class {
5227
5417
  let movedOn = false;
5228
5418
  try {
5229
5419
  while (await pull.advance()) {
5230
- const live = this.liveSnapshot(ref);
5231
- if (live !== snap) {
5232
- const now = await live.reader;
5233
- if (now === null || versionOf(now.generation, now.lineage) !== version) {
5234
- movedOn = true;
5235
- break;
5420
+ if (this.mayHaveMoved(key, snap, epoch)) {
5421
+ const live = this.liveSnapshot(ref);
5422
+ if (live !== snap) {
5423
+ let now;
5424
+ try {
5425
+ now = await versionNamed(live);
5426
+ } catch (err) {
5427
+ if (!isTransientError(err)) throw err;
5428
+ movedOn = true;
5429
+ break;
5430
+ }
5431
+ if (now !== version) {
5432
+ movedOn = true;
5433
+ break;
5434
+ }
5435
+ snap = live;
5236
5436
  }
5237
- snap = live;
5437
+ epoch = this.invalidations;
5238
5438
  }
5239
5439
  yield pull.take((chunk) => {
5240
5440
  yielded += 1;
@@ -5254,7 +5454,7 @@ var CrbmStorageChunkSource = class {
5254
5454
  if (!isNotFoundError(err) && !await this.replacedUnder(ref, pending, err, retry))
5255
5455
  throw err;
5256
5456
  await chunks?.settled();
5257
- this.dropStale(segmentKey(ref), snap);
5457
+ this.dropStale(key, snap);
5258
5458
  healed = true;
5259
5459
  }
5260
5460
  }
@@ -5795,6 +5995,11 @@ async function provesOwnObject(storage, key, fingerprint) {
5795
5995
  throw err;
5796
5996
  }
5797
5997
  }
5998
+ function cleartextUnderRequiredEncryption(ref, currentGen) {
5999
+ return new ValidationError(
6000
+ `requireEncryption: segment "${ref.segment}" already has generation ${currentGen} in cleartext, so this load cannot be encrypted \u2014 encryption is chosen when a segment is first loaded. Load into a new segment with the keystore wired, then drop this one.`
6001
+ );
6002
+ }
5798
6003
  async function bulkLoadAhead(driver, ref, ahead, ids, options = {}) {
5799
6004
  if (options.keystore === void 0 && options.requireEncryption === true) {
5800
6005
  throw new ValidationError("requireEncryption: a load needs a keystore to write encrypted");
@@ -5831,11 +6036,8 @@ async function bulkLoadAhead(driver, ref, ahead, ids, options = {}) {
5831
6036
  const aead = early !== void 0 && sameKeys(early.wrapped, existing.wrappedDeks) ? await early.aead : await options.keystore.openDek(existing.wrappedDeks);
5832
6037
  crypto = { aead, aadFor: (scope) => aadFor(key, key.generation, scope) };
5833
6038
  } else if (existing !== null && existing.currentGen !== null) {
5834
- if (options.requireEncryption === true) {
5835
- throw new ValidationError(
5836
- `requireEncryption: segment "${ref.segment}" already has generation ${existing.currentGen} in cleartext, so this load cannot be encrypted \u2014 encryption is chosen when a segment is first loaded. Load into a new segment with the keystore wired, then drop this one.`
5837
- );
5838
- }
6039
+ if (options.requireEncryption === true)
6040
+ throw cleartextUnderRequiredEncryption(ref, existing.currentGen);
5839
6041
  } else {
5840
6042
  const minted = await options.keystore.createDek();
5841
6043
  newWrapped = minted.wrapped;
@@ -6147,6 +6349,30 @@ function applyJitter(policy, delayMs, rng) {
6147
6349
  function retryRead(op, retry) {
6148
6350
  return retry === void 0 ? op() : withRetry(op, retry.policy ?? DEFAULT_RETRY_POLICY, retry);
6149
6351
  }
6352
+ function checkedRetryPolicy(policy) {
6353
+ const { maxAttempts, baseDelayMs, maxDelayMs, backoffFactor, jitter } = policy;
6354
+ const fail = (field, rule, v) => {
6355
+ throw new ValidationError(
6356
+ `retry.${field} must be ${rule}; got ${typeof v === "number" ? String(v) : `a ${typeof v}`}`
6357
+ );
6358
+ };
6359
+ if (typeof maxAttempts !== "number" || !Number.isFinite(maxAttempts) || maxAttempts < 1) {
6360
+ fail("maxAttempts", "a finite number >= 1", maxAttempts);
6361
+ }
6362
+ if (typeof baseDelayMs !== "number" || !Number.isFinite(baseDelayMs) || baseDelayMs < 0) {
6363
+ fail("baseDelayMs", "a finite number of milliseconds >= 0", baseDelayMs);
6364
+ }
6365
+ if (typeof maxDelayMs !== "number" || !Number.isFinite(maxDelayMs) || maxDelayMs < 0) {
6366
+ fail("maxDelayMs", "a finite number of milliseconds >= 0", maxDelayMs);
6367
+ }
6368
+ if (typeof backoffFactor !== "number" || !Number.isFinite(backoffFactor) || backoffFactor < 1) {
6369
+ fail("backoffFactor", "a finite number >= 1", backoffFactor);
6370
+ }
6371
+ if (jitter !== "full" && jitter !== "none") {
6372
+ throw new ValidationError(`retry.jitter must be 'full' or 'none'`);
6373
+ }
6374
+ return policy;
6375
+ }
6150
6376
  async function withRetry(op, policy, deps) {
6151
6377
  const retryable = deps.isRetryable ?? isTransientError;
6152
6378
  if (!Number.isFinite(policy.maxAttempts) || policy.maxAttempts < 1) {
@@ -6174,6 +6400,106 @@ async function withRetry(op, policy, deps) {
6174
6400
  }
6175
6401
 
6176
6402
  // src/core/load.ts
6403
+ function checkedGuard(guard, where) {
6404
+ if (guard === void 0 || guard === null) return void 0;
6405
+ const shown2 = (v) => typeof v === "number" ? String(v) : Array.isArray(v) ? "an array" : `a ${typeof v}`;
6406
+ if (typeof guard !== "object" || Array.isArray(guard)) {
6407
+ throw new ValidationError(
6408
+ `${where}guard must be an object of bounds, such as { minRetained: 0.5 }; got ${shown2(guard)}`
6409
+ );
6410
+ }
6411
+ const bounds = guard;
6412
+ const unknown = Object.keys(bounds).filter(
6413
+ (k) => k !== "minCardinality" && k !== "minRetained" && k !== "maxGrowth" && bounds[k] !== void 0
6414
+ );
6415
+ if (unknown.length > 0) {
6416
+ throw new ValidationError(
6417
+ `${where}guard: unknown option${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")}; it takes { minCardinality, minRetained, maxGrowth }`
6418
+ );
6419
+ }
6420
+ const { minCardinality, minRetained, maxGrowth } = guard;
6421
+ if (minCardinality !== void 0 && (!Number.isInteger(minCardinality) || minCardinality < 0)) {
6422
+ throw new ValidationError(
6423
+ `${where}guard.minCardinality must be a non-negative integer; got ${shown2(minCardinality)}`
6424
+ );
6425
+ }
6426
+ if (minRetained !== void 0 && (!Number.isFinite(minRetained) || minRetained < 0 || minRetained > 1)) {
6427
+ throw new ValidationError(
6428
+ `${where}guard.minRetained must be a fraction in 0..1; got ${shown2(minRetained)}`
6429
+ );
6430
+ }
6431
+ if (maxGrowth !== void 0 && (!Number.isFinite(maxGrowth) || maxGrowth !== 0 && maxGrowth < 1)) {
6432
+ throw new ValidationError(
6433
+ `${where}guard.maxGrowth must be 0 (no bound) or a factor of at least 1; got ${shown2(maxGrowth)}`
6434
+ );
6435
+ }
6436
+ return Object.freeze({
6437
+ ...minCardinality === void 0 ? {} : { minCardinality },
6438
+ ...minRetained === void 0 ? {} : { minRetained },
6439
+ ...maxGrowth === void 0 ? {} : { maxGrowth }
6440
+ });
6441
+ }
6442
+ function guardRefusal(cardinality, before, guard, allowEmpty) {
6443
+ if (cardinality === 0 && allowEmpty !== true && (before ?? 0) > 0) return "empty";
6444
+ if (guard?.minCardinality !== void 0 && cardinality < guard.minCardinality) {
6445
+ return "min-cardinality";
6446
+ }
6447
+ if (guard?.minRetained !== void 0 && before !== null && cardinality < before * guard.minRetained) {
6448
+ return "min-retained";
6449
+ }
6450
+ if (guard?.maxGrowth !== void 0 && guard.maxGrowth !== 0 && before !== null && before > 0 && cardinality > before * guard.maxGrowth) {
6451
+ return "max-growth";
6452
+ }
6453
+ return void 0;
6454
+ }
6455
+ async function judgeLoad(ref, cardinality, deps, options = {}) {
6456
+ validateUserRef(ref);
6457
+ if (!Number.isSafeInteger(cardinality) || cardinality < 0) {
6458
+ throw new ValidationError("judgeLoad: cardinality must be a non-negative integer");
6459
+ }
6460
+ const guard = checkedGuard(options.guard, "");
6461
+ assertRegistryCanWrite(deps.registry, "load");
6462
+ if (deps.requireEncryption === true && deps.keystore === void 0) {
6463
+ throw new ValidationError("requireEncryption: a load needs a keystore to write encrypted");
6464
+ }
6465
+ const row = await deps.registry.get(ref);
6466
+ if (row?.status === "destroyed") {
6467
+ throw new ValidationError(
6468
+ `segment "${ref.segment}" is destroyed (crypto-shredded) \u2014 refusing to write; use a new segment`
6469
+ );
6470
+ }
6471
+ const cleartext = row?.wrappedDeks === void 0 || row.wrappedDeks.length === 0;
6472
+ if (deps.requireEncryption === true && row !== null && row.currentGen !== null && cleartext) {
6473
+ throw cleartextUnderRequiredEncryption(ref, row.currentGen);
6474
+ }
6475
+ const wrapped = row?.wrappedDeks;
6476
+ let aead;
6477
+ if (row !== null && wrapped !== void 0 && wrapped.length > 0) {
6478
+ if (deps.keystore === void 0) {
6479
+ throw new KeyUnavailableError(
6480
+ `segment "${ref.segment}" is encrypted but load was given no keystore`
6481
+ );
6482
+ }
6483
+ aead = Promise.resolve(await deps.keystore.openDek(wrapped));
6484
+ }
6485
+ let current;
6486
+ try {
6487
+ current = await currentCardinality(ref, deps, row, aead);
6488
+ } catch (err) {
6489
+ if (sizeNeeded(guard, options.allowEmpty)) throw err;
6490
+ current = { cardinality: null, fromSummary: false };
6491
+ }
6492
+ const wouldRefuse = guardRefusal(cardinality, current.cardinality, guard, options.allowEmpty);
6493
+ const openedObject = row !== null && row.currentGen !== null && !current.fromSummary;
6494
+ return {
6495
+ cardinalityBefore: current.cardinality,
6496
+ ...wouldRefuse === void 0 ? {} : { wouldRefuse },
6497
+ reads: openedObject ? 2 : 1
6498
+ };
6499
+ }
6500
+ function sizeNeeded(guard, allowEmpty) {
6501
+ return guard?.minRetained !== void 0 || guard?.maxGrowth !== void 0 || allowEmpty !== true;
6502
+ }
6177
6503
  function settled(promise) {
6178
6504
  promise.catch(() => void 0);
6179
6505
  return promise;
@@ -6232,26 +6558,13 @@ function loadSegmentChunks(ref, chunks, deps, options = {}) {
6232
6558
  }
6233
6559
  async function runLoad(ref, prepare, deps, options) {
6234
6560
  validateUserRef(ref);
6561
+ checkedAuditSink(options.audit, "load");
6235
6562
  const codec = requireCodec(deps.codec, "loadSegment");
6236
6563
  const keep = options.keep ?? 1;
6237
6564
  if (!Number.isInteger(keep) || keep < 0) {
6238
6565
  throw new ValidationError(`keep must be a non-negative integer; got ${String(keep)}`);
6239
6566
  }
6240
- const guard = options.guard;
6241
- if (guard?.minCardinality !== void 0) {
6242
- const m = guard.minCardinality;
6243
- if (!Number.isInteger(m) || m < 0) {
6244
- throw new ValidationError(
6245
- `guard.minCardinality must be a non-negative integer; got ${String(m)}`
6246
- );
6247
- }
6248
- }
6249
- if (guard?.minRetained !== void 0) {
6250
- const s = guard.minRetained;
6251
- if (!Number.isFinite(s) || s < 0 || s > 1) {
6252
- throw new ValidationError(`guard.minRetained must be a fraction in 0..1; got ${String(s)}`);
6253
- }
6254
- }
6567
+ const guard = checkedGuard(options.guard, "");
6255
6568
  const metadata = copiedMetadata(options.metadata, (message) => {
6256
6569
  throw new ValidationError(message);
6257
6570
  });
@@ -6264,7 +6577,7 @@ async function runLoad(ref, prepare, deps, options) {
6264
6577
  const wrappedKeys = row?.wrappedDeks;
6265
6578
  const keystore = deps.keystore;
6266
6579
  const unwrapped = row !== null && row.status !== "destroyed" && wrappedKeys !== void 0 && wrappedKeys.length > 0 && keystore !== void 0 ? { wrapped: wrappedKeys, aead: settled((async () => keystore.openDek(wrappedKeys))()) } : void 0;
6267
- const needsBefore = guard?.minRetained !== void 0 || options.allowEmpty !== true;
6580
+ const needsBefore = sizeNeeded(guard, options.allowEmpty);
6268
6581
  const current = needsBefore ? await currentCardinality(ref, deps, row, unwrapped?.aead) : { cardinality: null, fromSummary: false };
6269
6582
  const before = current.cardinality;
6270
6583
  const numbering = settled(nextLoadGeneration(ref, deps, row));
@@ -6323,8 +6636,9 @@ async function runLoad(ref, prepare, deps, options) {
6323
6636
  }
6324
6637
  };
6325
6638
  let unanswered = false;
6639
+ const tidy = () => reclaim().catch(() => void 0);
6326
6640
  const refuse = async (reason) => {
6327
- await reclaim();
6641
+ await tidy();
6328
6642
  audit.onEvent({
6329
6643
  kind: "segment.load-refused",
6330
6644
  segment: ref.segment,
@@ -6346,15 +6660,8 @@ async function runLoad(ref, prepare, deps, options) {
6346
6660
  collected: []
6347
6661
  };
6348
6662
  };
6349
- if (written.cardinality === 0 && options.allowEmpty !== true && (before ?? 0) > 0) {
6350
- return refuse("empty");
6351
- }
6352
- if (guard?.minCardinality !== void 0 && written.cardinality < guard.minCardinality) {
6353
- return refuse("min-cardinality");
6354
- }
6355
- if (guard?.minRetained !== void 0 && before !== null) {
6356
- if (written.cardinality < before * guard.minRetained) return refuse("min-retained");
6357
- }
6663
+ const refusal = guardRefusal(written.cardinality, before, guard, options.allowEmpty);
6664
+ if (refusal !== void 0) return refuse(refusal);
6358
6665
  const leasesNow = deps.clock === void 0 ? void 0 : () => deps.clock.now();
6359
6666
  let published;
6360
6667
  try {
@@ -6370,8 +6677,8 @@ async function runLoad(ref, prepare, deps, options) {
6370
6677
  // The third case, and the one the two fences above structurally cannot cover: the guard judged a segment
6371
6678
  // that had NO ROW. Both `expectFrom` and `expectToken` compare against a value read from a row, so with no
6372
6679
  // row both are omitted and the publish becomes a bare forward-only advance — which lands over anything
6373
- // that appeared in between. `before` was `null`, so the empty and `minRetained` bounds had nothing to
6374
- // judge and passed vacuously. Verified: an empty generation published over a thousand ids that a
6680
+ // that appeared in between. `before` was `null`, so the empty, `minRetained` and `maxGrowth` bounds had nothing
6681
+ // to judge and passed vacuously. Verified: an empty generation published over a thousand ids that a
6375
6682
  // concurrent writer had created meanwhile, reporting success. A guarded write therefore has to fence on
6376
6683
  // the ABSENCE it relied on, exactly as it fences on the pointer it relied on.
6377
6684
  ...needsBefore && row === null ? { expectAbsent: true } : {},
@@ -6386,7 +6693,7 @@ async function runLoad(ref, prepare, deps, options) {
6386
6693
  }
6387
6694
  });
6388
6695
  } catch (err) {
6389
- if (isValidationError(err) || err instanceof KeyUnavailableError) await reclaim();
6696
+ if (isValidationError(err) || isKeyUnavailableError(err)) await tidy();
6390
6697
  throw err;
6391
6698
  }
6392
6699
  if (!published.published) return refuse("superseded");
@@ -6925,9 +7232,12 @@ var FeedCursor = class {
6925
7232
  this.finished = true;
6926
7233
  }
6927
7234
  // ---- the checks -------------------------------------------------------------------------------------------
6928
- refuse(key, operand, why) {
7235
+ /** How many records the feed has handed over, the one being read included: what names a record in an error. */
7236
+ records = 0;
7237
+ /** Refuse the record being read, named by its position in the feed: its key narrows the ids it holds to a range. */
7238
+ refuse(operand, why) {
6929
7239
  throw new ValidationError(
6930
- `materializeMany feed: record at key ${String(key)}${operand === void 0 ? "" : `, operand "${operand}"`}: ${why}`
7240
+ `materializeMany feed: record ${this.records}${operand === void 0 ? "" : `, operand "${operand}"`}: ${why}`
6931
7241
  );
6932
7242
  }
6933
7243
  charge(bytes) {
@@ -6944,20 +7254,17 @@ var FeedCursor = class {
6944
7254
  this.deps.release(bytes);
6945
7255
  }
6946
7256
  async convert(record) {
7257
+ this.records += 1;
6947
7258
  if (typeof record !== "object" || record === null) {
6948
- this.refuse("?", void 0, "a record is an object with a key and operands");
7259
+ this.refuse(void 0, "a record is an object with a key and operands");
6949
7260
  }
6950
7261
  const key = record.key;
6951
7262
  const operands = record.operands;
6952
7263
  if (typeof key !== "number" || !Number.isInteger(key) || key < 0 || key >= KEY_IDS) {
6953
- this.refuse("?", void 0, "the key must be an integer from 0 to 65535");
7264
+ this.refuse(void 0, "the key must be an integer from 0 to 65535");
6954
7265
  }
6955
7266
  if (key < this.prevKey) {
6956
- this.refuse(
6957
- key,
6958
- void 0,
6959
- `the key is below the previous record's (${this.prevKey}): keys never go back`
6960
- );
7267
+ this.refuse(void 0, `the key is below the previous record's: keys never go back`);
6961
7268
  }
6962
7269
  if (key !== this.prevKey) {
6963
7270
  for (const i of this.touched) this.seen[i] = 0;
@@ -6966,7 +7273,6 @@ var FeedCursor = class {
6966
7273
  }
6967
7274
  if (typeof operands !== "object" || operands === null || !isPlainObject(operands)) {
6968
7275
  this.refuse(
6969
- key,
6970
7276
  void 0,
6971
7277
  "operands must be a plain object of fed operand name to Uint32Array (not a Map, an array or a class instance)"
6972
7278
  );
@@ -6976,31 +7282,29 @@ var FeedCursor = class {
6976
7282
  const charged = [];
6977
7283
  try {
6978
7284
  for (const name of Reflect.ownKeys(operands)) {
6979
- if (typeof name !== "string") this.refuse(key, void 0, "operands has a symbol key");
7285
+ if (typeof name !== "string") this.refuse(void 0, "operands has a symbol key");
6980
7286
  const at = this.index.get(name);
6981
7287
  if (at === void 0) {
6982
- this.refuse(key, name, "not a declared fed operand (it is not in feed.names)");
7288
+ this.refuse(name, "not a declared fed operand (it is not in feed.names)");
6983
7289
  }
6984
7290
  const value = operands[name];
6985
7291
  const view = plainUint32(value);
6986
7292
  if (view === void 0) {
6987
7293
  this.refuse(
6988
- key,
6989
7294
  name,
6990
7295
  "the ids must be a Uint32Array (any other typed array, array or look-alike is refused)"
6991
7296
  );
6992
7297
  }
6993
- if (view === "detached") this.refuse(key, name, "the array's buffer is detached");
7298
+ if (view === "detached") this.refuse(name, "the array's buffer is detached");
6994
7299
  if (view === "out of bounds") {
6995
7300
  this.refuse(
6996
- key,
6997
7301
  name,
6998
7302
  "the array is out of bounds of its resizable buffer, which shrank under it"
6999
7303
  );
7000
7304
  }
7001
7305
  if (view.length === 0) continue;
7002
7306
  if (this.seen[at] === 1) {
7003
- this.refuse(key, name, "named twice at one key: an operand appears at most once per key");
7307
+ this.refuse(name, "named twice at one key: an operand appears at most once per key");
7004
7308
  }
7005
7309
  const n = this.scan(view, key, name);
7006
7310
  this.seen[at] = 1;
@@ -7034,17 +7338,16 @@ var FeedCursor = class {
7034
7338
  */
7035
7339
  scan(view, key, name) {
7036
7340
  const n = view.length;
7037
- if (n > KEY_IDS) this.refuse(key, name, `${n} ids, more than one chunk key holds`);
7341
+ if (n > KEY_IDS) this.refuse(name, `${n} ids, more than one chunk key holds`);
7038
7342
  const scratch = this.scratch;
7039
7343
  let prev = -1;
7040
7344
  for (let i = 0; i < n; i++) {
7041
7345
  const id = view[i];
7042
7346
  if (id >>> 16 !== key) {
7043
- this.refuse(key, name, `the id at position ${i} is not inside the key`);
7347
+ this.refuse(name, `the id at position ${i} is not inside the key`);
7044
7348
  }
7045
7349
  if (id <= prev) {
7046
7350
  this.refuse(
7047
- key,
7048
7351
  name,
7049
7352
  `the id at position ${i} is not above the one before it: ids are ascending and unique`
7050
7353
  );
@@ -7191,13 +7494,6 @@ function compileCombineMany(request) {
7191
7494
  }
7192
7495
  const index = /* @__PURE__ */ new Map();
7193
7496
  operands.forEach((op, i) => index.set(op.name, i));
7194
- for (const op of operands) {
7195
- if (op.held !== void 0 && op.held.chunks.empty && !op.held.mayBeEmpty) {
7196
- throw new ValidationError(
7197
- `materializeMany: operand "${op.name}" holds no id, so an include would contribute none and an exclude would subtract none. Check what it was made from, or name it in \`mayBeEmpty\` if it may be empty.`
7198
- );
7199
- }
7200
- }
7201
7497
  const feed = request.feed === void 0 ? void 0 : checkFeed(request.feed, new Set(index.keys()));
7202
7498
  feed?.names.forEach((name, j) => index.set(name, operands.length + j));
7203
7499
  let totalNodes = 0;
@@ -7227,24 +7523,9 @@ function compileCombineMany(request) {
7227
7523
  if (output.allowEmpty !== void 0 && typeof output.allowEmpty !== "boolean") {
7228
7524
  throw new ValidationError(`${where}.allowEmpty must be a boolean`);
7229
7525
  }
7230
- const guard = output.guard;
7231
- if (guard !== void 0) {
7232
- if (typeof guard !== "object" || guard === null) {
7233
- throw new ValidationError(`${where}.guard must be an object`);
7234
- }
7235
- const min = guard.minCardinality;
7236
- if (min !== void 0 && (!Number.isInteger(min) || min < 0)) {
7237
- throw new ValidationError(
7238
- `${where}.guard.minCardinality must be a non-negative integer; got ${String(min)}`
7239
- );
7240
- }
7241
- const retained = guard.minRetained;
7242
- if (retained !== void 0 && (!Number.isFinite(retained) || retained < 0 || retained > 1)) {
7243
- throw new ValidationError(
7244
- `${where}.guard.minRetained must be a fraction in 0..1; got ${String(retained)}`
7245
- );
7246
- }
7247
- }
7526
+ checkedAuditSink(output.audit, where);
7527
+ if (output.guard === null) throw new ValidationError(`${where}.guard must be an object`);
7528
+ const guard = checkedGuard(output.guard, `${where}.`);
7248
7529
  const metadata = copiedMetadata(output.metadata, (message) => {
7249
7530
  throw new ValidationError(`${where}.metadata: ${message}`);
7250
7531
  });
@@ -7423,6 +7704,7 @@ var Run = class {
7423
7704
  codec;
7424
7705
  clock;
7425
7706
  maxBitmapBytes;
7707
+ metrics;
7426
7708
  ledger;
7427
7709
  gate = new Gate(MAX_RANGES_IN_FLIGHT2);
7428
7710
  operands;
@@ -7437,6 +7719,8 @@ var Run = class {
7437
7719
  publishes = 0;
7438
7720
  opens = 0;
7439
7721
  planCharge = 0;
7722
+ /** The groups {@link formGroups} made, whose kept plans {@link dropKeptPlans} can release. */
7723
+ formed = [];
7440
7724
  attributedGet = 0;
7441
7725
  attributedPut = 0;
7442
7726
  planned = 0;
@@ -7462,6 +7746,7 @@ var Run = class {
7462
7746
  this.codec = deps.codec;
7463
7747
  this.clock = deps.clock;
7464
7748
  this.maxBitmapBytes = deps.maxBitmapBytes ?? DEFAULT_MAX_BITMAP_BYTES;
7749
+ this.metrics = deps.metrics === void 0 ? void 0 : safeMetrics(deps.metrics);
7465
7750
  this.ledger = new ResidentLedger(this.req.maxBufferedBytes);
7466
7751
  this.tick = yieldEvery(deps.clock, 256);
7467
7752
  this.operands = this.req.operands.map((spec, index) => ({
@@ -7501,7 +7786,6 @@ var Run = class {
7501
7786
  async execute() {
7502
7787
  this.window = windowOf2(this.req.after, this.req.through);
7503
7788
  if (this.checkedFeed !== void 0) this.feed = this.openFeed(this.checkedFeed);
7504
- this.indexHeld();
7505
7789
  await this.readIndexes();
7506
7790
  await this.refuseAbsent();
7507
7791
  this.planOutputs();
@@ -7565,11 +7849,13 @@ var Run = class {
7565
7849
  }
7566
7850
  }
7567
7851
  /**
7568
- * Make room in the ledger. A fed output cannot be run again (the feed is read once), so a fed call gives up its fed
7569
- * outputs and the feed first, as soon as the ledger passes the budget; only then is a stored-only buffer evicted, to run
7570
- * again alone.
7852
+ * Make room in the ledger. The plans kept for later groups give way first: they are rebuilt when their group starts,
7853
+ * and the outputs were judged without them. A fed output cannot be run again (the feed is read once), so a fed call
7854
+ * gives up its fed outputs and the feed next, as soon as the ledger passes the budget; only then is a stored-only
7855
+ * buffer evicted, to run again alone.
7571
7856
  */
7572
7857
  reclaim() {
7858
+ if (this.dropKeptPlans()) return true;
7573
7859
  if (this.feed !== void 0) {
7574
7860
  let fed = this.feed.heldBytes;
7575
7861
  for (const o of this.running)
@@ -7585,70 +7871,13 @@ var Run = class {
7585
7871
  return { ok: false, error: new Error("materializeMany: output did not run") };
7586
7872
  }
7587
7873
  // ---- index ------------------------------------------------------------------------------------------------
7588
- /** What an output that reads the held operand `op` gets when an erasure ran in the store since it was made. */
7589
- staleHeldError(op) {
7590
- return new StaleOperandError(
7591
- `materializeMany: an erasure ran in this store after operand "${op.spec.name}" was made, so it may hold an erased id and the outputs that read it were not published. Make it again from the source of truth, after the erasure, and run the call again.`,
7592
- op.spec.name,
7593
- "erased"
7594
- );
7595
- }
7596
- /** The error of the first held operand `o` reads that an erasure has made stale, if any. */
7597
- staleHeld(o) {
7598
- for (const i of o.compiled.operands) {
7599
- const op = this.operands[i];
7600
- if (op.spec.held?.moved() === true) return this.staleHeldError(op);
7601
- }
7602
- return void 0;
7603
- }
7604
- /**
7605
- * Index the held operands the outputs read, before any request: their keys and exact cardinalities are in memory, and
7606
- * one that an erasure has made stale is failed here, so the outputs that read it fail without a read.
7607
- */
7608
- indexHeld() {
7874
+ async readIndexes() {
7609
7875
  const wanted = /* @__PURE__ */ new Set();
7610
7876
  for (const o of this.outputs) for (const op of o.compiled.operands) wanted.add(op);
7611
7877
  if (this.window === "empty") return;
7878
+ for (const index of wanted) this.operands[index].read = true;
7612
7879
  const w = this.window;
7613
- const charged = /* @__PURE__ */ new Set();
7614
- let resident = 0;
7615
- for (const index of wanted) {
7616
- const op = this.operands[index];
7617
- const held = op.spec.held;
7618
- if (held === void 0) continue;
7619
- op.read = true;
7620
- if (held.moved()) {
7621
- op.error = { error: this.staleHeldError(op) };
7622
- continue;
7623
- }
7624
- if (!charged.has(held.chunks)) {
7625
- charged.add(held.chunks);
7626
- resident += held.chunks.residentBytes;
7627
- }
7628
- const all = held.chunks.keys;
7629
- const cards = held.chunks.cardinalities;
7630
- const keep = [];
7631
- const card = [];
7632
- for (let i = 0; i < all.length; i++) {
7633
- const key = all[i];
7634
- if (w !== null && (key < w.loKey || key > w.hiKey)) continue;
7635
- keep.push(key);
7636
- card.push(cards[i]);
7637
- }
7638
- op.bounds = { keys: Uint16Array.from(keep), card: Uint32Array.from(card) };
7639
- op.indexKeys = keep.length;
7640
- op.chunkless = all.length === 0;
7641
- }
7642
- if (resident > 0) this.chargePlan(resident, "the held operands");
7643
- }
7644
- async readIndexes() {
7645
- const wanted = /* @__PURE__ */ new Set();
7646
- for (const o of this.outputs) for (const op of o.compiled.operands) wanted.add(op);
7647
- if (this.window === "empty") return;
7648
- for (const index of wanted) this.operands[index].read = true;
7649
- const w = this.window;
7650
- const stored = [...wanted].filter((index) => this.operands[index].spec.held === void 0);
7651
- await mapWithConcurrency(stored, INDEX_PARALLELISM, async (index) => {
7880
+ await mapWithConcurrency([...wanted], INDEX_PARALLELISM, async (index) => {
7652
7881
  const op = this.operands[index];
7653
7882
  try {
7654
7883
  const listed = checkedChunkKeys(await this.source.listChunkKeys(op.spec.ref));
@@ -7686,7 +7915,7 @@ var Run = class {
7686
7915
  async refuseAbsent() {
7687
7916
  if (this.req.allowAbsentOperands === true || this.source.exists === void 0) return;
7688
7917
  const judged = this.operands.filter(
7689
- (op) => op.spec.held === void 0 && op.error === void 0 && (op.read ? op.chunkless : true)
7918
+ (op) => op.error === void 0 && (op.read ? op.chunkless : true)
7690
7919
  );
7691
7920
  if (judged.length === 0) return;
7692
7921
  const exists = this.source.exists.bind(this.source);
@@ -7697,7 +7926,7 @@ var Run = class {
7697
7926
  const absent = checked.filter((c) => !c.exists).map((c) => c.op.spec.name);
7698
7927
  if (absent.length === 0) return;
7699
7928
  throw new ValidationError(
7700
- `materializeMany: operand ${absent.map((n) => `"${n}"`).join(", ")} names a segment that does not exist, so it would contribute nothing \u2014 an exclude would suppress no ids and an include would contribute none. Check the name and the namespace (a segment addressed without its \`namespace\` is a DIFFERENT segment). Pass \`allowAbsentOperands: true\` if you meant it.`
7929
+ `materializeMany: operand ${absent.map((n) => `"${n}"`).join(", ")} names a segment that does not exist or was dropped, so it would contribute nothing \u2014 an exclude would suppress no ids and an include would contribute none. Check the name and the namespace (a segment addressed without its \`namespace\` is a DIFFERENT segment). Pass \`allowAbsentOperands: true\` if you meant it.`
7701
7930
  );
7702
7931
  }
7703
7932
  // ---- plan -------------------------------------------------------------------------------------------------
@@ -7759,12 +7988,15 @@ var Run = class {
7759
7988
  o.status = "waiting";
7760
7989
  }
7761
7990
  }
7762
- /** The bytes a group may take: the budget less what the plan holds. */
7763
- get room() {
7764
- return this.req.maxBufferedBytes - this.planCharge;
7765
- }
7766
- /** Outputs in call order, a group at a time while the ledger has room for the next. */
7991
+ /**
7992
+ * Outputs in call order, a group at a time while the ledger has room for the next. Every output is judged against the
7993
+ * room the call's own plan leaves, `base`: a group's plan kept for its start is released when it starts, or sooner when
7994
+ * a group needs the room ({@link dropKeptPlans}), so it is not resident while a later output runs, and counting it
7995
+ * would refuse an output, or cut a group short, by its position in the call.
7996
+ */
7767
7997
  formGroups(waiting, single) {
7998
+ const base = this.planCharge;
7999
+ const room = this.req.maxBufferedBytes - base;
7768
8000
  const groups = [];
7769
8001
  const fresh = () => new GroupCost((i) => this.operands[i].bytes, this.req.publishConcurrency);
7770
8002
  let cost = fresh();
@@ -7790,22 +8022,36 @@ var Run = class {
7790
8022
  };
7791
8023
  for (const o of waiting) {
7792
8024
  const alone = fresh().with(o);
7793
- if (alone > this.room) {
8025
+ if (alone > room) {
7794
8026
  this.fail(
7795
8027
  o,
7796
8028
  new BudgetExceededError(
7797
- `materializeMany: output ${o.index} needs about ${alone} bytes resident with its operands, and the plan holds ${this.planCharge}, over maxBufferedBytes (${this.req.maxBufferedBytes}) \u2014 raise maxBufferedBytes to at least ${alone + this.planCharge}`
8029
+ `materializeMany: output ${o.index} needs about ${alone} bytes resident with its operands, and the plan holds ${base}, over maxBufferedBytes (${this.req.maxBufferedBytes}) \u2014 raise maxBufferedBytes to at least ${alone + base}`
7798
8030
  )
7799
8031
  );
7800
8032
  continue;
7801
8033
  }
7802
- if (!single && cost.size > 0 && cost.with(o) > this.room) flush();
8034
+ if (!single && cost.size > 0 && cost.with(o) > room) flush();
7803
8035
  current.push(o);
7804
8036
  cost.add(o);
7805
8037
  }
7806
8038
  flush();
8039
+ this.formed = groups;
7807
8040
  return groups;
7808
8041
  }
8042
+ /** Release every group plan still kept for its start; each is built again when its group starts. */
8043
+ dropKeptPlans() {
8044
+ let dropped = false;
8045
+ for (const summary of this.formed) {
8046
+ const plan = summary.plan;
8047
+ if (plan === void 0) continue;
8048
+ summary.plan = void 0;
8049
+ this.ledger.release(plan.planBytes);
8050
+ this.planCharge -= plan.planBytes;
8051
+ dropped = true;
8052
+ }
8053
+ return dropped;
8054
+ }
7809
8055
  /** The keys each operand is fetched at for `members`, and the keys the pass visits. */
7810
8056
  buildGroup(members) {
7811
8057
  combineWork.planBuilds++;
@@ -7848,6 +8094,11 @@ var Run = class {
7848
8094
  pruned += this.operands[operand].indexKeys - set.size;
7849
8095
  fixed += this.operands[operand].bytes;
7850
8096
  }
8097
+ const named = /* @__PURE__ */ new Set();
8098
+ for (const o of members) for (const operand of o.compiled.operands) named.add(operand);
8099
+ for (const operand of named) {
8100
+ if (!bits.has(operand)) pruned += this.operands[operand].indexKeys;
8101
+ }
7851
8102
  const visited = Uint16Array.from(visit.toKeys());
7852
8103
  return {
7853
8104
  outputs: members.map((o) => o.index),
@@ -7892,13 +8143,13 @@ var Run = class {
7892
8143
  // ---- one group --------------------------------------------------------------------------------------------
7893
8144
  async runGroup(indices, summary) {
7894
8145
  const members = indices.map((i) => this.outputs[i]).filter((o) => o.status === "waiting");
7895
- if (members.length === 0) return;
7896
8146
  const kept = summary?.plan;
7897
8147
  if (summary !== void 0) summary.plan = void 0;
7898
8148
  if (kept !== void 0) {
7899
8149
  this.ledger.release(kept.planBytes);
7900
8150
  this.planCharge -= kept.planBytes;
7901
8151
  }
8152
+ if (members.length === 0) return;
7902
8153
  const plan = kept !== void 0 && members.length === indices.length ? kept : this.buildGroup(members);
7903
8154
  const group = this.groups++;
7904
8155
  const budget = this.limit;
@@ -7918,13 +8169,13 @@ var Run = class {
7918
8169
  o.chunkCount = 0;
7919
8170
  o.peak = 0;
7920
8171
  }
7921
- this.pruned += plan.pruned;
7922
8172
  const fixed = plan.fixedBytes + plan.planBytes;
7923
- if (!this.ledger.tryCharge(fixed)) {
8173
+ if (!this.ledger.tryCharge(fixed) && !(this.dropKeptPlans() && this.ledger.tryCharge(fixed))) {
7924
8174
  const error = budgetError(this.req.maxBufferedBytes, "the operand streams");
7925
8175
  for (const o of members) this.fail(o, error);
7926
8176
  return;
7927
8177
  }
8178
+ this.pruned += plan.pruned;
7928
8179
  this.running = members;
7929
8180
  try {
7930
8181
  await this.pass(plan, members);
@@ -7973,12 +8224,19 @@ var Run = class {
7973
8224
  });
7974
8225
  }
7975
8226
  const retry = (request) => this.gate.run(request);
7976
- const onRequest = (r) => {
8227
+ const onRequestOf = (op) => (r) => {
7977
8228
  this.rangeReads++;
7978
8229
  this.rangeBytes += r.bytes;
8230
+ this.metrics?.onEvent({
8231
+ kind: "storage.get",
8232
+ namespace: op.spec.ref.namespace,
8233
+ segment: op.spec.ref.segment,
8234
+ bytes: r.bytes,
8235
+ ms: r.ms
8236
+ });
7979
8237
  };
7980
8238
  const streamOf = (c) => {
7981
- c.stream ??= new ChunkStream(this.readKeys(c.op, c.keys, retry, onRequest));
8239
+ c.stream ??= new ChunkStream(this.readKeys(c.op, c.keys, retry, onRequestOf(c.op)));
7982
8240
  return c.stream;
7983
8241
  };
7984
8242
  const stop = (c) => {
@@ -8146,22 +8404,8 @@ var Run = class {
8146
8404
  /** The operand's chunks at `keys` as a stream: the source's coalesced ranges, or chunk by chunk where it has none. */
8147
8405
  readKeys(op, wanted, retry, onRequest) {
8148
8406
  const source = this.source;
8407
+ const clock = this.clock;
8149
8408
  const keys = Array.from(wanted);
8150
- const held = op.spec.held;
8151
- if (held !== void 0) {
8152
- const stale = () => this.staleHeldError(op);
8153
- return {
8154
- // Each chunk is checked and read in one step with no wait between, so a release or an erasure cannot land after
8155
- // the check and before the bytes.
8156
- async *[Symbol.asyncIterator]() {
8157
- for (const key of keys) {
8158
- if (held.moved()) throw stale();
8159
- const bytes = held.chunks.payload(key);
8160
- yield { key, bytes: bytes === null ? null : new Uint8Array(bytes), version: null };
8161
- }
8162
- }
8163
- };
8164
- }
8165
8409
  const ref = op.spec.ref;
8166
8410
  if (source.getChunks !== void 0) {
8167
8411
  return source.getChunks(ref, keys, { concurrency: this.req.concurrency, retry, onRequest });
@@ -8169,8 +8413,9 @@ var Run = class {
8169
8413
  return {
8170
8414
  async *[Symbol.asyncIterator]() {
8171
8415
  for (const key of keys) {
8416
+ const startedAt = clock.now();
8172
8417
  const bytes = await retry(() => source.getChunk({ ...ref, chunkKey: key }));
8173
- onRequest({ bytes: bytes?.length ?? 0, ms: 0 });
8418
+ onRequest({ bytes: bytes?.length ?? 0, ms: Math.max(0, clock.now() - startedAt) });
8174
8419
  yield { key, bytes, version: null };
8175
8420
  }
8176
8421
  }
@@ -8287,13 +8532,6 @@ var Run = class {
8287
8532
  wake();
8288
8533
  continue;
8289
8534
  }
8290
- const staleHeld = this.staleHeld(o);
8291
- if (staleHeld !== void 0) {
8292
- this.ledger.release(transient);
8293
- this.fail(o, staleHeld);
8294
- wake();
8295
- continue;
8296
- }
8297
8535
  if (o.compiled.fed.length > 0 && this.erased()) {
8298
8536
  this.ledger.release(transient);
8299
8537
  this.fail(o, this.staleError(o));
@@ -8301,7 +8539,7 @@ var Run = class {
8301
8539
  continue;
8302
8540
  }
8303
8541
  inFlight++;
8304
- this.publishes++;
8542
+ if (this.req.dryRun !== true) this.publishes++;
8305
8543
  const chunks = o.chunks;
8306
8544
  try {
8307
8545
  const value = await o.spec.publish(
@@ -8550,106 +8788,6 @@ function evaluate(node, kc) {
8550
8788
  return acc;
8551
8789
  }
8552
8790
 
8553
- // src/core/combine-held.ts
8554
- var PER_CHUNK_BYTES = 96;
8555
- var HeldChunks = class {
8556
- /** The chunk keys, ascending. */
8557
- keys;
8558
- /** How many ids each chunk holds. */
8559
- cardinalities;
8560
- /** What it holds in memory, for a call's resident-bytes ledger. */
8561
- residentBytes;
8562
- payloads;
8563
- constructor(chunks) {
8564
- const keys = [];
8565
- const cards = [];
8566
- const payloads = [];
8567
- let bytes = 0;
8568
- let last = -1;
8569
- for (const chunk of chunks) {
8570
- const { chunkKey, payload, cardinality } = chunk;
8571
- if (!Number.isInteger(chunkKey) || chunkKey < 0 || chunkKey > MAX_REMAINDER || chunkKey <= last) {
8572
- throw new ValidationError(
8573
- `a held chunk's key must be an integer in [0, 65535] above the last`
8574
- );
8575
- }
8576
- assertChunkCardinalityInRange(cardinality);
8577
- last = chunkKey;
8578
- keys.push(chunkKey);
8579
- cards.push(cardinality);
8580
- payloads.push(payload);
8581
- bytes += payload.byteLength + PER_CHUNK_BYTES;
8582
- }
8583
- this.keys = Uint16Array.from(keys);
8584
- this.cardinalities = Uint32Array.from(cards);
8585
- this.payloads = payloads;
8586
- this.residentBytes = bytes;
8587
- }
8588
- /** Whether it holds no id. */
8589
- get empty() {
8590
- return this.keys.length === 0;
8591
- }
8592
- /** Whether {@link release} ran. */
8593
- get released() {
8594
- return this.payloads === void 0;
8595
- }
8596
- /**
8597
- * The payload of the chunk at `key`, `null` when it holds none there. A view into what is held: not to be written to.
8598
- * @throws {ValidationError} once released.
8599
- */
8600
- payload(key) {
8601
- const payloads = this.payloads;
8602
- if (payloads === void 0) {
8603
- throw new ValidationError(
8604
- "this memory operand was released: make another with store.memory()"
8605
- );
8606
- }
8607
- let lo = 0;
8608
- let hi = this.keys.length - 1;
8609
- while (lo <= hi) {
8610
- const mid = lo + hi >>> 1;
8611
- const at = this.keys[mid];
8612
- if (at === key) return payloads[mid];
8613
- if (at < key) lo = mid + 1;
8614
- else hi = mid - 1;
8615
- }
8616
- return null;
8617
- }
8618
- /** Zero and drop the bytes. Every later read throws; a second call does nothing. */
8619
- release() {
8620
- const payloads = this.payloads;
8621
- if (payloads === void 0) return;
8622
- this.payloads = void 0;
8623
- for (const payload of payloads) payload.fill(0);
8624
- }
8625
- };
8626
- async function prepareHeld(input, codec, clock) {
8627
- if (typeof input === "object" && input !== null && !(Symbol.iterator in input) && !(Symbol.asyncIterator in input) && Object.prototype.hasOwnProperty.call(input, "ascending")) {
8628
- throw new ValidationError(
8629
- "a memory operand takes ids, { serialized } or { bitmap }, with no `ascending` option: ids that arrive in chunk-key order, one key at a time, are a feed of materializeMany"
8630
- );
8631
- }
8632
- const real = plainUint32(input);
8633
- if (real === "detached") {
8634
- throw new ValidationError("a memory operand's ids are a Uint32Array over a detached buffer");
8635
- }
8636
- if (real === "out of bounds") {
8637
- throw new ValidationError(
8638
- "a memory operand's ids are a resizable-buffer Uint32Array whose view is out of bounds"
8639
- );
8640
- }
8641
- const prepared = real !== void 0 ? new DecodedLoadInput(codec.fromValues(real)) : prepareLoadInput(input, codec, "a memory operand");
8642
- const encoded = await encodedChunksOfInput(prepared, codec, clock);
8643
- const tick = yieldEvery(clock, 256);
8644
- const chunks = [];
8645
- for (const chunk of encoded) {
8646
- chunks.push(chunk);
8647
- const pause = tick();
8648
- if (pause !== null) await pause;
8649
- }
8650
- return new HeldChunks(chunks);
8651
- }
8652
-
8653
8791
  // src/core/rollback.ts
8654
8792
  async function listGenerations(ref, deps) {
8655
8793
  validateUserRef(ref);
@@ -8679,6 +8817,7 @@ async function currentDescription(ref, record, deps) {
8679
8817
  }
8680
8818
  async function rollbackSegment(ref, toGeneration, deps, options = {}) {
8681
8819
  validateUserRef(ref);
8820
+ checkedAuditSink(options.audit, "rollback");
8682
8821
  if (!Number.isInteger(toGeneration) || toGeneration < 0) {
8683
8822
  throw new ValidationError(
8684
8823
  `rollback: generation must be a non-negative integer; got ${String(toGeneration)}`
@@ -8704,9 +8843,9 @@ async function rollbackSegment(ref, toGeneration, deps, options = {}) {
8704
8843
  `rollback: generation ${toGeneration} of "${ref.segment}" is not in the bucket` + (available.length === 0 ? " (no generations remain)" : ` \u2014 present: ${available.join(", ")}`)
8705
8844
  );
8706
8845
  }
8707
- if (record.currentGen !== null && toGeneration > record.currentGen && options.allowForward !== true) {
8846
+ if ((record.currentGen === null || toGeneration > record.currentGen) && options.allowForward !== true) {
8708
8847
  throw new ValidationError(
8709
- `rollback: generation ${toGeneration} of "${ref.segment}" is above the current pointer (${record.currentGen}) \u2014 it may never have been published. Pass { allowForward: true } if you are undoing an earlier rollback.`
8848
+ `rollback: generation ${toGeneration} of "${ref.segment}" is above the current pointer (${record.currentGen ?? "none"}) \u2014 it may never have been published. Pass { allowForward: true } if you are undoing an earlier rollback.`
8710
8849
  );
8711
8850
  }
8712
8851
  const keyed = record.wrappedDeks !== void 0 && record.wrappedDeks.length > 0;
@@ -8824,7 +8963,21 @@ function describingSummary(record) {
8824
8963
  // src/core/erase-id.ts
8825
8964
  var HOLDS_CONCURRENCY = 4;
8826
8965
  async function eraseIdFromSegment(ref, id, deps, options = {}) {
8966
+ const first = {};
8967
+ const result = await eraseOnce(ref, id, deps, options, first);
8968
+ if (result.reason === "destroyed" && first.tombstoned === false) {
8969
+ const again = await eraseOnce(ref, id, deps, options, {});
8970
+ if (again.reason === "destroyed") return result;
8971
+ if (!again.erased) return again;
8972
+ const collected = [.../* @__PURE__ */ new Set([...result.collected, ...again.collected])].sort((a, b) => a - b);
8973
+ const newest = Math.max(result.fromGeneration ?? -1, again.fromGeneration ?? -1);
8974
+ return { ...again, collected, ...newest >= 0 ? { fromGeneration: newest } : {} };
8975
+ }
8976
+ return result;
8977
+ }
8978
+ async function eraseOnce(ref, id, deps, options, seen) {
8827
8979
  validateUserRef(ref);
8980
+ checkedAuditSink(options.audit, "eraseIdFromSegment");
8828
8981
  const { chunkKey, remainder } = splitId(id);
8829
8982
  const codec = requireCodec(deps.codec, "eraseIdFromSegment");
8830
8983
  const maxBytes = deps.maxBitmapBytes ?? DEFAULT_MAX_BITMAP_BYTES;
@@ -8832,21 +8985,23 @@ async function eraseIdFromSegment(ref, id, deps, options = {}) {
8832
8985
  const read = (op) => retryRead(op, deps.readRetry);
8833
8986
  const record = await deps.registry.get(ref);
8834
8987
  if (record === null) return { ...base, erased: false, reason: "absent", collected: [] };
8835
- if (record.status === "destroyed")
8836
- return { ...base, erased: false, reason: "destroyed", collected: [] };
8837
- if (record.currentGen === null)
8838
- return { ...base, erased: false, reason: "no-generation", collected: [] };
8839
- const from = record.currentGen;
8988
+ const tombstoned = record.status === "destroyed";
8989
+ seen.tombstoned = tombstoned;
8990
+ const pointerless = !tombstoned && record.currentGen === null;
8991
+ const from = record.currentGen ?? -1;
8840
8992
  const fromToken = record.token;
8993
+ let premise = record;
8994
+ let premiseToken = fromToken;
8841
8995
  let aead;
8842
- if (record.wrappedDeks !== void 0 && record.wrappedDeks.length > 0) {
8996
+ if (tombstoned) {
8997
+ } else if (record.wrappedDeks !== void 0 && record.wrappedDeks.length > 0) {
8843
8998
  if (deps.keystore === void 0) {
8844
8999
  throw new KeyUnavailableError(
8845
9000
  `segment "${ref.segment}" is encrypted but eraseIdFromSegment has no keystore`
8846
9001
  );
8847
9002
  }
8848
9003
  aead = await deps.keystore.openDek(record.wrappedDeks);
8849
- } else if (deps.requireEncryption === true) {
9004
+ } else if (!pointerless && deps.requireEncryption === true) {
8850
9005
  throw new ValidationError(`requireEncryption: segment "${ref.segment}" is cleartext`);
8851
9006
  }
8852
9007
  const cryptoAt = (generation2) => aead === void 0 ? void 0 : { aead, aadFor: (scope) => aadFor(ref, generation2, scope) };
@@ -8863,8 +9018,9 @@ async function eraseIdFromSegment(ref, id, deps, options = {}) {
8863
9018
  if (row === null) return "absent";
8864
9019
  if (row.status === "destroyed") return "destroyed";
8865
9020
  if (row.currentGen === null) return "no-generation";
8866
- return row.currentGen === from && (row.token === fromToken || onlyLeasesDiffer(record, row)) ? null : "superseded";
9021
+ return row.currentGen === from && (row.token === premiseToken || onlyLeasesDiffer(premise, row)) ? null : "superseded";
8867
9022
  };
9023
+ const sealedUnpublished = /* @__PURE__ */ new Set();
8868
9024
  const holds = async (generation2) => {
8869
9025
  const key2 = { ...base, generation: generation2 };
8870
9026
  const chunkIn = (crypto) => read(async () => (await openGenerationReader(deps.storage, key2, crypto)).getChunk(chunkKey));
@@ -8873,7 +9029,15 @@ async function eraseIdFromSegment(ref, id, deps, options = {}) {
8873
9029
  try {
8874
9030
  bytes = await chunkIn(cryptoAt(generation2));
8875
9031
  } catch (err) {
8876
- if (!isIntegrityError(err) || cryptoAt(generation2) === void 0 || await read(() => objectIsEncrypted(deps.storage, key2))) {
9032
+ if (cryptoAt(generation2) === void 0) {
9033
+ if ((tombstoned || pointerless) && await read(() => objectIsEncrypted(deps.storage, key2))) {
9034
+ if (tombstoned) return false;
9035
+ sealedUnpublished.add(generation2);
9036
+ return true;
9037
+ }
9038
+ throw err;
9039
+ }
9040
+ if (!isIntegrityError(err) || await read(() => objectIsEncrypted(deps.storage, key2))) {
8877
9041
  throw err;
8878
9042
  }
8879
9043
  bytes = await chunkIn(void 0);
@@ -8919,6 +9083,51 @@ async function eraseIdFromSegment(ref, id, deps, options = {}) {
8919
9083
  const cannotRemove = (generation2) => new WriteConflictError(
8920
9084
  `erasure of segment ${ref.segment} could not remove generation ${generation2}, which still holds the id and is still in the bucket; re-run`
8921
9085
  );
9086
+ const fenceInFlight = async () => {
9087
+ const churn = leaseChurn({ clock: deps.clock, rng: deps.rng ?? deps.readRetry?.rng });
9088
+ let row = await deps.registry.get(ref);
9089
+ for (; ; ) {
9090
+ const verdict = rowVerdict(row);
9091
+ if (verdict !== null) return verdict;
9092
+ const current = row;
9093
+ const keptGens = current.keptGens !== void 0 && current.keptGens.length === 0 ? void 0 : [];
9094
+ try {
9095
+ const { token } = await deps.registry.compareAndSwap(ref, current.token, { keptGens });
9096
+ premise = { ...current, keptGens, token };
9097
+ premiseToken = token;
9098
+ return null;
9099
+ } catch (err) {
9100
+ if (!isWriteConflictError(err)) throw err;
9101
+ }
9102
+ const now = await deps.registry.get(ref);
9103
+ const settled2 = await churn.settle(current, now, () => deps.registry.get(ref));
9104
+ if (settled2 === void 0) return rowVerdict(now) ?? "superseded";
9105
+ row = settled2.row;
9106
+ }
9107
+ };
9108
+ const unpublished = async () => {
9109
+ const generations = [];
9110
+ for await (const key2 of deps.storage.list(ref)) generations.push(key2.generation);
9111
+ const newestFirst = generations.sort((a, b) => b - a);
9112
+ const holder = (await holdsEach(newestFirst, true)).find((h) => h.held === true);
9113
+ if (holder === void 0)
9114
+ return { ...base, erased: false, reason: "no-generation", collected: [] };
9115
+ const what = sealedUnpublished.has(holder.generation) ? "is sealed under a key that load has not published, so it cannot be searched" : "holds the id";
9116
+ throw new WriteConflictError(
9117
+ `eraseIdFromSegment: segment "${ref.segment}" has no published generation, and an object a first load wrote and never published (generation ${holder.generation}) ${what}. It cannot be deleted while that load may still publish it: re-run once the segment is loaded, which makes the object collectable, or drop the segment`
9118
+ );
9119
+ };
9120
+ const underTombstone = async () => {
9121
+ const generations = [];
9122
+ for await (const key2 of deps.storage.list(ref)) generations.push(key2.generation);
9123
+ const newestFirst = generations.sort((a, b) => b - a);
9124
+ const holder = (await holdsEach(newestFirst, true)).find((h) => h.held === true);
9125
+ if (holder === void 0) return { ...base, erased: false, reason: "destroyed", collected: [] };
9126
+ const collected2 = await gcOrphanGenerations(ref, deps, { keep: 0 });
9127
+ const left2 = await holderLeft(/* @__PURE__ */ new Set());
9128
+ if (left2 !== void 0) throw cannotRemove(left2);
9129
+ return { ...base, erased: true, fromGeneration: holder.generation, collected: collected2 };
9130
+ };
8922
9131
  const notInCurrent = async () => {
8923
9132
  const notMember = {
8924
9133
  ...base,
@@ -8955,8 +9164,9 @@ async function eraseIdFromSegment(ref, id, deps, options = {}) {
8955
9164
  const newest = holdersAbove[0] ?? holderBelow;
8956
9165
  if (newest === void 0) return notMember;
8957
9166
  const collected2 = [...await gcOrphanGenerations(ref, deps, { keep: 0 })];
8958
- let moved = null;
9167
+ let moved = holdersAbove.length > 0 ? await fenceInFlight() : null;
8959
9168
  for (const generation2 of holdersAbove) {
9169
+ if (moved !== null) break;
8960
9170
  moved = rowVerdict(await deps.registry.get(ref));
8961
9171
  if (moved !== null) break;
8962
9172
  await deps.storage.delete({ ...base, generation: generation2 });
@@ -9021,10 +9231,17 @@ async function eraseIdFromSegment(ref, id, deps, options = {}) {
9021
9231
  };
9022
9232
  const discardRefused = async (written) => {
9023
9233
  const row = await deps.registry.get(ref);
9024
- if (row === null || row.status !== "active" || row.currentGen === null) return;
9025
- if (row.currentGen <= from || row.currentGen >= written) return;
9234
+ if (row !== null && row.status === "destroyed") {
9235
+ await deps.storage.delete({ ...base, generation: written });
9236
+ return row;
9237
+ }
9238
+ if (row === null || row.status !== "active" || row.currentGen === null) return row;
9239
+ if (row.currentGen <= from || row.currentGen >= written) return row;
9026
9240
  await deps.storage.delete({ ...base, generation: written });
9241
+ return row;
9027
9242
  };
9243
+ if (tombstoned) return await underTombstone();
9244
+ if (pointerless) return await unpublished();
9028
9245
  const staged = await stage();
9029
9246
  if ("erased" in staged) {
9030
9247
  if (!staged.erased && staged.generation !== void 0) await discardRefused(staged.generation);
@@ -9048,11 +9265,11 @@ async function eraseIdFromSegment(ref, id, deps, options = {}) {
9048
9265
  rng: deps.rng ?? deps.readRetry?.rng
9049
9266
  });
9050
9267
  if (!published) {
9051
- await discardRefused(generation);
9268
+ const now = await discardRefused(generation);
9052
9269
  return {
9053
9270
  ...base,
9054
9271
  erased: false,
9055
- reason: "superseded",
9272
+ reason: rowVerdict(now) ?? "superseded",
9056
9273
  fromGeneration: from,
9057
9274
  generation,
9058
9275
  collected: []
@@ -9111,9 +9328,12 @@ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
9111
9328
  var KEY_BYTES = 32;
9112
9329
  var NONCE_BYTES2 = 12;
9113
9330
  var TAG_BYTES2 = 16;
9331
+ var isBytes = (v) => Object.prototype.toString.call(v) === "[object Uint8Array]";
9114
9332
  var NodeAead = class {
9115
9333
  key;
9116
9334
  constructor(key) {
9335
+ if (!isBytes(key))
9336
+ throw new ValidationError("AEAD key must be a Uint8Array of key bytes, not text");
9117
9337
  if (key.length !== KEY_BYTES)
9118
9338
  throw new ValidationError(`AEAD key must be ${KEY_BYTES} bytes, got ${key.length}`);
9119
9339
  this.key = Buffer.from(key);
@@ -9161,6 +9381,8 @@ var InProcessKeystore = class {
9161
9381
  const entries = Object.entries(options.keys);
9162
9382
  if (entries.length === 0) throw new ValidationError("keystore needs at least one KEK");
9163
9383
  for (const [id, k] of entries) {
9384
+ if (!isBytes(k))
9385
+ throw new ValidationError(`KEK "${id}" must be a Uint8Array of key bytes, not text`);
9164
9386
  if (k.length !== KEY_BYTES)
9165
9387
  throw new ValidationError(`KEK "${id}" must be ${KEY_BYTES} bytes, got ${k.length}`);
9166
9388
  }
@@ -9198,20 +9420,186 @@ var InProcessKeystore = class {
9198
9420
  }
9199
9421
  }
9200
9422
  if (anyKekHeld) throw lastUnwrapError;
9201
- const have = [...this.keys.keys()].join(", ") || "(none)";
9202
9423
  const need = wrapped.map((w) => w.keyId).join(", ") || "(none)";
9203
9424
  throw new KeyUnavailableError(
9204
- `no held KEK can unwrap this DEK \u2014 have [${have}], wrappings reference [${need}]. Restore the missing KEK (or its recovery KEK), or rebuild the segment from source.`
9425
+ `no held KEK can unwrap this DEK \u2014 the wrappings reference [${need}]. Restore the missing KEK (or its recovery KEK), or rebuild the segment from source.`
9205
9426
  );
9206
9427
  }
9207
9428
  };
9208
9429
 
9430
+ // src/core/retention.ts
9431
+ var EXPIRES_AT = "expiresAt";
9432
+ var RETENTION_CAS_ATTEMPTS = 5;
9433
+ var MIN_EXPIRES_AT_MS = 1e12;
9434
+ var SWEEP_MARK = "retiredBySweepAt";
9435
+ function withoutSweepMark(meta) {
9436
+ if (meta === void 0 || meta === null || typeof meta !== "object" || Array.isArray(meta))
9437
+ return null;
9438
+ if (!Object.prototype.hasOwnProperty.call(meta, SWEEP_MARK)) return null;
9439
+ const rest = Object.fromEntries(Object.entries(meta).filter(([key]) => key !== SWEEP_MARK));
9440
+ return Object.keys(rest).length === 0 ? void 0 : rest;
9441
+ }
9442
+ var MAX_DATE_MS = 864e13;
9443
+ function validateRetentionPolicy(policy) {
9444
+ if (policy === null || typeof policy !== "object" || Array.isArray(policy)) {
9445
+ throw new ValidationError(
9446
+ `retention policy must be an object such as { expiresAt }; got ${policy === null ? "null" : Array.isArray(policy) ? "an array" : `a ${typeof policy}`}`
9447
+ );
9448
+ }
9449
+ const { expiresAt } = policy;
9450
+ if (typeof expiresAt !== "number" || !Number.isInteger(expiresAt)) {
9451
+ throw new ValidationError(
9452
+ `retention.expiresAt must be an integer epoch-ms; got ${String(expiresAt)}`
9453
+ );
9454
+ }
9455
+ if (expiresAt > MAX_DATE_MS) {
9456
+ throw new ValidationError(
9457
+ `retention.expiresAt (${expiresAt}) is past the latest time a date can name (${MAX_DATE_MS}); to keep a segment, leave it without a policy or call clearRetention`
9458
+ );
9459
+ }
9460
+ if (expiresAt < MIN_EXPIRES_AT_MS) {
9461
+ throw new ValidationError(
9462
+ `retention.expiresAt (${expiresAt}) is before ${MIN_EXPIRES_AT_MS} \u2014 that is almost certainly epoch SECONDS rather than milliseconds, which would read as "already expired" and retire the segment on the next sweep. Use \`Date.now() + days * 86_400_000\`. To retire a segment now, call \`dropSegment\`.`
9463
+ );
9464
+ }
9465
+ }
9466
+ function readRetentionPolicy(meta) {
9467
+ if (meta === void 0) return null;
9468
+ if (meta === null || typeof meta !== "object" || Array.isArray(meta)) return "invalid";
9469
+ if (!(EXPIRES_AT in meta)) return null;
9470
+ const raw = meta[EXPIRES_AT];
9471
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < MIN_EXPIRES_AT_MS)
9472
+ return "invalid";
9473
+ return { expiresAt: raw };
9474
+ }
9475
+ async function getSegmentRetention(ref, deps) {
9476
+ validateUserRef(ref);
9477
+ const record = await deps.registry.get(ref);
9478
+ if (record === null || record.status === "destroyed") return null;
9479
+ return readRetentionPolicy(record.retention);
9480
+ }
9481
+ function readExpiresAt(record) {
9482
+ const parsed = record === null ? null : readRetentionPolicy(record.retention);
9483
+ return parsed === null || parsed === "invalid" ? void 0 : parsed.expiresAt;
9484
+ }
9485
+ async function reindex(ref, deps, expiresAt, previousExpiresAt) {
9486
+ if (!canIndex(ref)) return false;
9487
+ const nextBucket = expiresAt === void 0 ? void 0 : dueBucket(expiresAt);
9488
+ const oldBucket = previousExpiresAt === void 0 ? void 0 : dueBucket(previousExpiresAt);
9489
+ let indexed = expiresAt === void 0;
9490
+ if (nextBucket !== void 0) {
9491
+ try {
9492
+ await deps.registry.create(dueIndexRef(nextBucket, ref), { currentGen: null });
9493
+ indexed = true;
9494
+ } catch (err) {
9495
+ if (isWriteConflictError(err)) indexed = true;
9496
+ }
9497
+ }
9498
+ if (oldBucket !== void 0 && oldBucket !== nextBucket) {
9499
+ try {
9500
+ await deps.registry.delete(dueIndexRef(oldBucket, ref));
9501
+ } catch {
9502
+ }
9503
+ }
9504
+ return indexed;
9505
+ }
9506
+ async function setSegmentRetention(ref, deps, policy) {
9507
+ validateUserRef(ref);
9508
+ validateRetentionPolicy(policy);
9509
+ const base = { segment: ref.segment, namespace: ref.namespace };
9510
+ let previousExpiresAt;
9511
+ const churn = leaseChurn(deps);
9512
+ let lost;
9513
+ for (let attempt = 0; attempt < RETENTION_CAS_ATTEMPTS; attempt += 1) {
9514
+ let record = await deps.registry.get(ref);
9515
+ if (lost !== void 0) {
9516
+ const settled2 = await churn.settle(lost, record, () => deps.registry.get(ref));
9517
+ if (settled2 !== void 0) {
9518
+ record = settled2.row;
9519
+ attempt -= 1;
9520
+ }
9521
+ }
9522
+ try {
9523
+ previousExpiresAt = readExpiresAt(record);
9524
+ if (record === null) {
9525
+ await deps.registry.create(ref, {
9526
+ currentGen: null,
9527
+ retention: { [EXPIRES_AT]: policy.expiresAt }
9528
+ });
9529
+ return {
9530
+ ...base,
9531
+ expiresAt: policy.expiresAt,
9532
+ createdRow: true,
9533
+ indexed: await reindex(ref, deps, policy.expiresAt, void 0)
9534
+ };
9535
+ }
9536
+ assertNotDestroyed(record, ref);
9537
+ await deps.registry.compareAndSwap(ref, record.token, {
9538
+ retention: { ...record.retention, [EXPIRES_AT]: policy.expiresAt }
9539
+ });
9540
+ return {
9541
+ ...base,
9542
+ expiresAt: policy.expiresAt,
9543
+ createdRow: false,
9544
+ indexed: await reindex(ref, deps, policy.expiresAt, previousExpiresAt)
9545
+ };
9546
+ } catch (err) {
9547
+ if (!isWriteConflictError(err)) throw err;
9548
+ lost = record ?? void 0;
9549
+ }
9550
+ }
9551
+ throw new WriteConflictError(
9552
+ `setRetention: contention writing the retention policy for segment "${ref.segment}"`
9553
+ );
9554
+ }
9555
+ async function clearSegmentRetention(ref, deps) {
9556
+ validateUserRef(ref);
9557
+ const churn = leaseChurn(deps);
9558
+ let lost;
9559
+ for (let attempt = 0; attempt < RETENTION_CAS_ATTEMPTS; attempt += 1) {
9560
+ let record = await deps.registry.get(ref);
9561
+ if (lost !== void 0) {
9562
+ const settled2 = await churn.settle(lost, record, () => deps.registry.get(ref));
9563
+ if (settled2 !== void 0) {
9564
+ record = settled2.row;
9565
+ attempt -= 1;
9566
+ }
9567
+ }
9568
+ if (record === null) return false;
9569
+ if (record.status === "destroyed") return false;
9570
+ if (record.retention === void 0 || !(EXPIRES_AT in record.retention)) return false;
9571
+ const rest = { ...record.retention };
9572
+ delete rest[EXPIRES_AT];
9573
+ try {
9574
+ await deps.registry.compareAndSwap(ref, record.token, {
9575
+ retention: Object.keys(rest).length === 0 ? void 0 : rest
9576
+ });
9577
+ await reindex(ref, deps, void 0, readExpiresAt(record));
9578
+ return true;
9579
+ } catch (err) {
9580
+ if (!isWriteConflictError(err)) throw err;
9581
+ lost = record;
9582
+ }
9583
+ }
9584
+ throw new WriteConflictError(
9585
+ `clearRetention: contention clearing the retention policy for segment "${ref.segment}"`
9586
+ );
9587
+ }
9588
+ function assertNotDestroyed(record, ref) {
9589
+ if (record.status === "destroyed") {
9590
+ throw new ValidationError(
9591
+ `segment "${ref.segment}" is destroyed (crypto-shredded) \u2014 refusing to set a retention policy on a tombstone; there is nothing left to retire. Use a new segment.`
9592
+ );
9593
+ }
9594
+ }
9595
+
9209
9596
  // src/core/erasure.ts
9210
9597
  var MAX_CAS_ATTEMPTS = 8;
9211
9598
  var ERASE_CONCURRENCY = 8;
9212
9599
  var MAX_STORAGE_SWEEPS = 3;
9213
9600
  async function destroySegment(ref, deps, options) {
9214
9601
  validateUserRef(ref);
9602
+ checkedAuditSink(options.audit, "destroySegment");
9215
9603
  if (options.confirmSegment !== ref.segment) {
9216
9604
  throw new ValidationError(
9217
9605
  `destroySegment: confirmSegment must equal the segment name "${ref.segment}" (guard against accidental crypto-shred)`
@@ -9232,6 +9620,7 @@ async function eraseNamespace(namespace, deps, options) {
9232
9620
  throw new ValidationError("eraseNamespace: namespace must be a non-empty string");
9233
9621
  }
9234
9622
  validateUserNamespace(namespace);
9623
+ checkedAuditSink(options.audit, "eraseNamespace");
9235
9624
  if (options.confirmNamespace !== namespace) {
9236
9625
  throw new ValidationError(
9237
9626
  `eraseNamespace: confirmNamespace must equal the namespace "${namespace}" (guard against accidental erasure)`
@@ -9273,7 +9662,11 @@ async function eraseNamespace(namespace, deps, options) {
9273
9662
  return { destroyed };
9274
9663
  }
9275
9664
  async function dropSegment(ref, deps, options) {
9665
+ return await dropSegmentFor(ref, deps, options, void 0);
9666
+ }
9667
+ async function dropSegmentFor(ref, deps, options, sweep) {
9276
9668
  validateUserRef(ref);
9669
+ checkedAuditSink(options.audit, "dropSegment");
9277
9670
  if (options.confirmSegment !== ref.segment) {
9278
9671
  throw new ValidationError(
9279
9672
  `dropSegment: confirmSegment must equal the segment name "${ref.segment}" (guard against accidental deletion)`
@@ -9294,7 +9687,9 @@ async function dropSegment(ref, deps, options) {
9294
9687
  reason: record === null ? wouldDelete.length === 0 ? "absent" : void 0 : record.status === "destroyed" ? "already" : void 0
9295
9688
  };
9296
9689
  }
9297
- let shred = await shredSegment(ref, deps, true, "dropSegment");
9690
+ const first = await shredSegment(ref, deps, true, "dropSegment", sweep);
9691
+ if (first === null) return null;
9692
+ let shred = first;
9298
9693
  if (shred.reason === "absent") {
9299
9694
  const orphans = await listGenerations2(deps.storage, ref);
9300
9695
  if (orphans.length === 0) {
@@ -9371,7 +9766,7 @@ async function listGenerations2(storage, ref) {
9371
9766
  for await (const key of storage.list(ref)) generations.push(key.generation);
9372
9767
  return generations.sort((a, b) => a - b);
9373
9768
  }
9374
- async function shredSegment(ref, deps, allowCleartext, op = "destroySegment") {
9769
+ async function shredSegment(ref, deps, allowCleartext, op = "destroySegment", sweep) {
9375
9770
  const base = { segment: ref.segment, namespace: ref.namespace };
9376
9771
  const churn = leaseChurn(deps);
9377
9772
  let lost;
@@ -9384,6 +9779,9 @@ async function shredSegment(ref, deps, allowCleartext, op = "destroySegment") {
9384
9779
  attempt -= 1;
9385
9780
  }
9386
9781
  }
9782
+ if (sweep !== void 0 && (record === null || record.status === "destroyed" || !sweep.wanted(record))) {
9783
+ return null;
9784
+ }
9387
9785
  if (record === null) {
9388
9786
  return { ...base, destroyed: false, cryptoShredded: false, reason: "absent" };
9389
9787
  }
@@ -9394,6 +9792,7 @@ async function shredSegment(ref, deps, allowCleartext, op = "destroySegment") {
9394
9792
  if (!encrypted && !allowCleartext) {
9395
9793
  return { ...base, destroyed: false, cryptoShredded: false, reason: "cleartext" };
9396
9794
  }
9795
+ const unmarked = sweep === void 0 ? withoutSweepMark(record.retention) : null;
9397
9796
  try {
9398
9797
  await deps.registry.compareAndSwap(ref, record.token, {
9399
9798
  status: "destroyed",
@@ -9404,7 +9803,10 @@ async function shredSegment(ref, deps, allowCleartext, op = "destroySegment") {
9404
9803
  // the wrappings; clear, they would outlive the segment on its tombstone.
9405
9804
  summary: void 0,
9406
9805
  // A tombstone holds nothing: a lease names a generation of a segment that now resolves none.
9407
- leases: void 0
9806
+ leases: void 0,
9807
+ // A sweep's mark, in this same write, so no other tombstone ever carries it: any other tombstone write removes
9808
+ // one a restored or hand-edited row carried.
9809
+ ...sweep !== void 0 ? { retention: sweep.stamp(record.retention) } : unmarked === null ? {} : { retention: unmarked }
9408
9810
  });
9409
9811
  return { ...base, destroyed: true, cryptoShredded: encrypted };
9410
9812
  } catch (err) {
@@ -9415,153 +9817,6 @@ async function shredSegment(ref, deps, allowCleartext, op = "destroySegment") {
9415
9817
  throw new WriteConflictError(`${op}: contention shredding "${ref.segment}" \u2014 retry`);
9416
9818
  }
9417
9819
 
9418
- // src/core/retention.ts
9419
- var EXPIRES_AT = "expiresAt";
9420
- var RETENTION_CAS_ATTEMPTS = 5;
9421
- var MIN_EXPIRES_AT_MS = 1e12;
9422
- function validateRetentionPolicy(policy) {
9423
- const { expiresAt } = policy;
9424
- if (typeof expiresAt !== "number" || !Number.isInteger(expiresAt)) {
9425
- throw new ValidationError(
9426
- `retention.expiresAt must be an integer epoch-ms; got ${String(expiresAt)}`
9427
- );
9428
- }
9429
- if (expiresAt < MIN_EXPIRES_AT_MS) {
9430
- throw new ValidationError(
9431
- `retention.expiresAt (${expiresAt}) is before ${MIN_EXPIRES_AT_MS} \u2014 that is almost certainly epoch SECONDS rather than milliseconds, which would read as "already expired" and retire the segment on the next sweep. Use \`Date.now() + days * 86_400_000\`. To retire a segment now, call \`dropSegment\`.`
9432
- );
9433
- }
9434
- }
9435
- function readRetentionPolicy(meta) {
9436
- if (meta === void 0) return null;
9437
- if (meta === null || typeof meta !== "object" || Array.isArray(meta)) return "invalid";
9438
- if (!(EXPIRES_AT in meta)) return null;
9439
- const raw = meta[EXPIRES_AT];
9440
- if (typeof raw !== "number" || !Number.isInteger(raw) || raw < MIN_EXPIRES_AT_MS)
9441
- return "invalid";
9442
- return { expiresAt: raw };
9443
- }
9444
- async function getSegmentRetention(ref, deps) {
9445
- validateUserRef(ref);
9446
- const record = await deps.registry.get(ref);
9447
- if (record === null || record.status === "destroyed") return null;
9448
- return readRetentionPolicy(record.retention);
9449
- }
9450
- function readExpiresAt(record) {
9451
- const parsed = record === null ? null : readRetentionPolicy(record.retention);
9452
- return parsed === null || parsed === "invalid" ? void 0 : parsed.expiresAt;
9453
- }
9454
- async function reindex(ref, deps, expiresAt, previousExpiresAt) {
9455
- if (!canIndex(ref)) return false;
9456
- const nextBucket = expiresAt === void 0 ? void 0 : dueBucket(expiresAt);
9457
- const oldBucket = previousExpiresAt === void 0 ? void 0 : dueBucket(previousExpiresAt);
9458
- let indexed = expiresAt === void 0;
9459
- if (nextBucket !== void 0) {
9460
- try {
9461
- await deps.registry.create(dueIndexRef(nextBucket, ref), { currentGen: null });
9462
- indexed = true;
9463
- } catch (err) {
9464
- if (isWriteConflictError(err)) indexed = true;
9465
- }
9466
- }
9467
- if (oldBucket !== void 0 && oldBucket !== nextBucket) {
9468
- try {
9469
- await deps.registry.delete(dueIndexRef(oldBucket, ref));
9470
- } catch {
9471
- }
9472
- }
9473
- return indexed;
9474
- }
9475
- async function setSegmentRetention(ref, deps, policy) {
9476
- validateUserRef(ref);
9477
- validateRetentionPolicy(policy);
9478
- const base = { segment: ref.segment, namespace: ref.namespace };
9479
- let previousExpiresAt;
9480
- const churn = leaseChurn(deps);
9481
- let lost;
9482
- for (let attempt = 0; attempt < RETENTION_CAS_ATTEMPTS; attempt += 1) {
9483
- let record = await deps.registry.get(ref);
9484
- if (lost !== void 0) {
9485
- const settled2 = await churn.settle(lost, record, () => deps.registry.get(ref));
9486
- if (settled2 !== void 0) {
9487
- record = settled2.row;
9488
- attempt -= 1;
9489
- }
9490
- }
9491
- try {
9492
- previousExpiresAt = readExpiresAt(record);
9493
- if (record === null) {
9494
- await deps.registry.create(ref, {
9495
- currentGen: null,
9496
- retention: { [EXPIRES_AT]: policy.expiresAt }
9497
- });
9498
- return {
9499
- ...base,
9500
- expiresAt: policy.expiresAt,
9501
- createdRow: true,
9502
- indexed: await reindex(ref, deps, policy.expiresAt, void 0)
9503
- };
9504
- }
9505
- assertNotDestroyed(record, ref);
9506
- await deps.registry.compareAndSwap(ref, record.token, {
9507
- retention: { ...record.retention, [EXPIRES_AT]: policy.expiresAt }
9508
- });
9509
- return {
9510
- ...base,
9511
- expiresAt: policy.expiresAt,
9512
- createdRow: false,
9513
- indexed: await reindex(ref, deps, policy.expiresAt, previousExpiresAt)
9514
- };
9515
- } catch (err) {
9516
- if (!isWriteConflictError(err)) throw err;
9517
- lost = record ?? void 0;
9518
- }
9519
- }
9520
- throw new WriteConflictError(
9521
- `setRetention: contention writing the retention policy for segment "${ref.segment}"`
9522
- );
9523
- }
9524
- async function clearSegmentRetention(ref, deps) {
9525
- validateUserRef(ref);
9526
- const churn = leaseChurn(deps);
9527
- let lost;
9528
- for (let attempt = 0; attempt < RETENTION_CAS_ATTEMPTS; attempt += 1) {
9529
- let record = await deps.registry.get(ref);
9530
- if (lost !== void 0) {
9531
- const settled2 = await churn.settle(lost, record, () => deps.registry.get(ref));
9532
- if (settled2 !== void 0) {
9533
- record = settled2.row;
9534
- attempt -= 1;
9535
- }
9536
- }
9537
- if (record === null) return false;
9538
- if (record.status === "destroyed") return false;
9539
- if (record.retention === void 0 || !(EXPIRES_AT in record.retention)) return false;
9540
- const rest = { ...record.retention };
9541
- delete rest[EXPIRES_AT];
9542
- try {
9543
- await deps.registry.compareAndSwap(ref, record.token, {
9544
- retention: Object.keys(rest).length === 0 ? void 0 : rest
9545
- });
9546
- await reindex(ref, deps, void 0, readExpiresAt(record));
9547
- return true;
9548
- } catch (err) {
9549
- if (!isWriteConflictError(err)) throw err;
9550
- lost = record;
9551
- }
9552
- }
9553
- throw new WriteConflictError(
9554
- `clearRetention: contention clearing the retention policy for segment "${ref.segment}"`
9555
- );
9556
- }
9557
- function assertNotDestroyed(record, ref) {
9558
- if (record.status === "destroyed") {
9559
- throw new ValidationError(
9560
- `segment "${ref.segment}" is destroyed (crypto-shredded) \u2014 refusing to set a retention policy on a tombstone; there is nothing left to retire. Use a new segment.`
9561
- );
9562
- }
9563
- }
9564
-
9565
9820
  // src/core/retention-sweep.ts
9566
9821
  var DEFAULT_RETIRE_LIMIT = 100;
9567
9822
  var MAX_CONSECUTIVE_PURGE_FAULTS = 3;
@@ -9590,7 +9845,7 @@ async function rowsFromDueIndex(registry, options) {
9590
9845
  continue;
9591
9846
  }
9592
9847
  if (gone.has(key)) {
9593
- litter.push(found);
9848
+ if (litter.length < options.maxScanSegments) litter.push(found);
9594
9849
  continue;
9595
9850
  }
9596
9851
  if (rows.length >= options.maxScanSegments) {
@@ -9600,8 +9855,8 @@ async function rowsFromDueIndex(registry, options) {
9600
9855
  }
9601
9856
  const live = await registry.get(ref);
9602
9857
  if (live === null) {
9603
- gone.add(key);
9604
- litter.push(found);
9858
+ if (gone.size < options.maxScanSegments) gone.add(key);
9859
+ if (litter.length < options.maxScanSegments) litter.push(found);
9605
9860
  continue;
9606
9861
  }
9607
9862
  rows.push(live);
@@ -9667,6 +9922,23 @@ async function forgetPurgePointers(registry, ref, purgeAt, found, onFault) {
9667
9922
  }
9668
9923
  }
9669
9924
  async function retireExpired(deps, options) {
9925
+ checkedAuditSink(options.audit, "retireExpired");
9926
+ if (options.shards !== void 0) {
9927
+ const total = options.totalShards;
9928
+ if (typeof total !== "number" || !Number.isInteger(total) || total < 1) {
9929
+ throw new ValidationError("retireExpired: shards needs totalShards, an integer of 1 or more");
9930
+ }
9931
+ if (!Array.isArray(options.shards)) {
9932
+ throw new ValidationError("retireExpired: shards must be an array of shard numbers");
9933
+ }
9934
+ for (const shard of options.shards) {
9935
+ if (typeof shard !== "number" || !Number.isInteger(shard) || shard < 0 || shard >= total) {
9936
+ throw new ValidationError(
9937
+ `retireExpired: a shard is an integer from 0 to totalShards - 1 (${total - 1}); got ${String(shard)}`
9938
+ );
9939
+ }
9940
+ }
9941
+ }
9670
9942
  if (options.namespace !== void 0) validateUserNamespace(options.namespace);
9671
9943
  const now = options.now;
9672
9944
  if (!Number.isFinite(now)) {
@@ -9742,8 +10014,24 @@ async function retireExpired(deps, options) {
9742
10014
  attempted += 1;
9743
10015
  try {
9744
10016
  if (!await isFullyReclaimed(deps, ref)) {
9745
- if (!dryRun) await gcOrphanGenerations(ref, deps).catch(() => void 0);
10017
+ let collectFault;
10018
+ if (!dryRun) {
10019
+ await gcOrphanGenerations(ref, deps).catch((error) => {
10020
+ collectFault = { error };
10021
+ });
10022
+ }
9746
10023
  if (!await isFullyReclaimed(deps, ref)) {
10024
+ const lostRace = collectFault !== void 0 && isWriteConflictError(collectFault.error);
10025
+ if (!dryRun && !lostRace) {
10026
+ attempted -= 1;
10027
+ purgeRun += 1;
10028
+ if (purgeRun >= MAX_CONSECUTIVE_PURGE_FAULTS) purging = false;
10029
+ if (collectFault !== void 0) noteFault(collectFault.error);
10030
+ else {
10031
+ purgeFaults += 1;
10032
+ firstPurgeFault ??= "failed: the tombstone's objects were still there after its collection";
10033
+ }
10034
+ }
9747
10035
  entries.push({ ...base, action: "skipped", reason: "tombstone-not-empty" });
9748
10036
  continue;
9749
10037
  }
@@ -9796,11 +10084,23 @@ async function retireExpired(deps, options) {
9796
10084
  continue;
9797
10085
  }
9798
10086
  attempted += 1;
9799
- const result = await dropSegment(ref, deps, {
9800
- confirmSegment: rec.segment,
9801
- dryRun,
9802
- audit: options.audit
9803
- });
10087
+ const result = await dropSegmentFor(
10088
+ ref,
10089
+ deps,
10090
+ { confirmSegment: rec.segment, dryRun, audit: options.audit },
10091
+ dryRun ? void 0 : {
10092
+ wanted: (row) => {
10093
+ const p = readRetentionPolicy(row.retention);
10094
+ return p !== null && p !== "invalid" && p.expiresAt <= now;
10095
+ },
10096
+ stamp: (retention) => ({ ...retention, [RETIRED_AT]: now })
10097
+ }
10098
+ );
10099
+ if (result === null) {
10100
+ attempted -= 1;
10101
+ entries.push({ ...base, action: "skipped", reason: "policy-changed" });
10102
+ continue;
10103
+ }
9804
10104
  if (dryRun) {
9805
10105
  wouldRetire += 1;
9806
10106
  entries.push({ ...base, action: "would-retire", expiresAt: livePolicy.expiresAt, result });
@@ -9830,13 +10130,11 @@ async function retireExpired(deps, options) {
9830
10130
  if (deleted) continue;
9831
10131
  }
9832
10132
  }
9833
- const stamped = await stampRetirement(deps.registry, ref, now).catch(() => false);
9834
- if (stamped && removesRows) await filePurgePointer(deps.registry, ref, now + grace);
10133
+ if (removesRows) await filePurgePointer(deps.registry, ref, now + grace);
9835
10134
  } catch (err) {
9836
10135
  const after = await deps.registry.get(ref).catch(() => null);
9837
- if (after?.status === "destroyed") {
9838
- const stamped = await stampRetirement(deps.registry, ref, now).catch(() => false);
9839
- if (stamped && removesRows) await filePurgePointer(deps.registry, ref, now + grace);
10136
+ if (after?.status === "destroyed" && after.retention?.[RETIRED_AT] === now) {
10137
+ if (removesRows) await filePurgePointer(deps.registry, ref, now + grace);
9840
10138
  retired += 1;
9841
10139
  entries.push({
9842
10140
  ...base,
@@ -9884,7 +10182,7 @@ async function retireExpired(deps, options) {
9884
10182
  entries
9885
10183
  };
9886
10184
  }
9887
- var RETIRED_AT = "retiredBySweepAt";
10185
+ var RETIRED_AT = SWEEP_MARK;
9888
10186
  function retirementStamp(meta) {
9889
10187
  if (meta === void 0 || meta === null || typeof meta !== "object" || Array.isArray(meta)) {
9890
10188
  return null;
@@ -9892,21 +10190,6 @@ function retirementStamp(meta) {
9892
10190
  const raw = meta[RETIRED_AT];
9893
10191
  return typeof raw === "number" && Number.isInteger(raw) && raw >= MIN_EXPIRES_AT_MS ? raw : null;
9894
10192
  }
9895
- async function stampRetirement(registry, ref, now) {
9896
- for (let attempt = 0; attempt < 3; attempt += 1) {
9897
- const rec = await registry.get(ref);
9898
- if (rec === null || rec.status !== "destroyed") return false;
9899
- try {
9900
- await registry.compareAndSwap(ref, rec.token, {
9901
- retention: { ...rec.retention, [RETIRED_AT]: now }
9902
- });
9903
- return true;
9904
- } catch (err) {
9905
- if (!isWriteConflictError(err)) throw err;
9906
- }
9907
- }
9908
- return false;
9909
- }
9910
10193
  async function isFullyReclaimed(deps, ref) {
9911
10194
  for await (const key of deps.storage.list(ref)) {
9912
10195
  void key;
@@ -9919,31 +10202,6 @@ function failureReason(err) {
9919
10202
  return `failed: ${err instanceof Error ? err.message : String(err)}`;
9920
10203
  }
9921
10204
 
9922
- // src/core/registry-reap.ts
9923
- var DEFAULT_REAP_LIMIT = 1e3;
9924
- async function reapRegistryTombstones(registry, options = {}) {
9925
- const dryRun = options.dryRun === true;
9926
- if (!dryRun && options.confirmNoLegacyWriters !== true) {
9927
- throw new ValidationError(
9928
- "reapRegistryTombstones: a real run needs `confirmNoLegacyWriters: true`, your statement that no process on a release before 0.12 writes this registry (one that did would issue a removed row\u2019s tokens again). Pass `dryRun: true` to see what would be removed."
9929
- );
9930
- }
9931
- const limit = options.limit ?? DEFAULT_REAP_LIMIT;
9932
- if (!Number.isInteger(limit) || limit < 1) {
9933
- throw new ValidationError(
9934
- `reapRegistryTombstones: \`limit\` must be a positive integer; got ${String(limit)}`
9935
- );
9936
- }
9937
- if (options.namespace !== void 0) validateUserNamespace(options.namespace);
9938
- if (registry.reapLegacyTombstones === void 0) {
9939
- throw new UnsupportedError(
9940
- "reapRegistryTombstones: this registry keeps no deleted rows of a release before 0.12 to remove (only the object-store registries, S3, GCS and Azure Blob, do)"
9941
- );
9942
- }
9943
- const request = options.namespace === void 0 ? { dryRun, limit } : { namespace: options.namespace, dryRun, limit };
9944
- return registry.reapLegacyTombstones(request);
9945
- }
9946
-
9947
10205
  // src/core/consistency.ts
9948
10206
  var DEFAULT_CHECK_CONCURRENCY = 8;
9949
10207
  async function generationsPresent(storage, ref) {
@@ -9960,7 +10218,7 @@ async function checkSummary(ref, live, deps) {
9960
10218
  try {
9961
10219
  aead = await deps.keystore.openDek(live.wrappedDeks);
9962
10220
  } catch (err) {
9963
- if (isIntegrityError(err) || isCloudRoaringError(err) && err.name === "KeyUnavailableError")
10221
+ if (isIntegrityError(err) || isKeyUnavailableError(err))
9964
10222
  return { kind: "ok", unchecked: true };
9965
10223
  throw err;
9966
10224
  }
@@ -10048,8 +10306,11 @@ async function runConsistencyCheck(deps, options = {}) {
10048
10306
 
10049
10307
  // src/drivers/retry/retrying-chunk-source.ts
10050
10308
  function toRetry(opts) {
10309
+ if (opts.onRetry !== void 0 && typeof opts.onRetry !== "function") {
10310
+ throw new ValidationError("retry.onRetry must be a function");
10311
+ }
10051
10312
  return {
10052
- policy: opts.policy ?? DEFAULT_RETRY_POLICY,
10313
+ policy: checkedRetryPolicy(opts.policy ?? DEFAULT_RETRY_POLICY),
10053
10314
  deps: {
10054
10315
  clock: opts.clock,
10055
10316
  rng: opts.rng,
@@ -10182,24 +10443,27 @@ export {
10182
10443
  excludingReservedRows,
10183
10444
  getSegmentRetention,
10184
10445
  groundedReport,
10446
+ isBudgetExceededError,
10447
+ isCapabilityError,
10185
10448
  isCloudRoaringError,
10186
10449
  isIntegrityError,
10450
+ isKeyUnavailableError,
10187
10451
  isLeaseExpiredError,
10188
10452
  isLeaseLimitError,
10189
10453
  isNotFoundError,
10190
10454
  isStaleOperandError,
10191
10455
  isStorageBackend,
10192
10456
  isTransientError,
10457
+ isUnsupportedError,
10193
10458
  isValidationError,
10194
10459
  isWriteConflictError,
10460
+ judgeLoad,
10195
10461
  listGenerations,
10196
10462
  listSegments,
10197
10463
  loadSegment,
10198
10464
  loadSegmentChunks,
10199
10465
  mapWithConcurrency,
10200
- prepareHeld,
10201
10466
  readRetentionPolicy,
10202
- reapRegistryTombstones,
10203
10467
  rebindCombineMany,
10204
10468
  releaseLease,
10205
10469
  resolveBudget,