@cloudbitmaps/core 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/NOTICE +4 -4
  2. package/README.md +33 -16
  3. package/dist/chunk-5OMC5U7U.js +508 -0
  4. package/dist/chunk-5OMC5U7U.js.map +7 -0
  5. package/dist/core/audit.d.ts +128 -0
  6. package/dist/core/bit-route.d.ts +21 -0
  7. package/dist/core/blob.d.ts +34 -0
  8. package/dist/core/budget.d.ts +68 -0
  9. package/dist/core/codec.d.ts +122 -0
  10. package/dist/core/concurrency.d.ts +15 -0
  11. package/dist/core/consistency.d.ts +73 -0
  12. package/dist/core/cooperative.d.ts +78 -0
  13. package/dist/core/cost.d.ts +254 -0
  14. package/dist/core/crbm/crc32c.d.ts +10 -0
  15. package/dist/core/crbm/format.d.ts +114 -0
  16. package/dist/core/crbm/reader.d.ts +86 -0
  17. package/dist/core/crbm/varint.d.ts +13 -0
  18. package/dist/core/crbm/writer.d.ts +39 -0
  19. package/dist/core/crbm-storage-source.d.ts +522 -0
  20. package/dist/core/crypto.d.ts +80 -0
  21. package/dist/core/determinism.d.ts +42 -0
  22. package/dist/core/discover.d.ts +77 -0
  23. package/dist/core/due-index.d.ts +53 -0
  24. package/dist/core/engine.d.ts +246 -0
  25. package/dist/core/erase-id.d.ts +176 -0
  26. package/dist/core/erasure.d.ts +185 -0
  27. package/dist/core/errors.d.ts +113 -0
  28. package/dist/core/generation-gc.d.ts +56 -0
  29. package/dist/core/keys.d.ts +23 -0
  30. package/dist/core/load.d.ts +121 -0
  31. package/dist/core/lru.d.ts +69 -0
  32. package/dist/core/metrics.d.ts +136 -0
  33. package/dist/core/name-codec.d.ts +69 -0
  34. package/dist/core/pinned-storage-source.d.ts +90 -0
  35. package/dist/core/ports.d.ts +423 -0
  36. package/dist/core/reader-defaults.d.ts +18 -0
  37. package/dist/core/registry-scan.d.ts +47 -0
  38. package/dist/core/retention-sweep.d.ts +198 -0
  39. package/dist/core/retention.d.ts +67 -0
  40. package/dist/core/retry.d.ts +69 -0
  41. package/dist/core/rollback.d.ts +75 -0
  42. package/dist/core/validate.d.ts +44 -0
  43. package/dist/driver-kit.d.ts +9 -0
  44. package/dist/driver-kit.js +275 -0
  45. package/dist/driver-kit.js.map +7 -0
  46. package/dist/drivers/_shared/keys.d.ts +26 -0
  47. package/dist/drivers/_shared/object-registry-keys.d.ts +28 -0
  48. package/dist/drivers/_shared/object-registry.d.ts +89 -0
  49. package/dist/drivers/_shared/registry.d.ts +66 -0
  50. package/dist/drivers/backends.d.ts +43 -0
  51. package/dist/drivers/crypto.d.ts +38 -0
  52. package/dist/drivers/localfs/fs-util.d.ts +21 -0
  53. package/dist/drivers/localfs/paths.d.ts +33 -0
  54. package/dist/drivers/localfs/registry.d.ts +28 -0
  55. package/dist/drivers/localfs/storage.d.ts +19 -0
  56. package/dist/drivers/memory.d.ts +49 -0
  57. package/dist/drivers/retry/retrying-chunk-source.d.ts +59 -0
  58. package/dist/export/index.d.ts +93 -0
  59. package/dist/index.d.ts +76 -2760
  60. package/dist/index.js +3527 -3133
  61. package/dist/index.js.map +7 -1
  62. package/dist/testing/fuzz-core.d.ts +14 -0
  63. package/package.json +19 -115
  64. package/dist/azure/index.cjs +0 -368
  65. package/dist/azure/index.cjs.map +0 -1
  66. package/dist/azure/index.d.cts +0 -48
  67. package/dist/azure/index.d.ts +0 -48
  68. package/dist/azure/index.js +0 -304
  69. package/dist/azure/index.js.map +0 -1
  70. package/dist/cassandra/index.cjs +0 -265
  71. package/dist/cassandra/index.cjs.map +0 -1
  72. package/dist/cassandra/index.d.cts +0 -52
  73. package/dist/cassandra/index.d.ts +0 -52
  74. package/dist/cassandra/index.js +0 -204
  75. package/dist/cassandra/index.js.map +0 -1
  76. package/dist/chunk-7LMLYSVJ.js +0 -43
  77. package/dist/chunk-7LMLYSVJ.js.map +0 -1
  78. package/dist/chunk-BD22WDAN.js +0 -230
  79. package/dist/chunk-BD22WDAN.js.map +0 -1
  80. package/dist/chunk-NUIDEEFZ.js +0 -91
  81. package/dist/chunk-NUIDEEFZ.js.map +0 -1
  82. package/dist/chunk-OZ6LAFV4.js +0 -6
  83. package/dist/chunk-OZ6LAFV4.js.map +0 -1
  84. package/dist/chunk-SNJVZ227.js +0 -35
  85. package/dist/chunk-SNJVZ227.js.map +0 -1
  86. package/dist/dynamodb/index.cjs +0 -767
  87. package/dist/dynamodb/index.cjs.map +0 -1
  88. package/dist/dynamodb/index.d.cts +0 -106
  89. package/dist/dynamodb/index.d.ts +0 -106
  90. package/dist/dynamodb/index.js +0 -469
  91. package/dist/dynamodb/index.js.map +0 -1
  92. package/dist/gcs/index.cjs +0 -343
  93. package/dist/gcs/index.cjs.map +0 -1
  94. package/dist/gcs/index.d.cts +0 -46
  95. package/dist/gcs/index.d.ts +0 -46
  96. package/dist/gcs/index.js +0 -279
  97. package/dist/gcs/index.js.map +0 -1
  98. package/dist/index.cjs +0 -4990
  99. package/dist/index.cjs.map +0 -1
  100. package/dist/index.d.cts +0 -2761
  101. package/dist/mongodb/index.cjs +0 -260
  102. package/dist/mongodb/index.cjs.map +0 -1
  103. package/dist/mongodb/index.d.cts +0 -45
  104. package/dist/mongodb/index.d.ts +0 -45
  105. package/dist/mongodb/index.js +0 -199
  106. package/dist/mongodb/index.js.map +0 -1
  107. package/dist/mysql/index.cjs +0 -281
  108. package/dist/mysql/index.cjs.map +0 -1
  109. package/dist/mysql/index.d.cts +0 -56
  110. package/dist/mysql/index.d.ts +0 -56
  111. package/dist/mysql/index.js +0 -211
  112. package/dist/mysql/index.js.map +0 -1
  113. package/dist/ports-CvcvJWf6.d.cts +0 -395
  114. package/dist/ports-CvcvJWf6.d.ts +0 -395
  115. package/dist/postgres/index.cjs +0 -273
  116. package/dist/postgres/index.cjs.map +0 -1
  117. package/dist/postgres/index.d.cts +0 -54
  118. package/dist/postgres/index.d.ts +0 -54
  119. package/dist/postgres/index.js +0 -203
  120. package/dist/postgres/index.js.map +0 -1
  121. package/dist/redis/index.cjs +0 -257
  122. package/dist/redis/index.cjs.map +0 -1
  123. package/dist/redis/index.d.cts +0 -42
  124. package/dist/redis/index.d.ts +0 -42
  125. package/dist/redis/index.js +0 -197
  126. package/dist/redis/index.js.map +0 -1
  127. package/dist/s3/index.cjs +0 -918
  128. package/dist/s3/index.cjs.map +0 -1
  129. package/dist/s3/index.d.cts +0 -112
  130. package/dist/s3/index.d.ts +0 -112
  131. package/dist/s3/index.js +0 -555
  132. package/dist/s3/index.js.map +0 -1
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Fail-safe cross-store disaster-recovery check. The registry (`currentGen`) and the immutable `.crbm`
3
+ * generations can be restored **independently**, so a failover can recover the registry *ahead of* the storage
4
+ * objects — leaving `currentGen` pointing at a generation whose `.crbm` isn't present yet. That is likelier
5
+ * than it sounds even with both in one bucket: a restore scoped to a prefix, or replayed
6
+ * per-object from a version history, recovers the two prefixes at different points. That's a torn restore: reads of the affected segment then throw. This scan
7
+ * detects it up front (run it at startup after a restore) instead of discovering it on the first read.
8
+ *
9
+ * Read-only; bounded fan-out. `destroyed` (crypto-shredded) segments are skipped — their Storage is intentionally
10
+ * gone/unreadable, not a torn restore. A segment whose Storage/registry can't be read this pass is recorded in
11
+ * `errored` (never aborts the scan). Each segment is checked against its **authoritative live pointer** — one
12
+ * strong `registry.get` per segment — never the enumeration snapshot from `registry.list`, which a driver may
13
+ * serve eventually-consistently and so lag a recent in-place pointer advance: trusting it would both
14
+ * miss a torn *live* generation and cry torn on a generation the pointer has already advanced past (GC'd during
15
+ * the scan). Residual: a load's publish plus a GC landing in the tiny per-segment get→list gap can still yield a
16
+ * transient false positive — run the scan against a quiesced fleet (the documented restore procedure), or re-run
17
+ * to confirm a reported tear.
18
+ */
19
+ import type { IStorageDriver, IRegistryDriver } from './ports.js';
20
+ /** Default in-flight fan-out for the consistency scan — bounded, no thundering herd. */
21
+ /**
22
+ * Default ceiling on how many registry records one consistency check may hold resident: 250,000.
23
+ *
24
+ * **Named `maxScanSegments`, not `maxSegments`, on purpose.** A `maxSegments`-style option caps how much work
25
+ * one cycle does and then *continues normally* (the retention sweep's `limit` is that shape). This one caps how
26
+ * much a scan may hold and *refuses* past it. Same-sounding names with opposite behaviour at the limit is a
27
+ * trap; the name says what it is — a ceiling on a scan that fails closed.
28
+ *
29
+ * Generous — fleets of 100K+ segments are the design target — while still bounding a DR drill's memory to
30
+ * something a modest operator box survives. Raisable, because a ceiling you cannot lift is a landmine.
31
+ */
32
+ export { DEFAULT_MAX_SCAN_SEGMENTS } from './registry-scan.js';
33
+ export interface ConsistencyIssue {
34
+ readonly segment: string;
35
+ readonly namespace?: string;
36
+ /** The registry's `currentGen` for the segment — the generation whose `.crbm` is missing from Storage. */
37
+ readonly currentGen: number;
38
+ /** The only issue class today: `currentGen` references a Storage generation that is not present (torn restore). */
39
+ readonly issue: 'missing-storage-generation';
40
+ }
41
+ /** A segment that could not be checked this pass (Storage/registry read fault) — not proof of a torn restore. */
42
+ export interface ConsistencyErrorEntry {
43
+ readonly segment: string;
44
+ readonly namespace?: string;
45
+ /** The read error's message (never the raw error, so the report stays serializable/loggable). */
46
+ readonly error: string;
47
+ }
48
+ export interface ConsistencyReport {
49
+ /** Registered segments scanned (a `destroyed` segment counts as scanned but is never an issue). */
50
+ readonly checked: number;
51
+ /** Segments whose `currentGen` `.crbm` is absent — recover the object store (or restore to a coherent point). */
52
+ readonly inconsistent: ConsistencyIssue[];
53
+ /**
54
+ * Segments that couldn't be read this pass (a transient/partial object store during a restore is exactly when
55
+ * this runs) — **triage these too**: an unread segment is neither proven-coherent nor proven-torn. Empty on a
56
+ * clean pass.
57
+ */
58
+ readonly errored: ConsistencyErrorEntry[];
59
+ }
60
+ /**
61
+ * Verify every registered segment's `currentGen` `.crbm` actually exists in Storage. Enumerates the registry
62
+ * (optionally one namespace) and, for each non-`destroyed` segment, checks the object store lists that
63
+ * generation. Returns the torn segments in `inconsistent` (empty ⇒ coherent) and any unreadable segments in
64
+ * `errored`.
65
+ */
66
+ export declare function runConsistencyCheck(deps: {
67
+ readonly storage: IStorageDriver;
68
+ readonly registry: IRegistryDriver;
69
+ }, options?: {
70
+ namespace?: string;
71
+ concurrency?: number;
72
+ maxScanSegments?: number;
73
+ }): Promise<ConsistencyReport>;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Cooperative yielding for the long CPU-bound loops.
3
+ *
4
+ * Everything this library does on the hot read path is small. Bulk-load is the exception: it is the one place
5
+ * where a single call legitimately occupies the CPU for hundreds of milliseconds, and Node has exactly one thread
6
+ * to occupy. Measured on a 1M-id load of 65,536 chunks onto in-memory storage, on a loaded Apple M3 Pro: **518 ms
7
+ * wall, and 519 ms during which the event loop did not turn at all** (medians of 20 fresh-process runs). Wire that to a request handler and every other in-flight request on the instance — health
8
+ * checks included — waits out the whole load.
9
+ *
10
+ * The fix is not to make the work asynchronous. That was measured too, and it is a 7x *regression*: handing each
11
+ * chunk's insert to the threadpool costs ~9 µs of dispatch against ~1.5 µs of actual work once ids are spread
12
+ * across ~62,000 chunks (636 ms versus 92 ms). The work is genuinely CPU-bound and genuinely small per chunk;
13
+ * the only thing wrong with it is that it never stops to let anyone else go. So the fix is to keep the work
14
+ * synchronous and periodically hand the loop back.
15
+ *
16
+ * TWO TRAPS, both of which make a plausible implementation measure as no change at all:
17
+ *
18
+ * 1. **`await Promise.resolve()` — and equally `Clock.sleep(0)` — do nothing.** Both resolve on a microtask, and
19
+ * the microtask queue drains to empty before the event loop advances a single phase. A loop awaiting them
20
+ * yields to other *microtasks* and to nothing else; no pending I/O callback runs. Measured: 555 ms of
21
+ * starvation against a 568 ms unyielded baseline **on the synthetic yield-primitive benchmark below** —
22
+ * a different, smaller workload than the 442 ms end-to-end load above, which is why the two baselines
23
+ * differ. The yield must be a **macrotask** — hence
24
+ * `Clock.yieldNow`, backed by `setImmediate` in production wiring.
25
+ * 2. **Yielding per unit of work costs more than the work.** The yield has to be periodic, and `everyN` has to be
26
+ * large enough that its cost disappears into the batch it interrupts.
27
+ *
28
+ * At `everyN = 1024` over that same synthetic workload the trade is close to free: 569 ms wall against a 568 ms
29
+ * baseline, with the worst event-loop gap down from 568 ms to 13.8 ms.
30
+ *
31
+ * **Three experiments, three sets of numbers — do not mix them.** (a) The end-to-end 1M-id bulk load:
32
+ * 518 ms wall / 519 ms blocked unyielded, 526 ms / 24 ms yielded. (b) This synthetic yield-primitive comparison:
33
+ * 568 ms / 568 ms unyielded, 569 ms / 13.8 ms with `setImmediate`. (c) A per-chunk insert microbenchmark:
34
+ * 92 ms sync versus 636 ms via the threadpool. Quoting a baseline from one against a result from another describes
35
+ * work none of them did, such as a 92 ms operation with 450 ms of starvation inside it.
36
+ */
37
+ /**
38
+ * The only part of a `Clock` that yielding actually needs.
39
+ *
40
+ * Deliberately looser than `Clock` so a write path can opt in with whatever it has: a bulk load or a rewrite
41
+ * yields when its caller supplies a clock that can, and simply runs unbroken when handed nothing (or a bare
42
+ * `{ now }`). Requiring a full `Clock` would make every out-of-process caller wire one just to write a
43
+ * generation.
44
+ */
45
+ export interface Yielder {
46
+ yieldNow?(): Promise<void>;
47
+ sleep?(ms: number): Promise<void>;
48
+ }
49
+ /**
50
+ * How many units of work pass between yields. Sized so the yield is unmeasurable against the batch it interrupts
51
+ * (~60 yields across a 1M-id load) while still capping any single blocking stretch in the low tens of ms.
52
+ */
53
+ export declare const YIELD_EVERY = 1024;
54
+ /**
55
+ * Build a periodic yield.
56
+ *
57
+ * Call the returned function **once per unit of work**. It returns `null` on the vast majority of calls — nothing
58
+ * to await, nothing allocated — and a promise only on the `everyN`th, so the caller pays for a yield only when
59
+ * one actually happens:
60
+ *
61
+ * ```ts
62
+ * const tick = yieldEvery(clock);
63
+ * for (const item of millions) {
64
+ * work(item);
65
+ * const pause = tick();
66
+ * if (pause !== null) await pause;
67
+ * }
68
+ * ```
69
+ *
70
+ * The two-line shape is deliberate. `await tick()` would read better and would force a microtask turn on every
71
+ * single iteration — an unconditional cost on the very loop this exists to keep fast.
72
+ *
73
+ * With no `clock` the result always returns `null`: the loop runs unbroken, so adding a yield point to a code
74
+ * path can never change behaviour for a caller who did not opt in. `core/` cannot supply a clock itself — it is
75
+ * timer-free by lint, which is the whole reason scheduling goes through the seam — so `@cloudbitmaps/roaring`
76
+ * pre-binds a real one and flavor users get this with no wiring at all.
77
+ */
78
+ export declare function yieldEvery(clock: Yielder | undefined, everyN?: number): () => Promise<void> | null;
@@ -0,0 +1,254 @@
1
+ /** Pluggable rate card. Rates differ by cloud/region/term and drift over time; the formulas don't. */
2
+ export interface PricingProfile {
3
+ readonly name: string;
4
+ readonly storage: {
5
+ /** Object GET (per **million** requests). A ranged GET bills as a full GET. */
6
+ readonly getPerMillion: number;
7
+ /** Object PUT (per million) — what a load pays, per request. */
8
+ readonly putPerMillion: number;
9
+ readonly storagePerGiBMonth: number;
10
+ /**
11
+ * Requests one read costs when it needs the object's size first: a pointer read, and a segment's tail read.
12
+ * Default **1**, S3's, whose suffix-range GET returns the size with the bytes. **2** on GCS and Azure Blob,
13
+ * which read the metadata and then the bytes. A chunk read knows its range, and is one request everywhere.
14
+ */
15
+ readonly requestsPerSizedRead?: number;
16
+ };
17
+ /**
18
+ * The always-on Redis the verdict compares against: exactly one of the two. `sizedToData`, the default's, prices
19
+ * the cheapest cluster that holds the report's stored bytes; `monthlyUSD` is one cluster you name, whatever the
20
+ * data size. A profile carrying both is refused rather than read one way.
21
+ */
22
+ readonly redis: {
23
+ readonly monthlyUSD: number;
24
+ readonly sizedToData?: never;
25
+ } | {
26
+ readonly sizedToData: RedisSizing;
27
+ readonly monthlyUSD?: never;
28
+ };
29
+ }
30
+ /** One node type a Redis cluster can be built from, as its cloud prices it. */
31
+ export interface RedisNodeType {
32
+ readonly name: string;
33
+ /** Memory, in GiB, as the cloud publishes it for the node type. */
34
+ readonly memoryGiB: number;
35
+ /**
36
+ * SSD, in GiB, on a data-tiering node (ElastiCache's `r6gd`): keys stay in memory, and the values read least
37
+ * recently move to the SSD. Counted in full beside the memory, as AWS counts a data-tiering node's capacity.
38
+ */
39
+ readonly ssdGiB?: number;
40
+ /** Price per node-hour, in USD. */
41
+ readonly hourlyUSD: number;
42
+ /**
43
+ * The most shards a cluster of this type is priced at. The default catalogue prices its burstable nodes as one
44
+ * shard: a cluster sharded across them is not how data anyone queries is held. Default: no limit.
45
+ */
46
+ readonly maxShards?: number;
47
+ }
48
+ /**
49
+ * Redis sized to hold the data: enough shards for the bytes, each a primary and its replicas, on whichever node
50
+ * type in `nodeTypes` makes that cheapest. Prices within a millionth of a dollar of the cheapest are a tie, which
51
+ * fewer nodes win, then the lower price, then the name, so the answer never depends on the order of the rows. The data is held at its stored,
52
+ * compressed size, which is a floor on the memory Redis needs for it: a native Redis bitmap is sized by its highest
53
+ * id, not by how many ids it holds, so sparse ids need more.
54
+ */
55
+ export interface RedisSizing {
56
+ /** Where the prices come from, and when; the report's notes quote it. */
57
+ readonly source: string;
58
+ readonly nodeTypes: readonly RedisNodeType[];
59
+ /** Replicas per shard. AWS's best practice is 2; Multi-AZ needs at least 1. */
60
+ readonly replicasPerShard: number;
61
+ /** The share of each node's memory kept back from data: ElastiCache's `reserved-memory-percent`, 25% by default. */
62
+ readonly reservedMemoryFraction: number;
63
+ }
64
+ /**
65
+ * **ElastiCache for Redis OSS in us-east-1, on-demand**: node prices from AWS's public price list, version
66
+ * 20260914063714 (published 2026-09-14, effective 2026-09-01,
67
+ * https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonElastiCache/20260914063714/us-east-1/index.json), and the
68
+ * memory AWS publishes for each node type. Every shard is a primary and two replicas, AWS's best practice, with
69
+ * ElastiCache's default 25% of each node's memory reserved, and the burstable `t4g` nodes are priced as one shard.
70
+ *
71
+ * It lists only rows that are the cheapest fit for some size of data: the data-tiering `r6gd` rows above about
72
+ * 30 GiB, and the larger in-memory `r6g` rows, `2xlarge` and up, when those are left out. The current `m7g` and `r7g` nodes are not here,
73
+ * because an `m6g` or `r6g` with the same memory costs less. It is the cheapest cluster of THIS kind; Redis bought
74
+ * another way costs less — one replica a shard a third less, ElastiCache for Valkey 20% less a node, reserved nodes
75
+ * less again — so against those the saving the verdict reports is smaller. Pass their prices to compare with them.
76
+ */
77
+ export declare const ELASTICACHE_REDIS_US_EAST_1_ONDEMAND: RedisSizing;
78
+ /**
79
+ * One ElastiCache HA cluster, a primary and two replicas of cache.m7g.large — the m7g family's smallest node —
80
+ * at $0.158 an hour each: 3 × 730 h × $0.158 = $346.02, published to the dollar. The per-request crossovers on the
81
+ * benchmarks page are drawn against this one cluster, whatever the data size; pass it as `pricing.redis` to compare
82
+ * with it. It is not the cheapest cluster for any size of data: three cache.m6g.large hold the same memory for less.
83
+ */
84
+ export declare const ONE_REDIS_HA_CLUSTER: Readonly<{
85
+ monthlyUSD: number;
86
+ }>;
87
+ /**
88
+ * Default profile — **AWS us-east-1, on-demand**, mid-2026, from the fact-checked published pricing rather than
89
+ * copied from a blog post, with Redis sized to the data. Override it for your region, cloud, or committed term.
90
+ */
91
+ export declare const AWS_US_EAST_1_ONDEMAND: PricingProfile;
92
+ /** Sustained access pattern. All rates default to 0; unspecified ⇒ that op contributes nothing. */
93
+ export interface Workload {
94
+ /**
95
+ * Point reads (`has`) per second. Each cache miss is one object GET, once the segment's pointer and index are
96
+ * read.
97
+ */
98
+ readonly readsPerSec?: number;
99
+ readonly intersectsPerSec?: number;
100
+ /** Cache hit rate in `[0, 1]` — hits are free; only misses cost. Default 0. */
101
+ readonly cacheHitRate?: number;
102
+ /**
103
+ * Storage chunks one intersection fetches, summed over its operands: the chunk-skipping survivors. Default 1.
104
+ * Two segments sharing `k` chunks fetch `2k`. The model adds each operand's pointer and index reads itself (see
105
+ * {@link Workload.operandsPerIntersect}), so count chunks only. One more GET per operand whose index outgrows
106
+ * the reader's tail read (256 KiB by default), which then reads the index whole, belongs here too, and so does a
107
+ * pointer re-read by an intersect slow enough to outlive {@link Workload.genTtlMs}.
108
+ */
109
+ readonly chunksPerIntersect?: number;
110
+ /**
111
+ * Segments each intersection reads, `exclude` operands included; at least 1. Default 2. An intersection is priced
112
+ * **cold**: before its chunks, each operand's pointer is read, then its index, in one read of the object's tail —
113
+ * 2 GETs an operand, so a cold intersect of two segments sharing `k` chunks is `4 + 2k` GETs. `cacheHitRate`
114
+ * does not apply to intersections, so a long-lived reader that answers a repeat from its cache pays less. Other
115
+ * combines read their operands the same way and can be priced here too, with the chunks they fetch.
116
+ */
117
+ readonly operandsPerIntersect?: number;
118
+ /**
119
+ * Generations published per month across the modeled data — the write side of a loaded store. Default **0**
120
+ * ⇒ loads are not modeled and the report *discloses* the omission rather than silently under-reporting.
121
+ */
122
+ readonly loadsPerMonth?: number;
123
+ /**
124
+ * PUT-class requests one load's object write issues, each priced at the PUT rate. Default **1** (a single-object
125
+ * PUT). A multipart write of `P` parts bills `P + 2` (initiate, the parts, complete) — set it when you know your
126
+ * object sizes. The model adds what `store.load()` does around the write: two listings and the pointer's write,
127
+ * PUT-class on S3, and nine GETs, the pointer read eight times and the current generation's index once. That is
128
+ * a segment with two generations behind it; its first load makes two fewer GETs, and its second one fewer. On
129
+ * S3 at the default prices a single-part `store.load()` is then about $23.60 per million. A segment whose index
130
+ * outgrows the tail read makes one more GET, and a publish that loses a race to another writer reads the
131
+ * pointer again.
132
+ */
133
+ readonly requestsPerLoad?: number;
134
+ /**
135
+ * Segments each long-lived reader process keeps reading. A reader re-reads a segment's pointer when it reads the
136
+ * segment after {@link Workload.genTtlMs} has passed, so each costs at most one GET per `genTtlMs` in each process
137
+ * that reads it — 1,314,000 GETs a month at the default 2 s — and the whole term at most one GET per point read
138
+ * (`readsPerSec`, across the fleet). Intersections are priced cold and pay their own pointer reads. Default **0**
139
+ * ⇒ the refresh is not modeled, and the report *discloses* the omission when there are reads to refresh for.
140
+ *
141
+ * It assumes each hot segment stays open in the reader's cache (`cache.readerMax`, 1,024 segments by default,
142
+ * and `cache.readerMaxBytes`, 64 MiB of parsed index). A read of a segment the cache evicted opens it again, a
143
+ * pointer read and a tail read, which is not priced here, and neither is the re-open every reader makes after
144
+ * each load, for the new generation's index. Size those caches to keep the hot set open; the report says so when
145
+ * `hotSegments` is more than a store keeps open by default.
146
+ */
147
+ readonly hotSegments?: number;
148
+ /**
149
+ * Long-lived reader processes, each keeping its own {@link Workload.hotSegments} open and refreshing their
150
+ * pointers on its own: ten processes reading the same hundred segments pay ten times one process's refresh.
151
+ * At least 1. Default 1.
152
+ */
153
+ readonly readerProcesses?: number;
154
+ /**
155
+ * How long the reader trusts a pointer, in ms: the store's `cache.genTtlMs`. Default 2000, the store's own
156
+ * default; `segment.costReport()` uses the store's. `0` turns the timed refresh off, and the model bills none: a
157
+ * reader then re-reads a pointer only when its cache evicts the segment, a read finds its generation swept, or a
158
+ * write invalidates it, none of which this term prices.
159
+ */
160
+ readonly genTtlMs?: number;
161
+ }
162
+ /** One (group of) segment(s) for planning. `count` = how many like this (default 1). */
163
+ export interface SegmentSizing {
164
+ readonly sizeBytes?: number;
165
+ readonly cardinality?: number;
166
+ /** Number of segments with these characteristics. Default 1. */
167
+ readonly count?: number;
168
+ }
169
+ export interface EstimateInput {
170
+ readonly segments: readonly SegmentSizing[];
171
+ readonly workload?: Workload;
172
+ readonly pricing?: PricingProfile;
173
+ }
174
+ export interface CostReport {
175
+ readonly monthlyUSD: {
176
+ readonly byOp: {
177
+ readonly reads: number;
178
+ readonly intersects: number;
179
+ readonly storage: number;
180
+ /**
181
+ * Loads: each its object's `requestsPerLoad` PUT-class requests plus what `store.load()` adds around them.
182
+ * 0 unless `loadsPerMonth` is set.
183
+ */
184
+ readonly loads: number;
185
+ /** The pointer refresh of {@link Workload.hotSegments}. 0 unless `hotSegments` is set. */
186
+ readonly pointerRefresh: number;
187
+ };
188
+ readonly total: number;
189
+ };
190
+ /**
191
+ * The Redis the verdict compares against. Sized, the default, it is the cheapest cluster in
192
+ * `pricing.redis.sizedToData` that holds this report's stored bytes; fixed, it is `pricing.redis.monthlyUSD` as
193
+ * given.
194
+ *
195
+ * **It is not additive across reports.** Each report sizes Redis to its own bytes, so a per-segment report
196
+ * (`segment.costReport()`) compares with a cluster holding that one segment alone, and the baselines of a store's
197
+ * segments do not sum to the store's. To judge a store, price all its segments in one `estimateCost`. To alarm on
198
+ * one, sum `monthlyUSD.total` over its segments and compare the sum with the Redis you would run for it: a
199
+ * per-segment verdict against that whole price fires only when one segment alone costs more than all of it.
200
+ */
201
+ readonly redisBaseline: {
202
+ readonly basis: 'fixed';
203
+ readonly monthlyUSD: number;
204
+ } | {
205
+ readonly basis: 'sized-to-data';
206
+ readonly monthlyUSD: number;
207
+ /** The node type, the shards, the nodes (every shard's primary and replicas), and whether they tier to SSD. */
208
+ readonly cluster: {
209
+ readonly nodeType: string;
210
+ readonly shards: number;
211
+ readonly nodes: number;
212
+ readonly dataTiering: boolean;
213
+ };
214
+ };
215
+ /**
216
+ * Sustained read rate at which the pay-per-use model's cost passes {@link CostReport.redisBaseline}, **evaluated
217
+ * at this report's `cacheHitRate`** — so a higher cache-hit rate raises it (cache hits are free) — with the other
218
+ * request axes at 0, and with what keeping the data readable costs taken out of the baseline first: storage, and
219
+ * this report's pointer refresh. Loads, the write side, are left out of it. `Infinity` means it never crosses (a
220
+ * 100% cache-hit rate). The published anchor (~329 reads/s) is against {@link ONE_REDIS_HA_CLUSTER}, at
221
+ * `cacheHitRate: 0` with no refresh modeled.
222
+ */
223
+ readonly redisCrossover: {
224
+ readonly readsPerSec: number;
225
+ };
226
+ readonly verdict: 'win-big' | 'win' | 'lose-zone';
227
+ readonly rationale: string;
228
+ readonly assumptions: {
229
+ readonly cacheHitRate: number;
230
+ readonly pricingName: string;
231
+ /** True when segment **sizes** were real (grounded `costReport`), false for a pure `estimateCost`. */
232
+ readonly grounded: boolean;
233
+ /** What the model assumed and what it left out, in words. The last says how the Redis was priced. */
234
+ readonly notes: readonly string[];
235
+ };
236
+ }
237
+ /**
238
+ * **Planning** cost estimate — pure, no instance or live data needed (sizing, sales, what-if). Segment sizes
239
+ * are taken as given (or roughly derived from cardinality); use the grounded `segment.costReport()` for
240
+ * exact, real sizes. See {@link CostReport}.
241
+ */
242
+ export declare function estimateCost(input: EstimateInput): CostReport;
243
+ /**
244
+ * **Grounded** report from a real segment byte total (from the `.crbm` index) + a supplied workload. Used by
245
+ * `Segment.costReport()` in the facade. `grounded` defaults to true (the size is exact, not estimated); the
246
+ * caller passes `grounded: false` + a note when the Storage source can't measure size.
247
+ */
248
+ export declare function groundedReport(input: {
249
+ readonly storageBytes: number;
250
+ readonly grounded?: boolean;
251
+ readonly workload?: Workload;
252
+ readonly pricing?: PricingProfile;
253
+ readonly extraNotes?: readonly string[];
254
+ }): CostReport;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * CRC32C (Castagnoli, polynomial 0x1EDC6F41) — the pre-deserialize integrity screen for `.crbm`
3
+ * payloads, index, and footer.
4
+ *
5
+ * Pure TypeScript, table-driven: `core/` may not import `crypto`/`node:crypto` (the determinism lint
6
+ * enforces it), and a from-scratch implementation is what the cross-language golden corpus will be pinned
7
+ * against. CRC32C (not CRC32) is the locked choice — it matches the hardware-accelerated checksum used
8
+ * by SSE/iSCSI/Btrfs, and it is the algorithm the `.crbm` format locks in.
9
+ */
10
+ export declare function crc32c(bytes: Uint8Array, seed?: number): number;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Frozen `.crbm` v1.0 layout constants.
3
+ *
4
+ * These byte widths/offsets are pinned by the golden corpus and must never change for v1 —
5
+ * a new layout is a new format version. All multi-byte integers are little-endian (v1 fixes LE).
6
+ */
7
+ /** 4-byte magic at both file ends: "CRBM". */
8
+ export declare const MAGIC: Uint8Array<ArrayBuffer>;
9
+ export declare const VERSION_MAJOR = 1;
10
+ export declare const VERSION_MINOR = 0;
11
+ /** Front preamble: magic(4) + version_major(1) + version_minor(1) + reserved(2). */
12
+ export declare const PREAMBLE_BYTES = 8;
13
+ /** Payloads begin immediately after the preamble. */
14
+ export declare const PAYLOAD_START = 8;
15
+ /** Fixed footer size (v1.0). */
16
+ export declare const FOOTER_BYTES = 104;
17
+ /** Byte offsets of each field within the 104-byte footer. */
18
+ export declare const FOOTER: {
19
+ readonly indexOffset: 0;
20
+ readonly indexLength: 8;
21
+ readonly indexCrc32c: 16;
22
+ readonly flags: 20;
23
+ readonly payloadCodecId: 24;
24
+ readonly elementWidth: 26;
25
+ readonly containerCodec: 27;
26
+ readonly versionMajor: 28;
27
+ readonly versionMinor: 29;
28
+ readonly reserved2: 30;
29
+ readonly generation: 32;
30
+ readonly indexNonce: 40;
31
+ readonly indexTag: 52;
32
+ readonly keyId: 68;
33
+ readonly chunkCount: 84;
34
+ readonly totalCardinality: 88;
35
+ readonly footerCrc32c: 96;
36
+ readonly endMagic: 100;
37
+ };
38
+ /** Footer bytes covered by `footer_crc32c` (everything before the CRC field). */
39
+ export declare const FOOTER_CRC_COVERAGE: 96;
40
+ /** `flags` bit positions. */
41
+ export declare const FLAG_ENCRYPTED: number;
42
+ export declare const FLAG_INDEX_COMPRESSED: number;
43
+ export declare const FLAG_LITTLE_ENDIAN: number;
44
+ /** Flag bits a v1 reader understands; any bit outside this mask is an unsupported feature. */
45
+ export declare const KNOWN_FLAGS: number;
46
+ /**
47
+ * Default element width: 32-bit ids (the u32 member space). `64` is the *reserved* escape above the
48
+ * ~4.29 B-ids/segment u32 ceiling; a v1 (32-bit) reader must **reject** a 64-bit generation rather than feed
49
+ * it to the 32-bit deserializer (which would mis-count fleet-wide). A future 64-bit generation is therefore a
50
+ * **major**-version bump (auto-rejected by old readers), never a silent minor one — see
51
+ * {@link CrbmReader.open} validates this.
52
+ */
53
+ export declare const ELEMENT_WIDTH_32 = 32;
54
+ /**
55
+ * **Which codec produced the chunk payloads.** Recorded in the footer, validated on read.
56
+ *
57
+ * `.crbm` is a shared container: the index, the CRC32Cs, the AEAD framing and the generation model are all
58
+ * codec-independent, and **only the chunk payload bytes belong to a flavor** (hence *Chunked Remote BitMap* —
59
+ * see the `.crbm` format section of the API reference). This field is what lets one container hold either, the same way ZIP tags each member
60
+ * with a compression method.
61
+ *
62
+ * **A codec id, not a roaring-specific one.** A second codec is genuinely expected — `soaring` is a planned
63
+ * Roaring *variant*, so its serialized bytes are unlikely to be roaring-portable, and it lands *after* the `1.0`
64
+ * format freeze. A field frozen under a codec-specific meaning could not be reinterpreted later without a major
65
+ * format version.
66
+ *
67
+ * **Ids are permanent once published.** Add to {@link KNOWN_PAYLOAD_CODEC_IDS} when a codec ships; never
68
+ * reuse or renumber. Ids are deliberately *not* pre-allocated for codecs that do not exist — a reserved number
69
+ * for an unbuilt codec is a guess about a format nobody has designed.
70
+ */
71
+ export declare const PAYLOAD_CODEC_ROARING_PORTABLE = 1;
72
+ /**
73
+ * Every payload codec id this reader can decode.
74
+ *
75
+ * The reader validates membership rather than equality with a single constant. That is the whole point of an
76
+ * id registry: an unknown id is rejected with a typed error naming it, so an old reader meeting a
77
+ * future-codec generation **fails closed** — the correct direction, and the reason a store built on one codec
78
+ * can never silently misread another's bytes as its own. (The homogeneity contract means one store is one
79
+ * codec, so meeting a foreign generation implies misconfiguration, and a loud rejection is exactly what you
80
+ * want there.)
81
+ */
82
+ export declare const KNOWN_PAYLOAD_CODEC_IDS: ReadonlySet<number>;
83
+ /**
84
+ * The only container codec v1 defines: `0` = none (payloads are stored as the portable roaring serialization
85
+ * with no extra container-level transform). A non-zero codec is a future format feature; a v1 reader rejects
86
+ * it ({@link UnsupportedError}) rather than mis-decode. Stamped by the writer, validated by the reader.
87
+ */
88
+ export declare const CONTAINER_CODEC_NONE = 0;
89
+ /**
90
+ * Default speculative-tail-read size. The spec's worst-case index (a full 16-bit span) is ~400–650 KB,
91
+ * so 256 KB collapses the read to a single GET for the vast majority of real (sparse/medium) segments;
92
+ * only near-full-span segments take the documented second GET.
93
+ */
94
+ export declare const DEFAULT_TAIL_BYTES: number;
95
+ /**
96
+ * Hard cap on the index region a reader will fetch/parse from one (untrusted) object. The index is read
97
+ * whole on the cheap `open()`/`count()` path, so it must be bounded before allocation — generously
98
+ * above the ~650 KB full-span worst case, well below a denial-of-wallet allocation.
99
+ */
100
+ export declare const DEFAULT_MAX_INDEX_BYTES: number;
101
+ /** Hard cap on a single chunk payload, defending the native deserializer against oversized input. */
102
+ export declare const DEFAULT_MAX_PAYLOAD_BYTES: number;
103
+ /** A chunk's cardinality is in `[1, 65536]` (empty chunks are never written). */
104
+ export declare const MAX_CHUNK_CARDINALITY = 65536;
105
+ /** Fixed width of a per-chunk CRC32C field in the index (high-entropy → not varint). */
106
+ export declare const CRC32C_BYTES = 4;
107
+ /**
108
+ * v1 AEAD framing sizes (AES-256-GCM), fixed by the format exactly like the payload codec id — a
109
+ * different cipher is a new format version. An encrypted chunk payload is stored as `nonce ‖ ciphertext ‖ tag`;
110
+ * the encrypted **index**'s nonce/tag live in the footer's reserved `indexNonce`/`indexTag` slots. Matches
111
+ * {@link FOOTER.indexNonce} (12 B) and {@link FOOTER.indexTag} (16 B).
112
+ */
113
+ export declare const AEAD_NONCE_BYTES = 12;
114
+ export declare const AEAD_TAG_BYTES = 16;
@@ -0,0 +1,86 @@
1
+ import type { BlobReader } from '../blob.js';
2
+ import type { CrbmCrypto } from '../crypto.js';
3
+ export interface CrbmIndexEntry {
4
+ readonly chunkKey: number;
5
+ readonly offset: number;
6
+ readonly length: number;
7
+ readonly cardinality: number;
8
+ readonly crc32c: number;
9
+ }
10
+ export interface CrbmReaderOptions {
11
+ /** Speculative tail size in bytes (default 256 KB; clamped up to at least the footer size). */
12
+ readonly tailBytes?: number;
13
+ /** Hard cap on a single chunk payload length (default 16 MB). */
14
+ readonly maxPayloadBytes?: number;
15
+ /** Hard cap on the whole index region fetched/parsed from one object (default 8 MB). */
16
+ readonly maxIndexBytes?: number;
17
+ /** Decryption context for an encrypted object (its DEK's AEAD + AAD builder). Required iff `FLAG_ENCRYPTED`. */
18
+ readonly crypto?: CrbmCrypto;
19
+ /**
20
+ * Opaque marker for the *incarnation of the name* this object belongs to — the caller's own identity for the
21
+ * registry row it resolved. The reader never interprets it; it carries it so a caller holding an open reader
22
+ * can tell "the same generation of the same segment" from "the same generation NUMBER of a segment that was
23
+ * deleted and re-created", which the number alone cannot express.
24
+ */
25
+ readonly lineage?: unknown;
26
+ }
27
+ export declare class CrbmReader {
28
+ private readonly blob;
29
+ private readonly objectSize;
30
+ readonly generation: number;
31
+ /** See {@link CrbmReaderOptions.lineage} — carried, never interpreted. */
32
+ readonly lineage: unknown;
33
+ readonly totalCardinality: number;
34
+ private readonly entries;
35
+ private readonly orderedKeys;
36
+ /** True when `open()` satisfied the index from the tail GET alone (no second range read). */
37
+ readonly servedFromTail: boolean;
38
+ /** Set iff the object is encrypted — used to decrypt each chunk payload in {@link getChunk}. */
39
+ private readonly crypto;
40
+ /** The footer's own CRC, as verified at open: it covers the index's CRC, the chunk count and the generation. */
41
+ private readonly footerCrc;
42
+ private constructor();
43
+ /**
44
+ * Which object this reader opened, as its size and its footer's CRC. The footer covers the index's CRC, so two
45
+ * objects with the same fingerprint hold the same chunks, as far as a CRC can tell; a pin compares it to know that
46
+ * the object it reopens is the one it pinned, since a purged and reloaded name reuses the generation number.
47
+ */
48
+ get fingerprint(): string;
49
+ /** Total object bytes (from the one-GET tail read) — for grounded storage cost. */
50
+ get sizeBytes(): number;
51
+ /**
52
+ * Estimated retained JS heap of this reader's parsed index — the weight the storage reader cache bounds on
53
+ * (a wide segment's parsed index, not its payloads, dominates the reader's footprint). `entries.size` scales with the number
54
+ * of resident chunks (≤ 65536), so this is `entries.size × {@link RETAINED_BYTES_PER_INDEX_ENTRY}`.
55
+ */
56
+ get retainedIndexBytes(): number;
57
+ /** Per-chunk cardinality (`chunkKey → count`) from the parsed index — no payload reads. */
58
+ cardinalities(): Map<number, number>;
59
+ /**
60
+ * Whether the object behind `blob` is the one `fingerprint` names ({@link fingerprint}), from one tail read of a
61
+ * footer's worth, with no key and no index, since the footer is stored in the clear and checked by its own CRC.
62
+ * Another size is another object, whatever its bytes hold: a short one that is no `.crbm` at all is not the one
63
+ * pinned. At the same size the footer's CRC says, and a footer that fails its own checks says nothing about which
64
+ * object is there, so this throws, as an open would. What a pin asks, to tell the object it opened from one that
65
+ * has since been stored under its key, whoever wrote it.
66
+ */
67
+ static sameObject(blob: BlobReader, fingerprint: string): Promise<boolean>;
68
+ static open(blob: BlobReader, options?: CrbmReaderOptions): Promise<CrbmReader>;
69
+ /** Chunk keys present in this generation, ascending. */
70
+ chunkKeys(): number[];
71
+ /** Segment cardinality from the footer (no payload reads) — the cheap `count()` path. */
72
+ count(): number;
73
+ has(chunkKey: number): boolean;
74
+ /**
75
+ * Range-GET one chunk's payload, verifying its CRC32C before returning. Returns `null` if the chunk is
76
+ * absent. Throws `IntegrityError` on a CRC mismatch (the bytes must never reach the native
77
+ * deserializer). The returned buffer is a **read-only view** owned by the reader/driver — callers must
78
+ * not mutate it.
79
+ */
80
+ getChunk(chunkKey: number): Promise<Uint8Array | null>;
81
+ }
82
+ export declare function parseIndex(indexBytes: Uint8Array, payloadEnd: number, maxPayloadBytes: number, minPayloadBytes?: number): {
83
+ entries: Map<number, CrbmIndexEntry>;
84
+ orderedKeys: number[];
85
+ cardinalitySum: number;
86
+ };
@@ -0,0 +1,13 @@
1
+ /** Append the LEB128 encoding of a u32 `value` to `out`. */
2
+ export declare function writeVarint(out: number[], value: number): void;
3
+ export interface VarintRead {
4
+ readonly value: number;
5
+ /** Offset just past the bytes consumed. */
6
+ readonly next: number;
7
+ }
8
+ /**
9
+ * Decode a LEB128 varint from `bytes` starting at `offset`. Throws `IntegrityError` on a truncated or
10
+ * over-long (> 5-byte / > u32) encoding — the bytes are untrusted, so a malformed index must fail
11
+ * cleanly, never loop or overflow.
12
+ */
13
+ export declare function readVarint(bytes: Uint8Array, offset: number): VarintRead;