@cloudbitmaps/core 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.
Files changed (132) hide show
  1. package/NOTICE +4 -4
  2. package/README.md +28 -6
  3. package/dist/chunk-BUPDGZRR.js +461 -0
  4. package/dist/chunk-BUPDGZRR.js.map +7 -0
  5. package/dist/core/audit.d.ts +127 -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 +66 -0
  9. package/dist/core/codec.d.ts +114 -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 +102 -0
  14. package/dist/core/crbm/crc32c.d.ts +10 -0
  15. package/dist/core/crbm/format.d.ts +116 -0
  16. package/dist/core/crbm/reader.d.ts +69 -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 +432 -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 +75 -0
  23. package/dist/core/due-index.d.ts +55 -0
  24. package/dist/core/engine.d.ts +204 -0
  25. package/dist/core/erase-id.d.ts +141 -0
  26. package/dist/core/erasure.d.ts +179 -0
  27. package/dist/core/errors.d.ts +120 -0
  28. package/dist/core/generation-gc.d.ts +52 -0
  29. package/dist/core/keys.d.ts +23 -0
  30. package/dist/core/load.d.ts +102 -0
  31. package/dist/core/lru.d.ts +69 -0
  32. package/dist/core/metrics.d.ts +132 -0
  33. package/dist/core/name-codec.d.ts +79 -0
  34. package/dist/core/pinned-storage-source.d.ts +69 -0
  35. package/dist/core/ports.d.ts +340 -0
  36. package/dist/core/registry-scan.d.ts +38 -0
  37. package/dist/core/retention-sweep.d.ts +190 -0
  38. package/dist/core/retention.d.ts +67 -0
  39. package/dist/core/retry.d.ts +65 -0
  40. package/dist/core/rollback.d.ts +71 -0
  41. package/dist/core/validate.d.ts +20 -0
  42. package/dist/driver-kit.d.ts +10 -0
  43. package/dist/driver-kit.js +314 -0
  44. package/dist/driver-kit.js.map +7 -0
  45. package/dist/drivers/_shared/aws-errors.d.ts +18 -0
  46. package/dist/drivers/_shared/keys.d.ts +27 -0
  47. package/dist/drivers/_shared/object-registry-keys.d.ts +28 -0
  48. package/dist/drivers/_shared/object-registry.d.ts +84 -0
  49. package/dist/drivers/_shared/registry.d.ts +62 -0
  50. package/dist/drivers/backends.d.ts +49 -0
  51. package/dist/drivers/crypto.d.ts +38 -0
  52. package/dist/drivers/localfs/fs-util.d.ts +20 -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 +57 -0
  57. package/dist/drivers/retry/retrying-drivers.d.ts +90 -0
  58. package/dist/export/index.d.ts +92 -0
  59. package/dist/index.d.ts +85 -2534
  60. package/dist/index.js +3076 -2978
  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-L54FL5DW.js +0 -203
  79. package/dist/chunk-L54FL5DW.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-SNJVZ227.js +0 -35
  83. package/dist/chunk-SNJVZ227.js.map +0 -1
  84. package/dist/chunk-YEHMG6W2.js +0 -6
  85. package/dist/chunk-YEHMG6W2.js.map +0 -1
  86. package/dist/dynamodb/index.cjs +0 -740
  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 -4608
  99. package/dist/index.cjs.map +0 -1
  100. package/dist/index.d.cts +0 -2535
  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-5BQTGKze.d.cts +0 -356
  114. package/dist/ports-5BQTGKze.d.ts +0 -356
  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 -891
  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,120 @@
