@cloudbitmaps/roaring 0.8.2 → 0.10.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/NOTICE +3 -3
- package/PRIVACY.md +198 -72
- package/README.md +148 -50
- package/dist/bin/export-segments.d.ts +21 -0
- package/dist/bin/export-segments.js +1508 -30
- package/dist/bin/export-segments.js.map +7 -1
- package/dist/codec-bound.d.ts +38 -0
- package/dist/index.d.ts +875 -425
- package/dist/index.js +997 -306
- package/dist/index.js.map +7 -1
- package/dist/moved-options.d.ts +14 -0
- package/dist/portable/decode.d.ts +54 -0
- package/dist/roaring-codec.d.ts +61 -0
- package/dist/system-clock.d.ts +40 -0
- package/dist/testing/conformance.d.ts +34 -0
- package/dist/testing/fuzz-codec.d.ts +9 -0
- package/package.json +11 -116
- package/dist/azure/index.cjs +0 -14
- package/dist/azure/index.cjs.map +0 -1
- package/dist/azure/index.d.cts +0 -9
- package/dist/azure/index.d.ts +0 -9
- package/dist/azure/index.js +0 -3
- package/dist/azure/index.js.map +0 -1
- package/dist/bin/compact-segments.js +0 -134
- package/dist/bin/compact-segments.js.map +0 -1
- package/dist/cassandra/index.cjs +0 -14
- package/dist/cassandra/index.cjs.map +0 -1
- package/dist/cassandra/index.d.cts +0 -1
- package/dist/cassandra/index.d.ts +0 -1
- package/dist/cassandra/index.js +0 -3
- package/dist/cassandra/index.js.map +0 -1
- package/dist/chunk-4NVOQJGJ.js +0 -801
- package/dist/chunk-4NVOQJGJ.js.map +0 -1
- package/dist/dynamodb/index.cjs +0 -14
- package/dist/dynamodb/index.cjs.map +0 -1
- package/dist/dynamodb/index.d.cts +0 -1
- package/dist/dynamodb/index.d.ts +0 -1
- package/dist/dynamodb/index.js +0 -3
- package/dist/dynamodb/index.js.map +0 -1
- package/dist/gcs/index.cjs +0 -14
- package/dist/gcs/index.cjs.map +0 -1
- package/dist/gcs/index.d.cts +0 -1
- package/dist/gcs/index.d.ts +0 -1
- package/dist/gcs/index.js +0 -3
- package/dist/gcs/index.js.map +0 -1
- package/dist/index.cjs +0 -789
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -642
- package/dist/mongodb/index.cjs +0 -14
- package/dist/mongodb/index.cjs.map +0 -1
- package/dist/mongodb/index.d.cts +0 -1
- package/dist/mongodb/index.d.ts +0 -1
- package/dist/mongodb/index.js +0 -3
- package/dist/mongodb/index.js.map +0 -1
- package/dist/mysql/index.cjs +0 -14
- package/dist/mysql/index.cjs.map +0 -1
- package/dist/mysql/index.d.cts +0 -1
- package/dist/mysql/index.d.ts +0 -1
- package/dist/mysql/index.js +0 -3
- package/dist/mysql/index.js.map +0 -1
- package/dist/postgres/index.cjs +0 -14
- package/dist/postgres/index.cjs.map +0 -1
- package/dist/postgres/index.d.cts +0 -1
- package/dist/postgres/index.d.ts +0 -1
- package/dist/postgres/index.js +0 -3
- package/dist/postgres/index.js.map +0 -1
- package/dist/redis/index.cjs +0 -14
- package/dist/redis/index.cjs.map +0 -1
- package/dist/redis/index.d.cts +0 -1
- package/dist/redis/index.d.ts +0 -1
- package/dist/redis/index.js +0 -3
- package/dist/redis/index.js.map +0 -1
- package/dist/s3/index.cjs +0 -14
- package/dist/s3/index.cjs.map +0 -1
- package/dist/s3/index.d.cts +0 -1
- package/dist/s3/index.d.ts +0 -1
- package/dist/s3/index.js +0 -3
- package/dist/s3/index.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,84 +1,182 @@
|
|
|
1
1
|
# @cloudbitmaps/roaring
|
|
2
2
|
|
|
3
|
-
**
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
8
|
+
|
|
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`.
|
|
6
13
|
|
|
7
14
|
This is the **flagship flavor** of the [CloudBitmaps](https://github.com/cloudbitmaps/cloudbitmaps) family: the
|
|
8
15
|
roaring codec (CRoaring, via `roaring`) plus the `CloudRoaring` facade, on top of the codec-agnostic
|
|
9
|
-
[`@cloudbitmaps/core`](https://www.npmjs.com/package/@cloudbitmaps/core) engine
|
|
10
|
-
|
|
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.
|
|
11
18
|
|
|
12
19
|
```bash
|
|
13
|
-
|
|
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
|
|
14
22
|
```
|
|
15
23
|
|
|
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
|
+
|
|
16
34
|
```ts
|
|
17
35
|
import { CloudRoaring } from '@cloudbitmaps/roaring';
|
|
18
|
-
import {
|
|
19
|
-
import { DynamoDbWarmDriver } from '@cloudbitmaps/roaring/dynamodb';
|
|
36
|
+
import { S3Storage } from '@cloudbitmaps/s3';
|
|
20
37
|
```
|
|
21
38
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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`.
|
|
42
|
+
|
|
43
|
+
## The model in one paragraph
|
|
44
|
+
|
|
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.
|
|
51
|
+
|
|
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' });
|
|
55
|
+
|
|
56
|
+
const store = new CloudRoaring({ storage: backend });
|
|
57
|
+
|
|
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());
|
|
60
|
+
|
|
61
|
+
const seg = store.segment('high-value');
|
|
62
|
+
|
|
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
|
+
```
|
|
69
|
+
|
|
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.
|
|
25
72
|
|
|
26
73
|
## Measured, not modeled
|
|
27
74
|
|
|
28
|
-
Benchmarked against **real** S3
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
estimated. `count()` on a published segment does **0 payload reads**, and intersecting two
|
|
32
|
-
fetches only the shared chunks.
|
|
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.
|
|
33
82
|
|
|
34
|
-
The trade is stated plainly rather than buried: a membership check that misses the
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
37
87
|
|
|
38
88
|
## Coming from Redis bitmaps?
|
|
39
89
|
|
|
40
|
-
**A durable
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
43
93
|
bit array, byte for byte what you have now. It just stops paying for the chunks you never wrote to.
|
|
44
94
|
|
|
95
|
+
The read side carries over one-for-one:
|
|
96
|
+
|
|
45
97
|
| Redis | Here |
|
|
46
98
|
|---|---|
|
|
47
|
-
| `
|
|
48
|
-
| `prior = SETBIT` | `claimMany(ids)` — adds a batch and returns only the ids that were **not** already there, so an *"already sent to this id?"* check is exactly-once |
|
|
99
|
+
| `GETBIT` | `has(id)` |
|
|
49
100
|
| `BITCOUNT` | `count()` — exact, served from the index with no payload reads |
|
|
50
|
-
| `BITOP AND` / `OR` / `DIFF` | `intersect` / `union` / `andNot` — and `intersect` skips chunks that cannot contribute |
|
|
51
|
-
| `
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
is a set of ids, not an addressable bit buffer, and `NOT` in particular has no bounded universe to complement
|
|
66
|
-
against. Raw bit-position import/export is unbuilt;
|
|
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;
|
|
67
116
|
[say so in an issue](https://github.com/cloudbitmaps/cloudbitmaps/issues) if you need it, because that is what
|
|
68
117
|
decides whether it gets built.
|
|
69
118
|
|
|
70
|
-
##
|
|
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
|
+
```
|
|
148
|
+
|
|
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.
|
|
71
152
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
76
157
|
|
|
77
|
-
|
|
78
|
-
|
|
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
|
+
```
|
|
79
163
|
|
|
80
|
-
|
|
81
|
-
|
|
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.
|
|
82
180
|
|
|
83
181
|
Full README, guides, [benchmarks](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/benchmarks.md) (with
|
|
84
182
|
the method and what the numbers do *not* establish), and the design corpus live in the
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import type { ExportFormat, ExportManifest, ExportSink } from '../index.js';
|
|
3
|
+
export interface ExportConfig {
|
|
4
|
+
readonly root: string;
|
|
5
|
+
readonly out: string;
|
|
6
|
+
readonly format: ExportFormat;
|
|
7
|
+
readonly namespace?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Parse + validate config from an environment map. Throws a clear `Error` on misconfiguration. */
|
|
10
|
+
export declare function parseConfig(env: Record<string, string | undefined>): ExportConfig;
|
|
11
|
+
/**
|
|
12
|
+
* A filesystem {@link ExportSink}. Writes each segment to a **unique** `<segment><ext>.<uuid>.part` temp
|
|
13
|
+
* (`O_EXCL` create, mode `0o600`) and atomically renames it into `<out>/<namespace>/<segment><ext>` on `close()`,
|
|
14
|
+
* so a half-written file never masquerades as complete and concurrent exports to the same dir can't clobber one
|
|
15
|
+
* another. Uses a `FileHandle` (not `createWriteStream`) so an I/O fault **rejects the write** rather than
|
|
16
|
+
* emitting an unhandled `'error'` event that would crash the process, and loops on short writes so a partial
|
|
17
|
+
* `write()` never truncates the output. Artifacts are **owner-only** (dir `0o700`, files `0o600`) — cleartext.
|
|
18
|
+
*/
|
|
19
|
+
export declare function fsSink(out: string): ExportSink;
|
|
20
|
+
/** CLI entry: build a LocalFs store, export every enumerated segment, then write the manifest. */
|
|
21
|
+
export declare function main(env?: Record<string, string | undefined>, now?: () => number): Promise<ExportManifest>;
|