@cloudbitmaps/roaring 0.9.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 (80) hide show
  1. package/NOTICE +3 -3
  2. package/PRIVACY.md +268 -76
  3. package/README.md +85 -95
  4. package/dist/bin/export-segments.d.ts +21 -0
  5. package/dist/bin/export-segments.js +1972 -30
  6. package/dist/bin/export-segments.js.map +7 -1
  7. package/dist/index.d.ts +969 -458
  8. package/dist/index.js +1461 -354
  9. package/dist/index.js.map +7 -1
  10. package/dist/open-charging-storage.d.ts +34 -0
  11. package/dist/option-keys.d.ts +19 -0
  12. package/dist/portable/decode.d.ts +53 -0
  13. package/dist/portable/layout.d.ts +32 -0
  14. package/dist/reserved-namespace.d.ts +3 -0
  15. package/dist/roaring-codec.d.ts +68 -0
  16. package/dist/system-clock.d.ts +39 -0
  17. package/dist/testing/conformance.d.ts +54 -0
  18. package/dist/testing/fuzz-codec.d.ts +23 -0
  19. package/package.json +11 -117
  20. package/dist/azure/index.cjs +0 -14
  21. package/dist/azure/index.cjs.map +0 -1
  22. package/dist/azure/index.d.cts +0 -9
  23. package/dist/azure/index.d.ts +0 -9
  24. package/dist/azure/index.js +0 -3
  25. package/dist/azure/index.js.map +0 -1
  26. package/dist/bin/compact-segments.js +0 -201
  27. package/dist/bin/compact-segments.js.map +0 -1
  28. package/dist/cassandra/index.cjs +0 -14
  29. package/dist/cassandra/index.cjs.map +0 -1
  30. package/dist/cassandra/index.d.cts +0 -1
  31. package/dist/cassandra/index.d.ts +0 -1
  32. package/dist/cassandra/index.js +0 -3
  33. package/dist/cassandra/index.js.map +0 -1
  34. package/dist/chunk-D6LIJSGF.js +0 -906
  35. package/dist/chunk-D6LIJSGF.js.map +0 -1
  36. package/dist/dynamodb/index.cjs +0 -14
  37. package/dist/dynamodb/index.cjs.map +0 -1
  38. package/dist/dynamodb/index.d.cts +0 -1
  39. package/dist/dynamodb/index.d.ts +0 -1
  40. package/dist/dynamodb/index.js +0 -3
  41. package/dist/dynamodb/index.js.map +0 -1
  42. package/dist/gcs/index.cjs +0 -14
  43. package/dist/gcs/index.cjs.map +0 -1
  44. package/dist/gcs/index.d.cts +0 -1
  45. package/dist/gcs/index.d.ts +0 -1
  46. package/dist/gcs/index.js +0 -3
  47. package/dist/gcs/index.js.map +0 -1
  48. package/dist/index.cjs +0 -894
  49. package/dist/index.cjs.map +0 -1
  50. package/dist/index.d.cts +0 -734
  51. package/dist/mongodb/index.cjs +0 -14
  52. package/dist/mongodb/index.cjs.map +0 -1
  53. package/dist/mongodb/index.d.cts +0 -1
  54. package/dist/mongodb/index.d.ts +0 -1
  55. package/dist/mongodb/index.js +0 -3
  56. package/dist/mongodb/index.js.map +0 -1
  57. package/dist/mysql/index.cjs +0 -14
  58. package/dist/mysql/index.cjs.map +0 -1
  59. package/dist/mysql/index.d.cts +0 -1
  60. package/dist/mysql/index.d.ts +0 -1
  61. package/dist/mysql/index.js +0 -3
  62. package/dist/mysql/index.js.map +0 -1
  63. package/dist/postgres/index.cjs +0 -14
  64. package/dist/postgres/index.cjs.map +0 -1
  65. package/dist/postgres/index.d.cts +0 -1
  66. package/dist/postgres/index.d.ts +0 -1
  67. package/dist/postgres/index.js +0 -3
  68. package/dist/postgres/index.js.map +0 -1
  69. package/dist/redis/index.cjs +0 -14
  70. package/dist/redis/index.cjs.map +0 -1
  71. package/dist/redis/index.d.cts +0 -1
  72. package/dist/redis/index.d.ts +0 -1
  73. package/dist/redis/index.js +0 -3
  74. package/dist/redis/index.js.map +0 -1
  75. package/dist/s3/index.cjs +0 -14
  76. package/dist/s3/index.cjs.map +0 -1
  77. package/dist/s3/index.d.cts +0 -1
  78. package/dist/s3/index.d.ts +0 -1
  79. package/dist/s3/index.js +0 -3
  80. package/dist/s3/index.js.map +0 -1
package/dist/index.d.ts CHANGED
@@ -1,277 +1,228 @@
1
- import { bulkLoadCrbmGeneration as bulkLoadCrbmGeneration$1, compactSegment as compactSegment$1, runCompactionCycle as runCompactionCycle$1, runExport as runExport$1, CodecBitmap, CodecInterface, BudgetOption, IColdDriver, ColdChunkSource, IWarmDriver, IRegistryDriver, IKeystore, Clock, Rng, RetryPolicy, IMetricsSink, SegmentEngine, SegmentRef, PricingProfile, Workload, Topology, CostReport, IAuditSink, CompactionOptions, CompactionResult, DropResult, RetentionPolicy, SetRetentionResult, RetireExpiredOptions, RetireExpiredResult, ConsistencyReport, ExportSink, ExportOptions, ExportManifest, EstimateInput } from '@cloudbitmaps/core';
2
- export * from '@cloudbitmaps/core';
3
-
4
1
  /**
5
- * Codec-bound wrappers over the public `@cloudbitmaps/core` entry points that need a bitmap codec.
2
+ * CloudRoaring — distributed, cloud-native Roaring Bitmaps, read from object storage.
6
3
  *
7
- * `core` is codec-agnostic and therefore cannot default the codec (the concrete codec lives *here*, in a package
8
- * that depends on core — a default over there would invert the dependency arrow). But an application using
9
- * `@cloudbitmaps/roaring` should never have to pass one. So this module re-binds each such entry point with
10
- * {@link roaringCodec} and the facade re-exports these **explicitly**, which shadows the same names coming from
11
- * `export * from '@cloudbitmaps/core'` (an explicit export always wins over a star export). Net effect: every
12
- * call signature is unchanged from before the family split — you can still call
13
- * `bulkLoadCrbmGeneration(driver, key, ids)` with no options at all.
4
+ * The `CloudRoaring` class is the read engine over **loaded** segments: write-once `.crbm` generations in an
5
+ * object store, behind one registry pointer per segment. You wire storage **once**, as a single config object:
6
+ * pass a **backend** as `storage` — `S3Storage`, `GcsStorage`, `AzureBlobStorage`, `LocalFsStorage` or
7
+ * `MemoryStorage` — and it carries both halves, the generations and the pointer, from one bucket and one
8
+ * prefix. Add `encryption: { keystore }` for encryption-at-rest / crypto-shred.
14
9
  *
15
- * A caller who *wants* a different codec passes it explicitly; the binding only fills an absent one — via
16
- * `?? roaringCodec` rather than spread order, so an explicit `codec: undefined` still gets the binding
17
- * (`exactOptionalPropertyTypes` is off, so that call typechecks and must not fall through to the throw).
18
- */
19
-
20
- type BulkLoad = typeof bulkLoadCrbmGeneration$1;
21
- type CompactSegment = typeof compactSegment$1;
22
- type RunCompactionCycle = typeof runCompactionCycle$1;
23
- type RunExport = typeof runExport$1;
24
- /**
25
- * {@link coreBulkLoad} with the roaring codec **and a real clock** pre-bound.
26
- *
27
- * The clock is what makes a large load **cooperative**: bulk-load yields the event loop periodically instead of
28
- * stalling the process for its whole duration (measured end-to-end at 450 ms of unbroken blocking for a 1M-id
29
- * load, now ~19 ms worst case). `core/` cannot
30
- * default it — it is timer-free by lint, which is precisely why waiting goes through the `Clock` seam — so the
31
- * flavor package supplies it, exactly as it supplies the codec. A caller who passes their own clock (a virtual
32
- * one in a simulation, say) keeps it.
33
- */
34
- declare const bulkLoadCrbmGeneration: BulkLoad;
35
- /** {@link coreCompactSegment} with the roaring codec pre-bound. */
36
- declare const compactSegment: CompactSegment;
37
- /** {@link coreRunCompactionCycle} with the roaring codec pre-bound. */
38
- declare const runCompactionCycle: RunCompactionCycle;
39
- /** {@link coreRunExport} with the roaring codec pre-bound (only the `'roaring'` format needs it). */
40
- declare const runExport: RunExport;
41
-
42
- declare class SafeBitmap implements CodecBitmap {
43
- private readonly bitmap;
44
- private constructor();
45
- static empty(): SafeBitmap;
46
- static fromValues(values: Iterable<number>): SafeBitmap;
47
- /**
48
- * S1: validate size, then deserialize with the **portable** (validated) format.
49
- * Throws `IntegrityError` if the input exceeds `maxBytes` or fails to decode — the
50
- * native addon is never handed unbounded or unsafe-format input.
51
- */
52
- static safeDeserialize(bytes: Uint8Array, maxBytes: number): SafeBitmap;
53
- serialize(): Uint8Array;
54
- /**
55
- * Convert containers to run-encoding where that is smaller. Representation only — membership is untouched, and
56
- * the portable format still round-trips through {@link RoaringCodec.safeDeserialize}.
57
- *
58
- * Roaring's third container type is a RUN, and no implementation selects it on its own: it takes an explicit
59
- * `runOptimize()` pass. Nothing here made that call, so run-shaped ids paid array or bitset prices — measured
60
- * at **570×** for a contiguous 1M-id range (128.1 KiB → 0.2 KiB) and **63×** for a 2,000-run shape
61
- * (536.5 KiB → 8.5 KiB). Sparse ids come out byte-identical, so this is never a losing trade; roaring keeps
62
- * whichever encoding is smaller per container.
63
- *
64
- * Those two factors are THIS CALL's own before/after on the same ids. They are not the ones the site quotes
65
- * (543× and 63×) — those compare the optimized encoding against the best *fixed* representation at its floor,
66
- * a different baseline that happens to land near 63× on the run shape too. `pnpm bench:encoding` prints both
67
- * columns; don't carry a figure between them without checking which denominator it had.
68
- *
69
- * Called by the engine only when writing an immutable cold generation — see {@link CodecBitmap.optimize} for
70
- * why the per-operation warm path deliberately does not.
71
- */
72
- optimize(): void;
73
- /**
74
- * Largest value, or `undefined` when empty. O(1) — roaring reads it off the container index, so the engine's
75
- * per-chunk range assertion costs one call per chunk rather than a walk per id.
76
- */
77
- maximum(): number | undefined;
78
- add(value: number): void;
79
- addMany(values: Iterable<number>): void;
80
- remove(value: number): void;
81
- removeMany(values: Iterable<number>): void;
82
- has(value: number): boolean;
83
- get size(): number;
84
- get isEmpty(): boolean;
85
- clone(): SafeBitmap;
86
- /** In-place union: `this = this ∪ other`. */
87
- orInPlace(other: CodecBitmap): void;
88
- /** In-place difference: `this = this \ other`. */
89
- andNotInPlace(other: CodecBitmap): void;
90
- /** In-place intersection: `this = this ∩ other`. */
91
- andInPlace(other: CodecBitmap): void;
92
- /** Ascending iterator over the set values. */
93
- [Symbol.iterator](): IterableIterator<number>;
94
- toArray(): number[];
95
- }
96
- /**
97
- * The roaring {@link CodecInterface} — the flagship codec, delegating to {@link SafeBitmap}'s statics. This is
98
- * the default the `CloudRoaring` facade injects into the engine; `core/` itself never hard-references it once a
99
- * caller supplies a codec. Moves to `@cloudbitmaps/roaring` when the package split lands.
100
- */
101
- declare const roaringCodec: CodecInterface;
102
-
103
- /**
104
- * CloudRoaring — distributed, cloud-native Roaring Bitmaps.
105
- *
106
- * The `CloudRoaring` class is the read/write engine over the tiered seams. You wire storage **once**, as a
107
- * single config object: a Cold driver (`cold`), a Warm driver (`warm`), and optionally a `registry` (the
108
- * authoritative generation pointer — needed to read encrypted segments and to resolve generations without a
109
- * cold `list`-scan) and a `keystore` (encryption-at-rest / crypto-shred). Pass a **raw** {@link IColdDriver}
110
- * as `cold` (e.g. `S3ColdDriver`, `LocalFsColdDriver`, `MemoryColdDriver`) and the store assembles the `.crbm`
111
- * cold source ({@link CrbmColdChunkSource}) for you — so each driver is named exactly once. Or pass an
112
- * already-built {@link ColdChunkSource} to control advanced reader options yourself.
10
+ * Two narrower shapes are also accepted for `storage`: a bare {@link IStorageDriver}, which has no pointer and
11
+ * so resolves generations by list-scanning storage (**cleartext and read-only**), and an already-built
12
+ * {@link StorageChunkSource} for advanced reader options you configure yourself.
113
13
  *
114
- * In-process lifecycle helpers — `compact`, `eraseSubject`, `subjectReport` — reuse the store's own drivers, so
115
- * you never re-pass them (they need the store built with a raw cold driver + registry). Out-of-process cold
116
- * writers (the compaction daemon, the seed/bulk-load CLI, `destroySegment`/`eraseNamespace`) wire their own deps
117
- * against the same drivers — they run in separate processes.
14
+ * **Data gets in by loading a generation**, never by mutating one: `store.load()` streams a set of ids into one
15
+ * immutable object and advances the segment's pointer to it. The pointer only moves forward, so a load out-raced by
16
+ * a newer generation reports `superseded` instead of landing, and so does one that read the row when the row has
17
+ * changed since. Every other write in the library is a load in
18
+ * disguise — `intersectInto`/`unionInto`/`andNotInto` write a new generation of their destination, and
19
+ * `eraseSubject` rewrites a generation without one id. Reads (`has`/`count`/`iterate`/`intersect`/`union`/`andNot`)
20
+ * see whole, checksum-verified generations and nothing else.
118
21
  *
119
- * **You do not need any of them to start.** Construct a store and call `add`/`addMany`/`has`/`remove` — a segment
120
- * with no Cold generation at all is fully functional, because a read merges `(cold ∪ warm.adds) \ warm.removes`
121
- * and an absent Cold tier just makes that merge trivial. `bulkLoadCrbmGeneration` is an **import** path for data
122
- * you already have elsewhere, not an initialization step, and compaction is a **cost** optimization, never a
123
- * correctness requirement. See the README and the getting-started guide.
22
+ * In-process lifecycle helpers — `eraseSubject`, `subjectReport`, `dropSegment`, `retireExpired`,
23
+ * `checkConsistency`, `exportSegments` — reuse the store's own drivers, so you never re-pass them (they need the
24
+ * store built with a backend). See the README and the getting-started guide.
124
25
  */
