@cloudbitmaps/roaring 0.9.0 → 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.
Files changed (78) hide show
  1. package/NOTICE +3 -3
  2. package/PRIVACY.md +198 -72
  3. package/README.md +121 -57
  4. package/dist/bin/export-segments.d.ts +21 -0
  5. package/dist/bin/export-segments.js +1508 -30
  6. package/dist/bin/export-segments.js.map +7 -1
  7. package/dist/codec-bound.d.ts +38 -0
  8. package/dist/index.d.ts +805 -447
  9. package/dist/index.js +911 -325
  10. package/dist/index.js.map +7 -1
  11. package/dist/moved-options.d.ts +14 -0
  12. package/dist/portable/decode.d.ts +54 -0
  13. package/dist/roaring-codec.d.ts +61 -0
  14. package/dist/system-clock.d.ts +40 -0
  15. package/dist/testing/conformance.d.ts +34 -0
  16. package/dist/testing/fuzz-codec.d.ts +9 -0
  17. package/package.json +11 -116
  18. package/dist/azure/index.cjs +0 -14
  19. package/dist/azure/index.cjs.map +0 -1
  20. package/dist/azure/index.d.cts +0 -9
  21. package/dist/azure/index.d.ts +0 -9
  22. package/dist/azure/index.js +0 -3
  23. package/dist/azure/index.js.map +0 -1
  24. package/dist/bin/compact-segments.js +0 -201
  25. package/dist/bin/compact-segments.js.map +0 -1
  26. package/dist/cassandra/index.cjs +0 -14
  27. package/dist/cassandra/index.cjs.map +0 -1
  28. package/dist/cassandra/index.d.cts +0 -1
  29. package/dist/cassandra/index.d.ts +0 -1
  30. package/dist/cassandra/index.js +0 -3
  31. package/dist/cassandra/index.js.map +0 -1
  32. package/dist/chunk-D6LIJSGF.js +0 -906
  33. package/dist/chunk-D6LIJSGF.js.map +0 -1
  34. package/dist/dynamodb/index.cjs +0 -14
  35. package/dist/dynamodb/index.cjs.map +0 -1
  36. package/dist/dynamodb/index.d.cts +0 -1
  37. package/dist/dynamodb/index.d.ts +0 -1
  38. package/dist/dynamodb/index.js +0 -3
  39. package/dist/dynamodb/index.js.map +0 -1
  40. package/dist/gcs/index.cjs +0 -14
  41. package/dist/gcs/index.cjs.map +0 -1
  42. package/dist/gcs/index.d.cts +0 -1
  43. package/dist/gcs/index.d.ts +0 -1
  44. package/dist/gcs/index.js +0 -3
  45. package/dist/gcs/index.js.map +0 -1
  46. package/dist/index.cjs +0 -894
  47. package/dist/index.cjs.map +0 -1
  48. package/dist/index.d.cts +0 -734
  49. package/dist/mongodb/index.cjs +0 -14
  50. package/dist/mongodb/index.cjs.map +0 -1
  51. package/dist/mongodb/index.d.cts +0 -1
  52. package/dist/mongodb/index.d.ts +0 -1
  53. package/dist/mongodb/index.js +0 -3
  54. package/dist/mongodb/index.js.map +0 -1
  55. package/dist/mysql/index.cjs +0 -14
  56. package/dist/mysql/index.cjs.map +0 -1
  57. package/dist/mysql/index.d.cts +0 -1
  58. package/dist/mysql/index.d.ts +0 -1
  59. package/dist/mysql/index.js +0 -3
  60. package/dist/mysql/index.js.map +0 -1
  61. package/dist/postgres/index.cjs +0 -14
  62. package/dist/postgres/index.cjs.map +0 -1
  63. package/dist/postgres/index.d.cts +0 -1
  64. package/dist/postgres/index.d.ts +0 -1
  65. package/dist/postgres/index.js +0 -3
  66. package/dist/postgres/index.js.map +0 -1
  67. package/dist/redis/index.cjs +0 -14
  68. package/dist/redis/index.cjs.map +0 -1
  69. package/dist/redis/index.d.cts +0 -1
  70. package/dist/redis/index.d.ts +0 -1
  71. package/dist/redis/index.js +0 -3
  72. package/dist/redis/index.js.map +0 -1
  73. package/dist/s3/index.cjs +0 -14
  74. package/dist/s3/index.cjs.map +0 -1
  75. package/dist/s3/index.d.cts +0 -1
  76. package/dist/s3/index.d.ts +0 -1
  77. package/dist/s3/index.js +0 -3
  78. package/dist/s3/index.js.map +0 -1
package/README.md CHANGED
@@ -1,72 +1,139 @@
1
1
  # @cloudbitmaps/roaring
2
2
 
