@cloudbitmaps/core 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +32 -36
  2. package/dist/{chunk-BUPDGZRR.js → chunk-5OMC5U7U.js} +87 -40
  3. package/dist/chunk-5OMC5U7U.js.map +7 -0
  4. package/dist/core/audit.d.ts +15 -14
  5. package/dist/core/budget.d.ts +5 -3
  6. package/dist/core/codec.d.ts +18 -10
  7. package/dist/core/consistency.d.ts +3 -3
  8. package/dist/core/cooperative.d.ts +9 -9
  9. package/dist/core/cost.d.ts +167 -15
  10. package/dist/core/crbm/format.d.ts +7 -9
  11. package/dist/core/crbm/reader.d.ts +18 -1
  12. package/dist/core/crbm/writer.d.ts +2 -2
  13. package/dist/core/crbm-storage-source.d.ts +150 -60
  14. package/dist/core/determinism.d.ts +1 -1
  15. package/dist/core/discover.d.ts +7 -5
  16. package/dist/core/due-index.d.ts +3 -5
  17. package/dist/core/engine.d.ts +55 -13
  18. package/dist/core/erase-id.d.ts +76 -41
  19. package/dist/core/erasure.d.ts +22 -16
  20. package/dist/core/errors.d.ts +13 -20
  21. package/dist/core/generation-gc.d.ts +9 -5
  22. package/dist/core/keys.d.ts +2 -2
  23. package/dist/core/load.d.ts +28 -9
  24. package/dist/core/metrics.d.ts +8 -4
  25. package/dist/core/name-codec.d.ts +21 -31
  26. package/dist/core/pinned-storage-source.d.ts +29 -8
  27. package/dist/core/ports.d.ts +142 -59
  28. package/dist/core/reader-defaults.d.ts +18 -0
  29. package/dist/core/registry-scan.d.ts +15 -6
  30. package/dist/core/retention-sweep.d.ts +26 -18
  31. package/dist/core/retry.d.ts +8 -4
  32. package/dist/core/rollback.d.ts +11 -7
  33. package/dist/core/validate.d.ts +27 -3
  34. package/dist/driver-kit.d.ts +2 -3
  35. package/dist/driver-kit.js +13 -52
  36. package/dist/driver-kit.js.map +3 -3
  37. package/dist/drivers/_shared/keys.d.ts +4 -5
  38. package/dist/drivers/_shared/object-registry.d.ts +10 -5
  39. package/dist/drivers/_shared/registry.d.ts +9 -5
  40. package/dist/drivers/backends.d.ts +10 -16
  41. package/dist/drivers/localfs/fs-util.d.ts +3 -2
  42. package/dist/drivers/localfs/registry.d.ts +4 -4
  43. package/dist/drivers/memory.d.ts +2 -10
  44. package/dist/drivers/retry/retrying-chunk-source.d.ts +59 -0
  45. package/dist/export/index.d.ts +7 -6
  46. package/dist/index.d.ts +15 -24
  47. package/dist/index.js +1014 -373
  48. package/dist/index.js.map +4 -4
  49. package/dist/testing/fuzz-core.d.ts +1 -1
  50. package/package.json +1 -1
  51. package/dist/chunk-BUPDGZRR.js.map +0 -7
  52. package/dist/drivers/_shared/aws-errors.d.ts +0 -18
  53. package/dist/drivers/retry/retrying-drivers.d.ts +0 -90
@@ -1,12 +1,3 @@
1
- /**
2
- * Bridges the `.crbm` archive format to the engine's Storage seam.
3
- *
4
- * `CrbmStorageChunkSource` implements the {@link StorageChunkSource} the engine reads through, over an
5
- * {@link IStorageDriver}: it resolves a segment's current generation, opens its {@link CrbmReader} once, and serves
6
- * per-chunk payloads. `writeCrbmGeneration` / `writeCrbmGenerationStream` are the write primitives (a generation
7
- * built from bitmaps, in memory or streamed), `bulkLoadCrbmGeneration` is the load path over them, and
8
- * `publishGeneration` is the forward-only pointer advance every write ends with.
9
- */
10
1
  import { type IAuditSink } from './audit.js';
11
2
  import type { Yielder } from './cooperative.js';
12
3
  import type { Clock } from './determinism.js';
