@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
@@ -4,11 +4,18 @@
4
4
  *
5
5
  * There is no per-id delete on an immutable object, and there is no mutable tier to hold a tombstone, so erasure
6
6
  * is what every other write in this library is: a new generation. The current generation is streamed chunk by
7
- * chunk through the ascending writer — every chunk copied through, the one chunk holding the id re-encoded with
8
- * that bit cleared — then published fenced on the generation it streamed, and that generation is collected immediately
7
+ * chunk through the ascending writer — every chunk decoded, range-checked and re-encoded, the one holding the id
8
+ * with that bit cleared — then published fenced on the generation it streamed, and that generation is collected immediately
9
9
  * (`keep: 0`), so the bit is **physically gone from the bucket when this returns**. Constant memory: one chunk in
10
10
  * flight, never the whole segment.
11
11
  *
12
+ * **`erased: true` is a claim about every generation of the segment, not only the one it replaced.** A rollback
13
+ * leaves generations above the pointer that were once current and can be made current again, so a holder can sit
14
+ * above the pointer as well as below it, and there can be several. Before it reports `erased: true` the call
15
+ * therefore lists the bucket once more and reads every generation it has not already seen to be clean; any that
16
+ * still holds the id makes it report or throw instead. In the ordinary case that list returns one generation, the
17
+ * current one, and costs one `list`.
18
+ *
12
19
  * What it does NOT reach: backups, replicas and noncurrent object versions hold the old object until their own
13
20
  * lifecycle removes it. For an at-rest guarantee that survives those, use encryption plus crypto-shred
14
21
  * (`destroySegment`), which is per segment — a per-id shred is infeasible because one DEK covers the whole segment.
@@ -39,10 +46,12 @@
39
46
  * signal of that state, so a faulting re-read rethrows it rather than replacing it with a transient-looking
40
47
  * registry error.
41
48
  *
42
- * **No orphan is left behind by a refused rewrite**, and it is worth saying why, because nothing else collects
43
- * above the pointer: `putImmutable` commits atomically, so a rewrite whose stream throws leaves no object at all;
44
- * and if the write did complete, the winner's generation is necessarily higher, which puts ours below its pointer
45
- * where its `keep: 0` takes it. The two orderings are mutually exclusive.
49
+ * **A refused rewrite deletes its own object when it would outlive the winner above the pointer.** `putImmutable`
50
+ * commits atomically, so a rewrite whose stream throws leaves no object at all. One that completed is either
51
+ * below the winner's pointer, where the winner's collection takes it, or above it: the case where this call took
52
+ * its number after the winner's object was already in the bucket. Up there nothing collects it, and it was derived
53
+ * from a generation the winner has replaced, so when the winner was another erasure it still holds the id that
54
+ * erasure has just reported gone. That object is deleted before this call returns — see `discardRefused`.
46
55
  *
47
56
  * Without that fence the failure was silent and severe, and both halves were reproduced: `nextGeneration` picks a
48
57
  * number above everything in the bucket, so a forward-only publish here always won — discarding a concurrent
@@ -50,9 +59,12 @@
50
59
  * generation put the first one's id back and collected the generation that had evidenced its removal. A false
51
60
  * Art. 17 receipt is the worst output this module can produce.
52
61
  *
