@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.
- package/NOTICE +4 -4
- package/README.md +28 -6
- package/dist/chunk-BUPDGZRR.js +461 -0
- package/dist/chunk-BUPDGZRR.js.map +7 -0
- package/dist/core/audit.d.ts +127 -0
- package/dist/core/bit-route.d.ts +21 -0
- package/dist/core/blob.d.ts +34 -0
- package/dist/core/budget.d.ts +66 -0
- package/dist/core/codec.d.ts +114 -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 +102 -0
- package/dist/core/crbm/crc32c.d.ts +10 -0
- package/dist/core/crbm/format.d.ts +116 -0
- package/dist/core/crbm/reader.d.ts +69 -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 +432 -0
- package/dist/core/crypto.d.ts +80 -0
- package/dist/core/determinism.d.ts +42 -0
- package/dist/core/discover.d.ts +75 -0
- package/dist/core/due-index.d.ts +55 -0
- package/dist/core/engine.d.ts +204 -0
- package/dist/core/erase-id.d.ts +141 -0
- package/dist/core/erasure.d.ts +179 -0
- package/dist/core/errors.d.ts +120 -0
- package/dist/core/generation-gc.d.ts +52 -0
- package/dist/core/keys.d.ts +23 -0
- package/dist/core/load.d.ts +102 -0
- package/dist/core/lru.d.ts +69 -0
- package/dist/core/metrics.d.ts +132 -0
- package/dist/core/name-codec.d.ts +79 -0
- package/dist/core/pinned-storage-source.d.ts +69 -0
- package/dist/core/ports.d.ts +340 -0
- package/dist/core/registry-scan.d.ts +38 -0
- package/dist/core/retention-sweep.d.ts +190 -0
- package/dist/core/retention.d.ts +67 -0
- package/dist/core/retry.d.ts +65 -0
- package/dist/core/rollback.d.ts +71 -0
- package/dist/core/validate.d.ts +20 -0
- package/dist/driver-kit.d.ts +10 -0
- package/dist/driver-kit.js +314 -0
- package/dist/driver-kit.js.map +7 -0
- package/dist/drivers/_shared/aws-errors.d.ts +18 -0
- package/dist/drivers/_shared/keys.d.ts +27 -0
- package/dist/drivers/_shared/object-registry-keys.d.ts +28 -0
- package/dist/drivers/_shared/object-registry.d.ts +84 -0
- package/dist/drivers/_shared/registry.d.ts +62 -0
- package/dist/drivers/backends.d.ts +49 -0
- package/dist/drivers/crypto.d.ts +38 -0
- package/dist/drivers/localfs/fs-util.d.ts +20 -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 +57 -0
- package/dist/drivers/retry/retrying-drivers.d.ts +90 -0
- package/dist/export/index.d.ts +92 -0
- package/dist/index.d.ts +85 -2534
- package/dist/index.js +3076 -2978
- 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-L54FL5DW.js +0 -203
- package/dist/chunk-L54FL5DW.js.map +0 -1
- package/dist/chunk-NUIDEEFZ.js +0 -91
- package/dist/chunk-NUIDEEFZ.js.map +0 -1
- package/dist/chunk-SNJVZ227.js +0 -35
- package/dist/chunk-SNJVZ227.js.map +0 -1
- package/dist/chunk-YEHMG6W2.js +0 -6
- package/dist/chunk-YEHMG6W2.js.map +0 -1
- package/dist/dynamodb/index.cjs +0 -740
- 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 -4608
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -2535
- 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-5BQTGKze.d.cts +0 -356
- package/dist/ports-5BQTGKze.d.ts +0 -356
- 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 -891
- 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,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridges the `.crbm` archive format to the engine's Storage seam.
|
|
3
|
+
*
|
|
4
|
+
* `CrbmStorageChunkSource` implements the {@link StorageChunkSource} the engine reads through, over an
|
|
5
|
+
* {@link IStorageDriver}: it resolves a segment's current generation, opens its {@link CrbmReader} once, and serves
|
|
6
|
+
* per-chunk payloads. `writeCrbmGeneration` / `writeCrbmGenerationStream` are the write primitives (a generation
|
|
7
|
+
* built from bitmaps, in memory or streamed), `bulkLoadCrbmGeneration` is the load path over them, and
|
|
8
|
+
* `publishGeneration` is the forward-only pointer advance every write ends with.
|
|
9
|
+
*/
|
|
10
|
+
import { type IAuditSink } from './audit.js';
|
|
11
|
+
import type { Yielder } from './cooperative.js';
|
|
12
|
+
import type { Clock } from './determinism.js';
|
|
13
|
+
import type { CrbmCrypto, IKeystore, WrappedDek } from './crypto.js';
|
|
14
|
+
import type { ChunkRef, StorageChunkSource, GenKey, IStorageDriver, IRegistryDriver, SegmentRef, SegmentSize, Token } from './ports.js';
|
|
15
|
+
import { CrbmReader } from './crbm/reader.js';
|
|
16
|
+
import type { CrbmReaderOptions } from './crbm/reader.js';
|
|
17
|
+
import type { CodecBitmap, CodecInterface } from './codec.js';
|
|
18
|
+
export interface CrbmStorageChunkSourceOptions extends CrbmReaderOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Optional {@link IRegistryDriver}. When provided, the current generation is resolved via the registry's
|
|
21
|
+
* authoritative `currentGen` (one cheap strong read) instead of a `list` scan of every generation — the
|
|
22
|
+
* retirement of that scan. When absent, the source falls back to the `list`-scan (so the
|
|
23
|
+
* in-memory / simple setups keep working with no registry). The resolved generation is cached and
|
|
24
|
+
* **re-resolved on a short TTL** ({@link currentGenTtlMs}, needs a {@link clock}) so a long-lived source
|
|
25
|
+
* observes a load's new generation within the TTL instead of pinning one generation forever.
|
|
26
|
+
*/
|
|
27
|
+
readonly registry?: IRegistryDriver;
|
|
28
|
+
/**
|
|
29
|
+
* Optional {@link IKeystore} for reading **encrypted** segments. When a segment's registry record
|
|
30
|
+
* carries wrapped DEK(s), the source unwraps the DEK via this keystore and decrypts each chunk/index. Reading
|
|
31
|
+
* an encrypted segment without a keystore throws {@link KeyUnavailableError}; cleartext segments ignore it.
|
|
32
|
+
* Requires a `registry` (that's where the wrapped DEKs live).
|
|
33
|
+
*/
|
|
34
|
+
readonly keystore?: IKeystore;
|
|
35
|
+
/**
|
|
36
|
+
* Enforce that every segment read is encrypted. When true, resolving a **cleartext** segment (no
|
|
37
|
+
* wrapped DEKs) throws {@link KeyUnavailableError} — a guard against silently reading a segment that should
|
|
38
|
+
* have been encrypted. Off by default (encryption is opt-in).
|
|
39
|
+
*/
|
|
40
|
+
readonly requireEncryption?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Time source for the current-generation TTL refresh — the determinism seam; `core/` never reads
|
|
43
|
+
* ambient time. Refresh needs **both** a clock and a `registry`; without either the source **pins** the
|
|
44
|
+
* first-resolved generation for its lifetime (the behaviour before the registry was wired in). The `CloudRoaring` facade passes its
|
|
45
|
+
* clock automatically, so wiring a `registry` is enough to get live cross-generation invalidation.
|
|
46
|
+
*/
|
|
47
|
+
readonly clock?: Pick<Clock, 'now'>;
|
|
48
|
+
/**
|
|
49
|
+
* How long (ms) a resolved `currentGen` is trusted before the next read re-resolves it (default 2000) — the
|
|
50
|
+
* bound on read staleness after a load publishes: a reader may serve the prior generation for up to this long,
|
|
51
|
+
* then converges. Lazy (checked on read — no timer); ≤ one cheap registry read per segment per window, and a
|
|
52
|
+
* new {@link CrbmReader} is opened only when the generation actually changed. `0` (or no clock) ⇒ pin forever.
|
|
53
|
+
*/
|
|
54
|
+
readonly currentGenTtlMs?: number;
|
|
55
|
+
/**
|
|
56
|
+
* Hard ceiling on how many segments' readers (each holding a fully-parsed `.crbm` index) are cached at once
|
|
57
|
+
* (default 1024) — a memory bound for a long-running server. Past it, the least-recently-used
|
|
58
|
+
* segment's reader is evicted; the next read of an evicted segment re-opens it (one cheap tail GET, since
|
|
59
|
+
* generations are immutable). Raise it for a big cache working set of small segments.
|
|
60
|
+
*/
|
|
61
|
+
readonly maxOpenSegments?: number;
|
|
62
|
+
/**
|
|
63
|
+
* Aggregate byte ceiling on the parsed `.crbm` indices resident in the reader cache (default 64 MiB) — the
|
|
64
|
+
* **second half of that memory bound**. `maxOpenSegments` alone bounds by *count*, but a wide/dense segment's
|
|
65
|
+
* parsed index can be several MB, so 1024 wide indices could pin ~GBs and blow a small heap (e.g. a 128 MB
|
|
66
|
+
* Lambda) while the count is nominally "in bounds". This caps the summed {@link CrbmReader.retainedIndexBytes}
|
|
67
|
+
* across cached readers; the least-recently-used reader is evicted once the total would exceed it — whichever
|
|
68
|
+
* of the count/byte bounds binds first. Lower it for memory-tight deployments with wide segments; a single
|
|
69
|
+
* segment whose index alone exceeds the budget is still cached (it can't be shrunk) but nothing else alongside.
|
|
70
|
+
*/
|
|
71
|
+
readonly maxOpenIndexBytes?: number;
|
|
72
|
+
}
|
|
73
|
+
export declare class CrbmStorageChunkSource implements StorageChunkSource {
|
|
74
|
+
private readonly driver;
|
|
75
|
+
/**
|
|
76
|
+
* One resolved reader per segment, re-resolved on a short TTL ({@link CrbmStorageChunkSourceOptions.currentGenTtlMs},
|
|
77
|
+
* needs a clock). Within the TTL a segment's Storage bytes are treated as an immutable snapshot; when the TTL
|
|
78
|
+
* elapses the next read cheaply re-resolves `currentGen` and, only if it advanced (a load published),
|
|
79
|
+
* opens the new generation — so a long-lived source observes new generations within the TTL rather than
|
|
80
|
+
* pinning one forever. The engine pairs this with a **generation-keyed** cache so a bump never
|
|
81
|
+
* serves a stale decoded chunk. Without a clock or a registry the source pins the first generation for its
|
|
82
|
+
* lifetime (the behaviour before the registry was wired in). A segment with no generation yet is not memoized, so it's re-checked until
|
|
83
|
+
* one exists. **Bounded** by a {@link BoundedLru} ({@link CrbmStorageChunkSourceOptions.maxOpenSegments}, default
|
|
84
|
+
* 1024): past the ceiling the least-recently-used segment's reader (and its parsed index) is evicted — the
|
|
85
|
+
* steady-state memory bound; re-opening an evicted segment is one cheap tail GET.
|
|
86
|
+
*/
|
|
87
|
+
private readonly snapshots;
|
|
88
|
+
private readonly registry;
|
|
89
|
+
private readonly keystore;
|
|
90
|
+
private readonly requireEncryption;
|
|
91
|
+
private readonly readerOptions;
|
|
92
|
+
private readonly clock;
|
|
93
|
+
private readonly currentGenTtlMs;
|
|
94
|
+
constructor(driver: IStorageDriver, options?: CrbmStorageChunkSourceOptions);
|
|
95
|
+
/** The current resolved reader for a segment, refreshing on the TTL. Cheap within the TTL window. */
|
|
96
|
+
private resolvedReader;
|
|
97
|
+
/** TTL elapsed: cheaply re-resolve `currentGen`; reopen only if it actually changed, else reuse the prior reader. */
|
|
98
|
+
private computeRefreshedReader;
|
|
99
|
+
/**
|
|
100
|
+
* Memoize a reader-promise as the segment's snapshot at `now()`, forgetting it later if it resolves to
|
|
101
|
+
* null / throws (identity-guarded, so a stale cleanup never clobbers a fresher snapshot). Installing the
|
|
102
|
+
* (pending) promise **synchronously** is what lets concurrent callers coalesce onto one in-flight resolve.
|
|
103
|
+
*/
|
|
104
|
+
private install;
|
|
105
|
+
private now;
|
|
106
|
+
private expired;
|
|
107
|
+
/**
|
|
108
|
+
* The segment's current generation number — the engine keys its chunk cache by this so a generation bump
|
|
109
|
+
* is observed instead of serving a stale decoded chunk. Served from the (TTL-refreshed)
|
|
110
|
+
* snapshot, so no extra backend read within the TTL window. `null` if the segment has no committed generation.
|
|
111
|
+
*
|
|
112
|
+
* Heals a swept generation exactly like {@link withFreshSnapshot} — the engine calls this **once per op**,
|
|
113
|
+
* before any chunk fetch, so an unhealed miss here fails the whole operation rather than one chunk. It is
|
|
114
|
+
* spelled out rather than delegated because this is the hottest call in the library: once per operand of
|
|
115
|
+
* every `has`/`count`/`iterate`/`intersect`, and almost always served from the cached snapshot with no
|
|
116
|
+
* backend call at all. Routing it through the generic helper cost ~115 ns/op on that path (a second async
|
|
117
|
+
* frame, a per-call closure, and an `await` on a plain number) for a race that fires only during a
|
|
118
|
+
* concurrent sweep. One retry, then propagate — same contract, same eviction rule.
|
|
119
|
+
*/
|
|
120
|
+
currentGeneration(ref: SegmentRef): Promise<number | null>;
|
|
121
|
+
/**
|
|
122
|
+
* Forget everything derived from `ref`: the resolved snapshot and the open reader behind it (and with the
|
|
123
|
+
* reader, the DEK it unwrapped at open time). The next read resolves `currentGen` from the registry again.
|
|
124
|
+
*
|
|
125
|
+
* Needed because the TTL refresh only ever answers "has the pointer moved?", and every *destructive* verb —
|
|
126
|
+
* an erasure that deletes the generation holding the bit, a `dropSegment`, a crypto-shred, a retirement —
|
|
127
|
+
* leaves the pointer's answer unchanged from this source's point of view while making the snapshot wrong.
|
|
128
|
+
* Until this is called the source keeps serving that snapshot with no backend read at all, so nothing on the
|
|
129
|
+
* storage side can close the window; with `cache.genTtlMs: 0` or no clock it never closes.
|
|
130
|
+
*/
|
|
131
|
+
invalidate(ref: SegmentRef): void;
|
|
132
|
+
/**
|
|
133
|
+
* Whether the segment is a registered name at all. With a registry that is exactly "a row exists" — including
|
|
134
|
+
* a row with no generation yet (minted by `setRetention` before the first load) and a `destroyed` tombstone,
|
|
135
|
+
* both of which are names somebody deliberately created. Without a registry there is no registration to
|
|
136
|
+
* consult, so the honest answer is whether the bucket holds any generation of it.
|
|
137
|
+
*
|
|
138
|
+
* Deliberately NOT served from the snapshot memo: the memo resolves to `null` for both states this exists to
|
|
139
|
+
* tell apart.
|
|
140
|
+
*/
|
|
141
|
+
/**
|
|
142
|
+
* `<generation>` for a registry-less source (no row, so no incarnation to confuse), or
|
|
143
|
+
* `<generation>:<row token>` with one. The token moves on every row write, so an unrelated write (a
|
|
144
|
+
* `setRetention`) costs the segment's decoded chunks once — bounded, and on an admin path. Exactness in the
|
|
145
|
+
* direction that matters: two incarnations can never share a version string.
|
|
146
|
+
*/
|
|
147
|
+
currentVersion(ref: SegmentRef): Promise<string | null>;
|
|
148
|
+
/**
|
|
149
|
+
* Resolve the segment **once** and report what a pin should hold: the generation, and the version that
|
|
150
|
+
* identifies those exact bytes. `null` when the segment resolves to no generation — there is nothing to pin,
|
|
151
|
+
* and a caller must treat that as "this handle reads empty", not "pinning is unsupported here".
|
|
152
|
+
*/
|
|
153
|
+
pinGeneration(ref: SegmentRef): Promise<{
|
|
154
|
+
generation: number;
|
|
155
|
+
version: string;
|
|
156
|
+
} | null>;
|
|
157
|
+
/**
|
|
158
|
+
* A reader for one **specific** generation, memoized in the same bounded LRU as the live snapshots under a
|
|
159
|
+
* generation-qualified key.
|
|
160
|
+
*
|
|
161
|
+
* Sharing the LRU is the point, not an implementation detail. A pinned reader held in a private field is
|
|
162
|
+
* outside the memory ceiling the library advertises — measured at 10.34 MiB per live pin, 32 pins holding
|
|
163
|
+
* 111.7 MiB against an 8 MiB configured bound. A pinned *generation number* has no such problem: the
|
|
164
|
+
* generation is immutable, so eviction is harmless and re-opening at the same number reproduces the same
|
|
165
|
+
* bytes. It also removes a memoized-rejection bug by construction — `install` forgets a promise that rejects,
|
|
166
|
+
* where a hand-rolled `this.reader ??= open()` cached the rejection for the life of the handle and made a pin
|
|
167
|
+
* the one read path with no resilience.
|
|
168
|
+
*
|
|
169
|
+
* **`status` is re-checked here**, on every open rather than once at pin time. A pin taken before a
|
|
170
|
+
* crypto-shred must not keep unwrapping a DEK the shred destroyed; a destroyed segment resolves no generation
|
|
171
|
+
* for anyone, pinned or not.
|
|
172
|
+
*/
|
|
173
|
+
private readerAt;
|
|
174
|
+
private openAt;
|
|
175
|
+
/** The row's current key wrappings, for opening a pinned generation. */
|
|
176
|
+
private deksFor;
|
|
177
|
+
/** Read one chunk of a specific generation — the pinned read path. */
|
|
178
|
+
getChunkAt(ref: ChunkRef, generation: number): Promise<Uint8Array | null>;
|
|
179
|
+
/** Chunk keys of a specific generation — the pinned shape read. */
|
|
180
|
+
listChunkKeysAt(ref: SegmentRef, generation: number): Promise<number[]>;
|
|
181
|
+
/** Per-chunk cardinalities of a specific generation — powers a pinned `count()` with no payload reads. */
|
|
182
|
+
cardinalitiesAt(ref: SegmentRef, generation: number): Promise<ReadonlyMap<number, number> | null>;
|
|
183
|
+
/** Grounded size of a specific generation. */
|
|
184
|
+
sizeOfAt(ref: SegmentRef, generation: number): Promise<SegmentSize | null>;
|
|
185
|
+
exists(ref: SegmentRef): Promise<boolean>;
|
|
186
|
+
/**
|
|
187
|
+
* Evict a snapshot we just failed to read from — matched by its reader *promise*, so a concurrent call's
|
|
188
|
+
* fresher snapshot is never clobbered. The identity guard is the whole point: `install` replaces the entry
|
|
189
|
+
* wholesale, so comparing anything else would drop a good snapshot on the floor.
|
|
190
|
+
*/
|
|
191
|
+
private dropStale;
|
|
192
|
+
private resolveLatest;
|
|
193
|
+
/** Open a {@link CrbmReader} for an already-resolved generation target (decrypting if the segment is encrypted). */
|
|
194
|
+
private openForTarget;
|
|
195
|
+
/**
|
|
196
|
+
* The current generation + its DEK wrappings: the registry's authoritative record (one strong read), or a
|
|
197
|
+
* `list` scan for the max generation when there's no registry (a registry-less source can only read cleartext
|
|
198
|
+
* — there's nowhere a wrapped DEK could live).
|
|
199
|
+
*/
|
|
200
|
+
private resolveTarget;
|
|
201
|
+
/** Build the per-generation decryption context for an encrypted segment (undefined for cleartext). */
|
|
202
|
+
private cryptoForRead;
|
|
203
|
+
getChunk(ref: ChunkRef): Promise<Uint8Array | null>;
|
|
204
|
+
listChunkKeys(ref: SegmentRef): Promise<number[]>;
|
|
205
|
+
sizeOf(ref: SegmentRef): Promise<SegmentSize | null>;
|
|
206
|
+
cardinalities(ref: SegmentRef): Promise<ReadonlyMap<number, number> | null>;
|
|
207
|
+
/**
|
|
208
|
+
* Run `read` against the pinned snapshot, healing the torn-read window generation GC can open: if the
|
|
209
|
+
* generation we resolved was superseded *and* swept (the grace window elapsed), the Storage driver throws
|
|
210
|
+
* {@link NotFoundError}. Surfacing that as a query failure would turn a benign, recoverable race into a
|
|
211
|
+
* caller-visible error, so instead we drop the stale snapshot,
|
|
212
|
+
* re-resolve `currentGen`, and retry once — the read then serves the newer (committed, immutable) generation,
|
|
213
|
+
* a monotonic move forward — within one incarnation of the row. A name that was retired and re-created is a
|
|
214
|
+
* different segment and can resolve to a LOWER generation, which is why the snapshot carries the row's token
|
|
215
|
+
* as its lineage rather than trusting the generation number (invariant 1).
|
|
216
|
+
*
|
|
217
|
+
* **Resolution and open are inside the retry, not before it.** Resolving `currentGen` and opening that
|
|
218
|
+
* generation's object are two backend round trips with a gap between them, so the object can be swept after
|
|
219
|
+
* the registry named it and before its tail is read — and that miss surfaces from awaiting the snapshot, not
|
|
220
|
+
* from `read`. Awaiting outside the retry left the *first* attempt unhealed on the one path GC actually
|
|
221
|
+
* races: `keep: 0`, which every id erasure passes, sweeps microseconds after the publish.
|
|
222
|
+
*
|
|
223
|
+
* A *second* NotFound is pathological (GC outrunning resolution) and propagates rather than fabricating an
|
|
224
|
+
* absent answer — never return a wrong result. `ifGone` is returned when the segment has no committed
|
|
225
|
+
* generation to serve: storage is empty, or the row was dropped or crypto-shredded, in which case reading empty
|
|
226
|
+
* is the documented outcome rather than a failure.
|
|
227
|
+
*
|
|
228
|
+
* **Bounded to exactly two resolve-and-open round trips.** That is a cost contract, not a detail: the retry
|
|
229
|
+
* now re-reads the *registry*, so an unbounded one would hammer the shared, throttle-prone resource in a
|
|
230
|
+
* tight loop on a segment whose pointer names an object that is permanently absent — and an N-way
|
|
231
|
+
* `intersect` would do it N times. Gated in `tests/core/storage-source-heal-open.test.ts` by counting calls.
|
|
232
|
+
*/
|
|
233
|
+
private withFreshSnapshot;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Write one immutable generation from in-memory bitmaps (the seed / bulk-load primitive). Chunks are
|
|
237
|
+
* sorted ascending and empty bitmaps skipped (empty chunks are never stored). Returns the driver's
|
|
238
|
+
* `{ size, sha256 }` for the written object. Pass `options.crypto` to AES-256-GCM-encrypt the generation
|
|
239
|
+
* (built from the segment's DEK, with associated data bound to `(segment, generation)`).
|
|
240
|
+
*/
|
|
241
|
+
export declare function writeCrbmGeneration(driver: IStorageDriver, key: GenKey, chunks: Iterable<{
|
|
242
|
+
chunkKey: number;
|
|
243
|
+
bitmap: CodecBitmap;
|
|
244
|
+
}>, options?: {
|
|
245
|
+
crypto?: CrbmCrypto;
|
|
246
|
+
clock?: Yielder;
|
|
247
|
+
}): Promise<{
|
|
248
|
+
size: number;
|
|
249
|
+
sha256: string;
|
|
250
|
+
}>;
|
|
251
|
+
/** What {@link writeCrbmGenerationStream} wrote: the driver's `{ size, sha256 }` + a tally of the generation. */
|
|
252
|
+
export interface StreamWriteResult {
|
|
253
|
+
readonly size: number;
|
|
254
|
+
readonly sha256: string;
|
|
255
|
+
/** Non-empty chunk keys written, in ascending order (for the post-write verify, since the stream is consumed). */
|
|
256
|
+
readonly chunkKeys: number[];
|
|
257
|
+
/** Total ids written. */
|
|
258
|
+
readonly cardinality: number;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Streaming variant of {@link writeCrbmGeneration} for a **constant-memory** rewrite (the erasure rewrite, a
|
|
262
|
+
* sorted load): consumes an **already-ascending** async stream of `{ chunkKey, bitmap }`, feeding each to the
|
|
263
|
+
* codec and freeing it, instead of materializing the whole generation. Paired with a streaming storage sink (S3
|
|
264
|
+
* multipart / LocalFs temp file), peak memory is ~one chunk + one part. Returns a tally so the caller can verify
|
|
265
|
+
* the re-opened object without re-iterating the (now-consumed) stream. Input **must** be ascending by `chunkKey`
|
|
266
|
+
* — the codec rejects an out-of-order chunk; empty bitmaps are skipped.
|
|
267
|
+
*/
|
|
268
|
+
export declare function writeCrbmGenerationStream(driver: IStorageDriver, key: GenKey, chunks: AsyncIterable<{
|
|
269
|
+
chunkKey: number;
|
|
270
|
+
bitmap: CodecBitmap;
|
|
271
|
+
}>, options?: {
|
|
272
|
+
crypto?: CrbmCrypto;
|
|
273
|
+
clock?: Yielder;
|
|
274
|
+
}): Promise<StreamWriteResult>;
|
|
275
|
+
/**
|
|
276
|
+
* Point a segment's registry `currentGen` at `key.generation` — the publish step that makes a freshly-written
|
|
277
|
+
* generation the authoritative latest (so registry-aware readers see it). **Forward-only and idempotent:**
|
|
278
|
+
* if the registry has no row it creates one; if it's already at/ahead of `key.generation` it's a no-op (an
|
|
279
|
+
* out-of-order/duplicate publish never regresses the pointer); otherwise it advances via compare-and-swap,
|
|
280
|
+
* retrying a few times under contention. Separated from the Storage write so callers can publish atomically
|
|
281
|
+
* after the immutable object is durable (write-then-publish).
|
|
282
|
+
*
|
|
283
|
+
* **Throws** rather than returning, in two cases, because each would otherwise create a state no reader can
|
|
284
|
+
* recover from: a `destroyed` (crypto-shredded) row, where advancing the pointer would name an object encrypted
|
|
285
|
+
* under a shredded key; and an advance carrying **new** `wrappedDeks`, which would either strand that key or make
|
|
286
|
+
* the row advertise encryption over cleartext generations. Contention that never clears throws
|
|
287
|
+
* {@link WriteConflictError} after the retries.
|
|
288
|
+
*
|
|
289
|
+
* **`expectFrom` turns forward-only into read-modify-write.** Forward-only is the right rule for a writer whose
|
|
290
|
+
* content does not depend on what was current — a load computes its ids upstream, so publishing over a newer
|
|
291
|
+
* generation loses nothing the loader knew about. It is the WRONG rule for a writer that *derived* its content
|
|
292
|
+
* from a particular generation: the erasure rewrite streams generation `from` and clears one bit, so publishing
|
|
293
|
+
* it over a newer generation silently discards whatever that generation added. Passing `expectFrom` makes the
|
|
294
|
+
* publish land only while the pointer is still exactly there, and return `false` otherwise, so the caller can
|
|
295
|
+
* report `superseded` and re-derive. The compare-and-swap itself carries the token read in the same iteration,
|
|
296
|
+
* so the check and the write see the same row.
|
|
297
|
+
*
|
|
298
|
+
* **`expectToken` fences the LINEAGE, and a derived writer needs both.** A generation number identifies a
|
|
299
|
+
* generation only within one incarnation of a name: `nextGeneration` restarts at `0` once the row is purged and
|
|
300
|
+
* the bucket empty, so a name that is retired and re-created has a *different* segment wearing the *same*
|
|
301
|
+
* `currentGen`. `expectFrom` alone matched it — and an erasure rewrite then published one incarnation's content
|
|
302
|
+
* over another's, deleted the live objects with its `keep: 0` collection, and returned `erased: true`. The row's
|
|
303
|
+
* OCC token is the identity that survives this: {@link IRegistryDriver.delete} tombstones rather than unlinks,
|
|
304
|
+
* so "a later `create` still gets a fresh, greater token" and no token is ever reused across incarnations.
|
|
305
|
+
*
|
|
306
|
+
* Pass the token read alongside `expectFrom` and the publish lands only on the same row it was derived from.
|
|
307
|
+
* The check is deliberately **conservative**: the token also changes on writes that are not supersessions at
|
|
308
|
+
* all (a `setRetention`, a due-index reindex), so such a write makes a derived publish report `superseded` and
|
|
309
|
+
* the caller re-derive. That costs a re-run on a rare, unrelated write; the alternative costs a segment.
|
|
310
|
+
*/
|
|
311
|
+
export declare function publishGeneration(registry: IRegistryDriver, key: GenKey, options?: {
|
|
312
|
+
wrappedDeks?: readonly WrappedDek[];
|
|
313
|
+
expectFrom?: number;
|
|
314
|
+
expectToken?: Token;
|
|
315
|
+
/**
|
|
316
|
+
* Publish only while the segment still has **no registry row**.
|
|
317
|
+
*
|
|
318
|
+
* The fence for a caller whose decision rests on the row being ABSENT. `expectFrom` and `expectToken`
|
|
319
|
+
* cannot express it: both compare against a value read from a row, so when there was no row there is
|
|
320
|
+
* nothing to compare and both are simply omitted — leaving the publish a bare forward-only advance that
|
|
321
|
+
* happily lands over whatever appeared in the meantime.
|
|
322
|
+
*
|
|
323
|
+
* That gap was a silent wipe, not a theoretical one. A guarded load into a segment that did not exist yet
|
|
324
|
+
* read "no row", so its empty/`minRetained` bounds had nothing to judge and passed vacuously; a
|
|
325
|
+
* concurrent writer then created the row and published a thousand ids; and the guarded load published an
|
|
326
|
+
* EMPTY generation over them, reporting `published: true` with no reason. Reproduced through both
|
|
327
|
+
* `loadSegment` and the `*Into` verbs, which hit it far more often because materialising into a
|
|
328
|
+
* destination that does not exist yet is the ordinary first run of a pipeline.
|
|
329
|
+
*/
|
|
330
|
+
expectAbsent?: boolean;
|
|
331
|
+
}): Promise<boolean>;
|
|
332
|
+
/** What a bulk-load wrote — the driver's `{ size, sha256 }` plus a summary of the built generation. */
|
|
333
|
+
export interface BulkLoadResult {
|
|
334
|
+
/** Bytes written to the Storage object. */
|
|
335
|
+
readonly size: number;
|
|
336
|
+
/** The driver's content hash of the object. */
|
|
337
|
+
readonly sha256: string;
|
|
338
|
+
/** Distinct non-empty chunks written (≤ 65536). */
|
|
339
|
+
readonly chunkCount: number;
|
|
340
|
+
/**
|
|
341
|
+
* The DEK freshly minted for this generation, wrapped — present only when the segment is encrypted AND this
|
|
342
|
+
* call minted one (an existing segment reuses its key, which is already on the row).
|
|
343
|
+
*
|
|
344
|
+
* Returned so a caller that deliberately does **not** pass `registry` here — writing now and publishing later,
|
|
345
|
+
* which is how a load applies a guard while the old generation is still authoritative — can carry the key onto
|
|
346
|
+
* its own `publishGeneration`. Without it that publish stores no DEK and the generation it makes current is
|
|
347
|
+
* unreadable: the bytes are encrypted with a key nothing recorded.
|
|
348
|
+
*/
|
|
349
|
+
readonly wrappedDeks?: readonly WrappedDek[];
|
|
350
|
+
/** Total distinct ids in the generation (post-dedup). */
|
|
351
|
+
readonly cardinality: number;
|
|
352
|
+
/**
|
|
353
|
+
* Whether this generation is now the segment's **current** one — `undefined` when no `registry` was wired, so
|
|
354
|
+
* there is no pointer and no publish step to report on.
|
|
355
|
+
*
|
|
356
|
+
* `false` means the object is durable but a **concurrent writer published a higher generation first**, so this
|
|
357
|
+
* one is an orphan that no reader will ever resolve. The write succeeded and the *load* did not: whatever this
|
|
358
|
+
* call was asked to make the segment contain, the segment does not contain. That is not a detail a caller can
|
|
359
|
+
* be left to infer — a `*Into` materialisation used to report the generation it wrote as "the destination's
|
|
360
|
+
* new current generation" on exactly this path, which is a plain untruth — so the flag is on the result and the
|
|
361
|
+
* verbs that promise a published generation check it.
|
|
362
|
+
*/
|
|
363
|
+
readonly becameCurrent?: boolean;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* **Bulk-load** a whole immutable generation from a flat id source — the batch "seed/rebuild" entry point.
|
|
367
|
+
* Unlike {@link writeCrbmGeneration} (which takes pre-grouped bitmaps), this
|
|
368
|
+
* consumes an arbitrary, **unsorted** stream of u32 ids (sync or async) and routes each into its chunk's
|
|
369
|
+
* Roaring bitmap as it goes. The input is consumed lazily and ids dedup on insert, so memory is bounded by
|
|
370
|
+
* the **built generation** — the in-memory Roaring representation of the *distinct* set (≤ one bitmap per
|
|
371
|
+
* non-empty chunk) — not by the input length: you can stream a billion duplicate-heavy ids and hold only the
|
|
372
|
+
* distinct result. Note this is `O(distinct set)`, **not** window-bounded like {@link SegmentEngine.intersect}:
|
|
373
|
+
* bulk-load holds the whole generation in RAM, which suits batch seed/rebuild jobs but not an
|
|
374
|
+
* unbounded-cardinality stream (a segment larger than RAM needs external-merge / pre-sorted input — later phase).
|
|
375
|
+
*
|
|
376
|
+
* Each id must be an integer in `[0, 2³²)` ({@link splitId} throws {@link ValidationError} otherwise) — the
|
|
377
|
+
* source is consumed lazily, so a bad id aborts mid-stream without writing a partial object (the driver
|
|
378
|
+
* commits only after the callback resolves). An empty source writes a valid empty generation.
|
|
379
|
+
*
|
|
380
|
+
* Writes a fresh full snapshot of a segment. The caller picks the generation number in `key` — `nextGeneration`
|
|
381
|
+
* computes the right one from the registry and the bucket — and re-using an existing generation throws
|
|
382
|
+
* {@link WriteConflictError} (write-once). Without a registry a `StorageChunkSource` serves the **highest**
|
|
383
|
+
* generation present, so a too-high number silently shadows real data; with one, `publishGeneration` decides.
|
|
384
|
+
*/
|
|
385
|
+
export declare function bulkLoadCrbmGeneration(driver: IStorageDriver, key: GenKey, ids: Iterable<number> | AsyncIterable<number>, options?: {
|
|
386
|
+
registry?: IRegistryDriver;
|
|
387
|
+
keystore?: IKeystore;
|
|
388
|
+
requireEncryption?: boolean;
|
|
389
|
+
audit?: IAuditSink;
|
|
390
|
+
/** Bitmap codec. Optional in the type; a **flavor** package binds it ({@link requireCodec}). */
|
|
391
|
+
codec?: CodecInterface;
|
|
392
|
+
/**
|
|
393
|
+
* Injected clock. Supplying one makes bulk-load **cooperative**: it yields the event loop periodically so a
|
|
394
|
+
* long load does not stall everything else on the process. Without it the load still completes, just
|
|
395
|
+
* without yielding — which is the pre-existing behaviour, kept so this is purely additive.
|
|
396
|
+
*
|
|
397
|
+
* `@cloudbitmaps/roaring` supplies a real clock by default, so flavor users get cooperative behaviour with
|
|
398
|
+
* no wiring. `core/` cannot default it: it is timer-free by lint, which is exactly why waiting goes through
|
|
399
|
+
* this seam rather than `setTimeout`.
|
|
400
|
+
*/
|
|
401
|
+
clock?: Clock;
|
|
402
|
+
/**
|
|
403
|
+
* Write the object but do **not** advance the pointer (default: publish when a `registry` is wired).
|
|
404
|
+
*
|
|
405
|
+
* For a caller that has to inspect what it wrote before deciding whether it should become current — a
|
|
406
|
+
* guarded {@link loadSegment} is the one in-tree case — because the check is only meaningful while the old
|
|
407
|
+
* generation is still authoritative. The `registry` is still required for an encrypted segment and still
|
|
408
|
+
* consulted: it is where an existing segment's DEK lives, and reusing that key is not optional. The wrapped
|
|
409
|
+
* DEK comes back on the result so the deferred publish can store it.
|
|
410
|
+
*
|
|
411
|
+
* A caller that defers the publish owns what it wrote: an unpublished object sits ABOVE `currentGen`, where
|
|
412
|
+
* generation collection deliberately never looks, so nothing else will ever reclaim it.
|
|
413
|
+
*/
|
|
414
|
+
publish?: boolean;
|
|
415
|
+
}): Promise<BulkLoadResult>;
|
|
416
|
+
/**
|
|
417
|
+
* Open a {@link CrbmReader} on one generation over the storage driver's range/tail reads (decrypting if `crypto`).
|
|
418
|
+
* The reader the write paths use to re-read what they wrote, and the erasure rewrite uses to stream the old
|
|
419
|
+
* generation; the engine's read path goes through {@link CrbmStorageChunkSource} instead, which caches these.
|
|
420
|
+
*/
|
|
421
|
+
export declare function openGenerationReader(storage: IStorageDriver, key: GenKey, crypto: CrbmCrypto | undefined, options?: Omit<CrbmReaderOptions, 'crypto'>): Promise<CrbmReader>;
|
|
422
|
+
/**
|
|
423
|
+
* Re-open a freshly written generation and assert it round-trips exactly what was streamed into it: the same
|
|
424
|
+
* per-chunk key set *and* the same total cardinality (on top of the codec's own per-chunk CRC + footer checks).
|
|
425
|
+
* `expected` is the streaming writer's tally (the stream is consumed, so it can't be re-iterated) — the key-set
|
|
426
|
+
* comparison catches a dropped/extra chunk that a cardinality-only check could miss when two errors cancel out.
|
|
427
|
+
* Throws {@link IntegrityError}: the object is on disk but must not be published.
|
|
428
|
+
*/
|
|
429
|
+
export declare function verifyGeneration(storage: IStorageDriver, key: GenKey, expected: {
|
|
430
|
+
readonly chunkKeys: readonly number[];
|
|
431
|
+
readonly cardinality: number;
|
|
432
|
+
}, crypto: CrbmCrypto | undefined): Promise<void>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encryption seams — the **pure** interfaces `core/` depends on so the codec can encrypt/decrypt without
|
|
3
|
+
* importing `node:crypto` (lint-banned here, like `Clock`/`Rng`/drivers). Concrete AES-256-GCM lives outside
|
|
4
|
+
* `core/` (`src/drivers/crypto.ts`) and is injected. The envelope scheme: a per-segment DEK wrapped under an
|
|
5
|
+
* operator-held KEK, so discarding the DEK crypto-shreds the segment. Per-chunk AEAD over the `.crbm` payloads + index; a per-segment **DEK** wrapped (envelope
|
|
6
|
+
* encryption) under one or more **KEK**s by an {@link IKeystore}; the wrapped DEK(s) live in the registry, so
|
|
7
|
+
* crypto-shred = delete them.
|
|
8
|
+
*/
|
|
9
|
+
/** One AEAD result: a fresh per-message nonce, the ciphertext, and the authentication tag. */
|
|
10
|
+
export interface AeadSealed {
|
|
11
|
+
readonly nonce: Uint8Array;
|
|
12
|
+
readonly ciphertext: Uint8Array;
|
|
13
|
+
readonly tag: Uint8Array;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Authenticated encryption bound to a single key (a segment's DEK). `aad` (associated data) is authenticated
|
|
17
|
+
* but not encrypted — the codec passes each chunk's `(namespace, segment, generation, chunkKey)` identity
|
|
18
|
+
* (the library derives it; an `Aead` implementation receives it), so a chunk's ciphertext can't be silently
|
|
19
|
+
* relocated to another segment/generation/chunk or
|
|
20
|
+
* the index, even by someone who can rewrite Storage objects. The seam is symmetric and self-framing-agnostic:
|
|
21
|
+
* the caller decides where the nonce/tag go (inline with a chunk, or in the footer for the index).
|
|
22
|
+
*/
|
|
23
|
+
export interface Aead {
|
|
24
|
+
/** Encrypt `plaintext`, authenticating `aad`. Returns a fresh nonce + ciphertext + tag. */
|
|
25
|
+
seal(plaintext: Uint8Array, aad: Uint8Array): AeadSealed;
|
|
26
|
+
/**
|
|
27
|
+
* Decrypt + verify. Throws {@link IntegrityError} on **any** mismatch (wrong key, tampered ciphertext/tag,
|
|
28
|
+
* or `aad` that doesn't match what was sealed) — never returns wrong-but-plausible plaintext.
|
|
29
|
+
*/
|
|
30
|
+
open(sealed: AeadSealed, aad: Uint8Array): Uint8Array;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A DEK wrapped under one KEK. A segment stores a **list** of these (one per KEK it was wrapped under — e.g.
|
|
34
|
+
* an active KEK plus an offline recovery KEK), so losing one KEK isn't fatal and KEK rotation needs no
|
|
35
|
+
* data re-encryption. `keyId` names which KEK; `wrapped` is the opaque wrapped-key blob (base64). Carries no
|
|
36
|
+
* plaintext key material — safe to store in the registry next to the (encrypted) data.
|
|
37
|
+
*/
|
|
38
|
+
export interface WrappedDek {
|
|
39
|
+
readonly keyId: string;
|
|
40
|
+
readonly wrapped: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Envelope key management — the injected seam between the codec and however the operator holds key material
|
|
44
|
+
* (in-process BYOK by default; KMS/Vault as optional adapters later). Async so a remote keystore (KMS) fits
|
|
45
|
+
* the same interface. Never exposes raw key bytes to `core/`: it returns an {@link Aead} bound to the DEK.
|
|
46
|
+
*/
|
|
47
|
+
export interface IKeystore {
|
|
48
|
+
/**
|
|
49
|
+
* Mint a fresh per-segment DEK, wrapped under the active KEK (and any recovery KEK). Returns the wrapping(s)
|
|
50
|
+
* to persist in the registry plus an {@link Aead} to encrypt this segment's chunks with.
|
|
51
|
+
*/
|
|
52
|
+
createDek(): Promise<{
|
|
53
|
+
wrapped: WrappedDek[];
|
|
54
|
+
aead: Aead;
|
|
55
|
+
}>;
|
|
56
|
+
/**
|
|
57
|
+
* Reconstruct the {@link Aead} from a segment's stored wrappings by unwrapping with any KEK currently held.
|
|
58
|
+
* Throws {@link KeyUnavailableError} if it holds none of the referenced KEKs (lost key, or a shredded
|
|
59
|
+
* segment whose wrappings were deleted).
|
|
60
|
+
*/
|
|
61
|
+
openDek(wrapped: readonly WrappedDek[]): Promise<Aead>;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* What the encrypting codec needs: the {@link Aead} for this segment's DEK plus a way to build the AAD for a
|
|
65
|
+
* given scope (`'index'` or a chunkKey). Built per (segment, generation) by the storage-source bridge so the
|
|
66
|
+
* codec itself stays segment-agnostic.
|
|
67
|
+
*/
|
|
68
|
+
export interface CrbmCrypto {
|
|
69
|
+
readonly aead: Aead;
|
|
70
|
+
aadFor(scope: number | 'index'): Uint8Array;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Build the AEAD associated-data binding a chunk (or the index) to its exact location:
|
|
74
|
+
* `v1 ‖ len(namespace) ‖ namespace ‖ len(segment) ‖ segment ‖ generation ‖ scope ‖ chunkKey`. Length-prefixed
|
|
75
|
+
* so distinct `(namespace, segment)` pairs can never collide. Pure (no crypto) — just the authenticated label.
|
|
76
|
+
*/
|
|
77
|
+
export declare function aadFor(ref: {
|
|
78
|
+
readonly namespace?: string;
|
|
79
|
+
readonly segment: string;
|
|
80
|
+
}, generation: number, scope: number | 'index'): Uint8Array;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The determinism seam.
|
|
3
|
+
*
|
|
4
|
+
* `core/` is a pure function of its inputs + injected dependencies — it never reaches for
|
|
5
|
+
* ambient time or randomness. Production wiring supplies real implementations; the deterministic
|
|
6
|
+
* tests supply controlled, seeded ones, so any run can be replayed exactly from a seed.
|
|
7
|
+
*
|
|
8
|
+
* These are interfaces only: the seam the whole library depends on for reproducibility. Every module that
|
|
9
|
+
* needs time, randomness or a yield takes one of these rather than reaching for a global.
|
|
10
|
+
*/
|
|
11
|
+
/** Injected time source. `core/` must never call `Date.now()` directly. */
|
|
12
|
+
export interface Clock {
|
|
13
|
+
/** Milliseconds since the Unix epoch. */
|
|
14
|
+
now(): number;
|
|
15
|
+
/**
|
|
16
|
+
* Resolve after at least `ms` have elapsed. The retry/backoff path's only way to wait — `core/` must
|
|
17
|
+
* never reach for `setTimeout` directly (it's timer-free, lint-enforced). Production wiring supplies a
|
|
18
|
+
* `setTimeout`-backed implementation; a test supplies a virtual-time one so a backoff schedule is
|
|
19
|
+
* replayable. `ms <= 0` resolves on a microtask without scheduling a timer.
|
|
20
|
+
*/
|
|
21
|
+
sleep(ms: number): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Hand the event loop back once, without asking for any elapsed time.
|
|
24
|
+
*
|
|
25
|
+
* This is **not** `sleep(0)`, and the difference is the reason the member exists. `sleep(0)` resolves on a
|
|
26
|
+
* microtask, and microtasks drain before the loop advances a phase — so awaiting it inside a CPU-bound loop
|
|
27
|
+
* yields to nothing at all, and a co-resident server stays blocked for the loop's full duration. A real yield
|
|
28
|
+
* needs a macrotask. `sleep(1)` is one, but it buys the relief at ~1 ms of dead wall-clock per yield.
|
|
29
|
+
*
|
|
30
|
+
* **Optional**, so every `Clock` written before this member still satisfies the interface. Callers must
|
|
31
|
+
* therefore degrade rather than assume: `clock.yieldNow?.() ?? clock.sleep(1)` — correct on any clock, cheap
|
|
32
|
+
* on one that implements this. `core/` cannot supply it itself (it is timer-free, lint-enforced); production
|
|
33
|
+
* wiring backs it with `setImmediate`, and a test can make it a no-op so virtual time is not perturbed
|
|
34
|
+
* by what is purely a scheduling courtesy.
|
|
35
|
+
*/
|
|
36
|
+
yieldNow?(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
/** Injected randomness. `core/` must never call `Math.random()` directly. Seedable for simulation. */
|
|
39
|
+
export interface Rng {
|
|
40
|
+
/** A float in the half-open interval [0, 1). */
|
|
41
|
+
next(): number;
|
|
42
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `segmentExists` and `listSegments` — ask the registry what is there.
|
|
3
|
+
*
|
|
4
|
+
* The registry has always known which segments exist; it is what `checkConsistency`, the retention sweep and
|
|
5
|
+
* `exportSegments` each enumerate. It was simply never exposed, so a user asking "do I already have this?" had
|
|
6
|
+
* to infer it: `count()` returns `0` for a segment that was never loaded AND for one loaded with no ids, and
|
|
7
|
+
* `generations()` costs a bucket listing to answer a question the pointer alone settles.
|
|
8
|
+
*
|
|
9
|
+
* Inferring it is the expensive kind of wrong. The alternative a user reaches for is keeping their own list of
|
|
10
|
+
* segment names alongside the store — a second source of truth, updated by hand, drifting from the first the
|
|
11
|
+
* moment a load fails halfway. The registry is already the list; these two functions read it.
|
|
12
|
+
*/
|
|
13
|
+
import type { IRegistryDriver, RegistryRecord, SegmentRef } from './ports.js';
|
|
14
|
+
/** One segment the registry knows about. */
|
|
15
|
+
export interface SegmentInfo extends SegmentRef {
|
|
16
|
+
/**
|
|
17
|
+
* The generation the **pointer names** — not what a read would resolve. On a `destroyed` tombstone this is
|
|
18
|
+
* still a number while reads answer empty, so read it together with {@link SegmentInfo.status} rather than
|
|
19
|
+
* alone. `null` means the row exists with no Storage generation yet.
|
|
20
|
+
*/
|
|
21
|
+
readonly currentGen: number | null;
|
|
22
|
+
/**
|
|
23
|
+
* `destroyed` is a tombstone: the row stays so sweeps can still find it, and the generations are gone — on an
|
|
24
|
+
* encrypted segment the DEK wrappings went with them, which is what makes the bytes unrecoverable rather than
|
|
25
|
+
* merely deleted.
|
|
26
|
+
*/
|
|
27
|
+
readonly status: RegistryRecord['status'];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Whether this segment's pointer resolves a generation — the question behind "does it already exist?".
|
|
31
|
+
*
|
|
32
|
+
* One registry point read, on every backend. Deliberately narrower than "is there a row": it is **false** for a
|
|
33
|
+
* row that exists with `currentGen: null` (a retention policy recorded ahead of the first load) and for a
|
|
34
|
+
* `destroyed` tombstone, because a read answers empty in both. The predicate mirrors generation resolution
|
|
35
|
+
* clause for clause, which is what keeps the two from disagreeing.
|
|
36
|
+
*
|
|
37
|
+
* It is NOT the same question as `count() > 0`. A segment loaded with no ids exists and counts zero — the
|
|
38
|
+
* distinction between "never loaded" and "loaded, and genuinely empty" is the one `count()` cannot make and
|
|
39
|
+
* the main reason this function is worth having.
|
|
40
|
+
*
|
|
41
|
+
* Two states answer `true` where a read still gives you nothing, and neither is this call's job to detect: a
|
|
42
|
+
* **torn restore** (`missing-storage-generation` — a live pointer whose object was deleted) makes reads *throw*
|
|
43
|
+
* `NotFoundError` rather than answer empty, and `runConsistencyCheck` is the call that looks for it; and a
|
|
44
|
+
* handle carrying an expired `expiresAt` reads as empty by a rule that lives on the handle, not on the row.
|
|
45
|
+
*
|
|
46
|
+
* Not a lock. A segment can be created or dropped between this call and whatever you do next; if the answer
|
|
47
|
+
* has to hold, use the fences that exist for it — `load`'s guard, or `expectFrom`/`expectToken` on a publish.
|
|
48
|
+
*/
|
|
49
|
+
export declare function segmentExists(ref: SegmentRef, registry: IRegistryDriver): Promise<boolean>;
|
|
50
|
+
/**
|
|
51
|
+
* Every segment the registry holds, streamed — optionally scoped to one namespace.
|
|
52
|
+
*
|
|
53
|
+
* **This is an admin/discovery call, not a request-path one.** It is the registry's own enumeration — a paged
|
|
54
|
+
* LIST over the `registry/` prefix — so cost grows with the fleet, not with what you are looking for. The
|
|
55
|
+
* fleet-wide sweeps that *drain* it bound it (`maxScanSegments`); this one streams, so the bound is yours.
|
|
56
|
+
*
|
|
57
|
+
* **Scoping to a namespace narrows the LIST prefix**, so it really is the difference between reading one
|
|
58
|
+
* tenant and reading all of them.
|
|
59
|
+
*
|
|
60
|
+
* Streams, so a large fleet need not be held at once, and stopping the iteration stops the scan — **with one
|
|
61
|
+
* exception that matters**: a driver that buffers its enumeration defeats both properties, and
|
|
62
|
+
* `RetryingRegistryDriver` does exactly that (it must, to retry a `list` as a unit). Wrapped in it, the whole
|
|
63
|
+
* scan is paid for and resident before the first row reaches you. On the four native drivers the guarantee
|
|
64
|
+
* holds; on S3 the granularity is a page, so one LIST page and its in-flight row reads complete regardless.
|
|
65
|
+
*
|
|
66
|
+
* Yields `destroyed` tombstones, and rows whose `currentGen` is `null`, because hiding either would make this
|
|
67
|
+
* disagree with the registry it reports on — a filtered enumeration that looks complete is how a retention
|
|
68
|
+
* sweep ends up permanently skipping rows nobody can see. Filter on `status`/`currentGen` yourself, or use
|
|
69
|
+
* {@link segmentExists} for the "does the pointer resolve" question. Internal bookkeeping rows are the one
|
|
70
|
+
* exclusion, and only on an unscoped scan: they are not segments, and a caller naming their namespace still
|
|
71
|
+
* sees them.
|
|
72
|
+
*/
|
|
73
|
+
export declare function listSegments(registry: IRegistryDriver, options?: {
|
|
74
|
+
namespace?: string;
|
|
75
|
+
}): AsyncIterable<SegmentInfo>;
|