@@ -22,7 +13,7 @@ export interface CrbmStorageChunkSourceOptions extends CrbmReaderOptions {
22
13
  * retirement of that scan. When absent, the source falls back to the `list`-scan (so the
23
14
  * in-memory / simple setups keep working with no registry). The resolved generation is cached and
24
15
  * **re-resolved on a short TTL** ({@link currentGenTtlMs}, needs a {@link clock}) so a long-lived source
25
- * observes a load's new generation within the TTL instead of pinning one generation forever.
16
+ * observes another process's load within the TTL, not only when something else happens to re-resolve it.
26
17
  */
27
18
  readonly registry?: IRegistryDriver;
28
19
  /**
@@ -40,16 +31,19 @@ export interface CrbmStorageChunkSourceOptions extends CrbmReaderOptions {
40
31
  readonly requireEncryption?: boolean;
41
32
  /**
42
33
  * Time source for the current-generation TTL refresh — the determinism seam; `core/` never reads
43
- * ambient time. Refresh needs **both** a clock and a `registry`; without either the source **pins** the
44
- * first-resolved generation for its lifetime (the behaviour before the registry was wired in). The `CloudRoaring` facade passes its
45
- * clock automatically, so wiring a `registry` is enough to get live cross-generation invalidation.
34
+ * ambient time. Refresh needs **both** a clock and a `registry`. Without either there is no timed refresh, and
35
+ * that is the only difference: a segment is still re-resolved when its reader is evicted, when a fetch finds its
36
+ * generation swept, and on {@link CrbmStorageChunkSource.invalidate}. What goes is the bound on how long another
37
+ * process's publish takes to arrive. The `CloudRoaring` facade passes its clock automatically, so wiring a
38
+ * `registry` is enough to get the bounded refresh.
46
39
  */
47
40
  readonly clock?: Pick<Clock, 'now'>;
48
41
  /**
49
42
  * How long (ms) a resolved `currentGen` is trusted before the next read re-resolves it (default 2000) — the
50
43
  * bound on read staleness after a load publishes: a reader may serve the prior generation for up to this long,
51
44
  * then converges. Lazy (checked on read — no timer); ≤ one cheap registry read per segment per window, and a
52
- * new {@link CrbmReader} is opened only when the generation actually changed. `0` (or no clock) ⇒ pin forever.
45
+ * new {@link CrbmReader} is opened only when the generation actually changed. `0` turns the refresh off, as a
46
+ * missing clock or registry does: {@link clock} says what that changes, and what it does not.
53
47
  */
54
48
  readonly currentGenTtlMs?: number;
55
49
  /**
@@ -70,21 +64,52 @@ export interface CrbmStorageChunkSourceOptions extends CrbmReaderOptions {
70
64
  */
71
65
  readonly maxOpenIndexBytes?: number;
72
66
  }