1
+ /**
2
+ * Typed errors — callers learn *why* something failed, never by parsing strings. Retry is the driver
3
+ * decorators' job, not the engine's: a driver classifies its backend's failures into this vocabulary, and
4
+ * `withRetry` decides what is transient.
5
+ */
6
+ /**
7
+ * Registry-symbol brands, so an error stays classifiable when the class object is not shared.
8
+ *
9
+ * In an ordinary install there is ONE copy of these classes and `instanceof` holds everywhere — across the
10
+ * flavor, the driver packages and core itself — because every package leaves `@cloudbitmaps/*` external and
11
+ * resolves core at runtime rather than inlining it.
12
+ *
13
+ * What still breaks it is a SECOND copy: a version skew between our packages that the installer could not
14
+ * dedupe, or a bundler that emits core twice. Then a driver throws a class the catching side's `instanceof`
15
+ * does not match — and silently, because a `catch` that stops matching just falls through, defeating
16
+ * transient retry and the publish path's conflict handling with no error of its own.
17
+ *
18
+ * `Symbol.for` is identity-stable across copies, bundles and realms, so classify errors with the exported
19
+ * predicates below (never `instanceof`) anywhere an error may cross that boundary.
20
+ */
21
+ declare const ERROR_BRAND: unique symbol;
22
+ declare const TRANSIENT_BRAND: unique symbol;
23
+ /** Base class for every error CloudRoaring throws. */
24
+ export declare class CloudRoaringError extends Error {
25
+ /** Cross-bundle brand — see the predicates ({@link isCloudRoaringError}, …). Non-enumerable-ish (symbol key ⇒ not in JSON). */
26
+ readonly [ERROR_BRAND]: true;
27
+ constructor(message: string);
28
+ }
29
+ /** Invalid caller input (bad id, segment name, options). Raised before any storage call. */
30
+ export declare class ValidationError extends CloudRoaringError {
31
+ }
32
+ /** An OCC conditional write/delete lost the race — the row changed since it was read. */
33
+ export declare class WriteConflictError extends CloudRoaringError {
34
+ }
35
+ /** Bytes from a tier are corrupt, oversized, or fail a checksum/format check. */
36
+ export declare class IntegrityError extends CloudRoaringError {
37
+ }
38
+ /**
39
+ * A requested object/row does not exist. Part of the driver error vocabulary; thrown by
40
+ * the persistent drivers (S3, GCS, Azure Blob, local filesystem) — the in-memory drivers return
41
+ * `null` instead.
42
+ */
43
+ export declare class NotFoundError extends CloudRoaringError {
44
+ }
45
+ /**
46
+ * This build/configuration cannot perform the requested operation, though nothing is malformed. Two uses:
47
+ * (1) **format** — the bytes are well-formed but unreadable here (an unknown `.crbm` major version, an
48
+ * encrypted file before the crypto path exists) — distinct from `IntegrityError` (corruption); and (2)
49
+ * **store configuration** — an operation this store's wiring doesn't support (e.g. a lifecycle helper like
50
+ * `eraseSubject`/`retireExpired` called on a store built without a storage backend). Raised at
51
+ * operation time, before any mutation.
52
+ */
53
+ export declare class UnsupportedError extends CloudRoaringError {
54
+ }
55
+ /**
56
+ * A driver cannot meet a capability the chosen topology requires (e.g. a Storage driver without range
57
+ * reads). Raised fail-fast at wiring time, never mid-operation.
58
+ */
59
+ export declare class CapabilityError extends CloudRoaringError {
60
+ }
61
+ /**
62
+ * An operation would exceed its per-op **denial-of-wallet budget** — too many backend requests for a single
63
+ * `count`/`iterate`/`intersect`/`subjectReport`/`eraseSubject` call — so it is refused **before** fanning out
64
+ * (hard invariant 6). Default-on but generous (normal ops never hit it); tune it
65
+ * per store (`budget`) or per op, or disable with `budget: false`. Deterministic (never retried): the op is too
66
+ * big by policy, not by luck. Each request's bytes are separately capped (the safe-deserialize ceiling), so
67
+ * bounding the request count transitively bounds bytes. Carries the projected count + the limit, never data.
68
+ */
69
+ export declare class BudgetExceededError extends CloudRoaringError {
70
+ }
71
+ /**
72
+ * An encrypted segment's data key (DEK) cannot be unwrapped because the keystore holds none of the
73
+ * key-encryption-keys (KEKs) its wrappings reference — the KEK was never configured, rotated away without
74
+ * keeping the old key, or lost. Deterministic (never retried): without a KEK the ciphertext is unreadable by
75
+ * design. The flip side of crypto-shred — when this is *intended* (a destroyed segment) the registry row is
76
+ * already a `destroyed` tombstone; when it's *not*, restore the missing KEK (or its recovery KEK). Carries no
77
+ * key material.
78
+ */
79
+ export declare class KeyUnavailableError extends CloudRoaringError {
80
+ }
81
+ /**
82
+ * A **transient** infrastructure fault that is safe to retry — throttling, a 5xx, a dropped connection,
83
+ * a client-side request timeout. Drivers classify their backend's retryable faults and raise this (the
84
+ * SDK-specific knowledge stays in the SDK-specific driver); the retry layer (`core/retry`) retries **only**
85
+ * this class, never a deterministic error like {@link ValidationError}, {@link IntegrityError},
86
+ * {@link NotFoundError}, or {@link WriteConflictError} (retrying those is pointless or wrong). The original
87
+ * error is preserved in `cause` so callers can still inspect it.
88
+ *
89
+ * Note for logging hygiene: `cause` is the **raw SDK error**, which may carry operational
90
+ * metadata (endpoint host, request IDs, `$metadata`). The library's own `message` is identifier-only and safe
91
+ * to log; if you serialize the whole error *chain*, be aware you're including that metadata.
92
+ */
93
+ export declare class TransientError extends CloudRoaringError {
94
+ /** A second brand so the whole transient subtree (incl. {@link TimeoutError}) is classifiable cross-bundle. */
95
+ readonly [TRANSIENT_BRAND]: true;
96
+ constructor(message: string, options?: {
97
+ cause?: unknown;
98
+ });
99
+ }
100
+ /**
101
+ * A single attempt exceeded its time budget. Subclass of {@link TransientError} so the retry layer treats a
102
+ * timeout as retryable by default — a stalled request often succeeds on a fresh connection. Raised by a
103
+ * driver whose injected client reports a request timeout — setting one on your injected client is the
104
+ * recommended way to bound a hang.
105
+ */
106
+ export declare class TimeoutError extends TransientError {
107
+ }
108
+ /** Any error thrown by CloudRoaring (any tier, any bundle). */
109
+ export declare function isCloudRoaringError(err: unknown): err is CloudRoaringError;
110
+ /** An OCC conditional write/delete lost the race — retry the read-modify-write, don't fail. */
111
+ export declare function isWriteConflictError(err: unknown): err is WriteConflictError;
112
+ /** A retryable transient infrastructure fault (incl. {@link TimeoutError}). The retry layer keys on this. */
113
+ export declare function isTransientError(err: unknown): err is TransientError;
114
+ /** A requested object/row does not exist. */
115
+ export declare function isNotFoundError(err: unknown): err is NotFoundError;
116
+ /** Corrupt/oversized/failed-checksum bytes from a tier. */
117
+ export declare function isIntegrityError(err: unknown): err is IntegrityError;
118
+ /** Invalid caller input. */
119
+ export declare function isValidationError(err: unknown): err is ValidationError;
120
+ export {};
@@ -0,0 +1,52 @@
1
+ import type { IStorageDriver, IRegistryDriver, SegmentRef } from './ports.js';
2
+ /** The two ports generation bookkeeping needs: the objects, and the pointer that says which one is current. */
3
+ export interface GenerationDeps {
4
+ readonly storage: IStorageDriver;
5
+ readonly registry: IRegistryDriver;
6
+ }
7
+ /**
8
+ * The generation number a writer should use for the segment's **next** object: one above the highest generation
9
+ * the registry points at *or* that is present in Storage — whichever is higher.
10
+ *
11
+ * Both are consulted because they can disagree. A load that wrote its object and crashed before publishing leaves
12
+ * an object *above* `currentGen`; a writer that consulted only the pointer would pick that same number and hit the
13
+ * write-once conflict on every retry. Skipping past it costs one `list` and keeps the retry trivial — the crashed
14
+ * object is an orphan {@link gcOrphanGenerations} collects once the pointer moves on. Reusing such an object
15
+ * instead of skipping it (verify-and-publish) is `load()`'s job, not a numbering helper's.
16
+ *
17
+ * A segment with no row and no objects starts at 0. An admin/write-path helper, never on the read path.
18
+ */
19
+ export declare function nextGeneration(ref: SegmentRef, deps: GenerationDeps): Promise<number>;
20
+ /**
21
+ * Garbage-collect superseded Storage generations for a segment: everything strictly below `currentGen`, keeping
22
+ * the most recent `keep` of them as a grace window, so a read still fetching from a just-superseded
23
+ * generation need not re-resolve mid-call (**invariant 4**). It is a window, not a lock: a read whose
24
+ * generation is swept anyway re-resolves and retries once rather than failing (see `withFreshSnapshot`), so
25
+ * `keep` trades storage for round trips. Generations ≥ `currentGen` are never touched. Returns the generations
26
+ * deleted.
27
+ *
28
+ * **Except on a `destroyed` segment, where EVERY generation is garbage** and the grace window is meaningless.
29
+ * A tombstoned segment resolves no generation for any reader, pinned or not — a pin cannot be taken on one, and
30
+ * a pin taken while it was still active re-checks the status on every open — and nothing else in the library
31
+ * would ever collect them, so without this those objects are billed forever.
32
+ *
33
+ * **Throws {@link WriteConflictError} if the segment changed underneath the pass** — not to be confused with
34
+ * the empty array it still returns when there was genuinely nothing to collect (no row at all, or no pointer
35
+ * yet), which is why an empty array is not a receipt. The row is read before the listing and acted on after it, so a name that was purged and
36
+ * re-created in that window is a *different* segment wearing the same name, and its live object must not be
37
+ * collected on the strength of the old row. Refusing is safe — the objects are not going anywhere and the next
38
+ * pass reads a consistent row — but it has to be **distinguishable** from "there was nothing to collect", which
39
+ * an empty array is not: `eraseIdFromSegment` reads the returned list as the physical half of its erasure
40
+ * receipt, and would otherwise report `erased: true` over bytes still in the bucket. Re-run it.
41
+ *
42
+ * That state is reachable in practice: a `dropSegment` whose Storage sweep threw part-way, or a load that was
43
+ * already writing its object when the tombstone landed and finished the write afterwards. `dropSegment` re-sweeps
44
+ * and reports whatever it could not reclaim in `generationsRemaining`, but a drop that was never re-run leaves a
45
+ * residual, and this is what eventually collects it from the retention sweep.
46
+ *
47
+ * `keep: 0` is how a subject-erasure rewrite makes a bit **physically** gone on return: the generation that held
48
+ * it is collected the moment the rewrite is current. A reader pinned to it re-resolves on its next read.
49
+ */
50
+ export declare function gcOrphanGenerations(ref: SegmentRef, deps: GenerationDeps, options?: {
51
+ keep?: number;
52
+ }): Promise<number[]>;
@@ -0,0 +1,23 @@
1
+ import type { ChunkRef, SegmentRef } from './ports.js';
2
+ export declare function segmentKey(ref: SegmentRef): string;
3
+ export declare function segmentPrefix(ref: SegmentRef): string;
4
+ export declare function chunkRefKey(ref: ChunkRef): string;
5
+ /**
6
+ * CACHE-cache key for a chunk scoped to a specific **version** of the segment — {@link chunkRefKey} plus that
7
+ * version (space-delimited, injection-proof exactly as above — the name parts are already encoded). A publish bumps the version, so it naturally
8
+ * misses the cache instead of serving a stale superseded chunk, and the superseded entries age out under the
9
+ * LRU ceiling (no active purge needed).
10
+ *
11
+ * `version` is whatever the Storage source reports as identifying the bytes a read will see — its
12
+ * `currentVersion` where it has one, otherwise the bare generation number. **A generation number alone is not
13
+ * an identity**: `nextGeneration` restarts at 0 once a registry row is purged and the bucket emptied, so a
14
+ * retired-and-re-created name serves different data at the same `currentGen`, and a key built on the number
15
+ * hands the new incarnation the old one's decoded chunks.
16
+ */
17
+ export declare function chunkGenKey(ref: ChunkRef, version: string | number): string;
18
+ /**
19
+ * Deterministic FNV-1a shard assignment for a segment key — dependency-free, stable across workers and
20
+ * restarts. One definition, so every sharded fleet operation (today the retention sweep) agrees on it exactly:
21
+ * two definitions that disagreed would make the union across workers neither disjoint nor complete.
22
+ */
23
+ export declare function shardOf(key: string, totalShards: number): number;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `loadSegment` — the loaded store's primary write path, as one call.
3
+ *
4
+ * A load is always the same four steps: take the next generation number, write one immutable object, move the
5
+ * pointer, collect what the move superseded. Done by hand they are `nextGeneration` + `bulkLoadCrbmGeneration` +
6
+ * `publishGeneration` + `gcOrphanGenerations`, and the step people leave out is the last one — so stores that
7
+ * compose it themselves accumulate superseded generations they keep paying for. Composing it here is the point.
8
+ *
9
+ * The other reason it is one call is the **guard**. A load REPLACES a segment: whatever the stream contains is
10
+ * what the segment contains afterwards. That makes an upstream query returning fewer rows than usual — or none —
11
+ * a silent, successful-looking wipe, and nothing downstream can tell it from a legitimate shrink. The guard is
12
+ * the place to say what a plausible result looks like, and it has to sit **between the write and the publish**,
13
+ * because that is the only moment where the new content is known and the old one is still authoritative.
14
+ *
15
+ * Refusing therefore has to clean up after itself. The object is already durable at that point, and it sits
16
+ * ABOVE `currentGen`, where generation collection deliberately never looks (it deletes strictly below the
17
+ * pointer). Nothing else would ever reclaim it, so a refused load deletes its own object before returning.
18
+ */
19
+ import { type IAuditSink } from './audit.js';
20
+ import type { CodecInterface } from './codec.js';
21
+ import type { Clock } from './determinism.js';
22
+ import type { IKeystore } from './crypto.js';
23
+ import type { IStorageDriver, IRegistryDriver, SegmentRef } from './ports.js';
24
+ /** What {@link loadSegment} needs: the objects, the pointer, the codec, and key material if encrypted. */
25
+ export interface LoadDeps {
26
+ readonly storage: IStorageDriver;
27
+ readonly registry: IRegistryDriver;
28
+ readonly codec?: CodecInterface;
29
+ readonly keystore?: IKeystore;
30
+ readonly requireEncryption?: boolean;
31
+ readonly clock?: Clock;
32
+ }
33
+ /**
34
+ * What a plausible result looks like, so an implausible one is refused instead of published.
35
+ *
36
+ * Both bounds are compared against the **current** generation's cardinality, read from the `.crbm` index rather
37
+ * than its payload — one object-header read, and only when a bound is actually set.
38
+ */
39
+ export interface LoadGuard {
40
+ /** Refuse a generation with fewer than this many ids. Use for "this segment is never legitimately tiny". */
41
+ readonly minCardinality?: number;
42
+ /**
43
+ * Refuse a generation that retains less than this fraction of the current one — `0.5` means "at least half the
44
+ * ids must survive". A *growing* load is never refused by this bound, and neither is the first load of a
45
+ * segment, which has nothing to shrink from.
46
+ *
47
+ * Phrased as a floor rather than a ceiling on the loss so that `0` means the same thing here as it does on
48
+ * `minCardinality` above: **no bound**. The inverse spelling (`maxShrink`) would make `0` mean "refuse any
49
+ * shrink at all" — maximally strict — sitting in the same object literal as a field where `0` is maximally
50
+ * permissive, which is where an inversion would eventually be written and not noticed.
51
+ */
52
+ readonly minRetained?: number;
53
+ }
54
+ export interface LoadOptions {
55
+ /**
56
+ * Publish an empty generation over a non-empty one. Off by default: an empty result is far more often an
57
+ * upstream failure than an intent, and the failure is indistinguishable from success once it lands.
58
+ */
59
+ readonly allowEmpty?: boolean;
60
+ readonly guard?: LoadGuard;
61
+ /**
62
+ * Generations to keep below the new pointer as a grace window (default 1). Keep at least one: a read that is
63
+ * still fetching chunks from the just-superseded generation would otherwise have it deleted out from under it
64
+ * and pay a re-resolve. A wider window costs storage and buys nothing a pinned read would not do better.
65
+ */
66
+ readonly keep?: number;
67
+ readonly audit?: IAuditSink;
68
+ }
69
+ /** Why a load did not become current. */
70
+ export type LoadRefusal = 'empty' | 'min-cardinality' | 'min-retained'
71
+ /** Another writer published a higher generation first. The object was written, then removed. */
72
+ | 'superseded';
73
+ export interface LoadResult {
74
+ /** The generation written. Present even when refused — it is what was deleted again. */
75
+ readonly generation: number;
76
+ /** Whether this generation is now the segment's current one. */
77
+ readonly published: boolean;
78
+ /** Set only when `published` is false. */
79
+ readonly reason?: LoadRefusal;
80
+ readonly size: number;
81
+ readonly sha256: string;
82
+ readonly chunkCount: number;
83
+ /** Distinct ids in the generation that was written, post-dedup. */
84
+ readonly cardinality: number;
85
+ /**
86
+ * What the segment held when the guard judged it — `null` when there was no current generation, or when no
87
+ * bound needed it. Returned because an alert on `reason: 'min-retained'` is useless without it: "refused" is a
88
+ * page, "refused: 4 → 1" is a diagnosis, and re-reading the segment to find out costs a round trip and races
89
+ * whatever happens next.
90
+ */
91
+ readonly cardinalityBefore: number | null;
92
+ /** The superseded generations collected after publishing. Empty when nothing was published. */
93
+ readonly collected: readonly number[];
94
+ }
95
+ /**
96
+ * Replace a segment's contents with `ids`, as one immutable generation, and make it current.
97
+ *
98
+ * Returns what was written and whether it became current. A refusal is **not** an exception: `published: false`
99
+ * with a `reason` is a normal outcome a caller branches on, in the same shape as a successful load, because the
100
+ * interesting cases (a guard tripped, a racing writer won) are operational facts rather than faults.
101
+ */
102
+ export declare function loadSegment(ref: SegmentRef, ids: Iterable<number> | AsyncIterable<number>, deps: LoadDeps, options?: LoadOptions): Promise<LoadResult>;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Bounded LRU cache with optional TTL — the cache tier's memory ceiling.
3
+ *
4
+ * Time comes from an injected `Clock` (never `Date.now()`) so the cache is deterministic
5
+ * under simulation.
6
+ *
7
+ * The cache can be bounded by entry **count** (`maxEntries`, always) and, optionally, by an aggregate
8
+ * **byte weight** (`maxBytes`). The byte bound exists for values whose footprint is large and uneven — the
9
+ * storage reader cache, where one entry pins a fully-parsed `.crbm` index that can be ~6 MB for a wide segment
10
+ * yet a few hundred bytes for a small one (a count-only bound lets 1024 wide indices blow a Lambda's
11
+ * memory even though the count is "in bounds"). Weights arrive **after** insertion (a cached reader is a
12
+ * pending promise; its size is known only once it resolves), so weight is reported via {@link setWeight} and
13
+ * eviction re-runs then.
14
+ */
15
+ import type { Clock } from './determinism.js';
16
+ export interface LruOptions {
17
+ /** Hard ceiling on entries; the least-recently-used is evicted past it. */
18
+ readonly maxEntries: number;
19
+ /**
20
+ * Optional aggregate byte ceiling across all entries' reported weights (see {@link BoundedLru.setWeight}).
21
+ * When set, the least-recently-used entries are evicted until the total is within budget — in addition to
22
+ * the `maxEntries` bound. Omit for a pure count-bounded cache (weights stay 0, no byte eviction).
23
+ */
24
+ readonly maxBytes?: number;
25
+ /** Optional time-to-live in ms; an entry older than this is treated as absent. */
26
+ readonly ttlMs?: number;
27
+ /** Only `now()` is used (never `sleep`), so a count-only cache can pass a minimal time source. */
28
+ readonly clock: Pick<Clock, 'now'>;
29
+ }
30
+ export declare class BoundedLru<K, V> {
31
+ private readonly map;
32
+ private readonly maxEntries;
33
+ private readonly maxBytes;
34
+ private readonly ttlMs;
35
+ private readonly clock;
36
+ private totalBytes;
37
+ constructor(options: LruOptions);
38
+ get(key: K): V | undefined;
39
+ /** Read a value **without** counting as an access (recency unchanged, no purge on expiry). */
40
+ peek(key: K): V | undefined;
41
+ set(key: K, value: V): void;
42
+ /**
43
+ * Report an entry's byte weight (typically once a cached promise resolves and its size is known). Updates
44
+ * the running total and evicts the least-recently-used entries if the aggregate now exceeds `maxBytes`.
45
+ * Recency is **not** changed. The `key` being weighted is protected from this eviction pass (we just
46
+ * populated it) — so a single entry larger than the whole budget is kept rather than immediately dropped.
47
+ */
48
+ setWeight(key: K, bytes: number): void;
49
+ private evictToBudget;
50
+ /** The least-recently-used key that is not `protectKey`, or undefined if none other exists. */
51
+ private oldestExcept;
52
+ private dropKey;
53
+ private drop;
54
+ /** Membership test that does NOT count as an access (recency is unchanged). Purges if expired. */
55
+ has(key: K): boolean;
56
+ delete(key: K): boolean;
57
+ /**
58
+ * Delete every entry whose key matches `pred`, returning how many went. Used to drop one segment's decoded
59
+ * chunks when something destroys the generation they came from — a deletion the generation-keyed cache
60
+ * cannot express on its own, because a *destroyed* segment has no newer generation whose key would miss.
61
+ * Linear in the cache size; called on admin paths (erasure, drop, shred, retirement), never on a read.
62
+ */
63
+ deleteWhere(pred: (key: K) => boolean): number;
64
+ clear(): void;
65
+ /** Current entry count (may include not-yet-evicted expired entries). */
66
+ get size(): number;
67
+ /** Current aggregate reported weight (bytes) across all entries. */
68
+ get weightBytes(): number;
69
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Metrics seam — an injected sink the engine pushes typed events to, exactly like the `Clock`/driver seams
3
+ * keep `core/` pure and storage-agnostic.
4
+ *
5
+ * Design: the library emits **neutral domain events** and stays vendor-agnostic — you (or a ~12-line
6
+ * adapter) map them to OpenTelemetry / Datadog / a log line. The default is a **no-op**, and emission is
7
+ * skipped entirely when no sink is wired (near-zero overhead, no telemetry dependency). Events carry **raw
8
+ * observations** (bytes, counts, ms); turning those into billable units / dollars is the cost model's job,
9
+ * which keeps rates pluggable.
10
+ *
11
+ * Two caveats for sink authors: `onEvent` runs **synchronously on the I/O path**, so keep it cheap and
12
+ * non-blocking (offload batching/network to your own async queue); and `segment`/`namespace` are
13
+ * caller-controlled strings that may be PII and are **unbounded-cardinality** — do not map them to
14
+ * per-series metric labels unless they're known low-cardinality and PII-free. See the getting-started
15
+ * "Observability" section.
16
+ */
17
+ /** The segment operations that emit an `op` latency event (timed with the injected clock, at the facade). */
18
+ export type MetricOpName = 'has' | 'count' | 'intersectInto' | 'unionInto' | 'andNotInto';
19
+ /**
20
+ * One observability event. A discriminated union on `kind` — new variants can be added over time without
21
+ * breaking consumers (they switch on the kinds they care about). `namespace` is the segment's namespace
22
+ * (absent ⇒ the default namespace). Segment names are identifiers, never bitmap contents (threat model:
23
+ * never log PII / bitmap bits).
24
+ */
25
+ export type MetricEvent = {
26
+ readonly kind: 'storage.get';
27
+ readonly namespace?: string;
28
+ readonly segment: string;
29
+ /** Bytes returned (0 if the chunk was absent — a GET still happened). */
30
+ readonly bytes: number;
31
+ /**
32
+ * Elapsed wall time of the read — includes any transient-retry backoff on the storage call. From the
33
+ * injected clock, clamped to ≥ 0 (0 under the no-wait test clock).
34
+ */
35
+ readonly ms: number;
36
+ } | {
37
+ readonly kind: 'cache';
38
+ readonly hit: boolean;
39
+ } | {
40
+ readonly kind: 'retry';
41
+ /** Infrastructure-fault backoff (throttling, 5xx, a dropped connection) — the one kind of retry the store does. */
42
+ readonly reason: 'transient';
43
+ /** 1-based number of the attempt about to be retried. */
44
+ readonly attempt: number;
45
+ readonly delayMs: number;
46
+ } | {
47
+ readonly kind: 'intersect';
48
+ /**
49
+ * Which chunk-aligned combine this was. **Optional for backward compatibility** — absent means
50
+ * `'intersect'`, which is all this event reported before `union`/`andNot` existed.
51
+ *
52
+ * For `'union'` over its *include* operands alone, `skippedChunks` is 0 by construction — union reads
53
+ * every chunk of every operand, so there is nothing to prune. It can still be non-zero when a union
54
+ * carries `exclude` operands, because a suppression list may hold keys no include does and those are
55
+ * legitimately never fetched. So: 0 is the expected shape for a plain union, not a guarantee.
56
+ */
57
+ readonly op?: 'intersect' | 'union' | 'andNot';
58
+ /** Operand count, including any `exclude` (suppression) operands. */
59
+ readonly operands: number;
60
+ /**
61
+ * Distinct chunk **keys** selected to fetch — for `'intersect'`, those present in every operand.
62
+ *
63
+ * **Keys, not requests.** The actual storage GETs are roughly `fetchedChunks × operands`, plus one per
64
+ * `exclude` that holds a given key, which is why this number is smaller than what the per-op budget
65
+ * charges for the same call. The two are different units by design; if you are reconciling a bill, the
66
+ * budget's accounting is the one that models requests.
67
+ */
68
+ readonly fetchedChunks: number;
69
+ /**
70
+ * **Distinct** chunk-keys pruned — never fetched (the chunk-skipping saving). Counts distinct keys,
71
+ * not per-operand GETs, so it under-states the true GET saving when 3+ operands partially overlap.
72
+ */
73
+ readonly skippedChunks: number;
74
+ } | {
75
+ readonly kind: 'op';
76
+ readonly name: MetricOpName;
77
+ readonly ms: number;
78
+ };
79
+ /**
80
+ * The sink you plug in. One method, synchronous, fire-and-forget. It must never throw back into the
81
+ * caller — `CloudRoaring` wraps a user sink with {@link safeMetrics} so a buggy sink can't break I/O.
82
+ */
83
+ export interface IMetricsSink {
84
+ onEvent(event: MetricEvent): void;
85
+ }
86
+ /** The default sink: discards everything, zero overhead. Used whenever no sink is wired. */
87
+ export declare const NOOP_METRICS: IMetricsSink;
88
+ /**
89
+ * Wrap a sink so a throwing/buggy `onEvent` can never break the data path — observability is strictly
90
+ * best-effort. `CloudRoaring` applies this to the user-supplied sink at construction.
91
+ */
92
+ export declare function safeMetrics(sink: IMetricsSink): IMetricsSink;
93
+ /** Accumulated totals — the shape returned by {@link CountingMetricsSink.snapshot}. */
94
+ export interface MetricsSnapshot {
95
+ readonly storage: {
96
+ readonly gets: number;
97
+ readonly bytes: number;
98
+ readonly totalMs: number;
99
+ };
100
+ readonly cache: {
101
+ readonly hits: number;
102
+ readonly misses: number;
103
+ };
104
+ readonly retries: {
105
+ readonly transient: number;
106
+ };
107
+ readonly intersect: {
108
+ readonly calls: number;
109
+ readonly fetchedChunks: number;
110
+ readonly skippedChunks: number;
111
+ };
112
+ readonly ops: Readonly<Record<MetricOpName, {
113
+ readonly count: number;
114
+ readonly totalMs: number;
115
+ }>>;
116
+ }
117
+ /**
118
+ * A ready-made sink that tallies events into a {@link MetricsSnapshot} — handy for tests, quick scripts,
119
+ * and as the grounded-request-cost source behind `costReport()`. `snapshot()` returns an independent copy;
120
+ * `reset()` zeroes the counters.
121
+ */
122
+ export declare class CountingMetricsSink implements IMetricsSink {
123
+ private storage;
124
+ private cache;
125
+ private retries;
126
+ private intersect;
127
+ private ops;
128
+ private static zeroOps;
129
+ onEvent(event: MetricEvent): void;
130
+ snapshot(): MetricsSnapshot;
131
+ reset(): void;
132
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Name → physical-key encoding. The library validates *length*, and encodes everything else.
3
+ *
4
+ * Lives in `core/` rather than beside the drivers because it is pure string logic — no I/O, no SDK, no
5
+ * `node:` builtin — and because `validate.ts` needs it to measure a name against the key budget. Core may
6
+ * not import a driver (invariant 7), and a driver may import core, so this is the only layer both can see.
7
+ *
8
+ * The old grammar was an allowlist — `[A-Za-z0-9._:-]`. Widening it one character at a time is what exposed the
9
+ * real problem: the colon had been missing from it by accident rather than by decision, and nothing in the
10
+ * process would have caught the next omission either. An allowlist rejects names for the storage layer's
11
+ * convenience, and the storage layer's convenience is the library's job, not the caller's. So a name is **any
12
+ * non-empty string**, and each physical boundary escapes what *it* cannot take literally.
13
+ *
14
+ * It also fixes a hazard the allowlist *permitted*: `con`, `nul` and `com1` are Windows device names that
15
+ * validated cleanly and failed only on a user's machine. A grammar can be both too narrow and too permissive
16
+ * at once, which is the argument against having one.
17
+ *
18
+ * Percent-encoding, because it is the one escape everybody already reads. `%` escapes itself as `%25` and is
19
+ * encoded **first**, which is what makes the transform injective: every `%` in an encoded string starts an
20
+ * escape, so decoding is unambiguous and two distinct names can never collide on one key.
21
+ *
22
+ * There are two alphabets, because the boundaries differ:
23
+ *
24
+ * - **Object keys** (S3, GCS, Azure Blob) take almost anything. They need `/` escaped so a name cannot
25
+ * invent hierarchy or break a key parser that splits on it, `#` and `|` (see below), and control characters
26
+ * because they are not legal in the XML an S3 LIST returns.
27
+ * - **Filesystem paths** need all of that plus `:` (an NTFS alternate-data-stream separator, where a write can
28
+ * *succeed* while `readdir` never lists the result), plus three hazards that are about the component as a
29
+ * whole rather than its characters — see {@link encodeNameForPath}.
30
+ *
31
+ * **`#` and `|` are escaped for compatibility, not for any backend still here.** They were reserved because
32
+ * the DynamoDB registry composed its partition key from `<namespace>#<segment>|…`, and that driver is gone.
33
+ * The escaping stays anyway: it is baked into every key already written, and un-reserving the two characters
34
+ * would change what `encodeNameForKey` emits for a name containing either — silently moving those segments
35
+ * to a key nothing looks up. A cheap two-character reservation is the right price for not migrating a
36
+ * bucket; the property test below pins it.
37
+ *
38
+ * **Every name that was legal before encodes to itself on the OBJECT-KEY alphabet** — so S3, GCS and Azure
39
+ * keys are byte-identical and there is nothing to migrate there. That is the property to preserve if this
40
+ * file is ever edited, and a property test asserts it over the whole old grammar.
41
+ *
42
+ * The **path** alphabet is not identical, and the difference is a breaking change for an existing LocalFs
43
+ * store. Two classes move: a Windows device-name stem (`con`, `nul`, `com1`, `con.backup`) and a trailing dot
44
+ * (`a.`). Both were legal before and are escaped now — deliberately, since both silently alias or fail on
45
+ * Windows — but a 0.9 store holding one will not find it after the upgrade, because the readers' round-trip
46
+ * guard skips a spelling the driver would never have written. See the upgrade note in `CHANGELOG.md`.
47
+ */
48
+ /**
49
+ * Encode a name for use inside an **object key** (S3, GCS, Azure Blob).
50
+ *
51
+ * Leaves the historically-legal alphabet literal so existing keys do not move, and escapes everything else —
52
+ * including `/` (which would invent hierarchy and break a parser that splits on it), `#` and `|` (reserved by
53
+ * the former DynamoDB registry and kept reserved so existing keys stay valid), and control characters (not
54
+ * legal in S3's XML responses).
55
+ */
56
+ export declare function encodeNameForKey(name: string): string;
57
+ /** Inverse of {@link encodeNameForKey}. */
58
+ export declare function decodeNameFromKey(encoded: string): string;
59
+ /**
60
+ * Encode a name for use as a **filesystem path component**.
61
+ *
62
+ * Everything {@link encodeNameForKey} escapes, plus `:`, plus three hazards that are properties of the whole
63
+ * component rather than of any character in it. All three are silent corruption rather than errors, which is
64
+ * why they are handled here instead of being left to the OS:
65
+ *
66
+ * 1. **`.` and `..`** are traversal. `join(root, '..')` leaves the storage root.
67
+ * 2. **Windows reserved device names** — `CON`, `PRN`, `AUX`, `NUL`, `COM1`–`COM9`, `LPT1`–`LPT9`, case
68
+ * insensitive and *with or without an extension*, so `con.0.crbm` is reserved too. Opening one addresses a
69
+ * device rather than a file. Note the old grammar **permitted** these: `store.segment('con')` validated
70
+ * cleanly and broke only on a user's Windows box.
71
+ * 3. **A trailing `.` or space**, which Windows silently strips — so `a.` and `a` would become the same
72
+ * directory, quietly merging two segments.
73
+ *
74
+ * Each is fixed by escaping the one character that defuses it, which keeps the encoding injective: a name that
75
+ * needed defusing produces a `%XX` no other name produces.
76
+ */
77
+ export declare function encodeNameForPath(name: string): string;
78
+ /** Inverse of {@link encodeNameForPath}. */
79
+ export declare function decodeNameFromPath(encoded: string): string;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * A {@link StorageChunkSource} view that holds **one** segment at **one** generation, and passes everything else
3
+ * through to the live source.
4
+ *
5
+ * A pin exists so a long job describes a single instant. An ordinary handle re-resolves on `cache.genTtlMs`, so a
6
+ * publish part-way through an export, a send or a reconciliation means the second half of the job describes a
7
+ * different instant than the first — and nothing in the result says so.
8
+ *
9
+ * **A pin is a generation NUMBER, not a captured reader.** The number is immutable, so the reader behind it can
10
+ * live in the shared bounded LRU and be evicted freely: re-opening at the same number reproduces the same
11
+ * bytes. Holding the reader instead put every live pin outside the library's memory ceiling.
12
+ *
13
+ * **Other segments are not pinned, and that is the contract, not an omission.** `snap.intersect([other])` reads
14
+ * `snap` at its pinned generation and `other` at whatever is current. Pinning the whole query means pinning
15
+ * each segment. The alternative shapes are both worse: refusing the combine outright makes a pin useless to the
16
+ * export and reconciliation jobs it exists for, and silently pinning the operands would quietly change what
17
+ * `other` means to a caller who did not ask.
18
+ *
19
+ * What this must never do is the inverse — read a pinned handle's segment **live** because the call was made
20
+ * on some other handle. That is why the routing is by segment identity here rather than by which object the
21
+ * caller happened to start from.
22
+ */
23
+ import type { ChunkRef, StorageChunkSource, SegmentRef, SegmentSize } from './ports.js';
24
+ import type { CrbmStorageChunkSource } from './crbm-storage-source.js';
25
+ /** What a pin holds for one segment: the generation, and the version identifying those exact bytes. */
26
+ export interface PinnedAt {
27
+ readonly generation: number | null;
28
+ readonly version: string | null;
29
+ }
30
+ export declare class PinnedStorageChunkSource implements StorageChunkSource {
31
+ private readonly inner;
32
+ /**
33
+ * The pinned segments, keyed by {@link segmentKey}. A **set** rather than one entry because a combine may
34
+ * involve several pinned handles, and each must be read at its own pin — including when the call was made
35
+ * on a different handle. Reading a pinned handle live because the caller started elsewhere is the silent
36
+ * wrong answer this exists to prevent.
37
+ */
38
+ private readonly pins;
39
+ constructor(inner: CrbmStorageChunkSource,
40
+ /**
41
+ * The pinned segments, keyed by {@link segmentKey}. A **set** rather than one entry because a combine may
42
+ * involve several pinned handles, and each must be read at its own pin — including when the call was made
43
+ * on a different handle. Reading a pinned handle live because the caller started elsewhere is the silent
44
+ * wrong answer this exists to prevent.
45
+ */
46
+ pins: ReadonlyMap<string, PinnedAt>);
47
+ /**
48
+ * The pin for this segment, or `undefined` if it is not pinned here.
49
+ *
50
+ * A pin whose `generation` is `null` is a segment that had none when it was pinned: that handle reads empty
51
+ * for its lifetime, which is exactly what an unpinned read of it does. That is **not** the same as "this
52
+ * source cannot pin" — a source that genuinely cannot is a capability gap and fails fast at `pin()`.
53
+ */
54
+ private pinFor;
55
+ getChunk(ref: ChunkRef): Promise<Uint8Array | null>;
56
+ listChunkKeys(ref: SegmentRef): Promise<number[]>;
57
+ sizeOf(ref: SegmentRef): Promise<SegmentSize | null>;
58
+ cardinalities(ref: SegmentRef): Promise<ReadonlyMap<number, number> | null>;
59
+ currentGeneration(ref: SegmentRef): Promise<number | null>;
60
+ /**
61
+ * A pinned segment reports the version captured when it was pinned, so its decoded chunks are cached under a
62
+ * key that cannot collide with the live generation's. That is what lets a pinned handle share the store's
63
+ * chunk cache safely; sharing it on a generation-only key was how a pinned read could resurrect an id that
64
+ * `eraseIdFromSegment` had reported physically gone.
65
+ */
66
+ currentVersion(ref: SegmentRef): Promise<string | null>;
67
+ exists(ref: SegmentRef): Promise<boolean>;
68
+ invalidate(ref: SegmentRef): void;
69
+ }