@cloudbitmaps/core 0.19.1 → 0.20.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-RSLVTRJY.js → chunk-YSC7NJWV.js} +314 -216
  2. package/dist/chunk-YSC7NJWV.js.map +7 -0
  3. package/dist/core/audit.d.ts +56 -3
  4. package/dist/core/combine-many.d.ts +9 -6
  5. package/dist/core/consistency.d.ts +10 -4
  6. package/dist/core/crbm/fingerprint.d.ts +18 -0
  7. package/dist/core/crbm/format.d.ts +2 -4
  8. package/dist/core/crbm/frame.d.ts +10 -0
  9. package/dist/core/crbm/reader.d.ts +1 -8
  10. package/dist/core/crbm-storage-source.d.ts +229 -89
  11. package/dist/core/engine.d.ts +17 -14
  12. package/dist/core/erase-id.d.ts +62 -27
  13. package/dist/core/generation-gc.d.ts +2 -1
  14. package/dist/core/leases.d.ts +7 -0
  15. package/dist/core/load.d.ts +5 -3
  16. package/dist/core/metrics.d.ts +5 -5
  17. package/dist/core/pinned-storage-source.d.ts +3 -3
  18. package/dist/core/pointer-id.d.ts +19 -0
  19. package/dist/core/ports.d.ts +127 -35
  20. package/dist/core/reader-defaults.d.ts +14 -3
  21. package/dist/core/rollback.d.ts +2 -1
  22. package/dist/core/summary.d.ts +38 -21
  23. package/dist/core/token.d.ts +13 -8
  24. package/dist/driver-kit.d.ts +2 -1
  25. package/dist/driver-kit.js +11 -4
  26. package/dist/driver-kit.js.map +2 -2
  27. package/dist/drivers/_shared/object-registry.d.ts +1 -1
  28. package/dist/drivers/_shared/registry.d.ts +26 -17
  29. package/dist/drivers/localfs/storage.d.ts +7 -6
  30. package/dist/drivers/memory.d.ts +8 -6
  31. package/dist/drivers/retry/retrying-chunk-source.d.ts +1 -5
  32. package/dist/export/index.d.ts +16 -0
  33. package/dist/index.d.ts +2 -5
  34. package/dist/index.js +1169 -1041
  35. package/dist/index.js.map +4 -4
  36. package/package.json +1 -1
  37. package/dist/chunk-RSLVTRJY.js.map +0 -7
  38. package/dist/core/cost.d.ts +0 -351
@@ -11,9 +11,11 @@
11
11
  *
12
12
  * The rewrite is the same generation without one id, so it keeps everything else: the new object carries the source's
13
13
  * metadata as it is, and the row's summary of it, built from what was written, counts one id fewer and holds the same
14
- * metadata. Metadata is not scanned for the id. On an encrypted segment, a source object with no metadata block whose
15
- * row's sealed summary of it has metadata is rewritten with the row's: the block's presence is not authenticated, so
16
- * the authenticated copy decides, and the erasure still goes through.
14
+ * metadata. Metadata is not scanned for the id. A source object whose metadata block was stripped is another object
15
+ * than the row's summary names: its size is not the one the summary records, so the fingerprint check refuses it
16
+ * before the erasure reads any of its ids. Only one forged to match that fingerprint gets past, and on an encrypted
17
+ * segment it is rewritten with the metadata of the row's sealed summary: the block's presence is not authenticated, so
18
+ * the authenticated copy decides.
17
19
  *
18
20
  * **`erased: true` is a claim about every generation of the segment, not only the one it replaced.** A rollback
19
21
  * leaves generations above the pointer that were once current and can be made current again, so a holder can sit
@@ -48,16 +50,17 @@
48
50
  * Which reason comes from **re-reading the row**, not from assuming a supersession: a moved pointer is
49
51
  * `'superseded'`, a tombstoned row `'destroyed'`, a purged row `'absent'`, a row with no pointer
50
52
  * `'no-generation'`. A pointer still on `from` means the object it names is genuinely gone — the forbidden
51
- * `missing-storage-generation` state — and that throws, because no re-run fixes it. That `NotFoundError` is the only
52
- * signal of that state, so a faulting re-read rethrows it rather than replacing it with a transient-looking
53
- * registry error.
53
+ * `missing-storage-generation` state — or is another object than the row's summary names by its fingerprint, and that
54
+ * throws, because no re-run fixes either. That `NotFoundError` is the only signal of those states, so a faulting
55
+ * re-read rethrows it rather than replacing it with a transient-looking registry error.
54
56
  *
55
57
  * **A refused rewrite deletes its own object when it would outlive the winner above the pointer.** `putImmutable`
56
58
  * commits atomically, so a rewrite whose stream throws leaves no object at all. One that completed is either
57
59
  * below the winner's pointer, where the winner's collection takes it, or above it: the case where this call took
58
60
  * its number after the winner's object was already in the bucket. Up there nothing collects it, and it was derived
59
61
  * from a generation the winner has replaced, so when the winner was another erasure it still holds the id that
60
- * erasure has just reported gone. That object is deleted before this call returns — see `discardRefused`.
62
+ * erasure has just reported gone. That object is deleted before this call returns, once its footer proves it the
63
+ * rewrite's own, under the version that read reported — see `discardRefused`.
61
64
  *
62
65
  * Without that fence the failure was silent and severe, and both halves were reproduced: `nextGeneration` picks a
63
66
  * number above everything in the bucket, so a forward-only publish here always won — discarding a concurrent
@@ -68,11 +71,21 @@
68
71
  * A live read still fetching from the collected generation heals forward to the rewrite, and a pin of it fails with
69
72
  * `NotFoundError` for any chunk it has yet to read: that is the documented cost of physical deletion on return. **Do not re-load the id while erasing it**: a load that lands after this rewrite
