@cloudbitmaps/roaring 0.10.0 → 0.11.1

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/README.md CHANGED
@@ -1,183 +1,109 @@
1
1
  # @cloudbitmaps/roaring
2
2
 
3
- > **ESM-only, Node ≥ 22.12.** This package ships as ES modules; there is no CommonJS bundle.
4
- > `require()` works on Node 22.12+ through `require(esm)`, but a runner with its own CommonJS loader
5
- > (notably Jest in its default configuration) does not get that and needs `import` instead. On TypeScript,
6
- > a CommonJS project needs `"module": "nodenext"` or `"node20"`. See the
7
- > [repository README](https://github.com/cloudbitmaps/cloudbitmaps#install--entry-points) for the details.
3
+ **Large sets of integer ids, stored in your own object storage and read from anywhere.** Load a set as one immutable
4
+ file, then ask `has`, `count`, `iterate`, `intersect`, `union` and `andNot`, with no server or cache to run. Part of
5
+ [CloudBitmaps](https://github.com/cloudbitmaps/cloudbitmaps); this is the package you import from.
8
6
 
7
+ > **ESM-only, Node ≥ 22.12.** Use `import`; for `require()`, Jest and TypeScript, see
8
+ > [CommonJS, Jest and TypeScript](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/getting-started.md#commonjs-jest-and-typescript).
9
9
 
10
- **Distributed, cloud-native Roaring Bitmaps.** Query and intersect billion-scale integer sets straight out of
11
- object storage — a bounded RAM cache over immutable `.crbm` objects in your own bucket — at a fraction of an
12
- always-on cache, with the familiar bitmap API: `has`, `count`, `iterate`, `intersect`, `union`, `andNot`.
10
+ > Pre-1.0: the API and the on-disk format can still change. These docs describe `main`, ahead of the npm release.
13
11
 
14
- This is the **flagship flavor** of the [CloudBitmaps](https://github.com/cloudbitmaps/cloudbitmaps) family: the
15
- roaring codec (CRoaring, via `roaring`) plus the `CloudRoaring` facade, on top of the codec-agnostic
16
- [`@cloudbitmaps/core`](https://www.npmjs.com/package/@cloudbitmaps/core) engine, which is a dependency of
17
- this package and of the storage package you pair it with — so you never install or name core yourself.
12
+ ## Install
18
13
 
19
14
  ```bash
20
- pnpm add @cloudbitmaps/roaring @cloudbitmaps/s3 # the codec, and the storage you have
21
- # npm i @cloudbitmaps/roaring @cloudbitmaps/s3 # the same, with npm
15
+ pnpm add @cloudbitmaps/roaring # the store, with in-memory and local-disk backends
16
+ pnpm add @cloudbitmaps/s3 # the storage you have: or @cloudbitmaps/gcs, or @cloudbitmaps/azure-blob
22
17
  ```
23
18
 
24
- > **On pnpm 10+, allow the one build script.** pnpm 10 skips dependency build scripts by default, so
25
- > the `roaring` native addon never downloads and the package throws at `import` — while the install
26
- > itself prints a warning and **exits 0**. Add this to your `package.json`, then install:
27
- >
28
- > ```json
29
- > { "pnpm": { "onlyBuiltDependencies": ["roaring"] } }
30
- > ```
31
- >
32
- > pnpm 9 and npm run it already. [Full symptoms and fixes](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/getting-started.md#cannot-find-module-buildreleaseroaringnode-after-a-successful-install).
33
-
34
- ```ts
35
- import { CloudRoaring } from '@cloudbitmaps/roaring';
36
- import { S3Storage } from '@cloudbitmaps/s3';
37
- ```
19
+ 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).
38
20
 
39
- Install the storage you have alongside this: **`@cloudbitmaps/s3`** (also R2, MinIO, Ceph, Wasabi, B2),
40
- **`@cloudbitmaps/gcs`** or **`@cloudbitmaps/azure-blob`**. Each depends on its cloud SDK for real, so nothing
41
- is an optional peer and no install carries an SDK you do not use. Ships one CLI: `export-segments`.
21
+ ## Use
42
22
 
43
- ## The model in one paragraph
23
+ No setup needed: `MemoryStorage` keeps everything in the process, which is right for a first look and for tests.
24
+ Save this as `first-run.mjs` and run it with `node`:
44
25
 
45
- A segment is a set of **write-once `.crbm` generations** in your bucket behind one small registry row saying which
46
- generation is current. Data gets in by **loading** a new generation — you compute the set upstream (a warehouse
47
- query, a batch job, a combine of other segments) and `store.load` streams it into a single immutable object,
48
- checks the result is plausible, then advances the pointer forward-only. Nothing mutates a stored bitmap, so a crash before the publish
49
- leaves the previous generation authoritative, a rerun is idempotent, and there is no partially-visible write.
50
- Reads (`has`, `count`, `iterate`, `intersect`, `union`, `andNot`) see one whole, checksum-verified generation.
26
+ ```js
27
+ import { CloudRoaring, MemoryStorage } from '@cloudbitmaps/roaring';
51
28
 
52
- ```ts
53
- // One bucket is the whole deployment: the generations and the pointer, stated once.
54
- const backend = new S3Storage({ bucket: 'bitmaps', region: 'us-east-1' });
29
+ const store = new CloudRoaring({ storage: new MemoryStorage() });
55
30
 
56
- const store = new CloudRoaring({ storage: backend });
31
+ await store.load({ segment: 'shoppers' }, [5, 99_999, 1_234_567_890]); // a load replaces the segment
32
+ await store.load({ segment: 'active' }, [5, 7, 1_234_567_890]);
57
33
 
58
- // One call is the whole write path: write the object, check it, move the pointer, collect the old generation.
59
- await store.load({ segment: 'high-value' }, idsFromWarehouse());
34
+ const shoppers = store.segment('shoppers');
35
+ console.log(await shoppers.has(99_999)); // true
36
+ console.log(await shoppers.count()); // 3
37
+ for await (const id of shoppers.intersect([store.segment('active')])) console.log(id); // 5, then 1234567890
38
+ ```
60
39
 
61
- const seg = store.segment('high-value');
40
+ To persist, pass a different **backend** as `storage`. Nothing else changes:
62
41
 
63
- await seg.has(1_234_567_890); // one chunk — from the cache after the first read
64
- await seg.count(); // exact, and summed from the index with 0 payload reads
65
- for await (const id of seg.intersect([store.segment('eu-residents')], { exclude: [store.segment('opted-out')] })) {
66
- /* streamed ascending; only the chunks that can contribute are ever fetched */
67
- }
68
- ```
42
+ | Backend | From | Use it for |
43
+ |---|---|---|
44
+ | `MemoryStorage` | `@cloudbitmaps/roaring` | tests and a first look |
45
+ | `LocalFsStorage('./.cloudbitmaps')` | `@cloudbitmaps/roaring` | one process on one folder: a laptop or a CI job |
46
+ | `S3Storage({ bucket, prefix })` | `@cloudbitmaps/s3` | production: the validated one |
47
+ | `GcsStorage({ bucket, prefix })` | `@cloudbitmaps/gcs` | Google Cloud Storage; outside the [validated envelope](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/ROADMAP.md#the-validated-envelope--whats-proven-and-what-isnt) |
48
+ | `AzureBlobStorage({ connectionString, container })` | `@cloudbitmaps/azure-blob` | Azure Blob Storage; outside the [validated envelope](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/ROADMAP.md#the-validated-envelope--whats-proven-and-what-isnt) |
69
49
 
70
- `intersectInto`, `unionInto` and `andNotInto` write the result as a **new generation of the destination** rather
71
- than streaming it to you, using the same write-once-then-publish protocol.
50
+ There is no `add` or `remove`: a segment changes only by loading a new generation. A load is a batch job, not a
51
+ request handler. `store.exists(ref)` says whether a segment has been loaded.
72
52
 
73
- ## Measured, not modeled
53
+ ## Options
74
54
 
75
- Benchmarked against **real** S3 in `us-east-1`, not an emulator: **$0.14 per million** `count()`s and
76
- **$5.88 per million** segment publishes — against an always-on Redis-HA line of **$346/month, standing**, and
77
- **$0.03/month** for 1.2 GiB of segments at rest. Request counts are read off the AWS SDK layer rather than
78
- estimated from sizes. `count()` on a published segment does **0 payload reads**, and intersecting two
79
- 2,000,000-id segments fetches only the shared chunks. That run kept the generation pointer in a NoSQL
80
- registry that no longer ships, so both figures are the object-store half rather than today's total — the
81
- pointer is now an object request that is not in them.
55
+ `new CloudRoaring({ storage, ...options })`. Only `storage` is required. A key the store does not take is refused by
56
+ name.
82
57
 
83
- The trade is stated plainly rather than buried: a membership check that misses the cache costs a ranged GET
84
- against object storage, where an in-process RAM store costs a memory read. If you need a sub-millisecond p99 on a
85
- working set that fits a bounded cache, use Redis. If your sets are large, mostly read, and shouldn't cost
86
- $346/month to keep warm, use this.
58
+ | Option | What it holds |
59
+ |---|---|
60
+ | `storage` (required) | a backend (see above) |
61
+ | `cache` | `maxChunks`, `genTtlMs` (how soon a reader sees a new load, default 2000 ms), `readerMax`, `readerMaxBytes` |
62
+ | `encryption` | `{ keystore, required }`: encrypt segments at rest with your own key |
63
+ | `retry` | tune the retry of reads, or `false` to turn it off |
64
+ | `metrics` | a metrics sink; off by default |
65
+ | `budget` | `{ maxRequests }` or `false`: the per-call request ceiling |
66
+ | `seams` | `{ clock, rng }`: determinism, for tests |
87
67
 
88
- ## Coming from Redis bitmaps?
68
+ ## What it costs
89
69
 
90
- **A durable home for set-shaped work** — audiences, dedup, suppression, membership, eligibility — where the sets
91
- are large, mostly read, must survive a restart, and are **computed in batches** upstream. You are not giving up
92
- the bitmap either: past **4,096 ids** in a 65,536-id chunk — 6.25% of it — Roaring stores that chunk *as* a flat
93
- bit array, byte for byte what you have now. It just stops paying for the chunks you never wrote to.
70
+ Measured against real S3 in `us-east-1`, with the pointer in the same bucket as the data: the median cold `intersect`
71
+ of two 500,000-id segments sharing 100 of their 1,999 chunks made 206 GETs, **$82.40 per million**, requesting only
72
+ the shared chunks. Inside the region it is expected at 204 GETs, $81.60 per million. Writing and publishing a segment
73
+ is **$11.20 per million**, pointer included, from its measured requests at list prices. `count()` reads no payload.
94
74
 
95
- The read side carries over one-for-one:
75
+ The trade is stated plainly. A membership check that misses the cache costs a ranged GET against object storage,
76
+ where an in-process store costs a memory read. If you need sub-millisecond answers on a working set that fits a
77
+ bounded cache, use Redis. [The benchmarks](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/benchmarks.md)
78
+ give the method and what the numbers do not establish.
96
79
 
97
- | Redis | Here |
98
- |---|---|
99
- | `GETBIT` | `has(id)` |
100
- | `BITCOUNT` | `count()` — exact, served from the index with no payload reads |
101
- | `BITOP AND` / `OR` / `DIFF` | `intersect` / `union` / `andNot` — streamed, and `intersect` skips chunks that cannot contribute. `intersectInto` / `unionInto` / `andNotInto` publish the result as a new generation of the destination |
102
- | `SETBIT` | **no equivalent — there is no per-id write.** Build the set upstream and load it; remove one id everywhere with `eraseSubject` (a generation rewrite for compliance, not a hot-path verb) |
103
- | `EXPIRE` | **`setRetention(ref, { expiresAt })` + `retireExpired()`** — a per-segment expiry the writer sets, and a sweep **you** schedule (this library starts no timer, so it works the same in a Lambda and a server). No per-**id** TTL: a bitmap stores ids, not timestamps |
104
-
105
- **It is not a drop-in replacement, and the difference is the write model.** Redis mutates one bit in place per
106
- call; here a segment changes only by getting a new generation. So a `SETBIT` loop has nothing to port to: if your
107
- set is defined by a query, run the query and load the result; if it is defined by events arriving one at a time,
108
- accumulate them where they arrive (Redis is good at that) and load the set on a cadence. A load bills per
109
- **object**, not per id — ten million ids are a handful of PUTs.
110
-
111
- **What does not carry over: the raw bytes.** A `.crbm` object is not a flat bit array, so anything reading your
112
- Redis bitmap's underlying string — a job that `GET`s the key and indexes into it, a byte-for-byte backup — will
113
- not read ours. `BITFIELD`, `BITPOS`, `BITOP NOT` and byte-range `BITCOUNT` have no equivalent either: this is a
114
- set of ids, not an addressable bit buffer, and `NOT` in particular has no bounded universe to complement against.
115
- Raw bit-position import/export is unbuilt;
116
- [say so in an issue](https://github.com/cloudbitmaps/cloudbitmaps/issues) if you need it, because that is what
117
- decides whether it gets built.
118
-
119
- ## Compliance is built in, not bolted on
120
-
121
- - `subjectReport(id)` — which segments an id is in (GDPR Art. 15).
122
- - `eraseSubject(id)` — rewrites every segment holding the id without it and deletes the generation that held the
123
- bit, so it is **physically gone from the bucket when the call returns**; you get an erasure ledger back and a
124
- `segment.rewrite` audit event per segment it had to rewrite (Art. 17; an id erased out of a retained
125
- superseded generation is collected rather than rewritten, so that one is attested by the ledger entry alone).
126
- - `destroySegment` / `eraseNamespace` — **crypto-shred**: drop the segment's wrapped data key so its encrypted
127
- bytes are unreadable *everywhere, including backups*.
128
- - `dropSegment(ref, { confirmSegment, dryRun })` — retire a segment and reclaim its storage.
129
- - `exists(ref)` — whether this segment's pointer resolves a generation, as one registry read. There is no
130
- `create`: `segment(name)` is a validated address and does no I/O, so a name can never collide. Not the
131
- same as `count() > 0` — a segment loaded with no ids exists and counts zero.
132
- - `segments({ namespace })` — everything the registry holds, streamed, so you do not keep your own list of
133
- segment names beside the store. An admin/dashboard call, not a request-path one.
134
- - `checkConsistency()` — after a restore, verify every pointer's object is actually present.
135
- - `exportSegments(sink, { format })` — eject every segment to portable `roaring` or `ndjson`. Your exit path.
136
-
137
- ## Retiring data: a per-segment expiry, and a sweep you schedule
138
-
139
- A **segment** can expire; an individual **id** cannot (a bitmap stores ids, not `(id, timestamp)` pairs — a
140
- timestamp per id costs more than the compression saves).
141
-
142
- ```ts
143
- const DAY = 86_400_000;
144
- const ref = { namespace: 'active-daily', segment: '2026-08-05' };
145
-
146
- await store.setRetention(ref, { expiresAt: Date.now() + 30 * DAY }); // once, when you create the bucket
147
- ```
80
+ ## Before production
148
81
 
149
- `expiresAt` is an absolute instant **you** compute, not a duration derived from anything observed: every load
150
- rewrites the basis such a duration would use, so "30 days since the last write" would keep a busy bucket alive
151
- precisely *because* it is being refreshed. `getRetention` reads the policy back and `clearRetention` cancels it.
82
+ - Grant the permissions the library issues and add a lifecycle rule to abort incomplete uploads.
83
+ - Use a bucket that honors conditional writes, and `@aws-sdk/client-s3` 3.645.0 or later if you pass your own S3 client.
84
+ - Set a request timeout on your storage client. Reads retry by themselves; a write that fails transiently is yours to re-run.
85
+ - Back up the registry with the data, and the keystore too if you encrypt. If you lose the key, the data is gone.
86
+ - Schedule your loads, and `retireExpired` if you use retention. Run loads off the request path.
152
87
 
153
- Then, from whatever schedule your deployment already has — an EventBridge rule, a Kubernetes `CronJob`, `cron`, a
154
- queue job — run the sweep. **This library starts no background timer**, deliberately: the same code has to behave
155
- identically in a Lambda, an edge isolate and a long-lived server, and a timer that only works in one of those is
156
- worse than none.
88
+ The [production checklist](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/production.md) covers each item.
157
89
 
158
- ```ts
159
- const swept = await store.retireExpired({ namespace: 'active-daily' });
160
- for (const e of swept.entries) if (e.action === 'skipped') console.warn(e.segment, e.reason);
161
- if (swept.limited) scheduleAnotherPassSoon(); // the per-cycle cap deferred some; re-run
162
- ```
90
+ ## Coming from Redis bitmaps?
91
+
92
+ `GETBIT` is `has`, `BITCOUNT` is `count`, and `BITOP AND` / `OR` / `DIFF` are `intersect` / `union` / `andNot`, with
93
+ `intersectInto`, `unionInto` and `andNotInto` publishing the result as a new generation. `EXPIRE` becomes
94
+ `setRetention` plus a `retireExpired` sweep you schedule, per segment, never per id. `SETBIT` has no equivalent:
95
+ there is no per-id write. Build the set upstream and load it, and do not loop one id at a time. Remove one id
96
+ everywhere with `eraseSubject`, a rewrite for compliance, not a hot-path verb. This is not a drop-in replacement, and
97
+ what does not carry over is the write model and the raw bytes: `exportSegments` writes portable Roaring and ndjson, not Redis's layout. [The full mapping](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/getting-started.md#coming-from-redis-bitmaps)
98
+ says what else differs.
99
+
100
+ ## Documentation
101
+
102
+ - [Getting started](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/getting-started.md)
103
+ - [Before production](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/production.md)
104
+ - [API reference](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/api-reference.md)
105
+ - [Changelog](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/CHANGELOG.md)
106
+ - [Privacy and shared responsibility](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/PRIVACY.md)
107
+ ## License
163
108
 
164
- Each retirement goes through `dropSegment`, so the registry → object-store ordering is one implementation rather
165
- than two. The sweep is bounded (`limit`, default 100), previewable (`dryRun`), and returns a per-segment ledger
166
- rather than throwing — a fault on one segment must not decide the fate of the other ninety-nine. Once a day is
167
- enough for daily buckets. Full walkthrough:
168
- [getting-started §13.5](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/getting-started.md#135-retention-ttl-and-pruning--what-exists-and-what-doesnt).
169
-
170
- ## No background process, and nothing to seed
171
-
172
- There is no daemon, no compaction pass and no lifecycle worker to run: a segment exists once you have loaded a
173
- generation into it, and the only scheduled work is the retention sweep above (plus `gcOrphanGenerations` if you
174
- want superseded generations collected sooner than the sweep does it). Pass a **backend** — `S3Storage`,
175
- `GcsStorage` or `AzureBlobStorage` from the storage package you installed, or `LocalFsStorage` /
176
- `MemoryStorage` from this one — and you get all of it: generations resolved with one strong read, encrypted
177
- segments, and the lifecycle helpers. `storage` also accepts a bare driver or a pre-built chunk source for
178
- read-only wiring, which carries no registry and so offers none of those. There is no separate `registry`
179
- option any more; passing one is refused by name.
180
-
181
- Full README, guides, [benchmarks](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/benchmarks.md) (with
182
- the method and what the numbers do *not* establish), and the design corpus live in the
183
- [repository](https://github.com/cloudbitmaps/cloudbitmaps). Licensed Apache-2.0.
109
+ Apache-2.0