3
- **Distributed, cloud-native Roaring Bitmaps.** Query and intersect billion-scale integer sets over tiered cloud
4
- storage (RAM → NoSQL → object store) at a fraction of an always-on cache — with the fast, familiar in-memory API:
5
- `add`, `has`, `remove`, `count`, `intersect`.
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 — which arrives **transitively**,
10
- so this is the only package you install.
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
- npm i @cloudbitmaps/roaring @aws-sdk/client-s3 @aws-sdk/client-dynamodb # + only the SDK(s) you use
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 { S3ColdDriver } from '@cloudbitmaps/roaring/s3';
19
- import { DynamoDbWarmDriver } from '@cloudbitmaps/roaring/dynamodb';
36
+ import { S3Storage } from '@cloudbitmaps/s3';
20
37
  ```
21
38
 
22
- Every storage driver is re-exported on a matching subpath (`/s3`, `/gcs`, `/azure`, `/dynamodb`, `/postgres`,
23
- `/redis`, `/mongodb`, `/cassandra`, `/mysql`); each backend SDK is an optional peer dependency, so the main entry
24
- stays lean. Ships two CLIs: `compact-segments` and `export-segments`.
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 + DynamoDB in `us-east-1`, not an emulator: **$0.75 per million** incremental
29
- writes, **$0.14 per million** `count()`s, **$5.88 per million** segment publishes — against an always-on Redis-HA
30
- line of **$346/month, standing**. DynamoDB capacity is taken from AWS's own `ConsumedCapacity` rather than
31
- estimated. `count()` on a published segment does **0 payload reads**, and intersecting two 2,000,000-id segments
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 hot cache costs a network
35
- round trip to your warm tier, where an in-process RAM store costs a memory read. If you need a sub-millisecond
36
- p99 on a working set that fits a bounded hot cache, use Redis. If your sets are large, mostly read, and shouldn't cost $346/month to keep warm, use this.
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 alternative to Redis bitmaps for set-shaped work** — audiences, dedup, suppression, membership —
41
- where the sets are large, mostly idle, must survive a restart, and are written in **batches**. You are not giving
42
- up the bitmap either: past **4,096 ids** in a 65,536-id chunk — 6.25% of it — Roaring stores that chunk *as* a flat
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
- | `SETBIT` / `GETBIT` | `add` / `addMany` · `remove` / `removeMany` · `has` |
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
- | `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. And never enable your backend's own row expiry on the warm table — those rows are un-compacted deltas, so it discards writes silently |
52
-
53
- **It is not a drop-in replacement, and two limits are worth knowing before you port anything.**
54
-
55
- **1. Do not port a per-id write loop.** `SETBIT` flips one bit in place and is genuinely O(1). Here a warm write
56
- re-serializes a whole 65,536-id chunk, so **5,000 ids added one at a time cost 5,000 writes and 23,762 KB, against
57
- 1 write and 8 KB for the same ids in one call** — roughly 3,000× the bytes. Batch and you are far cheaper than
58
- Redis; port the loop literally and you are far more expensive. On a per-request-metered store (DynamoDB on-demand)
59
- that is a line item; on an instance-priced one (ElastiCache, RDS) it surfaces as IOPS and latency headroom instead,
60
- which makes it easier to miss rather than cheaper. `claimMany` takes a batch for exactly this reason.
61
-
62
- **2. What does not carry over: the raw bytes.** A `.crbm` object is not a flat bit array, so anything reading your
63
- Redis bitmap's underlying string — a job that `GET`s the key and indexes into it, a byte-for-byte backup —
64
- will not read ours. `BITFIELD`, `BITPOS`, `BITOP NOT` and byte-range `BITCOUNT` have no equivalent either: this
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
 
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
+
70
137
  ## Retiring data: a per-segment expiry, and a sweep you schedule
71
138
 
72
139
  A **segment** can expire; an individual **id** cannot (a bitmap stores ids, not `(id, timestamp)` pairs — a
@@ -79,9 +146,9 @@ const ref = { namespace: 'active-daily', segment: '2026-08-05' };
79
146
  await store.setRetention(ref, { expiresAt: Date.now() + 30 * DAY }); // once, when you create the bucket
80
147
  ```
81
148
 
82
- `expiresAt` is an absolute instant **you** compute, not a duration derived from anything observed: compaction
83
- rewrites every basis such a duration could use, so "30 days since the last write" would keep a busy bucket alive
84
- precisely *because* the daemon was keeping it cheap.
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.
85
152
 
86
153
  Then, from whatever schedule your deployment already has — an EventBridge rule, a Kubernetes `CronJob`, `cron`, a
87
154
  queue job — run the sweep. **This library starts no background timer**, deliberately: the same code has to behave
@@ -94,25 +161,22 @@ for (const e of swept.entries) if (e.action === 'skipped') console.warn(e.segmen
94
161
  if (swept.limited) scheduleAnotherPassSoon(); // the per-cycle cap deferred some; re-run
95
162
  ```
96
163
 
97
- Each retirement goes through `dropSegment`, so the Warm → registry → Cold ordering is one implementation rather
164
+ Each retirement goes through `dropSegment`, so the registry → object-store ordering is one implementation rather
98
165
  than two. The sweep is bounded (`limit`, default 100), previewable (`dryRun`), and returns a per-segment ledger
99
166
  rather than throwing — a fault on one segment must not decide the fate of the other ninety-nine. Once a day is
100
167
  enough for daily buckets. Full walkthrough:
101
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).
102
169
 
103
- ## No seed step, and compaction is optional
104
-
105
- A brand-new segment is usable the moment you construct the store: `addMany`, `has`, `remove`, `count`, `iterate`
106
- and the whole set algebra work against a segment with **no cold generation and no registry row**, because a read
107
- merges `(cold ∪ warm.adds) \ warm.removes` and an absent cold tier just makes that merge trivial. Bulk-load is an
108
- **import** path for data you already have elsewhere, not an initialization step.
109
-
110
- Compaction is a **cost** optimization, never a correctness requirement — it buys cheaper storage, smaller rewrites,
111
- and a free index-only `count()`. For a write-once dated bucket you retire (with `dropSegment`, or by recording an
112
- expiry as above), skip it entirely.
170
+ ## No background process, and nothing to seed
113
171
 
114
- Redis stays first-class as a **warm tier** underneath this (`@cloudbitmaps/roaring/redis`) — the point above is
115
- about replacing `SETBIT`-on-one-giant-key as your *data model*, not replacing Redis as infrastructure.
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.
116
180
 
117
181
  Full README, guides, [benchmarks](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/benchmarks.md) (with
118
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>;