67
+ /**
68
+ * What a pin holds of the object it pinned: the version its cache entries are keyed by, and the object's
69
+ * fingerprint, which a pinned read checks each time it opens the generation. `pinGeneration` always records one.
70
+ * A pin built by hand without one is not checked: it reads whatever object is under its generation's key.
71
+ */
72
+ export interface PinnedObject {
73
+ readonly version: string;
74
+ readonly fingerprint?: string;
75
+ }
73
76
  export declare class CrbmStorageChunkSource implements StorageChunkSource {
74
77
  private readonly driver;
75
78
  /**
76
79
  * One resolved reader per segment, re-resolved on a short TTL ({@link CrbmStorageChunkSourceOptions.currentGenTtlMs},
77
80
  * needs a clock). Within the TTL a segment's Storage bytes are treated as an immutable snapshot; when the TTL
78
81
  * elapses the next read cheaply re-resolves `currentGen` and, only if it advanced (a load published),
79
- * opens the new generation — so a long-lived source observes new generations within the TTL rather than
80
- * pinning one forever. The engine pairs this with a **generation-keyed** cache so a bump never
81
- * serves a stale decoded chunk. Without a clock or a registry the source pins the first generation for its
82
- * lifetime (the behaviour before the registry was wired in). A segment with no generation yet is not memoized, so it's re-checked until
83
- * one exists. **Bounded** by a {@link BoundedLru} ({@link CrbmStorageChunkSourceOptions.maxOpenSegments}, default
82
+ * opens the new generation — so a long-lived source observes new generations within the TTL. The engine pairs
83
+ * this with a **generation-keyed** cache so a bump never serves a stale decoded chunk. Without a clock or a
84
+ * registry there is no timed refresh, and a segment's reader is replaced only when it is evicted, when a read
85
+ * has to fetch from a generation a sweep deleted, or when {@link invalidate} drops it. A segment with no generation yet is not memoized, so
86
+ * it's re-checked until one exists. **Bounded** by a {@link BoundedLru} ({@link CrbmStorageChunkSourceOptions.maxOpenSegments}, default
84
87
  * 1024): past the ceiling the least-recently-used segment's reader (and its parsed index) is evicted — the
85
88
  * steady-state memory bound; re-opening an evicted segment is one cheap tail GET.
86
89
  */
87
90
  private readonly snapshots;
91
+ /**
92
+ * Pins ({@link heldKey}) whose object has been found replaced: another object is under its generation's key. Kept,
93
+ * and bounded as the readers are, so that a pin's later chunk reads and any reopen fail at once rather than pay for
94
+ * the check again. A replacement can be undone, by a restore that puts the pinned object back, so
95
+ * {@link invalidate} forgets the segment's, and a pin of the same version that opens the object forgets the one
96
+ * against it. An object found gone is not kept: that is the open's own `NotFoundError`, and a 404 can pass. A
97
+ * reader still memoised keeps answering from the index it holds, so the pin's `count()` does, until a later pin of
98
+ * that version opens the object now under the key, which takes its place.
99
+ */
100
+ private readonly replacedPins;
101
+ /**
102
+ * A check of a pinned object still under way, by {@link heldKey}, so the reads that ask at once share one footer
103
+ * read. One forgotten before it ends, by {@link invalidate} or a pin, keeps nothing it finds.
104
+ */
105
+ private readonly checking;
106
+ /**
107
+ * How many times {@link invalidate} has run, so a read that waited can tell whether the store was told something
108
+ * meanwhile. An eviction from the reader cache is not an invalidation, and what a read found stands through one. It
109
+ * counts every segment's invalidations: a read that waited through another segment's only costs a later read the
110
+ * check again.
111
+ */
112
+ private invalidations;
88
113
  private readonly registry;
89
114
  private readonly keystore;
90
115
  private readonly requireEncryption;
@@ -94,6 +119,12 @@ export declare class CrbmStorageChunkSource implements StorageChunkSource {
94
119
  constructor(driver: IStorageDriver, options?: CrbmStorageChunkSourceOptions);
95
120
  /** The current resolved reader for a segment, refreshing on the TTL. Cheap within the TTL window. */
96
121
  private resolvedReader;
122
+ /**
123
+ * A refresh failed transiently and the prior reader keeps serving: make this snapshot lapse after
124
+ * {@link REFRESH_RETRY_MS} (never longer than the TTL) rather than after a whole TTL, so the next read after
125
+ * that asks the registry again. It costs no read of its own; it only changes when the next refresh happens.
126
+ */
127
+ private retrySoon;
97
128
  /** TTL elapsed: cheaply re-resolve `currentGen`; reopen only if it actually changed, else reuse the prior reader. */
98
129
  private computeRefreshedReader;
99
130
  /**
@@ -103,85 +134,143 @@ export declare class CrbmStorageChunkSource implements StorageChunkSource {
103
134
  */
104
135
  private install;
105
136
  private now;
137
+ /**
138
+ * How often this source re-reads a segment's pointer while the segment is being read, in ms: its TTL, or 0 when
139
+ * it has no timed refresh (no clock, no registry, or a TTL of 0). The grounded cost report prices the refresh at
140
+ * this.
141
+ */
142
+ get pointerRefreshMs(): number;
106
143
  private expired;
107
144
  /**
108
145
  * The segment's current generation number — the engine keys its chunk cache by this so a generation bump
109
146
  * is observed instead of serving a stale decoded chunk. Served from the (TTL-refreshed)
110
147
  * snapshot, so no extra backend read within the TTL window. `null` if the segment has no committed generation.
111
148
  *
112
- * Heals a swept generation exactly like {@link withFreshSnapshot} — the engine calls this **once per op**,
113
- * before any chunk fetch, so an unhealed miss here fails the whole operation rather than one chunk. It is
114
- * spelled out rather than delegated because this is the hottest call in the library: once per operand of
115
- * every `has`/`count`/`iterate`/`intersect`, and almost always served from the cached snapshot with no
116
- * backend call at all. Routing it through the generic helper cost ~115 ns/op on that path (a second async
149
+ * Heals a swept generation exactly like {@link withFreshSnapshot}: a caller that resolves the generation once
150
+ * per op, before any chunk fetch, would otherwise fail the whole operation rather than one chunk. The engine
151
+ * keys by {@link currentVersion} instead, which heals the same way, and falls back to this for a source that
152
+ * cannot report a version. It is spelled out rather than delegated because a lookup like this runs once per
153
+ * operand of every `has`/`count`/`iterate`/`intersect`, almost always served from the cached snapshot with
154
+ * no backend call at all. Routing it through the generic helper cost ~115 ns/op on that path (a second async
117
155
  * frame, a per-call closure, and an `await` on a plain number) for a race that fires only during a
118
156
  * concurrent sweep. One retry, then propagate — same contract, same eviction rule.
119
157
  */
120
158
  currentGeneration(ref: SegmentRef): Promise<number | null>;
121
159
  /**
122
160
  * Forget everything derived from `ref`: the resolved snapshot and the open reader behind it (and with the
123
- * reader, the DEK it unwrapped at open time). The next read resolves `currentGen` from the registry again.
161
+ * reader, the DEK it unwrapped at open time), and what its pins' objects were found to be. The next read
162
+ * resolves the segment again.
124
163
  *
125
164
  * Needed because the TTL refresh only ever answers "has the pointer moved?", and every *destructive* verb —
126
165
  * an erasure that deletes the generation holding the bit, a `dropSegment`, a crypto-shred, a retirement —
127
166
  * leaves the pointer's answer unchanged from this source's point of view while making the snapshot wrong.
128
167
  * Until this is called the source keeps serving that snapshot with no backend read at all, so nothing on the
129
- * storage side can close the window; with `cache.genTtlMs: 0` or no clock it never closes.
168
+ * storage side can close the window; with no registry, no clock or `cache.genTtlMs: 0`, nothing bounds it at all.
130
169
  */
131
170
  invalidate(ref: SegmentRef): void;
132
171
  /**
133
- * Whether the segment is a registered name at all. With a registry that is exactly "a row exists" — including
134
- * a row with no generation yet (minted by `setRetention` before the first load) and a `destroyed` tombstone,
135
- * both of which are names somebody deliberately created. Without a registry there is no registration to
136
- * consult, so the honest answer is whether the bucket holds any generation of it.
172
+ * `<generation>` for a registry-less source, or `<generation>:<row token>` with one. The token moves on every row
173
+ * write, so an unrelated write (a `setRetention`) costs the segment's decoded chunks once — bounded, and on an
174
+ * admin path. Exactness in the direction that matters: with a registry, two incarnations can never share a version
175
+ * string. Without one they can: a name purged and loaded again out of band restarts at the same bare number, and
176
+ * a live read that reopens it after an eviction reads the new object's chunks beside the old one's cached ones. A
177
+ * store with no registry cannot write, so that takes a change made from outside it; a pin is kept apart from it by
178
+ * the fingerprint it records.
137
179
  *
138
- * Deliberately NOT served from the snapshot memo: the memo resolves to `null` for both states this exists to
139
- * tell apart.
140
- */
141
- /**
142
- * `<generation>` for a registry-less source (no row, so no incarnation to confuse), or
143
- * `<generation>:<row token>` with one. The token moves on every row write, so an unrelated write (a
144
- * `setRetention`) costs the segment's decoded chunks once — bounded, and on an admin path. Exactness in the
145
- * direction that matters: two incarnations can never share a version string.
180
+ * This is the call the engine makes **once per operand of every read**, before any chunk fetch, to key its
181
+ * chunk cache, so it heals a swept generation exactly as {@link currentGeneration} does: unhealed, a cold read
182
+ * racing a publish and a `keep: 0` sweep failed the whole operation with `NotFoundError`, where the same race
183
+ * on a chunk fetch heals. Spelled out rather than delegated for the reason `currentGeneration` gives: it is
184
+ * almost always served from the cached snapshot with no backend call at all. One retry, then propagate.
146
185
  */
147
186
  currentVersion(ref: SegmentRef): Promise<string | null>;
148
187
  /**
149
188
  * Resolve the segment **once** and report what a pin should hold: the generation, and the version that
150
189
  * identifies those exact bytes. `null` when the segment resolves to no generation — there is nothing to pin,
151
190
  * and a caller must treat that as "this handle reads empty", not "pinning is unsupported here".
191
+ *
192
+ * Heals a generation swept between the registry read and the open, as {@link currentVersion} does: a publish
193
+ * and a `keep: 0` sweep can land in that gap, and nothing has been handed out yet, so the generation current
194
+ * once the swept one is gone is exactly what a pin taken now should hold. One retry, then propagate.
152
195
  */
153
- pinGeneration(ref: SegmentRef): Promise<{
196
+ pinGeneration(ref: SegmentRef): Promise<({
154
197
  generation: number;
155
- version: string;
156
- } | null>;
198
+ } & Required<PinnedObject>) | null>;
199
+ private pinOnce;
200
+ /**
201
+ * Where a pinned reader of one object is memoised. With a registry that is its version, which names the object:
202
+ * a name purged and loaded again gets a new row, and with it a new token. Without one the version is the bare
203
+ * generation number, which two incarnations share, so the object's fingerprint is added to tell them apart.
204
+ */
205
+ private pinnedKey;
206
+ /**
207
+ * One pin's hold on one segment: its version, and the fingerprint of the object it pinned. A check of that object
208
+ * is shared under it, and what the check finds is kept under it, with a registry or without one: pins of one
209
+ * version can hold different objects, since a pin built by hand can name any, and an object replaced from outside
210
+ * the store leaves the row, and so the version, as it was.
211
+ */
212
+ private heldKey;
157
213
  /**
158
214
  * A reader for one **specific** generation, memoized in the same bounded LRU as the live snapshots under a
159
215
  * generation-qualified key.
160
216
  *
161
217
  * Sharing the LRU is the point, not an implementation detail. A pinned reader held in a private field is
162
218
  * outside the memory ceiling the library advertises — measured at 10.34 MiB per live pin, 32 pins holding
163
- * 111.7 MiB against an 8 MiB configured bound. A pinned *generation number* has no such problem: the
164
- * generation is immutable, so eviction is harmless and re-opening at the same number reproduces the same
165
- * bytes. It also removes a memoized-rejection bug by construction — `install` forgets a promise that rejects,
219
+ * 111.7 MiB against an 8 MiB configured bound. A pinned *generation number* has no such problem: an object is
220
+ * immutable, so eviction is harmless, and re-opening at the same number reopens the same bytes unless the name
221
+ * was purged and loaded again, which the pin's fingerprint then says. It also removes a memoized-rejection bug by
222
+ * construction — `install` forgets a promise that rejects,
166
223
  * where a hand-rolled `this.reader ??= open()` cached the rejection for the life of the handle and made a pin
167
224
  * the one read path with no resilience.
168
225
  *
169
226
  * **`status` is re-checked here**, on every open rather than once at pin time. A pin taken before a
170
227
  * crypto-shred must not keep unwrapping a DEK the shred destroyed; a destroyed segment resolves no generation
171
- * for anyone, pinned or not.
228
+ * for anyone, and a pin's read of one fails. That holds from the pin's next open: a reader already open keeps
229
+ * the key it unwrapped until it is evicted or invalidated.
230
+ *
231
+ * **And the object is checked**, when the caller says which one it pinned. A generation number is not an
232
+ * identity (invariant 1): once a name is purged and loaded again, its new segment starts again at generation 0,
233
+ * so a pin of the old one would open the new one's object as its own, and read it beside the chunks it had
234
+ * already cached from the old one. The row's token cannot tell them apart, since it moves on every write, a
235
+ * publish included; the object's fingerprint can, and a pin whose object has been replaced fails, as one whose
236
+ * generation has been swept does. The memo is keyed so that two pins of one generation number in two
237
+ * incarnations never share a reader, with a registry or without one ({@link pinnedKey}).
172
238
  */
173
239
  private readerAt;
174
240
  private openAt;
175
- /** The row's current key wrappings, for opening a pinned generation. */
176
- private deksFor;
177
- /** Read one chunk of a specific generation — the pinned read path. */
178
- getChunkAt(ref: ChunkRef, generation: number): Promise<Uint8Array | null>;
179
- /** Chunk keys of a specific generation — the pinned shape read. */
180
- listChunkKeysAt(ref: SegmentRef, generation: number): Promise<number[]>;
181
- /** Per-chunk cardinalities of a specific generation — powers a pinned `count()` with no payload reads. */
182
- cardinalitiesAt(ref: SegmentRef, generation: number): Promise<ReadonlyMap<number, number> | null>;
183
- /** Grounded size of a specific generation. */
184
- sizeOfAt(ref: SegmentRef, generation: number): Promise<SegmentSize | null>;
241
+ /**
242
+ * Read one chunk of a specific generation — the pinned read path. `held`, when given, is what a pin holds of the
243
+ * object, and the read fails if the generation is now another object.
244
+ */
245
+ getChunkAt(ref: ChunkRef, generation: number, held?: PinnedObject): Promise<Uint8Array | null>;
246
+ /**
247
+ * Throws the pin's NotFoundError if the object under `generation`'s key is no longer the one the pin holds. Its
248
+ * footer says, read with no key and no row ({@link CrbmReader.sameObject}), so an object written under a key this
249
+ * store lacks answers the same way, and so does one gone from its key. A replacement is kept
250
+ * ({@link replacedPins}), and a check under way is shared by the reads that ask at once; neither "gone" nor "the
251
+ * same" is kept, since either can change. A transient fault is rethrown, for the store's retries to see for what it
252
+ * is. A footer that cannot be read otherwise says nothing about which object is there, so this returns, and the
253
+ * caller's own error stands.
254
+ */
255
+ private throwIfReplaced;
256
+ /** Chunk keys of a specific generation — the pinned shape read. `held` as for {@link getChunkAt}. */
257
+ listChunkKeysAt(ref: SegmentRef, generation: number, held?: PinnedObject): Promise<number[]>;
258
+ /**
259
+ * Per-chunk cardinalities of a specific generation — powers a pinned `count()` with no payload reads. `held` as
260
+ * for {@link getChunkAt}.
261
+ */
262
+ cardinalitiesAt(ref: SegmentRef, generation: number, held?: PinnedObject): Promise<ReadonlyMap<number, number> | null>;
263
+ /** Grounded size of a specific generation. `held` as for {@link getChunkAt}. */
264
+ sizeOfAt(ref: SegmentRef, generation: number, held?: PinnedObject): Promise<SegmentSize | null>;
265
+ /**
266
+ * Whether the segment is a registered name at all. With a registry that is exactly "a row exists" — including
267
+ * a row with no generation yet (minted by `setRetention` before the first load) and a `destroyed` tombstone,
268
+ * both of which are names somebody deliberately created. Without a registry there is no registration to
269
+ * consult, so the honest answer is whether the bucket holds any generation of it.
270
+ *
271
+ * Deliberately NOT served from the snapshot memo: the memo resolves to `null` for both states this exists to
272
+ * tell apart.
273
+ */
185
274
  exists(ref: SegmentRef): Promise<boolean>;
186
275
  /**
187
276
  * Evict a snapshot we just failed to read from — matched by its reader *promise*, so a concurrent call's
@@ -233,7 +322,7 @@ export declare class CrbmStorageChunkSource implements StorageChunkSource {
233
322
  private withFreshSnapshot;
234
323
  }
235
324
  /**
236
- * Write one immutable generation from in-memory bitmaps (the seed / bulk-load primitive). Chunks are
325
+ * Write one immutable generation from in-memory bitmaps. Chunks are
237
326
  * sorted ascending and empty bitmaps skipped (empty chunks are never stored). Returns the driver's
238
327
  * `{ size, sha256 }` for the written object. Pass `options.crypto` to AES-256-GCM-encrypt the generation
239
328
  * (built from the segment's DEK, with associated data bound to `(segment, generation)`).
@@ -356,9 +445,9 @@ export interface BulkLoadResult {
356
445
  * `false` means the object is durable but a **concurrent writer published a higher generation first**, so this
357
446
  * one is an orphan that no reader will ever resolve. The write succeeded and the *load* did not: whatever this
358
447
  * call was asked to make the segment contain, the segment does not contain. That is not a detail a caller can
359
- * be left to infer — a `*Into` materialisation used to report the generation it wrote as "the destination's
360
- * new current generation" on exactly this path, which is a plain untruth — so the flag is on the result and the
361
- * verbs that promise a published generation check it.
448
+ * be left to infer — a `*Into` materialisation that reported the generation it wrote as "the destination's new
449
+ * current generation" on this path would state a plain untruth — so the flag is on the result and the verbs that
450
+ * promise a published generation check it.
362
451
  */
363
452
  readonly becameCurrent?: boolean;
364
453
  }
@@ -409,7 +498,8 @@ export declare function bulkLoadCrbmGeneration(driver: IStorageDriver, key: GenK
409
498
  * DEK comes back on the result so the deferred publish can store it.
410
499
  *
411
500
  * A caller that defers the publish owns what it wrote: an unpublished object sits ABOVE `currentGen`, where
412
- * generation collection deliberately never looks, so nothing else will ever reclaim it.
501
+ * generation collection deliberately never looks, so nothing reclaims it until a later generation above it is
502
+ * current.
413
503
  */
414
504
  publish?: boolean;
415
505
  }): Promise<BulkLoadResult>;
