@cloudbitmaps/core 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/NOTICE +4 -4
  2. package/README.md +28 -7
  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 -2760
  60. package/dist/index.js +2815 -3062
  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,340 @@
1
+ /**
2
+ * Storage-driver contracts the engine depends on.
3
+ *
4
+ * Two tiers and one pointer. **Storage** is immutable object storage holding `.crbm` generations (`IStorageDriver`,
5
+ * read by the engine through the per-chunk `StorageChunkSource` view the `.crbm` reader implements); the
6
+ * **registry** is the authoritative per-segment record — which generation is current, the wrapped DEKs, the
7
+ * governance metadata — under optimistic concurrency (`IRegistryDriver`). Drivers move opaque bytes and OCC
8
+ * tokens; they never understand roaring or the `.crbm` layout.
9
+ */
10
+ import type { BlobSink } from './blob.js';
11
+ import type { WrappedDek } from './crypto.js';
12
+ /** Opaque optimistic-concurrency token — unique per write, compared by equality only. */
13
+ export type Token = string;
14
+ export interface SegmentRef {
15
+ readonly namespace?: string;
16
+ readonly segment: string;
17
+ }
18
+ export interface ChunkRef extends SegmentRef {
19
+ readonly chunkKey: number;
20
+ }
21
+ /** Identifies one immutable `.crbm` object — a single generation of a segment. */
22
+ export interface GenKey extends SegmentRef {
23
+ readonly generation: number;
24
+ }
25
+ /**
26
+ * A segment's grounded on-disk footprint — the current generation's Storage object bytes, read cheaply from the
27
+ * `.crbm` footer/index (no payload reads). Powers the grounded `costReport()`.
28
+ */
29
+ export interface SegmentSize {
30
+ readonly sizeBytes: number;
31
+ }
32
+ /** Per-chunk read view of the immutable Storage tier (implemented by the `.crbm` reader). */
33
+ export interface StorageChunkSource {
34
+ /** Read-only bytes for the chunk, or `null` if absent. Callers must not mutate the buffer. */
35
+ getChunk(ref: ChunkRef): Promise<Uint8Array | null>;
36
+ listChunkKeys(ref: SegmentRef): Promise<number[]>;
37
+ /**
38
+ * Optional: the current generation's grounded size, cheaply (from the already-parsed `.crbm` index — no
39
+ * payload reads), or `null` if the segment has no Storage generation. Powers the grounded `costReport()`.
40
+ */
41
+ sizeOf?(ref: SegmentRef): Promise<SegmentSize | null>;
42
+ /**
43
+ * Optional: the current generation's **per-chunk cardinality** (`chunkKey → count`), read from the
44
+ * already-parsed `.crbm` index with **no payload reads**, or `null` if the segment has no Storage generation.
45
+ * Powers the free `count()` — the engine sums the index instead of fetching a single chunk. A source with no
46
+ * index (e.g. the in-memory source) omits this, and `count()` falls back to fetching every chunk.
47
+ */
48
+ cardinalities?(ref: SegmentRef): Promise<ReadonlyMap<number, number> | null>;
49
+ /**
50
+ * Optional: the segment's **current generation number** as this source resolves it right now (registry
51
+ * `currentGen`, or the highest storage generation), or `null` if the segment has no Storage generation. The engine
52
+ * keys its chunk cache by this so a generation bump (a load's publish) is observed — a new generation
53
+ * misses the cache instead of serving a stale decoded chunk, and an erased id can't resurrect from a cached
54
+ * superseded chunk. Cheap: served from the source's own (short-TTL-refreshed) snapshot, **not** a fresh
55
+ * backend read per call. A source that pins one immutable generation for its whole lifetime and never
56
+ * refreshes may omit this — the engine then keys the cache without a generation.
57
+ */
58
+ currentGeneration?(ref: SegmentRef): Promise<number | null>;
59
+ /**
60
+ * Optional: forget everything this source has derived from `ref` — its resolved snapshot, its open reader,
61
+ * and any key material that reader captured — so the next read resolves from the registry again.
62
+ *
63
+ * The TTL refresh and the generation-keyed chunk cache are both built for **a publish that advances
64
+ * `currentGen`**. Neither covers an event that *destroys* what the cache was derived from: an erasure that
65
+ * deletes the generation holding the bit, a `dropSegment`, a crypto-shred, a retirement. After one of those
66
+ * a source that had already resolved the segment keeps answering from memory — with no backend read at all,
67
+ * so no storage-side control can close the window — until its TTL lapses, and **never** if it has no clock
68
+ * or `cache.genTtlMs: 0` ("pin forever").
69
+ *
70
+ * Callers that destroy or retire a segment must call this. It is synchronous and best-effort: dropping
71
+ * memoized state cannot fail, and a source that memoizes nothing may omit the method entirely.
72
+ */
73
+ invalidate?(ref: SegmentRef): void;
74
+ /**
75
+ * Optional: does this segment **exist as a registered name**, as distinct from resolving to no generation?
76
+ *
77
+ * `currentGeneration` collapses two very different states into `null`: a name nobody ever created, and a
78
+ * segment that exists and is legitimately empty. For a *read* that distinction does not matter — both answer
79
+ * empty. For an **operand of a combine** it is the difference between a suppression list with nobody on it
80
+ * yet and a suppression list you misspelled, and those must not look alike: the first correctly suppresses
81
+ * nothing, the second silently suppresses nothing while you believe it is working.
82
+ *
83
+ * Consulted **only** when an operand resolved to no chunks at all, so a normal combine never calls it.
84
+ */
85
+ exists?(ref: SegmentRef): Promise<boolean>;
86
+ /**
87
+ * Optional: an opaque token identifying **which bytes** a read of this segment will see right now —
88
+ * the generation *and* the incarnation of the name it belongs to.
89
+ *
90
+ * {@link StorageChunkSource.currentGeneration} is not enough to key a decoded-chunk cache, and the gap is not
91
+ * theoretical: `nextGeneration` returns `max(currentGen, highest object) + 1`, so it **restarts at 0** once
92
+ * a registry row is purged and the bucket emptied. A retired, re-created name therefore serves different
93
+ * data at the same `currentGen`, and a cache keyed on `(segment, chunk, generation)` hands back the previous
94
+ * incarnation's ids — an erased id reappearing, with no read to intercept.
95
+ *
96
+ * Note that putting the incarnation in the *object key* would not fix this: the new incarnation still starts
97
+ * at generation 0, so the cache key collides either way. The identity has to reach the cache.
98
+ *
99
+ * Returns `null` when the segment resolves to no generation. A source that pins one immutable generation for
100
+ * its lifetime may omit this, and the engine falls back to the generation alone.
101
+ */
102
+ currentVersion?(ref: SegmentRef): Promise<string | null>;
103
+ }
104
+ /** Capabilities a Storage driver advertises; validated at wiring time, fail-fast. */
105
+ export interface StorageCaps {
106
+ /** REQUIRED — the format relies on byte-range reads. */
107
+ readonly rangeRead: true;
108
+ /** Largest single object the backend accepts (informs single-object-vs-shard — future). */
109
+ readonly maxObjectBytes: number;
110
+ /** Optional: enables the pure-object `LATEST`-pointer registry variant (S3 conditional put). */
111
+ readonly conditionalPut?: boolean;
112
+ }
113
+ /**
114
+ * Immutable object storage for `.crbm` generations. A "dumb byte mover": it
115
+ * understands neither roaring nor the `.crbm` layout, only opaque bytes addressed by a {@link GenKey}.
116
+ * The core never reuses a key, so puts are write-once.
117
+ */
118
+ export interface IStorageDriver {
119
+ capabilities(): StorageCaps;
120
+ /**
121
+ * Stream a new immutable generation. The driver opens a destination, hands `write` a {@link BlobSink},
122
+ * then atomically commits (and computes the content hash). Throws if the key already exists (write-once).
123
+ */
124
+ putImmutable(key: GenKey, write: (sink: BlobSink) => Promise<void>): Promise<{
125
+ size: number;
126
+ sha256: string;
127
+ }>;
128
+ /** Range read; the caller bounds-checks. Out-of-range is rejected, never a short/adjacent read. */
129
+ getRange(key: GenKey, offset: number, length: number): Promise<Uint8Array>;
130
+ /** Speculative tail read: the last `min(maxBytes, size)` bytes + the total object size. */
131
+ getTail(key: GenKey, maxBytes: number): Promise<{
132
+ bytes: Uint8Array;
133
+ size: number;
134
+ }>;
135
+ delete(key: GenKey): Promise<void>;
136
+ /** Enumerate the generations present for a segment (orphan sweep / latest-gen resolution). */
137
+ list(ref: SegmentRef): AsyncIterable<GenKey>;
138
+ }
139
+ /**
140
+ * Lifecycle status of a segment. `active` is the steady state; `destroyed` is the post-crypto-shred tombstone
141
+ * (the row is kept for audit but the segment is logically gone). `compacting`/`erasing` are **reserved** —
142
+ * stored and round-tripped so a row written by an earlier build still reads, but no writer in this build sets
143
+ * them.
144
+ */
145
+ export type RegistryStatus = 'active' | 'compacting' | 'erasing' | 'destroyed';
146
+ /**
147
+ * Free-form, JSON-serializable governance metadata — a **plain object** (both registry boundaries reject `null`,
148
+ * an array or a primitive, because a reader that tests `'key' in meta` would throw an untyped `TypeError`).
149
+ *
150
+ * `retention` has one **reserved key with defined semantics**: `expiresAt`, an absolute epoch-ms after which a
151
+ * retention sweep may retire the segment — written by `setSegmentRetention`, parsed by `readRetentionPolicy`. Any
152
+ * other key is yours (a legal hold, a note, an owner) and is preserved across policy writes. `residency` is still
153
+ * shape-later: stored and round-tripped, no semantics attached.
154
+ */
155
+ export type GovernanceMeta = Record<string, unknown>;
156
+ /**
157
+ * One registry row — the authoritative per-segment record. Exactly one per segment.
158
+ */
159
+ export interface RegistryRecord extends SegmentRef {
160
+ /**
161
+ * **The** authoritative LATEST pointer: which immutable Storage generation is current — or **`null` for a segment
162
+ * that has no Storage generation yet.**
163
+ *
164
+ * `null` is not "unknown", it is a positive statement: *this segment exists and has no Storage data.* It is what
165
+ * lets a segment have a registry row **before its first load** — `setSegmentRetention` mints one so a policy
166
+ * can be recorded ahead of the data, and so the segment is reachable by `registry.list()` and therefore by
167
+ * retention sweeps, `checkConsistency` and every other fleet-wide operation. The alternative — a row with
168
+ * `currentGen: 0` and no object behind it — is the forbidden `missing-storage-generation` state, which fails per
169
+ * read with `NotFoundError`. Generation resolution maps `null` onto the same path a segment with **no row**
170
+ * takes: every read answers empty, and the first publish advances the pointer.
171
+ */
172
+ readonly currentGen: number | null;
173
+ /**
174
+ * Per-segment data-key (DEK) wrappings for encryption-at-rest: the DEK envelope-wrapped under one or more KEKs
175
+ * (active + optional recovery). Reading unwraps with any held KEK; **crypto-shred deletes this whole list**,
176
+ * making the segment's at-rest bytes permanently unrecoverable. Absent ⇒ the segment is cleartext.
177
+ */
178
+ readonly wrappedDeks?: readonly WrappedDek[];
179
+ /**
180
+ * Optional **external** key reference (reserved) — e.g. a KMS key ARN for a future KMS keystore adapter that
181
+ * keeps wrapped material in the KMS rather than in-band {@link wrappedDeks}. Unused by the in-process BYOK
182
+ * keystore. Clearable on crypto-shred.
183
+ */
184
+ readonly keyId?: string;
185
+ readonly status: RegistryStatus;
186
+ /**
187
+ * Governance policy. `retention.expiresAt` drives the retention sweep (see {@link GovernanceMeta}); `residency`
188
+ * is stored and round-tripped with no semantics yet. Both must be plain objects, and both must survive a
189
+ * `list()` projection — a fleet sweep reads the policy from the enumeration rather than per-segment.
190
+ */
191
+ readonly retention?: GovernanceMeta;
192
+ readonly residency?: GovernanceMeta;
193
+ /** Epoch-ms of creation / last mutation (from the driver's injected clock). */
194
+ readonly createdAt: number;
195
+ readonly updatedAt: number;
196
+ /** Opaque OCC token — compare-by-equality, never reused (ABA-safe). */
197
+ readonly token: Token;
198
+ }
199
+ /** The caller-settable fields at {@link IRegistryDriver.create} (audit + token are driver-managed). */
200
+ export interface NewRegistryRecord {
201
+ /** `null` ⇒ the segment has no Storage generation yet — see {@link RegistryRecord.currentGen}. */
202
+ readonly currentGen: number | null;
203
+ readonly wrappedDeks?: readonly WrappedDek[];
204
+ readonly keyId?: string;
205
+ /** Defaults to `'active'`. */
206
+ readonly status?: RegistryStatus;
207
+ readonly retention?: GovernanceMeta;
208
+ readonly residency?: GovernanceMeta;
209
+ }
210
+ /** Fields a {@link IRegistryDriver.compareAndSwap} may mutate (identity + audit + token are off-limits). */
211
+ export type RegistryPatch = Partial<Pick<RegistryRecord, 'currentGen' | 'wrappedDeks' | 'keyId' | 'status' | 'retention' | 'residency'>>;
212
+ /** Capabilities a registry driver advertises; validated fail-fast at wiring time. */
213
+ export interface RegCaps {
214
+ /** REQUIRED — `currentGen` feeds read correctness + the publish CAS, so reads must be strongly consistent. */
215
+ readonly strongRead: true;
216
+ }
217
+ /**
218
+ * Brand for {@link StorageBackend}.
219
+ *
220
+ * `Symbol.for` rather than a class check, for the same reason the error brands are: a class object is only
221
+ * shared inside one package's bundle. `@cloudbitmaps/roaring` carries its own copy of core, so a backend
222
+ * built from one package and checked by the other would fail an `instanceof` on an ordinary install — no
223
+ * version skew required — as would a genuine second copy from a skew or a duplicating bundler. A registered
224
+ * symbol is identity-stable across all of them, and across realms.
225
+ *
226
+ * The string says `cloudbitmaps` while the error brands still say `cloud-roaring`: those are locked, because
227
+ * changing one would break error identity against already-published bundles. A new brand has no such
228
+ * constraint, so it uses the current name.
229
+ */
230
+ declare const STORAGE_BACKEND_BRAND: unique symbol;
231
+ /**
232
+ * Where a store keeps everything: the generations, and the pointer saying which one is current.
233
+ *
234
+ * **Branded on purpose.** The two fields alone are not enough to qualify, and that is the whole point. This
235
+ * shape is also {@link LoadDeps}/{@link GenerationDeps}, so before the brand any `{ storage, registry }`
236
+ * object literal satisfied it — including one assembling halves from two *unrelated* stores, which the store
237
+ * accepted and then answered **empty** for a segment that holds data, because it read a pointer from a place
238
+ * nothing had ever been written. That is the exact silent-empty failure one-class-per-backend exists to
239
+ * remove, and it was reachable in five lines.
240
+ *
241
+ * So a backend comes from `MemoryStorage`, `LocalFsStorage`, `S3Storage`, `GcsStorage` or
242
+ * `AzureBlobStorage` — each deriving both halves from one bucket and one prefix — or from
243
+ * {@link createBackend}, which is the same thing said on purpose. The halves stay readable as `.storage` /
244
+ * `.registry`, because the free functions genuinely need them.
245
+ *
246
+ * **What this stops is the accident, not a determined caller.** The brand is a registered symbol, so it can
247
+ * be written by hand; nothing here is a security boundary. It stops the two spellings people reach for
248
+ * without deciding to — an object literal, and a spread of a real backend.
249
+ */
250
+ export interface StorageBackend {
251
+ /** Cross-bundle brand. Set by the backend classes; not part of the public surface. */
252
+ readonly [STORAGE_BACKEND_BRAND]: true;
253
+ /** Where the immutable `.crbm` generations live. */
254
+ readonly storage: IStorageDriver;
255
+ /** Where the `currentGen` pointer, the discovery index and the wrapped DEKs live. */
256
+ readonly registry: IRegistryDriver;
257
+ }
258
+ /**
259
+ * Build a backend from two halves you supply yourself.
260
+ *
261
+ * **Reach for a backend class first** — `MemoryStorage`, `LocalFsStorage`, `S3Storage`, `GcsStorage`,
262
+ * `AzureBlobStorage`. Each derives both halves from one bucket and one prefix, so they cannot disagree, and
263
+ * that is the whole reason the classes exist.
264
+ *
265
+ * This is the door for the cases a class cannot express, and they are real: a driver wrapped for auditing,
266
+ * metrics, tenant scoping or client-side encryption; a registry in a database you already run; a
267
+ * fault-injecting double in a test. What it is NOT is a shortcut — naming it is the point. The plain
268
+ * `{ storage, registry }` literal is refused precisely because it let the two halves come from unrelated
269
+ * places by accident, and answer **empty** rather than fail.
270
+ *
271
+ * It cannot check that the halves agree: `IStorageDriver` and `IRegistryDriver` do not expose a location, so
272
+ * nothing here can compare one. Calling this is you taking that on.
273
+ */
274
+ export declare function createBackend(halves: {
275
+ readonly storage: IStorageDriver;
276
+ readonly registry: IRegistryDriver;
277
+ }): StorageBackend;
278
+ /**
279
+ * Stamp the brand on a backend, **non-enumerably**.
280
+ *
281
+ * Non-enumerable is load-bearing, not tidiness. A class field (`readonly [BRAND] = true`) emits an
282
+ * *enumerable* own property, and spread and `Object.assign` copy exactly those — so
283
+ * `{ ...backend, registry: other.registry }` carried the brand and was accepted, reproducing the silent-empty
284
+ * bug this whole thing exists to close. That spelling is also the most idiomatic way a JS developer swaps one
285
+ * half, which means the audience `createBackend` was added for walked straight past it.
286
+ *
287
+ * This does not make the brand unforgeable — the symbol is registered, so anyone who wants it can write
288
+ * `Symbol.for('cloudbitmaps.storage-backend')`. That is deliberate effort equivalent to calling
289
+ * `createBackend`, and it is not what the check is for. The check is for the accident.
290
+ */
291
+ /**
292
+ * The brand key, for the type-only `declare` field a backend class carries.
293
+ *
294
+ * Exported from `@cloudbitmaps/core/driver-kit` and part of the driver contract: a third-party storage
295
+ * package needs it to declare the field that {@link brandAsBackend} then stamps at runtime.
296
+ */
297
+ export declare const STORAGE_BACKEND: typeof STORAGE_BACKEND_BRAND;
298
+ export declare function brandAsBackend<T extends {
299
+ storage: IStorageDriver;
300
+ registry: IRegistryDriver;
301
+ }>(target: T): T & StorageBackend;
302
+ /**
303
+ * Is this a branded backend — from a backend class or from {@link createBackend}?
304
+ *
305
+ * Checks the brand, not the shape — see {@link StorageBackend} for why the shape alone is not enough.
306
+ */
307
+ export declare function isStorageBackend(value: unknown): value is StorageBackend;
308
+ /**
309
+ * Per-segment registry — the authoritative source of `currentGen`, the discovery index, and the wrapped-DEK
310
+ * holder. One row per segment under OCC: a never-reused, equality-compared {@link Token} (ABA-safe across
311
+ * delete→recreate).
312
+ */
313
+ export interface IRegistryDriver {
314
+ capabilities(): RegCaps;
315
+ /** The segment's record, or `null` if it doesn't exist (or was deleted). */
316
+ get(ref: SegmentRef): Promise<RegistryRecord | null>;
317
+ /** Create the row; throws {@link WriteConflictError} if it already exists (use CAS to mutate). */
318
+ create(ref: SegmentRef, record: NewRegistryRecord): Promise<{
319
+ token: Token;
320
+ }>;
321
+ /** Server-side compare-and-set: apply `patch` iff the stored token equals `expected`, else `WriteConflictError`. */
322
+ compareAndSwap(ref: SegmentRef, expected: Token, patch: RegistryPatch): Promise<{
323
+ token: Token;
324
+ }>;
325
+ /**
326
+ * Discovery: every **existing** record, optionally scoped to one namespace. Order is unspecified.
327
+ *
328
+ * "Existing" means not `delete`d. A **`destroyed` tombstone is still a record and must be yielded** — a driver
329
+ * that filters by `status` breaks callers silently, and two already depend on seeing them: `runConsistencyCheck`
330
+ * skips them itself, and the retention sweep can only clean up a tombstone row it can see (filtering it makes
331
+ * the cleanup a permanent no-op, indistinguishable from having nothing to do, while dead rows accumulate). Same
332
+ * rule for every other field: a row with **`currentGen: null`** must be yielded like any other, and `retention`
333
+ * must survive the projection — a fleet sweep reads the policy straight out of this enumeration rather than
334
+ * paying a `get()` per segment, so a `list()` that drops the field means nothing ever expires, silently.
335
+ */
336
+ list(namespace?: string): AsyncIterable<RegistryRecord>;
337
+ /** Remove the row (tombstoned for ABA-safety — a later `create` still gets a fresh, greater token). */
338
+ delete(ref: SegmentRef): Promise<void>;
339
+ }
340
+ export {};
@@ -0,0 +1,38 @@
1
+ import type { IRegistryDriver, RegistryRecord } from './ports.js';
2
+ /**
3
+ * Ceiling on registry records one fleet-wide scan holds resident. Raise it via the caller's `maxScanSegments` when
4
+ * the fleet really is that large *and* the memory is there; narrow the scan with a `namespace` otherwise.
5
+ */
6
+ export declare const DEFAULT_MAX_SCAN_SEGMENTS = 250000;
7
+ /** Fail fast on a bad ceiling BEFORE the (possibly huge) scan, not after. */
8
+ export declare function validateMaxScanSegments(value: number, op: string): void;
9
+ /**
10
+ * Drain every registry record (optionally one namespace) into an array, refusing past `maxScanSegments`.
11
+ *
12
+ * The bound is checked **before** each push, so the array never exceeds the ceiling the caller agreed to — the
13
+ * earlier form pushed first and then threw, which meant the very row that broke the budget was already resident,
14
+ * and a caller sizing a container against `maxScanSegments` was off by one row at the worst possible moment.
15
+ */
16
+ export declare function drainRegistry(registry: IRegistryDriver, options: {
17
+ namespace?: string;
18
+ maxScanSegments: number;
19
+ op: string;
20
+ }): Promise<RegistryRecord[]>;
21
+ /**
22
+ * Is this record **bookkeeping** rather than a segment? Covers every reserved family — today the due-index
23
+ * pointers. **Every unscoped fleet-wide enumeration must skip these**: the bounded drain,
24
+ * the export/eject scan, and the all-namespaces GDPR paths.
25
+ *
26
+ * This is the ONE place a reserved family is declared, and a new one belongs here rather than at the call
27
+ * sites. An earlier cut inlined the comparison per site and shipped with three missed — including
28
+ * `subjectReport`, where the rows consumed an Art. 15 request's per-op budget. A filter you have to remember at
29
+ * each site is a check that cannot fire.
30
+ */
31
+ export declare function isReservedRow(record: Pick<RegistryRecord, 'namespace'>): boolean;
32
+ /**
33
+ * Filter bookkeeping rows out of a registry listing **before** anything downstream pays for them. Needed where
34
+ * the enumeration is consumed inside a budgeted collector rather than a plain loop: charging a GDPR Art. 15
35
+ * request's per-op budget for pointer rows can refuse a subject report for a reason that has nothing to do with
36
+ * the subject.
37
+ */
38
+ export declare function excludingReservedRows(source: AsyncIterable<RegistryRecord>): AsyncIterable<RegistryRecord>;
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The retention **sweep** — the thing that acts on the policies `setSegmentRetention` records.
3
+ *
4
+ * `retireExpired` enumerates the registry, selects the segments whose `expiresAt` has passed, and retires each
5
+ * one through {@link dropSegment}. It deliberately **delegates rather than reimplements**: the registry → Storage
6
+ * ordering, the re-sweep for an object a load was still writing, and the `generationsRemaining` report are all
7
+ * load-bearing and already live there. A sweep that open-coded the deletions would be a second
8
+ * implementation of the most dangerous ordering in the library.
9
+ *
10
+ * **This is a call, not a daemon.** Nothing here schedules itself. You run it from whatever heartbeat your
11
+ * deployment already has — an EventBridge rule, a Kubernetes CronJob, a queue consumer, the job that runs your
12
+ * loads — and the library stays a library: it has to behave identically in a Lambda, an edge isolate and a
13
+ * long-lived server, and a timer that only works in one of those is worse than none.
14
+ *
15
+ * Three properties make it safe to point at a fleet:
16
+ *
17
+ * - **`dryRun` at the sweep level.** `dropSegment`'s `confirmSegment` guard is vacuous in a loop (it is the same
18
+ * variable twice), so the meaningful preview is here: it reports what it *would* retire, per segment, and
19
+ * touches nothing.
20
+ * - **A per-cycle `limit`.** Clock skew, a bad backfill, or a namespace-wide policy mistake should cost one
21
+ * bounded batch, not the fleet. When the limit bites, the result says so (`limited: true`) rather than looking
22
+ * like a completed sweep.
23
+ * - **A ledger, never an exception.** A per-segment fault is recorded and the sweep continues — the caller's
24
+ * load-bearing question is *which* segments were retired, and a throw from the middle of the loop answers it
25
+ * for none of them while having already retired some. Same posture as `eraseNamespace`.
26
+ *
27
+ * It also **purges the tombstones its own retirements leave behind**. A retired segment that had a registry row
28
+ * gets a `destroyed` row, and one dead row per retired daily bucket — or per retired dedup wave — is exactly the
29
+ * registry litter `dropSegment` already refuses to create for a row-less accumulator. Purging is narrow on
30
+ * purpose: only a tombstone that still carries an **expired retention policy** (so it is attributably ours, never
31
+ * a GDPR crypto-shred), only after a grace period, and only once Storage is provably empty for it —
32
+ * because deleting the row is what makes the name reusable and takes the segment out of reach of
33
+ * `gcOrphanGenerations`.
34
+ */
35
+ import { type IAuditSink } from './audit.js';
36
+ import type { DropDeps, DropResult } from './erasure.js';
37
+ /** Default cap on retirements per sweep — a bounded batch, so a policy mistake costs one batch, not the fleet. */
38
+ export declare const DEFAULT_RETIRE_LIMIT = 100;
39
+ /**
40
+ * Default delay before a retirement's own tombstone row is purged: 24 h.
41
+ *
42
+ * The row is a fence — while it exists, `publishGeneration` and `bulkLoadCrbmGeneration` refuse the segment, so
43
+ * a load that was mid-write when the drop landed cannot resurrect it. That window is
44
+ * seconds to minutes in practice; a day of margin costs one tiny row and removes any need to reason about it.
45
+ */
46
+ export declare const DEFAULT_TOMBSTONE_GRACE_MS = 86400000;
47
+ export interface RetireExpiredOptions {
48
+ /** Scope the sweep to one namespace. Omit to sweep every namespace the registry knows. */
49
+ readonly namespace?: string;
50
+ /**
51
+ * The instant to compare policies against (epoch-ms). Required here because `core/` takes its time from the
52
+ * caller, never from the platform — `store.retireExpired()` fills it in from the store's clock.
53
+ */
54
+ readonly now: number;
55
+ /** Maximum segments to retire in this cycle (default 100). */
56
+ readonly limit?: number;
57
+ /** Report what would be retired and change nothing. */
58
+ readonly dryRun?: boolean;
59
+ /** Forwarded to each `dropSegment`, so every retirement lands in the audit trail as `segment.dispose`. */
60
+ readonly audit?: IAuditSink;
61
+ /** Ceiling on rows enumerated (default 250,000); exceeding it throws. */
62
+ readonly maxScanSegments?: number;
63
+ /**
64
+ * **Where the candidates come from.**
65
+ *
66
+ * - `'fleet'` (default) — drain `registry.list()` and filter. Cost tracks the **fleet**, every cycle, even
67
+ * when nothing expires. Complete by construction: it cannot miss a policy.
68
+ * - `'index'` — read only the due buckets of the due index. Cost tracks **what is
69
+ * expiring**. Each candidate's live row is still re-read before anything is decided, so a stale pointer
70
+ * costs one read and retires nothing.
71
+ *
72
+ * **`'index'` is not a drop-in replacement for `'fleet'`; it is the fast half of a pair.** A policy written
73
+ * before the index existed, or one whose pointer write failed (`indexed: false`), has no pointer — so a
74
+ * deployment that *only* ever runs `'index'` will never retire those. Run `'fleet'` periodically as the
75
+ * repair pass. The default stays `'fleet'` so that upgrading changes nothing about what gets retired.
76
+ */
77
+ readonly scan?: 'fleet' | 'index';
78
+ /**
79
+ * **The shards this worker owns**, with {@link totalShards}. Without them every replica sweeps the whole
80
+ * fleet and contends over the same segments. A stable hash of the segment key, so a worker owns the same slice
81
+ * across restarts.
82
+ */
83
+ readonly shards?: readonly number[];
84
+ /** Total shards the fleet is split into. Required with {@link shards}; ignored without it. */
85
+ readonly totalShards?: number;
86
+ /**
87
+ * How many **past** buckets an `'index'` scan reads besides the current one (default 7, one per day).
88
+ * A sweep that did not run — scaled to zero, a failed deploy, a paused
89
+ * schedule — leaves its buckets behind, and this is how far back a later cycle reaches for them. Bounded so a
90
+ * long outage costs a bounded number of list calls per cycle rather than one per day since the epoch;
91
+ * anything older is the `'fleet'` repair pass's job.
92
+ */
93
+ readonly lookbackBuckets?: number;
94
+ /**
95
+ * Whether to delete the tombstone rows this sweep's own past retirements left (default `true`). Set `false` to
96
+ * keep every tombstone forever — the right choice if something outside this library treats the presence of a
97
+ * `destroyed` row as an attestation.
98
+ *
99
+ * Two knobs rather than one `number | 'never'`, deliberately: `0` would have had to mean "purge immediately"
100
+ * here while `cache.genTtlMs: 0` in this same library means "pin forever", and one option whose zero is the
101
+ * opposite of another's is a reading hazard for whoever tunes both.
102
+ */
103
+ readonly purgeTombstones?: boolean;
104
+ /** How long a retirement's tombstone must age before this sweep deletes it (default 24 h). */
105
+ readonly tombstoneGraceMs?: number;
106
+ }
107
+ /** What happened to one segment in a sweep. */
108
+ export type RetireEntry = {
109
+ readonly segment: string;
110
+ readonly namespace?: string;
111
+ /** Retired: `result` is `dropSegment`'s full report — **check `generationsRemaining`**. */
112
+ readonly action: 'retired';
113
+ readonly expiresAt: number;
114
+ readonly result: DropResult;
115
+ /**
116
+ * Present when the retirement **completed the destructive part and then faulted** — the tombstone is written
117
+ * and the segment reads empty, but something after that (the Storage sweep) threw. The storage may not be fully
118
+ * reclaimed; re-run. Reported as `retired` rather than `skipped` because the segment really is retired, and
119
+ * saying otherwise is the one thing a caller cannot recover from.
120
+ */
121
+ readonly fault?: `failed: ${string}`;
122
+ } | {
123
+ readonly segment: string;
124
+ readonly namespace?: string;
125
+ /** `dryRun` only — what a real sweep would have retired, with `dropSegment`'s own preview attached. */
126
+ readonly action: 'would-retire';
127
+ readonly expiresAt: number;
128
+ readonly result: DropResult;
129
+ } | {
130
+ readonly segment: string;
131
+ readonly namespace?: string;
132
+ /** A tombstone row from an earlier retirement was deleted (or would be, under `dryRun`). */
133
+ readonly action: 'purged-tombstone' | 'would-purge-tombstone';
134
+ } | {
135
+ readonly segment: string;
136
+ readonly namespace?: string;
137
+ readonly action: 'skipped';
138
+ /**
139
+ * `'invalid-policy'` — the row has an `expiresAt` that is not usable (hand-edited, or restored from another
140
+ * schema). Reported rather than ignored: reading as "never expires" on a segment someone believes is
141
+ * expiring is the silence that costs a retention commitment.
142
+ * `'limit'` — eligible, but this cycle's `limit` was already spent. Re-run to continue.
143
+ * `'tombstone-not-empty'` — a tombstone whose Storage generations are not gone even after a GC
144
+ * attempt, so its row is kept: the row is what keeps the segment reachable by `gcOrphanGenerations` and
145
+ * refused by every writer. Several causes, all self-healing: the storage really could not be reclaimed,
146
+ * the collection *declined* because the row changed underneath it (`WriteConflictError`, which this
147
+ * sweep swallows deliberately), or this was a `dryRun`, which reports the reason without attempting the
148
+ * collection at all. Except under `dryRun` the next cycle retries.
149
+ * `'policy-changed'` — the live row no longer says "expired" (a `clearRetention`, a new `expiresAt`, or
150
+ * someone else's drop landed between the enumeration and this segment's turn). Not an error: the sweep
151
+ * re-reads before every deletion precisely so cancelling an expiry works on a sweep already in flight.
152
+ * `` `failed: …` `` — any fault, isolated to this segment. A fault that happened *after* the tombstone
153
+ * landed is reported as `retired` with a `fault` instead, because that segment IS retired.
154
+ */
155
+ readonly reason: 'invalid-policy' | 'limit' | 'tombstone-not-empty' | 'policy-changed' | `failed: ${string}`;
156
+ };
157
+ export interface RetireExpiredResult {
158
+ /** Registry rows enumerated. */
159
+ readonly scanned: number;
160
+ /** Rows whose policy said "expired" — including any the `limit` deferred. */
161
+ readonly eligible: number;
162
+ /**
163
+ * Segments **actually retired**. Zero under `dryRun` — see `wouldRetire`. Kept honest because this is the field
164
+ * most likely to end up on a dashboard, and the CLI emits it: a counter that means "deleted" in one mode and
165
+ * "would delete" in another produces phantom deletions on any graph that does not also join on `dryRun`.
166
+ */
167
+ readonly retired: number;
168
+ /** Segments a real sweep **would** have retired. Only ever non-zero under `dryRun`. */
169
+ readonly wouldRetire: number;
170
+ /** Tombstone rows actually deleted. Zero under `dryRun`; the `would-purge-tombstone` entries carry the preview. */
171
+ readonly tombstonesPurged: number;
172
+ /** True when `limit` cut the cycle short — **more segments are still eligible**. Re-run. */
173
+ readonly limited: boolean;
174
+ readonly dryRun: boolean;
175
+ /** Per-segment ledger. Inspect it: a `skipped` or failed entry is a segment that still holds data. */
176
+ readonly entries: readonly RetireEntry[];
177
+ }
178
+ /**
179
+ * Retire every segment whose retention policy has expired, and clean up the tombstones earlier sweeps left.
180
+ *
181
+ * Returns a ledger; **never throws for a per-segment fault** (those become entries). It does throw for a bad
182
+ * argument, and for a fleet larger than `maxScanSegments` — a scan that cannot be held in memory is a fail-loud
183
+ * condition, not a partial result to be mistaken for a complete sweep.
184
+ */
185
+ /**
186
+ * How many past buckets an index scan reads besides the current one. A week: long enough that a weekend outage
187
+ * or a paused schedule recovers on its own, short enough that a cycle is eight list calls rather than hundreds.
188
+ */
189
+ export declare const DEFAULT_LOOKBACK_BUCKETS = 7;
190
+ export declare function retireExpired(deps: DropDeps, options: RetireExpiredOptions): Promise<RetireExpiredResult>;
@@ -0,0 +1,67 @@
1
+ import type { GovernanceMeta, IRegistryDriver, SegmentRef } from './ports.js';
2
+ /**
3
+ * The smallest accepted `expiresAt`: 2001-09-09, the point below which an epoch-**seconds** value would land.
4
+ *
5
+ * This is a deliberate guard against the single most damaging typo available here. `Date.now() / 1000 + 30 * 86400`
6
+ * is a natural thing to write, and it produces a number ~1.8 billion — an instant in **1970**, which is to say
7
+ * *already expired*. Without this check, that typo is not a validation error, it is a sweep that retires the
8
+ * segment on its very next pass. Anything below this floor is far likelier to be seconds than a genuine intent to
9
+ * expire a segment two decades ago, and the caller who really means "retire it now" can say so with `dropSegment`.
10
+ */
11
+ export declare const MIN_EXPIRES_AT_MS = 1000000000000;
12
+ /** A segment's retention policy: the instant it becomes eligible for retirement. */
13
+ export interface RetentionPolicy {
14
+ /**
15
+ * Absolute epoch-**milliseconds**. Once `now >= expiresAt`, a retention sweep may retire the segment — tombstone
16
+ * it and delete its Storage generations. A value in the past is legal (backfilling a policy onto existing
17
+ * buckets is a normal thing to do) and means "eligible on the next sweep".
18
+ */
19
+ readonly expiresAt: number;
20
+ }
21
+ export interface RetentionDeps {
22
+ readonly registry: IRegistryDriver;
23
+ }
24
+ export interface SetRetentionResult {
25
+ readonly segment: string;
26
+ readonly namespace?: string;
27
+ /** The policy now stored on the row. */
28
+ readonly expiresAt: number;
29
+ /**
30
+ * True iff this call **minted the registry row** — a policy recorded before the segment's first load. The
31
+ * segment is now enumerable by `registry.list()` and therefore by every fleet-wide operation; the row claims no
32
+ * Storage generation (`currentGen: null`), so reads answer empty until the first load publishes onto it.
33
+ */
34
+ readonly createdRow: boolean;
35
+ /**
36
+ * True iff the **due-index pointer** for this policy was written, so a fast sweep will find this segment by
37
+ * reading only its expiry day instead of scanning the fleet.
38
+ *
39
+ * `false` is a **degradation, not an error**, and the policy is committed either way: either the ref is too
40
+ * long to encode into one index-row name, or the pointer write failed. The full-scan repair pass still sees
41
+ * the segment's own row, so it is retired on the repair cadence rather than the fast one — slower, never
42
+ * never. Alarm on a *sustained* run of `false`, not on one.
43
+ */
44
+ readonly indexed: boolean;
45
+ }
46
+ /**
47
+ * Read the policy off a registry row's `retention` metadata. Returns `null` when there is no policy, and
48
+ * `'invalid'` when the key is present but unusable (wrong type, or below the epoch-seconds floor).
49
+ *
50
+ * The three-way answer exists so a fleet sweep can *report* a malformed policy instead of choosing between two
51
+ * bad options: treating it as "no policy" hides an operator error on a segment someone believes is expiring, and
52
+ * throwing aborts a ledger over one bad row. Nothing writes an invalid policy through
53
+ * {@link setSegmentRetention} — hand-edited rows, an older/newer writer, or a restore are how it happens.
54
+ */
55
+ export declare function readRetentionPolicy(meta: GovernanceMeta | undefined): RetentionPolicy | null | 'invalid';
56
+ /** The stored policy for one segment, or `null` if it has none (and `'invalid'` for a malformed one). */
57
+ export declare function getSegmentRetention(ref: SegmentRef, deps: RetentionDeps): Promise<RetentionPolicy | null | 'invalid'>;
58
+ export declare function setSegmentRetention(ref: SegmentRef, deps: RetentionDeps, policy: RetentionPolicy): Promise<SetRetentionResult>;
59
+ /**
60
+ * Remove a segment's expiry so no sweep will retire it. Returns whether a policy was actually removed (`false`
61
+ * for a segment with no row, or one that had no policy) — a separate verb from setting one, because "cancel the
62
+ * expiry" and "expire at time T" are different intents and a magic sentinel value for the first is how a typo
63
+ * becomes a deletion.
64
+ *
65
+ * Other `retention` keys are left in place; if removing the expiry empties the metadata, the field is cleared.
66
+ */
67
+ export declare function clearSegmentRetention(ref: SegmentRef, deps: RetentionDeps): Promise<boolean>;