@cloudbitmaps/roaring 0.19.1 → 0.21.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.
package/PRIVACY.md CHANGED
@@ -43,7 +43,7 @@ from constructing a cross-region topology. The points where personal data moves
43
43
  | Location | What's there | Residency note |
44
44
  |---|---|---|
45
45
  | **Storage** (object store) | immutable `.crbm` generations — every generation a segment has had, until a superseded one is collected. Azure Blob objects, and GCS objects above the simple-upload threshold, also carry a random write id in their metadata (`cbwid`): 128 random bits, no data from the bitmap or the source | the region of the bucket you wire |
46
- | **Registry** (S3 / GCS / Azure Blob / local) | one row per segment: the current-generation pointer, wrapped keys, retention metadata, and the current generation's id count and your metadata (sealed when the segment is encrypted) — no IDs, unless you put one in the metadata, which you must not. An Azure Blob row also carries the random write id in its metadata | the region of the bucket you wire |
46
+ | **Registry** (S3 / GCS / Azure Blob / local) | one row per segment: its namespace and name, its status and timestamps, the current-generation pointer, wrapped keys and any key id, its `retention` and `residency` metadata, the current generation's id count, its object's size and footer checksum and your metadata (those three sealed when the segment is encrypted), the generations its loads keep, any leases, and a token and `pointerId` — no IDs, unless you put one in the metadata or a name, which you must not. An Azure Blob row also carries the random write id in its metadata | the region of the bucket you wire |
47
47
  | **cache** (process RAM) | decoded chunks, and the stored chunk bytes (ciphertext on an encrypted segment) a reader keeps of a small generation it read whole; bounded LRU | **wherever your process/Lambda runs** — an EU segment queried from a US function is processed in the US |
48
48
  | **Loads and rewrites** (`store.load()`, the `*Into` verbs, `store.materializeMany()`, `eraseSubject`) | read your source (or existing generations), write a new generation; a fed operand of `materializeMany` is read from your own process's memory | run wherever you run them — a loader in one region writing to a bucket in another is a transfer |
49
49
  | **Intersection** | pulls chunks from N segments into one process | co-locates those segments in one region |
@@ -60,7 +60,7 @@ CloudBitmaps gives you three levers with different guarantees. Use them delibera
60
60
 
61
61
  | Lever | API | Guarantee | Use for |
62
62
  |---|---|---|---|