70
73
  * carries whatever its source held, and the library cannot know that source was meant to exclude the id.
71
- * A load already in flight writes its object above the pointer before it publishes. An erasure that finds the id in that
72
- * object writes the row before deleting it, so the load's publish, fenced on the row it read, is refused
73
- * (`published: false`) and the pointer never names a missing object (see `fenceInFlight`). On a row with no pointer
74
- * the erasure writes nothing to the row, so a first load's object there may still be published; an erasure that finds
75
- * the id in it refuses instead of deleting it.
74
+ * A load already in flight writes its object above the pointer before it publishes, or, as a first load onto a row with
75
+ * no pointer, before there is a pointer at all. An erasure that finds the id in that object renews the row's `pointerId`
76
+ * before deleting it, so the load's publish, fenced on the row it read, is refused (`published: false`) and the pointer
77
+ * never names a missing object (see `fenceInFlight`). An erasure that finds no holder writes nothing.
78
+ *
79
+ * An object sealed under a key its row does not hold cannot be searched, and no read of the segment can open it: a
80
+ * first load's on a row with no pointer, whose key that load has not published; on a segment with a key, a first
81
+ * load's that lost the race to the one that published, or crashed, under a key it made and never stored; and on a
82
+ * cleartext segment with a pointer, any encrypted object, since a key is made only for a segment's first generation
83
+ * and a publish never adds one to a segment that has generations: a first load's that minted a key and crashed, or
84
+ * lost the race to a cleartext first load. It counts as
85
+ * a holder whatever the id: an erasure of any id that finds one deletes it, under the same renewal of the row and read
86
+ * before the delete, and lists it in `collected`, and a first load still in flight that wrote it is refused. Only a
87
+ * searched object that held the id makes the answer `erased: true`. The current generation is never one of them: one
88
+ * the row's key does not open throws, as every read of the segment does.
76
89
  */
77
90
  import { type IAuditSink } from './audit.js';
78
91
  import type { CodecInterface } from './codec.js';