53
- * Readers pinned to the collected generation re-resolve on their next read; that is the documented cost of
54
- * physical deletion on return. **Do not re-load the id while erasing it**: a load that lands after this rewrite
62
+ * A live read still fetching from the collected generation heals forward to the rewrite, and a pin of it fails with
63
+ * `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
55
64
  * carries whatever its source held, and the library cannot know that source was meant to exclude the id.
65
+ * A load already in flight is the same case: it writes its object above the pointer before it publishes, and an
66
+ * erasure that finds the id in that object deletes it as it does any holder there, so a load that then publishes
67
+ * leaves the pointer naming a missing object.
56
68
  */
57
69
  import { type IAuditSink } from './audit.js';
58
70
  import type { CodecInterface } from './codec.js';
@@ -74,9 +86,9 @@ export interface EraseIdDeps {
74
86
  * -bound AAD. Rewriting an encrypted segment without a keystore throws {@link KeyUnavailableError}.
75
87
  */
76
88
  readonly keystore?: IKeystore;
77
- /** When true, refuse to rewrite a **cleartext** segment (the same guard the read path and bulk-load offer). */
89
+ /** When true, refuse to rewrite a **cleartext** segment (the same guard the read path and a load offer). */
78
90
  readonly requireEncryption?: boolean;
79
- /** Supplying a clock that can yield makes the rewrite cooperative — see `bulkLoadCrbmGeneration`. */
91
+ /** Supplying a clock that can yield makes the rewrite cooperative, as it makes a load. */
80
92
  readonly clock?: Yielder;
81
93
  /** Per-chunk decode ceiling (invariant 5); defaults to 1 MiB. */
82
94
  readonly maxBitmapBytes?: number;
@@ -85,57 +97,80 @@ export interface EraseIdResult {
85
97
  readonly segment: string;
86
98
  readonly namespace?: string;
87
99
  /**
88
- * True iff the id **was** a member and a generation without it is now the segment's current generation — and
89
- * the generation that held it has been deleted (see `collected`). This is the field an erasure ledger records.
100
+ * True iff the id **was** in the segment — in its current generation, or in any other generation still in the
101
+ * bucket, above the pointer or below it — and **no generation of the segment holds it now**. The current
102
+ * generation is one without it, every generation that held it has been deleted (see `collected`), and the call
103
+ * listed the bucket and read what was left before saying so. This is the field an erasure ledger records.
90
104
  */
91
105
  readonly erased: boolean;
92
106
  /**
93
- * Why nothing was rewritten, when `erased` is false. `'absent'` (no registry row), `'destroyed'` (a crypto-shred
94
- * tombstone — already unreadable), `'no-generation'` (a row with no Storage data yet), `'not-member'` (the id is not
95
- * in the current generation — the common case across a fleet scan), or `'superseded'`.
107
+ * Why the id was not erased, when `erased` is false. `'absent'` (no registry row), `'destroyed'` (a crypto-shred
108
+ * tombstone — already unreadable), `'no-generation'` (a row with no Storage data yet), `'not-member'` (no
109
+ * generation in the bucket holds the id — the common case across a fleet scan), or `'superseded'`.
96
110
  *
97
111
  * **`'superseded'` means this call did not erase the id, not that the id is still there.** Another writer — a
98
- * load, or another erasure — moved the pointer off the generation this rewrite was derived from, so the
99
- * rewrite is not a valid successor to what is now current. Re-run against the new generation: if the id is
100
- * still present it is erased then; if the racing writer was another erasure of the *same* id, the re-run
101
- * reports `'not-member'` because it is already gone. Either way the re-run settles it, which is why it is the
102
- * documented action for this reason and for no other.
112
+ * load, another erasure, or a rollback — moved the pointer off the generation this call read, so what it was
113
+ * doing no longer follows from what is current: a rewrite derived from that generation is not a valid successor
114
+ * to the new one, and a generation it meant to delete above the pointer may be the one the pointer now names.
115
+ * Re-run against the new generation: if the id is still present it is erased then; if the racing writer was
116
+ * another erasure of the *same* id, the re-run reports `'not-member'` because it is already gone. Either way the
117
+ * re-run settles it, which is why it is the documented action for this reason and for no other.
103
118
  *
104
- * The first four reasons are also returned when the row changes *underneath* a rewrite already in flight,
105
- * not only when it is read up front — a concurrent `dropSegment` gives `'destroyed'`, a retention sweep that
106
- * purges the row gives `'absent'`. A caller branching on `reason` never has to care which it was.
119
+ * The first three reasons are also returned when the row changes *underneath* a call already in flight, not
120
+ * only when it is read up front — a concurrent `dropSegment` gives `'destroyed'`, a retention sweep that purges
121
+ * the row gives `'absent'`. A caller branching on `reason` never has to care which it was.
107
122
  */
108
123
  readonly reason?: 'absent' | 'destroyed' | 'no-generation' | 'not-member' | 'superseded';
109
- /** The generation the id was found in (present whenever it was read). */
124
+ /**
125
+ * The generation the id was found in (present whenever one was read) — the newest of them, when the current
126
+ * generation did not hold it and several others did.
127
+ */
110
128
  readonly fromGeneration?: number;
111
- /** The generation written without the id (present whenever one was written, even if `superseded`). */
129
+ /**
130
+ * The generation written without the id (present whenever one was written, even if `superseded`). A refused
131
+ * rewrite whose object sits above the winner's pointer deletes that object again before it returns.
132
+ */
112
133
  readonly generation?: number;
113
134
  /**
114
- * Generations deleted after the publish — normally `[fromGeneration]`, plus any older orphans. Empty when nothing
115
- * was rewritten. **It is what THIS call deleted, not the proof that the id is gone** — those differ: a
116
- * concurrent collector can take the generation holding the id first, and then `erased: true` is returned with
117
- * `collected` empty, because the claim is about the bucket rather than about who emptied it. Treat a
118
- * non-empty `collected` as evidence and an empty one as "someone else got there", never as a failure.
135
+ * Generations this call deleted. On a rewrite that is normally `[fromGeneration]`, plus any older orphans. When
136
+ * the current generation did not hold the id, it is every generation below the pointer (`keep: 0` takes them
137
+ * together) and each one above the pointer that held the id; the ones above it that did not hold the id stay,
138
+ * because they are an operator's rollback targets. Empty when nothing was deleted.
119
139
  *
120
- * A call that cannot establish the claim **throws** rather than report `erased: true` over bytes that are
121
- * still there. Three ways that happens: a Storage `delete` fault, a collection pass that could not prove the
122
- * segment was still the same one (`WriteConflictError`, which an ordinary retirement landing mid-call is
123
- * enough to cause), and the same refusal on the collect-only path below, where nothing was published at all.
140
+ * **It is what THIS call deleted, not the proof that the id is gone** — those differ: a concurrent collector can
141
+ * take a generation holding the id first, and then `erased: true` is returned without it in `collected`, because
142
+ * the claim is about the bucket rather than about who emptied it. Treat a non-empty `collected` as evidence and
143
+ * a missing holder as "someone else got there", never as a failure.
124
144
  *
125
- * **Re-run it**, and read what the re-run says rather than assuming it finished the job. The pointer has
126
- * moved, so the re-run looks for the id in the *superseded* generations as well as the current one, and there
127
- * are three outcomes:
145
+ * A call that cannot establish the claim **throws** `WriteConflictError` or the underlying fault rather than
146
+ * report `erased: true` over bytes that are still there. Four ways that happens: a Storage `delete` fault; a
147
+ * collection pass that could not prove the segment was still the same one (which an ordinary retirement landing
148
+ * mid-call is enough to cause); the same refusal on the path where the current generation did not hold the id
149
+ * and nothing was published at all; and a generation still holding the id when the bucket is listed at the end
150
+ * — one an operator rolled the pointer onto while a rewrite was collecting, say.
128
151
  *
129
- * - `erased: true` naming the superseded generation it found the id in and collected — the ordinary case,
130
- * and the one that gives you the receipt the failed call could not;
152
+ * **Re-run it**, and read what the re-run says rather than assuming it finished the job. The re-run looks for
153
+ * the id in every generation in the bucket, not only the current one, and there are three outcomes:
154
+ *
155
+ * - `erased: true` naming the generation it found the id in — the ordinary case, and the one that gives you
156
+ * the receipt the failed call could not;
131
157
  * - `'not-member'` — a racing collector took that generation first. The bit is gone, but **no run reports a
132
158
  * receipt for it**, so keep the failed call's error alongside your ledger if you need the audit trail;
133
159
  * - `'absent'` — the segment's registry row is gone, so it is no longer a segment at all and
134
- * `store.eraseSubject` will not even scan it. Anything left in the bucket is an **orphan**: find it with
135
- * `checkConsistency()` and collect it with `gcOrphanGenerations`.
160
+ * `store.eraseSubject` will not even scan it. Anything left in the bucket is an **orphan**: list it with
161
+ * `listGenerations` (`store.generations`) and delete it with `dropSegment` (`store.dropSegment`), which both
162
+ * read the bucket as well as the row. `checkConsistency()` and the generation collection start from the row, so
163
+ * neither reaches a segment that has none.
136
164
  */
137
165
  readonly collected: readonly number[];
138
166
  }
167
+ /**
168
+ * Remove `id` from `ref`: rewrite its current generation without it, and delete every other generation that holds
169
+ * it. See the module note for the contract.
170
+ *
171
+ * Emits one `segment.rewrite` audit event at the publish (before the superseded generation is collected), so the
172
+ * compliance record exists the moment the generation without the id is authoritative.
173
+ */
139
174
  export declare function eraseIdFromSegment(ref: SegmentRef, id: number, deps: EraseIdDeps, options?: {
140
175
  audit?: IAuditSink;
141
176
  }): Promise<EraseIdResult>;
@@ -8,7 +8,7 @@
8
8
  * segment/namespace). A `destroyed` segment reads as empty (its DEK is gone). Only works on an **encrypted**
9
9
  * segment; a cleartext segment has no key to shred.
10
10
  *
11
- * The tombstone is also the fence every writer respects: `publishGeneration` and `bulkLoadCrbmGeneration` refuse
11
+ * The tombstone is also the fence every writer respects: a load refuses
12
12
  * a `destroyed` row, so a load racing an erasure cannot resurrect the segment. A single id's erasure is a different
13
13
  * operation — `eraseIdFromSegment` rewrites the generation without it.
14
14
  */
@@ -54,11 +54,18 @@ export declare function destroySegment(ref: SegmentRef, deps: EraseDeps, options
54
54
  /**
55
55
  * Crypto-shred every segment in a namespace. **Irreversible.** `confirmNamespace` must equal `namespace`.
56
56
  * Returns a per-segment result (skips cleartext segments unless `allowCleartext`).
57
+ *
58
+ * **Bounded.** It lists the whole namespace before it destroys anything, and holds the listing resident, so the
59
+ * listing is capped at `maxScanSegments` (default {@link DEFAULT_MAX_SCAN_SEGMENTS}, 250,000), the ceiling every
60
+ * other fleet scan keeps. A namespace over it throws `BudgetExceededError` **before any segment is destroyed**,
61
+ * never part-way through; raise `maxScanSegments` when the namespace really is that large and the memory is there.
62
+ * A `maxScanSegments` that is not a finite number >= 1 throws `ValidationError`, again before anything is destroyed.
57
63
  */
58
64
  export declare function eraseNamespace(namespace: string, deps: EraseDeps, options: {
59
65
  confirmNamespace: string;
60
66
  allowCleartext?: boolean;
61
67
  audit?: IAuditSink;
68
+ maxScanSegments?: number;
62
69
  }): Promise<{
63
70
  destroyed: DestroyResult[];
64
71
  }>;
@@ -82,12 +89,12 @@ export interface DropResult {
82
89
  * Generations still present in Storage when the sweep gave up, ascending. **Empty is the normal outcome** — a
83
90
  * non-empty value means the storage was NOT fully reclaimed and the drop should be re-run.
84
91
  *
85
- * This field exists because its absence was a defect. A drop used to return `dropped: true` with a populated
86
- * `generationsDeleted` and no `reason` even when an object holding the **complete set** had just been left in
87
- * the bucket by a writer that was already mid-write when the tombstone landed (its publish is refused, but the
88
- * object survives). For a cleartext segment those bytes are readable — and `gcOrphanGenerations` only collects
89
- * a destroyed segment's generations when something runs it. The result was indistinguishable from a clean
90
- * drop, so an operator got no signal to re-run. Now they do.
92
+ * Without it, a drop would return `dropped: true` with a populated `generationsDeleted` and no `reason` even
93
+ * when an object holding the **complete set** had just been left in the bucket by a writer that was already
94
+ * mid-write when the tombstone landed (its publish is refused, but the object survives). For a cleartext
95
+ * segment those bytes are readable — and the collection only takes a destroyed segment's generations
96
+ * when something runs it. That result would look like a clean drop, and an operator would get no signal to
97
+ * re-run.
91
98
  */
92
99
  readonly generationsRemaining: readonly number[];
93
100
  /**
@@ -136,9 +143,8 @@ export interface DropResult {
136
143
  * - `dropSegment` **removes the storage**. It works on a cleartext segment, and on an encrypted one it *also*
137
144
  * drops the DEKs, so it is a strict superset there.
138
145
  *
139
- * Before this existed there was no supported way to delete a segment and stop paying for it, and the obvious
140
- * workaround — an object-store lifecycle rule on the key prefix — deletes the bytes while the registry still
141
- * points at them. That is exactly the `missing-storage-generation` state the DR runbook says not to serve traffic
146
+ * It is the supported way to delete a segment and stop paying for it. The obvious workaround — an object-store
147
+ * lifecycle rule on the key prefix — deletes the bytes while the registry still points at them. That is exactly the `missing-storage-generation` state the DR runbook says not to serve traffic
142
148
  * on, and it surfaces *intermittently*, because a read consults the cache before Storage: cached chunks answer
143
149
  * correctly and evicted ones throw. The whole value of this function is that the ordering below cannot be got
144
150
  * wrong by a caller.
@@ -146,8 +152,8 @@ export interface DropResult {
146
152
  * **THE ORDER IS THE CONTRACT — registry first, then Storage.**
147
153
  *
148
154
  * 1. **Registry first.** After the tombstone nothing resolves a generation for this segment, so no reader can
149
- * reach for bytes that are about to disappear, and no writer can publish onto it (`publishGeneration` and
150
- * `bulkLoadCrbmGeneration` refuse a `destroyed` row).
155
+ * reach for bytes that are about to disappear, and no writer can publish onto it (a
156
+ * load refuses a `destroyed` row).
151
157
  * 2. **Storage second, best-effort, and re-swept.** Once the pointer is a tombstone the segment resolves as empty,
152
158
  * so a failure part-way through leaves **orphaned bytes, not a wrong answer.** Orphans cost money and are
153
159
  * cleaned up by re-running; a torn pointer costs correctness and is not self-healing. Given the choice, leak
@@ -163,10 +169,10 @@ export interface DropResult {
163
169
  * cache for a window. A fresh store, or any reader that had not touched the segment, sees empty at once.
164
170
  *
165
171
  * That window is bounded by `cache.genTtlMs` (default 2 s) **only for a reader whose storage source has both a clock
166
- * and a registry and a positive TTL** — expiry needs all three. A source built without a clock, or with
167
- * `cache.genTtlMs: 0` (documented as "pin forever"), holds its resolved snapshot for its own lifetime; because a
168
- * cache-LRU hit never reaches Storage, such a reader can answer `true` for a dropped segment **indefinitely** and must
169
- * be restarted. This is the *same* caching that makes the delete-bytes-first ordering fail intermittently rather
172
+ * and a registry and a positive TTL** — expiry needs all three. A source built without a clock or a registry, or
173
+ * with `cache.genTtlMs: 0`, has no timed refresh: it notices the drop only when a read has to fetch from a deleted
174
+ * generation or its reader cache evicts the segment. Because a cache-LRU hit never reaches Storage, such a reader
175
+ * can answer `true` for a dropped segment **indefinitely**, and must be invalidated or restarted. This is the *same* caching that makes the delete-bytes-first ordering fail intermittently rather
170
176
  * than loudly — it cuts both ways.
171
177
  *
172
178
  * `confirmSegment` must equal `ref.segment`, matching `destroySegment`/`eraseNamespace`. For an automated
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Typed errors — callers learn *why* something failed, never by parsing strings. Retry is the driver
3
- * decorators' job, not the engine's: a driver classifies its backend's failures into this vocabulary, and
4
- * `withRetry` decides what is transient.
2
+ * Typed errors — callers learn *why* something failed, never by parsing strings. Retry is not the engine's job: a
3
+ * driver classifies its backend's failures into this vocabulary, and the store's read wrapper retries the
4
+ * transient ones through `withRetry`.
5
5
  */
6
6
  /**
7
7
  * Registry-symbol brands, so an error stays classifiable when the class object is not shared.
@@ -36,16 +36,16 @@ export declare class WriteConflictError extends CloudRoaringError {
36
36
  export declare class IntegrityError extends CloudRoaringError {
37
37
  }
38
38
  /**
39
- * A requested object/row does not exist. Part of the driver error vocabulary; thrown by
40
- * the persistent drivers (S3, GCS, Azure Blob, local filesystem) — the in-memory drivers return
41
- * `null` instead.
39
+ * A requested object/row does not exist. Part of the driver error vocabulary: every storage driver this
40
+ * library ships, the in-memory one included, throws it for a read of a generation object that is not there.
41
+ * A registry `get` of a row that is not there returns `null` instead.
42
42
  */
43
43
  export declare class NotFoundError extends CloudRoaringError {
44
44
  }
45
45
  /**
46
46
  * This build/configuration cannot perform the requested operation, though nothing is malformed. Two uses:
47
- * (1) **format** — the bytes are well-formed but unreadable here (an unknown `.crbm` major version, an
48
- * encrypted file before the crypto path exists) — distinct from `IntegrityError` (corruption); and (2)
47
+ * (1) **format** — the bytes are well-formed but unreadable here (an unknown `.crbm` major version) —
48
+ * distinct from `IntegrityError` (corruption); and (2)
49
49
  * **store configuration** — an operation this store's wiring doesn't support (e.g. a lifecycle helper like
50
50
  * `eraseSubject`/`retireExpired` called on a store built without a storage backend). Raised at
51
51
  * operation time, before any mutation.
@@ -79,8 +79,9 @@ export declare class BudgetExceededError extends CloudRoaringError {
79
79
  export declare class KeyUnavailableError extends CloudRoaringError {
80
80
  }
81
81
  /**
82
- * A **transient** infrastructure fault that is safe to retry — throttling, a 5xx, a dropped connection,
83
- * a client-side request timeout. Drivers classify their backend's retryable faults and raise this (the
82
+ * A **transient** infrastructure fault — throttling, a 5xx, a dropped connection, a client-side request
83
+ * timeout. Retrying a read that failed this way is safe; a write that failed this way may still have landed, so
84
+ * its caller re-runs the call rather than replaying the request. Drivers classify their backend's retryable faults and raise this (the
84
85
  * SDK-specific knowledge stays in the SDK-specific driver); the retry layer (`core/retry`) retries **only**
85
86
  * this class, never a deterministic error like {@link ValidationError}, {@link IntegrityError},
86
87
  * {@link NotFoundError}, or {@link WriteConflictError} (retrying those is pointless or wrong). The original
@@ -91,25 +92,17 @@ export declare class KeyUnavailableError extends CloudRoaringError {
91
92
  * to log; if you serialize the whole error *chain*, be aware you're including that metadata.
92
93
  */
93
94
  export declare class TransientError extends CloudRoaringError {
94
- /** A second brand so the whole transient subtree (incl. {@link TimeoutError}) is classifiable cross-bundle. */
95
+ /** A second brand so a transient fault is classifiable cross-bundle. */
95
96
  readonly [TRANSIENT_BRAND]: true;
96
97
  constructor(message: string, options?: {
97
98
  cause?: unknown;
98
99
  });
99
100
  }
100
- /**
101
- * A single attempt exceeded its time budget. Subclass of {@link TransientError} so the retry layer treats a
102
- * timeout as retryable by default — a stalled request often succeeds on a fresh connection. Raised by a
103
- * driver whose injected client reports a request timeout — setting one on your injected client is the
104
- * recommended way to bound a hang.
105
- */
106
- export declare class TimeoutError extends TransientError {
107
- }
108
101
  /** Any error thrown by CloudRoaring (any tier, any bundle). */
109
102
  export declare function isCloudRoaringError(err: unknown): err is CloudRoaringError;
110
103
  /** An OCC conditional write/delete lost the race — retry the read-modify-write, don't fail. */
111
104
  export declare function isWriteConflictError(err: unknown): err is WriteConflictError;
112
- /** A retryable transient infrastructure fault (incl. {@link TimeoutError}). The retry layer keys on this. */
105
+ /** A retryable transient infrastructure fault. The retry layer keys on this. */
113
106
  export declare function isTransientError(err: unknown): err is TransientError;
114
107
  /** A requested object/row does not exist. */
115
108
  export declare function isNotFoundError(err: unknown): err is NotFoundError;
@@ -30,11 +30,14 @@ export declare function nextGeneration(ref: SegmentRef, deps: GenerationDeps): P
30
30
  * a pin taken while it was still active re-checks the status on every open — and nothing else in the library
31
31
  * would ever collect them, so without this those objects are billed forever.
32
32
  *
33
- * **Throws {@link WriteConflictError} if the segment changed underneath the pass** — not to be confused with
34
- * the empty array it still returns when there was genuinely nothing to collect (no row at all, or no pointer
35
- * yet), which is why an empty array is not a receipt. The row is read before the listing and acted on after it, so a name that was purged and
33
+ * **Throws {@link WriteConflictError} when it cannot act on the row it read**: the row is gone when it is re-read,
34
+ * after the listing or before a delete; on an active segment, the pointer has fallen below the cutoff before a
35
+ * delete; on a `destroyed` segment, the row's token has changed. Not to be confused with the empty array it still
36
+ * returns when there was genuinely nothing to collect (no row at all, or no pointer yet), which is why an empty
37
+ * array is not a receipt. The row is read before the listing and acted on after it, so a name that was purged and
36
38
  * re-created in that window is a *different* segment wearing the same name, and its live object must not be
37
- * collected on the strength of the old row. Refusing is safe — the objects are not going anywhere and the next
39
+ * collected on the strength of the old row: on an active segment the lower of the two pointers is what keeps it
40
+ * safe, and on a `destroyed` one the token. Refusing is safe — the objects are not going anywhere and the next
38
41
  * pass reads a consistent row — but it has to be **distinguishable** from "there was nothing to collect", which
39
42
  * an empty array is not: `eraseIdFromSegment` reads the returned list as the physical half of its erasure
40
43
  * receipt, and would otherwise report `erased: true` over bytes still in the bucket. Re-run it.
@@ -45,7 +48,8 @@ export declare function nextGeneration(ref: SegmentRef, deps: GenerationDeps): P
45
48
  * residual, and this is what eventually collects it from the retention sweep.
46
49
  *
47
50
  * `keep: 0` is how a subject-erasure rewrite makes a bit **physically** gone on return: the generation that held
48
- * it is collected the moment the rewrite is current. A reader pinned to it re-resolves on its next read.
51
+ * it is collected the moment the rewrite is current. A live read still fetching from it heals forward to the
52
+ * rewrite, and a pin of it (`seg.pin()`) fails with `NotFoundError` for any chunk it has yet to read.
49
53
  */
50
54
  export declare function gcOrphanGenerations(ref: SegmentRef, deps: GenerationDeps, options?: {
51
55
  keep?: number;
@@ -3,14 +3,14 @@ export declare function segmentKey(ref: SegmentRef): string;
3
3
  export declare function segmentPrefix(ref: SegmentRef): string;
4
4
  export declare function chunkRefKey(ref: ChunkRef): string;
5
5
  /**
6
- * CACHE-cache key for a chunk scoped to a specific **version** of the segment — {@link chunkRefKey} plus that
6
+ * Cache key for a chunk scoped to a specific **version** of the segment — {@link chunkRefKey} plus that
7
7
  * version (space-delimited, injection-proof exactly as above — the name parts are already encoded). A publish bumps the version, so it naturally
8
8
  * misses the cache instead of serving a stale superseded chunk, and the superseded entries age out under the
9
9
  * LRU ceiling (no active purge needed).
10
10
  *
11
11
  * `version` is whatever the Storage source reports as identifying the bytes a read will see — its
12
12
  * `currentVersion` where it has one, otherwise the bare generation number. **A generation number alone is not
13
- * an identity**: `nextGeneration` restarts at 0 once a registry row is purged and the bucket emptied, so a
13
+ * an identity**: a load's generation number restarts at 0 once a registry row is purged and the bucket emptied, so a
14
14
  * retired-and-re-created name serves different data at the same `currentGen`, and a key built on the number
15
15
  * hands the new incarnation the old one's decoded chunks.
16
16
  */
@@ -2,9 +2,10 @@
2
2
  * `loadSegment` — the loaded store's primary write path, as one call.
3
3
  *
4
4
  * A load is always the same four steps: take the next generation number, write one immutable object, move the
5
- * pointer, collect what the move superseded. Done by hand they are `nextGeneration` + `bulkLoadCrbmGeneration` +
6
- * `publishGeneration` + `gcOrphanGenerations`, and the step people leave out is the last one — so stores that
7
- * compose it themselves accumulate superseded generations they keep paying for. Composing it here is the point.
5
+ * pointer, collect what the move superseded. Inside the library each is its own internal function, and the
6
+ * step a hand-composed load leaves out is the last one, so a store that composed it would accumulate superseded
7
+ * generations it keeps paying for.
8
+ * Composing it here, once, is the point.
8
9
  *
9
10
  * The other reason it is one call is the **guard**. A load REPLACES a segment: whatever the stream contains is
10
11
  * what the segment contains afterwards. That makes an upstream query returning fewer rows than usual — or none —
@@ -14,10 +15,12 @@
14
15
  *
15
16
  * Refusing therefore has to clean up after itself. The object is already durable at that point, and it sits
16
17
  * ABOVE `currentGen`, where generation collection deliberately never looks (it deletes strictly below the
17
- * pointer). Nothing else would ever reclaim it, so a refused load deletes its own object before returning.
18
+ * pointer), so a refused load deletes its own object before returning, while the segment's row is unchanged or
19
+ * gone. Once another write has changed the row, the object's number may name a re-created segment's live object,
20
+ * so it stays, and collection takes it like any other generation once one above it is current.
18
21
  */
19
22
  import { type IAuditSink } from './audit.js';
20
- import type { CodecInterface } from './codec.js';
23
+ import { type CodecInterface } from './codec.js';
21
24
  import type { Clock } from './determinism.js';
22
25
  import type { IKeystore } from './crypto.js';
23
26
  import type { IStorageDriver, IRegistryDriver, SegmentRef } from './ports.js';
@@ -59,19 +62,35 @@ export interface LoadOptions {
59
62
  readonly allowEmpty?: boolean;
60
63
  readonly guard?: LoadGuard;
61
64
  /**
62
- * Generations to keep below the new pointer as a grace window (default 1). Keep at least one: a read that is
65
+ * Generations to keep below the new pointer as a grace window (default 1). A non-negative integer: anything
66
+ * else (negative, fractional, `NaN`, infinite) throws `ValidationError` before anything is written. Keep at
67
+ * least one: a read that is
63
68
  * still fetching chunks from the just-superseded generation would otherwise have it deleted out from under it
64
- * and pay a re-resolve. A wider window costs storage and buys nothing a pinned read would not do better.
69
+ * and pay a re-resolve. A wider window costs storage, and it is what a pinned handle needs: a pin is never
70
+ * re-resolved, so a chunk a pinned read has not fetched fails with `NotFoundError` once its generation is
71
+ * collected. Keep at least one generation for every one that can be written above the pinned one while your
72
+ * longest pinned job runs, on every writer that loads the segment — see "Generations and `keep`" in the loading
73
+ * guide.
65
74
  */
66
75
  readonly keep?: number;
67
76
  readonly audit?: IAuditSink;
68
77
  }
69
78
  /** Why a load did not become current. */
70
79
  export type LoadRefusal = 'empty' | 'min-cardinality' | 'min-retained'
71
- /** Another writer published a higher generation first. The object was written, then removed. */
80
+ /**
81
+ * Another writer got there first. Either another load wrote the same generation number first, so the write-once
82
+ * put refused this one and it wrote nothing (`size: 0`); or the segment's registry row changed while the load was
83
+ * writing: another load published, a retention change, a rollback or an erasure wrote the row, or the row was
84
+ * deleted. Once another write has changed the row, the object stays in the bucket, because its generation number
85
+ * may by then name another incarnation's live object; once the row is gone, the object is deleted.
86
+ */
72
87
  | 'superseded';
73
88
  export interface LoadResult {
74
- /** The generation written. Present even when refused — it is what was deleted again. */
89
+ /**
90
+ * The generation written. Present even when refused: a refusal deletes that object while the segment's row is
91
+ * unchanged or gone, and leaves it in the bucket once another write has changed the row. A load that lost its
92
+ * generation number to another wrote nothing, and reports `size: 0`.
93
+ */
75
94
  readonly generation: number;
76
95
  /** Whether this generation is now the segment's current one. */
77
96
  readonly published: boolean;
@@ -11,8 +11,8 @@
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
13
13
  * caller-controlled strings that may be PII and are **unbounded-cardinality** — do not map them to
14
- * per-series metric labels unless they're known low-cardinality and PII-free. See the getting-started
15
- * "Observability" section.
14
+ * per-series metric labels unless they're known low-cardinality and PII-free. See the observability
15
+ * guide.
16
16
  */
17
17
  /** The segment operations that emit an `op` latency event (timed with the injected clock, at the facade). */
18
18
  export type MetricOpName = 'has' | 'count' | 'intersectInto' | 'unionInto' | 'andNotInto';
@@ -46,8 +46,7 @@ export type MetricEvent = {
46
46
  } | {
47
47
  readonly kind: 'intersect';
48
48
  /**
49
- * Which chunk-aligned combine this was. **Optional for backward compatibility** — absent means
50
- * `'intersect'`, which is all this event reported before `union`/`andNot` existed.
49
+ * Which chunk-aligned combine this was. **Optional** — absent means `'intersect'`.
51
50
  *
52
51
  * For `'union'` over its *include* operands alone, `skippedChunks` is 0 by construction — union reads
53
52
  * every chunk of every operand, so there is nothing to prune. It can still be non-zero when a union
@@ -69,6 +68,11 @@ export type MetricEvent = {
69
68
  /**
70
69
  * **Distinct** chunk-keys pruned — never fetched (the chunk-skipping saving). Counts distinct keys,
71
70
  * not per-operand GETs, so it under-states the true GET saving when 3+ operands partially overlap.
71
+ *
72
+ * On a read bounded by `after` / `through`, this and `fetchedChunks` count only the keys inside the range:
73
+ * the keys outside it are never considered, so they are not counted as pruned either. Both count the keys the
74
+ * call would fetch if read to the end, so a page that stops early reports every key up to `through`, or to the
75
+ * end of the segment when `through` is left out.
72
76
  */
73
77
  readonly skippedChunks: number;
74
78
  } | {
@@ -5,15 +5,12 @@
5
5
  * `node:` builtin — and because `validate.ts` needs it to measure a name against the key budget. Core may
6
6
  * not import a driver (invariant 7), and a driver may import core, so this is the only layer both can see.
7
7
  *
8
- * The old grammar was an allowlist — `[A-Za-z0-9._:-]`. Widening it one character at a time is what exposed the
9
- * real problem: the colon had been missing from it by accident rather than by decision, and nothing in the
10
- * process would have caught the next omission either. An allowlist rejects names for the storage layer's
11
- * convenience, and the storage layer's convenience is the library's job, not the caller's. So a name is **any
12
- * non-empty string**, and each physical boundary escapes what *it* cannot take literally.
13
- *
14
- * It also fixes a hazard the allowlist *permitted*: `con`, `nul` and `com1` are Windows device names that
15
- * validated cleanly and failed only on a user's machine. A grammar can be both too narrow and too permissive
16
- * at once, which is the argument against having one.
8
+ * A name is **any non-empty string**, and each physical boundary escapes what *it* cannot take literally. An
9
+ * allowlist of characters would reject names for the storage layer's convenience, and the storage layer's
10
+ * convenience is the library's job, not the caller's. It would also let through the names that are actually
11
+ * dangerous: `con`, `nul` and `com1` are Windows device names made only of letters and digits, which pass any
12
+ * allowlist and fail only on a user's machine. A grammar can be both too narrow and too permissive at once,
13
+ * which is the argument against having one.
17
14
  *
18
15
  * Percent-encoding, because it is the one escape everybody already reads. `%` escapes itself as `%25` and is
19
16
  * encoded **first**, which is what makes the transform injective: every `%` in an encoded string starts an
@@ -22,36 +19,30 @@
22
19
  * There are two alphabets, because the boundaries differ:
23
20
  *
24
21
  * - **Object keys** (S3, GCS, Azure Blob) take almost anything. They need `/` escaped so a name cannot
25
- * invent hierarchy or break a key parser that splits on it, `#` and `|` (see below), and control characters
26
- * because they are not legal in the XML an S3 LIST returns.
22
+ * invent hierarchy or break a key parser that splits on it, and control characters because they are not
23
+ * legal in the XML an S3 LIST returns. Every other character outside `[A-Za-z0-9._:-]` is escaped as well.
27
24
  * - **Filesystem paths** need all of that plus `:` (an NTFS alternate-data-stream separator, where a write can
28
25
  * *succeed* while `readdir` never lists the result), plus three hazards that are about the component as a
