@cloudbitmaps/core 0.10.0 → 0.11.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 (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
@@ -6,9 +6,10 @@
6
6
  * publish part-way through an export, a send or a reconciliation means the second half of the job describes a
7
7
  * different instant than the first — and nothing in the result says so.
8
8
  *
9
- * **A pin is a generation NUMBER, not a captured reader.** The number is immutable, so the reader behind it can
10
- * live in the shared bounded LRU and be evicted freely: re-opening at the same number reproduces the same
11
- * bytes. Holding the reader instead put every live pin outside the library's memory ceiling.
9
+ * **A pin is a generation number and the object it named, not a captured reader.** The reader behind it can live
10
+ * in the shared bounded LRU and be evicted freely: re-opening at the same number reopens the same object, or, if
11
+ * the name was purged and loaded again since, finds another one, which the pin's fingerprint refuses. Holding the
12
+ * reader instead put every live pin outside the library's memory ceiling.
12
13
  *
13
14
  * **Other segments are not pinned, and that is the contract, not an omission.** `snap.intersect([other])` reads
14
15
  * `snap` at its pinned generation and `other` at whatever is current. Pinning the whole query means pinning
@@ -22,10 +23,18 @@
22
23
  */
23
24
  import type { ChunkRef, StorageChunkSource, SegmentRef, SegmentSize } from './ports.js';
24
25
  import type { CrbmStorageChunkSource } from './crbm-storage-source.js';
25
- /** What a pin holds for one segment: the generation, and the version identifying those exact bytes. */
26
+ /**
27
+ * What a pin holds for one segment: the generation, the version identifying those exact bytes, and the pinned
28
+ * object's fingerprint, so a read can tell that the generation it opens is still that object.
29
+ *
30
+ * `fingerprint` is optional so that a pin built by hand, before there was one, still compiles. Nothing checks the
31
+ * object such a pin reads, so it cannot tell a name purged and loaded again from the segment it pinned.
32
+ * `seg.pin()` always records it.
33
+ */
26
34
  export interface PinnedAt {
27
35
  readonly generation: number | null;
28
36
  readonly version: string | null;
37
+ readonly fingerprint?: string | null;
29
38
  }
30
39
  export declare class PinnedStorageChunkSource implements StorageChunkSource {
31
40
  private readonly inner;
@@ -44,6 +53,8 @@ export declare class PinnedStorageChunkSource implements StorageChunkSource {
44
53
  * wrong answer this exists to prevent.
45
54
  */
46
55
  pins: ReadonlyMap<string, PinnedAt>);
56
+ /** The unpinned segments refresh as the store's own source does; a pinned one never does. */
57
+ get pointerRefreshMs(): number;
47
58
  /**
48
59
  * The pin for this segment, or `undefined` if it is not pinned here.
49
60
  *
@@ -58,10 +69,20 @@ export declare class PinnedStorageChunkSource implements StorageChunkSource {
58
69
  cardinalities(ref: SegmentRef): Promise<ReadonlyMap<number, number> | null>;
59
70
  currentGeneration(ref: SegmentRef): Promise<number | null>;
60
71
  /**
61
- * A pinned segment reports the version captured when it was pinned, so its decoded chunks are cached under a
62
- * key that cannot collide with the live generation's. That is what lets a pinned handle share the store's
63
- * chunk cache safely; sharing it on a generation-only key was how a pinned read could resurrect an id that
64
- * `eraseIdFromSegment` had reported physically gone.
72
+ * A pinned segment reports the version captured when it was pinned, marked as a pin's, so its decoded chunks
73
+ * are cached under keys that no live read writes. A live read keys its fetches by the version it resolved when
74
+ * it began, but is served whatever generation the live source holds when each fetch lands — after a publish
75
+ * and a lapsed `cache.genTtlMs`, a reader-cache eviction, a sweep that heals the read forward, or an
76
+ * invalidation, a different one — so an entry under a live version can hold another generation's chunk.
77
+ * Invariant 3 lets that live call see it, and later live reads resolve the current version and never look the
78
+ * entry up; a pin sharing the key would be handed it, and return a read that mixed two generations. A pinned
79
+ * read fetches exactly its own generation, so the entries it fills are always that generation's.
80
+ *
81
+ * The pin still shares the store's chunk cache, and its memory ceiling; what it gives up is a hit on a chunk a
82
+ * live read of the same version cached, which costs a pin one GET per such chunk. Live reads make no call for it,
83
+ * though a pin's entries share the cache's bound with theirs, so under a small `cache.maxChunks` each can evict
84
+ * the other. The version, not the bare generation, is still what the key carries: sharing on a generation-only
85
+ * key was how a pinned read could resurrect an id that `eraseIdFromSegment` had reported physically gone.
65
86
  */
66
87
  currentVersion(ref: SegmentRef): Promise<string | null>;
67
88
  exists(ref: SegmentRef): Promise<boolean>;
@@ -11,6 +11,17 @@ import type { BlobSink } from './blob.js';
11
11
  import type { WrappedDek } from './crypto.js';
12
12
  /** Opaque optimistic-concurrency token — unique per write, compared by equality only. */
13
13
  export type Token = string;
14
+ /**
15
+ * The address of one segment: a `namespace` and a `segment` name, both any non-empty string of at most 256
16
+ * characters once encoded for storage. Omitting the namespace addresses a different segment from one that names it.
17
+ *
18
+ * **A namespace starting with `cbm.due.` is reserved**, and every call of the store, and of the free functions
19
+ * behind it, that takes a ref or a `namespace` option throws `ValidationError` for one. The library keeps the due
20
+ * index there (one pointer row per expiring segment), and every fleet-wide scan skips those rows as bookkeeping, so a
21
+ * segment in such a namespace would be invisible to an erasure, a consistency check, an export and a retention sweep.
22
+ * A driver takes the prefix, since the due index's own rows go through it. Only that exact prefix is reserved:
23
+ * `cbm.dueX` and `cbmdue.eu` are ordinary namespaces, and a segment name is never restricted.
24
+ */
14
25
  export interface SegmentRef {
15
26
  readonly namespace?: string;
16
27
  readonly segment: string;
@@ -39,6 +50,12 @@ export interface StorageChunkSource {
39
50
  * payload reads), or `null` if the segment has no Storage generation. Powers the grounded `costReport()`.
40
51
  */
41
52
  sizeOf?(ref: SegmentRef): Promise<SegmentSize | null>;
53
+ /**
54
+ * Optional: how often this source re-reads a segment's pointer while the segment is being read, in ms, or 0 if
55
+ * it never does. The grounded `costReport()` prices the pointer refresh at this interval; a source that omits it
56
+ * is priced at the store's default.
57
+ */
58
+ readonly pointerRefreshMs?: number;
42
59
  /**
43
60
  * Optional: the current generation's **per-chunk cardinality** (`chunkKey → count`), read from the
44
61
  * already-parsed `.crbm` index with **no payload reads**, or `null` if the segment has no Storage generation.
@@ -52,8 +69,8 @@ export interface StorageChunkSource {
52
69
  * keys its chunk cache by this so a generation bump (a load's publish) is observed — a new generation
53
70
  * misses the cache instead of serving a stale decoded chunk, and an erased id can't resurrect from a cached
54
71
  * superseded chunk. Cheap: served from the source's own (short-TTL-refreshed) snapshot, **not** a fresh
55
- * backend read per call. A source that pins one immutable generation for its whole lifetime and never
56
- * refreshes may omit this — the engine then keys the cache without a generation.
72
+ * backend read per call. A source that only ever serves one immutable generation may omit this — the engine
73
+ * then keys the cache without a generation.
57
74
  */
58
75
  currentGeneration?(ref: SegmentRef): Promise<number | null>;
59
76
  /**
@@ -64,8 +81,8 @@ export interface StorageChunkSource {
64
81
  * `currentGen`**. Neither covers an event that *destroys* what the cache was derived from: an erasure that
65
82
  * deletes the generation holding the bit, a `dropSegment`, a crypto-shred, a retirement. After one of those
66
83
  * a source that had already resolved the segment keeps answering from memory — with no backend read at all,
67
- * so no storage-side control can close the window — until its TTL lapses, and **never** if it has no clock
68
- * or `cache.genTtlMs: 0` ("pin forever").
84
+ * so no storage-side control can close the window — until its TTL lapses, and with no bound at all if it has
85
+ * no registry, no clock or `cache.genTtlMs: 0`.
69
86
  *
70
87
  * Callers that destroy or retire a segment must call this. It is synchronous and best-effort: dropping
71
88
  * memoized state cannot fail, and a source that memoizes nothing may omit the method entirely.
@@ -88,7 +105,7 @@ export interface StorageChunkSource {
88
105
  * the generation *and* the incarnation of the name it belongs to.
89
106
  *
90
107
  * {@link StorageChunkSource.currentGeneration} is not enough to key a decoded-chunk cache, and the gap is not
91
- * theoretical: `nextGeneration` returns `max(currentGen, highest object) + 1`, so it **restarts at 0** once
108
+ * theoretical: a load numbers its generation `max(currentGen, highest object) + 1`, so the number **restarts at 0** once
92
109
  * a registry row is purged and the bucket emptied. A retired, re-created name therefore serves different
93
110
  * data at the same `currentGen`, and a cache keyed on `(segment, chunk, generation)` hands back the previous
94
111
  * incarnation's ids — an erased id reappearing, with no read to intercept.
@@ -96,8 +113,8 @@ export interface StorageChunkSource {
96
113
  * Note that putting the incarnation in the *object key* would not fix this: the new incarnation still starts
97
114
  * at generation 0, so the cache key collides either way. The identity has to reach the cache.
98
115
  *
99
- * Returns `null` when the segment resolves to no generation. A source that pins one immutable generation for
100
- * its lifetime may omit this, and the engine falls back to the generation alone.
116
+ * Returns `null` when the segment resolves to no generation. A source that only ever serves one immutable
117
+ * generation may omit this, and the engine falls back to the generation alone.
101
118
  */
102
119
  currentVersion?(ref: SegmentRef): Promise<string | null>;
103
120
  }
@@ -114,35 +131,77 @@ export interface StorageCaps {
114
131
  * Immutable object storage for `.crbm` generations. A "dumb byte mover": it
115
132
  * understands neither roaring nor the `.crbm` layout, only opaque bytes addressed by a {@link GenKey}.
116
133
  * The core never reuses a key, so puts are write-once.
134
+ *
135
+ * **What a driver must do** — callers rely on each of these, and the storage conformance suite holds every
136
+ * shipped driver to them:
137
+ *
138
+ * - **`putImmutable` is write-once and reports a collision as {@link WriteConflictError}.** It never overwrites a
139
+ * stored object. `load` reads that error as a lost race for the generation number (`superseded`), so a driver
140
+ * that threw anything else for an existing key would surface as a failed load.
141
+ * - **A missing object makes `getRange` and `getTail` throw {@link NotFoundError}** — never an empty or short
142
+ * result. Heal-forward (a read whose generation a sweep collected re-resolves), the erasure's holder probe and
143
+ * its verify of the rewrite, and a pin's replaced-object check all branch on that one error. (A zero-length
144
+ * `getRange` may answer empty without reaching the backend, so it proves neither that the object exists nor
145
+ * that the offset lies inside it: the cloud drivers answer it empty, the memory and local-filesystem drivers
146
+ * throw for a missing object or an offset past the end.)
147
+ * - **An out-of-range read throws {@link ValidationError}**: a range that runs past the end of the object, or a
148
+ * negative or non-integer offset or length. Never a clamped, short or adjacent read. A pin whose object
149
+ * was purged and loaded again as a smaller one asks for a range past its end, and this error is what sends it
150
+ * to check that the object is still the one it pinned.
151
+ * - **`getTail` reports the object's true total size**, not the number of bytes it returned.
152
+ * - **`delete` is idempotent.** Deleting an absent key is a no-op, not an error: collection passes race each
153
+ * other and retry.
154
+ * - **`list` is strongly consistent, read-after-delete.** Once `delete` has resolved, `list` no longer yields
155
+ * that generation, and once `putImmutable` has resolved it does. The erasure's re-check for a generation still
156
+ * holding the id, `dropSegment`'s `generationsRemaining`, the retention sweep's check that a tombstone's storage
157
+ * is gone, and rollback's post-move check prove a deletion or a presence by listing, so a listing that lags
158
+ * reports an erasure as incomplete or a stale object as gone.
159
+ * - **Never replay a conditional write without telling the replay apart.** A `putImmutable` that lands and then
160
+ * loses its response, sent again, finds its own object and would report a collision. Either send each write
161
+ * once, with the backend client's retry off for that request, or recognise your own write when the
162
+ * precondition fails by reading an id you stored with it back, and treat a match as success. The first is
163
+ * what the S3 driver and the single-request GCS upload do; the second is what the Azure Blob driver and the
164
+ * resumable GCS upload do.
165
+ * - **Raise a transient fault as {@link TransientError}** (throttling, a 5xx, a dropped connection), so the read
166
+ * retry can ride it out and a write's caller can tell it from a deterministic failure.
117
167
  */
118
168
  export interface IStorageDriver {
119
169
  capabilities(): StorageCaps;
120
170
  /**
121
171
  * Stream a new immutable generation. The driver opens a destination, hands `write` a {@link BlobSink},
122
- * then atomically commits (and computes the content hash). Throws if the key already exists (write-once).
172
+ * then atomically commits (and computes the content hash). Throws {@link WriteConflictError} if the key
173
+ * already exists (write-once).
123
174
  */
124
175
  putImmutable(key: GenKey, write: (sink: BlobSink) => Promise<void>): Promise<{
125
176
  size: number;
126
177
  sha256: string;
127
178
  }>;
128
- /** Range read; the caller bounds-checks. Out-of-range is rejected, never a short/adjacent read. */
179
+ /**
180
+ * Range read; the caller bounds-checks. Out-of-range throws {@link ValidationError}, never a short/adjacent
181
+ * read, and a missing object throws {@link NotFoundError}.
182
+ */
129
183
  getRange(key: GenKey, offset: number, length: number): Promise<Uint8Array>;
130
- /** Speculative tail read: the last `min(maxBytes, size)` bytes + the total object size. */
184
+ /**
185
+ * Speculative tail read: the last `min(maxBytes, size)` bytes + the object's **true total size**. A missing
186
+ * object throws {@link NotFoundError}.
187
+ */
131
188
  getTail(key: GenKey, maxBytes: number): Promise<{
132
189
  bytes: Uint8Array;
133
190
  size: number;
134
191
  }>;
192
+ /** Idempotent: deleting an absent key is a no-op, not an error. */
135
193
  delete(key: GenKey): Promise<void>;
136
- /** Enumerate the generations present for a segment (orphan sweep / latest-gen resolution). */
194
+ /**
195
+ * Enumerate the generations present for a segment (orphan sweep / latest-gen resolution). Strongly
196
+ * consistent, read-after-delete and read-after-put.
197
+ */
137
198
  list(ref: SegmentRef): AsyncIterable<GenKey>;
138
199
  }
139
200
  /**
140
201
  * Lifecycle status of a segment. `active` is the steady state; `destroyed` is the post-crypto-shred tombstone
141
- * (the row is kept for audit but the segment is logically gone). `compacting`/`erasing` are **reserved** —
142
- * stored and round-tripped so a row written by an earlier build still reads, but no writer in this build sets
143
- * them.
202
+ * (the row is kept for audit but the segment is logically gone).
144
203
  */
145
- export type RegistryStatus = 'active' | 'compacting' | 'erasing' | 'destroyed';
204
+ export type RegistryStatus = 'active' | 'destroyed';
146
205
  /**
147
206
  * Free-form, JSON-serializable governance metadata — a **plain object** (both registry boundaries reject `null`,
148
207
  * an array or a primitive, because a reader that tests `'key' in meta` would throw an untyped `TypeError`).
@@ -218,29 +277,27 @@ export interface RegCaps {
218
277
  * Brand for {@link StorageBackend}.
219
278
  *
220
279
  * `Symbol.for` rather than a class check, for the same reason the error brands are: a class object is only
221
- * shared inside one package's bundle. `@cloudbitmaps/roaring` carries its own copy of core, so a backend
222
- * built from one package and checked by the other would fail an `instanceof` on an ordinary install — no
223
- * version skew required — as would a genuine second copy from a skew or a duplicating bundler. A registered
224
- * symbol is identity-stable across all of them, and across realms.
280
+ * shared while one copy of core is loaded, so a backend built from one copy and checked by another — after a
281
+ * version skew, or behind a duplicating bundler — would fail an `instanceof`. A registered symbol is
282
+ * identity-stable across all of them, and across realms.
225
283
  *
226
- * The string says `cloudbitmaps` while the error brands still say `cloud-roaring`: those are locked, because
227
- * changing one would break error identity against already-published bundles. A new brand has no such
228
- * constraint, so it uses the current name.
284
+ * A brand string is part of that identity contract, so once a release carries one it never changes: a changed
285
+ * string would stop matching the copies already installed.
229
286
  */
230
287
  declare const STORAGE_BACKEND_BRAND: unique symbol;
231
288
  /**
232
289
  * Where a store keeps everything: the generations, and the pointer saying which one is current.
233
290
  *
234
291
  * **Branded on purpose.** The two fields alone are not enough to qualify, and that is the whole point. This
235
- * shape is also {@link LoadDeps}/{@link GenerationDeps}, so before the brand any `{ storage, registry }`
292
+ * shape is also {@link LoadDeps}, so before the brand any `{ storage, registry }`
236
293
  * object literal satisfied it — including one assembling halves from two *unrelated* stores, which the store
237
294
  * accepted and then answered **empty** for a segment that holds data, because it read a pointer from a place
238
295
  * nothing had ever been written. That is the exact silent-empty failure one-class-per-backend exists to
239
296
  * remove, and it was reachable in five lines.
240
297
  *
241
298
  * So a backend comes from `MemoryStorage`, `LocalFsStorage`, `S3Storage`, `GcsStorage` or
242
- * `AzureBlobStorage` — each deriving both halves from one bucket and one prefix — or from
243
- * {@link createBackend}, which is the same thing said on purpose. The halves stay readable as `.storage` /
299
+ * `AzureBlobStorage` — each deriving both halves from one bucket and one prefix. A driver author composing halves
300
+ * of their own says so on purpose, with {@link brandAsBackend}. The halves stay readable as `.storage` /
244
301
  * `.registry`, because the free functions genuinely need them.
245
302
  *
246
303
  * **What this stops is the accident, not a determined caller.** The brand is a registered symbol, so it can
@@ -256,51 +313,43 @@ export interface StorageBackend {
256
313
  readonly registry: IRegistryDriver;
257
314
  }
258
315
  /**
259
- * Build a backend from two halves you supply yourself.
316
+ * The brand key, for the type-only `declare` field a backend class carries.
317
+ *
318
+ * Exported from `@cloudbitmaps/core/driver-kit` and part of the driver contract: a third-party storage
319
+ * package needs it to declare the field that {@link brandAsBackend} then stamps at runtime.
320
+ */
321
+ export declare const STORAGE_BACKEND: typeof STORAGE_BACKEND_BRAND;
322
+ /**
323
+ * Stamp the brand on a backend, **non-enumerably**, after checking both halves are drivers. Returns `target`.
260
324
  *
261
- * **Reach for a backend class first** — `MemoryStorage`, `LocalFsStorage`, `S3Storage`, `GcsStorage`,
262
- * `AzureBlobStorage`. Each derives both halves from one bucket and one prefix, so they cannot disagree, and
263
- * that is the whole reason the classes exist.
325
+ * This is the driver author's door, and it works on a backend class (`brandAsBackend(this)` as the last line of
326
+ * its constructor) and on a plain `{ storage, registry }` object alike — the latter is how a backend of your
327
+ * own halves is built: a driver wrapped for auditing, metrics, tenant scoping or client-side encryption, or
328
+ * paired with a registry in a database you already run. An application does not need it: it gets both halves
329
+ * from one backend class, which derives them from one bucket and one prefix so they cannot disagree.
264
330
  *
265
- * This is the door for the cases a class cannot express, and they are real: a driver wrapped for auditing,
266
- * metrics, tenant scoping or client-side encryption; a registry in a database you already run; a
267
- * fault-injecting double in a test. What it is NOT is a shortcut — naming it is the point. The plain
268
- * `{ storage, registry }` literal is refused precisely because it let the two halves come from unrelated
269
- * places by accident, and answer **empty** rather than fail.
331
+ * It throws {@link ValidationError} when `storage` has no `putImmutable` or `registry` has no `compareAndSwap`,
332
+ * so a half that is not a driver is refused here rather than at its first write, and when `target` is frozen or
333
+ * non-extensible, since the brand is stamped on it in place.
270
334
  *
271
335
  * It cannot check that the halves agree: `IStorageDriver` and `IRegistryDriver` do not expose a location, so
272
- * nothing here can compare one. Calling this is you taking that on.
273
- */
274
- export declare function createBackend(halves: {
275
- readonly storage: IStorageDriver;
276
- readonly registry: IRegistryDriver;
277
- }): StorageBackend;
278
- /**
279
- * Stamp the brand on a backend, **non-enumerably**.
336
+ * nothing here can compare one. Branding two unrelated halves is you taking that on — the plain
337
+ * `{ storage, registry }` literal is refused by the store precisely because it let the two halves come from
338
+ * unrelated places by accident, and answer **empty** rather than fail.
280
339
  *
281
340
  * Non-enumerable is load-bearing, not tidiness. A class field (`readonly [BRAND] = true`) emits an
282
341
  * *enumerable* own property, and spread and `Object.assign` copy exactly those — so
283
342
  * `{ ...backend, registry: other.registry }` carried the brand and was accepted, reproducing the silent-empty
284
- * bug this whole thing exists to close. That spelling is also the most idiomatic way a JS developer swaps one
285
- * half, which means the audience `createBackend` was added for walked straight past it.
286
- *
287
- * This does not make the brand unforgeable — the symbol is registered, so anyone who wants it can write
288
- * `Symbol.for('cloudbitmaps.storage-backend')`. That is deliberate effort equivalent to calling
289
- * `createBackend`, and it is not what the check is for. The check is for the accident.
290
- */
291
- /**
292
- * The brand key, for the type-only `declare` field a backend class carries.
293
- *
294
- * Exported from `@cloudbitmaps/core/driver-kit` and part of the driver contract: a third-party storage
295
- * package needs it to declare the field that {@link brandAsBackend} then stamps at runtime.
343
+ * bug the brand exists to close. This does not make the brand unforgeable — the symbol is registered, so anyone
344
+ * who wants it can write `Symbol.for('cloudbitmaps.storage-backend')`. That is deliberate effort equivalent to
345
+ * calling this function, and it is not what the check is for. The check is for the accident.
296
346
  */
297
- export declare const STORAGE_BACKEND: typeof STORAGE_BACKEND_BRAND;
298
347
  export declare function brandAsBackend<T extends {
299
348
  storage: IStorageDriver;
300
349
  registry: IRegistryDriver;
301
350
  }>(target: T): T & StorageBackend;
302
351
  /**
303
- * Is this a branded backend — from a backend class or from {@link createBackend}?
352
+ * Is this a branded backend — from a backend class or from {@link brandAsBackend}?
304
353
  *
305
354
  * Checks the brand, not the shape — see {@link StorageBackend} for why the shape alone is not enough.
306
355
  */
@@ -309,10 +358,34 @@ export declare function isStorageBackend(value: unknown): value is StorageBacken
309
358
  * Per-segment registry — the authoritative source of `currentGen`, the discovery index, and the wrapped-DEK
310
359
  * holder. One row per segment under OCC: a never-reused, equality-compared {@link Token} (ABA-safe across
311
360
  * delete→recreate).
361
+ *
362
+ * **What a driver must do** — callers rely on each of these, and the registry conformance suite holds every
363
+ * shipped driver to them:
364
+ *
365
+ * - **`create` and `compareAndSwap` are atomic conditional writes.** A `create` over a live row, and a
366
+ * `compareAndSwap` whose token is not the stored one, throw {@link WriteConflictError} and change nothing.
367
+ * Two racing writers have exactly one winner.
368
+ * - **Tokens are never reused**, not even across `delete` then `create`: a delete leaves a tombstone (or a
369
+ * global counter) so a recreated row always carries a fresh token and a stale holder cannot swap into it.
370
+ * - **`delete` is idempotent** without an `expected` token: deleting an absent row is a no-op, not an error.
371
+ * **With `expected` it is fenced**: it lands only while the row still carries that token, and otherwise throws
372
+ * {@link WriteConflictError} and leaves the row.
373
+ * - **Reads are strongly consistent** (`RegCaps.strongRead`), and `list` yields every existing row,
374
+ * `destroyed` tombstones included, with every field (see {@link IRegistryDriver.list}).
375
+ * - **Never replay a conditional write without telling the replay apart.** A `create` or `compareAndSwap`
376
+ * that lands and then loses its response, sent again, finds its own write and would report a conflict.
377
+ * Either send each write once, with the backend client's retry off for that request, or recognise your own
378
+ * write when the precondition fails by reading an id you stored with it back. The shipped registries do
379
+ * one or the other.
380
+ * - **Raise a transient fault as {@link TransientError}.**
312
381
  */
313
382
  export interface IRegistryDriver {
314
383
  capabilities(): RegCaps;
315
- /** The segment's record, or `null` if it doesn't exist (or was deleted). */
384
+ /**
385
+ * The segment's record, or `null` if it doesn't exist (or was deleted). Throw {@link TransientError} for a fault
386
+ * worth riding out (throttling, a 5xx, a dropped connection): a reader whose pointer refresh meets one keeps
387
+ * serving what it holds and asks again shortly, while any other error reaches the read that met it.
388
+ */
316
389
  get(ref: SegmentRef): Promise<RegistryRecord | null>;
317
390
  /** Create the row; throws {@link WriteConflictError} if it already exists (use CAS to mutate). */
318
391
  create(ref: SegmentRef, record: NewRegistryRecord): Promise<{
@@ -334,7 +407,17 @@ export interface IRegistryDriver {
334
407
  * paying a `get()` per segment, so a `list()` that drops the field means nothing ever expires, silently.
335
408
  */
336
409
  list(namespace?: string): AsyncIterable<RegistryRecord>;
337
- /** Remove the row (tombstoned for ABA-safety — a later `create` still gets a fresh, greater token). */
338
- delete(ref: SegmentRef): Promise<void>;
410
+ /**
411
+ * Remove the row (tombstoned for ABA-safety — a later `create` still gets a fresh, greater token).
412
+ *
413
+ * Without `expected`, idempotent: deleting an absent row is a no-op.
414
+ *
415
+ * With `expected`, the delete is **fenced like a {@link IRegistryDriver.compareAndSwap}**: it lands only while
416
+ * the row still carries exactly that token, and otherwise — the token differs, or the row is absent or already
417
+ * deleted — throws {@link WriteConflictError} and leaves the row. Pass the token of the row you read when you
418
+ * decided to delete it, so a delete that is delayed, replayed, or racing a re-create cannot tombstone a row
419
+ * created after the decision. A third-party driver that ignores `expected` keeps the unfenced behaviour.
420
+ */
421
+ delete(ref: SegmentRef, expected?: Token): Promise<void>;
339
422
  }
340
423
  export {};
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The storage reader's defaults, stated once for the two modules that need them: the reader, which applies them,
3
+ * and the cost model, which prices what they cause. The cost model once imported them from the reader itself,
4
+ * which pulled the whole read path into a module that is otherwise arithmetic.
5
+ */
6
+ /** Default TTL (ms) for re-resolving a segment's `currentGen` — the bound on post-publish read staleness. */
7
+ export declare const DEFAULT_CURRENT_GEN_TTL_MS = 2000;
8
+ /**
9
+ * How long (ms) a reader keeps serving after a pointer refresh failed transiently, before the next read asks the
10
+ * registry again. Short, so an outage ends the stale serving soon after the registry answers; fixed, so a registry
11
+ * that is down is asked at most once per this interval per segment being read (the refresh is coalesced per
12
+ * segment). Never longer than the TTL itself.
13
+ */
14
+ export declare const REFRESH_RETRY_MS = 500;
15
+ /** Default ceiling on cached segment readers (each holds a parsed `.crbm` index) — the steady-state count bound. */
16
+ export declare const DEFAULT_MAX_OPEN_SEGMENTS = 1024;
17
+ /** Default aggregate ceiling (bytes) on resident parsed indices in the reader cache — the steady-state byte bound. */
18
+ export declare const DEFAULT_MAX_OPEN_INDEX_BYTES: number;
@@ -9,14 +9,24 @@ export declare function validateMaxScanSegments(value: number, op: string): void
9
9
  /**
10
10
  * Drain every registry record (optionally one namespace) into an array, refusing past `maxScanSegments`.
11
11
  *
12
- * The bound is checked **before** each push, so the array never exceeds the ceiling the caller agreed to — the
13
- * earlier form pushed first and then threw, which meant the very row that broke the budget was already resident,
14
- * and a caller sizing a container against `maxScanSegments` was off by one row at the worst possible moment.
12
+ * The bound is checked **before** each push, so the array never exceeds the ceiling the caller agreed to. Pushing
13
+ * first and then throwing would leave the very row that broke the budget resident, and a caller sizing a container
14
+ * against `maxScanSegments` off by one row at the worst possible moment.
15
15
  */
16
16
  export declare function drainRegistry(registry: IRegistryDriver, options: {
17
17
  namespace?: string;
18
18
  maxScanSegments: number;
19
19
  op: string;
20
+ /**
21
+ * How a caller of `op` raises the ceiling, completing "Narrow it with `namespace`, or …" in the refusal.
22
+ * Defaults to raising `maxScanSegments`, which is right wherever `op` takes that option itself.
23
+ */
24
+ raise?: string;
25
+ /**
26
+ * Whether the caller could scope the scan down to a `namespace`. Defaults to true; `eraseNamespace` is already
27
+ * one namespace, so it passes false and the refusal does not tell it to narrow what it cannot.
28
+ */
29
+ narrowable?: boolean;
20
30
  }): Promise<RegistryRecord[]>;
21
31
  /**
22
32
  * Is this record **bookkeeping** rather than a segment? Covers every reserved family — today the due-index
@@ -24,9 +34,8 @@ export declare function drainRegistry(registry: IRegistryDriver, options: {
24
34
  * the export/eject scan, and the all-namespaces GDPR paths.
25
35
  *
26
36
  * This is the ONE place a reserved family is declared, and a new one belongs here rather than at the call
27
- * sites. An earlier cut inlined the comparison per site and shipped with three missed — including
28
- * `subjectReport`, where the rows consumed an Art. 15 request's per-op budget. A filter you have to remember at
29
- * each site is a check that cannot fire.
37
+ * sites. A comparison inlined per site is easy to miss at one of them — at `subjectReport`, the rows would consume
38
+ * an Art. 15 request's per-op budget. A filter you have to remember at each site is a check that cannot fire.
30
39
  */
31
40
  export declare function isReservedRow(record: Pick<RegistryRecord, 'namespace'>): boolean;
32
41
  /**
@@ -27,10 +27,12 @@
27
27
  * It also **purges the tombstones its own retirements leave behind**. A retired segment that had a registry row
28
28
  * gets a `destroyed` row, and one dead row per retired daily bucket — or per retired dedup wave — is exactly the
29
29
  * registry litter `dropSegment` already refuses to create for a row-less accumulator. Purging is narrow on
30
- * purpose: only a tombstone that still carries an **expired retention policy** (so it is attributably ours, never
31
- * a GDPR crypto-shred), only after a grace period, and only once Storage is provably empty for it —
30
+ * purpose: only a tombstone carrying the **stamp this sweep writes on its own retirements** (so it is attributably
31
+ * ours: a crypto-shred's tombstone does not carry it, unless the shred lands between this sweep's re-read of the
32
+ * row and its drop, which then reports the tombstone dropped and stamps it), only after a grace period, and only
33
+ * once Storage is provably empty for it —
32
34
  * because deleting the row is what makes the name reusable and takes the segment out of reach of
33
- * `gcOrphanGenerations`.
35
+ * the generation collection.
34
36
  */
35
37
  import { type IAuditSink } from './audit.js';
36
38
  import type { DropDeps, DropResult } from './erasure.js';
@@ -39,7 +41,7 @@ export declare const DEFAULT_RETIRE_LIMIT = 100;
39
41
  /**
40
42
  * Default delay before a retirement's own tombstone row is purged: 24 h.
41
43
  *
42
- * The row is a fence — while it exists, `publishGeneration` and `bulkLoadCrbmGeneration` refuse the segment, so
44
+ * The row is a fence — while it exists, a load refuses the segment, so
43
45
  * a load that was mid-write when the drop landed cannot resurrect it. That window is
44
46
  * seconds to minutes in practice; a day of margin costs one tiny row and removes any need to reason about it.
45
47
  */
@@ -69,10 +71,10 @@ export interface RetireExpiredOptions {
69
71
  * expiring**. Each candidate's live row is still re-read before anything is decided, so a stale pointer
70
72
  * costs one read and retires nothing.
71
73
  *
72
- * **`'index'` is not a drop-in replacement for `'fleet'`; it is the fast half of a pair.** A policy written
73
- * before the index existed, or one whose pointer write failed (`indexed: false`), has no pointer — so a
74
+ * **`'index'` is not a drop-in replacement for `'fleet'`; it is the fast half of a pair.** A policy whose
75
+ * pointer write failed (`indexed: false`), or whose ref is too long to index, has no pointer — so a
74
76
  * deployment that *only* ever runs `'index'` will never retire those. Run `'fleet'` periodically as the
75
- * repair pass. The default stays `'fleet'` so that upgrading changes nothing about what gets retired.
77
+ * repair pass. The default is `'fleet'` because it is complete by construction.
76
78
  */
77
79
  readonly scan?: 'fleet' | 'index';
78
80
  /**
@@ -93,12 +95,18 @@ export interface RetireExpiredOptions {
93
95
  readonly lookbackBuckets?: number;
94
96
  /**
95
97
  * Whether to delete the tombstone rows this sweep's own past retirements left (default `true`). Set `false` to
96
- * keep every tombstone forever — the right choice if something outside this library treats the presence of a
97
- * `destroyed` row as an attestation.
98
+ * keep every one of them — the right choice if something outside this library treats the presence of a
99
+ * `destroyed` row as an attestation. That includes the row of a retirement whose drop found no Storage
100
+ * generation to delete and left none behind: with the default that row is deleted in the same pass, since it
101
+ * would do nothing but fence the name against every writer, and with `false` it stays, stamped like any other
102
+ * of this sweep's tombstones, so a later sweep with purging on deletes it once `tombstoneGraceMs` has passed.
103
+ * While it stays, it fences the name like every kept tombstone. What records a retirement either way is its
104
+ * ledger entry, and its `segment.dispose` event (`generationsDeleted: 0` for an empty one) when an `audit` sink
105
+ * is passed.
98
106
  *
99
107
  * Two knobs rather than one `number | 'never'`, deliberately: `0` would have had to mean "purge immediately"
100
- * here while `cache.genTtlMs: 0` in this same library means "pin forever", and one option whose zero is the
101
- * opposite of another's is a reading hazard for whoever tunes both.
108
+ * here while `cache.genTtlMs: 0` in this same library means "never refresh on a timer", and one option whose
109
+ * zero is the opposite of another's is a reading hazard for whoever tunes both.
102
110
  */
103
111
  readonly purgeTombstones?: boolean;
104
112
  /** How long a retirement's tombstone must age before this sweep deletes it (default 24 h). */
@@ -141,7 +149,7 @@ export type RetireEntry = {
141
149
  * expiring is the silence that costs a retention commitment.
142
150
  * `'limit'` — eligible, but this cycle's `limit` was already spent. Re-run to continue.
143
151
  * `'tombstone-not-empty'` — a tombstone whose Storage generations are not gone even after a GC
144
- * attempt, so its row is kept: the row is what keeps the segment reachable by `gcOrphanGenerations` and
152
+ * attempt, so its row is kept: the row is what keeps the segment reachable by the generation collection and
145
153
  * refused by every writer. Several causes, all self-healing: the storage really could not be reclaimed,
146
154
  * the collection *declined* because the row changed underneath it (`WriteConflictError`, which this
147
155
  * sweep swallows deliberately), or this was a `dryRun`, which reports the reason without attempting the
@@ -161,7 +169,7 @@ export interface RetireExpiredResult {
161
169
  readonly eligible: number;
162
170
  /**
163
171
  * Segments **actually retired**. Zero under `dryRun` — see `wouldRetire`. Kept honest because this is the field
164
- * most likely to end up on a dashboard, and the CLI emits it: a counter that means "deleted" in one mode and
172
+ * most likely to end up on a dashboard: a counter that means "deleted" in one mode and
165
173
  * "would delete" in another produces phantom deletions on any graph that does not also join on `dryRun`.
166
174
  */
167
175
  readonly retired: number;
@@ -175,6 +183,11 @@ export interface RetireExpiredResult {
175
183
  /** Per-segment ledger. Inspect it: a `skipped` or failed entry is a segment that still holds data. */
176
184
  readonly entries: readonly RetireEntry[];
177
185
  }
186
+ /**
187
+ * How many past buckets an index scan reads besides the current one. A week: long enough that a weekend outage
188
+ * or a paused schedule recovers on its own, short enough that a cycle is eight list calls rather than hundreds.
189
+ */
190
+ export declare const DEFAULT_LOOKBACK_BUCKETS = 7;
178
191
  /**
179
192
  * Retire every segment whose retention policy has expired, and clean up the tombstones earlier sweeps left.
180
193
  *
@@ -182,9 +195,4 @@ export interface RetireExpiredResult {
182
195
  * argument, and for a fleet larger than `maxScanSegments` — a scan that cannot be held in memory is a fail-loud
183
196
  * condition, not a partial result to be mistaken for a complete sweep.
184
197
  */
185
- /**
186
- * How many past buckets an index scan reads besides the current one. A week: long enough that a weekend outage
187
- * or a paused schedule recovers on its own, short enough that a cycle is eight list calls rather than hundreds.
188
- */
189
- export declare const DEFAULT_LOOKBACK_BUCKETS = 7;
190
198
  export declare function retireExpired(deps: DropDeps, options: RetireExpiredOptions): Promise<RetireExpiredResult>;
@@ -11,7 +11,7 @@
11
11
  * It deliberately does **not** retry deterministic failures ({@link WriteConflictError},
12
12
  * {@link ValidationError}, {@link IntegrityError}, {@link NotFoundError}, …): retrying those either can't
13
13
  * help or would be incorrect. A pointer conflict is retried by a *separate* loop inside
14
- * `publishGeneration`, because each attempt must re-read the row and re-decide — a blind replay of the same
14
+ * the publish, because each attempt must re-read the row and re-decide — a blind replay of the same
15
15
  * compare-and-swap would either fail again on a stale token or, worse, advance a pointer whose state has
16
16
  * changed underneath it. A write-once object collision is never replayed at all: the generation number is
17
17
  * taken, so the caller has to pick a new one.
@@ -35,15 +35,19 @@ export interface RetryPolicy {
35
35
  /**
36
36
  * Conservative defaults: 4 attempts, 50ms → 100 → 200 (×2), capped at 2s, full jitter. Tuned for a cloud
37
37
  * backend's brief throttle/5xx blip — enough to ride out a transient fault without turning a hard outage
38
- * into a long hang. Override per store/driver if your latency budget differs.
38
+ * into a long hang. Override per store (`retry`) or per read source (`RetryingStorageChunkSource`) if your latency
39
+ * budget differs.
39
40
  */
40
41
  export declare const DEFAULT_RETRY_POLICY: RetryPolicy;
41
42
  export interface RetryDeps {
42
43
  readonly clock: Clock;
43
44
  readonly rng: Rng;
44
- /** Override which errors are retryable. Default: any {@link TransientError} (incl. `TimeoutError`). */
45
+ /** Override which errors are retryable. Default: any {@link TransientError}. */
45
46
  readonly isRetryable?: (err: unknown) => boolean;
46
- /** Optional hook (observability) fired before each backoff wait. `attempt` is 1-based (the one that failed). */
47
+ /**
48
+ * Optional hook (observability) fired before each backoff wait. `attempt` is 1-based (the one that failed).
49
+ * Best-effort: an error it throws is swallowed, so it can neither stop the retry nor change the error thrown.
50
+ */
47
51
  readonly onRetry?: (info: {
48
52
  attempt: number;
49
53
  delayMs: number;