@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,55 @@
1
+ import { MAX_NAME_LENGTH } from './validate.js';
2
+ import type { RegistryRecord, SegmentRef } from './ports.js';
3
+ /** Namespace prefix for due-index rows. */
4
+ export declare const DUE_NAMESPACE_PREFIX = "cbm.due.";
5
+ /** Bucket width. One day: small enough that a cycle reads little, coarse enough that the index stays tiny. */
6
+ export declare const DUE_BUCKET_MS = 86400000;
7
+ /**
8
+ * The name-length cap, and an index row encodes **both** of the original ref's parts into one name — so a ref
9
+ * whose encoding would exceed it is simply not indexed (see {@link canIndex}).
10
+ *
11
+ * Re-exported from the validator rather than restated. The two were separate constants with the same value
12
+ * until the validator started measuring the **encoded** length; `canIndex` went on counting raw characters and
13
+ * began answering `true` for refs the registry then refused, turning a documented graceful degradation into a
14
+ * guaranteed throw on the retention fast path.
15
+ */
16
+ export { MAX_NAME_LENGTH };
17
+ /** Which bucket an expiry falls in. Total and reversible; no calendar, no timezone, no ambient time. */
18
+ export declare function dueBucket(expiresAt: number): number;
19
+ /** The namespace holding one bucket's pointers. */
20
+ export declare function dueNamespace(bucket: number): string;
21
+ /** Is this record a due-index pointer rather than a segment? */
22
+ export declare function isDueIndexRow(record: Pick<RegistryRecord, 'namespace'>): boolean;
23
+ /**
24
+ * Encode a ref into one index-row name, unambiguously.
25
+ *
26
+ * `${namespaceLength}.${namespace}${segment}` — a decimal length, a dot, then the two parts concatenated. The
27
+ * length prefix is what makes it reversible: every character the grammar allows (`.`, `-`, `_`, `:`, alphanumerics)
28
+ * is legal *inside* a name, so no separator character could ever be unambiguous on its own. Reading the digits
29
+ * up to the first dot tells the parser exactly where the namespace ends.
30
+ */
31
+ export declare function encodeDueName(ref: SegmentRef): string;
32
+ /**
33
+ * Can this ref be indexed at all? False when the encoded name would exceed the grammar's 256-character cap —
34
+ * possible only for a ref whose namespace and segment are together near the limit.
35
+ *
36
+ * **Not indexable is not "not retired".** The repair scan still sees the segment's own row, so the consequence
37
+ * is that it expires on the repair cadence instead of the fast one. Callers must not treat `false` as an error.
38
+ */
39
+ export declare function canIndex(ref: SegmentRef): boolean;
40
+ /** The registry ref of the pointer for `ref` in `bucket`. Throws if the ref cannot be encoded — check first. */
41
+ export declare function dueIndexRef(bucket: number, ref: SegmentRef): SegmentRef;
42
+ /**
43
+ * Recover the original ref from an index row's name. `null` for anything we did not write — a foreign row in
44
+ * the reserved namespace is ignored rather than acted upon.
45
+ */
46
+ export declare function decodeDueName(name: string): SegmentRef | null;
47
+ /**
48
+ * Which buckets a cycle must read at `now`: everything from `since` up to and including the current one.
49
+ *
50
+ * Past buckets are included because a sweep that did not run — a scaled-to-zero worker, a failed deploy, a
51
+ * paused schedule — must not leave its buckets stranded. `lookbackBuckets` bounds how far back a first run
52
+ * reaches, so a long outage costs a bounded number of list calls per cycle rather than one per day since the
53
+ * epoch; anything older is the repair scan's job.
54
+ */
55
+ export declare function dueBucketsAt(now: number, lookbackBuckets: number): number[];
@@ -0,0 +1,204 @@
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 `roaringCodec` — 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
+ /** Options common to the chunk-aligned combines. */
30
+ export interface CombineOptions {
31
+ /** Max chunk keys resolved concurrently — bounds the Storage footprint. A positive integer. */
32
+ readonly concurrency?: number;
33
+ /** Override the store's per-op budget for this call (`false` lifts it). */
34
+ readonly budget?: BudgetOption;
35
+ /** Segments whose ids are subtracted from the result. */
36
+ readonly exclude?: readonly SegmentRef[];
37
+ /**
38
+ * Allow an operand that names a segment which does not exist. Default `false` — a combine refuses one,
39
+ * because a misspelled or mis-namespaced operand is indistinguishable from a correct one in the result.
40
+ * Set `true` when you genuinely intend to combine against a name that may not have been created yet.
41
+ */
42
+ readonly allowAbsentOperands?: boolean;
43
+ }
44
+ export declare class SegmentEngine {
45
+ private readonly storage;
46
+ private readonly cache;
47
+ private readonly codec;
48
+ private readonly maxBitmapBytes;
49
+ private readonly clock;
50
+ private readonly metrics;
51
+ private readonly metricsOn;
52
+ /** Resolved per-op budget (null = disabled); undefined deps ⇒ the generous default. */
53
+ private readonly budget;
54
+ constructor(deps: EngineDeps);
55
+ /** Membership: one chunk lookup — the cache, else one Storage fetch of that chunk. */
56
+ has(seg: SegmentRef, id: number): Promise<boolean>;
57
+ /**
58
+ * Cardinality. When the Storage source can serve per-chunk cardinality from its `.crbm` index (the `.crbm`
59
+ * source can), the count is summed from the index with **zero payload reads**. A source without that
60
+ * capability (the in-memory source) falls back to fetching every chunk.
61
+ */
62
+ count(seg: SegmentRef): Promise<number>;
63
+ /** Whether the Storage source can measure segment size (for grounded cost); false ⇒ storage isn't grounded. */
64
+ get supportsStorageSize(): boolean;
65
+ /** Grounded Storage size of a segment's current generation (cost reporting), or null if it has no generation. */
66
+ segmentSize(seg: SegmentRef): Promise<SegmentSize | null>;
67
+ /** Every id, ascending, one chunk at a time. */
68
+ iterate(seg: SegmentRef): AsyncGenerator<number>;
69
+ /**
70
+ * `segs[0] ∩ segs[1] ∩ …`, minus every id in `options.exclude` — streamed ascending.
71
+ *
72
+ * The `exclude` operands are what make suppression **compose without materialising**. Applying suppression
73
+ * afterwards (intersect into a temp segment, then subtract) writes an intermediate segment that nobody wants;
74
+ * applying it here folds it into the same chunk-aligned pass. It is also cheaper than it looks: an exclude
75
+ * operand is read **only at the keys that survived the intersection** — the cost is bounded by the
76
+ * audience's surviving key count and **never scales with the size of the suppression list**. Subtracting a
77
+ * 61,000-chunk global opt-out list costs at most one read per surviving key, not 61,000.
78
+ *
79
+ * ---
80
+ *
81
+ * Chunk-skipping intersection. Aligns each segment's chunk-key set (read from the `.crbm` index, no payload),
82
+ * keeps only keys present in **all** operands (a key missing from any operand can't contribute → its Storage
83
+ * chunks are never fetched — the core saving), then for each surviving key fetches the operands' chunks in
84
+ * parallel and hands them to the codec for the AND, streaming results through a bounded in-flight window.
85
+ *
86
+ * **Memory:** the Storage payload footprint is bounded by the window (`concurrency × operands × chunk`), not by
87
+ * segment size — that's the Lambda-friendly property.
88
+ *
89
+ * Generation-consistent within the call (normal case): each operand's current generation is resolved **once**
90
+ * up front (before the fan-out) and threaded into every chunk read, so a concurrent load can't corrupt or tear
91
+ * the result — every chunk read is a whole, checksum-verified, immutable generation. The edge a *long* call can
92
+ * hit: if it straddles a mid-call `cache.genTtlMs` boundary and a load has published, an operand's not-yet-read
93
+ * chunks may re-resolve forward to the newer generation (a generation hop within one long call) — the call
94
+ * never crashes or returns a torn object, but may mix generations. A shorter call is unaffected **unless the
95
+ * reader cache evicts an operand mid-call** (`maxOpenSegments`): the re-read re-resolves fresh (bypassing the
96
+ * TTL), which can hop generations even sub-TTL — still whole/immutable per read, never torn.
97
+ */
98
+ intersect(segs: readonly SegmentRef[], options?: CombineOptions): AsyncGenerator<number>;
99
+ /**
100
+ * `segs[0] ∪ segs[1] ∪ …`, minus every id in `options.exclude` — streamed ascending.
101
+ *
102
+ * **Union is the one composite read with no chunk-skipping**, and that is a property of the operation rather
103
+ * than of this implementation: an id in *any* operand belongs to the result, so every chunk of every operand
104
+ * has to be read. `intersect` can prune a key absent from any operand; union cannot prune anything. It is
105
+ * budgeted exactly like `intersect` so a wide union is refused rather than quietly billed.
106
+ */
107
+ union(segs: readonly SegmentRef[], options?: CombineOptions): AsyncGenerator<number>;
108
+ /**
109
+ * `seg \ (excludes[0] ∪ excludes[1] ∪ …)` — streamed ascending. Suppression on its own.
110
+ *
111
+ * Reads every chunk of `seg` (any of them may survive) but each exclude **only where it overlaps `seg`**, so
112
+ * the cost scales with the segment being filtered rather than with the size of the suppression list.
113
+ */
114
+ andNot(seg: SegmentRef, excludes: readonly SegmentRef[], options?: Omit<CombineOptions, 'exclude'>): AsyncGenerator<number>;
115
+ private combine;
116
+ /**
117
+ * The AND (or OR) of one chunk key across all operands, minus the excludes that hold it, or `null` if empty.
118
+ * Operand chunks are fetched **in parallel** (the spec's parallel byte-range reads).
119
+ *
120
+ * The accumulator is a **clone** of the first operand's chunk, and that clone is load-bearing: every other
121
+ * bitmap here is the cached, shared Storage instance and must never be mutated, or the cache is poisoned for
122
+ * every later reader. The remaining operands and the excludes are only *read* by the in-place ops, so they are
123
+ * used as-is — one clone per key, not one per operand.
124
+ */
125
+ private combineChunk;
126
+ /** The segment's chunk keys, ascending — a shape read off the index, no payload. Keys are untrusted (invariant 5). */
127
+ private chunkKeys;
128
+ /**
129
+ * Tier-derived keys are untrusted (invariant 5) — fail fast on a corrupt/out-of-range key rather than
130
+ * letting it flow into the id-routing step and produce a bogus id.
131
+ */
132
+ private assertChunkKeyInRange;
133
+ /**
134
+ * The other half of invariant 5: a chunk payload holds **remainders** — 16-bit offsets within one chunk — so
135
+ * every value must be `<= 0xffff`.
136
+ *
137
+ * Nothing upstream establishes that. The byte cap bounds *size*, and CRC/AEAD prove the bytes are the bytes
138
+ * that were written — which anyone able to write the bucket satisfies trivially. A value `>= 65536` would then
139
+ * reach the id-routing step, which masks it (`remainder & 0xffff`) and emits a **fabricated id belonging to a different
140
+ * chunk's id space**: indistinguishable from real data, inflating `count()` and creating spurious `intersect`
141
+ * matches.
142
+ *
143
+ * Costs one `maximum()` per chunk, not per id — `maximum` is optional on the codec seam precisely so a codec
144
+ * that cannot answer in O(1) opts out instead of making the read path walk every value.
145
+ */
146
+ private assertChunkPayloadInRange;
147
+ /**
148
+ * Refuse a combine whose operand names a segment that was never created.
149
+ *
150
+ * A segment that resolves to nothing is ambiguous in a way that matters only here. Read it directly and
151
+ * "empty" is the right answer either way. Pass it as an **operand** and the two states diverge: a
152
+ * suppression list with nobody on it correctly suppresses nothing, while one whose namespace you omitted
153
+ * *silently* suppresses nothing — and the result is not empty and obviously wrong, it is the full audience
154
+ * and plausibly right. That direction is the dangerous one: the failure mode is mailing the people who
155
+ * opted out.
156
+ *
157
+ * The asymmetry is why this is checked rather than left to the caller. A mistyped **include** collapses an
158
+ * intersect to nothing, which you notice; a mistyped **exclude** removes a safeguard, which you do not. Both
159
+ * are refused, because both are wiring errors and one rule is easier to trust than two.
160
+ *
161
+ * **Costs nothing on a normal combine.** `exists` is consulted only for an operand that resolved to zero
162
+ * chunks — a segment with data is self-evidently registered — so the check is a registry read exactly when
163
+ * a combine was about to do something suspicious, and no reads at all otherwise. A source that cannot answer
164
+ * `exists` (it has no notion of registration) skips the check rather than guessing.
165
+ */
166
+ private refuseAbsentOperands;
167
+ /**
168
+ * Drop every piece of state this engine derived from `ref`, and tell the Storage source to do the same.
169
+ *
170
+ * The decoded-chunk cache is keyed by generation, which handles a *publish* (the new generation misses) but
171
+ * not a *destruction*: a segment that was erased from, dropped, shredded or retired has no newer generation
172
+ * whose key would miss, so its decoded chunks stay resident and keep answering. That is why the erasing
173
+ * process itself could report `erased: true` and then still answer `true` for the same id, out of RAM, with
174
+ * no storage read to intercept.
175
+ *
176
+ * Called by the destructive verbs on the facade. Synchronous and best-effort — forgetting cannot fail.
177
+ */
178
+ invalidate(ref: SegmentRef): void;
179
+ /** The segment's current generation, resolved once per op (`undefined` ⇒ source can't report it). */
180
+ private currentGen;
181
+ /**
182
+ * The cache-key component identifying **which bytes** this op will read: the source's `currentVersion` when
183
+ * it has one, else the generation alone.
184
+ *
185
+ * The generation alone is not an identity. `nextGeneration` restarts at 0 once a row is purged and the
186
+ * bucket emptied, so a retired-and-re-created name serves different data at the same `currentGen` — and a
187
+ * decoded chunk cached under `(segment, chunk, 0)` is handed straight back to the new incarnation. That is an
188
+ * erased id reappearing with no read to intercept it, which is why this is keyed on the version rather than
189
+ * the number.
190
+ */
191
+ private cacheVersion;
192
+ /**
193
+ * Decode a Storage chunk. `gen` is the segment's current generation, resolved **once per op** by the caller (not
194
+ * per chunk — that would put a registry re-resolve on every chunk of a count/intersect). The cache is
195
+ * keyed by it, so a load that advances the generation misses the cache and re-reads the new bytes instead of
196
+ * serving a stale decoded chunk (an erased id can't resurrect from a cached superseded chunk). `gen === null`
197
+ * ⇒ the source reports no current generation ⇒ no storage bytes for any chunk, so skip the fetch entirely.
198
+ * `gen === undefined` ⇒ the source can't report a generation (pins one for its lifetime) ⇒ the key stays
199
+ * generation-free. Superseded-generation entries age out under the LRU ceiling — no active purge.
200
+ *
201
+ * The returned instance is **shared** (it may be the cached one): callers read it or clone it, never mutate it.
202
+ */
203
+ private storageChunk;
204
+ }
@@ -0,0 +1,141 @@
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 copied through, the one chunk holding the id re-encoded with
8
+ * 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
+ * What it does NOT reach: backups, replicas and noncurrent object versions hold the old object until their own
13
+ * lifecycle removes it. For an at-rest guarantee that survives those, use encryption plus crypto-shred
14
+ * (`destroySegment`), which is per segment — a per-id shred is infeasible because one DEK covers the whole segment.
15
+ *
16
+ * Concurrency. This is a **read-modify-write**, not a plain forward-only publish, and that distinction is where
17
+ * its correctness lives: the new generation is `from` minus one bit, so it is a valid successor to `from` and to
18
+ * nothing else. The publish is therefore fenced on `from` **and on the row it was read from**
19
+ * (`publishGeneration`'s `expectFrom` + `expectToken`) and lands only while the pointer is still exactly there,
20
+ * on that same row. Both halves are needed: a generation number identifies a generation only within one
21
+ * incarnation of a name, and a name that is retired and re-created restarts at `0`, so the number alone matched
22
+ * a different segment entirely. Anything else — a load that published in the meantime, or another
23
+ * erasure that got there first — is reported as `reason: 'superseded'`, `erased: false`, and the caller re-runs
24
+ * against the new generation. A writer that claims the same generation *number* first surfaces as a
25
+ * `WriteConflictError` (write-once).
26
+ *
27
+ * The same answer covers the read half, and for the same reason. An erasure collects with `keep: 0`, which takes
28
+ * every generation below its new pointer — so a racing erasure can delete the generation this call is streaming,
29
+ * and (once it is below the winner's pointer) the object this call just wrote. All three exposed round trips (the
30
+ * reader open, any chunk of the whole-segment rewrite, the verify) therefore report a *reason* rather than a bare
31
+ * `NotFoundError`, which was the same event wearing an unactionable face: through `eraseSubject` it became a note
32
+ * the facade documents as ambiguous about which side of the publish it landed on, telling an operator to triage
33
+ * where the truth was to re-run.
34
+ *
35
+ * Which reason comes from **re-reading the row**, not from assuming a supersession: a moved pointer is
36
+ * `'superseded'`, a tombstoned row `'destroyed'`, a purged row `'absent'`, a row with no pointer
37
+ * `'no-generation'`. A pointer still on `from` means the object it names is genuinely gone — the forbidden
38
+ * `missing-storage-generation` state — and that throws, because no re-run fixes it. That `NotFoundError` is the only
39
+ * signal of that state, so a faulting re-read rethrows it rather than replacing it with a transient-looking
40
+ * registry error.
41
+ *
42
+ * **No orphan is left behind by a refused rewrite**, and it is worth saying why, because nothing else collects
43
+ * above the pointer: `putImmutable` commits atomically, so a rewrite whose stream throws leaves no object at all;
44
+ * and if the write did complete, the winner's generation is necessarily higher, which puts ours below its pointer
45
+ * where its `keep: 0` takes it. The two orderings are mutually exclusive.
46
+ *
47
+ * Without that fence the failure was silent and severe, and both halves were reproduced: `nextGeneration` picks a
48
+ * number above everything in the bucket, so a forward-only publish here always won — discarding a concurrent
49
+ * load's whole set, and letting two concurrent erasures each return `erased: true` while the second one's
50
+ * generation put the first one's id back and collected the generation that had evidenced its removal. A false
51
+ * Art. 17 receipt is the worst output this module can produce.
52
+ *
53
+ * Readers pinned to the collected generation re-resolve on their next read; that is the documented cost of
54
+ * physical deletion on return. **Do not re-load the id while erasing it**: a load that lands after this rewrite
55
+ * carries whatever its source held, and the library cannot know that source was meant to exclude the id.
56
+ */
57
+ import { type IAuditSink } from './audit.js';
58
+ import type { CodecInterface } from './codec.js';
59
+ import type { Yielder } from './cooperative.js';
60
+ import type { IKeystore } from './crypto.js';
61
+ import type { IStorageDriver, IRegistryDriver, SegmentRef } from './ports.js';
62
+ /** What {@link eraseIdFromSegment} needs: the objects, the pointer, the codec, and the key material if encrypted. */
63
+ export interface EraseIdDeps {
64
+ readonly storage: IStorageDriver;
65
+ readonly registry: IRegistryDriver;
66
+ /**
67
+ * Bitmap codec — the rewrite decodes and re-encodes every chunk through it. Optional in the type so this stays
68
+ * call-compatible public API; a **flavor** package binds it (see {@link requireCodec}).
69
+ */
70
+ readonly codec?: CodecInterface;
71
+ /**
72
+ * Keystore for an **encrypted** segment: its registry record carries wrapped DEK(s), and the rewrite reuses
73
+ * that DEK — unwrapping it to decrypt the old generation and encrypt the new one under a `(segment, generation)`
74
+ * -bound AAD. Rewriting an encrypted segment without a keystore throws {@link KeyUnavailableError}.
75
+ */
76
+ readonly keystore?: IKeystore;
77
+ /** When true, refuse to rewrite a **cleartext** segment (the same guard the read path and bulk-load offer). */
78
+ readonly requireEncryption?: boolean;
79
+ /** Supplying a clock that can yield makes the rewrite cooperative — see `bulkLoadCrbmGeneration`. */
80
+ readonly clock?: Yielder;
81
+ /** Per-chunk decode ceiling (invariant 5); defaults to 1 MiB. */
82
+ readonly maxBitmapBytes?: number;
83
+ }
84
+ export interface EraseIdResult {
85
+ readonly segment: string;
86
+ readonly namespace?: string;
87
+ /**
88
+ * True iff the id **was** a member and a generation without it is now the segment's current generation — and
89
+ * the generation that held it has been deleted (see `collected`). This is the field an erasure ledger records.
90
+ */
91
+ readonly erased: boolean;
92
+ /**
93
+ * Why nothing was rewritten, when `erased` is false. `'absent'` (no registry row), `'destroyed'` (a crypto-shred
94
+ * tombstone — already unreadable), `'no-generation'` (a row with no Storage data yet), `'not-member'` (the id is not
95
+ * in the current generation — the common case across a fleet scan), or `'superseded'`.
96
+ *
97
+ * **`'superseded'` means this call did not erase the id, not that the id is still there.** Another writer — a
98
+ * load, or another erasure — moved the pointer off the generation this rewrite was derived from, so the
99
+ * rewrite is not a valid successor to what is now current. Re-run against the new generation: if the id is
100
+ * still present it is erased then; if the racing writer was another erasure of the *same* id, the re-run
101
+ * reports `'not-member'` because it is already gone. Either way the re-run settles it, which is why it is the
102
+ * documented action for this reason and for no other.
103
+ *
104
+ * The first four reasons are also returned when the row changes *underneath* a rewrite already in flight,
105
+ * not only when it is read up front — a concurrent `dropSegment` gives `'destroyed'`, a retention sweep that
106
+ * purges the row gives `'absent'`. A caller branching on `reason` never has to care which it was.
107
+ */
108
+ readonly reason?: 'absent' | 'destroyed' | 'no-generation' | 'not-member' | 'superseded';
109
+ /** The generation the id was found in (present whenever it was read). */
110
+ readonly fromGeneration?: number;
111
+ /** The generation written without the id (present whenever one was written, even if `superseded`). */
112
+ readonly generation?: number;
113
+ /**
114
+ * Generations deleted after the publish — normally `[fromGeneration]`, plus any older orphans. Empty when nothing
115
+ * was rewritten. **It is what THIS call deleted, not the proof that the id is gone** — those differ: a
116
+ * concurrent collector can take the generation holding the id first, and then `erased: true` is returned with
117
+ * `collected` empty, because the claim is about the bucket rather than about who emptied it. Treat a
118
+ * non-empty `collected` as evidence and an empty one as "someone else got there", never as a failure.
119
+ *
120
+ * A call that cannot establish the claim **throws** rather than report `erased: true` over bytes that are
121
+ * still there. Three ways that happens: a Storage `delete` fault, a collection pass that could not prove the
122
+ * segment was still the same one (`WriteConflictError`, which an ordinary retirement landing mid-call is
123
+ * enough to cause), and the same refusal on the collect-only path below, where nothing was published at all.
124
+ *
125
+ * **Re-run it**, and read what the re-run says rather than assuming it finished the job. The pointer has
126
+ * moved, so the re-run looks for the id in the *superseded* generations as well as the current one, and there
127
+ * are three outcomes:
128
+ *
129
+ * - `erased: true` naming the superseded generation it found the id in and collected — the ordinary case,
130
+ * and the one that gives you the receipt the failed call could not;
131
+ * - `'not-member'` — a racing collector took that generation first. The bit is gone, but **no run reports a
132
+ * receipt for it**, so keep the failed call's error alongside your ledger if you need the audit trail;
133
+ * - `'absent'` — the segment's registry row is gone, so it is no longer a segment at all and
134
+ * `store.eraseSubject` will not even scan it. Anything left in the bucket is an **orphan**: find it with
135
+ * `checkConsistency()` and collect it with `gcOrphanGenerations`.
136
+ */
137
+ readonly collected: readonly number[];
138
+ }
139
+ export declare function eraseIdFromSegment(ref: SegmentRef, id: number, deps: EraseIdDeps, options?: {
140
+ audit?: IAuditSink;
141
+ }): Promise<EraseIdResult>;
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Whole-segment erasure: crypto-shred (`destroySegment`, `eraseNamespace`) and disposal (`dropSegment`).
3
+ *
4
+ * GDPR "right to erasure" on immutable, backed-up storage: you can't delete a `.crbm` object from every
5
+ * backup, but you can **delete its key**. `destroySegment` drops the segment's wrapped DEK(s) from the registry
6
+ * (a CAS to a `destroyed` tombstone) — the encrypted Storage bytes are then permanently unreadable, everywhere,
7
+ * forever. **Immediate + irreversible**, so it's gated behind an explicit confirmation (name the exact
8
+ * segment/namespace). A `destroyed` segment reads as empty (its DEK is gone). Only works on an **encrypted**
9
+ * segment; a cleartext segment has no key to shred.
10
+ *
11
+ * The tombstone is also the fence every writer respects: `publishGeneration` and `bulkLoadCrbmGeneration` refuse
12
+ * a `destroyed` row, so a load racing an erasure cannot resurrect the segment. A single id's erasure is a different
13
+ * operation — `eraseIdFromSegment` rewrites the generation without it.
14
+ */
15
+ import { type IAuditSink } from './audit.js';
16
+ import type { IStorageDriver, IRegistryDriver, SegmentRef } from './ports.js';
17
+ export interface EraseDeps {
18
+ readonly registry: IRegistryDriver;
19
+ }
20
+ export interface DestroyResult {
21
+ readonly segment: string;
22
+ readonly namespace?: string;
23
+ /** True iff the segment is now a `destroyed` tombstone (incl. the idempotent already-destroyed case). */
24
+ readonly destroyed: boolean;
25
+ /**
26
+ * True iff **this call** performed a genuine crypto-shred — an encrypted segment whose wrapped DEK(s) were
27
+ * dropped, so its Storage bytes are now permanently unreadable. False for a cleartext tombstone (bytes remain
28
+ * readable), for the idempotent already-destroyed case, and for an absent segment. This is what the
29
+ * `segment.erase` audit event keys off — a cleartext "erase" is not an irreversible destruction.
30
+ */
31
+ readonly cryptoShredded: boolean;
32
+ /**
33
+ * Why this segment was not a fresh crypto-shred. `'absent'` (no registry row), `'already'` (already
34
+ * destroyed), `'cleartext'` (no DEK to shred) — and from `eraseNamespace` only, where per-segment faults are
35
+ * isolated so one failure cannot discard the ledger: `'contended'` (the row was rewritten during every CAS
36
+ * attempt) or `` `failed: <message>` `` (any other fault).
37
+ *
38
+ * The last two come with `destroyed: false` and mean **the segment still holds data**. A namespace erase
39
+ * returns them rather than throwing, so its entries have to be inspected — see {@link eraseNamespace}.
40
+ */
41
+ readonly reason?: string;
42
+ }
43
+ /**
44
+ * Crypto-shred one segment. **Irreversible.** `confirmSegment` must equal `ref.segment` (a guard against an
45
+ * accidental destroy — you must name the exact segment). A non-encrypted segment is rejected unless you opt in
46
+ * via `allowCleartext` (there's no key to shred; its Storage bytes stay readable — the tombstone only stops the
47
+ * segment resolving).
48
+ */
49
+ export declare function destroySegment(ref: SegmentRef, deps: EraseDeps, options: {
50
+ confirmSegment: string;
51
+ allowCleartext?: boolean;
52
+ audit?: IAuditSink;
53
+ }): Promise<DestroyResult>;
54
+ /**
55
+ * Crypto-shred every segment in a namespace. **Irreversible.** `confirmNamespace` must equal `namespace`.
56
+ * Returns a per-segment result (skips cleartext segments unless `allowCleartext`).
57
+ */
58
+ export declare function eraseNamespace(namespace: string, deps: EraseDeps, options: {
59
+ confirmNamespace: string;
60
+ allowCleartext?: boolean;
61
+ audit?: IAuditSink;
62
+ }): Promise<{
63
+ destroyed: DestroyResult[];
64
+ }>;
65
+ /**
66
+ * Deps for {@link dropSegment}. Adds `storage`, because unlike a crypto-shred this one deletes the objects.
67
+ */
68
+ export interface DropDeps extends EraseDeps {
69
+ readonly storage: IStorageDriver;
70
+ }
71
+ export interface DropResult {
72
+ readonly segment: string;
73
+ readonly namespace?: string;
74
+ /**
75
+ * **True iff this segment is now empty as a result of this call — or already was.** This is the field a
76
+ * retention job should branch on: `false` means the call found nothing to do and your ref is probably wrong.
77
+ */
78
+ readonly dropped: boolean;
79
+ /** Storage generations physically deleted, ascending. Empty on a dry run — see {@link DropResult.wouldDelete}. */
80
+ readonly generationsDeleted: readonly number[];
81
+ /**
82
+ * Generations still present in Storage when the sweep gave up, ascending. **Empty is the normal outcome** — a
83
+ * non-empty value means the storage was NOT fully reclaimed and the drop should be re-run.
84
+ *
85
+ * This field exists because its absence was a defect. A drop used to return `dropped: true` with a populated
86
+ * `generationsDeleted` and no `reason` even when an object holding the **complete set** had just been left in
87
+ * the bucket by a writer that was already mid-write when the tombstone landed (its publish is refused, but the
88
+ * object survives). For a cleartext segment those bytes are readable — and `gcOrphanGenerations` only collects
89
+ * a destroyed segment's generations when something runs it. The result was indistinguishable from a clean
90
+ * drop, so an operator got no signal to re-run. Now they do.
91
+ */
92
+ readonly generationsRemaining: readonly number[];
93
+ /**
94
+ * On a dry run, the generations that **would** be deleted. `undefined` on a real run.
95
+ *
96
+ * This exists because the confirmation guard the erasure calls use — naming the segment twice — protects a
97
+ * hand-typed literal and nothing else. In the loop this operation is actually *for*
98
+ * (`for (const day of expired) drop({segment: day}, {confirmSegment: day})`) the same variable appears twice
99
+ * and the guard is pure ceremony. A dry run is the guard that still works when a machine is calling.
100
+ *
101
+ * It is a **snapshot**: a later real run can see a different set.
102
+ */
103
+ readonly wouldDelete?: readonly number[];
104
+ /**
105
+ * On a dry run, whether the real drop would **also** crypto-shred (i.e. the segment is encrypted).
106
+ * `undefined` on a real run. This is the one fact in the preview that is irreversible *everywhere*, backups
107
+ * included — a dry run that did not surface it was previewing the recoverable half only.
108
+ */
109
+ readonly wouldCryptoShred?: boolean;
110
+ /**
111
+ * True iff the segment was encrypted and its wrapped DEK(s) were dropped as part of this call — so its bytes
112
+ * are unreadable *everywhere*, backups included, not merely deleted from the bucket. Deleting an object does
113
+ * not reach a noncurrent version, a replica, or a PITR snapshot; discarding the key does.
114
+ */
115
+ readonly cryptoShredded: boolean;
116
+ /**
117
+ * How the segment got to its current state. Absent on a fresh, ordinary drop (tombstone written, Storage swept).
118
+ *
119
+ * - `'already'` — it was already a tombstone. Idempotent; pairs with `dropped: true`. A re-drop is **not** a
120
+ * no-op — it re-sweeps Storage, so it is how a residual in `generationsRemaining` is collected.
121
+ * - `'absent'` — **nothing existed.** No registry row and no Storage objects. Pairs with `dropped: false`, and it
122
+ * is the one value worth alerting on: the usual cause is a mistyped name or an omitted `namespace`, both of
123
+ * which address a *different* segment than you meant.
124
+ */
125
+ readonly reason?: string;
126
+ }
127
+ /**
128
+ * **Dispose of a segment: tombstone it, then delete its Storage objects.** Irreversible.
129
+ *
130
+ * WHY THIS EXISTS, given {@link destroySegment} already erases. Because `destroySegment` answers a *compliance*
131
+ * question and this one answers an *operational* question, and they are not the same:
132
+ *
133
+ * - `destroySegment` **crypto-shreds** — it discards the key so the bytes are unreadable everywhere including
134
+ * immutable backups, which is the only erasure that survives WORM. But it **leaves the objects in the
135
+ * bucket**, still billed, and it *requires* encryption because a cleartext segment has no key to discard.
136
+ * - `dropSegment` **removes the storage**. It works on a cleartext segment, and on an encrypted one it *also*
137
+ * drops the DEKs, so it is a strict superset there.
138
+ *
139
+ * Before this existed there was no supported way to delete a segment and stop paying for it, and the obvious
140
+ * workaround — an object-store lifecycle rule on the key prefix — deletes the bytes while the registry still
141
+ * points at them. That is exactly the `missing-storage-generation` state the DR runbook says not to serve traffic
142
+ * on, and it surfaces *intermittently*, because a read consults the cache before Storage: cached chunks answer
143
+ * correctly and evicted ones throw. The whole value of this function is that the ordering below cannot be got
144
+ * wrong by a caller.
145
+ *
146
+ * **THE ORDER IS THE CONTRACT — registry first, then Storage.**
147
+ *
148
+ * 1. **Registry first.** After the tombstone nothing resolves a generation for this segment, so no reader can
149
+ * reach for bytes that are about to disappear, and no writer can publish onto it (`publishGeneration` and
150
+ * `bulkLoadCrbmGeneration` refuse a `destroyed` row).
151
+ * 2. **Storage second, best-effort, and re-swept.** Once the pointer is a tombstone the segment resolves as empty,
152
+ * so a failure part-way through leaves **orphaned bytes, not a wrong answer.** Orphans cost money and are
153
+ * cleaned up by re-running; a torn pointer costs correctness and is not self-healing. Given the choice, leak
154
+ * bytes — but say so: whatever survives the sweep is reported in {@link DropResult.generationsRemaining}.
155
+ *
156
+ * **Why step 2 sweeps more than once.** A load that was already writing its object when the tombstone landed
157
+ * still finishes the write — its publish is then refused, but the object survives, and it holds the complete
158
+ * set. A single list-then-delete misses it entirely. The re-sweep converges because the tombstone *is* a hard
159
+ * fence on **publishing**, so only already-in-flight writes can appear and they are finite.
160
+ *
161
+ * **When "reads as empty" starts being true.** Not instantly, for a store that has already read this segment: a
162
+ * resolved generation is cached and decoded chunks sit in the cache, so an in-flight reader can answer from
163
+ * cache for a window. A fresh store, or any reader that had not touched the segment, sees empty at once.
164
+ *
165
+ * That window is bounded by `cache.genTtlMs` (default 2 s) **only for a reader whose storage source has both a clock
166
+ * and a registry and a positive TTL** — expiry needs all three. A source built without a clock, or with
167
+ * `cache.genTtlMs: 0` (documented as "pin forever"), holds its resolved snapshot for its own lifetime; because a
168
+ * cache-LRU hit never reaches Storage, such a reader can answer `true` for a dropped segment **indefinitely** and must
169
+ * be restarted. This is the *same* caching that makes the delete-bytes-first ordering fail intermittently rather
170
+ * than loudly — it cuts both ways.
171
+ *
172
+ * `confirmSegment` must equal `ref.segment`, matching `destroySegment`/`eraseNamespace`. For an automated
173
+ * caller that guard is ceremony — use `dryRun` first, which reports what would go without touching anything.
174
+ */
175
+ export declare function dropSegment(ref: SegmentRef, deps: DropDeps, options: {
176
+ confirmSegment: string;
177
+ dryRun?: boolean;
178
+ audit?: IAuditSink;
179
+ }): Promise<DropResult>;