63
- | **Subject erasure** | `store.eraseSubject(id, { namespace })` | *Physical on return* — the segment's current generation is rewritten without the id (every chunk streamed through, one bit cleared), published **fenced on the generation it streamed**, and **the generation that held the bit is deleted before the call returns**. So is every other generation still holding the id: a retained *superseded* one (an ex-member dropped by a re-seed), and each one above the pointer after a `rollback` — generations a rollback can move the pointer onto again — while, when the current generation does not hold the id, the ones there that never held it stay as rollback targets (a rewrite is numbered above everything, so its `keep: 0` collection takes every older generation, above the pointer or below). Before it says `erased: true` the call lists the bucket and reads what is left, so **`erased: true` means no generation of the segment holds the id**, above the pointer or below it. `eraseSubject` reports a per-segment fault as an `error: …` ledger entry rather than throwing it, and a call that a racing writer overtook (a load, another erasure, or a rollback) as `erased: false, note: 'superseded'`, so "on return" is a claim about every segment whose entry says **`erased: true`** (a fault is an `error: …` entry, never `erased: true`). The store that performs the call stops serving the id immediately; **other processes converge on their own read TTL** — see [One process, and the rest of your fleet](#one-process-and-the-rest-of-your-fleet). Does **not** reach backups, replicas or noncurrent object versions — those hold the old generation until their own lifecycle removes it. It carries a generation's metadata over unchanged and does **not** scan it, so **never put a subject's id in metadata**. | "forget this person" — GDPR Art. 17 |
63
+ | **Subject erasure** | `store.eraseSubject(id, { namespace })` | *Physical on return* — the segment's current generation is rewritten without the id (every chunk streamed through, one bit cleared), published **fenced on the generation it streamed**, and **the generation that held the bit is deleted before the call returns**. So is every other generation still holding the id: a retained *superseded* one (an ex-member dropped by a re-seed), and each one above the pointer after a `rollback` — generations a rollback can move the pointer onto again — while, when the current generation does not hold the id, the ones there that never held it stay as rollback targets, but for any object no read of the segment can open, which goes whatever the id. (When the current generation holds the id, the rewrite is numbered above everything, so its `keep: 0` collection takes every older generation, above the pointer or below.) Before it says `erased: true` the call lists the bucket and reads what is left, so **`erased: true` means no generation of the segment holds the id**, above the pointer or below it. `eraseSubject` reports a per-segment fault as an `error: …` ledger entry rather than throwing it, and a call that a racing writer overtook (a load, another erasure, or a rollback) as `erased: false, note: 'superseded'`, so "on return" is a claim about every segment whose entry says **`erased: true`** (a fault is an `error: …` entry, never `erased: true`). The store that performs the call stops serving the id immediately; **other processes converge on their own read TTL** — see [One process, and the rest of your fleet](#one-process-and-the-rest-of-your-fleet). Does **not** reach backups, replicas or noncurrent object versions — those hold the old generation until their own lifecycle removes it. It carries a generation's metadata over unchanged and does **not** scan it, so **never put a subject's id in metadata**. | "forget this person" — GDPR Art. 17 |
64
64
  | **Dispose** | `store.dropSegment(ref, { confirmSegment })` | *Immediate* — the segment is tombstoned and its Storage generations deleted, reclaiming the space. **Check `generationsRemaining`:** if it is non-empty the space was *not* fully reclaimed and the drop should be re-run (a load that had read the segment before the tombstone landed, even one still consuming its ids, or an `eraseSubject` rewrite already under way, can write its object after the drop's sweep; it deletes that object itself once its publish is refused, and only one whose process stops in between, or whose publish fails without a definite answer (a lost response, a timeout), leaves it, for a re-run of the drop). Works on cleartext; on an encrypted segment it *also* crypto-shreds, on the terms in the next row. Does **not** reach noncurrent versions / replicas / PITR snapshots — deleting an object is weaker than removing a key. | retiring a dated bucket; rolling-window retention |
65
65
  | **Crypto-shred** | `destroySegment` / `eraseNamespace` | *At rest, complete once no copy of the wrapped key survives* — removes the segment's wrapped DEK(s) from its registry row in one compare-and-swap, without touching the bytes or any KEK. **Every** copy of the objects (current, prior generations, backups, WORM-locked objects) is then unreadable once no retained copy of that row still holds the wrapped key — noncurrent object versions, backups and PITR copies of the row included — or once every KEK that wrapped it is destroyed. Until then, a retained copy of the row and a KEK that wrapped it decrypt the segment, and a registry restore to a point before the shred makes it readable again; see *Erasure vs. backups / WORM* below, and [an optional registry expiry rule](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/disaster-recovery.md#optional-make-a-shred-durable-with-a-registry-expiry-rule) that makes a shred durable after a set number of days. Requires the segment to be encrypted. A process that had already opened the segment holds the **unwrapped** key in memory and keeps reading until it is told — call `store.invalidate(ref)` there; see [One process, and the rest of your fleet](#one-process-and-the-rest-of-your-fleet). | whole-segment / tenant offboarding; erasure under immutable backups (see below) |
66
66
 
@@ -76,20 +76,42 @@ It reuses the store's own drivers (so build the store with a backend). It return
76
76
  **erasure ledger** — one entry per segment the id was found in, `{ segment, namespace?, erased, fromGeneration,
77
77
  generation, note? }` — as your proof of deletion; persist it or route it to your audit sink, which also receives
78
78
  one `segment.rewrite { fromGeneration, generation }` event per rewrite when you pass `audit` — an id found only
79
- outside the current generation (a retained *superseded* one, or one above the pointer after a rollback) is
80
- collected rather than rewritten, so it emits no event and its ledger entry carries no `generation`. Segments the id is
81
- not in are not listed. `store.subjectReport(id, { namespace })` answers the read side (Art. 15 — which segments
79
+ outside the current generation (a retained *superseded* one, one above the pointer after a rollback, an object left
80
+ under a tombstone, or a first load's object on a segment with no generation yet) is collected rather than rewritten,
81
+ so it emits `segment.collect { fromGeneration, collected }`
82
+ instead and its ledger entry carries no `generation`. Each `segment.*` event also names the segment's `incarnation`
83
+ when its registry token carries one, so a segment created again under the same name is told apart from the one
84
+ erased. Segments the id is
85
+ not in are not listed. A segment where the erasure found the id in nothing it could search, and deleted only objects
86
+ no read of it can open, is not listed either: the audit sink gets `segment.collect { collected }` for it, with no
87
+ `fromGeneration`, and that event is the only record of the deletion. `store.subjectReport(id, { namespace })` answers
88
+ the read side (Art. 15 — which segments
82
89
  an id is in).
83
90
 
84
- Two rules. **Do not load a segment while erasing from it**: a load that lands after the rewrite carries whatever
91
+ An erasure that finds the id in no generation but an older one also writes the segment's registry row once, before it
92
+ deletes anything, when the row's ledger of known generations names a generation the call is about to delete: it takes
93
+ those names out of the ledger first (see [how it stays correct](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/erasure.md#how-it-stays-correct)). That write
94
+ is a timing signal. Someone who can read the registry and knows when an erasure ran can tell that this segment's row
95
+ changed then, and so that the segment held the subject in a generation older than its current one. The row also
96
+ changes at erasure time when the segment's current generation held the id, or one above the pointer, a first load's
97
+ object on no pointer, or an object no read could open. The entries the write removes from the ledger show which
98
+ generations went, and for a holder above the pointer that is exactly the holders; the ledger names no id and no size,
99
+ and is written in the clear on every row, an encrypted one included, carrying a generation's number and its object's
100
+ footer checksum. When a load starts a ledger while the call runs, or a rollback or a load makes the row name a holder
101
+ again, the row changes a second time.
102
+
103
+ Two of the rules while you erase. **Do not load a segment while erasing from it**: a load that lands after the rewrite carries whatever
85
104
  its source held, and the library cannot know that source was meant to exclude the id — fix the source first, or
86
105
  quiesce loads of the affected segments for the duration. A writer that lands *during* the rewrite is caught and
87
106
  the entry says `erased: false, note: 'superseded'` — because the publish was refused **by that fence**, because
88
107
  a racing **erasure** (which collects with `keep: 0`) deleted the generation the rewrite was still streaming, or
89
- because an operator's `rollback` moved the pointer while the call was deleting generations above it. Two
90
- erasures of different ids racing on one segment are safe: the loser's rewrite was derived from the generation the
91
- winner replaced, so it still holds the winner's id, and when it sits above the winner's pointer the loser deletes
92
- it before returning. **Read the ledger**: an `erased: false` entry means *this run* did not erase the id from that segment,
108
+ because an operator's `rollback` moved the pointer while the call was deleting generations above it. When two
109
+ erasures of different ids race on one segment, the loser's rewrite was derived from the generation the winner
110
+ replaced, so it still holds the winner's id, and when it sits above the winner's pointer the loser deletes it before
111
+ returning, unless writes other than lease writes and other erasures' renewals beat its renewal of the segment's
112
+ row 8 times: it then keeps that object, one `rollback({ allowUnknown: true })` from being served, until a later call
113
+ removes it: an erasure that rewrites the segment (the loser's re-run, while the loser's id is still in the current
114
+ generation), an erasure of the winner's id, or the collection of loads that number past it ([how it stays correct](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/erasure.md#how-it-stays-correct)). **Read the ledger**: an `erased: false` entry means *this run* did not erase the id from that segment,
93
115
  which is not the same as the id still being present — if the racing writer was another erasure of the same id, it
94
116
  is already gone. For `'superseded'` re-run `eraseSubject`: it is idempotent, it erases the id if the id is still
95
117
  there, and a segment the id is no longer in is simply not listed — though "not listed" alone does not prove the
@@ -117,11 +139,22 @@ sweep run (it collects nothing without a row) reaches. List those objects with `
117
139
  `store.dropSegment(ref, { confirmSegment: ref.segment })`, which leaves a tombstone row fencing the name. An empty
118
140
  ledger is not by itself proof the id is gone. A tombstoned segment is searched too: a
119
141
  cleartext object left under it that holds the id is deleted with everything under the tombstone, and the entry reads
120
- `erased: true`. A segment whose row names no generation yet (a retention policy set before its first load) is
121
- searched too: an object a first load wrote and never published that holds the id, or on an encrypted store cannot
122
- be searched until its load publishes the key, is reported as an `error: …` note and not deleted: the erasure writes
123
- nothing to such a row, so the load that wrote the object may still publish it. Load the segment, which makes the
124
- object collectable, or drop it, and re-run.
142
+ `erased: true`. A segment whose row names no generation yet (a retention policy set before its first load) is searched
143
+ too: an object a first load wrote and never published that holds the id is deleted, and the entry reads `erased:
144
+ true`. Before it deletes anything the erasure renews the row's `pointerId`, so a load still running that wrote such an
145
+ object is refused at its publish and never names an object that is gone. Each delete names the object the erasure
146
+ read: on a storage driver that reports `conditionalDelete`, another erasure of the id that deletes it first, followed
147
+ by a load that takes the number freed and publishes, does not lose that load's generation to this erasure's delete; on
148
+ one that does not, it can, and the row then names a missing object, which `store.checkConsistency()` reports
149
+ ([a number taken again](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/erasure.md#how-it-stays-correct)). On an encrypted store such an object cannot
150
+ be searched until its load publishes the key, so it is deleted whatever id is erased: an erasure of any id refuses an
151
+ encrypted first load in flight that has written its object onto a segment whose row was made ahead of its data, and
152
+ that load is re-run ([the erasure guide](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/erasure.md#three-rules-while-you-erase)). On an encrypted segment,
153
+ an object sealed under a key its row does not hold, and on a cleartext segment with a generation, an encrypted object,
154
+ open for no read of the segment (the object of a first load that lost the race to the one that published, or
155
+ crashed): each is deleted the same way, whatever id is erased, wherever an erasure meets it, except as the current
156
+ generation, which is never deleted for this, and under a tombstone, where it is no holder and goes only with
157
+ everything there, when another object under the tombstone holds the id.
125
158
 
126
159
  **Do not roll a segment back while erasing from it.** A rollback that lands mid-erasure can move the pointer onto a
127
160
  generation the erasure did not rewrite, so the id can be current again. Roll back before the erasure starts or
@@ -137,8 +170,8 @@ A call with a feed reads operands the library never stores, which no erasure can
137
170
 
138
171
  Erasure and crypto-shred are **immediate in storage and immediate in the store whose verb performed them**, for
139
172
  every read that starts after the verb returns. A read already in progress in that store can still yield an erased id
140
- from a chunk it had requested before the verb ran: up to 32 chunks for `iterate` and `count`, and up to `concurrency`
141
- keys (32 by default) for a combine. A combine or `iterate` reads ranges of the object, and resolves the segment again before it serves each chunk, one held in its cache included, as a read of that chunk alone does, so the ranges it had already requested are dropped, not served. They are not immediate in any *other* store, in the same process or another, and this library ships nothing that could
173
+ from a chunk it had requested before the verb ran: up to 32 chunks for `iterate` and `count`, and up to `concurrency` + 1
174
+ keys (33 by default) for a combine. That is the bound: the chunk or key a read is handing out and what it had already requested, which a `count` that reads chunks (on a source with no index of its own) reaches. A running `iterate` or combine on the library's own sources stops sooner: the verb invalidates the segment, so before the next key it takes the read lists its chunk keys again from the generation current then and drops what it had requested, and so yields the id only from the chunk or key it is handing out. A combine or `iterate` reads ranges of the object, and resolves the segment again before it serves each chunk, one held in its cache included, as a read of that chunk alone does, so the ranges it had already requested are dropped, not served. A pinned read in progress does not move to the rewritten generation: before its next chunk it opens its object again, and fails with `NotFoundError` once the erasure has deleted it, so it yields no more than that bound either. They are not immediate in any *other* store, in the same process or another, and this library ships nothing that could
142
175
  make them so — there is no daemon, no bus, and no connection between two stores that happen to point at the same
143
176
  bucket.
144
177
 
@@ -148,17 +181,21 @@ bucket.
148
181
  | the store whose verb made the call (`eraseSubject`, `dropSegment`, `retireExpired`) | on return, for every read that starts after it — it invalidates what it cached, and its pins then fail; a read already in progress there can still yield it from a chunk it had requested before |
149
182
  | another store, with a registry and a `cache.genTtlMs` above 0 | within `cache.genTtlMs` (default 2 s), when its snapshot re-resolves, **while the registry can be read** (see below) |
150
183
  | another store with **no registry** (a bare `IStorageDriver`), with `cache: { genTtlMs: 0 }`, or on a storage source built with **no clock** | **no bound** — only when its caches happen to let the segment go, or something tells it |
151
- | `seg.pinAt` of a generation the erasure rewrote, in any store | on return it fails with `NotFoundError`, since the object is deleted (and, with a registry, the row has moved on); during the erasure, between the rewrite's publish and its delete of the old generation, it can still open that generation, and a handle opened then is a pin, covered by the row below |
184
+ | `seg.pinAt` of a generation the erasure rewrote, in any store | on return it fails with `NotFoundError`, since the object is deleted (and, with a registry, the row has moved on); during the erasure, between the rewrite's publish and its delete of the old generation, it can still open that generation, and a handle opened then is a pin, covered by the rows for the store whose verb made the call and for a pinned handle in another store |
152
185
  | a `materializeMany` call with a feed, and an erasure by any path but this store's `eraseSubject` | **no bound**: the call is refused only by this store's `eraseSubject` |
153
186
  | a pinned handle (`seg.pin()`) in another store | **no bound** — until that store's reader cache evicts the pin's reader (in a store with a timed pointer refresh, a small generation's reader holds all of its chunks, decoded or not) and its chunk cache evicts the chunks the pin decoded, or something tells it |
154
187
  | a leased pinned handle (`seg.pin({ leaseUntil })`) in another store | until its lease ends, at most 14 days after it was taken: its reads then throw `LeaseExpiredError`, whether or not its reader still holds the chunks. Until then, as for any pinned handle in the row above |
155
188
 
156
189
  **An outage of the registry extends that bound.** A refresh that cannot read the row because of a transient fault
157
- (throttling, a 5xx, a dropped connection) keeps serving the generation the reader already holds, and the key it
158
- unwrapped, and asks again 500 ms later, or after `cache.genTtlMs` if that is shorter. So while the registry cannot
159
- be read, a shred or a drop is not seen, and the store converges within one retry of the registry answering. Any
160
- other failure of the refresh (an access denial, a row that will not parse) is not ridden out: the read that finds it
161
- fails with that error, and the reader is dropped with the key it held.
190
+ (throttling, a 5xx, a dropped connection) keeps serving what the store resolved before the outage, for a segment
191
+ still in its reader cache: its open reader and the key that reader unwrapped, or, where no reader is open (a
192
+ `count()` answered from the row opens none), the row it resolved. A read during the outage that needs the key,
193
+ where none was unwrapped before it, unwraps it from that row's wrapped keys. The refresh asks again 500 ms later, or
194
+ after `cache.genTtlMs` if that is shorter. So while the registry cannot be read, a shred or a drop is not seen, and
195
+ the store converges within one retry of the registry answering. A segment the reader cache has let go is served only
196
+ until its resolution lapses: after that its next read fails with the fault, as a cold read does. Any other failure
197
+ of the refresh (an access denial, a row that will not parse) is not ridden out: the read that finds it fails with
198
+ that error, and the reader is dropped with the key it held.
162
199
 
163
200
  `destroySegment` and `eraseNamespace` are free functions over raw drivers, not verbs of a store, so for them every
164
201
  store is another store, one in the same process included.
@@ -191,8 +228,9 @@ leased handle stops answering when its lease ends, and its reads then throw `Lea
191
228
  registry row is a random holder id, a generation number and an instant, with no id and no count.
192
229
 
193
230
  **Your exit path** (and a building block for a **data-portability / Art. 20** response): `store.exportSegments(sink,
194
- { format })` (and the `export-segments` CLI) dumps every registered segment's current generation to a portable
195
- file — `roaring` (loadable by any roaring library) or `ndjson` (newline ids) — readable without CloudBitmaps.
231
+ { format })` (and the `export-segments` CLI) dumps the current generation of every registered segment that is not
232
+ destroyed (crypto-shredded or dropped) to a portable
233
+ file — `roaring` (loadable by any roaring library) or `ndjson` (newline ids) — readable without CloudBitmaps. A destroyed segment resolves no generation, so it is not exported: the manifest's `skipped` lists it, and `segments`, `failed` and `skipped` together account for every segment row the registry listed.
196
234
  It's a **controller-side bulk dump** (all segments, opaque ids), not a per-subject deliverable — the per-subject
197
235
  rights are `subjectReport` (Art. 15) / `eraseSubject` (Art. 17). Encrypted segments are decrypted transparently
198
236
  if the store has the keystore, so the **export is cleartext — protect it** (the CLI writes owner-only files;
@@ -261,10 +299,10 @@ to run, and the deletion is ours to perform correctly.** Practical patterns:
261
299
  The registry row's `retention` is untouched by a load, an `*Into` materialization or an erasure rewrite, so the
262
300
  `expiresAt` you set stays put across all of them.
263
301
  - Surface segment age and size so unbounded growth is visible, not silent. The **metrics sink** carries neither (its `storage.get` events count the bytes of each read, not a segment's size).
264
- `seg.count()` gives a segment's cardinality; `seg.costReport()` prices its current generation's measured size
265
- (`monthlyUSD.byOp.storage`, $0 when the storage source cannot measure it), and does not count superseded
266
- generations still in the bucket, which `store.generations(ref)` lists; and its registry row carries `createdAt`
267
- and `updatedAt`, which the backend's `registry.list()` returns for every row.
302
+ `seg.count()` gives a segment's cardinality; `seg.stat()` gives its current generation's size in bytes (`sizeBytes`,
303
+ `null` when the storage source cannot measure it), and does not count superseded generations still in the bucket,
304
+ which `store.generations(ref)` lists; and its registry row carries `createdAt` and `updatedAt`, which the
305
+ backend's `registry.list()` returns for every row.
268
306
 
269
307
  **Be precise about what "drop the oldest" involves**, because the three levers differ in what they guarantee:
270
308
 
@@ -276,10 +314,10 @@ to run, and the deletion is ours to perform correctly.** Practical patterns:
276
314
  - **A deleted row keeps its record only where the registry cannot remove it.** Where the registry reports
277
315
  `conditionalDelete` (the default for S3 when its client sends to an AWS S3 host, for
278
316
  Azure Blob, the local filesystem and memory), `registry.delete` and the retention sweep's purge of a tombstone remove
279
- a row created by 0.12 from the bucket, and its token's random incarnation id keeps a re-create apart from it. Where it
280
- does not (an S3 client that sends to an S3-compatible store or an emulator, or a GCS client, by default, or
281
- `conditionalDelete: false`), and for a row written by a release before 0.12 on any backend, a delete writes a deleted marker that holds
282
- the record, so the row's token counter survives a re-create. A tombstone left by `dropSegment` or `destroySegment`
317
+ the row from the bucket, and a re-create's random incarnation id keeps it apart from the removed row. Where it does not
318
+ (an S3 client that sends to an S3-compatible store or an emulator, or a GCS client, by default, or
319
+ `conditionalDelete: false`), a delete writes a deleted marker that holds the record, so the row's token counter
320
+ survives a re-create. A tombstone left by `dropSegment` or `destroySegment`
283
321
  holds no wrapped key and no summary, since both clear them, so a purge that leaves a tombstone keeps the name, the
284
322
  pointer, the retention policy and the timestamps. A **live** row deleted directly with `registry.delete` keeps its
285
323
  summary, the current generation's id count and your metadata, in a marker where one is written.
@@ -308,12 +346,11 @@ So the two are complements, not alternatives: **`dropSegment` for "stop paying f
308
346
  > ⚠️ **Never let a lifecycle rule expire a registry row's current version, a tombstone, or a due-index pointer** —
309
347
  > a separate trap, and a worse one. A rule cannot tell a live row from a tombstone, and an expired live row is a
310
348
  > segment whose pointer is gone: its generations look unreferenced, and nothing reads or collects them. The library
311
- > removes a row only by a delete the store applies to the exact version it judged, and only a row whose token
312
- > carries a random incarnation id, so a name re-created later is told apart from it with overwhelming probability.
313
- > A tombstone of a row written by a release before 0.12 carries the row's token counter on instead, and that counter
314
- > is what keeps the name's tokens unique against a process still on that release. That token is the segment's
315
- > **identity**: it is what a cached reader, a fenced publish and a generation-collection pass each compare to decide
316
- > whether two observations describe the same segment. Re-issue one and they can all answer "yes" about a segment that
349
+ > removes a row only by a delete the store applies to the exact version it judged, and every row's token carries a
350
+ > random incarnation id, so a name re-created later is told apart from it with overwhelming probability. That token is
351
+ > the segment's **identity**: a fenced publish and a generation-collection pass compare it, and a cached reader
352
+ > compares the row's `pointerId`, itself one of the row's tokens, to decide whether two observations describe the same
353
+ > segment. Re-issue one and they can all answer "yes" about a segment that
317
354
  > no longer exists — serving a deleted incarnation's data, or collecting a live one's objects. A rule on
318
355
  > *noncurrent* versions of the registry prefix touches neither the current row nor its tombstone; what it shortens is
319
356
  > the history a registry restore picks from, and the time a crypto-shred takes to complete (above).
@@ -332,8 +369,7 @@ response, a removal the registry refused (`purgeFaults` counts it), or a pointer
332
369
  it reads, and an unscoped fleet scan, one given no `namespace`, from every day. A deployment that scopes every sweep to a
333
370
  namespace, or runs only index scans, keeps such a pointer until an unscoped fleet scan runs. Where the registry does not
334
371
  report `conditionalDelete` (an S3 client that sends to an S3-compatible store or an emulator, or a GCS client, by default, or
335
- `conditionalDelete: false`), and for a row written by a release before 0.12 on any backend, the purge leaves a tombstone
336
- instead: every one of those fields, in the bucket, indefinitely, read by every full listing. A registry that does not
372
+ `conditionalDelete: false`), the purge leaves a tombstone instead: every one of those fields, in the bucket, indefinitely, read by every full listing. A registry that does not
337
373
  report it files no pointer to begin with. A removed
338
374
  row stays recoverable wherever the storage keeps a copy of it: with object versioning on, its earlier versions stay until
339
375
  a noncurrent-version rule expires them, as an overwritten row's do; with soft delete on, it stays for the retention
@@ -382,8 +418,13 @@ Wire the **audit sink** (`IAuditSink`) to get an append-only, vendor-neutral rec
382
418
  state changes — `segment.publish` (a loaded generation became current), `segment.load-refused` (a load that did
383
419
  not publish, because its guard refused it or another writer got there first), `segment.rollback` (an operator moved
384
420
  the pointer to a generation it named), `segment.rewrite` (a subject-erasure
385
- rewrite: `fromGeneration` → `generation`), `segment.erase` (a genuine crypto-shred), `segment.dispose` (a
386
- `dropSegment`, including every retirement the sweep performs) and `namespace.erase` — for your audit log / SIEM.
421
+ rewrite: `fromGeneration` → `generation`), `segment.collect` (a subject erasure that rewrote nothing and deleted what
422
+ no reader resolves: the generations holding the id, when it found the id only outside the current generation, or, with
423
+ no `fromGeneration`, only objects no read of the segment can open, which it deletes whatever the id), `segment.erase`
424
+ (a genuine
425
+ crypto-shred), `segment.dispose` (a `dropSegment`, including every retirement the sweep performs) and
426
+ `namespace.erase` — for your audit log / SIEM. Each `segment.*` event names the segment's `incarnation` when its
427
+ registry token carries one, so a segment created again under the same name is told apart from the one before.
387
428
  It is off by default and exception-safe. See the [dashboards guide](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/dashboards.md), which also says
388
429
  which event is the receipt for which claim. An erasure *ledger* — per-subject-request proof of physical deletion
389
430
  — is returned by **`eraseSubject`**. `subjectReport` is the read side and returns only which segments an id is
package/README.md CHANGED
@@ -14,6 +14,7 @@ file, then ask `has`, `count`, `iterate`, `intersect`, `union` and `andNot`, wit
14
14
  ```bash
15
15
  pnpm add @cloudbitmaps/roaring # the store, with in-memory and local-disk backends
16
16
  pnpm add @cloudbitmaps/s3 # the storage you have: or @cloudbitmaps/gcs, or @cloudbitmaps/azure-blob
17
+ pnpm add @cloudbitmaps/tools # optional: price a store, offline
17
18
  ```
18
19
 
19
20
  On npm 12 and pnpm 10 and later, allow `roaring`'s one install script first, or the install exits 0 and the package throws at `import`. Put this in your `package.json`: `{ "allowScripts": { "roaring": true }, "pnpm": { "onlyBuiltDependencies": ["roaring"] } }`. npm 11 runs the script but warns until you allow it the same way; pnpm 9 needs nothing extra. [Details](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/getting-started.md#cannot-find-module-buildreleaseroaringnode-after-a-successful-install).
@@ -77,13 +78,14 @@ name.
77
78
  A cold `intersect` of two 500,000-id segments sharing 100 of their 1,999 chunks made 6 GETs and took 99.80 ms at the median, **$2.40 per
78
79
  million** at list prices: measured on S3 in-region, in the run of 2026-10-06, whose client had 128 sockets. On a spread layout it reads more
79
80
  bytes than it needs, which cross-region can cost more than the requests it saves. A segment's first `store.load()` made
80
- 2 PUT + 3 GET, **$11.20 per million** at the default prices, pointer included: the requests are measured on S3 in-region, in the same run. A steady `store.load()` at `keep: 12` that deletes a generation by name made 2 PUT + 4 GET and a free delete, measured: **$11.60 per million**. A cold `count()` is one
81
+ 2 PUT + 3 GET, **$11.20 per million** at the default prices, pointer included: the requests are measured on S3 in-region, in the same run. A steady `store.load()` at `keep: 12` that deletes a generation by name costs **$12.00 per million**, for 2 PUT + 5 GET and a free delete, counted by the engine's tests. A cold `count()` is one
81
82
  pointer read: it reads no payload and no object.
82
83
 
83
84
  The trade is stated plainly. A membership check that misses the cache costs a ranged GET against object storage,
84
85
  where an in-process store costs a memory read. If you need sub-millisecond answers on a working set that fits a
85
86
  bounded cache, use Redis. [The benchmarks](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/benchmarks.md)
86
- give the method and what the numbers do not establish.
87
+ give the method and what the numbers do not establish. To price your own store, from the sizes `seg.stat()` reports, use
88
+ `@cloudbitmaps/tools` ([the cost guide](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/cost.md)).
87
89
 
88
90
  ## Before production
89
91
 
@@ -6,7 +6,7 @@ export interface ExportConfig {
6
6
  readonly format: ExportFormat;
7
7
  readonly namespace?: string;
8
8
  }
9
- /** Parse + validate config from an environment map. Throws a clear `Error` on misconfiguration. */
9
+ /** Parse + validate config from an environment map. Throws a {@link ValidationError} on misconfiguration. */
10
10
  export declare function parseConfig(env: Record<string, string | undefined>): ExportConfig;
11
11
  /**
12
12
  * A filesystem {@link ExportSink}. Writes each segment to a **unique** `<segment><ext>.<uuid>.part` temp
@@ -15,6 +15,10 @@ export declare function parseConfig(env: Record<string, string | undefined>): Ex
15
15
  * another. Uses a `FileHandle` (not `createWriteStream`) so an I/O fault **rejects the write** rather than
16
16
  * emitting an unhandled `'error'` event that would crash the process, and loops on short writes so a partial
17
17
  * `write()` never truncates the output. Artifacts are **owner-only** (dir `0o700`, files `0o600`) — cleartext.
18
+ *
19
+ * A namespace named `manifest.json`, in any letter case, is refused with a `ValidationError`, so each of its segments
20
+ * lands in `failed`: its directory would take the manifest's path. Aborting a segment also removes its namespace
21
+ * directory when that leaves it empty.
18
22
  */
19
23
  export declare function fsSink(out: string): ExportSink;
20
24
  /** CLI entry: build a LocalFs store, export every enumerated segment, then write the manifest. */