125
-
26
+ import type { BudgetOption, GenerationEntry, LoadGuard, LoadOptions, LoadRefusal, LoadResult, RollbackResult, Clock, StorageChunkSource, ConsistencyReport, CostReport, DropResult, EstimateInput, ExportManifest, ExportOptions, ExportSink, IAuditSink, IStorageDriver, IKeystore, IMetricsSink, StorageBackend, PricingProfile, RetentionPolicy, RetireExpiredOptions, RetireExpiredResult, RetryPolicy, Rng, SetRetentionResult, PinnedAt, IdRange, SegmentRef, Workload } from '@cloudbitmaps/core';
27
+ import type { SegmentInfo } from '@cloudbitmaps/core';
126
28
  /**
127
- * Wiring for a {@link CloudRoaring} store. **Only `cold` and `warm` are required** — the minimal call is
128
- * `new CloudRoaring({ cold, warm })`. Add a `registry` to unlock eject / compaction / encryption and to skip the
129
- * cold list-scan (recommended for anything beyond a quick look). Everything else is **optional tuning with
130
- * sensible defaults** — resilience/retries are already on, the hot cache is bounded, metrics are a no-op — so
131
- * reach for them only when you need to.
29
+ * Wiring for a {@link CloudRoaring} store. **Only `storage` is required** — the minimal call is
30
+ * `new CloudRoaring({ storage: backend })`. A backend resolves generations with one strong read, reads
31
+ * encrypted segments, and unlocks every lifecycle helper; the narrower shapes `storage` also accepts are
32
+ * described on the option itself. Everything else is **optional
33
+ * tuning with sensible defaults** — resilience/retries are already on, the cache is bounded, metrics are a
34
+ * no-op — so reach for them only when you need to.
132
35
  */