29
26
  * whole rather than its characters — see {@link encodeNameForPath}.
30
27
  *
31
- * **`#` and `|` are escaped for compatibility, not for any backend still here.** They were reserved because
32
- * the DynamoDB registry composed its partition key from `<namespace>#<segment>|…`, and that driver is gone.
33
- * The escaping stays anyway: it is baked into every key already written, and un-reserving the two characters
34
- * would change what `encodeNameForKey` emits for a name containing either — silently moving those segments
35
- * to a key nothing looks up. A cheap two-character reservation is the right price for not migrating a
36
- * bucket; the property test below pins it.
28
+ * **The key alphabet is fixed for good.** Adding a character to it would change what `encodeNameForKey` emits
29
+ * for every name containing that character, silently moving those segments to keys nothing looks up. A test pins
30
+ * it, one printable ASCII character at a time.
37
31
  *
38
- * **Every name that was legal before encodes to itself on the OBJECT-KEY alphabet** — so S3, GCS and Azure
39
- * keys are byte-identical and there is nothing to migrate there. That is the property to preserve if this
40
- * file is ever edited, and a property test asserts it over the whole old grammar.
32
+ * **A plain name encodes to itself on the OBJECT-KEY alphabet** — one made only of `[A-Za-z0-9._:-]` that
33
+ * starts with a letter or digit — so S3, GCS and Azure keys read as the names that made them. That is the
34
+ * property to preserve if this file is ever edited, and a property test asserts it over every plain name.
41
35
  *