@@ -98,11 +111,15 @@ export interface EraseIdDeps {
98
111
  readonly keystore?: IKeystore;
99
112
  /** When true, refuse to rewrite a **cleartext** segment (the same guard the read path and a load offer). */
100
113
  readonly requireEncryption?: boolean;
101
- /** Supplying a clock that can yield makes the rewrite cooperative, as it makes a load. */
114
+ /**
115
+ * Supplying a clock that can yield makes the rewrite cooperative, as it makes a load. Its `sleep` is what a registry
116
+ * write that got no answer waits on before a fresh one is sent; with no `sleep`, such a write is not sent again and
117
+ * its `TransientError` is thrown.
118
+ */
102
119
  readonly clock?: Yielder;
103
120
  /**
104
- * The random source that spreads the waits between the rewrite's fresh compare-and-swaps. Absent, the read retry's
105
- * source is used if it has one, and otherwise each wait is its bound.
121
+ * The random source that spreads the waits between the fresh compare-and-swaps of the rewrite and of the row's
122
+ * renewal. Absent, the read retry's source is used if it has one, and otherwise each wait is its bound.
106
123
  */
107
124
  readonly rng?: Rng;
108
125
  /** Per-chunk decode ceiling (invariant 5); defaults to 1 MiB. The `.crbm` reader refuses an entry above its own `maxPayloadBytes` (1 MiB, plus 28 bytes when encrypted) at open, so raise that on the chunk source too. */
@@ -111,7 +128,7 @@ export interface EraseIdDeps {
111
128
  * The store's read retry, for the reads the rewrite makes along the way: the generation it rewrites and each of its
112
129
  * chunks, the read-back that verifies the generation it wrote, and any other generation it checks for the id. A
113
130
  * transient fault on one is run again under it rather than failing the erasure. Absent, each read is made once.
114
- * The deletes are not retried, and the rewrite's registry write is settled as a load's is.
131
+ * The deletes are not retried, and the rewrite's registry write and the row's renewal are settled as a load's is.
115
132
  */
116
133
  readonly readRetry?: ReadRetry;
117
134
  }
@@ -127,13 +144,20 @@ export interface EraseIdResult {
127
144
  readonly erased: boolean;
128
145
  /**
129
146
  * Why the id was not erased, when `erased` is false. `'absent'` (no registry row), `'destroyed'` (a crypto-shred
130
- * tombstone — already unreadable), `'no-generation'` (a row with no Storage data yet), `'not-member'` (no
131
- * generation in the bucket holds the id — the common case across a fleet scan), or `'superseded'`.
147
+ * tombstone — already unreadable), `'no-generation'` (a row with no generation yet: its bucket can hold first loads'
148
+ * objects, none of them found to hold the id, and `collected` names any this call deleted unsearched, see below),
149
+ * `'not-member'` (no
150
+ * generation in the bucket holds the id — the common case across a fleet scan — with the same note on `collected`),
151
+ * or `'superseded'`.
132
152
  *
133
153
  * **`'superseded'` means this call did not erase the id, not that the id is still there.** Another writer — a
134
- * load, another erasure, or a rollback — moved the pointer off the generation this call read, so what it was
135
- * doing no longer follows from what is current: a rewrite derived from that generation is not a valid successor
136
- * to the new one, and a generation it meant to delete above the pointer may be the one the pointer now names.
154
+ * load, another erasure, or a rollback — moved the pointer off the generation this call read (on a row with no
155
+ * pointer, any other write of the row before this call renewed it, a `setRetention` included), or replaced an
156
+ * object it meant to delete (another erasure deleted a holder above the pointer, or on a row with no pointer, and a
157
+ * load stored another object under its number, so the storage driver's conditional delete refused this call's,
158
+ * whether or not the pointer moved). What it was doing no longer follows from what is current: a rewrite derived
159
+ * from that generation is not a valid successor to the new one, and a generation it meant to delete may be the one
160
+ * the pointer now names, or another object than the one it read.
137
161
  * Re-run against the new generation: if the id is still present it is erased then; if the racing writer was
138
162
  * another erasure of the *same* id, the re-run reports `'not-member'` because it is already gone. Either way the
139
163
  * re-run settles it, which is why it is the documented action for this reason and for no other.
@@ -151,14 +175,20 @@ export interface EraseIdResult {
151
175
  readonly fromGeneration?: number;
152
176
  /**
153
177
  * The generation written without the id (present whenever one was written, even if `superseded`). A refused
154
- * rewrite whose object sits above the winner's pointer deletes that object again before it returns.
178
+ * rewrite whose object sits above the winner's pointer deletes that object again before it returns, when its footer
179
+ * proves it the rewrite's own.
155
180
  */
156
181
  readonly generation?: number;
157
182
  /**
158
183
  * Generations this call deleted. On a rewrite that is normally `[fromGeneration]`, plus any older orphans. When
159
- * the current generation did not hold the id, it is every generation below the pointer (`keep: 0` takes them
160
- * together) and each one above the pointer that held the id; the ones above it that did not hold the id stay,
161
- * because they are an operator's rollback targets. Empty when nothing was deleted.
184
+ * the current generation did not hold the id and a generation this call searched did, it is every generation below
185
+ * the pointer (`keep: 0` takes them together) and each one above the pointer that held the id; the ones above it
186
+ * that did not hold the id stay, because they are an operator's rollback targets. On a row with no pointer it is
187
+ * each first load's object that held the id. Wherever it found them, it is also each object sealed under a key the
188
+ * row does not hold, which this call cannot search and deletes whatever the id. When those are the only holders, it
189
+ * is only those, deleted one by one, and every generation this call searched stays: `reason` is then
190
+ * `'no-generation'` on a row with no pointer and `'not-member'` on one with a pointer. Empty when nothing was
191
+ * deleted.
162
192
  *
163
193
  * **It is what THIS call deleted, not the proof that the id is gone** — those differ: a concurrent collector can
164
194
  * take a generation holding the id first, and then `erased: true` is returned without it in `collected`, because
@@ -170,7 +200,10 @@ export interface EraseIdResult {
170
200
  * collection pass that could not prove the segment was still the same one (which an ordinary retirement landing
171
201
  * mid-call is enough to cause); the same refusal on the path where the current generation did not hold the id
172
202
  * and nothing was published at all; and a generation still holding the id when the bucket is listed at the end
173
- * — one an operator rolled the pointer onto while a rewrite was collecting, say.
203
+ * — one an operator rolled the pointer onto while a rewrite was collecting, say, or, on a row with no pointer, the
204
+ * object of a first load that read the row before this call renewed it and wrote it after this call listed the
205
+ * bucket. The row write an erasure makes before deleting a holder a load may still publish, when it gets no answer
206
+ * that reading the row can settle, throws the registry's `TransientError`, and that holder is not deleted.
174
207
  *
175
208
  * **Re-run it**, and read what the re-run says rather than assuming it finished the job. The re-run looks for
176
209
  * the id in every generation in the bucket, not only the current one, and there are three outcomes:
@@ -192,7 +225,9 @@ export interface EraseIdResult {
192
225
  * it. See the module note for the contract.
193
226
  *
194
227
  * Emits one `segment.rewrite` audit event at the publish (before the superseded generation is collected), so the
195
- * compliance record exists the moment the generation without the id is authoritative.
228
+ * compliance record exists the moment the generation without the id is authoritative. An erasure that rewrites nothing,
229
+ * because only other generations held the id, emits one `segment.collect` once no generation holds it, and so does one
230
+ * that deleted only objects no read of the segment can open, which names no generation the id was found in.
196
231
  */
197
232
  export declare function eraseIdFromSegment(ref: SegmentRef, id: number, deps: EraseIdDeps, options?: {
198
233
  audit?: IAuditSink;
@@ -46,7 +46,8 @@ export interface LoadNumber {
46
46
  * and collection takes it once a generation above it is current. Like a listing's, the number can be one whose
47
47
  * object was deleted (an erasure removes the generations above a rolled-back pointer that held the id), which is
48
48
  * why nothing identifies a generation by its number alone: the reader cache and the chunk cache key on the number and
49
- * the row's token, checked against the object a reader opens, and a pin on its object's fingerprint.
49
+ * the row's `pointerId`, which every write that takes a number again renews, and an open of a row with a usable
50
+ * summary holds the object to the fingerprint the summary records; a pin keys on its object's fingerprint.
50
51
  */
51
52
  export declare function nextLoadGeneration(ref: SegmentRef, deps: GenerationDeps, row: RegistryRecord | null): Promise<LoadNumber>;
52
53
  /**
@@ -17,6 +17,13 @@ export declare const MAX_STORED_LEASES = 256;
17
17
  * plus a few, so that the most a segment can hold, all arriving at once, can all land.
18
18
  */
19
19
  export declare const LEASE_CAS_ATTEMPTS: number;
20
+ /**
21
+ * Fresh writes sent after one that got no answer and left the row as it was, by the lease writers and by an erasure's
22
+ * renewal of the row.
23
+ */
24
+ export declare const UNANSWERED_RESENDS = 3;
25
+ /** The upper bound of the first of those waits, in ms; each next one doubles it. */
26
+ export declare const UNANSWERED_BASE_MS = 500;
20
27
  /**
21
28
  * Whether an entry holds its generation against a collector reading `now`: it has not ended (`until` plus the margin
22
29
  * is ahead), and it does not run further past `now` than any honest holder with a clock inside the margin could have
@@ -15,9 +15,11 @@
15
15
  *
16
16
  * Refusing therefore has to clean up after itself. The object is already durable at that point, and it sits
17
17
  * ABOVE `currentGen`, where generation collection deliberately never looks (it deletes strictly below the
18
- * pointer), so a refused load deletes its own object before returning, while the segment's row is unchanged, gone
19
- * or `destroyed`. Once another write has changed the row, the object's number may name a re-created segment's live
20
- * object, so it stays, and collection takes it like any other generation once one above it is current.
18
+ * pointer), so a refused load deletes its own object before returning, while the segment's row is unchanged (or
19
+ * differs only in its leases), gone or `destroyed`, or has key material when the load wrote cleartext. Under a row
20
+ * that is gone or keyed, a read of the object's footer must prove it the load's own first. Once another write has
21
+ * otherwise changed the row, the object's number may name a re-created segment's live object, so it stays, and
22
+ * collection takes it like any other generation once one above it is current.
21
23
  */
22
24
  import { type IAuditSink } from './audit.js';
23
25
  import { type CodecBitmap, type CodecInterface } from './codec.js';
@@ -5,8 +5,8 @@
5
5
  * Design: the library emits **neutral domain events** and stays vendor-agnostic — you (or a ~12-line
6
6
  * adapter) map them to OpenTelemetry / Datadog / a log line. The default is a **no-op**, and emission is
7
7
  * skipped entirely when no sink is wired (near-zero overhead, no telemetry dependency). Events carry **raw
8
- * observations** (bytes, counts, ms); turning those into billable units / dollars is the cost model's job,
9
- * which keeps rates pluggable.
8
+ * observations** (bytes, counts, ms); turning those into billable units / dollars is left to whoever reads them,
9
+ * which keeps rates out of the library (`@cloudbitmaps/tools` prices a workload you give it).
10
10
  *
11
11
  * Two caveats for sink authors: `onEvent` runs **synchronously on the I/O path**, so keep it cheap and
12
12
  * non-blocking (offload batching/network to your own async queue); and `segment`/`namespace` are
@@ -168,9 +168,9 @@ export interface MetricsSnapshot {
168
168
  }>>;
169
169
  }
170
170
  /**
171
- * A ready-made sink that tallies events into a {@link MetricsSnapshot} — handy for tests, quick scripts,
172
- * and as the grounded-request-cost source behind `costReport()`. `snapshot()` returns an independent copy;
173
- * `reset()` zeroes the counters.
171
+ * A ready-made sink that tallies events into a {@link MetricsSnapshot} — handy for tests, quick scripts, and for
172
+ * reading the request rates a cost model is given. `snapshot()` returns an independent copy; `reset()` zeroes the
173
+ * counters.
174
174
  */
175
175
  export declare class CountingMetricsSink implements IMetricsSink {
176
176
  private storage;
@@ -53,8 +53,6 @@ export declare class PinnedStorageChunkSource implements StorageChunkSource {
53
53
  * wrong answer this exists to prevent.
54
54
  */
55
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;
58
56
  /**
59
57
  * The pin for this segment, or `undefined` if it is not pinned here.
60
58
  *
@@ -74,12 +72,14 @@ export declare class PinnedStorageChunkSource implements StorageChunkSource {
74
72
  sizeOf(ref: SegmentRef): Promise<SegmentSize | null>;
75
73
  cardinalities(ref: SegmentRef): Promise<ReadonlyMap<number, number> | null>;
76
74
  summary(ref: SegmentRef): Promise<GenerationSummary | null>;
75
+ stat(ref: SegmentRef): Promise<(GenerationSummary & SegmentSize) | null>;
77
76
  currentGeneration(ref: SegmentRef): Promise<number | null>;
78
77
  /**
79
78
  * A pinned segment reports the version captured when it was pinned, marked as a pin's, so its decoded chunks
80
79
  * are cached under keys that no live read writes. A live read keys its fetches by the version it resolved when
81
80
  * it began, but is served whatever generation the live source holds when each fetch lands — after a publish
82
- * and a lapsed `cache.genTtlMs`, a reader-cache eviction, a sweep that heals the read forward, or an
81
+ * and a lapsed `cache.genTtlMs`, a reader-cache eviction on a store with no timed refresh, the resolution
82
+ * cache letting the segment's resolution go on a store with one, a sweep that heals the read forward, or an
83
83
  * invalidation, a different one — so an entry under a live version can hold another generation's chunk.
84
84
  * Invariant 3 lets that live call see it, and later live reads resolve the current version and never look the
85
85
  * entry up; a pin sharing the key would be handed it, and return a read that mixed two generations. A pinned
@@ -0,0 +1,19 @@
1
+ import type { RegistryPatch, RegistryRecord, Token } from './ports.js';
2
+ /** The fields a read resolves through: a patch that names any of them renews the row's `pointerId`. */
3
+ export declare const RESOLVED_FIELDS: readonly ["currentGen", "status", "wrappedDeks", "keyId", "summary"];
4
+ /**
5
+ * Whether a compare-and-swap of `patch` renews the row's `pointerId`: whether it names a resolved field, by presence
6
+ * (`'field' in patch`), whatever the value, as every patch rule reads a patch.
7
+ */
8
+ export declare function renewsPointer(patch: RegistryPatch): boolean;
9
+ /**
10
+ * The patch that renews a row's `pointerId` and changes nothing a read resolves: the pointer, named at the value it
11
+ * has. A same-value pointer keeps the summary and the kept window, which follow the pointer only when it changes value.
12
+ */
13
+ export declare function renewPointer(row: Pick<RegistryRecord, 'currentGen'>): RegistryPatch;
14
+ /**
15
+ * The row's `pointerId`, refusing a row that has none with {@link UnsupportedError}: a registry written against an
16
+ * earlier port contract returns rows without it, and a reader that cannot tell which write set the row's resolution
17
+ * cannot key a cache on it. A TypeScript registry fails to compile instead; this catches one in JavaScript.
18
+ */
19
+ export declare function pointerIdOf(row: RegistryRecord): Token;
@@ -38,8 +38,9 @@ export interface GenKey extends SegmentRef {
38
38
  readonly generation: number;
39
39
  }
40
40
  /**
41
- * A segment's grounded on-disk footprint — the current generation's Storage object bytes, read cheaply from the
42
- * `.crbm` footer/index (no payload reads). Powers the grounded `costReport()`.
41
+ * A segment's on-disk footprint — the current generation's Storage object bytes, read cheaply from the `.crbm`
42
+ * footer/index (no payload reads). It is the `sizeBytes` a segment's `stat()` reports, and what bounds the bytes a combine
43
+ * holds for an operand's stream.
43
44
  */
44
45
  export interface SegmentSize {
45
46
  readonly sizeBytes: number;
@@ -119,16 +120,10 @@ export interface StorageChunkSource {
119
120
  */
120
121
  getChunks?(ref: SegmentRef, keys: readonly number[], options?: ReadChunksOptions): AsyncIterable<ChunkRead>;
121
122
  /**
122
- * Optional: the current generation's grounded size, cheaply (from the already-parsed `.crbm` index — no
123
- * payload reads), or `null` if the segment has no Storage generation. Powers the grounded `costReport()`.
123
+ * Optional: the current generation's size, cheaply (from the already-parsed `.crbm` index — no payload reads), or
124
+ * `null` if the segment has no Storage generation.
124
125
  */
125
126
  sizeOf?(ref: SegmentRef): Promise<SegmentSize | null>;
126
- /**
127
- * Optional: how often this source re-reads a segment's pointer while the segment is being read, in ms, or 0 if
128
- * it never does. The grounded `costReport()` prices the pointer refresh at this interval; a source that omits it
129
- * is priced at the store's default.
130
- */
131
- readonly pointerRefreshMs?: number;
132
127
  /**
133
128
  * Optional: the current generation's **per-chunk cardinality** (`chunkKey → count`), read from the
134
129
  * already-parsed `.crbm` index with **no payload reads**, or `null` if the segment has no Storage generation.
@@ -144,6 +139,17 @@ export interface StorageChunkSource {
144
139
  * summary omits this, and `count()` keeps its other paths.
145
140
  */
146
141
  summary?(ref: SegmentRef): Promise<GenerationSummary | null>;
142
+ /**
143
+ * Optional: what {@link summary} answers, with the size of the generation's object, both from **one** resolution of
144
+ * the segment, so the size is the size of the generation described; `null` if the segment has no Storage
145
+ * generation. The `.crbm` source answers from the registry row's summary when it can use it, whose fingerprint
146
+ * carries the object's size: one registry read and no read of the object. When it cannot, it opens the generation
147
+ * (one tail read of the object when it is not open already, and a range read for an index longer than the tail) and
148
+ * answers from its footer and index, with no payload read. For a source that omits it, `stat()` asks
149
+ * {@link summary} and {@link sizeOf}, two resolutions that can straddle a publish, and reports no size when
150
+ * there is no `sizeOf` either.
151
+ */
152
+ stat?(ref: SegmentRef): Promise<(GenerationSummary & SegmentSize) | null>;
147
153
  /**
148
154
  * Optional: the segment's **current generation number** as this source resolves it right now (registry
149
155
  * `currentGen`, or the highest storage generation), or `null` if the segment has no Storage generation. The engine
@@ -211,6 +217,46 @@ export interface StorageCaps {
211
217
  readonly maxObjectBytes: number;
212
218
  /** Optional: enables the pure-object `LATEST`-pointer registry variant (S3 conditional put). */
213
219
  readonly conditionalPut?: boolean;
220
+ /**
221
+ * Optional: `true` when `delete` applies {@link StorageDeleteOptions.ifVersion}. The driver then removes an object
222
+ * only while it is still the one that version names, by a check the backend applies in the same step as the removal
223
+ * (a precondition on the request, or a check and a removal with nothing between them), refuses another object under
224
+ * the key with {@link WriteConflictError}, and reports a `version` on every {@link IStorageDriver.getTail} of an
225
+ * object. `false` or absent: `delete` ignores `ifVersion` and removes whatever is under the key, and a caller that
226
+ * decided to delete one object can remove another stored under the key since.
227
+ *
228
+ * A driver that learns this from its client may report `false` until its first delete given `ifVersion`; what the
229
+ * delete does is what it states. The S3 driver does: it reads where the client sends its requests, and whether the
230
+ * SDK sends the header, on its first such delete, and reports `false` for good when that cannot be found out.
231
+ */
232
+ readonly conditionalDelete?: boolean;
233
+ }
234
+ /** What {@link IStorageDriver.getTail} read. */
235
+ export interface TailRead {
236
+ /** The last `min(maxBytes, size)` bytes of the object. */
237
+ readonly bytes: Uint8Array;
238
+ /** The object's true total size, not the number of bytes returned. */
239
+ readonly size: number;
240
+ /**
241
+ * Optional: which object the read saw, as the backend names it (an S3 or Azure Blob ETag, a GCS object generation),
242
+ * taken from the response that carried `bytes`, or from the metadata read when no bytes were asked for. Opaque, and
243
+ * compared by equality only, by the driver that reported it. It names the object, not the key: two objects stored
244
+ * one after the other under one key carry different versions, except where the backend's version is computed from
245
+ * the bytes (an S3 ETag of an object stored without SSE-KMS or SSE-C is), where two objects with the same bytes, each
246
+ * stored whole in one request, or each in parts of the same sizes, share one. A driver that reports
247
+ * `conditionalDelete: true` reports it on every read; another may omit it.
248
+ */
249
+ readonly version?: string;
250
+ }
251
+ /** Options of {@link IStorageDriver.delete}. */
252
+ export interface StorageDeleteOptions {
253
+ /**
254
+ * Delete the object only while it is the one this version names: the `version` a {@link IStorageDriver.getTail} of
255
+ * it reported. Pass the one read when the decision to delete was made, never one read after it, or the condition
256
+ * names whatever is under the key by then. Applied only by a driver that reports
257
+ * {@link StorageCaps.conditionalDelete}; ignored by any other, and absent, the delete is unconditional.
258
+ */
259
+ readonly ifVersion?: string;
214
260
  }
215
261
  /**
216
262
  * Immutable object storage for `.crbm` generations. A "dumb byte mover": it
@@ -239,9 +285,27 @@ export interface StorageCaps {
239
285
  * negative or non-integer offset or length. Never a clamped, short or adjacent read. A pin whose object
240
286
  * was purged and loaded again as a smaller one asks for a range past its end, and this error is what sends it
241
287
  * to check that the object is still the one it pinned.
242
- * - **`getTail` reports the object's true total size**, not the number of bytes it returned.
288
+ * - **`getTail` reports the object's true total size**, not the number of bytes it returned, and, where the driver has
289
+ * one, the object's `version` ({@link TailRead.version}) from the same response as the bytes.
243
290
  * - **`delete` is idempotent.** Deleting an absent key is a no-op, not an error: collection passes race each
244
- * other and retry.
291
+ * other and retry. That holds with `ifVersion` too: an absent object is a no-op whatever version is given.
292
+ * - **`delete` with `ifVersion` does what {@link StorageCaps.conditionalDelete} says.** A driver that reports `true`
293
+ * removes the object only while it is the one the version names, by a check the backend applies in the same step as
294
+ * the removal (S3 `If-Match`, GCS `ifGenerationMatch`, Azure Blob `ifMatch`; in memory, the check and the removal with
295
+ * no await between them), and for another object under the key throws {@link WriteConflictError} and leaves it. A
296
+ * check made by reading the object first and deleting after is not one step, and does not qualify. Where the service
297
+ * answers a failed precondition for a key with no object, the driver looks again before it reports a conflict, so an
298
+ * absent object stays a no-op. A driver that reports `false`, or omits it, ignores `ifVersion` and deletes whatever is
299
+ * under the key. A number can be taken again once its object is deleted, so this is what keeps a delete decided from
300
+ * one read, and delayed, from removing an object stored under the number since: the erasure's delete of a holder
301
+ * above the pointer passes the version it read when it searched that object, and a refused load's delete of an
302
+ * object it proved its own by a footer read passes the version of that read.
303
+ * - **A driver that wraps another forwards `ifVersion` and the `version` of a tail read, or reports
304
+ * `conditionalDelete: false`.** A wrapper that hands `delete` the key alone, and reports the inner driver's
305
+ * capability, makes every conditional delete through it unconditional without a word.
306
+ * - **A conditional delete may be sent again**, by the backend client's retry, after a lost response: a copy that meets
307
+ * its own landed delete finds the object absent, which is its success, and one that meets an object stored since is
308
+ * refused. It can remove nothing the first copy could not.
245
309
  * - **`list` is strongly consistent, read-after-delete.** Once `delete` has resolved, `list` no longer yields
246
310
  * that generation, and once `putImmutable` has resolved it does. The erasure's re-check for a generation still
247
311
  * holding the id, `dropSegment`'s `generationsRemaining`, the retention sweep's check that a tombstone's storage
@@ -276,15 +340,16 @@ export interface IStorageDriver {
276
340
  */
277
341
  getRange(key: GenKey, offset: number, length: number): Promise<Uint8Array>;
278
342
  /**
279
- * Speculative tail read: the last `min(maxBytes, size)` bytes + the object's **true total size**. A missing
280
- * object throws {@link NotFoundError}.
343
+ * Speculative tail read: the last `min(maxBytes, size)` bytes + the object's **true total size**, and its `version`
344
+ * where the driver has one ({@link TailRead}). A missing object throws {@link NotFoundError}.
281
345
  */
282
- getTail(key: GenKey, maxBytes: number): Promise<{
283
- bytes: Uint8Array;
284
- size: number;
285
- }>;
286
- /** Idempotent: deleting an absent key is a no-op, not an error. */
287
- delete(key: GenKey): Promise<void>;
346
+ getTail(key: GenKey, maxBytes: number): Promise<TailRead>;
347
+ /**
348
+ * Idempotent: deleting an absent key is a no-op, not an error. With `options.ifVersion`, on a driver that reports
349
+ * {@link StorageCaps.conditionalDelete}, the object is removed only while it is the one that version names, and
350
+ * another object under the key is left and the delete throws {@link WriteConflictError}; any other driver ignores it.
351
+ */
352
+ delete(key: GenKey, options?: StorageDeleteOptions): Promise<void>;
288
353
  /**
289
354
  * Enumerate the generations present for a segment (orphan sweep / latest-gen resolution). Strongly
290
355
  * consistent, read-after-delete and read-after-put.
@@ -307,10 +372,12 @@ export type RegistryStatus = 'active' | 'destroyed';
307
372
  */
308
373
  export type GovernanceMeta = Record<string, unknown>;
309
374
  /**
310
- * A cached description of one generation, carried on the registry row: how many ids it holds, and the metadata it
311
- * was loaded with. The generation's `.crbm` object stays the truth; this is a copy, written by the same write that
312
- * moves the pointer, and it names the generation it describes so a copy left behind by a writer that did not
313
- * carry it can never be taken for another generation's.
375
+ * A cached description of one generation, carried on the registry row: how many ids it holds, the metadata it was
376
+ * loaded with, and the fingerprint of its object (its size and footer checksum). The generation's `.crbm` object stays
377
+ * the truth; this is a copy, written by the same write that moves the pointer, and it names the generation it describes
378
+ * so a copy left behind by a writer that did not carry it can never be taken for another generation's. The fingerprint
379
+ * names the object too: a reader checks every object it opens for the row's generation against it, and refuses one
380
+ * that is another object under that number.
314
381
  *
315
382
  * Two shapes. On a cleartext segment the values are in the clear. On an encrypted segment (a row with
316
383
  * `wrappedDeks`) they are sealed under the segment's data key, as the generation's index is, so the row reveals
@@ -337,6 +404,12 @@ export interface ClearRegistrySummary {
337
404
  readonly generation: number;
338
405
  /** How many ids the generation holds: an integer from 0 to 2^32. */
339
406
  readonly cardinality: number;
407
+ /**
408
+ * The fingerprint of the generation's object, `<size>:<footer checksum>` in decimal, as `CrbmReader.fingerprint` and
409
+ * a pin's `pinnedAt.fingerprint` spell it: the object's size in bytes, which `stat()` reports, and the CRC32C its
410
+ * footer stores.
411
+ */
412
+ readonly fingerprint: string;
340
413
  /** The generation's metadata, when it has any. Never the empty object. */
341
414
  readonly metadata?: GenerationMetadata;
342
415
  }
@@ -345,8 +418,9 @@ export interface SealedRegistrySummary {
345
418
  /** The generation this describes. A non-negative safe integer. */
346
419
  readonly generation: number;
347
420
  /**
348
- * Base64 of `nonce(12) ‖ ciphertext ‖ tag(16)`, sealing the cardinality as a little-endian u64 followed by the
349
- * metadata's canonical JSON, if any. The count is fixed-width, so the length reveals only the metadata's size.
421
+ * Base64 of `nonce(12) ‖ ciphertext ‖ tag(16)`, sealing the cardinality as a little-endian u64, the object's size as a
422
+ * little-endian u64 and its footer checksum as a little-endian u32 (its fingerprint), then the metadata's canonical
423
+ * JSON, if any. The numbers are fixed-width, so the length reveals only the metadata's size.
350
424
  */
351
425
  readonly sealed: string;
352
426
  }
@@ -430,12 +504,23 @@ export interface RegistryRecord extends SegmentRef {
430
504
  * restored from a backup. It holds with overwhelming probability rather than by construction, since the token carries random parts: a
431
505
  * 128-bit incarnation id drawn when the row is created, and a 64-bit part drawn for each write. Two incarnations of one
432
506
  * name meet with probability 2^-128 for any pair (about n^2 / 2^129 among n of them), and two writes at one counter,
433
- * after a restore, with probability 2^-64. A bare decimal token, on a row no 0.12 or later registry has written,
434
- * carries neither part.
507
+ * after a restore, with probability 2^-64.
435
508
  */
436
509
  readonly token: Token;
510
+ /**
511
+ * The token of the most recent write that named a field a read resolves through: the row's create, or a compare-and-swap
512
+ * whose patch named `currentGen`, `status`, `wrappedDeks`, `keyId` or `summary` (the resolved fields), at any value,
513
+ * the one the field already had included. A write that names only `leases`, `retention`, `residency` or `keptGens`
514
+ * leaves it. The registry sets it; a caller never does. Compared by equality only, and never reused under one name,
515
+ * since a token is not: a reader keys what it caches on a generation with it, so a lease or a policy write leaves a
516
+ * warm reader's cache standing, and a write that changes the resolution never does.
517
+ */
518
+ readonly pointerId: Token;
437
519
  }
438
- /** The caller-settable fields at {@link IRegistryDriver.create} (audit + token are driver-managed). */
520
+ /**
521
+ * The caller-settable fields at {@link IRegistryDriver.create} (audit, `token` and `pointerId` are driver-managed: the new
522
+ * row's `pointerId` is the token the create returns).
523
+ */
439
524
  export interface NewRegistryRecord {
440
525
  /** `null` ⇒ the segment has no Storage generation yet — see {@link RegistryRecord.currentGen}. */
441
526
  readonly currentGen: number | null;
@@ -451,13 +536,18 @@ export interface NewRegistryRecord {
451
536
  readonly keptGens?: readonly number[];
452
537
  }
453
538
  /**
454
- * Fields a {@link IRegistryDriver.compareAndSwap} may mutate (identity + audit + token are off-limits).
539
+ * Fields a {@link IRegistryDriver.compareAndSwap} may mutate (identity, audit, `token` and `pointerId` are
540
+ * off-limits).
455
541
  *
456
542
  * Presence-based: a field the patch does not mention is left as it was. `summary` is the one exception, because it
457
543
  * describes the current generation: a patch that moves `currentGen` and does not mention `summary` drops the old
458
544
  * one rather than keep a description of another generation. A `summary` the patch gives must name the
459
545
  * `currentGen` the row will have. `keptGens` follows the pointer the same way: a patch that moves `currentGen` and does
460
546
  * not mention it drops it, and one that gives it must name only generations below the `currentGen` the row will have.
547
+ * "Moves" means changes value: a patch that names `currentGen` at the value it has keeps both.
548
+ *
549
+ * A patch that names a resolved field (`currentGen`, `status`, `wrappedDeks`, `keyId`, `summary`), by presence and at any
550
+ * value, sets the row's {@link RegistryRecord.pointerId} to the token the write returns; any other patch leaves it.
461
551
  */
462
552
  export type RegistryPatch = Partial<Pick<RegistryRecord, 'currentGen' | 'wrappedDeks' | 'keyId' | 'status' | 'retention' | 'residency' | 'summary' | 'keptGens' | 'leases'>>;
463
553
  /**
@@ -492,9 +582,7 @@ export interface RegCaps {
492
582
  * `true` when this registry's `delete` removes a row from its backend for good, so that no later `list` reads it, and
493
583
  * removes it only while it is still the exact version the delete read: by a delete the backend applies under a
494
584
  * precondition (an S3 or Azure Blob ETag, a GCS object generation), or under a lock no other writer of the backend
495
- * can take. Only a row whose token carries an incarnation id is removed so. A row first written by a release before
496
- * 0.12 has a bare decimal token, and a delete still tombstones it: a process on that release, re-creating the name
497
- * over nothing, would issue those counters again from 0, so its row could not be told apart from the deleted one.
585
+ * can take.
498
586
  *
499
587
  * `false` or absent: every `delete` leaves a tombstone, which every later full `list` still reads. A shipped registry
500
588
  * says which it is.
@@ -613,6 +701,10 @@ export declare function isStorageBackend(value: unknown): value is StorageBacken
613
701
  * keeps going; where nothing of the earlier row is left, or the row is restored from a backup to an older counter, the
614
702
  * random parts alone keep the tokens apart: with probability 1 - 2^-128 per pair of incarnations, and 1 - 2^-64 per
615
703
  * pair of writes at one counter.
704
+ * - **Every row carries its `pointerId`** ({@link RegistryRecord.pointerId}): `create` sets it to the token it returns,
705
+ * a `compareAndSwap` whose patch names a resolved field (`renewsPointer` in `@cloudbitmaps/core/driver-kit`) sets it
706
+ * to the token that write returns, and every other write leaves it. `get` and `list` return it. A caller's write
707
+ * never sets it. A row returned without one fails every read of the segment with {@link UnsupportedError}.
616
708
  * - **`delete` is idempotent** without an `expected` token: deleting an absent row is a no-op, not an error.
617
709
  * **With `expected` it is fenced**: it lands only while the row still carries that token, and otherwise throws
618
710
  * {@link WriteConflictError} and leaves the row.
@@ -666,8 +758,8 @@ export interface IRegistryDriver {
666
758
  */
667
759
  list(namespace?: string): AsyncIterable<RegistryRecord>;
668
760
  /**
669
- * Remove the row. A registry whose {@link RegCaps.conditionalDelete} is `true` removes a row whose token carries an
670
- * incarnation id from its backend; every other row is tombstoned. Either way a later `create` gets a token no
761
+ * Remove the row. A registry whose {@link RegCaps.conditionalDelete} is `true` removes it from its backend; any other
762
+ * tombstones it. Either way a later `create` gets a token no
671
763
  * earlier incarnation of the name held, but for a collision of probability 2^-128 per pair of incarnations.
672
764
  *
673
765
  * Without `expected`, idempotent: deleting an absent row is a no-op.
@@ -1,7 +1,6 @@
1
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.
2
+ * The storage reader's defaults. `@cloudbitmaps/tools` prices what two of them cause and keeps its own copies, which
3
+ * `tests/tools/store-defaults.test.ts` holds equal to these.
5
4
  */
6
5
  /** Default TTL (ms) for re-resolving a segment's `currentGen` — the bound on post-publish read staleness. */
7
6
  export declare const DEFAULT_CURRENT_GEN_TTL_MS = 2000;
@@ -16,3 +15,15 @@ export declare const REFRESH_RETRY_MS = 500;
16
15
  export declare const DEFAULT_MAX_OPEN_SEGMENTS = 1024;
17
16
  /** Default aggregate ceiling (bytes) on resident parsed indices in the reader cache — the steady-state byte bound. */
18
17
  export declare const DEFAULT_MAX_OPEN_INDEX_BYTES: number;
18
+ /**
19
+ * How many segments' resolutions a store keeps per reader it keeps open: the resolution cache's count bound is this
20
+ * times `cache.readerMax` (8,192 by default). A resolution is the few hundred bytes of a row a read resolves through,
21
+ * kept for `cache.genTtlMs` whether or not the segment's reader is still open, so a store reading more segments at once
22
+ * than its readers hold keeps more of them than it keeps readers.
23
+ */
24
+ export declare const RESOLUTIONS_PER_OPEN_SEGMENT = 8;
25
+ /**
26
+ * The resolution cache's byte bound is `cache.readerMaxBytes` divided by this (4 MiB by default): what it holds is a
27
+ * sixteenth of what the readers may.
28
+ */
29
+ export declare const RESOLUTION_BYTES_DIVISOR = 16;
@@ -95,7 +95,8 @@ export interface RollbackResult {
95
95
  * `currentGen`, where generation collection never looks. They stay until loads pass them (the first load whose
96
96
  * number one of them holds numbers above them all, and collection then keeps the newest `keep` of what is below its
97
97
  * pointer), `dropSegment` deletes them, or a subject erasure
98
- * deletes them: all of those present when it rewrites, and only those holding the id when the current generation does not.
98
+ * deletes them: all of those present when it rewrites, and, when the current generation does not hold the id, only those
99
+ * holding it and any no read of the segment can open, whatever the id.
99
100
  * Not collecting them is a deliberate trade: an operator who has just undone a bad load should not have the
100
101
  * evidence collected out from under them, while a rollback target that still holds erased data would make the
101
102
  * erasure undoable by an ordinary operator action.