133
- interface CloudRoaringOptions {
36
+ export interface CloudRoaringOptions {
134
37
  /**
135
- * Cold tier. Pass a **raw** {@link IColdDriver} (`S3ColdDriver`, `LocalFsColdDriver`, `MemoryColdDriver`, …)
136
- * and the store wraps it in a {@link CrbmColdChunkSource} using `registry`/`keystore` below — the common case,
137
- * so you wire each driver **once**. Or pass an already-built {@link ColdChunkSource} (`MemoryColdChunkSource`,
138
- * or a `CrbmColdChunkSource` you configured with advanced reader options) to use as-is.
38
+ * Where the store keeps everything. **The only required option.**
39
+ *
40
+ * Pass a {@link StorageBackend} — `S3Storage`, `GcsStorage`, `AzureBlobStorage`, `LocalFsStorage`,
41
+ * `MemoryStorage` — and you are done: it carries both halves, the generations and the `currentGen` pointer,
42
+ * configured from one bucket and one prefix. That is the whole wiring, and it is the shape to reach for.
43
+ *
44
+ * **Pass the backend itself, not `backend.storage`.** They differ by one property access and the mistake is
45
+ * silent: `storage: backend` is the store, while `storage: backend.storage` hands over the *driver half*
46
+ * alone, which constructs without complaint and gives you a cleartext, read-only store that fails later on
47
+ * the first write or lifecycle call. `destroySegment` and `eraseNamespace` take `backend.registry`; the store takes `backend`.
48
+ *
49
+ * Two lower-level shapes stay accepted for wiring the facade does not cover:
50
+ *
51
+ * - a **raw {@link IStorageDriver}**, which the store wraps in a {@link CrbmStorageChunkSource}. There is no
52
+ * registry in this shape, so the store resolves generations by list-scanning storage: **cleartext and
53
+ * read-only**. Encrypted segments, the `*Into` verbs and every lifecycle helper need a backend.
54
+ * - an already-built {@link StorageChunkSource}, used as-is, for advanced reader options.
139
55
  */
140
- readonly cold: IColdDriver | ColdChunkSource;
141
- /** Warm tier: the per-chunk delta store under OCC (`DynamoDbWarmDriver`, `LocalFsWarmDriver`, `MemoryWarmDriver`). */
142
- readonly warm: IWarmDriver;
56
+ readonly storage: StorageBackend | IStorageDriver | StorageChunkSource;
143
57
  /**
144
- * Authoritative registry — the per-segment `currentGen` pointer + wrapped-DEK holder. Applies when `cold` is a
145
- * **raw driver**: it (a) resolves the current generation with one strong read instead of a cold `list`-scan,
146
- * and (b) lets the store read **encrypted** segments (that's where wrapped DEKs live). Optional — a
147
- * registry-less store reads the highest generation by list-scanning Cold (cleartext only). When you pass a
148
- * pre-built `ColdChunkSource`, that source resolves its own generations, so a top-level `registry` is inert
149
- * there and rejected as a wiring mistake — configure it on the source instead.
58
+ * Memory and staleness bounds for the read path. Every key is optional and every default is already sane;
59
+ * reach for these when a deployment's shape differs from "a long-running server with room to breathe".
150
60
  */
151
- readonly registry?: IRegistryDriver;
61
+ readonly cache?: CacheOptions;
152
62
  /**
153
- * Keystore for encryption-at-rest / crypto-shred (Phase 4e). Required to read encrypted segments; needs a
154
- * `registry` (that's where wrapped DEKs are stored). Applied only when `cold` is a raw driver — when you pass a
155
- * pre-built {@link ColdChunkSource}, configure the keystore on that source instead.
63
+ * Encryption at rest. Omit it entirely for a cleartext store — encryption is opt-in, and a segment's
64
+ * encryption is decided at its **first** generation.
156
65
  */
157
- readonly keystore?: IKeystore;
66
+ readonly encryption?: EncryptionOptions;
158
67
  /**
159
- * Refuse to read a **cleartext** segment — a guard against silently reading data that should be encrypted.
160
- * Needs a `registry`; applied only when `cold` is a raw driver. Off by default (encryption is opt-in).
68
+ * Resilience: by default every read that answers a query retries **transient** faults (throttling, 5xx, dropped
69
+ * connections) with bounded, jittered exponential backoff (see {@link DEFAULT_RETRY_POLICY}): `has`, `count`,
70
+ * `iterate` and the combines, the `*Into` verbs' reads of their operands included, a pinned handle's reads and
71
+ * `pin()` itself. Writes are not retried, and nor are an erasure's reads, a load's guard read, or the calls that
72
+ * read the registry or list the bucket directly (`exists`, `segments`, `generations`, `getRetention`, and the
73
+ * registry scan `subjectReport`, `exportSegments` and `checkConsistency` start from): they report a transient fault to their caller, because a conditional write that lands and then loses its response would, replayed,
74
+ * report its own write as a conflict. Pass a partial policy to tune it — anything you leave out keeps its
75
+ * default — or `false` to turn the read retry off (e.g. if your injected client already retries).
76
+ * Deterministic errors (`ValidationError`/`IntegrityError`/`WriteConflictError`/…) are never retried by this
77
+ * layer.
161
78
  */
162
- readonly requireEncryption?: boolean;
163
- /** Injected for deterministic tests; defaults to a system clock. */
164
- readonly clock?: Clock;
165
- /** Injected for deterministic tests; defaults to `Math.random`-backed. Drives backoff jitter. */
166
- readonly rng?: Rng;
167
- /** HOT cache ceiling (decoded Cold chunks). */
168
- readonly cacheMaxChunks?: number;
169
- /** Optional TTL on cached chunks (ms). */
170
- readonly cacheTtlMs?: number;
79
+ readonly retry?: RetryOptions | false;
171
80
  /**
172
- * How long (ms) the store trusts a segment's resolved `currentGen` before re-resolving it on the next read
173
- * (default 2000) — the bound on read staleness after a compaction commits a new generation (gap #4). Applies
174
- * only when `cold` is a raw driver **and** a `registry` is wired (the cheap `currentGen` read the refresh
175
- * needs; a registry-less store pins per source lifetime). Lazy — no timer; ≤ one registry read per segment per
176
- * window, opening a new reader only when the generation actually advanced.
81
+ * Observability sink: receives typed metric events (storage GET/bytes, cache hit/miss, retries, intersection
82
+ * efficiency, op latency). Defaults to a no-op — emission is skipped entirely when unused (near-zero
83
+ * overhead). Any exception the sink throws is swallowed — metrics can never break a read.
177
84
  */
178
- readonly coldGenTtlMs?: number;
179
- /**
180
- * Ceiling on how many segments' `.crbm` readers (each holding a parsed index) the store keeps open at once
181
- * (default 1024) — the steady-state memory bound for a long-running server that reads across many segments
182
- * (gap #1). Past it the least-recently-used segment's reader is evicted; re-opening it later is one cheap tail
183
- * GET. Applies only when `cold` is a raw driver (a pre-built `ColdChunkSource` manages its own reader cache).
184
- */
185
- readonly coldReaderCacheMax?: number;
186
- /**
187
- * Aggregate byte ceiling on the parsed `.crbm` indices the open readers hold (default 64 MiB) — the byte
188
- * half of the gap #1 memory bound, complementing the `coldReaderCacheMax` *count* bound. A wide/dense
189
- * segment's parsed index can be several MB, so a count-only bound could let the open readers pin ~GBs and
190
- * blow a small heap (e.g. a 128 MB Lambda); this evicts the least-recently-used reader once the summed index
191
- * footprint would exceed the ceiling — whichever of the count/byte bounds binds first. Lower it for
192
- * memory-tight deployments that read across wide segments. Applies only when `cold` is a raw driver.
193
- */
194
- readonly coldReaderCacheMaxBytes?: number;
195
- /**
196
- * Resilience: by default every warm/cold call retries **transient** faults (throttling, 5xx, dropped
197
- * connections) with bounded, jittered exponential backoff (see {@link DEFAULT_RETRY_POLICY}), and OCC
198
- * conflicts back off between retries. Pass a {@link RetryPolicy} to tune it, or `false` to disable the
199
- * transient-retry wrappers entirely (e.g. if your injected client already retries). Deterministic errors
200
- * (`ValidationError`/`IntegrityError`/`WriteConflictError`/…) are never retried by this layer.
201
- */
202
- readonly retry?: RetryPolicy | false;
203
- /** Backoff schedule between OCC conflict retries; defaults to a small, tight one. */
204
- readonly occBackoff?: RetryPolicy;
205
- /** Observability: called before each transient-retry backoff wait. */
206
- readonly onRetry?: (info: {
207
- attempt: number;
208
- delayMs: number;
209
- err: unknown;
210
- }) => void;
85
+ readonly metrics?: IMetricsSink;
211
86
  /**
212
- * Observability sink (Phase 5a): receives typed metric events (cold GET/bytes, warm read/write, cache
213
- * hit/miss, retries, intersection efficiency, op latency). Defaults to a no-op — emission is skipped
214
- * entirely when unused (near-zero overhead). Any exception the sink throws is swallowed — metrics can
215
- * never break a read/write.
87
+ * Per-op **denial-of-wallet** budget: the max backend requests a single
88
+ * `count`/`iterate`/`intersect`/`union`/`andNot`/`subjectReport`/`eraseSubject` may fan out into before it's
89
+ * refused with {@link BudgetExceededError} (`eraseSubject` reports an over-budget segment in its ledger instead,
90
+ * see {@link CloudRoaring.eraseSubject}) — so one runaway op can't drive unbounded GET cost on a shared
91
+ * backend. **On by default, generous** ({@link DEFAULT_BUDGET}: 1,000,000 requests — a normal op never hits it).
92
+ * Tune with `{ maxRequests }`, override per op (on the combines / `subjectReport` / `eraseSubject`), or set
93
+ * `false` to disable. The check is O(1) (before fan-out), so the hot path is untouched; per-request bytes are
94
+ * separately size-capped, so bounding requests transitively bounds bytes.
216
95
  */
217
- readonly metrics?: IMetricsSink;
96
+ readonly budget?: BudgetOption;
218
97
  /**
219
- * Warm read consistency for the READ paths (`has`/`count`/`iterate`/`intersect`). Default `'strong'`
220
- * (read-your-writes). `'eventual'` trades read-after-write for **~½ the DynamoDB read cost** (a strong read
221
- * bills 2× RCU) — a good fit for read-heavy, staleness-tolerant workloads (mirrors the cold tier's bounded
222
- * eventual reads). The compaction/OCC write path is always strongly consistent regardless. No effect on the
223
- * in-memory/LocalFs drivers (always strong). (Audit gap #9.)
98
+ * Determinism seams, for tests and replayable jobs. Production stores leave this out and get a system clock
99
+ * and a `Math.random`-backed source.
224
100
  */
225
- readonly warmReadConsistency?: 'strong' | 'eventual';
101
+ readonly seams?: SeamOptions;
102
+ }
103
+ /** {@link CloudRoaringOptions.cache} — the memory and staleness bounds. */
104
+ export interface CacheOptions {
105
+ /** Ceiling on decoded Storage chunks held in RAM (default 1024). */
106
+ readonly maxChunks?: number;
107
+ /** Optional TTL on cached chunks (ms). Omit to keep a chunk until it is evicted by the count bound. */
108
+ readonly ttlMs?: number;
226
109
  /**
227
- * Max Warm chunk writes in flight per `addMany`/`removeMany` (the bounded flusher). Default **4**
228
- * (`DEFAULT_WRITE_CONCURRENCY`); set it to `1` for strictly serial writes.
110
+ * How long (ms) the store trusts a segment's resolved `currentGen` before re-resolving it on the next read
111
+ * (default 2000) — the bound on read staleness after a load publishes a new generation. Applies when
112
+ * `storage` is a **backend**, whose registry supplies the cheap `currentGen` read the refresh needs. Lazy — no
113
+ * timer; ≤ one registry read per segment per window, opening a new reader only when the generation actually
114
+ * advanced.
229
115
  *
230
- * Distinct chunks are independent OCC rows, so fanning them out cannot make them conflict with each other.
231
- * The bound exists for the *backend*: a provisioned-capacity store answers a burst by throttling, and 4 stays
232
- * comfortably inside what the transient-retry path absorbs (measured 8x below the first observed failure).
233
- * Raise it if your backend is provisioned for the fan-out — on-demand DynamoDB, or a connection pool sized to
234
- * match. A pool *smaller* than this needs no action: `pg` and `mysql2` both queue rather than reject, so the
235
- * flusher simply degrades toward serial.
116
+ * **While the registry cannot be read, the bound stretches.** A refresh that fails with a
117
+ * {@link TransientError} keeps serving the generation the reader holds, and the key it unwrapped, and is tried
118
+ * again 500 ms later (or after this TTL, if shorter), so the store converges within one retry of the registry
119
+ * answering. A refresh that fails with anything else, an access denial or a row that will not parse, is not
120
+ * ridden out: the read that meets it throws that error, and the reader is dropped.
236
121
  *
237
- * As before, a mid-flush failure can leave a **partial result**; `addMany`/`removeMany` are not atomic. What
238
- * concurrency changes is only how much of the batch may already have landed when the first error surfaces.
122
+ * `0` turns this timed refresh off, and so does wiring a bare `IStorageDriver`, which has no registry. That is
123
+ * all it does. The store still moves a segment on to whatever generation is current when its reader cache
124
+ * evicts the segment, when a read has to fetch from a generation a sweep deleted, and when it is invalidated,
125
+ * as its own `load`, `rollback`, `eraseSubject` and `*Into` writes do and {@link CloudRoaring.invalidate} does.
126
+ * A read its caches can answer touches no storage, so a sweep alone moves nothing. What `0` gives up is the bound:
127
+ * the store notices another process's publish, erasure or drop only through one of those, whenever that happens. To read
128
+ * one generation for as long as you need it, take a {@link Segment.pin}.
239
129
  */
240
- readonly writeConcurrency?: number;
130
+ readonly genTtlMs?: number;
241
131
  /**
242
- * Hard ceiling on the warm-delta bytes one segment scan may hold resident. Default **64 MiB**.
243
- *
244
- * **A memory bound, deliberately separate from `budget`, and still enforced when `budget: false`.** The
245
- * budget limits *cost* (billable requests); this limits *memory*. They are different axes, and treating the
246
- * budget as a memory control is what allowed a segment with thousands of warm chunks to materialise ~12 MB
247
- * before a `maxRequests: 2` budget could refuse it. It is also the only bound available to `intersect`,
248
- * whose budget is `common keys × operands` — a product a single wide operand can legitimately exceed in row
249
- * count while remaining entirely within contract.
132
+ * Ceiling on how many segments' `.crbm` readers (each holding a parsed index) the store keeps open at once
133
+ * (default 1024) — the steady-state memory bound for a long-running server that reads across many segments.
134
+ * Past it the least-recently-used segment's reader is evicted; re-opening it later is one cheap tail GET.
135
+ * Applies whenever the store builds its own read path — a backend or a bare `IStorageDriver`. A pre-built
136
+ * `StorageChunkSource` manages its own reader cache.
137
+ */
138
+ readonly readerMax?: number;
139
+ /**
140
+ * Aggregate byte ceiling on the parsed `.crbm` indices the open readers hold (default 64 MiB) — the byte half
141
+ * of the memory bound, complementing the {@link CacheOptions.readerMax} *count* bound. A wide/dense segment's
142
+ * parsed index can be several MB, so a count-only bound could let the open readers pin ~GBs and blow a small
143
+ * heap (e.g. a 128 MB Lambda); this evicts the least-recently-used reader once the summed index footprint
144
+ * would exceed the ceiling — whichever of the count/byte bounds binds first. Lower it for memory-tight
145
+ * deployments that read across wide segments. Applies whenever the store builds its own read path.
146
+ */
147
+ readonly readerMaxBytes?: number;
148
+ }
149
+ /** {@link CloudRoaringOptions.encryption} — encryption at rest and crypto-shred. */
150
+ export interface EncryptionOptions {
151
+ /**
152
+ * The keystore that mints and unwraps per-segment DEKs. Needs a **backend**: the wrapped DEK lives in the
153
+ * registry, so there is nowhere to put it on a store wired with a bare driver, and that is refused at
154
+ * construction rather than at the first read.
155
+ */
156
+ readonly keystore?: IKeystore;
157
+ /**
158
+ * Refuse to touch a **cleartext** segment — a guard against silently reading, or writing, data that should be
159
+ * encrypted. Needs a backend, for the same reason as {@link EncryptionOptions.keystore}. Off by default.
250
160
  *
251
- * Raise it for genuinely large segments; it exists to keep a modest container alive, not to second-guess you.
161
+ * It refuses **writes** as well as reads, which is easy to miss: the `*Into` verbs and `eraseSubject` both
162
+ * carry it into their write path, so on a cleartext segment a materialisation throws and an erasure records
163
+ * `note: 'error: requireEncryption: …'` in its ledger rather than erasing. And since a segment's encryption is
164
+ * decided at its **first** generation, this cannot be switched on for a segment that already has one — load
165
+ * into a new encrypted segment and drop the old one.
252
166
  */
253
- readonly maxWarmScanBytes?: number;
167
+ readonly required?: boolean;
168
+ }
169
+ /**
170
+ * {@link CloudRoaringOptions.retry} — a partial {@link RetryPolicy} plus the retry callback.
171
+ *
172
+ * Partial on purpose, so tuning one field does not mean restating all five. Anything omitted here keeps its
173
+ * {@link DEFAULT_RETRY_POLICY} value.
174
+ */
175
+ export interface RetryOptions extends Partial<RetryPolicy> {
254
176
  /**
255
- * Per-op **denial-of-wallet** budget (Decision #3 / invariant T3): the max backend requests a
256
- * single `count`/`iterate`/`intersect`/`subjectReport`/`eraseSubject` may fan out into before it's refused
257
- * with {@link BudgetExceededError} — so one runaway op can't drive unbounded RCU/GET cost on a shared backend.
258
- * **On by default, generous** ({@link DEFAULT_BUDGET}: 1,000,000 requests — a normal op never hits it). Tune
259
- * with `{ maxRequests }`, override per op (on `intersect`/`subjectReport`/`eraseSubject`), or set `false` to
260
- * disable. The check is O(1) (before fan-out), so the hot path is untouched; per-request bytes are separately
261
- * size-capped, so bounding requests transitively bounds bytes.
177
+ * Observability: called before each transient-retry backoff wait. Best-effort: an error it throws is
178
+ * swallowed, so it can neither stop the retry nor change the error a read fails with.
262
179
  */
263
- readonly budget?: BudgetOption;
180
+ readonly onRetry?: (info: {
181
+ attempt: number;
182
+ delayMs: number;
183
+ err: unknown;
184
+ }) => void;
185
+ }
186
+ /** {@link CloudRoaringOptions.seams} — injected for deterministic tests and replayable jobs. */
187
+ export interface SeamOptions {
188
+ /** Defaults to a system clock. */
189
+ readonly clock?: Clock;
190
+ /** Defaults to `Math.random`-backed. Drives transient-retry jitter. */
191
+ readonly rng?: Rng;
264
192
  }
265
- interface SegmentOptions {
193
+ export interface SegmentOptions {
266
194
  readonly namespace?: string;
195
+ /**
196
+ * **When this segment stops being readable** — an absolute epoch-ms instant, declared where the segment is
197
+ * named instead of in a separate `setRetention` call.
198
+ *
199
+ * Every read through this handle checks it first: past the deadline, `has` is `false`, `count` is `0`, and
200
+ * `iterate` yields nothing — **one integer compare against the injected clock, no I/O, on every backend**.
201
+ * That is Redis's lazy expiry, and it is what makes an expiry *correct* rather than *eventually correct*: a
202
+ * deployment whose sweep is late — or which has no sweep at all, like a Lambda-only reader — still stops
203
+ * serving the data on time.
204
+ *
205
+ * **Two things this does NOT do, both deliberate:**
206
+ *
207
+ * - It does not reclaim the bytes. That is {@link CloudRoaring.retireExpired}, and until it runs the data is
208
+ * still stored and still billed. `count()` reporting 0 while objects exist is the expected state in that
209
+ * window, not a bug.
210
+ * - It does not apply to *other* handles. The deadline lives on this handle; a second handle opened without
211
+ * the option reads the segment normally. Record the policy with {@link CloudRoaring.setRetention} to make
212
+ * it durable, fleet-visible, and reclaimable.
213
+ *
214
+ * Must be **milliseconds** since the epoch and at or after {@link MIN_EXPIRES_AT_MS} — a seconds value would
215
+ * land in 1970 and make the segment permanently unreadable, so it is refused rather than honoured.
216
+ */
217
+ readonly expiresAt?: number;
267
218
  }
268
219
  /** A segment reference in a subject report / erasure ledger. */
269
- interface SubjectSegmentRef {
220
+ export interface SubjectSegmentRef {
270
221
  readonly segment: string;
271
222
  readonly namespace?: string;
272
223
  }
273
224
  /** Result of {@link CloudRoaring.subjectReport} — the segments an id is a member of (over registered segments). */
274
- interface SubjectReport {
225
+ export interface SubjectReport {
275
226
  readonly id: number;
276
227
  /** The registered segments the id is currently a member of. */
277
228
  readonly segments: SubjectSegmentRef[];
@@ -279,65 +230,187 @@ interface SubjectReport {
279
230
  readonly scannedSegments: number;
280
231
  }
281
232
  /** One segment's entry in an erasure ledger (see {@link EraseSubjectResult}). */
282
- interface SubjectErasureEntry {
233
+ export interface SubjectErasureEntry {
283
234
  readonly segment: string;
284
235
  readonly namespace?: string;
285
- /** The id was present and a logical `remove` tombstone was written. */
286
- readonly removed: boolean;
287
- /**
288
- * True iff *this call* committed a new generation with the segment's tombstones applied — the bit is
289
- * physically gone from Cold (assuming no concurrent re-add of the id; see {@link CloudRoaring.eraseSubject}).
290
- * `false` is **conservative**: the purge may have been deferred (`note:'leased-by-other'`) or already done by
291
- * a concurrent daemon (`note:'clean'`); the logical removal holds regardless.
292
- */
293
- readonly physicallyPurged: boolean;
294
- /** The generation that retired the bit (present when `physicallyPurged`). */
295
- readonly toGen?: number;
296
- /** Why physical purge wasn't committed by this call (compaction's `reason`, e.g. `'leased-by-other'`, `'clean'`). */
236
+ /**
237
+ * True iff the id **was** in the segment and **no generation of it holds the id now** — not the current one, not
238
+ * one below the pointer, and not one above it, where a rollback leaves the generations it rolled back from. The
239
+ * current generation is one without it, every generation that held it has been deleted from the bucket, and the
240
+ * call listed the bucket and read what was left before this entry was returned (see
241
+ * {@link CloudRoaring.eraseSubject}).
242
+ */
243
+ readonly erased: boolean;
244
+ /**
245
+ * The generation the id was found in (present whenever the segment was read) — the newest of them, when the
246
+ * current generation did not hold it and several others did.
247
+ */
248
+ readonly fromGeneration?: number;
249
+ /** The generation written without the id (present whenever one was written). */
250
+ readonly generation?: number;
251
+ /**
252
+ * Why the id was NOT erased from this segment, when `erased` is false. `'superseded'` — the pointer moved while
253
+ * the call was in flight, by a load, another erasure or a rollback, so **this call** did not erase the id;
254
+ * re-run against the new generation, which erases it if it is still there and reports nothing for the segment
255
+ * if the racing writer already removed it. `` `error: <message>` `` — an isolated per-segment fault
256
+ * (per-segment faults are recorded so one segment can't discard the whole ledger); re-run after fixing the
257
+ * fault. A budget refusal is one of these: the segment's generations did not fit `budget`, so it was not finished;
258
+ * re-run with a higher `budget`. A fault that landed once part of the work was already done — a Storage `delete` fault, a collect that
259
+ * could not prove the segment was still the same one, or a generation still holding the id when the bucket was
260
+ * listed at the end, whether or not a rewrite was published first — also re-runs, but **read what the re-run
261
+ * says**: it usually reports `erased: true` against the generation it found the id in, it reports
262
+ * nothing at all if a racing collector took that generation first (the bit is gone, but no run holds a
263
+ * receipt for it), and if the segment's row has since been purged it is no longer scanned at all — anything
264
+ * left in its bucket is an orphan, which `store.generations(ref)` lists and `store.dropSegment(ref, {
265
+ * confirmSegment })` deletes; `checkConsistency` and the collection a load runs read rows, so neither reaches it.
266
+ * **Segments the id is not in
267
+ * are not listed, and neither are segments that no longer have a registry row** — an empty ledger is not by
268
+ * itself proof the id is gone.
269
+ */
297
270
  readonly note?: string;
298
271
  }
299
272
  /**
300
273
  * The erasure ledger returned by {@link CloudRoaring.eraseSubject} — your proof-of-deletion artifact. It is a
301
274
  * return value only (no library-side persistence): persist it / route it to your audit sink as you see fit.
302
275
  */
303
- interface EraseSubjectResult {
276
+ export interface EraseSubjectResult {
304
277
  readonly id: number;
305
- /** Per-segment records for the segments the id was erased from (absent segments are not listed). */
278
+ /** Per-segment records for the segments the id was found in (absent segments are not listed). */
306
279
  readonly erasedFrom: SubjectErasureEntry[];
307
280
  /** How many registered segments were scanned. */
308
281
  readonly scannedSegments: number;
309
282
  }
310
- declare class CloudRoaring {
283
+ /**
284
+ * Why a materialisation did not become current.
285
+ *
286
+ * {@link LoadRefusal} minus `'superseded'`: that one is a lost race, and the `*Into` verbs throw
287
+ * {@link WriteConflictError} for it rather than reporting it. Narrowing the type rather than saying so only
288
+ * in prose means the compiler enforces it, and a caller's exhaustive `switch` has no dead branch.
289
+ */
290
+ export type MaterializeRefusal = Exclude<LoadRefusal, 'superseded'>;
291
+ /** What an `*Into` verb wrote: the new generation of the destination, and whether it became current. */
292
+ export interface MaterializeResult {
293
+ /**
294
+ * The generation written. Present even when refused: the refusal deleted that object, unless the destination's
295
+ * row changed while it was writing, which leaves it in the bucket.
296
+ */
297
+ readonly generation: number;
298
+ /**
299
+ * Whether this generation is now the destination's current one.
300
+ *
301
+ * `false` when the guard refused the result: an empty one over a non-empty destination, or one outside
302
+ * `guard`'s bounds. Branch on it: a refusal is reported, not thrown, so a caller that ignores it sees a
303
+ * successful-looking result for a write that deliberately did not happen.
304
+ */
305
+ readonly published: boolean;
306
+ /** Set only when `published` is false. A lost race throws {@link WriteConflictError} rather than appearing here. */
307
+ readonly reason?: MaterializeRefusal;
308
+ /** Ids in the generation. */
309
+ readonly cardinality: number;
310
+ /**
311
+ * What the destination held when the guard judged it — `null` when it had no current generation, **or when
312
+ * no bound needed the read**. The read costs an object-header fetch, so it is taken only when a bound will
313
+ * use it: `allowEmpty: true` with no `guard.minRetained` skips it, and this is `null` even though `dest`
314
+ * was non-empty.
315
+ */
316
+ readonly cardinalityBefore: number | null;
317
+ /** Non-empty chunks in the generation. */
318
+ readonly chunkCount: number;
319
+ /** Bytes of the written object. */
320
+ readonly size: number;
321
+ /**
322
+ * The superseded generations collected after publishing. Empty when nothing was published — and **also
323
+ * empty on a successful publish** unless you passed `keep`, because a materialisation collects nothing by
324
+ * default. See {@link MaterializeOptions.keep}.
325
+ */
326
+ readonly collected: readonly number[];
327
+ }
328
+ export declare class CloudRoaring {
311
329
  private readonly engine;
330
+ private readonly cache;
331
+ /** The store's retry options, for a pinned engine to read through as the store's own does; undefined when off. */
332
+ private readonly retryOptions;
333
+ private readonly crbmSource;
312
334
  private readonly clock;
313
335
  private readonly metrics;
314
- private readonly coldDriver;
315
- private readonly warmDriver;
336
+ private readonly storageDriver;
316
337
  private readonly registry;
317
338
  private readonly keystore;
318
339
  private readonly requireEncryption;
319
340
  /** Resolved store-level per-op budget (null = disabled); the admin scans use it, with a per-op override. */
320
341
  private readonly budget;
342
+ /**
343
+ * Refuse any option key the store does not take, at the top level or inside a group, naming each one.
344
+ *
345
+ * An option the store ignored would do nothing and look as if it had — a typo'd `cache.maxChunk`, a key from
346
+ * another library's config spread into this one — so every key is checked against {@link OPTION_KEYS}.
347
+ */
348
+ private static rejectUnknownOptions;
321
349
  constructor(options: CloudRoaringOptions);
322
350
  /**
323
- * Build {@link CompactionDeps} from the store's own drivers, for the in-process lifecycle helpers
324
- * ({@link compact} / {@link eraseSubject}). Requires the store to have been constructed with a **raw cold
325
- * driver** (a pre-built `ColdChunkSource` has no underlying `IColdDriver` to compact through) and a `registry`
326
- * (the authoritative `currentGen` pointer compaction swaps). Out-of-process callers use the `compactSegment`
327
- * free function with explicit deps.
351
+ * The write-side deps, from the store's own drivers, for the lifecycle helpers and the `*Into` verbs. Requires
352
+ * the store to have been constructed with a **backend**, which supplies both halves: an `IStorageDriver` to
353
+ * write generations through (a pre-built `StorageChunkSource` has none) and the registry holding the pointer
354
+ * every write publishes.
328
355
  */
329
- private compactionDeps;
330
- /** Get a handle to a segment. Validates the name/namespace grammar (finding S2). */
356
+ private lifecycleDeps;
357
+ /** Get a handle to a segment. Validates the name (non-empty, well-formed, within the encoded-length cap). */
331
358
  segment(name: string, options?: SegmentOptions): Segment;
332
359
  /**
333
- * **Subject access (GDPR Art. 15 / CCPA right-to-know): which segments is this id a member of?** (Phase 6b.)
360
+ * Write `ids` as a **new generation of `dest`** and publish it — the shared body of the `*Into` verbs. An `*Into`
361
+ * materialisation is a load, and publishes the same way (hard invariant 1): every load that finds a row fences its
362
+ * publish on the row's token; a guarded load (the default, since the empty refusal reads the current generation)
363
+ * also fences on the pointer it judged (`expectFrom`), and one that found no row fences on that absence instead.
364
+ * Only an unguarded load (`allowEmpty: true` and no `guard.minRetained`) onto a segment with no row publishes bare
365
+ * forward-only. The destination's previous generation stays readable until the publish lands (readers re-resolve
366
+ * within `cache.genTtlMs`).
367
+ *
368
+ * This routes through `loadSegment` rather than writing the generation itself, and that is the whole point
369
+ * of it. A materialisation is a load whose ids happen to come from a combine instead of from upstream, so
370
+ * everything `load()` learned the hard way applies unchanged: the generation is written UNPUBLISHED, the
371
+ * guard runs while the old generation is still authoritative, the publish is fenced as above, and a refused
372
+ * object is reclaimed only when a re-read finds the row unchanged (the same token) or gone (hard invariant 1:
373
+ * deleting it after a purge-and-recreate would put a live row over a missing generation).
374
+ *
375
+ * That last check narrows the window rather than closing it: the row read and the delete are two round
376
+ * trips, and `IStorageDriver` has no conditional delete to make them one. the collection a load runs carries
377
+ * the same residual. The failure it leaves is an orphan object, which costs storage until
378
+ * something collects it — deliberately the cheaper side of the trade.
379
+ *
380
+ * Written and published in one step, with no guard, an empty combine — a typo'd operand, an `exclude` that
381
+ * swallowed everything, an operand that had not loaded yet — would silently replace `dest` with an empty
382
+ * generation: the same failure `load()`'s guard exists to prevent, on the same data, reachable without passing
383
+ * any option at all.
384
+ *
385
+ * **A lost race throws.** `loadSegment` reports one as `reason: 'superseded'`; the `*Into` verbs throw
386
+ * {@link WriteConflictError} for it, so a caller can `catch (WriteConflictError)`. That one refusal is
387
+ * translated into the throw, and `MaterializeResult.reason` never carries it.
388
+ *
389
+ * **A `WriteConflictError` does not by itself mean nothing was published**, and that is worth knowing
390
+ * before you write the retry. `'superseded'` covers five different causes — the write-once PUT collided,
391
+ * the pointer moved, a row appeared where the load found none, the row's token changed, the row was purged —
392
+ * and only the first three are the "somebody beat us" the name suggests. A token can also change on a write
393
+ * that is not a supersession at all, such as a `setRetention` on the destination. On top of that, the collection pass that runs AFTER a
394
+ * successful publish can raise the same error. So: treat it as "re-read the destination and decide",
395
+ * never as "the write did not happen".
334
396
  *
335
- * Enumerates the **registered** segments (via the store's own `registry`) and does a tier-merging `has(id)` on
336
- * each — no drivers to re-pass. Complete only over registered segments (register your segments if you claim
337
- * SAR support). There is deliberately **no `id → segments` reverse index** — that would tax every write for a
338
- * rare request; this admin scan is `O(registered segments)` and touches no hot path.
339
- * Requires a `registry` in the store config
340
- * (throws {@link UnsupportedError} otherwise).
397
+ * **And by default it collects nothing**, unlike `load()`. See the `keep` default below.
398
+ */
399
+ private materialize;
400
+ /**
401
+ * **Subject access (GDPR Art. 15 / CCPA right-to-know): which segments is this id a member of?**
402
+ *
403
+ * Enumerates the **registered** segments (via the store's own `registry`) and does a `has(id)` on each — no
404
+ * drivers to re-pass. Complete only over registered segments (every loaded segment has a row, so build the
405
+ * store on the backend the loads used). There is deliberately **no `id → segments` reverse index** — that would
406
+ * tax every load for a rare request; this admin scan is `O(registered segments)` and touches no hot path. Needs
407
+ * a storage backend (throws {@link UnsupportedError} otherwise).
408
+ *
409
+ * **Current as of the registry row, not as of the reader's cache.** Each segment's resolved generation is
410
+ * compared with the row the scan listed, and a segment whose cached generation differs is re-resolved before the
411
+ * read, so a load or an erasure from another process shows at once — whatever `cache.genTtlMs` is, `0` included.
412
+ * The comparison is on the generation **and** the row's token, so a segment retired, purged and loaded again from
413
+ * generation 0 while this store held its old generation 0 is told apart too.
341
414
  */
342
415
  subjectReport(id: number, options?: {
343
416
  namespace?: string;
@@ -346,42 +419,68 @@ declare class CloudRoaring {
346
419
  budget?: BudgetOption;
347
420
  }): Promise<SubjectReport>;
348
421
  /**
349
- * **Subject erasure (GDPR Art. 17): remove an id from every segment it's in, with a physical-deletion
350
- * guarantee.** (Phase 6b.)
351
- *
352
- * For each **registered** segment the id is a member of: writes a logical `remove` tombstone (immediate) and
353
- * then **force-compacts that segment now** — folding the tombstone into a fresh immutable generation so the
354
- * bit is *physically* gone from Cold on return, even for an otherwise-idle/archival segment that organic
355
- * compaction would never touch (the P13 fix). The returned per-segment record is your **erasure ledger** —
356
- * persist it / route it to your audit sink as the proof of deletion; the physical-purge proof is
357
- * compaction's own VERIFY step (a re-`has()` here would be unsound — a pinned cold source can still read the
358
- * old generation; take a fresh generation view after erasing, exactly as with the compaction daemon).
359
- *
360
- * Uses the store's **own** drivers (raw cold + warm + registry), so the old integrator obligation to wire a
361
- * matching `CompactionDeps` is gone — the `remove()` and the force-compaction provably run over the same tiers.
362
- * Requires the store built with a **raw cold driver + registry** (throws {@link UnsupportedError} otherwise;
363
- * a pre-built `ColdChunkSource` store has no `IColdDriver` to compact through — use the `compactSegment` free
364
- * function there). **One contract remains** (an integrator obligation the library cannot check): **do not
365
- * concurrently re-add the same id while erasing it.** A normal `add(id)` landing between the `remove` and the
366
- * compaction clears the tombstone and folds the id back into the new generation
367
- * (`effective = (cold ∪ adds) \ removes`); `physicallyPurged` attests only that compaction committed a
368
- * generation with the tombstones applied at pin time — quiesce writes for the subject during erasure.
369
- *
370
- * **A `physicallyPurged:false` entry means the logical removal holds but the physical purge didn't run this
371
- * call** — either a daemon held a live lease (`note:'leased-by-other'`; the daemon finishes it), or the
372
- * force-compaction hit an isolated fault (`note:'error: …'`; per-segment faults are caught so one segment
373
- * can't discard the whole ledger). `physicallyPurged:false` is **conservative** (a concurrent daemon may
374
- * already have purged the bit — `'clean'`). **Recovery:** follow up any `physicallyPurged:false` entry with
375
- * `store.compact(ref)` (or let the daemon do it) — **re-running `eraseSubject` will NOT re-purge it**, because
376
- * `has()` now reads false (the tombstone) so the segment is skipped as a non-member. The physical-purge proof
377
- * is compaction's own VERIFY step (a re-`has()` here would be unsound: a pinned cold source can still read the
378
- * old generation — take a fresh generation view after erasing, as with the daemon). Admin-only path;
379
- * `O(registered segments)`, no hot-path cost. Per-subject crypto-shred is infeasible (a subject's bit is
422
+ * **Subject erasure (GDPR Art. 17): remove an id from every segment it's in, with the bit physically gone
423
+ * from the bucket on return.**
424
+ *
425
+ * For each **registered** segment the id is a member of, `eraseIdFromSegment` rewrites the current generation
426
+ * without the id — every chunk streamed through, one bit cleared — publishes the rewrite **fenced on the
427
+ * generation it was derived from**, and collects the generation that held the bit. A segment whose current
428
+ * generation does not hold the id is searched anyway, every generation in its bucket: a retained superseded one
429
+ * can still hold it, and so can one above the pointer after a {@link CloudRoaring.rollback}, which the rollback
430
+ * could make current again. Each holder is deleted — below the pointer by a `keep: 0` collection, above it one
431
+ * by one, re-proved against the row first — and the generations above the pointer that never held the id stay
432
+ * as rollback targets. An entry says `erased: true` only once the call has listed the bucket and read what is
433
+ * left: **no generation of the segment holds the id**. The returned per-segment record is your **erasure ledger** —
434
+ * persist it / route it to your audit sink as the proof of deletion (a `segment.rewrite` audit event is also
435
+ * emitted per rewrite when you pass `audit`).
436
+ *
437
+ * Uses the backend's **own** two halves, so the membership check and the rewrite provably run
438
+ * over the same generation. Requires the store built with a **backend** (throws
439
+ * {@link UnsupportedError} otherwise; a pre-built `StorageChunkSource` store has no `IStorageDriver` to write
440
+ * through; build the store on a backend instead).
441
+ *
442
+ * **One contract remains** (an integrator obligation the library cannot check): **do not load the segment
443
+ * while erasing from it.** A load that lands after the rewrite carries whatever its source held, and the
444
+ * library cannot know that source was meant to exclude the id. Quiesce loads of the affected segments for the
445
+ * duration, or fix the source first and load after. A writer that lands *during* the rewrite is caught: the
446
+ * rewrite's publish is refused **by the fence** — the publish lands its compare-and-swap only
447
+ * while the pointer is still on the generation the rewrite streamed — and the entry says `note: 'superseded'`,
448
+ * so re-run. Forward-only alone would NOT refuse it: the rewrite's number goes above everything in the bucket,
449
+ * so the rewrite would out-rank the newer generation and then collect it.
450
+ *
451
+ * A racing **erasure** is caught before that, and reported the same way. It collects with `keep: 0`, taking
452
+ * every generation below its new pointer — the one this rewrite is streaming, and the object this rewrite
453
+ * just wrote — so the loser can find its own inputs deleted mid-flight. That surfaces as an outcome rather than
454
+ * an error, read off the row: a moved pointer is a `'superseded'` entry, which a re-run settles, and a segment
455
+ * that a concurrent `dropSegment` tombstoned or a retention sweep purged is left out of the ledger, as a fresh
456
+ * call would leave it out.
457
+ *
458
+ * **Read `note` on any `erased: false` entry — the two reasons mean different things.** `'superseded'` means
459
+ * another writer (a load, another erasure, or a rollback) moved the pointer mid-call, so **this call** did not
460
+ * erase the id. Re-run: it erases the id if it is still there, and lists nothing for the segment if a racing
461
+ * erasure of the same id already removed it. Do not read `'superseded'` as "the id is still present" —
462
+ * read it as "not done by this call, and the re-run settles it".
463
+ * `` `error: …` `` is a per-segment fault (caught so one segment can't discard the whole ledger) and it can land
464
+ * on either side of the publish: if the rewrite had not published, the id is still there and a re-run erases
465
+ * it; if the publish succeeded and only the **collection** of the old generation failed, the id is already
466
+ * absent from every read and what remains is an object in the bucket that still contains the bit. The same
467
+ * note, after a publish, can mean a rollback moved the pointer back onto a generation that still holds the id
468
+ * while the collection ran — then the id is served again, and the entry says so rather than `erased: true`. A
469
+ * re-run searches every generation in the bucket, so it rewrites or collects what is left and usually reports
470
+ * `erased: true`; {@link SubjectErasureEntry.note} says what else a re-run can report. Re-running is otherwise safe
471
+ * and idempotent: a segment the id is no longer in is not listed. Admin-only path;
472
+ * `O(registered segments + superseded generations)`, no hot-path cost: a segment whose current generation lacks
473
+ * the id is searched generation by generation, one open for each other generation in its bucket, and with the
474
+ * keep-everything default of the `*Into` verbs that is one per generation the segment ever had. The per-op
475
+ * `budget` is charged one unit for each segment and one for each generation opened beyond the one its row names,
476
+ * so a fleet with long histories can exhaust it where the segment count alone would not. A segment that does is
477
+ * reported `erased: false` with an `error:` note, before it deletes anything (the one refusal that can come after a
478
+ * rewrite is the last check for a generation a concurrent writer left behind), and the
479
+ * rest of the scan continues: the call itself does not throw for it. Per-subject crypto-shred is infeasible (a subject's bit is
380
480
  * co-mingled in a shared container), so this is the single-subject erasure route; whole-segment/tenant erasure
381
- * is the `destroySegment`/`eraseNamespace` free functions.
481
+ * is `dropSegment` / the `destroySegment`/`eraseNamespace` free functions.
382
482
  */
383
- eraseSubject(id: number, options: {
384
- owner: string;
483
+ eraseSubject(id: number, options?: {
385
484
  namespace?: string;
386
485
  allNamespaces?: boolean;
387
486
  audit?: IAuditSink;
@@ -389,20 +488,156 @@ declare class CloudRoaring {
389
488
  budget?: BudgetOption;
390
489
  }): Promise<EraseSubjectResult>;
391
490
  /**
392
- * **Compact one segment now**, in-process, using the store's own drivers — a convenience over the
393
- * {@link compactSegment} free function. Merges `(cold ∪ adds) \ removes` into a fresh immutable generation,
394
- * swaps `currentGen`, then version-fenced-purges the archived Warm rows. A no-op (`compacted:false`) when the
395
- * segment has no dirty rows or a daemon holds a live lease — never throws on the normal contention/lease paths.
396
- * Requires the store built with a **raw cold driver + registry** (throws {@link UnsupportedError} otherwise);
397
- * like the daemon, it uses the raw drivers directly (a transient fault surfaces to you rather than retrying
398
- * under the hood). **Not for the request/hot path** — it reads and rewrites a whole Cold generation (streaming,
399
- * constant-memory, but full-segment I/O). Use the `compact-segments` daemon (`runCompactionCycle`) for routine
400
- * background compaction; reach for `store.compact()` for occasional/manual one-shots — a maintenance endpoint,
401
- * a small daemon-less deployment, or right after a targeted `remove`.
491
+ * Every generation still in the bucket for this segment, ascending, with the current one marked.
492
+ *
493
+ * What the bucket holds, not what the segment has ever been — collection deletes superseded objects, so this is
494
+ * the grace window plus whatever has not been collected yet. It is the set {@link CloudRoaring.rollback} can
495
+ * choose from, which is the reason to look at it. One registry read and one listing; it does not open the objects.
496
+ *
497
+ * Needs a backend.
498
+ */
499
+ generations(ref: SegmentRef): Promise<GenerationEntry[]>;
500
+ /**
501
+ * Whether a read of this segment would find anything — the "do I already have this?" question, as one
502
+ * registry point read.
503
+ *
504
+ * There is no `create` in this library: {@link CloudRoaring.segment} is a validated address and does no I/O,
505
+ * so naming a segment can never collide with an existing one. A segment starts existing when something is
506
+ * first loaded into it, and this is how you ask whether that has happened.
507
+ *
508
+ * **Not the same as `count() > 0`.** A segment loaded with no ids exists and counts zero. Distinguishing
509
+ * "never loaded" from "loaded, and genuinely empty" is the thing `count()` cannot do and the reason this
510
+ * exists. It is `false` for a segment whose row was minted ahead of its first load (by `setRetention`) and
511
+ * for a `destroyed` tombstone, because a read answers empty in both cases.
512
+ *
513
+ * Two states answer `true` where a read still gives you nothing: a torn restore (a live pointer whose object
514
+ * was deleted) makes reads *throw* rather than answer empty — `checkConsistency` is the call for that — and
515
+ * a handle carrying an expired `expiresAt` reads empty by a rule that lives on the handle, not the row.
516
+ *
517
+ * Not a lock: the answer can change the moment it returns. If it has to hold, use the fence built for that —
518
+ * `load`'s `guard`, or `expectFrom`/`expectToken` on a publish. Needs a storage backend.
519
+ *
520
+ * ```ts
521
+ * if (!(await store.exists({ segment: 'users' }))) {
522
+ * await store.load({ segment: 'users' }, idsFromUpstream);
523
+ * }
524
+ * ```
525
+ */
526
+ exists(ref: SegmentRef): Promise<boolean>;
527
+ /**
528
+ * Every segment the registry knows about, streamed — optionally scoped to one namespace.
529
+ *
530
+ * The registry is already the list of your segments, which is why you should not keep a second one beside it:
531
+ * a hand-maintained list is a source of truth that drifts from this one the first time a load fails halfway.
532
+ *
533
+ * **An admin/discovery call, not a request-path one.** This is the registry's own enumeration — a paged
534
+ * LIST over the `registry/` prefix — so its cost grows with the size of the fleet rather than with what you
535
+ * are looking for.
536
+ *
537
+ * Scoping to a namespace narrows the LIST prefix, so it really is the difference between reading one tenant
538
+ * and reading all of them.
539
+ *
540
+ * It streams, and stopping the iteration stops the scan. It reads the registry directly and is not retried: a
541
+ * transient fault part-way through ends the loop with that error, and calling `segments()` again scans from the
542
+ * start.
543
+ *
544
+ * Yields `destroyed` tombstones and rows with `currentGen: null`, because a filtered enumeration that looks
545
+ * complete is worse than an honest one — filter on `status`/`currentGen` yourself, or ask
546
+ * {@link CloudRoaring.exists} the narrower question. Needs a storage backend.
547
+ *
548
+ * ```ts
549
+ * for await (const s of store.segments({ namespace: 'active-daily' })) {
550
+ * console.log(s.segment, s.currentGen, s.status);
551
+ * }
552
+ * ```
553
+ */
554
+ segments(options?: {
555
+ namespace?: string;
556
+ }): AsyncIterable<SegmentInfo>;
557
+ /**
558
+ * Move this segment's pointer **back** to a generation still in the bucket — the one write in the library that
559
+ * is not forward-only.
560
+ *
561
+ * Forward-only is right for a writer: a load whose ids came from upstream loses nothing by being out-raced, and
562
+ * letting it regress would let a slow loader silently undo a fast one. It is wrong for an operator who has
563
+ * looked at the segment, decided the current generation is wrong, and knows which one they want. So this is
564
+ * reachable only by asking for it by name — no sweep, retry or reconciliation calls it — and it is audited
565
+ * (`segment.rollback`), because every other pointer move can be reconstructed from "a load happened" and this
566
+ * one cannot.
567
+ *
568
+ * It refuses rather than guesses: a generation not in the bucket (collected, or never written) throws
569
+ * `NotFoundError` naming what *is* available, and a crypto-shredded segment throws
570
+ * {@link ValidationError} because every generation of it is unreadable. Rolling to the generation already
571
+ * current is a no-op that reports itself.
572
+ *
573
+ * It deletes nothing. The generations above the new pointer stay put — which is what makes this reversible —
574
+ * and are then *above* `currentGen`, where collection never looks. They remain until a load numbers above them
575
+ * (collection then keeps the newest `keep` of what is below its pointer), {@link CloudRoaring.dropSegment}
576
+ * deletes them, or {@link CloudRoaring.eraseSubject} does: all of those present when it rewrites, and only those holding
577
+ * the id when the current generation does not. An operator who has just undone a bad load should not have the
578
+ * evidence collected out from under them, while a rollback target that still holds erased data would make the
579
+ * erasure undoable.
580
+ *
581
+ * A target **above** the pointer needs `{ allowForward: true }`, and is refused with {@link ValidationError}
582
+ * without it: that is also where objects live that were never published, such as a load that wrote its object
583
+ * and died before the publish. Undoing an earlier rollback is what the opt-in is for.
584
+ *
585
+ * Needs a backend.
586
+ */
587
+ rollback(ref: SegmentRef, toGeneration: number, options?: {
588
+ audit?: IAuditSink;
589
+ allowForward?: boolean;
590
+ }): Promise<RollbackResult>;
591
+ /**
592
+ * **Replace this segment's contents** with `ids`, as one new immutable generation, and make it current.
593
+ *
594
+ * The whole write path in one call: take the next generation number, write the object, check the result is
595
+ * plausible, move the pointer, collect what the move superseded. Composed by hand those are four functions and
596
+ * the one that gets left out is the last, so segments quietly accumulate superseded generations nobody pays
597
+ * attention to and everybody pays for.
598
+ *
599
+ * ```ts
600
+ * const r = await store.load({ segment: 'audience:active' }, idsFromWarehouse, {
601
+ * guard: { minRetained: 0.5 }, // refuse a load that would drop more than half the segment
602
+ * });
603
+ * if (!r.published) console.warn(`load refused: ${r.reason}`);
604
+ * ```
605
+ *
606
+ * **A load REPLACES.** Whatever the stream contains is what the segment contains afterwards, so an upstream
607
+ * query that returns fewer rows than usual is a shrink nobody asked for and an empty one is a wipe — both
608
+ * reported as a successful write, because at the storage layer they are one. That is what `guard` and the
609
+ * default empty refusal are for, and why they run **between** the write and the publish: the only moment where
610
+ * the new content is known and the old one is still authoritative.
611
+ *
612
+ * **Branch on `published`.** A refusal is a normal outcome, not an exception — `published: false` with a
613
+ * `reason`, in the same shape as a success — so a discarded result is a load that silently did nothing, and
614
+ * with the empty guard on by default this verb refuses more readily than any other. Note the sibling `*Into`
615
+ * verbs *throw* on the same superseded condition rather than reporting it; this one follows
616
+ * `eraseIdFromSegment` instead, because three of its four refusals are expected guard outcomes rather than
617
+ * faults.
618
+ *
619
+ * `'superseded'` means another writer got there first: another load took the same generation number, so this
620
+ * one wrote nothing (`size: 0`), or the segment's registry row changed while the load was writing — another load
621
+ * published, a retention change, a rollback or an erasure wrote the row, or the row was deleted.
622
+ *
623
+ * A refused load deletes the object it wrote before returning — it sits above `currentGen`, where generation
624
+ * collection deliberately never looks — but only while the segment's registry row is unchanged or gone. Once
625
+ * another write has changed the row, the generation number it holds may name another incarnation's live object,
626
+ * so it leaves the orphan rather than risk deleting live data. The orphan is an ordinary generation once a later
627
+ * one is current above it, and collection counts it within `keep`.
628
+ *
629
+ * What it **throws** for is a fault rather than an outcome: invalid options or ids, and a crypto-shredded
630
+ * segment (`ValidationError`); a key the keystore cannot provide (`KeyUnavailableError`); a current generation
631
+ * that will not open when a guard reads its size (`IntegrityError`); a driver failure; and a collection pass
632
+ * that could not prove the segment was still the same one (`WriteConflictError`). The last can be raised
633
+ * **after** the publish already landed, so a throw does not by itself mean the load did not take effect —
634
+ * re-read the pointer rather than assuming.
635
+ *
636
+ * Needs a backend (throws {@link UnsupportedError} otherwise).
402
637
  */
403
- compact(ref: SegmentRef, options: CompactionOptions): Promise<CompactionResult>;
638
+ load(ref: SegmentRef, ids: Iterable<number> | AsyncIterable<number>, options?: LoadOptions): Promise<LoadResult>;
404
639
  /**
405
- * **Dispose of a segment — tombstone it, delete its Warm rows, delete its Cold objects.** Irreversible.
640
+ * **Dispose of a segment — tombstone it, then delete its storage objects.** Irreversible.
406
641
  *
407
642
  * The operation a rolling window needs: `destroySegment` crypto-shreds (bytes unreadable everywhere including
408
643
  * backups, but still sitting in your bucket and still billed, and it requires encryption), while this one
@@ -414,7 +649,8 @@ declare class CloudRoaring {
414
649
  *
415
650
  * ```ts
416
651
  * for (const day of expiredDays) {
417
- * // A colon is NOT legal in a name — the family goes in the namespace, the date in the segment.
652
+ * // The family in the namespace, the date in the segment — `registry.list(namespace)` then enumerates
653
+ * // exactly this family's buckets, which a flat `active:2026-08-01` name cannot do without string-matching.
418
654
  * const ref = { namespace: 'active-daily', segment: day };
419
655
  * await store.dropSegment(ref, { confirmSegment: ref.segment });
420
656
  * }
@@ -424,24 +660,23 @@ declare class CloudRoaring {
424
660
  * `{ dropped: false, reason: 'absent' }`, not a throw, so a retention loop with that mistake deletes nothing
425
661
  * forever and quietly. Branch on `dropped`, and treat `reason: 'absent'` as the alert.
426
662
  *
427
- * **Works on an accumulator too.** A segment you created by writing to it — never bulk-loaded, never compacted,
428
- * so it has no registry row and no Cold objects — is retired by deleting its Warm rows alone. That reports
429
- * `{ dropped: true, reason: 'warm-only' }`: there is no tombstone to write, and deliberately none is written,
430
- * because a `destroyed` row per retired daily bucket would be registry litter. The data really is gone.
431
- *
432
663
  * **Inspect `generationsRemaining`.** Empty is the normal outcome; non-empty means the storage was NOT fully
433
- * reclaimed and the drop should be re-run. A compaction that was already in flight when the tombstone landed
434
- * still finishes staging one more object, so a single sweep can miss it — this call re-sweeps and then reports
435
- * whatever it still could not remove rather than returning a result that looks like a clean drop.
664
+ * reclaimed and the drop should be re-run. A load that was already writing when the tombstone landed still
665
+ * finishes its object, so a single sweep can miss it — this call re-sweeps and then reports whatever it still
666
+ * could not remove rather than returning a result that looks like a clean drop.
436
667
  *
437
- * Reads become empty within `coldGenTtlMs` (default 2 s), not instantly: a store that had already read this
438
- * segment may answer from its cached generation + hot chunks until that window lapses. A reader that never
439
- * touched it sees empty at once. **That bound needs a clock and `coldGenTtlMs > 0`** — a store built without a
440
- * clock, or with `coldGenTtlMs: 0` ("pin forever"), holds its resolved snapshot for its own lifetime and can
441
- * keep answering `true` for a dropped segment indefinitely; restart it.
668
+ * Reads become empty within `cache.genTtlMs` (default 2 s), not instantly: a store that had already read this
669
+ * segment may answer from its cached generation + cached chunks until that window lapses, or, while the registry
670
+ * cannot be read because of a transient fault, until a retry 500 ms apart reaches it again. A reader that never
671
+ * touched it sees empty at once. **That bound needs a registry and `cache.genTtlMs > 0`.** A store with no registry
672
+ * (a bare `IStorageDriver`), with `cache.genTtlMs: 0`, or on a storage source built with no clock, has no timed
673
+ * refresh. It notices the drop only when a read has to fetch
674
+ * from a deleted generation or its reader cache evicts the segment, and until then a chunk it has cached
675
+ * answers without reaching storage, so it can keep answering `true` for a dropped segment indefinitely. Tell
676
+ * it with {@link invalidate}, or restart it.
442
677
  *
443
- * Needs the store built with a **raw cold driver + a registry** (throws {@link UnsupportedError} otherwise),
444
- * because it has to enumerate and delete generations — a pre-built `ColdChunkSource` only reads.
678
+ * Needs the store built with a **backend** (throws {@link UnsupportedError} otherwise),
679
+ * because it has to enumerate and delete generations — a pre-built `StorageChunkSource` only reads.
445
680
  */
446
681
  dropSegment(ref: SegmentRef, options: {
447
682
  confirmSegment: string;
@@ -455,9 +690,9 @@ declare class CloudRoaring {
455
690
  * no background thread (it has to work identically in a Lambda, an edge isolate and a long-lived server).
456
691
  *
457
692
  * `expiresAt` is an **absolute epoch-ms you compute**, not a duration the library derives. A relative TTL would
458
- * have to be anchored to something the library knows — `updatedAt`, or the current generation — and compaction
693
+ * have to be anchored to something the library knows — `updatedAt`, or the current generation — and every load
459
694
  * rewrites both, so "expire 30 days after the last write" would keep a busy daily bucket alive forever
460
- * precisely because the daemon is working.
695
+ * precisely because it is being reloaded.
461
696
  *
462
697
  * ```ts
463
698
  * const DAY = 86_400_000;
@@ -465,15 +700,14 @@ declare class CloudRoaring {
465
700
  * await store.setRetention(ref, { expiresAt: Date.now() + 30 * DAY });
466
701
  * ```
467
702
  *
468
- * **Works on an accumulator**, which is the whole point: a segment you created by writing to it has no
469
- * registry row, so it is invisible to `registry.list()` and therefore to every fleet-wide operation, including
470
- * the sweep. This call mints the row (`createdRow: true` in the result) with **no Cold generation**, so it
471
- * becomes enumerable while every read still resolves exactly as before.
703
+ * **Works before the first load**: a segment with no registry row yet gets one (`createdRow: true` in the
704
+ * result) with **no Storage generation**, so the policy is recorded ahead of the data and the segment is already
705
+ * enumerable by the sweep; the first load then publishes onto that row.
472
706
  *
473
707
  * A value in the past is legal and means "eligible on the next sweep" — backfilling a policy onto existing
474
708
  * buckets is normal. A value below `MIN_EXPIRES_AT_MS` (2001-09-09) is rejected: it is almost certainly epoch
475
- * **seconds**, which would read as long-expired and retire the segment on the next pass. Needs a `registry`
476
- * in the store config (throws {@link UnsupportedError} otherwise), and refuses a crypto-shredded segment.
709
+ * **seconds**, which would read as long-expired and retire the segment on the next pass. Needs a storage
710
+ * backend (throws {@link UnsupportedError} otherwise), and refuses a crypto-shredded segment.
477
711
  */
478
712
  setRetention(ref: SegmentRef, policy: RetentionPolicy): Promise<SetRetentionResult>;
479
713
  /**
@@ -493,17 +727,17 @@ declare class CloudRoaring {
493
727
  * **Run the retention sweep: retire every segment whose `expiresAt` has passed.** This is the call that acts on
494
728
  * the policies `setRetention` records — and it is a **call, not a daemon**. Nothing schedules it; you run it from
495
729
  * whatever heartbeat your deployment already has (an EventBridge rule, a Kubernetes CronJob, a queue consumer,
496
- * your compaction worker's loop). A library that started a timer would behave differently in a Lambda, an edge
730
+ * the job that runs your loads). A library that started a timer would behave differently in a Lambda, an edge
497
731
  * isolate and a long-lived server, which is worse than not having one.
498
732
  *
499
- * Each retirement goes through `dropSegment`, so the Warm → registry → Cold ordering, the re-sweep for a
500
- * generation staged by an in-flight compaction, and the `generationsRemaining` report all come from one
501
- * implementation rather than two.
733
+ * Each retirement goes through `dropSegment`, so the registry → Storage ordering, the re-sweep for an object a
734
+ * load was still writing, and the `generationsRemaining` report all come from one implementation rather than
735
+ * two.
502
736
  *
503
737
  * ```ts
504
738
  * // In your scheduled handler. Start with a preview in a new deployment.
505
739
  * const preview = await store.retireExpired({ namespace: 'active-daily', dryRun: true });
506
- * console.log(`would retire ${preview.retired} of ${preview.scanned} (limited: ${preview.limited})`);
740
+ * console.log(`would retire ${preview.wouldRetire} of ${preview.scanned} (limited: ${preview.limited})`);
507
741
  *
508
742
  * const swept = await store.retireExpired({ namespace: 'active-daily' });
509
743
  * for (const e of swept.entries) {
@@ -519,76 +753,162 @@ declare class CloudRoaring {
519
753
  * sweep would leave the caller unable to say which segments were retired, having already retired some. (A bad
520
754
  * argument does throw, and so does a fleet larger than `maxScanSegments`.) `limited: true` means the per-cycle
521
755
  * `limit` (default 100) cut the pass short and more are eligible. That cap is charged on **attempts**, not
522
- * successes, which is what makes it a real bound: `dropSegment` deletes Warm and writes the tombstone before
523
- * sweeping Cold, so a fault in the Cold phase is a segment that is already retired. Retirements are sequential
524
- * (~8 round trips each), so `limit` is a wall-clock knob too, and `retired` counts deletions only — a dry run
525
- * reports `wouldRetire` instead, so a dashboard summing `retired` can never show a phantom deletion.
756
+ * successes, which is what makes it a real bound: `dropSegment` writes the tombstone before sweeping Storage, so a
757
+ * fault in the Storage phase is a segment that is already retired. Retirements are sequential, so `limit` is a
758
+ * wall-clock knob too, and `retired` counts deletions only — a dry run reports `wouldRetire` instead, so a
759
+ * dashboard summing `retired` can never show a phantom deletion.
526
760
  *
527
761
  * It also **deletes the tombstone rows its own past retirements left**, after `tombstoneGraceMs` (default 24 h)
528
- * and only once that segment's Warm rows and Cold generations are provably gone — collecting a straggler
529
- * generation itself first, since nothing else ever would for a tombstoned segment. Attribution is a **positive
530
- * marker the sweep stamps on its own retirements**, not an inference from "destroyed + an expired policy": a
531
- * crypto-shred leaves `retention` untouched, so setting a policy and then honouring a right-to-erasure request
532
- * mid-window produces exactly that row, and deleting it would destroy the Art. 17 attestation and un-fence the
533
- * name. Pass `purgeTombstones: false` to keep every tombstone.
762
+ * and only once that segment's Storage generations are provably gone — collecting a straggler generation itself
763
+ * first, since nothing else ever would for a tombstoned segment. Attribution is a **positive marker the sweep
764
+ * stamps on its own retirements**, not an inference from "destroyed + an expired policy": a crypto-shred leaves
765
+ * `retention` untouched, so setting a policy and then honouring a right-to-erasure request mid-window produces
766
+ * exactly that row, and deleting it would destroy the Art. 17 attestation and un-fence the name. By default a
767
+ * segment that held nothing has its row deleted in the pass that retires it, since that row would only fence the name.
768
+ * Pass `purgeTombstones: false` to keep every tombstone, that row included.
534
769
  *
535
- * Needs the store built with a **raw cold driver + a registry** (throws {@link UnsupportedError} otherwise),
536
- * because retiring a segment deletes its Cold objects. `now` defaults to the store's clock.
770
+ * Needs the store built with a **backend** (throws {@link UnsupportedError} otherwise),
771
+ * because retiring a segment deletes its storage objects. `now` defaults to the store's clock.
537
772
  */
538
773
  retireExpired(options?: Omit<RetireExpiredOptions, 'now'> & {
539
774
  now?: number;
540
775
  }): Promise<RetireExpiredResult>;
776
+ /**
777
+ * **Forget everything this store has cached about a segment.** Use it after destroying or retiring a segment
778
+ * through a path this store did not perform itself.
779
+ *
780
+ * A store keeps two layers of derived state — a resolved snapshot per segment (an open `.crbm` reader, plus
781
+ * the DEK it unwrapped) and decoded chunks keyed by generation. Both are built for one event: **a publish
782
+ * that advances `currentGen`**, which the snapshot TTL notices and the generation-keyed cache misses on.
783
+ * Neither notices an event that *destroys* what they were derived from.
784
+ *
785
+ * The store's own writes handle themselves: `load`, `rollback`, `eraseSubject`, `dropSegment` and `retireExpired`
786
+ * here, and a segment's `*Into` verbs, invalidate what they touch. This method is for the cases they cannot see:
787
+ *
788
+ * - **`destroySegment` / `eraseNamespace`**, which are free functions over raw drivers rather than methods
789
+ * here, so a crypto-shred performed beside this store invalidates nothing in it. It keeps its open reader and
790
+ * the unwrapped DEK, and decrypts with them, chunks it had never fetched before the shred included, until its
791
+ * snapshot re-resolves: within `cache.genTtlMs` when the store has a registry and a `genTtlMs` above 0, and
792
+ * otherwise only when its caches let the segment go or a read has to fetch from a generation a sweep collected.
793
+ * A pin answers with no timed bound, until it is told or both caches let it go.
794
+ * - **Another process.** Erasing on one box invalidates nothing on the others; each store bounds its own
795
+ * staleness by `cache.genTtlMs`, and a store with no registry (a bare `IStorageDriver`), with
796
+ * `cache.genTtlMs: 0`, or on a storage source built with no clock has no bound at all: it converges only when a read happens to miss its caches. If a compliance deadline depends on every
797
+ * reader converging, you need to signal them — this is the call to make when your own fan-out delivers.
798
+ *
799
+ * It also forgets any replacement this store found of a pin's object, so once it is called after a restore puts that
800
+ * object back, the pin reads it again.
801
+ *
802
+ * Synchronous, best-effort, and safe to call for a segment this store has never read.
803
+ *
804
+ * ```ts
805
+ * await destroySegment(ref, { registry: backend.registry }, { confirmSegment: ref.segment });
806
+ * store.invalidate(ref); // this process
807
+ * await bus.publish('cloudbitmaps.invalidate', ref); // and every other one
808
+ * ```
809
+ */
810
+ invalidate(ref: SegmentRef): void;
811
+ /**
812
+ * Build a handle held at the generation `ref` resolves to right now. See {@link Segment.pin}.
813
+ *
814
+ * The pinned handle gets its own engine but **shares the store's chunk cache**, under keys of its own: the
815
+ * pinned view reports the version captured at pin time, marked as a pin's, so its decoded chunks are never
816
+ * those of a live read that fetched across a publish (see {@link PinnedStorageChunkSource.currentVersion}).
817
+ */
818
+ private pinSegment;
819
+ /**
820
+ * An engine reading every segment in `pins` at its pinned generation and everything else live, with the store's
821
+ * retries, so a transient fault on a pinned read is retried as it would be on a live one.
822
+ *
823
+ * It **shares the store's chunk cache and its memory bound**, under keys of its own for each pinned segment.
824
+ */
825
+ private engineWithPins;
826
+ /**
827
+ * The engine a combine should run on, given the handles involved.
828
+ *
829
+ * A pinned handle passed as an **operand** must still be read at its pin. Routing by "which handle the call
830
+ * was made on" reads it live instead — `snap.intersect([other])` honours the pin while
831
+ * `other.intersect([snap])` silently does not, and the two are the same question. So the combine collects
832
+ * every pin in play and runs on an engine that honours all of them. With no pins anywhere this is the
833
+ * store's own engine and costs nothing.
834
+ *
835
+ * One engine reads a segment at one generation, so a combine that holds the same segment at two — two pins of
836
+ * it, or a pin and a live handle — is refused. Keeping one of them would answer the question for the wrong
837
+ * instant, and say nothing: diffing two snapshots of a segment returned no ids at all.
838
+ */
839
+ private engineForCombine;
840
+ /** `op` under the store's retries, as the store's own reads run; bare when the store has retries off. */
841
+ private withRetries;
541
842
  /** The store's registry, or a typed error naming the operation that needs one. */
542
843
  private requireRegistry;
543
844
  /**
544
- * **Cross-tier DR consistency check (audit gap #11).** After a restore/failover, verify every registered
545
- * segment's `currentGen` actually has its `.crbm` present in Cold — catching a **torn restore** where the
546
- * registry (`currentGen`) came back ahead of the object store, so a pointer references a generation that
547
- * isn't there (reads would then throw). Read-only, bounded fan-out; run it at startup after a restore.
548
- * Returns `{ checked, inconsistent }` — `inconsistent` empty ⇒ coherent; otherwise it names the segments to
549
- * recover (restore the object store, or roll the registry back to a coherent point). Needs the store built
550
- * with a **raw cold driver + a registry** (throws {@link UnsupportedError} otherwise). `destroyed`
551
- * (crypto-shredded) segments are skipped. Pair it with the DR runbook (docs/guide/disaster-recovery.md).
845
+ * **Cross-tier DR consistency check.** After a restore/failover, verify every registered segment's `currentGen`
846
+ * actually has its `.crbm` present in Storage — catching a **torn restore** where the registry (`currentGen`) came
847
+ * back ahead of the object store, so a pointer references a generation that isn't there (reads would then
848
+ * throw). Read-only, bounded fan-out; run it at startup after a restore. Returns `{ checked, inconsistent,
849
+ * errored }` — `inconsistent` empty ⇒ coherent; otherwise it names the segments to recover (restore the object
850
+ * store, or roll the registry back to a coherent point). Needs the store built with a **backend**
851
+ * (throws {@link UnsupportedError} otherwise). `destroyed` (crypto-shredded) segments are skipped. Pair it with
852
+ * the DR runbook (docs/guide/disaster-recovery.md).
853
+ *
854
+ * It holds the registry rows it enumerates resident, at most 250,000 of them, and throws
855
+ * `BudgetExceededError` past that rather than report a partial scan as a whole one. This method takes no
856
+ * ceiling of its own: narrow the scan with `namespace`, or call `runConsistencyCheck` over the backend's
857
+ * `storage` and `registry` with a higher `maxScanSegments`.
552
858
  */
553
859
  checkConsistency(options?: {
554
860
  namespace?: string;
555
861
  concurrency?: number;
556
862
  }): Promise<ConsistencyReport>;
557
863
  /**
558
- * **Export ("eject") every registered segment's current effective set** through the injected `sink`, using
559
- * only public read APIs — so your data is readable **without CloudRoaring** (the exit path; see the README's
560
- * "Your data stays yours"). `format: 'roaring'` (default) writes one **portable RoaringBitmap32** per segment
864
+ * **Export ("eject") every registered segment's current generation** through the injected `sink`, using only
865
+ * public read APIs — so your data is readable **without CloudRoaring** (the exit path; see the README's "Your
866
+ * data stays yours"). `format: 'roaring'` (default) writes one **portable RoaringBitmap32** per segment
561
867
  * (loadable by any roaring library); `'ndjson'` writes newline-delimited ids (zero-dependency, streaming).
562
868
  * Enumerates via the store's **own** registry (needs one — throws {@link UnsupportedError} otherwise) so the
563
- * enumeration and the read path provably share one registry; an all-warm segment not yet in the registry can be
564
- * named via `options.candidates`. Encrypted segments are decrypted transparently **iff** this store was wired
565
- * with their keystore — the export is therefore **cleartext**; protect it. Crypto-shredded segments are skipped.
566
- * A segment that can't be read is isolated into the manifest's `failed[]` (the run continues), so "a manifest
567
- * exists" means the run finished — check `failed`. The `export-segments` CLI wraps this with a filesystem sink.
869
+ * enumeration and the read path provably share one registry. Encrypted segments are decrypted transparently
870
+ * **iff** this store was wired with their keystore — the export is therefore **cleartext**; protect it.
871
+ * Crypto-shredded segments are skipped. A segment that can't be read is isolated into the manifest's `failed[]`
872
+ * (the run continues), so "a manifest exists" means the run finished — check `failed`. The `export-segments`
873
+ * CLI wraps this with a filesystem sink.
568
874
  */
569
875
  exportSegments(sink: ExportSink, options?: ExportOptions): Promise<ExportManifest>;
570
876
  /**
571
- * Planning cost estimate (Phase 5b) — pure, no instance/data needed: sizing, sales, what-if. For a real,
572
- * grounded report from live segment sizes, use `store.segment(name).costReport()`. See {@link CostReport}.
877
+ * Planning cost estimate — pure, no instance/data needed: sizing, sales, what-if. For a real, grounded report
878
+ * from live segment sizes, use `store.segment(name).costReport()`. See {@link CostReport}.
573
879
  */
574
880
  static estimateCost(input: EstimateInput): CostReport;
575
881
  }
576
882
  /**
577
- * A handle bound to one segment — the read/write ops.
578
- *
579
- * **IDs must be integers in `[0, 2^32)`** (dense 32-bit). A non-integer / negative / out-of-range id
580
- * throws {@link ValidationError}.
883
+ * Options common to every chunk-aligned combine (`intersect` / `union` / `andNot`), and the `*Into` verbs.
581
884
  *
582
- * **`addMany`/`removeMany`/`intersectInto`/`unionInto`/`andNotInto` are not atomic across chunks**: ids are grouped by chunk and
583
- * applied one chunk at a time, so if a later chunk fails (e.g. {@link WriteConflictError} after retries)
584
- * earlier chunks are already applied. Within a single chunk the update is atomic.
885
+ * The range, `after` and `through`, applies to every operand and every `exclude`: the combine yields only the ids
886
+ * in `(after, through]` and fetches only the chunks the range overlaps, as {@link Segment.iterate} does.
585
887
  */
586
- /** Options common to every chunk-aligned combine (`intersect` / `union` / `andNot`). */
587
- interface BaseCombineOptions {
588
- /** Max chunk keys resolved concurrently — bounds the Cold footprint. A positive integer. */
888
+ export interface BaseCombineOptions extends IdRange {
889
+ /** Max chunk keys resolved concurrently — bounds the Storage footprint. A positive integer. */
589
890
  readonly concurrency?: number;
590
891
  /** Override the store's per-op denial-of-wallet budget for this call (`false` lifts it). */
591
892
  readonly budget?: BudgetOption;
893
+ /**
894
+ * Allow an operand naming a segment that does not exist. Default `false`: a combine **refuses** one, because
895
+ * a misspelled or mis-namespaced operand is indistinguishable from a correct one in the result.
896
+ *
897
+ * Reading a segment that does not exist answers empty, and that is right — but passing one as an operand is
898
+ * different. A suppression list with nobody on it correctly suppresses nothing; one whose `namespace` you
899
+ * omitted *silently* suppresses nothing, and the result is not obviously-empty, it is the full audience and
900
+ * plausibly right. The failure mode is mailing the people who opted out.
901
+ *
902
+ * ```ts
903
+ * // `global-opt-out` lives in the `suppression` namespace. This addresses a DIFFERENT segment:
904
+ * audience.andNot([store.segment('global-opt-out')]); // throws ValidationError
905
+ * audience.andNot([store.segment('global-opt-out', { namespace: 'suppression' })]); // correct
906
+ * ```
907
+ *
908
+ * Set `true` when you genuinely intend to combine against a name that may not exist yet. Checked only for an
909
+ * operand that resolved to no chunks at all, so a normal combine pays nothing for it.
910
+ */
911
+ readonly allowAbsentOperands?: boolean;
592
912
  }
593
913
  /**
594
914
  * A combine that can also subtract.
@@ -597,96 +917,287 @@ interface BaseCombineOptions {
597
917
  * chunk-aligned pass, and each exclude is read **only at the keys that survived** — so a large global opt-out
598
918
  * list costs reads proportional to the audience, not to itself.
599
919
  */
600
- interface CombineOptions extends BaseCombineOptions {
601
- /** Segments whose ids are subtracted from the result. */
920
+ export interface CombineOptions extends BaseCombineOptions {
921
+ /**
922
+ * Segments whose ids are subtracted from the result. An **expired** handle here excludes nothing, in every
923
+ * combine — it is skipped without being read, exactly as in {@link Segment.andNot}.
924
+ */
602
925
  readonly exclude?: Segment[];
603
926
  }
604
- /** {@link CombineOptions} plus the write batching used by the `*Into` variants. */
605
- interface CombineIntoOptions extends CombineOptions {
606
- /** Ids buffered per `addMany` while materializing into `dest`. */
607
- readonly batchSize?: number;
927
+ /**
928
+ * A combine that WRITES its result — the extra options `intersect`/`union`/`andNot` have no use for.
929
+ *
930
+ * Separate from {@link CombineOptions} on purpose: `allowEmpty` and `guard` decide whether a generation is
931
+ * published, and a read verb publishes nothing. Offering them on `intersect()` would be offering a parameter
932
+ * that cannot do anything.
933
+ */
934
+ export interface MaterializeOptions extends CombineOptions {
935
+ /**
936
+ * Audit sink. A materialisation publishes a generation, and a publish is an auditable event
937
+ * (`segment.publish`, exactly as a load emits) — as is a refusal (`segment.load-refused`).
938
+ *
939
+ * It is on the call rather than on the store because that is where every other auditable operation takes it
940
+ * (`eraseSubject`, `dropSegment`, `retireExpired`): the caller who performs the act decides where the record
941
+ * goes. Without it a `*Into` would be the one write path in the library that could make a generation current
942
+ * and leave no trace in the compliance trail.
943
+ *
944
+ * It sits HERE rather than on {@link BaseCombineOptions} for the reason this type exists: the streaming verbs
945
+ * write nothing, so an audit sink on `intersect()` would be a parameter that could not do anything.
946
+ */
947
+ readonly audit?: IAuditSink;
948
+ /**
949
+ * Publish an empty result over a non-empty destination. Off by default, exactly as on {@link CloudRoaring.load}:
950
+ * an empty combine is far more often a mistake upstream — a typo'd operand, a segment that has not loaded
951
+ * yet, an `exclude` that swallowed everything — than an intent, and once it lands it is indistinguishable
952
+ * from a correct run.
953
+ */
954
+ readonly allowEmpty?: boolean;
955
+ /** Refuse an implausible result rather than publish it. Same bounds, and same meaning, as on `load()`. */
956
+ readonly guard?: LoadGuard;
957
+ /**
958
+ * Generations to keep below the new pointer — see {@link LoadOptions.keep}. A value that is not a non-negative
959
+ * integer throws `ValidationError`.
960
+ *
961
+ * **Defaults to keeping everything**, unlike `load()`, which keeps 1 and collects the rest. An operator's
962
+ * recovery story can depend on a materialisation collecting nothing: `rollbackSegment` refuses a target that
963
+ * has been collected. Pass a number to collect on the way through; `0` keeps only the
964
+ * generation this call publishes.
965
+ */
966
+ readonly keep?: number;
608
967
  }
609
- declare class Segment {
968
+ /**
969
+ * What `andNotInto` takes: the write options without `exclude`, because its `excludes` argument IS the
970
+ * subtraction — a second one in the options would be two spellings of one thing.
971
+ *
972
+ * Named rather than left as an inline `Omit` so it can be imported and annotated. The read sibling `andNot`
973
+ * solves the same problem the same way, by taking {@link BaseCombineOptions}.
974
+ */
975
+ export type AndNotIntoOptions = Omit<MaterializeOptions, 'exclude'>;
976
+ /**
977
+ * A handle bound to one segment — the read verbs, plus the three `*Into` verbs that write a **new generation**
978
+ * of another segment.
979
+ *
980
+ * **IDs must be integers in `[0, 2^32)`** (dense 32-bit). A non-integer / negative / out-of-range id
981
+ * throws {@link ValidationError}.
982
+ *
983
+ * A handle has no per-id write: data enters a segment as a whole generation (`store.load()`, or one of the
984
+ * `*Into` verbs), and leaves it the same way (`eraseSubject`, `dropSegment`).
985
+ */
986
+ export declare class Segment {
987
+ private readonly metricsOn;
610
988
  private readonly engine;
611
989
  private readonly ref;
612
990
  private readonly clock;
613
991
  private readonly metrics;
614
- private readonly metricsOn;
615
- constructor(engine: SegmentEngine, ref: SegmentRef, clock: Clock, metrics: IMetricsSink);
992
+ private readonly materialize;
993
+ private readonly pinned;
994
+ private readonly combineEngine;
995
+ /** Absolute epoch-ms deadline from {@link SegmentOptions.expiresAt}; `undefined` ⇒ this handle never expires. */
996
+ readonly expiresAt?: number;
616
997
  /**
617
- * Time an op with the injected clock and emit an `op` metric on completion (success or throw). Skipped
618
- * entirely when no sink is wired, so the default path pays nothing.
998
+ * The generation this handle is held at, when it came from {@link Segment.pin}. Read by the store so a
999
+ * pinned handle passed as an **operand** is still read at its pin rather than live.
619
1000
  */
620
- private timed;
621
- /** Add an id (integer in `[0, 2^32)`). Throws {@link ValidationError} on a bad id. */
622
- add(id: number): Promise<void>;
1001
+ readonly pinnedAt?: PinnedAt;
623
1002
  /**
624
- * Add many ids, from a **sync or async** iterable. Not atomic across chunks — see the class note.
625
- *
626
- * An async source means a database cursor streams straight in — `addMany(athenaCursor())` rather than
627
- * hand-batching `page → addMany(page)`. Ids are grouped by chunk and each chunk is written **exactly once**,
628
- * however long the stream: pending ids are held compressed, not buffered-and-flushed, so a long stream costs
629
- * no more backend writes than a short one. Peak memory is the roaring representation of the ids you pass.
630
- *
631
- * **For a very large set, reach for `bulkLoadCrbmGeneration` instead.** `addMany` expresses a *delta* and
632
- * writes one warm row per touched chunk — around 61,000 of them for a set spread across the whole id space.
633
- * Bulk-load *replaces* the segment with one immutable Cold object. See the guide's cost comparison; picking
634
- * the wrong one of these is the most expensive mistake available in this library.
1003
+ * Not constructible: a handle comes from {@link CloudRoaring.segment}, which wires it to the store's engine,
1004
+ * caches and write path. The constructor takes those internals, so it is not part of the surface.
635
1005
  */
636
- addMany(ids: Iterable<number> | AsyncIterable<number>): Promise<void>;
637
- /** Remove an id (integer in `[0, 2^32)`). Throws {@link ValidationError} on a bad id. */
638
- remove(id: number): Promise<void>;
639
- /** Remove many ids, from a **sync or async** iterable. Not atomic across chunks — see {@link addMany}. */
640
- removeMany(ids: Iterable<number> | AsyncIterable<number>): Promise<void>;
1006
+ private constructor();
641
1007
  /**
642
- * **Claim ids atomically — add them, and get back only the ones that were not already there.**
1008
+ * **Hold this segment at the generation that is current right now**, for as long as you keep the handle.
643
1009
  *
644
- * The durable analogue of Redis `SETBIT` returning the prior bit, which is what an exactly-once *"have I
645
- * already sent to / already processed this id?"* check needs. `has()` then `add()` cannot give you this: two
646
- * workers can both read absent and both proceed.
1010
+ * An ordinary handle re-resolves on `cache.genTtlMs`, so a publish part-way through a long job means its second
1011
+ * half describes a different instant than its first — every chunk whole and verified, but the answer covering
1012
+ * two moments, with nothing in the result saying so. That is fine for a dashboard and wrong for an export, a
1013
+ * reconciliation, or a send that has to match the count you reported. A pin is how you get one instant.
647
1014
  *
648
1015
  * ```ts
649
- * const sent = store.segment(day, { namespace: 'sent-daily' });
650
- * const toSend = await sent.claimMany(candidateIds); // only the ids this worker won
651
- * for (const id of toSend) enqueue(id); // ...so nobody else will send them
1016
+ * const snap = await store.segment('active-30d').pin();
1017
+ * const total = await snap.count(); // the number you report
1018
+ * for await (const id of snap.iterate()) { … } // …and the ids it counted, however long this takes
652
1019
  * ```
653
1020
  *
654
- * **Pass a batch, not one id.** A Warm write rewrites a whole 64K-id chunk bitmap, so claiming ids one at a
655
- * time is the single most expensive way to use this library — measured at ~5,000 writes and ~23 MB for 5,000
656
- * ids, against 1 write and ~8 KB for the same ids in one call. This method does one OCC read-modify-write per
657
- * distinct chunk, giving you Redis's *semantics* without Redis's per-id *cost shape*.
1021
+ * **Only this segment is pinned.** `snap.intersect([other])` reads `snap` at its pinned generation and
1022
+ * `other` at whatever is current — pin each segment if you want the whole query held. And a pinned handle
1023
+ * used as an *operand* is still read at its pin, never live.
1024
+ *
1025
+ * **One call reads a segment at one generation**, so a combine that holds this segment at two —
1026
+ * `snap0.andNot([snap1])`, or `live.intersect([snap])` — throws {@link ValidationError} when it is read, and an
1027
+ * `*Into` of one throws before it reads anything. Materialise one side first, with `intersectInto(dest, [])`.
1028
+ *
1029
+ * **A pin knows its object, not only its number.** A name purged and loaded again starts again at generation
1030
+ * 0, so a pin of the old segment never reads the new one: what it has already read still answers, as the
1031
+ * instant it pinned, and anything it would have to fetch fails with `NotFoundError`, as a swept pin's does.
1032
+ *
1033
+ * **It is a hold, not a lease.** Nothing here stops a collection deleting the generation underneath
1034
+ * you: a pinned read deliberately does **not** heal forward, because silently serving a different generation
1035
+ * is the one thing a pin exists to prevent, so it fails instead. Size `keep` to cover your longest pinned
1036
+ * job — see [Generations and `keep`](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/loading.md#generations-and-keep) — or take the pin on a segment
1037
+ * you are not collecting.
1038
+ *
1039
+ * A segment with no current generation pins nothing and reads empty, exactly as it would unpinned. A pinned
1040
+ * segment whose row is later dropped or destroyed fails once it must open its object again, rather than go empty
1041
+ * part-way through a call. **A pin
1042
+ * keeps the key its reader unwrapped for as long as that reader stays open, and answers from the chunks it
1043
+ * decoded for as long as they stay cached.** This store invalidates a pin:
1044
+ *
1045
+ * - a `load`, a `rollback` or an `*Into` invalidates a pin of the segment it writes;
1046
+ * - `dropSegment` invalidates a pin of the segment it drops, and `retireExpired` a pin of each segment its ledger
1047
+ * lists, retired or not, neither on a dry run;
1048
+ * - `eraseSubject` invalidates a pin of each segment it scans that is not already destroyed.
1049
+ *
1050
+ * An invalidated pin opens its object again, and fails if that object is gone or replaced, or its row is gone or
1051
+ * destroyed.
1052
+ * Anything else leaves the pin as it is: after a `destroySegment` beside this store, or an erasure, a drop or a
1053
+ * retirement through another store, in this process or another, the pin answers from what it holds until this
1054
+ * store's reader cache evicts the pin's reader and its chunk cache evicts the chunks the pin decoded, or
1055
+ * {@link CloudRoaring.invalidate} is called on this store. Where the
1056
+ * object the pin reads has been deleted, by an erasure, a drop or a sweep, a chunk the pin has not cached fails at
1057
+ * once. No timed refresh bounds any of that, as none bounds anything else a pin holds.
1058
+ *
1059
+ * A replacement a pin has found is remembered, so its later chunk reads fail with no request, until the store
1060
+ * forgets it: an invalidation of the segment does, as does a later `pin()` of the same version that opens the object
1061
+ * again, and the store remembers at most `cache.readerMax` of them. So once a restore puts the pinned object back,
1062
+ * call {@link CloudRoaring.invalidate} on this store: the pin then reads the object again.
658
1063
  *
659
- * **Exactly-once holds per id**, which is the guarantee that matters: each id lives in one chunk, a chunk is
660
- * one OCC row, so exactly one concurrent claimer sees any given id as new. Like `addMany` it is **not** atomic
661
- * across chunks — a mid-flight failure can leave some chunks claimed — but re-running is safe, because
662
- * already-claimed ids simply come back as not-new.
1064
+ * `pin()` reads the registry row and opens the generation at once, so the pin knows its object before its first
1065
+ * read. With a registry, pins of one generation taken while its row is unchanged share one reader while the store
1066
+ * keeps it open, pins taken at the same moment included: the first costs a tail read, and a key unwrap for an
1067
+ * encrypted segment, whether or not it is read, and the rest cost the row read alone. Without a registry every
1068
+ * `pin()` lists the segment's objects and makes the tail read, since only the object can tell two incarnations of
1069
+ * a name apart there. It retries a
1070
+ * transient fault as the store's reads do, and heals a generation swept between the two, but it fails where the
1071
+ * generation cannot be opened: `NotFoundError` for a pointer at a missing object, `IntegrityError` for a damaged
1072
+ * or misfiled one.
663
1073
  *
664
- * Order of the returned ids is unspecified.
1074
+ * Needs a store built on the `.crbm` storage source (the default when you pass a backend or a raw driver). Throws
1075
+ * {@link UnsupportedError} on a store wired with a pre-built source that cannot pin.
665
1076
  */
666
- claimMany(ids: Iterable<number>): Promise<number[]>;
1077
+ pin(): Promise<Segment>;
1078
+ /**
1079
+ * An opaque string that names this handle's segment, namespace included: two handles of one segment have the
1080
+ * same key, a live handle and its pins among them. Use it as a `Map` key or a log field when you track handles
1081
+ * and have not kept the name you made them with. The format is not specified and is not a storage key, so
1082
+ * compare keys, never parse one.
1083
+ */
1084
+ key(): string;
1085
+ /**
1086
+ * Has this handle's deadline passed? **The lazy half of expiry** — the whole check is one comparison against
1087
+ * the injected clock, so it costs nothing on a handle with no deadline and no I/O on one that has expired.
1088
+ *
1089
+ * Deliberately evaluated per call rather than cached: a long-lived handle created before its deadline must
1090
+ * start reading empty the moment the deadline passes, without the caller re-creating it.
1091
+ */
1092
+ private expired;
1093
+ /**
1094
+ * Refuse a materialisation that involves an **expired** handle. Called by the three `*Into` verbs only.
1095
+ *
1096
+ * On the read verbs an expired handle answers empty, which is the point of lazy expiry. On a *write* the same
1097
+ * rule would be destructive in a way nobody asks for: `a.intersectInto(dest, [b])` where `b`'s deadline has
1098
+ * quietly passed would, unrefused, replace `dest` with an **empty generation** — a wipe, reported as a successful
1099
+ * write, with the cause (a deadline on a handle somewhere) nowhere in the result. Reads degrade to empty; writes must not.
1100
+ *
1101
+ * So every handle in the call is checked, `dest` included: an expired `dest` does not change the bytes written,
1102
+ * but a caller who put a deadline on the thing they are writing into has said something contradictory and is
1103
+ * better told than guessed at. Open a handle without `expiresAt` to write, or drop the deadline.
1104
+ *
1105
+ * (The broader guard — refusing to publish an empty or implausible generation over a non-empty one, with an
1106
+ * `allowEmpty` override — covers these verbs too: they route through the same guarded write path as
1107
+ * {@link CloudRoaring.load}. The two stay separate because they differ in kind. That one is a REPORTED
1108
+ * refusal a caller may legitimately override; an expired handle is a wiring mistake, so it THROWS, before
1109
+ * any object is written — and `allowEmpty: true` does not reach it.)
1110
+ *
1111
+ * The verbs are `async` so this surfaces as a **rejected promise**, like every other validation in the facade —
1112
+ * a synchronous throw out of a promise-returning method escapes a caller who attached `.catch()` instead of
1113
+ * awaiting.
1114
+ */
1115
+ private refuseIfExpired;
1116
+ /**
1117
+ * Time an op with the injected clock and emit an `op` metric on completion (success or throw). Skipped
1118
+ * entirely when no sink is wired, so the default path pays nothing.
1119
+ */
1120
+ private timed;
1121
+ /** Membership: one chunk — the cache, else one ranged GET. Throws {@link ValidationError} on a bad id. */
667
1122
  has(id: number): Promise<boolean>;
1123
+ /**
1124
+ * Cardinality — summed from the `.crbm` index with **zero payload reads** on a loaded segment.
1125
+ *
1126
+ * What this trusts: the answer is the sum of the per-chunk cardinalities the index records, and no payload is
1127
+ * decoded to confirm it. The index is checked for internal consistency when the object is opened (each key in
1128
+ * range and ascending, each cardinality in `1..65536`, each payload inside the payload region, and, on an
1129
+ * unencrypted object, the footer's chunk count and total agreeing with the index), and a corrupt index that is
1130
+ * still internally consistent yields a wrong count. `iterate()` and the combines decode the payloads, whose
1131
+ * structure is checked.
1132
+ */
668
1133
  count(): Promise<number>;
669
- iterate(): AsyncIterable<number>;
670
1134
  /**
671
- * Chunk-skipping intersection: stream the ids in **this** segment AND every segment in `others`, ascending.
672
- * Fetches only the Cold chunks present in *all* operands (a key absent from any operand contributes nothing
673
- * and is never downloaded), streaming under a bounded in-flight window — so the Cold footprint stays small
674
- * (Lambda-friendly) regardless of segment size. Pass `concurrency` to tune that window (a positive integer).
675
- * AND is commutative, so `a.intersect([b])` and `b.intersect([a])` yield the same ids. (Warm state is read
676
- * up front, so total memory also carries each operand's Warm size — negligible under Topology-A.) Pass
677
- * `budget` to override the store's per-op denial-of-wallet budget for this call (or `false` to lift it).
1135
+ * Every id, ascending, streamed one chunk at a time. Pass a range to read part of the segment: the ids in
1136
+ * `(after, through]`, fetching only the chunks the range overlaps. The per-op budget is charged for every chunk in
1137
+ * the range, so give a page `through` as well as `after`. On a pinned handle it reads the pinned generation, as a
1138
+ * full read does.
1139
+ *
1140
+ * ```ts
1141
+ * // Keyset paging: each page resumes where the one before ended, and stops at its window's end.
1142
+ * for await (const id of seg.iterate({ after: previousEnd, through: windowEnd })) await send(id);
1143
+ * ```
1144
+ *
1145
+ * Each bound is optional and an integer in `0..4294967295`; a bad one throws {@link ValidationError} when the
1146
+ * stream is first read. `after >= through` is an empty range, which reads nothing. An expired handle reads empty
1147
+ * without checking its options, as every read of one does.
678
1148
  */
1149
+ iterate(options?: IdRange): AsyncIterable<number>;
679
1150
  /**
680
- * Map the facade's `Segment` handles in `exclude` down to the plain refs `core` takes. A method rather than
681
- * a module function because `ref` is class-private — the encapsulation is worth more than the free function.
1151
+ * The options a combine hands the engine, read once, when it is called ({@link readOptions}), with the `exclude`
1152
+ * it was given, already reduced to its live handles ({@link liveExcludes}) and mapped down to the plain refs
1153
+ * `core` takes. A method rather than a module function because `ref` is class-private — the encapsulation is
1154
+ * worth more than the free function.
1155
+ *
1156
+ * Each field is named rather than copied with a rest spread: a rest copy takes only own enumerable properties, so
1157
+ * a bound held in a getter or inherited from a prototype was dropped, and the read silently widened to the whole
1158
+ * segment.
682
1159
  */
683
1160
  private refsIn;
1161
+ /**
1162
+ * The handles in `options.exclude` that have not expired. **An expired exclusion excludes nothing**, in every
1163
+ * combine, so it is dropped here, before the engine is asked for anything: it is never fetched, never
1164
+ * checked for absence, and never counted in the pin-consistency check. `exclude` is read once, so a getter sees
1165
+ * one call.
1166
+ *
1167
+ * With no `exclude` this is one property read and a shared empty list, and with none expired it returns the
1168
+ * caller's own array, so the common case allocates nothing.
1169
+ */
1170
+ private liveExcludes;
1171
+ /**
1172
+ * Chunk-skipping intersection: stream the ids in **this** segment AND every segment in `others`, ascending.
1173
+ * Fetches only the Storage chunks present in *all* operands (a key absent from any operand contributes nothing
1174
+ * and is never downloaded), streaming under a bounded in-flight window — so the Storage footprint stays small
1175
+ * (Lambda-friendly) regardless of segment size. Pass `concurrency` to tune that window (a positive integer).
1176
+ * AND is commutative, so `a.intersect([b])` and `b.intersect([a])` yield the same ids. Pass `budget` to
1177
+ * override the store's per-op denial-of-wallet budget for this call (or `false` to lift it).
1178
+ */
684
1179
  intersect(others: Segment[], options?: CombineOptions): AsyncIterable<number>;
685
1180
  /**
686
- * Materialize `this ∩ others…` (minus `exclude`) **into** `dest` (added, not replaced). Streaming +
687
- * bounded-memory, but **not atomic** across chunks — see {@link addMany}.
1181
+ * Materialize `this ∩ others…` (minus `exclude`) as a **new generation of `dest`** — `dest`'s previous contents
1182
+ * are superseded, not added to. Streaming + bounded-memory; the result is one immutable object published
1183
+ * as a load is (the pointer advances to it, fenced on the row), so readers of `dest` see either the old generation
1184
+ * or the new one, never a partial. Needs the
1185
+ * store built with a backend (throws {@link UnsupportedError} otherwise).
1186
+ *
1187
+ * **An empty result does NOT overwrite a non-empty destination.** A combine that comes out empty is far more
1188
+ * often a mistake upstream — a typo'd operand, an `exclude` that swallowed everything, an operand that has
1189
+ * not loaded yet — than an intent, and once it publishes it is indistinguishable from a correct run. So the
1190
+ * write is **refused and reported**: `published: false`, `reason: 'empty'`, and `dest` keeps what it had.
1191
+ * Pass `allowEmpty: true` when emptying the destination is the point.
1192
+ *
1193
+ * `guard` adds the same plausibility bounds `load()` takes — `minCardinality` and `minRetained` — judged
1194
+ * against what `dest` held before. A refusal is **reported, not thrown**, exactly as on `load()`; branch on
1195
+ * `published`. A lost race is the one outcome that still throws ({@link WriteConflictError}), because a
1196
+ * materialisation that silently did not take effect is the one thing a caller cannot detect on its own.
1197
+ *
1198
+ * A call involving an **expired handle** is refused before any of this. See {@link refuseIfExpired}.
688
1199
  */
689
- intersectInto(dest: Segment, others: Segment[], options?: CombineIntoOptions): Promise<void>;
1200
+ intersectInto(dest: Segment, others: Segment[], options?: MaterializeOptions): Promise<MaterializeResult>;
690
1201
  /**
691
1202
  * `this ∪ others…`, minus `options.exclude` — streamed ascending.
692
1203
  *
@@ -695,11 +1206,11 @@ declare class Segment {
695
1206
  * `intersect` prunes any key missing from any operand; union has nothing to prune. It is charged against the
696
1207
  * same per-op budget, so a wide union is refused rather than quietly billed — pass `budget` to raise it
697
1208
  * deliberately. If you find yourself unioning the same segments on every read, materializing the combined
698
- * segment once (`unionInto`, or a bulk-load rebuild) is the cheaper shape.
1209
+ * segment once (`unionInto`, or a load) is the cheaper shape.
699
1210
  */
700
1211
  union(others: Segment[], options?: CombineOptions): AsyncIterable<number>;
701
- /** Materialize `this ∪ others…` (minus `exclude`) **into** `dest`. **Not atomic** — see {@link addMany}. */
702
- unionInto(dest: Segment, others: Segment[], options?: CombineIntoOptions): Promise<void>;
1212
+ /** Materialize `this ∪ others…` (minus `exclude`) as a **new generation of `dest`** — see {@link intersectInto}. */
1213
+ unionInto(dest: Segment, others: Segment[], options?: MaterializeOptions): Promise<MaterializeResult>;
703
1214
  /**
704
1215
  * `this \ (excludes…)` — streamed ascending. Suppression on its own.
705
1216
  *
@@ -708,27 +1219,27 @@ declare class Segment {
708
1219
  * suppression list: at most one read per surviving key of `this`, so subtracting a 61,000-chunk global
709
1220
  * opt-out list from a 40-chunk audience costs at most 40 reads, not 61,000.
710
1221
  *
1222
+ * An expired handle in `excludes` excludes nothing, and is skipped without being read; if every one has
1223
+ * expired the result is `this` whole. An expired `this` is empty. `exclude` on `intersect` and `union` follows
1224
+ * the same rule.
1225
+ *
711
1226
  * To filter the *result of an intersection*, do not chain — pass `exclude` to {@link intersect} instead, so
712
1227
  * the suppression folds into the same pass rather than materializing an intermediate segment first.
713
1228
  */
714
1229
  andNot(excludes: Segment[], options?: BaseCombineOptions): AsyncIterable<number>;
715
- /** Materialize `this \ (excludes…)` **into** `dest`. **Not atomic** — see {@link addMany}. */
716
- andNotInto(dest: Segment, excludes: Segment[], options?: BaseCombineOptions & {
717
- batchSize?: number;
718
- }): Promise<void>;
1230
+ /** Materialize `this \ (excludes…)` as a **new generation of `dest`** — see {@link intersectInto}. */
1231
+ andNotInto(dest: Segment, excludes: Segment[], options?: AndNotIntoOptions): Promise<MaterializeResult>;
719
1232
  /**
720
- * Grounded cost report for this segment (Phase 5b): storage cost from its **real** `.crbm` size (exact,
721
- * no payload reads); request cost from the supplied `workload` rates. A segment with no Cold generation
722
- * reports zero storage. See {@link CostReport} — it always includes a verdict (incl. the lose-zone).
1233
+ * Grounded cost report for this segment: storage cost from its **real** `.crbm` size (exact, no payload
1234
+ * reads); request cost from the supplied `workload` rates. A segment with no Storage generation reports zero
1235
+ * storage. The pointer refresh is priced at the store's own `cache.genTtlMs`, or at none when the store never
1236
+ * refreshes, unless the workload sets `genTtlMs`. See {@link CostReport} — it always includes a verdict (incl.
1237
+ * the lose-zone).
723
1238
  */
724
1239
  costReport(options?: {
725
1240
  pricing?: PricingProfile;
726
1241
  workload?: Workload;
727
- topology?: Topology;
728
1242
  }): Promise<CostReport>;
729
1243
  }
730
-
731
- /** Package version marker. Kept in sync with package.json at release. */
732
- declare const VERSION = "0.9.0";
733
-
734
- export { type BaseCombineOptions, CloudRoaring, type CloudRoaringOptions, type CombineIntoOptions, type CombineOptions, type EraseSubjectResult, SafeBitmap, Segment, type SegmentOptions, type SubjectErasureEntry, type SubjectReport, type SubjectSegmentRef, VERSION, bulkLoadCrbmGeneration, compactSegment, roaringCodec, runCompactionCycle, runExport };
1244
+ export { MemoryStorage, LocalFsStorage, CrbmStorageChunkSource, destroySegment, eraseNamespace, excludingReservedRows, readRetentionPolicy, MIN_EXPIRES_AT_MS, InProcessKeystore, NodeAead, aadFor, CloudRoaringError, ValidationError, WriteConflictError, IntegrityError, NotFoundError, UnsupportedError, CapabilityError, TransientError, KeyUnavailableError, BudgetExceededError, isCloudRoaringError, isWriteConflictError, isTransientError, isNotFoundError, isIntegrityError, isValidationError, CrbmReader, BufferReader, CountingMetricsSink, RecordingAuditSink, DEFAULT_RETRY_POLICY, AWS_US_EAST_1_ONDEMAND, ELASTICACHE_REDIS_US_EAST_1_ONDEMAND, ONE_REDIS_HA_CLUSTER, } from '@cloudbitmaps/core';
1245
+ export type { Aead, AeadSealed, AuditEvent, BlobReader, BlobSink, Budget, BudgetOption, ChunkRef, Clock, CodecBitmap, CodecInterface, ConsistencyErrorEntry, ConsistencyIssue, ConsistencyReport, CostReport, CrbmCrypto, CrbmReaderOptions, CrbmStorageChunkSourceOptions, DestroyResult, DropResult, EraseDeps, EstimateInput, ExportFailure, ExportFormat, ExportManifest, ExportOptions, ExportSink, ExportWriter, ExportedSegment, GenKey, GenerationEntry, GovernanceMeta, IAuditSink, IKeystore, IMetricsSink, IRegistryDriver, IStorageDriver, IdRange, InProcessKeystoreOptions, LoadGuard, LoadOptions, LoadRefusal, LoadResult, LocalFsStorageOptions, MemoryStorageOptions, MetricEvent, MetricOpName, MetricsSnapshot, NewRegistryRecord, PinnedAt, PinnedObject, PricingProfile, RedisNodeType, RedisSizing, RegCaps, RegistryPatch, RegistryRecord, RegistryStatus, RetentionPolicy, RetireEntry, RetireExpiredOptions, RetireExpiredResult, RetryPolicy, Rng, RollbackResult, SegmentInfo, SegmentRef, SegmentSize, SegmentSizing, SetRetentionResult, StorageBackend, StorageCaps, StorageChunkSource, Token, Workload, WrappedDek, } from '@cloudbitmaps/core';