42
- * The **path** alphabet is not identical, and the difference is a breaking change for an existing LocalFs
43
- * store. Two classes move: a Windows device-name stem (`con`, `nul`, `com1`, `con.backup`) and a trailing dot
44
- * (`a.`). Both were legal before and are escaped now — deliberately, since both silently alias or fail on
45
- * Windows — but a 0.9 store holding one will not find it after the upgrade, because the readers' round-trip
46
- * guard skips a spelling the driver would never have written. See the upgrade note in `CHANGELOG.md`.
36
+ * The **path** alphabet escapes more. Besides `:`, two classes of plain name are escaped on a path: a Windows
37
+ * device-name stem (`con`, `nul`, `com1`, `con.backup`) and a trailing dot (`a.`), since both silently alias
38
+ * or fail on Windows.
47
39
  */
48
40
  /**
49
41
  * Encode a name for use inside an **object key** (S3, GCS, Azure Blob).
50
42
  *
51
- * Leaves the historically-legal alphabet literal so existing keys do not move, and escapes everything else —
52
- * including `/` (which would invent hierarchy and break a parser that splits on it), `#` and `|` (reserved by
53
- * the former DynamoDB registry and kept reserved so existing keys stay valid), and control characters (not
54
- * legal in S3's XML responses).
43
+ * Leaves `[A-Za-z0-9._:-]` literal, except a leading `_` and a name that is only `.` or `..`, so a plain name
44
+ * reads as itself, and escapes everything else — including `/` (which would invent hierarchy and break a parser
45
+ * that splits on it) and control characters (not legal in S3's XML responses).
55
46
  */
56
47
  export declare function encodeNameForKey(name: string): string;
57
48
  /** Inverse of {@link encodeNameForKey}. */
@@ -66,8 +57,7 @@ export declare function decodeNameFromKey(encoded: string): string;
66
57
  * 1. **`.` and `..`** are traversal. `join(root, '..')` leaves the storage root.
67
58
  * 2. **Windows reserved device names** — `CON`, `PRN`, `AUX`, `NUL`, `COM1`–`COM9`, `LPT1`–`LPT9`, case
68
59
  * insensitive and *with or without an extension*, so `con.0.crbm` is reserved too. Opening one addresses a
69
- * device rather than a file. Note the old grammar **permitted** these: `store.segment('con')` validated
70
- * cleanly and broke only on a user's Windows box.
60
+ * device rather than a file, so `store.segment('con')` would break only on a user's Windows box.
71
61
  * 3. **A trailing `.` or space**, which Windows silently strips — so `a.` and `a` would become the same
72
62
  * directory, quietly merging two segments.
73
63
  *