@@ -27,7 +27,7 @@ export interface Clock {
27
27
  * yields to nothing at all, and a co-resident server stays blocked for the loop's full duration. A real yield
28
28
  * needs a macrotask. `sleep(1)` is one, but it buys the relief at ~1 ms of dead wall-clock per yield.
29
29
  *
30
- * **Optional**, so every `Clock` written before this member still satisfies the interface. Callers must
30
+ * **Optional**, so a minimal `Clock` — `now` and `sleep` alone — still satisfies the interface. Callers must
31
31
  * therefore degrade rather than assume: `clock.yieldNow?.() ?? clock.sleep(1)` — correct on any clock, cheap
32
32
  * on one that implements this. `core/` cannot supply it itself (it is timer-free, lint-enforced); production
33
33
  * wiring backs it with `setImmediate`, and a test can make it a no-op so virtual time is not perturbed
@@ -57,11 +57,13 @@ export declare function segmentExists(ref: SegmentRef, registry: IRegistryDriver
57
57
  * **Scoping to a namespace narrows the LIST prefix**, so it really is the difference between reading one
58
58
  * tenant and reading all of them.
59
59
  *
60
- * Streams, so a large fleet need not be held at once, and stopping the iteration stops the scan — **with one
61
- * exception that matters**: a driver that buffers its enumeration defeats both properties, and
62
- * `RetryingRegistryDriver` does exactly that (it must, to retry a `list` as a unit). Wrapped in it, the whole
63
- * scan is paid for and resident before the first row reaches you. On the four native drivers the guarantee
64
- * holds; on S3 the granularity is a page, so one LIST page and its in-flight row reads complete regardless.
60
+ * Streams, so a large fleet need not be held at once, and stopping the iteration stops the scan — as long as the
61
+ * registry driver's own `list` streams. One that collects its whole enumeration before yielding defeats both
62
+ * properties, since the scan is then paid for and resident before the first row reaches you, so a custom driver's
63
+ * `list` should yield each row as it reads it. The registry drivers this library ships all stream; on S3, GCS and
64
+ * Azure Blob the granularity is a page, so one LIST page and its in-flight row reads complete regardless. Nothing
65
+ * here retries the scan: a transient fault part-way through ends the iteration with that error, and calling
66
+ * `listSegments` again scans from the start, since the iterable one call returns is used up.
65
67
  *
66
68
  * Yields `destroyed` tombstones, and rows whose `currentGen` is `null`, because hiding either would make this
67
69
  * disagree with the registry it reports on — a filtered enumeration that looks complete is how a retention
@@ -1,7 +1,5 @@
1
1
  import { MAX_NAME_LENGTH } from './validate.js';
2
2
  import type { RegistryRecord, SegmentRef } from './ports.js';
3
- /** Namespace prefix for due-index rows. */
4
- export declare const DUE_NAMESPACE_PREFIX = "cbm.due.";
5
3
  /** Bucket width. One day: small enough that a cycle reads little, coarse enough that the index stays tiny. */
6
4
  export declare const DUE_BUCKET_MS = 86400000;
7
5
  /**
@@ -24,13 +22,13 @@ export declare function isDueIndexRow(record: Pick<RegistryRecord, 'namespace'>)
24
22
  * Encode a ref into one index-row name, unambiguously.
25
23
  *
26
24
  * `${namespaceLength}.${namespace}${segment}` — a decimal length, a dot, then the two parts concatenated. The
27
- * length prefix is what makes it reversible: every character the grammar allows (`.`, `-`, `_`, `:`, alphanumerics)
28
- * is legal *inside* a name, so no separator character could ever be unambiguous on its own. Reading the digits
25
+ * length prefix is what makes it reversible: a name may contain any character, the dot included, so no
26
+ * separator character could ever be unambiguous on its own. Reading the digits
29
27
  * up to the first dot tells the parser exactly where the namespace ends.
30
28
  */
31
29
  export declare function encodeDueName(ref: SegmentRef): string;
32
30
  /**
33
- * Can this ref be indexed at all? False when the encoded name would exceed the grammar's 256-character cap —
31
+ * Can this ref be indexed at all? False when the encoded name would exceed the 256-character name cap —
34
32
  * possible only for a ref whose namespace and segment are together near the limit.
35
33
  *
36
34
  * **Not indexable is not "not retired".** The repair scan still sees the segment's own row, so the consequence
@@ -12,7 +12,7 @@ export interface EngineDeps {
12
12
  /**
13
13
  * The bitmap codec — **required**. `core/` is codec-agnostic: it can have no default, because the concrete codec
14
14
  * lives in a *flavor* package that depends on core (a default here would invert that arrow). A flavor's facade
15
- * injects it — `@cloudbitmaps/roaring` passes `roaringCodec` — so applications never see this.
15
+ * injects it — `@cloudbitmaps/roaring` passes the roaring codec — so applications never see this.
16
16
  */
17
17
  readonly codec: CodecInterface;
18
18
  /** Time source for the `storage.get` latency metric only; defaults to a clock that reads 0. */
@@ -26,8 +26,27 @@ export interface EngineDeps {
26
26
  */
27
27
  readonly budget?: Budget | null;
28
28
  }
29
+ /**
30
+ * A range of ids for a read to yield: those greater than `after` and no greater than `through`, `(after, through]`.
31
+ *
32
+ * Built for keyset paging: `after` is the last id the caller already has, `through` the end of its window. Each is
33
+ * optional: left out, the read starts at the first id or runs to the last. Each is an integer in `0..4294967295`, or
34
+ * the read throws {@link ValidationError} when it is first read. `after >= through` is an empty range, not an error,
35
+ * because a cursor that has reached the end of its window is normal; an empty range reads nothing, not even whether
36
+ * a combine's operands exist.
37
+ *
38
+ * A read with a range fetches only the chunks the range overlaps, on a combine for every operand and every
39
+ * `exclude`. The per-op budget is charged once, before the first fetch, for every chunk in the range, so with
40
+ * `after` alone it is charged to the end of the segment however early the caller stops; pass `through` to bound it.
41
+ */
42
+ export interface IdRange {
43
+ /** Exclusive lower bound: the read yields only ids greater than this. */
44
+ readonly after?: number | undefined;
45
+ /** Inclusive upper bound: the read yields only ids up to and including this. */
46
+ readonly through?: number | undefined;
47
+ }
29
48
  /** Options common to the chunk-aligned combines. */
30
- export interface CombineOptions {
49
+ export interface CombineOptions extends IdRange {
31
50
  /** Max chunk keys resolved concurrently — bounds the Storage footprint. A positive integer. */
32
51
  readonly concurrency?: number;
33
52
  /** Override the store's per-op budget for this call (`false` lifts it). */
@@ -58,14 +77,29 @@ export declare class SegmentEngine {
58
77
  * Cardinality. When the Storage source can serve per-chunk cardinality from its `.crbm` index (the `.crbm`
59
78
  * source can), the count is summed from the index with **zero payload reads**. A source without that
60
79
  * capability (the in-memory source) falls back to fetching every chunk.
80
+ *
81
+ * The index sum is trusted, not confirmed: the reader checked the index for internal consistency when it opened
82
+ * the object, and a corrupt index that is still internally consistent yields a wrong count. `iterate()` and the
83
+ * combines decode the payloads.
61
84
  */
62
85
  count(seg: SegmentRef): Promise<number>;
63
86
  /** Whether the Storage source can measure segment size (for grounded cost); false ⇒ storage isn't grounded. */
64
87
  get supportsStorageSize(): boolean;
88
+ /** How often the Storage source re-reads a pointer while reading (cost reporting); undefined if it does not say. */
89
+ get pointerRefreshMs(): number | undefined;
65
90
  /** Grounded Storage size of a segment's current generation (cost reporting), or null if it has no generation. */
66
91
  segmentSize(seg: SegmentRef): Promise<SegmentSize | null>;
67
- /** Every id, ascending, one chunk at a time. */
68
- iterate(seg: SegmentRef): AsyncGenerator<number>;
92
+ /**
93
+ * Every id, ascending, one chunk at a time; with `range`, only the ids in `(after, through]`, fetching only the
94
+ * chunks the range overlaps (see {@link IdRange}).
95
+ *
96
+ * Two generators, not one with a branch: a second `yield` site in the full read's generator grows its frame, and
97
+ * every id of a read that asked for no range paid for it (measured at about 5% per id). The full read keeps the
98
+ * loop it always had.
99
+ */
100
+ iterate(seg: SegmentRef, range?: IdRange): AsyncGenerator<number>;
101
+ private iterateAll;
102
+ private iterateRange;
69
103
  /**
70
104
  * `segs[0] ∩ segs[1] ∩ …`, minus every id in `options.exclude` — streamed ascending.
71
105
  *
@@ -89,11 +123,13 @@ export declare class SegmentEngine {
89
123
  * Generation-consistent within the call (normal case): each operand's current generation is resolved **once**
90
124
  * up front (before the fan-out) and threaded into every chunk read, so a concurrent load can't corrupt or tear
91
125
  * the result — every chunk read is a whole, checksum-verified, immutable generation. The edge a *long* call can
92
- * hit: if it straddles a mid-call `cache.genTtlMs` boundary and a load has published, an operand's not-yet-read
93
- * chunks may re-resolve forward to the newer generation (a generation hop within one long call) — the call
94
- * never crashes or returns a torn object, but may mix generations. A shorter call is unaffected **unless the
95
- * reader cache evicts an operand mid-call** (`maxOpenSegments`): the re-read re-resolves fresh (bypassing the
96
- * TTL), which can hop generations even sub-TTL — still whole/immutable per read, never torn.
126
+ * hit is invariant 3's: if it straddles a mid-call `cache.genTtlMs` boundary and a load has published, an
127
+ * operand's not-yet-read chunks may re-resolve forward to the newer generation (a generation hop within one long
128
+ * call) — the call never crashes or returns a torn object, but may mix generations. Three things hop it without
129
+ * waiting for the TTL, so a shorter call can meet them too: **the reader cache evicting an operand mid-call**
130
+ * (`maxOpenSegments`), whose re-read re-resolves fresh; a sweep deleting the generation it was reading, which
131
+ * heals the read forward; and an invalidation, which this store's own `load`, `rollback` and `eraseSubject` make
132
+ * and `invalidate()` makes on request. Still whole/immutable per read, never torn.
97
133
  */
98
134
  intersect(segs: readonly SegmentRef[], options?: CombineOptions): AsyncGenerator<number>;
99
135
  /**
@@ -142,6 +178,11 @@ export declare class SegmentEngine {
142
178
  *
143
179
  * Costs one `maximum()` per chunk, not per id — `maximum` is optional on the codec seam precisely so a codec
144
180
  * that cannot answer in O(1) opts out instead of making the read path walk every value.
181
+ *
182
+ * One value stands for all of them only because the decode is structurally checked. Roaring answers `maximum()`
183
+ * from its last container, so a payload listing its containers out of order would report the wrong one's
184
+ * largest value and pass here while holding values above 65,535. The codec's `safeDeserialize` refuses that
185
+ * payload, and every other shape that would make this answer wrong, before it reaches this check.
145
186
  */
146
187
  private assertChunkPayloadInRange;
147
188
  /**
@@ -161,7 +202,8 @@ export declare class SegmentEngine {
161
202
  * **Costs nothing on a normal combine.** `exists` is consulted only for an operand that resolved to zero
162
203
  * chunks — a segment with data is self-evidently registered — so the check is a registry read exactly when
163
204
  * a combine was about to do something suspicious, and no reads at all otherwise. A source that cannot answer
164
- * `exists` (it has no notion of registration) skips the check rather than guessing.
205
+ * `exists` (it has no notion of registration) skips the check rather than guessing. An operand is judged by
206
+ * all its chunks, not those inside a range: one with nothing in range but data elsewhere is not suspicious.
165
207
  */
166
208
  private refuseAbsentOperands;
167
209
  /**
@@ -182,8 +224,8 @@ export declare class SegmentEngine {
182
224
  * The cache-key component identifying **which bytes** this op will read: the source's `currentVersion` when
183
225
  * it has one, else the generation alone.
184
226
  *
185
- * The generation alone is not an identity. `nextGeneration` restarts at 0 once a row is purged and the
186
- * bucket emptied, so a retired-and-re-created name serves different data at the same `currentGen` — and a
227
+ * The generation alone is not an identity. A load's generation number restarts at 0 once a row is purged
228
+ * and the bucket emptied, so a retired-and-re-created name serves different data at the same `currentGen` — and a
187
229
  * decoded chunk cached under `(segment, chunk, 0)` is handed straight back to the new incarnation. That is an
188
230
  * erased id reappearing with no read to intercept it, which is why this is keyed on the version rather than
189
231
  * the number.
@@ -195,7 +237,7 @@ export declare class SegmentEngine {
195
237
  * keyed by it, so a load that advances the generation misses the cache and re-reads the new bytes instead of
196
238
  * serving a stale decoded chunk (an erased id can't resurrect from a cached superseded chunk). `gen === null`
197
239
  * ⇒ the source reports no current generation ⇒ no storage bytes for any chunk, so skip the fetch entirely.
198
- * `gen === undefined` ⇒ the source can't report a generation (pins one for its lifetime) ⇒ the key stays
240
+ * `gen === undefined` ⇒ the source can't report a generation (it only ever serves one) ⇒ the key stays
199
241
  * generation-free. Superseded-generation entries age out under the LRU ceiling — no active purge.
200
242
  *
201
243
  * The returned instance is **shared** (it may be the cached one): callers read it or clone it, never mutate it.