@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,42 @@
1
+ /**
2
+ * The determinism seam.
3
+ *
4
+ * `core/` is a pure function of its inputs + injected dependencies — it never reaches for
5
+ * ambient time or randomness. Production wiring supplies real implementations; the deterministic
6
+ * tests supply controlled, seeded ones, so any run can be replayed exactly from a seed.
7
+ *
8
+ * These are interfaces only: the seam the whole library depends on for reproducibility. Every module that
9
+ * needs time, randomness or a yield takes one of these rather than reaching for a global.
10
+ */
11
+ /** Injected time source. `core/` must never call `Date.now()` directly. */
12
+ export interface Clock {
13
+ /** Milliseconds since the Unix epoch. */
14
+ now(): number;
15
+ /**
16
+ * Resolve after at least `ms` have elapsed. The retry/backoff path's only way to wait — `core/` must
17
+ * never reach for `setTimeout` directly (it's timer-free, lint-enforced). Production wiring supplies a
18
+ * `setTimeout`-backed implementation; a test supplies a virtual-time one so a backoff schedule is
19
+ * replayable. `ms <= 0` resolves on a microtask without scheduling a timer.
20
+ */
21
+ sleep(ms: number): Promise<void>;
22
+ /**
23
+ * Hand the event loop back once, without asking for any elapsed time.
24
+ *
25
+ * This is **not** `sleep(0)`, and the difference is the reason the member exists. `sleep(0)` resolves on a
26
+ * microtask, and microtasks drain before the loop advances a phase — so awaiting it inside a CPU-bound loop
27
+ * yields to nothing at all, and a co-resident server stays blocked for the loop's full duration. A real yield
28
+ * needs a macrotask. `sleep(1)` is one, but it buys the relief at ~1 ms of dead wall-clock per yield.
29
+ *
30
+ * **Optional**, so a minimal `Clock` — `now` and `sleep` alone — still satisfies the interface. Callers must
31
+ * therefore degrade rather than assume: `clock.yieldNow?.() ?? clock.sleep(1)` — correct on any clock, cheap
32
+ * on one that implements this. `core/` cannot supply it itself (it is timer-free, lint-enforced); production
33
+ * wiring backs it with `setImmediate`, and a test can make it a no-op so virtual time is not perturbed
34
+ * by what is purely a scheduling courtesy.
35
+ */
36
+ yieldNow?(): Promise<void>;
37
+ }
38
+ /** Injected randomness. `core/` must never call `Math.random()` directly. Seedable for simulation. */
39
+ export interface Rng {
40
+ /** A float in the half-open interval [0, 1). */
41
+ next(): number;
42
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `segmentExists` and `listSegments` — ask the registry what is there.
3
+ *
4
+ * The registry has always known which segments exist; it is what `checkConsistency`, the retention sweep and
5
+ * `exportSegments` each enumerate. It was simply never exposed, so a user asking "do I already have this?" had
6
+ * to infer it: `count()` returns `0` for a segment that was never loaded AND for one loaded with no ids, and
7
+ * `generations()` costs a bucket listing to answer a question the pointer alone settles.
8
+ *
9
+ * Inferring it is the expensive kind of wrong. The alternative a user reaches for is keeping their own list of
10
+ * segment names alongside the store — a second source of truth, updated by hand, drifting from the first the
11
+ * moment a load fails halfway. The registry is already the list; these two functions read it.
12
+ */
13
+ import type { IRegistryDriver, RegistryRecord, SegmentRef } from './ports.js';
14
+ /** One segment the registry knows about. */
15
+ export interface SegmentInfo extends SegmentRef {
16
+ /**
17
+ * The generation the **pointer names** — not what a read would resolve. On a `destroyed` tombstone this is
18
+ * still a number while reads answer empty, so read it together with {@link SegmentInfo.status} rather than
19
+ * alone. `null` means the row exists with no Storage generation yet.
20
+ */
21
+ readonly currentGen: number | null;
22
+ /**
23
+ * `destroyed` is a tombstone: the row stays so sweeps can still find it, and the generations are gone — on an
24
+ * encrypted segment the DEK wrappings went with them, which is what makes the bytes unrecoverable rather than
25
+ * merely deleted.
26
+ */
27
+ readonly status: RegistryRecord['status'];
28
+ }
29
+ /**
30
+ * Whether this segment's pointer resolves a generation — the question behind "does it already exist?".
31
+ *
32
+ * One registry point read, on every backend. Deliberately narrower than "is there a row": it is **false** for a
33
+ * row that exists with `currentGen: null` (a retention policy recorded ahead of the first load) and for a
34
+ * `destroyed` tombstone, because a read answers empty in both. The predicate mirrors generation resolution
35
+ * clause for clause, which is what keeps the two from disagreeing.
36
+ *
37
+ * It is NOT the same question as `count() > 0`. A segment loaded with no ids exists and counts zero — the
38
+ * distinction between "never loaded" and "loaded, and genuinely empty" is the one `count()` cannot make and
39
+ * the main reason this function is worth having.
40
+ *
41
+ * Two states answer `true` where a read still gives you nothing, and neither is this call's job to detect: a
42
+ * **torn restore** (`missing-storage-generation` — a live pointer whose object was deleted) makes reads *throw*
43
+ * `NotFoundError` rather than answer empty, and `runConsistencyCheck` is the call that looks for it; and a
44
+ * handle carrying an expired `expiresAt` reads as empty by a rule that lives on the handle, not on the row.
45
+ *
46
+ * Not a lock. A segment can be created or dropped between this call and whatever you do next; if the answer
47
+ * has to hold, use the fences that exist for it — `load`'s guard, or `expectFrom`/`expectToken` on a publish.
48
+ */
49
+ export declare function segmentExists(ref: SegmentRef, registry: IRegistryDriver): Promise<boolean>;
50
+ /**
51
+ * Every segment the registry holds, streamed — optionally scoped to one namespace.
52
+ *
53
+ * **This is an admin/discovery call, not a request-path one.** It is the registry's own enumeration — a paged
54
+ * LIST over the `registry/` prefix — so cost grows with the fleet, not with what you are looking for. The
55
+ * fleet-wide sweeps that *drain* it bound it (`maxScanSegments`); this one streams, so the bound is yours.
56
+ *
57
+ * **Scoping to a namespace narrows the LIST prefix**, so it really is the difference between reading one
58
+ * tenant and reading all of them.
59
+ *
60
+ * Streams, so a large fleet need not be held at once, and stopping the iteration stops the scan — as long as the
61
+ * registry driver's own `list` streams. One that collects its whole enumeration before yielding defeats both
62
+ * properties, since the scan is then paid for and resident before the first row reaches you, so a custom driver's
63
+ * `list` should yield each row as it reads it. The registry drivers this library ships all stream; on S3, GCS and
64
+ * Azure Blob the granularity is a page, so one LIST page and its in-flight row reads complete regardless. Nothing
65
+ * here retries the scan: a transient fault part-way through ends the iteration with that error, and calling
66
+ * `listSegments` again scans from the start, since the iterable one call returns is used up.
67
+ *
68
+ * Yields `destroyed` tombstones, and rows whose `currentGen` is `null`, because hiding either would make this
69
+ * disagree with the registry it reports on — a filtered enumeration that looks complete is how a retention
70
+ * sweep ends up permanently skipping rows nobody can see. Filter on `status`/`currentGen` yourself, or use
71
+ * {@link segmentExists} for the "does the pointer resolve" question. Internal bookkeeping rows are the one
72
+ * exclusion, and only on an unscoped scan: they are not segments, and a caller naming their namespace still
73
+ * sees them.
74
+ */
75
+ export declare function listSegments(registry: IRegistryDriver, options?: {
76
+ namespace?: string;
77
+ }): AsyncIterable<SegmentInfo>;
@@ -0,0 +1,53 @@
1
+ import { MAX_NAME_LENGTH } from './validate.js';
2
+ import type { RegistryRecord, SegmentRef } from './ports.js';
3
+ /** Bucket width. One day: small enough that a cycle reads little, coarse enough that the index stays tiny. */
4
+ export declare const DUE_BUCKET_MS = 86400000;
5
+ /**
6
+ * The name-length cap, and an index row encodes **both** of the original ref's parts into one name — so a ref
7
+ * whose encoding would exceed it is simply not indexed (see {@link canIndex}).
8
+ *
9
+ * Re-exported from the validator rather than restated. The two were separate constants with the same value
10
+ * until the validator started measuring the **encoded** length; `canIndex` went on counting raw characters and
11
+ * began answering `true` for refs the registry then refused, turning a documented graceful degradation into a
12
+ * guaranteed throw on the retention fast path.
13
+ */
14
+ export { MAX_NAME_LENGTH };
15
+ /** Which bucket an expiry falls in. Total and reversible; no calendar, no timezone, no ambient time. */
16
+ export declare function dueBucket(expiresAt: number): number;
17
+ /** The namespace holding one bucket's pointers. */
18
+ export declare function dueNamespace(bucket: number): string;
19
+ /** Is this record a due-index pointer rather than a segment? */
20
+ export declare function isDueIndexRow(record: Pick<RegistryRecord, 'namespace'>): boolean;
21
+ /**
22
+ * Encode a ref into one index-row name, unambiguously.
23
+ *
24
+ * `${namespaceLength}.${namespace}${segment}` — a decimal length, a dot, then the two parts concatenated. The
25
+ * length prefix is what makes it reversible: a name may contain any character, the dot included, so no
26
+ * separator character could ever be unambiguous on its own. Reading the digits
27
+ * up to the first dot tells the parser exactly where the namespace ends.
28
+ */
29
+ export declare function encodeDueName(ref: SegmentRef): string;
30
+ /**
31
+ * Can this ref be indexed at all? False when the encoded name would exceed the 256-character name cap —
32
+ * possible only for a ref whose namespace and segment are together near the limit.
33
+ *
34
+ * **Not indexable is not "not retired".** The repair scan still sees the segment's own row, so the consequence
35
+ * is that it expires on the repair cadence instead of the fast one. Callers must not treat `false` as an error.
36
+ */
37
+ export declare function canIndex(ref: SegmentRef): boolean;
38
+ /** The registry ref of the pointer for `ref` in `bucket`. Throws if the ref cannot be encoded — check first. */
39
+ export declare function dueIndexRef(bucket: number, ref: SegmentRef): SegmentRef;
40
+ /**
41
+ * Recover the original ref from an index row's name. `null` for anything we did not write — a foreign row in
42
+ * the reserved namespace is ignored rather than acted upon.
43
+ */
44
+ export declare function decodeDueName(name: string): SegmentRef | null;
45
+ /**
46
+ * Which buckets a cycle must read at `now`: everything from `since` up to and including the current one.
47
+ *
48
+ * Past buckets are included because a sweep that did not run — a scaled-to-zero worker, a failed deploy, a
49
+ * paused schedule — must not leave its buckets stranded. `lookbackBuckets` bounds how far back a first run
50
+ * reaches, so a long outage costs a bounded number of list calls per cycle rather than one per day since the
51
+ * epoch; anything older is the repair scan's job.
52
+ */
53
+ export declare function dueBucketsAt(now: number, lookbackBuckets: number): number[];
@@ -0,0 +1,246 @@
1
+ import type { CodecBitmap, CodecInterface } from './codec.js';
2
+ import type { Budget, BudgetOption } from './budget.js';
3
+ import type { Clock } from './determinism.js';
4
+ import type { BoundedLru } from './lru.js';
5
+ import type { IMetricsSink } from './metrics.js';
6
+ import type { StorageChunkSource, SegmentRef, SegmentSize } from './ports.js';
7
+ export interface EngineDeps {
8
+ readonly storage: StorageChunkSource;
9
+ /** Optional cache of decoded (immutable) Storage chunks. */
10
+ readonly cache?: BoundedLru<string, CodecBitmap>;
11
+ readonly maxBitmapBytes?: number;
12
+ /**
13
+ * The bitmap codec — **required**. `core/` is codec-agnostic: it can have no default, because the concrete codec
14
+ * lives in a *flavor* package that depends on core (a default here would invert that arrow). A flavor's facade
15
+ * injects it — `@cloudbitmaps/roaring` passes the roaring codec — so applications never see this.
16
+ */
17
+ readonly codec: CodecInterface;
18
+ /** Time source for the `storage.get` latency metric only; defaults to a clock that reads 0. */
19
+ readonly clock?: Pick<Clock, 'now'>;
20
+ /** Observability sink; defaults to a no-op. Assumed exception-safe (the facade wraps it). */
21
+ readonly metrics?: IMetricsSink;
22
+ /**
23
+ * Per-op denial-of-wallet budget, already resolved by the facade: a {@link Budget} to enforce, or `null` to
24
+ * disable. Undefined ⇒ {@link DEFAULT_BUDGET} (a direct engine construction still gets the generous default).
25
+ * Read ops refuse before fan-out if they'd exceed it.
26
+ */
27
+ readonly budget?: Budget | null;
28
+ }
29
+ /**
30
+ * A range of ids for a read to yield: those greater than `after` and no greater than `through`, `(after, through]`.
31
+ *
32
+ * Built for keyset paging: `after` is the last id the caller already has, `through` the end of its window. Each is
33
+ * optional: left out, the read starts at the first id or runs to the last. Each is an integer in `0..4294967295`, or
34
+ * the read throws {@link ValidationError} when it is first read. `after >= through` is an empty range, not an error,
35
+ * because a cursor that has reached the end of its window is normal; an empty range reads nothing, not even whether
36
+ * a combine's operands exist.
37
+ *
38
+ * A read with a range fetches only the chunks the range overlaps, on a combine for every operand and every
39
+ * `exclude`. The per-op budget is charged once, before the first fetch, for every chunk in the range, so with
40
+ * `after` alone it is charged to the end of the segment however early the caller stops; pass `through` to bound it.
41
+ */
42
+ export interface IdRange {
43
+ /** Exclusive lower bound: the read yields only ids greater than this. */
44
+ readonly after?: number | undefined;
45
+ /** Inclusive upper bound: the read yields only ids up to and including this. */
46
+ readonly through?: number | undefined;
47
+ }
48
+ /** Options common to the chunk-aligned combines. */
49
+ export interface CombineOptions extends IdRange {
50
+ /** Max chunk keys resolved concurrently — bounds the Storage footprint. A positive integer. */
51
+ readonly concurrency?: number;
52
+ /** Override the store's per-op budget for this call (`false` lifts it). */
53
+ readonly budget?: BudgetOption;
54
+ /** Segments whose ids are subtracted from the result. */
55
+ readonly exclude?: readonly SegmentRef[];
56
+ /**
57
+ * Allow an operand that names a segment which does not exist. Default `false` — a combine refuses one,
58
+ * because a misspelled or mis-namespaced operand is indistinguishable from a correct one in the result.
59
+ * Set `true` when you genuinely intend to combine against a name that may not have been created yet.
60
+ */
61
+ readonly allowAbsentOperands?: boolean;
62
+ }
63
+ export declare class SegmentEngine {
64
+ private readonly storage;
65
+ private readonly cache;
66
+ private readonly codec;
67
+ private readonly maxBitmapBytes;
68
+ private readonly clock;
69
+ private readonly metrics;
70
+ private readonly metricsOn;
71
+ /** Resolved per-op budget (null = disabled); undefined deps ⇒ the generous default. */
72
+ private readonly budget;
73
+ constructor(deps: EngineDeps);
74
+ /** Membership: one chunk lookup — the cache, else one Storage fetch of that chunk. */
75
+ has(seg: SegmentRef, id: number): Promise<boolean>;
76
+ /**
77
+ * Cardinality. When the Storage source can serve per-chunk cardinality from its `.crbm` index (the `.crbm`
78
+ * source can), the count is summed from the index with **zero payload reads**. A source without that
79
+ * capability (the in-memory source) falls back to fetching every chunk.
80
+ *
81
+ * The index sum is trusted, not confirmed: the reader checked the index for internal consistency when it opened
82
+ * the object, and a corrupt index that is still internally consistent yields a wrong count. `iterate()` and the
83
+ * combines decode the payloads.
84
+ */
85
+ count(seg: SegmentRef): Promise<number>;
86
+ /** Whether the Storage source can measure segment size (for grounded cost); false ⇒ storage isn't grounded. */
87
+ get supportsStorageSize(): boolean;
88
+ /** How often the Storage source re-reads a pointer while reading (cost reporting); undefined if it does not say. */
89
+ get pointerRefreshMs(): number | undefined;
90
+ /** Grounded Storage size of a segment's current generation (cost reporting), or null if it has no generation. */
91
+ segmentSize(seg: SegmentRef): Promise<SegmentSize | null>;
92
+ /**
93
+ * Every id, ascending, one chunk at a time; with `range`, only the ids in `(after, through]`, fetching only the
94
+ * chunks the range overlaps (see {@link IdRange}).
95
+ *
96
+ * Two generators, not one with a branch: a second `yield` site in the full read's generator grows its frame, and
97
+ * every id of a read that asked for no range paid for it (measured at about 5% per id). The full read keeps the
98
+ * loop it always had.
99
+ */
100
+ iterate(seg: SegmentRef, range?: IdRange): AsyncGenerator<number>;
101
+ private iterateAll;
102
+ private iterateRange;
103
+ /**
104
+ * `segs[0] ∩ segs[1] ∩ …`, minus every id in `options.exclude` — streamed ascending.
105
+ *
106
+ * The `exclude` operands are what make suppression **compose without materialising**. Applying suppression
107
+ * afterwards (intersect into a temp segment, then subtract) writes an intermediate segment that nobody wants;
108
+ * applying it here folds it into the same chunk-aligned pass. It is also cheaper than it looks: an exclude
109
+ * operand is read **only at the keys that survived the intersection** — the cost is bounded by the
110
+ * audience's surviving key count and **never scales with the size of the suppression list**. Subtracting a
111
+ * 61,000-chunk global opt-out list costs at most one read per surviving key, not 61,000.
112
+ *
113
+ * ---
114
+ *
115
+ * Chunk-skipping intersection. Aligns each segment's chunk-key set (read from the `.crbm` index, no payload),
116
+ * keeps only keys present in **all** operands (a key missing from any operand can't contribute → its Storage
117
+ * chunks are never fetched — the core saving), then for each surviving key fetches the operands' chunks in
118
+ * parallel and hands them to the codec for the AND, streaming results through a bounded in-flight window.
119
+ *
120
+ * **Memory:** the Storage payload footprint is bounded by the window (`concurrency × operands × chunk`), not by
121
+ * segment size — that's the Lambda-friendly property.
122
+ *
123
+ * Generation-consistent within the call (normal case): each operand's current generation is resolved **once**
124
+ * up front (before the fan-out) and threaded into every chunk read, so a concurrent load can't corrupt or tear
125
+ * the result — every chunk read is a whole, checksum-verified, immutable generation. The edge a *long* call can
126
+ * hit is invariant 3's: if it straddles a mid-call `cache.genTtlMs` boundary and a load has published, an
127
+ * operand's not-yet-read chunks may re-resolve forward to the newer generation (a generation hop within one long
128
+ * call) — the call never crashes or returns a torn object, but may mix generations. Three things hop it without
129
+ * waiting for the TTL, so a shorter call can meet them too: **the reader cache evicting an operand mid-call**
130
+ * (`maxOpenSegments`), whose re-read re-resolves fresh; a sweep deleting the generation it was reading, which
131
+ * heals the read forward; and an invalidation, which this store's own `load`, `rollback` and `eraseSubject` make
132
+ * and `invalidate()` makes on request. Still whole/immutable per read, never torn.
133
+ */
134
+ intersect(segs: readonly SegmentRef[], options?: CombineOptions): AsyncGenerator<number>;
135
+ /**
136
+ * `segs[0] ∪ segs[1] ∪ …`, minus every id in `options.exclude` — streamed ascending.
137
+ *
138
+ * **Union is the one composite read with no chunk-skipping**, and that is a property of the operation rather
139
+ * than of this implementation: an id in *any* operand belongs to the result, so every chunk of every operand
140
+ * has to be read. `intersect` can prune a key absent from any operand; union cannot prune anything. It is
141
+ * budgeted exactly like `intersect` so a wide union is refused rather than quietly billed.
142
+ */
143
+ union(segs: readonly SegmentRef[], options?: CombineOptions): AsyncGenerator<number>;
144
+ /**
145
+ * `seg \ (excludes[0] ∪ excludes[1] ∪ …)` — streamed ascending. Suppression on its own.
146
+ *
147
+ * Reads every chunk of `seg` (any of them may survive) but each exclude **only where it overlaps `seg`**, so
148
+ * the cost scales with the segment being filtered rather than with the size of the suppression list.
149
+ */
150
+ andNot(seg: SegmentRef, excludes: readonly SegmentRef[], options?: Omit<CombineOptions, 'exclude'>): AsyncGenerator<number>;
151
+ private combine;
152
+ /**
153
+ * The AND (or OR) of one chunk key across all operands, minus the excludes that hold it, or `null` if empty.
154
+ * Operand chunks are fetched **in parallel** (the spec's parallel byte-range reads).
155
+ *
156
+ * The accumulator is a **clone** of the first operand's chunk, and that clone is load-bearing: every other
157
+ * bitmap here is the cached, shared Storage instance and must never be mutated, or the cache is poisoned for
158
+ * every later reader. The remaining operands and the excludes are only *read* by the in-place ops, so they are
159
+ * used as-is — one clone per key, not one per operand.
160
+ */
161
+ private combineChunk;
162
+ /** The segment's chunk keys, ascending — a shape read off the index, no payload. Keys are untrusted (invariant 5). */
163
+ private chunkKeys;
164
+ /**
165
+ * Tier-derived keys are untrusted (invariant 5) — fail fast on a corrupt/out-of-range key rather than
166
+ * letting it flow into the id-routing step and produce a bogus id.
167
+ */
168
+ private assertChunkKeyInRange;
169
+ /**
170
+ * The other half of invariant 5: a chunk payload holds **remainders** — 16-bit offsets within one chunk — so
171
+ * every value must be `<= 0xffff`.
172
+ *
173
+ * Nothing upstream establishes that. The byte cap bounds *size*, and CRC/AEAD prove the bytes are the bytes
174
+ * that were written — which anyone able to write the bucket satisfies trivially. A value `>= 65536` would then
175
+ * reach the id-routing step, which masks it (`remainder & 0xffff`) and emits a **fabricated id belonging to a different
176
+ * chunk's id space**: indistinguishable from real data, inflating `count()` and creating spurious `intersect`
177
+ * matches.
178
+ *
179
+ * Costs one `maximum()` per chunk, not per id — `maximum` is optional on the codec seam precisely so a codec
180
+ * that cannot answer in O(1) opts out instead of making the read path walk every value.
181
+ *
182
+ * One value stands for all of them only because the decode is structurally checked. Roaring answers `maximum()`
183
+ * from its last container, so a payload listing its containers out of order would report the wrong one's
184
+ * largest value and pass here while holding values above 65,535. The codec's `safeDeserialize` refuses that
185
+ * payload, and every other shape that would make this answer wrong, before it reaches this check.
186
+ */
187
+ private assertChunkPayloadInRange;
188
+ /**
189
+ * Refuse a combine whose operand names a segment that was never created.
190
+ *
191
+ * A segment that resolves to nothing is ambiguous in a way that matters only here. Read it directly and
192
+ * "empty" is the right answer either way. Pass it as an **operand** and the two states diverge: a
193
+ * suppression list with nobody on it correctly suppresses nothing, while one whose namespace you omitted
194
+ * *silently* suppresses nothing — and the result is not empty and obviously wrong, it is the full audience
195
+ * and plausibly right. That direction is the dangerous one: the failure mode is mailing the people who
196
+ * opted out.
197
+ *
198
+ * The asymmetry is why this is checked rather than left to the caller. A mistyped **include** collapses an
199
+ * intersect to nothing, which you notice; a mistyped **exclude** removes a safeguard, which you do not. Both
200
+ * are refused, because both are wiring errors and one rule is easier to trust than two.
201
+ *
202
+ * **Costs nothing on a normal combine.** `exists` is consulted only for an operand that resolved to zero
203
+ * chunks — a segment with data is self-evidently registered — so the check is a registry read exactly when
204
+ * a combine was about to do something suspicious, and no reads at all otherwise. A source that cannot answer
205
+ * `exists` (it has no notion of registration) skips the check rather than guessing. An operand is judged by
206
+ * all its chunks, not those inside a range: one with nothing in range but data elsewhere is not suspicious.
207
+ */
208
+ private refuseAbsentOperands;
209
+ /**
210
+ * Drop every piece of state this engine derived from `ref`, and tell the Storage source to do the same.
211
+ *
212
+ * The decoded-chunk cache is keyed by generation, which handles a *publish* (the new generation misses) but
213
+ * not a *destruction*: a segment that was erased from, dropped, shredded or retired has no newer generation
214
+ * whose key would miss, so its decoded chunks stay resident and keep answering. That is why the erasing
215
+ * process itself could report `erased: true` and then still answer `true` for the same id, out of RAM, with
216
+ * no storage read to intercept.
217
+ *
218
+ * Called by the destructive verbs on the facade. Synchronous and best-effort — forgetting cannot fail.
219
+ */
220
+ invalidate(ref: SegmentRef): void;
221
+ /** The segment's current generation, resolved once per op (`undefined` ⇒ source can't report it). */
222
+ private currentGen;
223
+ /**
224
+ * The cache-key component identifying **which bytes** this op will read: the source's `currentVersion` when
225
+ * it has one, else the generation alone.
226
+ *
227
+ * The generation alone is not an identity. A load's generation number restarts at 0 once a row is purged
228
+ * and the bucket emptied, so a retired-and-re-created name serves different data at the same `currentGen` — and a
229
+ * decoded chunk cached under `(segment, chunk, 0)` is handed straight back to the new incarnation. That is an
230
+ * erased id reappearing with no read to intercept it, which is why this is keyed on the version rather than
231
+ * the number.
232
+ */
233
+ private cacheVersion;
234
+ /**
235
+ * Decode a Storage chunk. `gen` is the segment's current generation, resolved **once per op** by the caller (not
236
+ * per chunk — that would put a registry re-resolve on every chunk of a count/intersect). The cache is
237
+ * keyed by it, so a load that advances the generation misses the cache and re-reads the new bytes instead of
238
+ * serving a stale decoded chunk (an erased id can't resurrect from a cached superseded chunk). `gen === null`
239
+ * ⇒ the source reports no current generation ⇒ no storage bytes for any chunk, so skip the fetch entirely.
240
+ * `gen === undefined` ⇒ the source can't report a generation (it only ever serves one) ⇒ the key stays
241
+ * generation-free. Superseded-generation entries age out under the LRU ceiling — no active purge.
242
+ *
243
+ * The returned instance is **shared** (it may be the cached one): callers read it or clone it, never mutate it.
244
+ */
245
+ private storageChunk;
246
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Subject erasure on a loaded segment (GDPR Art. 17): remove **one id** from a segment by rewriting its current
3
+ * generation without that id.
4
+ *
5
+ * There is no per-id delete on an immutable object, and there is no mutable tier to hold a tombstone, so erasure
6
+ * is what every other write in this library is: a new generation. The current generation is streamed chunk by
7
+ * chunk through the ascending writer — every chunk decoded, range-checked and re-encoded, the one holding the id
8
+ * with that bit cleared — then published fenced on the generation it streamed, and that generation is collected immediately
9
+ * (`keep: 0`), so the bit is **physically gone from the bucket when this returns**. Constant memory: one chunk in
10
+ * flight, never the whole segment.
11
+ *
12
+ * **`erased: true` is a claim about every generation of the segment, not only the one it replaced.** A rollback
13
+ * leaves generations above the pointer that were once current and can be made current again, so a holder can sit
14
+ * above the pointer as well as below it, and there can be several. Before it reports `erased: true` the call
15
+ * therefore lists the bucket once more and reads every generation it has not already seen to be clean; any that
16
+ * still holds the id makes it report or throw instead. In the ordinary case that list returns one generation, the
17
+ * current one, and costs one `list`.
18
+ *
19
+ * What it does NOT reach: backups, replicas and noncurrent object versions hold the old object until their own
20
+ * lifecycle removes it. For an at-rest guarantee that survives those, use encryption plus crypto-shred
21
+ * (`destroySegment`), which is per segment — a per-id shred is infeasible because one DEK covers the whole segment.
22
+ *
23
+ * Concurrency. This is a **read-modify-write**, not a plain forward-only publish, and that distinction is where
24
+ * its correctness lives: the new generation is `from` minus one bit, so it is a valid successor to `from` and to
25
+ * nothing else. The publish is therefore fenced on `from` **and on the row it was read from**
26
+ * (`publishGeneration`'s `expectFrom` + `expectToken`) and lands only while the pointer is still exactly there,
27
+ * on that same row. Both halves are needed: a generation number identifies a generation only within one
28
+ * incarnation of a name, and a name that is retired and re-created restarts at `0`, so the number alone matched
29
+ * a different segment entirely. Anything else — a load that published in the meantime, or another
30
+ * erasure that got there first — is reported as `reason: 'superseded'`, `erased: false`, and the caller re-runs
31
+ * against the new generation. A writer that claims the same generation *number* first surfaces as a
32
+ * `WriteConflictError` (write-once).
33
+ *
34
+ * The same answer covers the read half, and for the same reason. An erasure collects with `keep: 0`, which takes
35
+ * every generation below its new pointer — so a racing erasure can delete the generation this call is streaming,
36
+ * and (once it is below the winner's pointer) the object this call just wrote. All three exposed round trips (the
37
+ * reader open, any chunk of the whole-segment rewrite, the verify) therefore report a *reason* rather than a bare
38
+ * `NotFoundError`, which was the same event wearing an unactionable face: through `eraseSubject` it became a note
39
+ * the facade documents as ambiguous about which side of the publish it landed on, telling an operator to triage
40
+ * where the truth was to re-run.
41
+ *
42
+ * Which reason comes from **re-reading the row**, not from assuming a supersession: a moved pointer is
43
+ * `'superseded'`, a tombstoned row `'destroyed'`, a purged row `'absent'`, a row with no pointer
44
+ * `'no-generation'`. A pointer still on `from` means the object it names is genuinely gone — the forbidden
45
+ * `missing-storage-generation` state — and that throws, because no re-run fixes it. That `NotFoundError` is the only
46
+ * signal of that state, so a faulting re-read rethrows it rather than replacing it with a transient-looking
47
+ * registry error.
48
+ *
49
+ * **A refused rewrite deletes its own object when it would outlive the winner above the pointer.** `putImmutable`
50
+ * commits atomically, so a rewrite whose stream throws leaves no object at all. One that completed is either
51
+ * below the winner's pointer, where the winner's collection takes it, or above it: the case where this call took
52
+ * its number after the winner's object was already in the bucket. Up there nothing collects it, and it was derived
53
+ * from a generation the winner has replaced, so when the winner was another erasure it still holds the id that
54
+ * erasure has just reported gone. That object is deleted before this call returns — see `discardRefused`.
55
+ *
56
+ * Without that fence the failure was silent and severe, and both halves were reproduced: `nextGeneration` picks a
57
+ * number above everything in the bucket, so a forward-only publish here always won — discarding a concurrent
58
+ * load's whole set, and letting two concurrent erasures each return `erased: true` while the second one's
59
+ * generation put the first one's id back and collected the generation that had evidenced its removal. A false
60
+ * Art. 17 receipt is the worst output this module can produce.
61
+ *
62
+ * A live read still fetching from the collected generation heals forward to the rewrite, and a pin of it fails with
63
+ * `NotFoundError` for any chunk it has yet to read: that is the documented cost of physical deletion on return. **Do not re-load the id while erasing it**: a load that lands after this rewrite
64
+ * carries whatever its source held, and the library cannot know that source was meant to exclude the id.
65
+ * A load already in flight is the same case: it writes its object above the pointer before it publishes, and an
66
+ * erasure that finds the id in that object deletes it as it does any holder there, so a load that then publishes
67
+ * leaves the pointer naming a missing object.
68
+ */
69
+ import { type IAuditSink } from './audit.js';
70
+ import type { CodecInterface } from './codec.js';
71
+ import type { Yielder } from './cooperative.js';
72
+ import type { IKeystore } from './crypto.js';
73
+ import type { IStorageDriver, IRegistryDriver, SegmentRef } from './ports.js';
74
+ /** What {@link eraseIdFromSegment} needs: the objects, the pointer, the codec, and the key material if encrypted. */
75
+ export interface EraseIdDeps {
76
+ readonly storage: IStorageDriver;
77
+ readonly registry: IRegistryDriver;
78
+ /**
79
+ * Bitmap codec — the rewrite decodes and re-encodes every chunk through it. Optional in the type so this stays
80
+ * call-compatible public API; a **flavor** package binds it (see {@link requireCodec}).
81
+ */
82
+ readonly codec?: CodecInterface;
83
+ /**
84
+ * Keystore for an **encrypted** segment: its registry record carries wrapped DEK(s), and the rewrite reuses
85
+ * that DEK — unwrapping it to decrypt the old generation and encrypt the new one under a `(segment, generation)`
86
+ * -bound AAD. Rewriting an encrypted segment without a keystore throws {@link KeyUnavailableError}.
87
+ */
88
+ readonly keystore?: IKeystore;
89
+ /** When true, refuse to rewrite a **cleartext** segment (the same guard the read path and a load offer). */
90
+ readonly requireEncryption?: boolean;
91
+ /** Supplying a clock that can yield makes the rewrite cooperative, as it makes a load. */
92
+ readonly clock?: Yielder;
93
+ /** Per-chunk decode ceiling (invariant 5); defaults to 1 MiB. */
94
+ readonly maxBitmapBytes?: number;
95
+ }
96
+ export interface EraseIdResult {
97
+ readonly segment: string;
98
+ readonly namespace?: string;
99
+ /**
100
+ * True iff the id **was** in the segment — in its current generation, or in any other generation still in the
101
+ * bucket, above the pointer or below it — and **no generation of the segment holds it now**. The current
102
+ * generation is one without it, every generation that held it has been deleted (see `collected`), and the call
103
+ * listed the bucket and read what was left before saying so. This is the field an erasure ledger records.
104
+ */
105
+ readonly erased: boolean;
106
+ /**
107
+ * Why the id was not erased, when `erased` is false. `'absent'` (no registry row), `'destroyed'` (a crypto-shred
108
+ * tombstone — already unreadable), `'no-generation'` (a row with no Storage data yet), `'not-member'` (no
109
+ * generation in the bucket holds the id — the common case across a fleet scan), or `'superseded'`.
110
+ *
111
+ * **`'superseded'` means this call did not erase the id, not that the id is still there.** Another writer — a
112
+ * load, another erasure, or a rollback — moved the pointer off the generation this call read, so what it was
113
+ * doing no longer follows from what is current: a rewrite derived from that generation is not a valid successor
114
+ * to the new one, and a generation it meant to delete above the pointer may be the one the pointer now names.
115
+ * Re-run against the new generation: if the id is still present it is erased then; if the racing writer was
116
+ * another erasure of the *same* id, the re-run reports `'not-member'` because it is already gone. Either way the
117
+ * re-run settles it, which is why it is the documented action for this reason and for no other.
118
+ *
119
+ * The first three reasons are also returned when the row changes *underneath* a call already in flight, not
120
+ * only when it is read up front — a concurrent `dropSegment` gives `'destroyed'`, a retention sweep that purges
121
+ * the row gives `'absent'`. A caller branching on `reason` never has to care which it was.
122
+ */
123
+ readonly reason?: 'absent' | 'destroyed' | 'no-generation' | 'not-member' | 'superseded';
124
+ /**
125
+ * The generation the id was found in (present whenever one was read) — the newest of them, when the current
126
+ * generation did not hold it and several others did.
127
+ */
128
+ readonly fromGeneration?: number;
129
+ /**
130
+ * The generation written without the id (present whenever one was written, even if `superseded`). A refused
131
+ * rewrite whose object sits above the winner's pointer deletes that object again before it returns.
132
+ */
133
+ readonly generation?: number;
134
+ /**
135
+ * Generations this call deleted. On a rewrite that is normally `[fromGeneration]`, plus any older orphans. When
136
+ * the current generation did not hold the id, it is every generation below the pointer (`keep: 0` takes them
137
+ * together) and each one above the pointer that held the id; the ones above it that did not hold the id stay,
138
+ * because they are an operator's rollback targets. Empty when nothing was deleted.
139
+ *
140
+ * **It is what THIS call deleted, not the proof that the id is gone** — those differ: a concurrent collector can
141
+ * take a generation holding the id first, and then `erased: true` is returned without it in `collected`, because
142
+ * the claim is about the bucket rather than about who emptied it. Treat a non-empty `collected` as evidence and
143
+ * a missing holder as "someone else got there", never as a failure.
144
+ *
145
+ * A call that cannot establish the claim **throws** `WriteConflictError` or the underlying fault rather than
146
+ * report `erased: true` over bytes that are still there. Four ways that happens: a Storage `delete` fault; a
147
+ * collection pass that could not prove the segment was still the same one (which an ordinary retirement landing
148
+ * mid-call is enough to cause); the same refusal on the path where the current generation did not hold the id
149
+ * and nothing was published at all; and a generation still holding the id when the bucket is listed at the end
150
+ * — one an operator rolled the pointer onto while a rewrite was collecting, say.
151
+ *
152
+ * **Re-run it**, and read what the re-run says rather than assuming it finished the job. The re-run looks for
153
+ * the id in every generation in the bucket, not only the current one, and there are three outcomes:
154
+ *
155
+ * - `erased: true` naming the generation it found the id in — the ordinary case, and the one that gives you
156
+ * the receipt the failed call could not;
157
+ * - `'not-member'` — a racing collector took that generation first. The bit is gone, but **no run reports a
158
+ * receipt for it**, so keep the failed call's error alongside your ledger if you need the audit trail;
159
+ * - `'absent'` — the segment's registry row is gone, so it is no longer a segment at all and
160
+ * `store.eraseSubject` will not even scan it. Anything left in the bucket is an **orphan**: list it with
161
+ * `listGenerations` (`store.generations`) and delete it with `dropSegment` (`store.dropSegment`), which both
162
+ * read the bucket as well as the row. `checkConsistency()` and the generation collection start from the row, so
163
+ * neither reaches a segment that has none.
164
+ */
165
+ readonly collected: readonly number[];
166
+ }
167
+ /**
168
+ * Remove `id` from `ref`: rewrite its current generation without it, and delete every other generation that holds
169
+ * it. See the module note for the contract.
170
+ *
171
+ * Emits one `segment.rewrite` audit event at the publish (before the superseded generation is collected), so the
172
+ * compliance record exists the moment the generation without the id is authoritative.
173
+ */
174
+ export declare function eraseIdFromSegment(ref: SegmentRef, id: number, deps: EraseIdDeps, options?: {
175
+ audit?: IAuditSink;
176
+ }): Promise<EraseIdResult>;