@cloudbitmaps/core 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +4 -4
- package/README.md +33 -16
- package/dist/chunk-5OMC5U7U.js +508 -0
- package/dist/chunk-5OMC5U7U.js.map +7 -0
- package/dist/core/audit.d.ts +128 -0
- package/dist/core/bit-route.d.ts +21 -0
- package/dist/core/blob.d.ts +34 -0
- package/dist/core/budget.d.ts +68 -0
- package/dist/core/codec.d.ts +122 -0
- package/dist/core/concurrency.d.ts +15 -0
- package/dist/core/consistency.d.ts +73 -0
- package/dist/core/cooperative.d.ts +78 -0
- package/dist/core/cost.d.ts +254 -0
- package/dist/core/crbm/crc32c.d.ts +10 -0
- package/dist/core/crbm/format.d.ts +114 -0
- package/dist/core/crbm/reader.d.ts +86 -0
- package/dist/core/crbm/varint.d.ts +13 -0
- package/dist/core/crbm/writer.d.ts +39 -0
- package/dist/core/crbm-storage-source.d.ts +522 -0
- package/dist/core/crypto.d.ts +80 -0
- package/dist/core/determinism.d.ts +42 -0
- package/dist/core/discover.d.ts +77 -0
- package/dist/core/due-index.d.ts +53 -0
- package/dist/core/engine.d.ts +246 -0
- package/dist/core/erase-id.d.ts +176 -0
- package/dist/core/erasure.d.ts +185 -0
- package/dist/core/errors.d.ts +113 -0
- package/dist/core/generation-gc.d.ts +56 -0
- package/dist/core/keys.d.ts +23 -0
- package/dist/core/load.d.ts +121 -0
- package/dist/core/lru.d.ts +69 -0
- package/dist/core/metrics.d.ts +136 -0
- package/dist/core/name-codec.d.ts +69 -0
- package/dist/core/pinned-storage-source.d.ts +90 -0
- package/dist/core/ports.d.ts +423 -0
- package/dist/core/reader-defaults.d.ts +18 -0
- package/dist/core/registry-scan.d.ts +47 -0
- package/dist/core/retention-sweep.d.ts +198 -0
- package/dist/core/retention.d.ts +67 -0
- package/dist/core/retry.d.ts +69 -0
- package/dist/core/rollback.d.ts +75 -0
- package/dist/core/validate.d.ts +44 -0
- package/dist/driver-kit.d.ts +9 -0
- package/dist/driver-kit.js +275 -0
- package/dist/driver-kit.js.map +7 -0
- package/dist/drivers/_shared/keys.d.ts +26 -0
- package/dist/drivers/_shared/object-registry-keys.d.ts +28 -0
- package/dist/drivers/_shared/object-registry.d.ts +89 -0
- package/dist/drivers/_shared/registry.d.ts +66 -0
- package/dist/drivers/backends.d.ts +43 -0
- package/dist/drivers/crypto.d.ts +38 -0
- package/dist/drivers/localfs/fs-util.d.ts +21 -0
- package/dist/drivers/localfs/paths.d.ts +33 -0
- package/dist/drivers/localfs/registry.d.ts +28 -0
- package/dist/drivers/localfs/storage.d.ts +19 -0
- package/dist/drivers/memory.d.ts +49 -0
- package/dist/drivers/retry/retrying-chunk-source.d.ts +59 -0
- package/dist/export/index.d.ts +93 -0
- package/dist/index.d.ts +76 -2760
- package/dist/index.js +3527 -3133
- package/dist/index.js.map +7 -1
- package/dist/testing/fuzz-core.d.ts +14 -0
- package/package.json +19 -115
- package/dist/azure/index.cjs +0 -368
- package/dist/azure/index.cjs.map +0 -1
- package/dist/azure/index.d.cts +0 -48
- package/dist/azure/index.d.ts +0 -48
- package/dist/azure/index.js +0 -304
- package/dist/azure/index.js.map +0 -1
- package/dist/cassandra/index.cjs +0 -265
- package/dist/cassandra/index.cjs.map +0 -1
- package/dist/cassandra/index.d.cts +0 -52
- package/dist/cassandra/index.d.ts +0 -52
- package/dist/cassandra/index.js +0 -204
- package/dist/cassandra/index.js.map +0 -1
- package/dist/chunk-7LMLYSVJ.js +0 -43
- package/dist/chunk-7LMLYSVJ.js.map +0 -1
- package/dist/chunk-BD22WDAN.js +0 -230
- package/dist/chunk-BD22WDAN.js.map +0 -1
- package/dist/chunk-NUIDEEFZ.js +0 -91
- package/dist/chunk-NUIDEEFZ.js.map +0 -1
- package/dist/chunk-OZ6LAFV4.js +0 -6
- package/dist/chunk-OZ6LAFV4.js.map +0 -1
- package/dist/chunk-SNJVZ227.js +0 -35
- package/dist/chunk-SNJVZ227.js.map +0 -1
- package/dist/dynamodb/index.cjs +0 -767
- package/dist/dynamodb/index.cjs.map +0 -1
- package/dist/dynamodb/index.d.cts +0 -106
- package/dist/dynamodb/index.d.ts +0 -106
- package/dist/dynamodb/index.js +0 -469
- package/dist/dynamodb/index.js.map +0 -1
- package/dist/gcs/index.cjs +0 -343
- package/dist/gcs/index.cjs.map +0 -1
- package/dist/gcs/index.d.cts +0 -46
- package/dist/gcs/index.d.ts +0 -46
- package/dist/gcs/index.js +0 -279
- package/dist/gcs/index.js.map +0 -1
- package/dist/index.cjs +0 -4990
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -2761
- package/dist/mongodb/index.cjs +0 -260
- package/dist/mongodb/index.cjs.map +0 -1
- package/dist/mongodb/index.d.cts +0 -45
- package/dist/mongodb/index.d.ts +0 -45
- package/dist/mongodb/index.js +0 -199
- package/dist/mongodb/index.js.map +0 -1
- package/dist/mysql/index.cjs +0 -281
- package/dist/mysql/index.cjs.map +0 -1
- package/dist/mysql/index.d.cts +0 -56
- package/dist/mysql/index.d.ts +0 -56
- package/dist/mysql/index.js +0 -211
- package/dist/mysql/index.js.map +0 -1
- package/dist/ports-CvcvJWf6.d.cts +0 -395
- package/dist/ports-CvcvJWf6.d.ts +0 -395
- package/dist/postgres/index.cjs +0 -273
- package/dist/postgres/index.cjs.map +0 -1
- package/dist/postgres/index.d.cts +0 -54
- package/dist/postgres/index.d.ts +0 -54
- package/dist/postgres/index.js +0 -203
- package/dist/postgres/index.js.map +0 -1
- package/dist/redis/index.cjs +0 -257
- package/dist/redis/index.cjs.map +0 -1
- package/dist/redis/index.d.cts +0 -42
- package/dist/redis/index.d.ts +0 -42
- package/dist/redis/index.js +0 -197
- package/dist/redis/index.js.map +0 -1
- package/dist/s3/index.cjs +0 -918
- package/dist/s3/index.cjs.map +0 -1
- package/dist/s3/index.d.cts +0 -112
- package/dist/s3/index.d.ts +0 -112
- package/dist/s3/index.js +0 -555
- package/dist/s3/index.js.map +0 -1
|
@@ -0,0 +1,423 @@
|
|
|
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
|
+
/**
|
|
15
|
+
* The address of one segment: a `namespace` and a `segment` name, both any non-empty string of at most 256
|
|
16
|
+
* characters once encoded for storage. Omitting the namespace addresses a different segment from one that names it.
|
|
17
|
+
*
|
|
18
|
+
* **A namespace starting with `cbm.due.` is reserved**, and every call of the store, and of the free functions
|
|
19
|
+
* behind it, that takes a ref or a `namespace` option throws `ValidationError` for one. The library keeps the due
|
|
20
|
+
* index there (one pointer row per expiring segment), and every fleet-wide scan skips those rows as bookkeeping, so a
|
|
21
|
+
* segment in such a namespace would be invisible to an erasure, a consistency check, an export and a retention sweep.
|
|
22
|
+
* A driver takes the prefix, since the due index's own rows go through it. Only that exact prefix is reserved:
|
|
23
|
+
* `cbm.dueX` and `cbmdue.eu` are ordinary namespaces, and a segment name is never restricted.
|
|
24
|
+
*/
|
|
25
|
+
export interface SegmentRef {
|
|
26
|
+
readonly namespace?: string;
|
|
27
|
+
readonly segment: string;
|
|
28
|
+
}
|
|
29
|
+
export interface ChunkRef extends SegmentRef {
|
|
30
|
+
readonly chunkKey: number;
|
|
31
|
+
}
|
|
32
|
+
/** Identifies one immutable `.crbm` object — a single generation of a segment. */
|
|
33
|
+
export interface GenKey extends SegmentRef {
|
|
34
|
+
readonly generation: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A segment's grounded on-disk footprint — the current generation's Storage object bytes, read cheaply from the
|
|
38
|
+
* `.crbm` footer/index (no payload reads). Powers the grounded `costReport()`.
|
|
39
|
+
*/
|
|
40
|
+
export interface SegmentSize {
|
|
41
|
+
readonly sizeBytes: number;
|
|
42
|
+
}
|
|
43
|
+
/** Per-chunk read view of the immutable Storage tier (implemented by the `.crbm` reader). */
|
|
44
|
+
export interface StorageChunkSource {
|
|
45
|
+
/** Read-only bytes for the chunk, or `null` if absent. Callers must not mutate the buffer. */
|
|
46
|
+
getChunk(ref: ChunkRef): Promise<Uint8Array | null>;
|
|
47
|
+
listChunkKeys(ref: SegmentRef): Promise<number[]>;
|
|
48
|
+
/**
|
|
49
|
+
* Optional: the current generation's grounded size, cheaply (from the already-parsed `.crbm` index — no
|
|
50
|
+
* payload reads), or `null` if the segment has no Storage generation. Powers the grounded `costReport()`.
|
|
51
|
+
*/
|
|
52
|
+
sizeOf?(ref: SegmentRef): Promise<SegmentSize | null>;
|
|
53
|
+
/**
|
|
54
|
+
* Optional: how often this source re-reads a segment's pointer while the segment is being read, in ms, or 0 if
|
|
55
|
+
* it never does. The grounded `costReport()` prices the pointer refresh at this interval; a source that omits it
|
|
56
|
+
* is priced at the store's default.
|
|
57
|
+
*/
|
|
58
|
+
readonly pointerRefreshMs?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Optional: the current generation's **per-chunk cardinality** (`chunkKey → count`), read from the
|
|
61
|
+
* already-parsed `.crbm` index with **no payload reads**, or `null` if the segment has no Storage generation.
|
|
62
|
+
* Powers the free `count()` — the engine sums the index instead of fetching a single chunk. A source with no
|
|
63
|
+
* index (e.g. the in-memory source) omits this, and `count()` falls back to fetching every chunk.
|
|
64
|
+
*/
|
|
65
|
+
cardinalities?(ref: SegmentRef): Promise<ReadonlyMap<number, number> | null>;
|
|
66
|
+
/**
|
|
67
|
+
* Optional: the segment's **current generation number** as this source resolves it right now (registry
|
|
68
|
+
* `currentGen`, or the highest storage generation), or `null` if the segment has no Storage generation. The engine
|
|
69
|
+
* keys its chunk cache by this so a generation bump (a load's publish) is observed — a new generation
|
|
70
|
+
* misses the cache instead of serving a stale decoded chunk, and an erased id can't resurrect from a cached
|
|
71
|
+
* superseded chunk. Cheap: served from the source's own (short-TTL-refreshed) snapshot, **not** a fresh
|
|
72
|
+
* backend read per call. A source that only ever serves one immutable generation may omit this — the engine
|
|
73
|
+
* then keys the cache without a generation.
|
|
74
|
+
*/
|
|
75
|
+
currentGeneration?(ref: SegmentRef): Promise<number | null>;
|
|
76
|
+
/**
|
|
77
|
+
* Optional: forget everything this source has derived from `ref` — its resolved snapshot, its open reader,
|
|
78
|
+
* and any key material that reader captured — so the next read resolves from the registry again.
|
|
79
|
+
*
|
|
80
|
+
* The TTL refresh and the generation-keyed chunk cache are both built for **a publish that advances
|
|
81
|
+
* `currentGen`**. Neither covers an event that *destroys* what the cache was derived from: an erasure that
|
|
82
|
+
* deletes the generation holding the bit, a `dropSegment`, a crypto-shred, a retirement. After one of those
|
|
83
|
+
* a source that had already resolved the segment keeps answering from memory — with no backend read at all,
|
|
84
|
+
* so no storage-side control can close the window — until its TTL lapses, and with no bound at all if it has
|
|
85
|
+
* no registry, no clock or `cache.genTtlMs: 0`.
|
|
86
|
+
*
|
|
87
|
+
* Callers that destroy or retire a segment must call this. It is synchronous and best-effort: dropping
|
|
88
|
+
* memoized state cannot fail, and a source that memoizes nothing may omit the method entirely.
|
|
89
|
+
*/
|
|
90
|
+
invalidate?(ref: SegmentRef): void;
|
|
91
|
+
/**
|
|
92
|
+
* Optional: does this segment **exist as a registered name**, as distinct from resolving to no generation?
|
|
93
|
+
*
|
|
94
|
+
* `currentGeneration` collapses two very different states into `null`: a name nobody ever created, and a
|
|
95
|
+
* segment that exists and is legitimately empty. For a *read* that distinction does not matter — both answer
|
|
96
|
+
* empty. For an **operand of a combine** it is the difference between a suppression list with nobody on it
|
|
97
|
+
* yet and a suppression list you misspelled, and those must not look alike: the first correctly suppresses
|
|
98
|
+
* nothing, the second silently suppresses nothing while you believe it is working.
|
|
99
|
+
*
|
|
100
|
+
* Consulted **only** when an operand resolved to no chunks at all, so a normal combine never calls it.
|
|
101
|
+
*/
|
|
102
|
+
exists?(ref: SegmentRef): Promise<boolean>;
|
|
103
|
+
/**
|
|
104
|
+
* Optional: an opaque token identifying **which bytes** a read of this segment will see right now —
|
|
105
|
+
* the generation *and* the incarnation of the name it belongs to.
|
|
106
|
+
*
|
|
107
|
+
* {@link StorageChunkSource.currentGeneration} is not enough to key a decoded-chunk cache, and the gap is not
|
|
108
|
+
* theoretical: a load numbers its generation `max(currentGen, highest object) + 1`, so the number **restarts at 0** once
|
|
109
|
+
* a registry row is purged and the bucket emptied. A retired, re-created name therefore serves different
|
|
110
|
+
* data at the same `currentGen`, and a cache keyed on `(segment, chunk, generation)` hands back the previous
|
|
111
|
+
* incarnation's ids — an erased id reappearing, with no read to intercept.
|
|
112
|
+
*
|
|
113
|
+
* Note that putting the incarnation in the *object key* would not fix this: the new incarnation still starts
|
|
114
|
+
* at generation 0, so the cache key collides either way. The identity has to reach the cache.
|
|
115
|
+
*
|
|
116
|
+
* Returns `null` when the segment resolves to no generation. A source that only ever serves one immutable
|
|
117
|
+
* generation may omit this, and the engine falls back to the generation alone.
|
|
118
|
+
*/
|
|
119
|
+
currentVersion?(ref: SegmentRef): Promise<string | null>;
|
|
120
|
+
}
|
|
121
|
+
/** Capabilities a Storage driver advertises; validated at wiring time, fail-fast. */
|
|
122
|
+
export interface StorageCaps {
|
|
123
|
+
/** REQUIRED — the format relies on byte-range reads. */
|
|
124
|
+
readonly rangeRead: true;
|
|
125
|
+
/** Largest single object the backend accepts (informs single-object-vs-shard — future). */
|
|
126
|
+
readonly maxObjectBytes: number;
|
|
127
|
+
/** Optional: enables the pure-object `LATEST`-pointer registry variant (S3 conditional put). */
|
|
128
|
+
readonly conditionalPut?: boolean;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Immutable object storage for `.crbm` generations. A "dumb byte mover": it
|
|
132
|
+
* understands neither roaring nor the `.crbm` layout, only opaque bytes addressed by a {@link GenKey}.
|
|
133
|
+
* The core never reuses a key, so puts are write-once.
|
|
134
|
+
*
|
|
135
|
+
* **What a driver must do** — callers rely on each of these, and the storage conformance suite holds every
|
|
136
|
+
* shipped driver to them:
|
|
137
|
+
*
|
|
138
|
+
* - **`putImmutable` is write-once and reports a collision as {@link WriteConflictError}.** It never overwrites a
|
|
139
|
+
* stored object. `load` reads that error as a lost race for the generation number (`superseded`), so a driver
|
|
140
|
+
* that threw anything else for an existing key would surface as a failed load.
|
|
141
|
+
* - **A missing object makes `getRange` and `getTail` throw {@link NotFoundError}** — never an empty or short
|
|
142
|
+
* result. Heal-forward (a read whose generation a sweep collected re-resolves), the erasure's holder probe and
|
|
143
|
+
* its verify of the rewrite, and a pin's replaced-object check all branch on that one error. (A zero-length
|
|
144
|
+
* `getRange` may answer empty without reaching the backend, so it proves neither that the object exists nor
|
|
145
|
+
* that the offset lies inside it: the cloud drivers answer it empty, the memory and local-filesystem drivers
|
|
146
|
+
* throw for a missing object or an offset past the end.)
|
|
147
|
+
* - **An out-of-range read throws {@link ValidationError}**: a range that runs past the end of the object, or a
|
|
148
|
+
* negative or non-integer offset or length. Never a clamped, short or adjacent read. A pin whose object
|
|
149
|
+
* was purged and loaded again as a smaller one asks for a range past its end, and this error is what sends it
|
|
150
|
+
* to check that the object is still the one it pinned.
|
|
151
|
+
* - **`getTail` reports the object's true total size**, not the number of bytes it returned.
|
|
152
|
+
* - **`delete` is idempotent.** Deleting an absent key is a no-op, not an error: collection passes race each
|
|
153
|
+
* other and retry.
|
|
154
|
+
* - **`list` is strongly consistent, read-after-delete.** Once `delete` has resolved, `list` no longer yields
|
|
155
|
+
* that generation, and once `putImmutable` has resolved it does. The erasure's re-check for a generation still
|
|
156
|
+
* holding the id, `dropSegment`'s `generationsRemaining`, the retention sweep's check that a tombstone's storage
|
|
157
|
+
* is gone, and rollback's post-move check prove a deletion or a presence by listing, so a listing that lags
|
|
158
|
+
* reports an erasure as incomplete or a stale object as gone.
|
|
159
|
+
* - **Never replay a conditional write without telling the replay apart.** A `putImmutable` that lands and then
|
|
160
|
+
* loses its response, sent again, finds its own object and would report a collision. Either send each write
|
|
161
|
+
* once, with the backend client's retry off for that request, or recognise your own write when the
|
|
162
|
+
* precondition fails by reading an id you stored with it back, and treat a match as success. The first is
|
|
163
|
+
* what the S3 driver and the single-request GCS upload do; the second is what the Azure Blob driver and the
|
|
164
|
+
* resumable GCS upload do.
|
|
165
|
+
* - **Raise a transient fault as {@link TransientError}** (throttling, a 5xx, a dropped connection), so the read
|
|
166
|
+
* retry can ride it out and a write's caller can tell it from a deterministic failure.
|
|
167
|
+
*/
|
|
168
|
+
export interface IStorageDriver {
|
|
169
|
+
capabilities(): StorageCaps;
|
|
170
|
+
/**
|
|
171
|
+
* Stream a new immutable generation. The driver opens a destination, hands `write` a {@link BlobSink},
|
|
172
|
+
* then atomically commits (and computes the content hash). Throws {@link WriteConflictError} if the key
|
|
173
|
+
* already exists (write-once).
|
|
174
|
+
*/
|
|
175
|
+
putImmutable(key: GenKey, write: (sink: BlobSink) => Promise<void>): Promise<{
|
|
176
|
+
size: number;
|
|
177
|
+
sha256: string;
|
|
178
|
+
}>;
|
|
179
|
+
/**
|
|
180
|
+
* Range read; the caller bounds-checks. Out-of-range throws {@link ValidationError}, never a short/adjacent
|
|
181
|
+
* read, and a missing object throws {@link NotFoundError}.
|
|
182
|
+
*/
|
|
183
|
+
getRange(key: GenKey, offset: number, length: number): Promise<Uint8Array>;
|
|
184
|
+
/**
|
|
185
|
+
* Speculative tail read: the last `min(maxBytes, size)` bytes + the object's **true total size**. A missing
|
|
186
|
+
* object throws {@link NotFoundError}.
|
|
187
|
+
*/
|
|
188
|
+
getTail(key: GenKey, maxBytes: number): Promise<{
|
|
189
|
+
bytes: Uint8Array;
|
|
190
|
+
size: number;
|
|
191
|
+
}>;
|
|
192
|
+
/** Idempotent: deleting an absent key is a no-op, not an error. */
|
|
193
|
+
delete(key: GenKey): Promise<void>;
|
|
194
|
+
/**
|
|
195
|
+
* Enumerate the generations present for a segment (orphan sweep / latest-gen resolution). Strongly
|
|
196
|
+
* consistent, read-after-delete and read-after-put.
|
|
197
|
+
*/
|
|
198
|
+
list(ref: SegmentRef): AsyncIterable<GenKey>;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Lifecycle status of a segment. `active` is the steady state; `destroyed` is the post-crypto-shred tombstone
|
|
202
|
+
* (the row is kept for audit but the segment is logically gone).
|
|
203
|
+
*/
|
|
204
|
+
export type RegistryStatus = 'active' | 'destroyed';
|
|
205
|
+
/**
|
|
206
|
+
* Free-form, JSON-serializable governance metadata — a **plain object** (both registry boundaries reject `null`,
|
|
207
|
+
* an array or a primitive, because a reader that tests `'key' in meta` would throw an untyped `TypeError`).
|
|
208
|
+
*
|
|
209
|
+
* `retention` has one **reserved key with defined semantics**: `expiresAt`, an absolute epoch-ms after which a
|
|
210
|
+
* retention sweep may retire the segment — written by `setSegmentRetention`, parsed by `readRetentionPolicy`. Any
|
|
211
|
+
* other key is yours (a legal hold, a note, an owner) and is preserved across policy writes. `residency` is still
|
|
212
|
+
* shape-later: stored and round-tripped, no semantics attached.
|
|
213
|
+
*/
|
|
214
|
+
export type GovernanceMeta = Record<string, unknown>;
|
|
215
|
+
/**
|
|
216
|
+
* One registry row — the authoritative per-segment record. Exactly one per segment.
|
|
217
|
+
*/
|
|
218
|
+
export interface RegistryRecord extends SegmentRef {
|
|
219
|
+
/**
|
|
220
|
+
* **The** authoritative LATEST pointer: which immutable Storage generation is current — or **`null` for a segment
|
|
221
|
+
* that has no Storage generation yet.**
|
|
222
|
+
*
|
|
223
|
+
* `null` is not "unknown", it is a positive statement: *this segment exists and has no Storage data.* It is what
|
|
224
|
+
* lets a segment have a registry row **before its first load** — `setSegmentRetention` mints one so a policy
|
|
225
|
+
* can be recorded ahead of the data, and so the segment is reachable by `registry.list()` and therefore by
|
|
226
|
+
* retention sweeps, `checkConsistency` and every other fleet-wide operation. The alternative — a row with
|
|
227
|
+
* `currentGen: 0` and no object behind it — is the forbidden `missing-storage-generation` state, which fails per
|
|
228
|
+
* read with `NotFoundError`. Generation resolution maps `null` onto the same path a segment with **no row**
|
|
229
|
+
* takes: every read answers empty, and the first publish advances the pointer.
|
|
230
|
+
*/
|
|
231
|
+
readonly currentGen: number | null;
|
|
232
|
+
/**
|
|
233
|
+
* Per-segment data-key (DEK) wrappings for encryption-at-rest: the DEK envelope-wrapped under one or more KEKs
|
|
234
|
+
* (active + optional recovery). Reading unwraps with any held KEK; **crypto-shred deletes this whole list**,
|
|
235
|
+
* making the segment's at-rest bytes permanently unrecoverable. Absent ⇒ the segment is cleartext.
|
|
236
|
+
*/
|
|
237
|
+
readonly wrappedDeks?: readonly WrappedDek[];
|
|
238
|
+
/**
|
|
239
|
+
* Optional **external** key reference (reserved) — e.g. a KMS key ARN for a future KMS keystore adapter that
|
|
240
|
+
* keeps wrapped material in the KMS rather than in-band {@link wrappedDeks}. Unused by the in-process BYOK
|
|
241
|
+
* keystore. Clearable on crypto-shred.
|
|
242
|
+
*/
|
|
243
|
+
readonly keyId?: string;
|
|
244
|
+
readonly status: RegistryStatus;
|
|
245
|
+
/**
|
|
246
|
+
* Governance policy. `retention.expiresAt` drives the retention sweep (see {@link GovernanceMeta}); `residency`
|
|
247
|
+
* is stored and round-tripped with no semantics yet. Both must be plain objects, and both must survive a
|
|
248
|
+
* `list()` projection — a fleet sweep reads the policy from the enumeration rather than per-segment.
|
|
249
|
+
*/
|
|
250
|
+
readonly retention?: GovernanceMeta;
|
|
251
|
+
readonly residency?: GovernanceMeta;
|
|
252
|
+
/** Epoch-ms of creation / last mutation (from the driver's injected clock). */
|
|
253
|
+
readonly createdAt: number;
|
|
254
|
+
readonly updatedAt: number;
|
|
255
|
+
/** Opaque OCC token — compare-by-equality, never reused (ABA-safe). */
|
|
256
|
+
readonly token: Token;
|
|
257
|
+
}
|
|
258
|
+
/** The caller-settable fields at {@link IRegistryDriver.create} (audit + token are driver-managed). */
|
|
259
|
+
export interface NewRegistryRecord {
|
|
260
|
+
/** `null` ⇒ the segment has no Storage generation yet — see {@link RegistryRecord.currentGen}. */
|
|
261
|
+
readonly currentGen: number | null;
|
|
262
|
+
readonly wrappedDeks?: readonly WrappedDek[];
|
|
263
|
+
readonly keyId?: string;
|
|
264
|
+
/** Defaults to `'active'`. */
|
|
265
|
+
readonly status?: RegistryStatus;
|
|
266
|
+
readonly retention?: GovernanceMeta;
|
|
267
|
+
readonly residency?: GovernanceMeta;
|
|
268
|
+
}
|
|
269
|
+
/** Fields a {@link IRegistryDriver.compareAndSwap} may mutate (identity + audit + token are off-limits). */
|
|
270
|
+
export type RegistryPatch = Partial<Pick<RegistryRecord, 'currentGen' | 'wrappedDeks' | 'keyId' | 'status' | 'retention' | 'residency'>>;
|
|
271
|
+
/** Capabilities a registry driver advertises; validated fail-fast at wiring time. */
|
|
272
|
+
export interface RegCaps {
|
|
273
|
+
/** REQUIRED — `currentGen` feeds read correctness + the publish CAS, so reads must be strongly consistent. */
|
|
274
|
+
readonly strongRead: true;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Brand for {@link StorageBackend}.
|
|
278
|
+
*
|
|
279
|
+
* `Symbol.for` rather than a class check, for the same reason the error brands are: a class object is only
|
|
280
|
+
* shared while one copy of core is loaded, so a backend built from one copy and checked by another — after a
|
|
281
|
+
* version skew, or behind a duplicating bundler — would fail an `instanceof`. A registered symbol is
|
|
282
|
+
* identity-stable across all of them, and across realms.
|
|
283
|
+
*
|
|
284
|
+
* A brand string is part of that identity contract, so once a release carries one it never changes: a changed
|
|
285
|
+
* string would stop matching the copies already installed.
|
|
286
|
+
*/
|
|
287
|
+
declare const STORAGE_BACKEND_BRAND: unique symbol;
|
|
288
|
+
/**
|
|
289
|
+
* Where a store keeps everything: the generations, and the pointer saying which one is current.
|
|
290
|
+
*
|
|
291
|
+
* **Branded on purpose.** The two fields alone are not enough to qualify, and that is the whole point. This
|
|
292
|
+
* shape is also {@link LoadDeps}, so before the brand any `{ storage, registry }`
|
|
293
|
+
* object literal satisfied it — including one assembling halves from two *unrelated* stores, which the store
|
|
294
|
+
* accepted and then answered **empty** for a segment that holds data, because it read a pointer from a place
|
|
295
|
+
* nothing had ever been written. That is the exact silent-empty failure one-class-per-backend exists to
|
|
296
|
+
* remove, and it was reachable in five lines.
|
|
297
|
+
*
|
|
298
|
+
* So a backend comes from `MemoryStorage`, `LocalFsStorage`, `S3Storage`, `GcsStorage` or
|
|
299
|
+
* `AzureBlobStorage` — each deriving both halves from one bucket and one prefix. A driver author composing halves
|
|
300
|
+
* of their own says so on purpose, with {@link brandAsBackend}. The halves stay readable as `.storage` /
|
|
301
|
+
* `.registry`, because the free functions genuinely need them.
|
|
302
|
+
*
|
|
303
|
+
* **What this stops is the accident, not a determined caller.** The brand is a registered symbol, so it can
|
|
304
|
+
* be written by hand; nothing here is a security boundary. It stops the two spellings people reach for
|
|
305
|
+
* without deciding to — an object literal, and a spread of a real backend.
|
|
306
|
+
*/
|
|
307
|
+
export interface StorageBackend {
|
|
308
|
+
/** Cross-bundle brand. Set by the backend classes; not part of the public surface. */
|
|
309
|
+
readonly [STORAGE_BACKEND_BRAND]: true;
|
|
310
|
+
/** Where the immutable `.crbm` generations live. */
|
|
311
|
+
readonly storage: IStorageDriver;
|
|
312
|
+
/** Where the `currentGen` pointer, the discovery index and the wrapped DEKs live. */
|
|
313
|
+
readonly registry: IRegistryDriver;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* The brand key, for the type-only `declare` field a backend class carries.
|
|
317
|
+
*
|
|
318
|
+
* Exported from `@cloudbitmaps/core/driver-kit` and part of the driver contract: a third-party storage
|
|
319
|
+
* package needs it to declare the field that {@link brandAsBackend} then stamps at runtime.
|
|
320
|
+
*/
|
|
321
|
+
export declare const STORAGE_BACKEND: typeof STORAGE_BACKEND_BRAND;
|
|
322
|
+
/**
|
|
323
|
+
* Stamp the brand on a backend, **non-enumerably**, after checking both halves are drivers. Returns `target`.
|
|
324
|
+
*
|
|
325
|
+
* This is the driver author's door, and it works on a backend class (`brandAsBackend(this)` as the last line of
|
|
326
|
+
* its constructor) and on a plain `{ storage, registry }` object alike — the latter is how a backend of your
|
|
327
|
+
* own halves is built: a driver wrapped for auditing, metrics, tenant scoping or client-side encryption, or
|
|
328
|
+
* paired with a registry in a database you already run. An application does not need it: it gets both halves
|
|
329
|
+
* from one backend class, which derives them from one bucket and one prefix so they cannot disagree.
|
|
330
|
+
*
|
|
331
|
+
* It throws {@link ValidationError} when `storage` has no `putImmutable` or `registry` has no `compareAndSwap`,
|
|
332
|
+
* so a half that is not a driver is refused here rather than at its first write, and when `target` is frozen or
|
|
333
|
+
* non-extensible, since the brand is stamped on it in place.
|
|
334
|
+
*
|
|
335
|
+
* It cannot check that the halves agree: `IStorageDriver` and `IRegistryDriver` do not expose a location, so
|
|
336
|
+
* nothing here can compare one. Branding two unrelated halves is you taking that on — the plain
|
|
337
|
+
* `{ storage, registry }` literal is refused by the store precisely because it let the two halves come from
|
|
338
|
+
* unrelated places by accident, and answer **empty** rather than fail.
|
|
339
|
+
*
|
|
340
|
+
* Non-enumerable is load-bearing, not tidiness. A class field (`readonly [BRAND] = true`) emits an
|
|
341
|
+
* *enumerable* own property, and spread and `Object.assign` copy exactly those — so
|
|
342
|
+
* `{ ...backend, registry: other.registry }` carried the brand and was accepted, reproducing the silent-empty
|
|
343
|
+
* bug the brand exists to close. This does not make the brand unforgeable — the symbol is registered, so anyone
|
|
344
|
+
* who wants it can write `Symbol.for('cloudbitmaps.storage-backend')`. That is deliberate effort equivalent to
|
|
345
|
+
* calling this function, and it is not what the check is for. The check is for the accident.
|
|
346
|
+
*/
|
|
347
|
+
export declare function brandAsBackend<T extends {
|
|
348
|
+
storage: IStorageDriver;
|
|
349
|
+
registry: IRegistryDriver;
|
|
350
|
+
}>(target: T): T & StorageBackend;
|
|
351
|
+
/**
|
|
352
|
+
* Is this a branded backend — from a backend class or from {@link brandAsBackend}?
|
|
353
|
+
*
|
|
354
|
+
* Checks the brand, not the shape — see {@link StorageBackend} for why the shape alone is not enough.
|
|
355
|
+
*/
|
|
356
|
+
export declare function isStorageBackend(value: unknown): value is StorageBackend;
|
|
357
|
+
/**
|
|
358
|
+
* Per-segment registry — the authoritative source of `currentGen`, the discovery index, and the wrapped-DEK
|
|
359
|
+
* holder. One row per segment under OCC: a never-reused, equality-compared {@link Token} (ABA-safe across
|
|
360
|
+
* delete→recreate).
|
|
361
|
+
*
|
|
362
|
+
* **What a driver must do** — callers rely on each of these, and the registry conformance suite holds every
|
|
363
|
+
* shipped driver to them:
|
|
364
|
+
*
|
|
365
|
+
* - **`create` and `compareAndSwap` are atomic conditional writes.** A `create` over a live row, and a
|
|
366
|
+
* `compareAndSwap` whose token is not the stored one, throw {@link WriteConflictError} and change nothing.
|
|
367
|
+
* Two racing writers have exactly one winner.
|
|
368
|
+
* - **Tokens are never reused**, not even across `delete` then `create`: a delete leaves a tombstone (or a
|
|
369
|
+
* global counter) so a recreated row always carries a fresh token and a stale holder cannot swap into it.
|
|
370
|
+
* - **`delete` is idempotent** without an `expected` token: deleting an absent row is a no-op, not an error.
|
|
371
|
+
* **With `expected` it is fenced**: it lands only while the row still carries that token, and otherwise throws
|
|
372
|
+
* {@link WriteConflictError} and leaves the row.
|
|
373
|
+
* - **Reads are strongly consistent** (`RegCaps.strongRead`), and `list` yields every existing row,
|
|
374
|
+
* `destroyed` tombstones included, with every field (see {@link IRegistryDriver.list}).
|
|
375
|
+
* - **Never replay a conditional write without telling the replay apart.** A `create` or `compareAndSwap`
|
|
376
|
+
* that lands and then loses its response, sent again, finds its own write and would report a conflict.
|
|
377
|
+
* Either send each write once, with the backend client's retry off for that request, or recognise your own
|
|
378
|
+
* write when the precondition fails by reading an id you stored with it back. The shipped registries do
|
|
379
|
+
* one or the other.
|
|
380
|
+
* - **Raise a transient fault as {@link TransientError}.**
|
|
381
|
+
*/
|
|
382
|
+
export interface IRegistryDriver {
|
|
383
|
+
capabilities(): RegCaps;
|
|
384
|
+
/**
|
|
385
|
+
* The segment's record, or `null` if it doesn't exist (or was deleted). Throw {@link TransientError} for a fault
|
|
386
|
+
* worth riding out (throttling, a 5xx, a dropped connection): a reader whose pointer refresh meets one keeps
|
|
387
|
+
* serving what it holds and asks again shortly, while any other error reaches the read that met it.
|
|
388
|
+
*/
|
|
389
|
+
get(ref: SegmentRef): Promise<RegistryRecord | null>;
|
|
390
|
+
/** Create the row; throws {@link WriteConflictError} if it already exists (use CAS to mutate). */
|
|
391
|
+
create(ref: SegmentRef, record: NewRegistryRecord): Promise<{
|
|
392
|
+
token: Token;
|
|
393
|
+
}>;
|
|
394
|
+
/** Server-side compare-and-set: apply `patch` iff the stored token equals `expected`, else `WriteConflictError`. */
|
|
395
|
+
compareAndSwap(ref: SegmentRef, expected: Token, patch: RegistryPatch): Promise<{
|
|
396
|
+
token: Token;
|
|
397
|
+
}>;
|
|
398
|
+
/**
|
|
399
|
+
* Discovery: every **existing** record, optionally scoped to one namespace. Order is unspecified.
|
|
400
|
+
*
|
|
401
|
+
* "Existing" means not `delete`d. A **`destroyed` tombstone is still a record and must be yielded** — a driver
|
|
402
|
+
* that filters by `status` breaks callers silently, and two already depend on seeing them: `runConsistencyCheck`
|
|
403
|
+
* skips them itself, and the retention sweep can only clean up a tombstone row it can see (filtering it makes
|
|
404
|
+
* the cleanup a permanent no-op, indistinguishable from having nothing to do, while dead rows accumulate). Same
|
|
405
|
+
* rule for every other field: a row with **`currentGen: null`** must be yielded like any other, and `retention`
|
|
406
|
+
* must survive the projection — a fleet sweep reads the policy straight out of this enumeration rather than
|
|
407
|
+
* paying a `get()` per segment, so a `list()` that drops the field means nothing ever expires, silently.
|
|
408
|
+
*/
|
|
409
|
+
list(namespace?: string): AsyncIterable<RegistryRecord>;
|
|
410
|
+
/**
|
|
411
|
+
* Remove the row (tombstoned for ABA-safety — a later `create` still gets a fresh, greater token).
|
|
412
|
+
*
|
|
413
|
+
* Without `expected`, idempotent: deleting an absent row is a no-op.
|
|
414
|
+
*
|
|
415
|
+
* With `expected`, the delete is **fenced like a {@link IRegistryDriver.compareAndSwap}**: it lands only while
|
|
416
|
+
* the row still carries exactly that token, and otherwise — the token differs, or the row is absent or already
|
|
417
|
+
* deleted — throws {@link WriteConflictError} and leaves the row. Pass the token of the row you read when you
|
|
418
|
+
* decided to delete it, so a delete that is delayed, replayed, or racing a re-create cannot tombstone a row
|
|
419
|
+
* created after the decision. A third-party driver that ignores `expected` keeps the unfenced behaviour.
|
|
420
|
+
*/
|
|
421
|
+
delete(ref: SegmentRef, expected?: Token): Promise<void>;
|
|
422
|
+
}
|
|
423
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storage reader's defaults, stated once for the two modules that need them: the reader, which applies them,
|
|
3
|
+
* and the cost model, which prices what they cause. The cost model once imported them from the reader itself,
|
|
4
|
+
* which pulled the whole read path into a module that is otherwise arithmetic.
|
|
5
|
+
*/
|
|
6
|
+
/** Default TTL (ms) for re-resolving a segment's `currentGen` — the bound on post-publish read staleness. */
|
|
7
|
+
export declare const DEFAULT_CURRENT_GEN_TTL_MS = 2000;
|
|
8
|
+
/**
|
|
9
|
+
* How long (ms) a reader keeps serving after a pointer refresh failed transiently, before the next read asks the
|
|
10
|
+
* registry again. Short, so an outage ends the stale serving soon after the registry answers; fixed, so a registry
|
|
11
|
+
* that is down is asked at most once per this interval per segment being read (the refresh is coalesced per
|
|
12
|
+
* segment). Never longer than the TTL itself.
|
|
13
|
+
*/
|
|
14
|
+
export declare const REFRESH_RETRY_MS = 500;
|
|
15
|
+
/** Default ceiling on cached segment readers (each holds a parsed `.crbm` index) — the steady-state count bound. */
|
|
16
|
+
export declare const DEFAULT_MAX_OPEN_SEGMENTS = 1024;
|
|
17
|
+
/** Default aggregate ceiling (bytes) on resident parsed indices in the reader cache — the steady-state byte bound. */
|
|
18
|
+
export declare const DEFAULT_MAX_OPEN_INDEX_BYTES: number;
|
|
@@ -0,0 +1,47 @@
|
|
|
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. Pushing
|
|
13
|
+
* first and then throwing would leave the very row that broke the budget resident, and a caller sizing a container
|
|
14
|
+
* against `maxScanSegments` 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
|
+
/**
|
|
21
|
+
* How a caller of `op` raises the ceiling, completing "Narrow it with `namespace`, or …" in the refusal.
|
|
22
|
+
* Defaults to raising `maxScanSegments`, which is right wherever `op` takes that option itself.
|
|
23
|
+
*/
|
|
24
|
+
raise?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Whether the caller could scope the scan down to a `namespace`. Defaults to true; `eraseNamespace` is already
|
|
27
|
+
* one namespace, so it passes false and the refusal does not tell it to narrow what it cannot.
|
|
28
|
+
*/
|
|
29
|
+
narrowable?: boolean;
|
|
30
|
+
}): Promise<RegistryRecord[]>;
|
|
31
|
+
/**
|
|
32
|
+
* Is this record **bookkeeping** rather than a segment? Covers every reserved family — today the due-index
|
|
33
|
+
* pointers. **Every unscoped fleet-wide enumeration must skip these**: the bounded drain,
|
|
34
|
+
* the export/eject scan, and the all-namespaces GDPR paths.
|
|
35
|
+
*
|
|
36
|
+
* This is the ONE place a reserved family is declared, and a new one belongs here rather than at the call
|
|
37
|
+
* sites. A comparison inlined per site is easy to miss at one of them — at `subjectReport`, the rows would consume
|
|
38
|
+
* an Art. 15 request's per-op budget. A filter you have to remember at each site is a check that cannot fire.
|
|
39
|
+
*/
|
|
40
|
+
export declare function isReservedRow(record: Pick<RegistryRecord, 'namespace'>): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Filter bookkeeping rows out of a registry listing **before** anything downstream pays for them. Needed where
|
|
43
|
+
* the enumeration is consumed inside a budgeted collector rather than a plain loop: charging a GDPR Art. 15
|
|
44
|
+
* request's per-op budget for pointer rows can refuse a subject report for a reason that has nothing to do with
|
|
45
|
+
* the subject.
|
|
46
|
+
*/
|
|
47
|
+
export declare function excludingReservedRows(source: AsyncIterable<RegistryRecord>): AsyncIterable<RegistryRecord>;
|