@cloudbitmaps/core 0.14.0 → 0.15.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.
@@ -1,8 +1,8 @@
1
1
  import { type IAuditSink } from './audit.js';
2
2
  import type { Yielder } from './cooperative.js';
3
3
  import type { Clock, Rng } from './determinism.js';
4
- import type { CrbmCrypto, IKeystore, WrappedDek } from './crypto.js';
5
- import type { ChunkRef, StorageChunkSource, GenerationMetadata, GenerationSummary, GenKey, IStorageDriver, IRegistryDriver, RegistryRecord, RegistrySummary, SegmentRef, SegmentSize, Token } from './ports.js';
4
+ import type { Aead, CrbmCrypto, IKeystore, WrappedDek } from './crypto.js';
5
+ import type { ChunkRef, ChunkRead, ReadChunksOptions, StorageChunkSource, GenerationMetadata, GenerationSummary, GenKey, IStorageDriver, IRegistryDriver, RegistryRecord, RegistrySummary, SegmentRef, SegmentSize, Token } from './ports.js';
6
6
  import { CrbmReader } from './crbm/reader.js';
7
7
  import type { CrbmReaderOptions } from './crbm/reader.js';
8
8
  import type { CodecBitmap, CodecInterface } from './codec.js';
@@ -55,12 +55,16 @@ export interface CrbmStorageChunkSourceOptions extends CrbmReaderOptions {
55
55
  */
56
56
  readonly maxOpenSegments?: number;
57
57
  /**
58
- * Aggregate byte ceiling on the parsed `.crbm` indices resident in the reader cache (default 64 MiB) — the
58
+ * Aggregate byte ceiling on what the reader cache holds (default 64 MiB): the parsed `.crbm` indices, and the chunk bytes a
59
+ * reader keeps when its first read returned a whole small object (see below) — the
59
60
  * **second half of that memory bound**. `maxOpenSegments` alone bounds by *count*, but a wide/dense segment's
60
61
  * parsed index can reach about 1.3 MB, so 1024 wide indices could pin over a GB and blow a small heap (e.g. a 128 MB
61
62
  * Lambda) while the count is nominally "in bounds". This caps the summed {@link CrbmReader.retainedBytes} (the
62
- * parsed index, and any metadata a generation carries) across cached readers; the least-recently-used reader is evicted once the total would exceed it — whichever
63
- * of the count/byte bounds binds first. Lower it for memory-tight deployments with wide segments; a single
63
+ * parsed index, any metadata a generation carries, and any kept chunk bytes) across cached readers; the least-recently-used reader is evicted once the total would exceed it — whichever
64
+ * of the count/byte bounds binds first. A reader whose open read the whole object keeps a copy of its chunk region when
65
+ * that region is at most this ceiling divided by {@link maxOpenSegments} (64 KiB by default) — its fair share — so a small generation's
66
+ * chunks are served from memory with no further request, and a bigger one is read by range. Only a source with a timed
67
+ * pointer refresh ({@link currentGenTtlMs} above 0, a registry and a clock) keeps chunk bytes. Lower it for memory-tight deployments with wide segments; a single
64
68
  * segment whose index alone exceeds the budget is still cached (it can't be shrunk) but nothing else alongside.
65
69
  */
66
70
  readonly maxOpenIndexBytes?: number;
@@ -122,6 +126,8 @@ export declare class CrbmStorageChunkSource implements StorageChunkSource {
122
126
  private readonly keystore;
123
127
  private readonly requireEncryption;
124
128
  private readonly readerOptions;
129
+ /** The most chunk bytes a reader keeps from its open: the reader cache's byte bound over its count bound, per reader; 0 without a timed pointer refresh. */
130
+ private readonly keepChunkBytesUpTo;
125
131
  private readonly clock;
126
132
  private readonly currentGenTtlMs;
127
133
  constructor(driver: IStorageDriver, options?: CrbmStorageChunkSourceOptions);
@@ -317,6 +323,22 @@ export declare class CrbmStorageChunkSource implements StorageChunkSource {
317
323
  /** Build the per-generation decryption context for an encrypted segment (undefined for cleartext). */
318
324
  private cryptoForRead;
319
325
  getChunk(ref: ChunkRef): Promise<Uint8Array | null>;
326
+ /**
327
+ * Several chunks as a stream, in as few storage requests as {@link CrbmReader.readChunks} can make them (see
328
+ * {@link StorageChunkSource.getChunks}). The stream is served from one resolved generation, and every chunk says
329
+ * which version it came from. A generation swept, or an object replaced under its number, while the stream runs
330
+ * re-resolves the segment and carries on with the keys not yet yielded from whatever is current then, so each chunk
331
+ * is whole and from one generation and the stream may describe two; a second failure with no chunk yielded in between
332
+ * propagates. `options.retry` runs the resolution, each range request and the check that a failed read's object was
333
+ * replaced, so a transient fault repeats that step alone. Nothing is resolved until the first chunk is asked for.
334
+ */
335
+ getChunks(ref: SegmentRef, keys: readonly number[], options?: ReadChunksOptions): AsyncGenerator<ChunkRead>;
336
+ /**
337
+ * {@link getChunks} of a specific generation, the pinned shape read. It yields the chunks without a version: the
338
+ * caller that pinned the generation knows it. It never moves to another generation; `held` as for
339
+ * {@link getChunkAt}.
340
+ */
341
+ getChunksAt(ref: SegmentRef, generation: number, keys: readonly number[], held?: PinnedObject, options?: ReadChunksOptions): AsyncGenerator<Omit<ChunkRead, 'version'>>;
320
342
  listChunkKeys(ref: SegmentRef): Promise<number[]>;
321
343
  sizeOf(ref: SegmentRef): Promise<SegmentSize | null>;
322
344
  cardinalities(ref: SegmentRef): Promise<ReadonlyMap<number, number> | null>;
@@ -681,6 +703,28 @@ export declare function bulkLoadCrbmGeneration(driver: IStorageDriver, key: GenK
681
703
  */
682
704
  row?: RegistryRecord | null;
683
705
  }): Promise<BulkLoadResult>;
706
+ /**
707
+ * What a load has already started when it calls {@link bulkLoadAhead}: its number, and the segment's data key, each
708
+ * still in flight so their round trips overlap the encoding. Both are joined where the write needs them, and neither
709
+ * may be left to reject unobserved.
710
+ */
711
+ export interface LoadAhead {
712
+ /** The number the generation takes (see {@link nextLoadGeneration}); joined just before the object is written. */
713
+ readonly generation: PromiseLike<number>;
714
+ /**
715
+ * The key a caller already asked the keystore to unwrap, with the wrappings it asked about. Used only when the
716
+ * row the write reads after the ids carries exactly these wrappings; otherwise the write unwraps for itself.
717
+ */
718
+ readonly unwrapped?: {
719
+ readonly wrapped: readonly WrappedDek[];
720
+ readonly aead: PromiseLike<Aead>;
721
+ };
722
+ }
723
+ /**
724
+ * {@link bulkLoadCrbmGeneration} for a load whose number, and perhaps its key, are still being fetched: the same
725
+ * options, with the same meaning, and the same writes. The number is joined only where the object is written.
726
+ */
727
+ export declare function bulkLoadAhead(driver: IStorageDriver, ref: SegmentRef, ahead: LoadAhead, ids: Iterable<number> | AsyncIterable<number> | DecodedLoadInput, options?: NonNullable<Parameters<typeof bulkLoadCrbmGeneration>[3]>): Promise<BulkLoadResult>;
684
728
  /**
685
729
  * Open a {@link CrbmReader} on one generation over the storage driver's range/tail reads (decrypting if `crypto`).
686
730
  * The reader the write paths use to re-read what they wrote, and the erasure rewrite uses to stream the old
@@ -4,6 +4,13 @@ import type { Clock } from './determinism.js';
4
4
  import type { BoundedLru } from './lru.js';
5
5
  import type { IMetricsSink } from './metrics.js';
6
6
  import type { GenerationMetadata, StorageChunkSource, SegmentRef, SegmentSize } from './ports.js';
7
+ /**
8
+ * Range requests a stream holds ahead of a combine or `iterate` by default (chunk keys, on a source that reads chunk by
9
+ * chunk, and for `count`'s fallback) — bounds memory and concurrent reads (invariant 6). A read that does not fit
10
+ * one range of a source's stream takes about one sequential round trip per this many ranges, so this, not the network,
11
+ * is what sets how long a long read takes.
12
+ */
13
+ export declare const DEFAULT_INTERSECT_CONCURRENCY = 32;
7
14
  export interface EngineDeps {
8
15
  readonly storage: StorageChunkSource;
9
16
  /** Optional cache of decoded (immutable) Storage chunks. */
@@ -49,11 +56,15 @@ export interface IdRange {
49
56
  /** Options common to the chunk-aligned combines. */
50
57
  export interface CombineOptions extends IdRange {
51
58
  /**
52
- * Max chunk keys resolved concurrently — bounds the Storage footprint (about `concurrency × operands` chunks in
53
- * flight). A positive integer; default 32. The window opens 8 keys wide (or `concurrency`, if lower) and widens
54
- * as keys are taken, so a read that stops early fetches no more than a few keys ahead; one that runs to the end
55
- * spends nearly all of its round trips at the full width. A read that stops early has requested up to
56
- * `concurrency` keys per operand past the last one it used.
59
+ * How far ahead each operand is read: a positive integer, default 32. The library's own stores read each operand as
60
+ * one stream of coalesced ranges (chunks that sit near each other are one request), and `concurrency` is how many
61
+ * range requests a stream holds ahead of the read, in flight or landed and not yet used. So about
62
+ * `concurrency × operands` requests are in flight at most, and, since a range is at most 1 MiB (one chunk, when a chunk
63
+ * is larger), about `concurrency × operands` MiB are held, whatever the segment's size. The stream opens 4 ranges wide
64
+ * (or `concurrency`, if lower) and widens as ranges are taken, so a read that stops early has asked for little past
65
+ * where it stopped, and a read that fits one range makes one request however many chunks it needs. A source that
66
+ * reads chunk by chunk (a custom one) is read as before: `concurrency` chunk keys at once, opening 8 wide. A running
67
+ * stream holds its operand's reader outside the reader cache's bounds until it ends or the segment moves on.
57
68
  */
58
69
  readonly concurrency?: number;
59
70
  /** Override the store's per-op budget for this call (`false` lifts it). */
@@ -83,6 +94,11 @@ export declare class SegmentEngine {
83
94
  * promise per distinct key in flight.
84
95
  */
85
96
  private readonly openReads;
97
+ /**
98
+ * The chunk streams open right now. An {@link invalidate} of a segment marks its streams, which then cache no chunk
99
+ * they deliver, as a read whose entry an invalidation dropped does not (streams hold no `openReads` entry).
100
+ */
101
+ private readonly openStreams;
86
102
  constructor(deps: EngineDeps);
87
103
  /** Membership: one chunk lookup — the cache, else one Storage fetch of that chunk. */
88
104
  has(seg: SegmentRef, id: number): Promise<boolean>;
@@ -117,9 +133,17 @@ export declare class SegmentEngine {
117
133
  /** The chunks of `seg` at `chunkKeys`, read through a window of {@link DEFAULT_INTERSECT_CONCURRENCY}. */
118
134
  private chunkWindow;
119
135
  /**
120
- * Every id, ascending, reading ahead through a window of up to 32 chunk fetches that opens 1, 2, 4 … 32 wide, so a
121
- * read that stops early has fetched only a handful of chunks past the one it stopped in; with `range`, only the ids in `(after, through]`, fetching only the
122
- * chunks the range overlaps (see {@link IdRange}).
136
+ * The chunks of `seg` at `chunkKeys`, taken in ascending key order by a read that may stop at any point: one stream of
137
+ * coalesced ranges when the source has `getChunks`, opening 1, 2, 4 … ranges wide up to {@link DEFAULT_INTERSECT_CONCURRENCY},
138
+ * else the chunk-by-chunk window of that many reads. `close` stops what is left of it.
139
+ */
140
+ private chunkSequence;
141
+ /**
142
+ * Every id, ascending, reading the chunks as one stream of coalesced ranges (chunks that sit near each other are one
143
+ * request) that opens 1, 2, 4 … 32 ranges wide, so a read that stops early has asked for only a few ranges past the
144
+ * one it stopped in (each at most 1 MiB); with `range`, only the ids in `(after, through]`, fetching only the
145
+ * chunks the range overlaps (see {@link IdRange}). A source that reads chunk by chunk is read through a window of up to
146
+ * 32 chunk fetches that opens 1, 2, 4 … 32 wide.
123
147
  *
124
148
  * Two generators, not one with a branch: a second `yield` site in the full read's generator grows its frame, and
125
149
  * every id of a read that asked for no range paid for it (measured at about 5% per id). The full read keeps the
@@ -149,11 +173,11 @@ export declare class SegmentEngine {
149
173
  *
150
174
  * Chunk-skipping intersection. Aligns each segment's chunk-key set (read from the `.crbm` index, no payload),
151
175
  * keeps only keys present in **all** operands (a key missing from any operand can't contribute → its Storage
152
- * chunks are never fetched — the core saving), then for each surviving key fetches the operands' chunks in
153
- * parallel and hands them to the codec for the AND, streaming results through a bounded in-flight window.
176
+ * chunks are never fetched — the core saving), then reads each operand's surviving chunks as one stream of coalesced
177
+ * ranges and hands them, key by key, to the codec for the AND, streaming results through a bounded window.
154
178
  *
155
- * **Memory:** the Storage payload footprint is bounded by the window (`concurrency × operands × chunk`), not by
156
- * segment size — that's the Lambda-friendly property.
179
+ * **Memory:** the Storage payload footprint is bounded by the window (`concurrency × operands × chunk`, a range being
180
+ * at most a chunk's cap of 1 MiB), not by segment size — that's the Lambda-friendly property.
157
181
  *
158
182
  * Generation-consistent within the call (normal case): each operand's current generation is resolved **once**
159
183
  * up front (before the fan-out) and threaded into every chunk read, so a concurrent load can't corrupt or tear
@@ -164,7 +188,9 @@ export declare class SegmentEngine {
164
188
  * waiting for the TTL, so a shorter call can meet them too: **the reader cache evicting an operand mid-call**
165
189
  * (`maxOpenSegments`), whose re-read re-resolves fresh; a sweep deleting the generation it was reading, which
166
190
  * heals the read forward; and an invalidation, which this store's own `load`, `rollback` and `eraseSubject` make
167
- * and `invalidate()` makes on request. Still whole/immutable per read, never torn.
191
+ * and `invalidate()` makes on request. Still whole/immutable per read, never torn. An operand's stream resolves its
192
+ * segment again before it serves each chunk, so each of those moves a running read as it would a read of one chunk, and
193
+ * the ranges it had requested of the earlier generation are dropped, not served.
168
194
  */
169
195
  intersect(segs: readonly SegmentRef[], options?: CombineOptions): AsyncGenerator<number>;
170
196
  /**
@@ -207,6 +233,32 @@ export declare class SegmentEngine {
207
233
  * used as-is — one clone per key, not one per operand.
208
234
  */
209
235
  private combineChunk;
236
+ /**
237
+ * One operand's chunk at `key`: from its stream when it has one, else a read of that chunk on its own. Not `async`,
238
+ * so a stream's take is made before anything is awaited: concurrent takes of one stream must be made in key order.
239
+ */
240
+ private operandChunk;
241
+ /**
242
+ * Open `keys` of `seg` as a stream of coalesced ranges, or `undefined` when the read is per key: a source with no
243
+ * `getChunks`, a segment with no generation, or nothing to read. The chunks already decoded in the cache are taken out
244
+ * of the key list first and served from there, so a repeat read asks the source for nothing it holds. Nothing is
245
+ * requested until the first chunk is asked for.
246
+ */
247
+ private openStreamed;
248
+ private closeStreamed;
249
+ /**
250
+ * The decoded chunk at `chunkKey` of a streamed read: the next chunk of its stream, or, for a chunk the cache held
251
+ * when the read opened, the cached instance while it is still there, and a read of that chunk alone once it is not.
252
+ */
253
+ private streamedChunk;
254
+ /**
255
+ * The version a streamed chunk is cached under, or `null` for one not to cache: the version its read reports when the
256
+ * source names versions as its lookups do (`currentVersion`), and otherwise the one the read planned under, since a
257
+ * lookup by generation number would never find a chunk cached under a stream's version.
258
+ */
259
+ private streamedVersion;
260
+ /** The cache key of a chunk: by the version it was read under, or by segment and key alone for a source with none. */
261
+ private chunkCacheKey;
210
262
  /** The segment's chunk keys, ascending — a shape read off the index, no payload. Keys are untrusted (invariant 5). */
211
263
  private chunkKeys;
212
264
  /**
@@ -297,6 +349,16 @@ export declare class SegmentEngine {
297
349
  * one): callers read it or clone it, never mutate it.
298
350
  */
299
351
  private storageChunk;
300
- /** One request for one chunk, decoded, range-checked and cached. */
352
+ /**
353
+ * A chunk's stored bytes decoded under the size cap (invariant 5) and range-checked: the one place a chunk becomes a
354
+ * bitmap, whether its bytes came from a request of its own or from a stream of coalesced ranges.
355
+ */
356
+ private decodeChunk;
357
+ /**
358
+ * One chunk, decoded, range-checked and cached. A source with `getChunks` is read through it, as a one-key stream, so
359
+ * a `storage.get` is reported only for a request that was sent (a chunk the source holds in memory sends none);
360
+ * the chunk is cached under the version it came from, as a stream's chunk is. A source without it is read by
361
+ * `getChunk`, one event a call.
362
+ */
301
363
  private fetchChunk;
302
364
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Pulls the items of an async iterator one at a time so that the code relaying them never holds the one it just
3
+ * handed on. A binding of a generator's body (`for await (const item of …)`, `yield*`) is kept in the generator's
4
+ * frame across its next `yield`, so the relay would hold the previous item, and whatever buffer it is a view into,
5
+ * for as long as it waits for the next. Here the item sits in the pull between {@link advance} and {@link take}, and
6
+ * `take` clears it before it returns.
7
+ *
8
+ * ```ts
9
+ * const pull = new ItemPull(source);
10
+ * try {
11
+ * while (await pull.advance()) yield pull.take((item) => wrap(item));
12
+ * } finally {
13
+ * await pull.close();
14
+ * }
15
+ * ```
16
+ *
17
+ * Internal to core: not exported from any entry point.
18
+ */
19
+ export declare class ItemPull<A> {
20
+ private readonly iterator;
21
+ private item;
22
+ constructor(source: AsyncIterable<A>);
23
+ /** Waits for the next item and keeps it inside; `false` once the source has ended. A failure of the source throws. */
24
+ advance(): Promise<boolean>;
25
+ /** Hands over the item {@link advance} fetched, through `map`, and forgets it. */
26
+ take<B>(map: (item: A) => B): B;
27
+ /** Ends the source early (a consumer that stopped), as leaving a `for await` does. */
28
+ close(): Promise<void>;
29
+ }
@@ -26,19 +26,26 @@ export type MetricEvent = {
26
26
  readonly kind: 'storage.get';
27
27
  readonly namespace?: string;
28
28
  readonly segment: string;
29
- /** One event per request, however many callers were waiting on it. Bytes returned (0 if the chunk was absent — a GET still happened). */
29
+ /**
30
+ * One event per request for chunks: a range request of a combine or `iterate`, which carries every chunk the read
31
+ * needs from a stretch of the object, or the one chunk of a read made on its own, however many callers were waiting
32
+ * on it. Bytes returned: the range's, the gaps between its chunks included (0 if a single chunk was absent — a GET
33
+ * still happened).
34
+ */
30
35
  readonly bytes: number;
31
36
  /**
32
- * Elapsed wall time of the read — includes any transient-retry backoff on the storage call. From the
33
- * injected clock, clamped to ≥ 0 (0 under the no-wait test clock).
37
+ * Elapsed wall time of the request — includes any transient-retry backoff on the storage call. From the
38
+ * injected clock, clamped to ≥ 0 (0 under the no-wait test clock). For a range of a stream, the time from the
39
+ * range being asked for to its answer, which the source measures.
34
40
  */
35
41
  readonly ms: number;
36
42
  } | {
37
43
  /**
38
44
  * One decoded-chunk cache lookup (emitted only when a cache is configured). `hit: false` means the lookup found
39
- * no cached chunk. A caller that missed while another caller's read of the same chunk was open waits on that
40
- * read and adds no `storage.get`, so the misses can outnumber the `storage.get` events; the `storage.get`
41
- * count is the number of requests.
45
+ * no cached chunk. A combine or `iterate` looks up every chunk it needs when it opens its streams. A caller that
46
+ * missed while another caller's read of the same chunk was open waits on that read and adds no `storage.get`, and a
47
+ * range carries several chunks, so the misses can outnumber the `storage.get` events; the `storage.get` count is
48
+ * the number of requests for chunks.
42
49
  */
43
50
  readonly kind: 'cache';
44
51
  readonly hit: boolean;
@@ -21,7 +21,7 @@
21
21
  * on some other handle. That is why the routing is by segment identity here rather than by which object the
22
22
  * caller happened to start from.
23
23
  */
24
- import type { ChunkRef, GenerationSummary, StorageChunkSource, SegmentRef, SegmentSize } from './ports.js';
24
+ import type { ChunkRef, ChunkRead, ReadChunksOptions, GenerationSummary, StorageChunkSource, SegmentRef, SegmentSize } from './ports.js';
25
25
  import type { CrbmStorageChunkSource } from './crbm-storage-source.js';
26
26
  /**
27
27
  * What a pin holds for one segment: the generation, the version identifying those exact bytes, and the pinned
@@ -64,6 +64,12 @@ export declare class PinnedStorageChunkSource implements StorageChunkSource {
64
64
  */
65
65
  private pinFor;
66
66
  getChunk(ref: ChunkRef): Promise<Uint8Array | null>;
67
+ /**
68
+ * A pinned segment's chunks come from the pinned generation alone, under the version a pinned read keys by
69
+ * ({@link currentVersion}); another segment's go to the live source, which answers with its own.
70
+ */
71
+ getChunks(ref: SegmentRef, keys: readonly number[], options?: ReadChunksOptions): AsyncIterable<ChunkRead>;
72
+ private pinnedChunks;
67
73
  listChunkKeys(ref: SegmentRef): Promise<number[]>;
68
74
  sizeOf(ref: SegmentRef): Promise<SegmentSize | null>;
69
75
  cardinalities(ref: SegmentRef): Promise<ReadonlyMap<number, number> | null>;
@@ -40,11 +40,80 @@ export interface GenKey extends SegmentRef {
40
40
  export interface SegmentSize {
41
41
  readonly sizeBytes: number;
42
42
  }
43
+ /**
44
+ * One chunk of a {@link StorageChunkSource.getChunks} stream: the key asked for, its bytes (`null` where the generation
45
+ * holds none), and the version of the generation the bytes were read from, or `null` when the segment has no
46
+ * generation. The version is what {@link StorageChunkSource.currentVersion} reports for that generation, so a caller
47
+ * can key what it keeps by the generation the bytes came from rather than the one it resolved earlier.
48
+ */
49
+ export interface ChunkRead {
50
+ readonly key: number;
51
+ readonly bytes: Uint8Array | null;
52
+ readonly version: string | null;
53
+ }
54
+ /** Options of {@link StorageChunkSource.getChunks}. */
55
+ export interface ReadChunksOptions {
56
+ /**
57
+ * Runs one storage request, and may run it again if it fails. A source passes each request it makes through
58
+ * this, so a caller that retries transient faults repeats the one request that failed, not the ones that landed.
59
+ * Absent, each request runs once.
60
+ */
61
+ readonly retry?: <T>(request: () => Promise<T>) => Promise<T>;
62
+ /**
63
+ * How many range requests the stream holds ahead of its consumer, in flight or landed and not yet taken: a positive
64
+ * integer, 32 by default. It bounds the requests in flight, and the memory the stream holds, at this many times the
65
+ * largest range (1 MiB, 1 MiB + 28 B for one encrypted chunk).
66
+ */
67
+ readonly concurrency?: number;
68
+ /**
69
+ * Open the window 1, 2, 4 … ranges wide up to `concurrency` instead of at full width, so a reader that stops early
70
+ * has asked for little; a number starts it that wide instead of at 1 (a positive integer), and it doubles from there.
71
+ */
72
+ readonly ramp?: boolean | number;
73
+ /**
74
+ * Called once for every range request the stream sends, when it settles: a request the consumer took, one that was
75
+ * still in flight when the consumer stopped, and one that failed. `bytes` is what the request moved, the gaps between
76
+ * the chunks it carried included (0 when it failed), and `ms` how long it took, retries included (0 when the source
77
+ * has no clock). It is how a caller counts what it is billed for, since several chunks can share a request and
78
+ * a stopped stream leaves some in flight. A throw from it is ignored.
79
+ */
80
+ readonly onRequest?: (request: {
81
+ readonly bytes: number;
82
+ readonly ms: number;
83
+ }) => void;
84
+ }
43
85
  /** Per-chunk read view of the immutable Storage tier (implemented by the `.crbm` reader). */
44
86
  export interface StorageChunkSource {
45
87
  /** Read-only bytes for the chunk, or `null` if absent. Callers must not mutate the buffer. */
46
88
  getChunk(ref: ChunkRef): Promise<Uint8Array | null>;
47
89
  listChunkKeys(ref: SegmentRef): Promise<number[]>;
90
+ /**
91
+ * Optional: several chunks of one segment as a stream, read **from one generation** with fewer storage requests than
92
+ * one per chunk where the chunks sit near each other in the object. `keys` are chunk keys in ascending order (a key
93
+ * may repeat; any other order is a `ValidationError`). The stream yields one {@link ChunkRead} per position of
94
+ * `keys`, in that order, as the requests that carry them land; a key the generation does not hold yields `null`
95
+ * bytes, as {@link StorageChunkSource.getChunk} answers. Each chunk is checked as a read of it alone is.
96
+ *
97
+ * **Nothing is resolved or read until the first chunk is asked for**, and the source holds at most
98
+ * {@link ReadChunksOptions.concurrency} ranges at a time (in flight, or landed and not yet taken), however many keys
99
+ * there are: a consumer that is slow holds the stream back, and one that stops early (`break`, or `return()`) stops
100
+ * the reads, so the requests it had already started (at most that many) finish and are dropped, and are not retried.
101
+ * A stream that fails raises at once and sends nothing further, and the requests it left in flight finish in the
102
+ * background and never raise. A plain chunk may be a view into a buffer of up to 1 MiB that it shares with its
103
+ * neighbours (and the same view at two positions for a key asked twice), so do not write to it, and copy a chunk to
104
+ * keep it.
105
+ *
106
+ * One stream reads one generation. If that generation is swept, or its object replaced, while the stream runs, a
107
+ * source may re-resolve the segment and continue with the keys not yet yielded from the generation that is current
108
+ * then; each chunk says which version it came from, and each is whole and verified either way. Before it does, it
109
+ * waits for the requests the failed stream left in flight, so a heal never opens a second window beside them: a
110
+ * request that never answers delays a heal, as it would hang a read of one chunk, until the driver's read timeout (if
111
+ * it has one) ends it, and never delays an error. A source that reads a pinned generation never heals.
112
+ *
113
+ * A source that cannot read a range of an object omits this, and a caller reads chunk by chunk; the `.crbm`
114
+ * source, which is what the stores the library ships read through, implements it.
115
+ */
116
+ getChunks?(ref: SegmentRef, keys: readonly number[], options?: ReadChunksOptions): AsyncIterable<ChunkRead>;
48
117
  /**
49
118
  * Optional: the current generation's grounded size, cheaply (from the already-parsed `.crbm` index — no
50
119
  * payload reads), or `null` if the segment has no Storage generation. Powers the grounded `costReport()`.
@@ -42,5 +42,10 @@ export declare function isReservedNamespace(namespace: string | undefined): bool
42
42
  export declare function validateUserNamespace(namespace: string): void;
43
43
  /** Validate a segment ref an application hands in: the name rules, and a namespace outside the reserved prefix. */
44
44
  export declare function validateUserRef(ref: SegmentRef): void;
45
+ /**
46
+ * Validate the keys of a `getChunks` stream: in ascending order, a key may repeat. Checked before anything is
47
+ * resolved or read, so a caller's mistake costs no request.
48
+ */
49
+ export declare function validateChunkKeyOrder(keys: readonly number[]): void;
45
50
  /** Validate a chunk ref: the segment/namespace rules plus `chunkKey` ∈ `[0, 65535]` (a u16). A driver-boundary check. */
46
51
  export declare function validateChunkRef(ref: ChunkRef): void;
@@ -28,7 +28,7 @@ import {
28
28
  validateRegistryPatch,
29
29
  validateSegmentRef,
30
30
  webCryptoEntropy
31
- } from "./chunk-5W3XS2IU.js";
31
+ } from "./chunk-B7IGS3W6.js";
32
32
 
33
33
  // src/drivers/_shared/object-registry-keys.ts
34
34
  var REGISTRY_SUFFIX = ".reg";
@@ -104,7 +104,7 @@ function parseRegistryKey(prefix, objectKey) {
104
104
  var MAX_ROW_BYTES = 1 * 1024 * 1024;
105
105
  var MAX_DELETE_ATTEMPTS = 8;
106
106
  var MAX_READ_ATTEMPTS = 8;
107
- var LIST_READ_CONCURRENCY = 16;
107
+ var LIST_READ_CONCURRENCY = 48;
108
108
  var ObjectVersionRaced = class extends Error {
109
109
  constructor(objectKey) {
110
110
  super(`registry object was overwritten mid-read: ${objectKey}`);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/drivers/_shared/object-registry-keys.ts", "../src/drivers/_shared/object-registry.ts"],
4
- "sourcesContent": ["/**\n * The registry object layout, shared by every object-store registry (S3, GCS, Azure Blob).\n *\n * One tiny JSON object per segment at `<prefix>registry/<ns>/<segment>.reg`, **registry-first** \u2014 separate\n * from the namespace-first storage layout (`<prefix><ns>/segments/\u2026`) \u2014 so discovery is a single list over\n * `registry/` for all namespaces, or `registry/<ns>/` for one, never entangled with the `.crbm` payloads.\n *\n * It lives here rather than in a driver because all three object stores encode names the same way\n * (`encodeNameForKey`, `namespaceKeyPart`) and build byte-identical keys. Three copies of this would be\n * three chances for one cloud's layout to drift from the others \u2014 and a drifted key is not a visible bug, it\n * is a segment that quietly cannot be found by a store pointed at the same bucket.\n */\nimport { ValidationError } from '@/core/errors';\nimport { validateSegmentRef } from '@/core/validate';\nimport type { SegmentRef } from '@/core/ports';\nimport { DEFAULT_NAMESPACE, decodeNameFromKey, encodeNameForKey, namespaceKeyPart } from './keys';\n\nconst REGISTRY_SUFFIX = '.reg';\n\n/** Normalize an optional caller prefix to either `''` or `trimmed/` (no leading/trailing slashes). */\nexport function prefixPart(prefix: string | undefined): string {\n if (prefix === undefined) return '';\n const trimmed = prefix.replace(/^\\/+|\\/+$/g, '');\n return trimmed === '' ? '' : `${trimmed}/`;\n}\n\n/**\n * Validate a caller-supplied key prefix (trusted config, but make it a real containment boundary): reject\n * control characters and `.`/`..` path segments so a prefix cannot traverse out of its intended space.\n * Returns it unchanged \u2014 the key builders normalize slashes.\n *\n * The traversal check covers three spellings, because the tools that walk these buckets do. A bare `..` is\n * the obvious one. A **backslash** segment matters on ADLS Gen2 and to Windows-side tooling, which treat it\n * as a separator this code otherwise would not. And a percent-encoded `..` matters because `gsutil`,\n * `s3fs`, `gcsfuse` and `azcopy` decode a key on their way to a local path \u2014 the same reasoning\n * `name-codec.ts` already applies to segment names, which a prefix has no reason to be exempt from.\n */\nexport function normalizeObjectPrefix(prefix: string | undefined): string | undefined {\n if (prefix === undefined) return undefined;\n for (const ch of prefix) {\n const code = ch.charCodeAt(0);\n // C0 plus DEL: a bare `< 0x20` lets U+007F through, and it is as unprintable as the rest.\n if (code < 0x20 || code === 0x7f) {\n throw new ValidationError('prefix must not contain control characters');\n }\n }\n if (prefix.includes('\\\\')) {\n throw new ValidationError(\n `prefix must not contain backslashes (a path separator on some backends): ${JSON.stringify(prefix)}`,\n );\n }\n // Check the decoded spelling too, so an encoded `..` cannot slip past the literal comparison.\n let decoded: string;\n try {\n decoded = decodeURIComponent(prefix);\n } catch {\n decoded = prefix; // not valid percent-encoding; the literal check still applies\n }\n for (const source of decoded === prefix ? [prefix] : [prefix, decoded]) {\n for (const segment of source.split('/')) {\n if (segment === '.' || segment === '..') {\n throw new ValidationError(\n `prefix must not contain \".\" or \"..\" path segments: ${JSON.stringify(prefix)}`,\n );\n }\n }\n }\n return prefix;\n}\n\n/** The key prefix under which every registry object lives: `<prefix>registry/`. */\nexport function registryPrefix(prefix: string | undefined): string {\n return `${prefixPart(prefix)}registry/`;\n}\n\n/** The full key of one segment's registry object: `<prefix>registry/<ns>/<segment>.reg`. */\nexport function registryObjectKey(prefix: string | undefined, ref: SegmentRef): string {\n validateSegmentRef(ref);\n return `${registryPrefix(prefix)}${namespaceKeyPart(ref.namespace)}/${encodeNameForKey(ref.segment)}${REGISTRY_SUFFIX}`;\n}\n\n/** The list prefix for discovery: registry-wide, or scoped to one namespace. */\nexport function registryListPrefix(prefix: string | undefined, namespace?: string): string {\n const base = registryPrefix(prefix);\n return namespace === undefined ? base : `${base}${namespaceKeyPart(namespace)}/`;\n}\n\n/**\n * Parse a `<prefix>registry/<ns>/<segment>.reg` key back to its {@link SegmentRef}, or `null` if it does not\n * match \u2014 a stray or foreign object under the prefix, or one whose parsed ref fails the round-trip check.\n * `_default` maps back to the absent namespace. A name is percent-encoded on the way in, so no encoded name\n * can contain `/` and the split stays unambiguous whatever the caller named their segment.\n */\nexport function parseRegistryKey(prefix: string | undefined, objectKey: string): SegmentRef | null {\n const base = registryPrefix(prefix);\n if (!objectKey.startsWith(base) || !objectKey.endsWith(REGISTRY_SUFFIX)) return null;\n const rest = objectKey.slice(base.length, objectKey.length - REGISTRY_SUFFIX.length);\n const slash = rest.indexOf('/');\n if (slash < 0) return null;\n const nsPart = rest.slice(0, slash);\n const encodedSegment = rest.slice(slash + 1);\n if (encodedSegment.length === 0 || encodedSegment.includes('/')) return null;\n const segment = decodeNameFromKey(encodedSegment);\n const namespace = nsPart === DEFAULT_NAMESPACE ? undefined : decodeNameFromKey(nsPart);\n // The encoding must ROUND-TRIP, not merely decode: a foreign object placed under our prefix could spell a\n // name two ways, and reporting both would hand a sweep one segment under two identities.\n if (encodeNameForKey(segment) !== encodedSegment) return null;\n if (namespace !== undefined && encodeNameForKey(namespace) !== nsPart) return null;\n const ref: SegmentRef = { segment, namespace };\n try {\n validateSegmentRef(ref); // reject a hostile/foreign key that isn't a valid ref\n } catch {\n return null;\n }\n return ref;\n}\n", "/**\n * `ObjectStoreRegistry` \u2014 an {@link IRegistryDriver} over any object store with conditional writes.\n *\n * Lets a **read-mostly deployment run on one bucket alone** \u2014 storage `.crbm` generations plus the registry in\n * the same place, with no separate database. One tiny JSON object per segment at\n * `<prefix>registry/<ns>/<segment>.reg` holding the `{ deleted, record }` envelope (the same shape LocalFs\n * persists). The OCC token is a random incarnation id drawn when the row is created, a counter advanced on every\n * mutation, and a random part drawn for every write, so with overwhelming probability a deleted-then-recreated row, or\n * one restored from a backup, never re-issues an old token (ABA-safe) \u2014 identical semantics to the LocalFs registry, so it passes the same\n * conformance suite.\n *\n * **`delete` removes a row for good where the store vouches for a conditional delete** (`If-Match: <etag>`,\n * `ifGenerationMatch: <generation>`), and only a row whose token carries an incarnation id: a re-create draws a new\n * incarnation, so nothing of the old row is needed to keep its tokens apart, and a full `list()` no longer reads a\n * row for every name it ever held. Every other row is **tombstoned**: the object stays, its counter advanced, and a\n * re-create carries the counter on. A row a release before 0.12 wrote has a bare decimal token and is always\n * tombstoned, since a process still on that release, re-creating the name over nothing, would start its counter at 0\n * again and issue the old row's tokens.\n *\n * **The atomic swap is offloaded to the store's conditional writes.** `create` writes only if absent (or\n * over a tombstone under its version), and `compareAndSwap`/`delete` write (or delete) only if the object still\n * carries the version we read \u2014 so a concurrent writer between our read and our write loses, and the loss surfaces\n * as {@link WriteConflictError}. No in-process lock is needed (unlike LocalFs): the precondition fences\n * writers *across processes*.\n *\n * **Why this is shared rather than written per cloud.** Every object store worth using has the same two\n * primitives under different names \u2014 S3 `If-None-Match: *` / `If-Match: <etag>`, GCS `ifGenerationMatch: 0`\n * / `ifGenerationMatch: <generation>`, Azure `If-None-Match: *` / `If-Match: <etag>` \u2014 so the only thing\n * that differs is a few I/O calls, which is what {@link ObjectRegistryStore} abstracts. The parts that are\n * genuinely hard (the ABA-safe token, the delete and its tombstone, the bounded delete retry, the round-trip key check)\n * exist once. Three copies would be three chances for one cloud to drift, and a drifted registry is not a\n * visible bug \u2014 it is a lost `currentGen` swap.\n *\n * **Deployment requirements** (a backend that violates these silently corrupts the registry):\n * - The store **must honor the conditional write**. One that accepts the precondition header and ignores it\n * degrades compare-and-swap to last-write-wins \u2192 lost `currentGen` swaps.\n * - Reads must be **strongly consistent** \u2014 true of S3 (since 2020), GCS and Azure Blob \u2014 since the registry\n * advertises `strongRead` and generation resolution depends on it.\n * - **Do not apply a lifecycle-expiration rule to the `registry/` prefix that expires a current version.**\n * Expiring a live row loses its pointer. A tombstone `delete` left keeps the counter for a re-create; one expired\n * by a rule is no worse than a row removed for good, since a recreate draws a fresh incarnation and its tokens are\n * new but for a collision of probability 2^-128 per pair of incarnations, but a rule cannot tell a tombstone from a\n * live row.\n * - **A store's conditional delete must be honoured by its backend** before the store sets `conditionalDelete`.\n * One that ignores the precondition lets two sweepers and a re-create delete a live row.\n *\n * **`list()` is fail-closed, and one bad object stops it for everyone.** An object under the `registry/`\n * prefix whose key parses but whose body does not aborts the whole enumeration \u2014 every namespace, not just\n * the affected segment \u2014 and it stays that way until an operator removes the object. The error names the\n * offending key.\n *\n * That is the deliberate choice, and it is the *safer* one rather than the more convenient one. Skipping the\n * unreadable row would keep discovery running, but `list()` is what tells orphan generation collection which\n * segments exist: a row missing from the enumeration makes its storage `.crbm` generations look unreferenced,\n * and the next GC pass would delete them. Silently skipping turns a parse error into data loss. Refusing to\n * enumerate keeps every sweep off a set it cannot vouch for (invariant 5 \u2014 bytes from the tier are\n * untrusted). **Recovery:** read the named object, and either restore a valid envelope or delete it; nothing\n * else is damaged, and discovery resumes on its own.\n */\nimport {\n IntegrityError,\n TransientError,\n ValidationError,\n WriteConflictError,\n isWriteConflictError,\n} from '@/core/errors';\nimport type {\n IRegistryDriver,\n NewRegistryRecord,\n RegCaps,\n RegistryPatch,\n RegistryRecord,\n SegmentRef,\n Token,\n} from '@/core/ports';\nimport type { Entropy } from '@/core/determinism';\nimport { mapWithConcurrency } from '@/core/concurrency';\nimport {\n applyRegistryPatch,\n incarnationOf,\n newIncarnationToken,\n nextRegistryToken,\n parseRegistryEnvelope,\n recordFromNew,\n serializeRegistryEnvelope,\n validateNewRegistryRecord,\n validateRegistryPatch,\n type RegistryEnvelope,\n} from './registry';\nimport { entropyIsAvailable, webCryptoEntropy } from './entropy';\nimport { parseRegistryKey, registryListPrefix, registryObjectKey } from './object-registry-keys';\n\n/** Defensive cap on a single registry object read from storage, before allocation (rows are tiny; ~1 KB). */\nexport const MAX_ROW_BYTES = 1 * 1024 * 1024;\n/** Bounded retry for `delete`'s read then delete or tombstone, under cross-process contention (then fails typed). */\nconst MAX_DELETE_ATTEMPTS = 8;\n/** Bounded re-read when a store's pinned version is overwritten mid-read (see {@link ObjectVersionRaced}). */\nconst MAX_READ_ATTEMPTS = 8;\n/** In-flight reads per `list()` page \u2014 turns the serial N+1 into one list plus bounded parallel reads. */\nconst LIST_READ_CONCURRENCY = 16;\n\n/**\n * Raised by a store when the version it pinned for a read vanished before it could fetch the bytes \u2014\n * a concurrent writer overwrote or deleted the object in between.\n *\n * This is deliberately **neither** of the two things it superficially resembles. It is not absence: the row\n * is very likely still live, just one version further on. And it is not a write conflict: the caller may be\n * a read-only `get()`, which has nothing to conflict with. Either is an easy misreading for a store that reads a\n * row in two calls, metadata and then bytes pinned to it, which is why the signal is part of the port rather than\n * left to each store's judgement. {@link ObjectStoreRegistry} answers it the only way that is correct for every\n * caller: it reads again.\n *\n * Stores whose read is atomic can never raise it, and every shipped store's is: S3 serves bytes and `ETag` from one\n * `GetObject`, GCS bytes and `generation` from one GET, and Azure Blob bytes and `ETag` from one GET.\n */\nexport class ObjectVersionRaced extends Error {\n constructor(objectKey: string) {\n super(`registry object was overwritten mid-read: ${objectKey}`);\n this.name = 'ObjectVersionRaced';\n }\n}\n\n/** One object as read, with the opaque version that fences a later conditional write. */\nexport interface ObjectRow {\n readonly bytes: Uint8Array;\n /** The store's version fence \u2014 an S3/Azure ETag, a GCS generation. Opaque to this class. */\n readonly version: string;\n}\n\n/**\n * The I/O calls an object store must provide to host a registry: three, and an optional conditional delete. Everything\n * else \u2014 the OCC token, tombstones, the delete retry, key parsing \u2014 is implemented once above.\n */\nexport interface ObjectRegistryStore {\n /** A label for error messages, e.g. `GCS` or `Azure Blob`. */\n readonly label: string;\n /**\n * Read one object, or `null` when it is absent.\n *\n * **`null` means the object does not exist** \u2014 nothing weaker. A store that reads in two round trips\n * (metadata for the version fence, then the bytes pinned to it) can find that version already gone; it\n * MUST report that with {@link ObjectVersionRaced} rather than `null` or {@link WriteConflictError}, and\n * the caller re-reads. Retryable faults throw {@link TransientError}.\n *\n * `bytes` and `version` MUST describe the **same** observation of the object: `version` is the fence a\n * later conditional write is conditioned on, so a pair drawn from two different versions would either\n * fail a write that should have succeeded or, worse, pass one that should not.\n */\n read(key: string): Promise<ObjectRow | null>;\n /**\n * Write one object under a precondition: `'absent'` means create-only, a version means\n * compare-and-swap against exactly that version. A lost race MUST throw {@link WriteConflictError}.\n *\n * A store must not replay this write after a lost response without telling the replay apart: the replay meets\n * the row it just wrote and its precondition fails, which would read as a lost race. Send it once, or store a\n * write id with the row and treat a conflict whose stored row carries it as success. Retryable faults throw\n * {@link TransientError}.\n */\n write(key: string, body: Uint8Array, expect: 'absent' | { version: string }): Promise<void>;\n /** Every object key under `prefix`, paginated internally. */\n listKeys(prefix: string): AsyncIterable<string>;\n /**\n * Optional: delete one object, and only while it is still at exactly `expect.version`, by a precondition the\n * backend applies (`If-Match: <etag>`, `ifGenerationMatch: <generation>`). An object that was overwritten, or is\n * gone, MUST throw {@link WriteConflictError} and delete nothing. Retryable faults throw {@link TransientError}.\n *\n * Sending it again after a lost response is safe, unlike a write: the precondition names one version, so a second\n * copy can remove nothing the first was not allowed to. A copy that meets its own landed delete fails the\n * precondition and reports a conflict; the registry re-reads, and finds the row gone.\n *\n * The registry calls it only when {@link conditionalDelete} is `true`.\n */\n delete?(key: string, expect: { version: string }): Promise<void>;\n /**\n * Whether {@link delete} may be relied on: `true` only where the backend is known to apply the precondition. A\n * backend that accepts the header and ignores it would let a delete remove a row written after the delete read it,\n * a live one included. Absent or `false`, the registry never calls `delete` and tombstones every row instead.\n */\n readonly conditionalDelete?: boolean;\n}\n\nexport class ObjectStoreRegistry implements IRegistryDriver {\n /**\n * `entropy` draws the random parts of every token: a new row's incarnation id, and each write's own part. It\n * defaults to the platform's Web Crypto. Inject one only to make a test replayable: a seeded source in production\n * gives every process the same ids.\n */\n constructor(\n private readonly store: ObjectRegistryStore,\n private readonly prefix: string | undefined,\n private readonly now: () => number,\n private readonly entropy: Entropy = webCryptoEntropy,\n ) {}\n\n capabilities(): RegCaps {\n const conditionalDelete = this.removesRows();\n return entropyIsAvailable(this.entropy)\n ? { strongRead: true, conditionalDelete }\n : { strongRead: true, canWrite: false, conditionalDelete };\n }\n\n /** Whether a delete removes a row born with an incarnation id: the store has a delete, and vouches for it. */\n private removesRows(): boolean {\n return this.store.conditionalDelete === true && typeof this.store.delete === 'function';\n }\n\n async get(ref: SegmentRef): Promise<RegistryRecord | null> {\n const current = await this.readRow(registryObjectKey(this.prefix, ref));\n return current && !current.env.deleted ? current.env.record : null;\n }\n\n async create(ref: SegmentRef, record: NewRegistryRecord): Promise<{ token: Token }> {\n const checked = validateNewRegistryRecord(record);\n const key = registryObjectKey(this.prefix, ref);\n const current = await this.readRow(key);\n if (current !== null && !current.env.deleted) {\n throw new WriteConflictError(`registry row already exists for segment ${ref.segment}`);\n }\n // A new incarnation, whose counter continues across a tombstone, so with overwhelming probability a recreate never re-issues an old token.\n const token = newIncarnationToken(this.entropy, current?.env.record);\n const env: RegistryEnvelope = {\n deleted: false,\n record: recordFromNew(ref, checked, this.now(), token),\n };\n // Create-only when truly absent; overwrite the tombstone under its version when recreating. Either way\n // a concurrent create loses the precondition and surfaces as a conflict.\n await this.putRow(key, env, current ? { version: current.version } : 'absent');\n return { token };\n }\n\n async compareAndSwap(\n ref: SegmentRef,\n expected: Token,\n patch: RegistryPatch,\n ): Promise<{ token: Token }> {\n const checked = validateRegistryPatch(patch);\n const key = registryObjectKey(this.prefix, ref);\n const current = await this.readRow(key);\n if (current === null || current.env.deleted || current.env.record.token !== expected) {\n throw new WriteConflictError(`OCC token mismatch for registry row ${ref.segment}`);\n }\n const token = nextRegistryToken(current.env.record, this.entropy);\n const env: RegistryEnvelope = {\n deleted: false,\n record: applyRegistryPatch(current.env.record, checked, this.now(), token),\n };\n // The version we read fences a concurrent writer between that read and this write.\n await this.putRow(key, env, { version: current.version });\n return { token };\n }\n\n async *list(namespace?: string): AsyncIterable<RegistryRecord> {\n const prefix = registryListPrefix(this.prefix, namespace);\n // One list plus bounded parallel reads. A serial await-in-loop made this O(N) sequential round trips.\n let page: string[] = [];\n const flush = async function* (\n this: ObjectStoreRegistry,\n keys: string[],\n ): AsyncIterable<RegistryRecord> {\n const rows = await mapWithConcurrency(keys, LIST_READ_CONCURRENCY, (key) =>\n this.readRow(key),\n );\n for (const current of rows) {\n if (current && !current.env.deleted) yield current.env.record;\n }\n }.bind(this);\n\n for await (const key of this.store.listKeys(prefix)) {\n if (parseRegistryKey(this.prefix, key) === null) continue; // stray or foreign object\n page.push(key);\n if (page.length >= LIST_READ_CONCURRENCY) {\n yield* flush(page);\n page = [];\n }\n }\n if (page.length > 0) yield* flush(page);\n }\n\n async delete(ref: SegmentRef, expected?: Token): Promise<void> {\n const key = registryObjectKey(this.prefix, ref);\n // A row born with an incarnation id is removed from the store, by a delete the store applies only while the\n // object is still the version read here: a later create draws a new incarnation, so nothing of this row is needed\n // to keep its tokens apart. Any other row is tombstoned (the counter advanced, the object kept), so a re-create\n // carries the counter on. Retry the read and the write on a cross-process race; it converges, then fails typed\n // rather than silently leaving the row live.\n for (let attempt = 0; attempt < MAX_DELETE_ATTEMPTS; attempt++) {\n const current = await this.readRow(key);\n if (expected !== undefined) {\n // Fenced: the row must still carry the token the caller read. Re-checked on every attempt, so a row\n // that was swapped, or deleted and re-created, between two attempts is refused rather than tombstoned.\n if (current === null || current.env.deleted || current.env.record.token !== expected) {\n throw new WriteConflictError(`OCC token mismatch for registry row ${ref.segment}`);\n }\n } else if (current === null || current.env.deleted) {\n return; // idempotent \u2014 already gone\n }\n try {\n const remove = this.removesRows() ? this.store.delete : undefined;\n if (remove !== undefined && incarnationOf(current.env.record.token) !== undefined) {\n // The version read above is the row the caller's token names, so this removes that row and no later one.\n await remove.call(this.store, key, { version: current.version });\n return;\n }\n const token = nextRegistryToken(current.env.record, this.entropy);\n const env: RegistryEnvelope = {\n deleted: true,\n record: { ...current.env.record, token, updatedAt: this.now() },\n };\n await this.putRow(key, env, { version: current.version });\n return;\n } catch (err) {\n if (isWriteConflictError(err)) continue; // raced \u2014 re-read and try again\n throw err;\n }\n }\n throw new WriteConflictError(`registry delete: contention deleting \"${ref.segment}\" \u2014 retry`);\n }\n\n /**\n * Read + parse a registry object, returning its envelope and version fence, or null if absent.\n *\n * A store that pins a version to read consistently ({@link ObjectVersionRaced}) loses that pin whenever a\n * concurrent writer lands mid-read. Re-reading is the whole answer \u2014 the next read simply observes the\n * newer version \u2014 and it is bounded so a row under permanent write saturation fails typed instead of\n * spinning. Only a store that reads in two calls gets here; none of the shipped stores does.\n */\n private async readRow(\n objectKey: string,\n ): Promise<{ env: RegistryEnvelope; version: string } | null> {\n const row = await this.readRaced(objectKey);\n if (row === null) return null;\n if (row.bytes.length > MAX_ROW_BYTES) {\n throw new IntegrityError(\n `registry object ${row.bytes.length}B exceeds cap ${MAX_ROW_BYTES}B`,\n );\n }\n if (row.version.length === 0) {\n // The version is the OCC fence for CAS; a backend that omits it cannot be used safely as a registry.\n throw new IntegrityError(\n `registry object has no version fence (needed for compare-and-swap): ${objectKey}`,\n );\n }\n const env = parseRegistryEnvelope(new TextDecoder().decode(row.bytes), objectKey);\n return { env, version: row.version };\n }\n\n /** {@link ObjectRegistryStore.read}, retrying the bounded number of times a mid-read overwrite allows. */\n private async readRaced(objectKey: string): Promise<ObjectRow | null> {\n for (let attempt = 0; attempt < MAX_READ_ATTEMPTS; attempt++) {\n try {\n return await this.store.read(objectKey);\n } catch (err) {\n if (!(err instanceof ObjectVersionRaced)) throw err;\n }\n }\n throw new TransientError(\n `registry read: ${this.store.label} object \"${objectKey}\" was overwritten on every one of ` +\n `${MAX_READ_ATTEMPTS} attempts \u2014 retry`,\n );\n }\n\n /** Conditional write of an envelope. The store maps a lost precondition to {@link WriteConflictError}. */\n private async putRow(\n objectKey: string,\n env: RegistryEnvelope,\n expect: 'absent' | { version: string },\n ): Promise<void> {\n const body = new TextEncoder().encode(serializeRegistryEnvelope(env));\n if (body.length > MAX_ROW_BYTES) {\n // Belt-and-braces: the governance/DEK fields are already capped by validate*, so this cannot fire for\n // valid input \u2014 but never write a row that would later be unreadable.\n throw new ValidationError(`registry object ${body.length}B exceeds cap ${MAX_ROW_BYTES}B`);\n }\n await this.store.write(objectKey, body, expect);\n }\n}\n"],
4
+ "sourcesContent": ["/**\n * The registry object layout, shared by every object-store registry (S3, GCS, Azure Blob).\n *\n * One tiny JSON object per segment at `<prefix>registry/<ns>/<segment>.reg`, **registry-first** \u2014 separate\n * from the namespace-first storage layout (`<prefix><ns>/segments/\u2026`) \u2014 so discovery is a single list over\n * `registry/` for all namespaces, or `registry/<ns>/` for one, never entangled with the `.crbm` payloads.\n *\n * It lives here rather than in a driver because all three object stores encode names the same way\n * (`encodeNameForKey`, `namespaceKeyPart`) and build byte-identical keys. Three copies of this would be\n * three chances for one cloud's layout to drift from the others \u2014 and a drifted key is not a visible bug, it\n * is a segment that quietly cannot be found by a store pointed at the same bucket.\n */\nimport { ValidationError } from '@/core/errors';\nimport { validateSegmentRef } from '@/core/validate';\nimport type { SegmentRef } from '@/core/ports';\nimport { DEFAULT_NAMESPACE, decodeNameFromKey, encodeNameForKey, namespaceKeyPart } from './keys';\n\nconst REGISTRY_SUFFIX = '.reg';\n\n/** Normalize an optional caller prefix to either `''` or `trimmed/` (no leading/trailing slashes). */\nexport function prefixPart(prefix: string | undefined): string {\n if (prefix === undefined) return '';\n const trimmed = prefix.replace(/^\\/+|\\/+$/g, '');\n return trimmed === '' ? '' : `${trimmed}/`;\n}\n\n/**\n * Validate a caller-supplied key prefix (trusted config, but make it a real containment boundary): reject\n * control characters and `.`/`..` path segments so a prefix cannot traverse out of its intended space.\n * Returns it unchanged \u2014 the key builders normalize slashes.\n *\n * The traversal check covers three spellings, because the tools that walk these buckets do. A bare `..` is\n * the obvious one. A **backslash** segment matters on ADLS Gen2 and to Windows-side tooling, which treat it\n * as a separator this code otherwise would not. And a percent-encoded `..` matters because `gsutil`,\n * `s3fs`, `gcsfuse` and `azcopy` decode a key on their way to a local path \u2014 the same reasoning\n * `name-codec.ts` already applies to segment names, which a prefix has no reason to be exempt from.\n */\nexport function normalizeObjectPrefix(prefix: string | undefined): string | undefined {\n if (prefix === undefined) return undefined;\n for (const ch of prefix) {\n const code = ch.charCodeAt(0);\n // C0 plus DEL: a bare `< 0x20` lets U+007F through, and it is as unprintable as the rest.\n if (code < 0x20 || code === 0x7f) {\n throw new ValidationError('prefix must not contain control characters');\n }\n }\n if (prefix.includes('\\\\')) {\n throw new ValidationError(\n `prefix must not contain backslashes (a path separator on some backends): ${JSON.stringify(prefix)}`,\n );\n }\n // Check the decoded spelling too, so an encoded `..` cannot slip past the literal comparison.\n let decoded: string;\n try {\n decoded = decodeURIComponent(prefix);\n } catch {\n decoded = prefix; // not valid percent-encoding; the literal check still applies\n }\n for (const source of decoded === prefix ? [prefix] : [prefix, decoded]) {\n for (const segment of source.split('/')) {\n if (segment === '.' || segment === '..') {\n throw new ValidationError(\n `prefix must not contain \".\" or \"..\" path segments: ${JSON.stringify(prefix)}`,\n );\n }\n }\n }\n return prefix;\n}\n\n/** The key prefix under which every registry object lives: `<prefix>registry/`. */\nexport function registryPrefix(prefix: string | undefined): string {\n return `${prefixPart(prefix)}registry/`;\n}\n\n/** The full key of one segment's registry object: `<prefix>registry/<ns>/<segment>.reg`. */\nexport function registryObjectKey(prefix: string | undefined, ref: SegmentRef): string {\n validateSegmentRef(ref);\n return `${registryPrefix(prefix)}${namespaceKeyPart(ref.namespace)}/${encodeNameForKey(ref.segment)}${REGISTRY_SUFFIX}`;\n}\n\n/** The list prefix for discovery: registry-wide, or scoped to one namespace. */\nexport function registryListPrefix(prefix: string | undefined, namespace?: string): string {\n const base = registryPrefix(prefix);\n return namespace === undefined ? base : `${base}${namespaceKeyPart(namespace)}/`;\n}\n\n/**\n * Parse a `<prefix>registry/<ns>/<segment>.reg` key back to its {@link SegmentRef}, or `null` if it does not\n * match \u2014 a stray or foreign object under the prefix, or one whose parsed ref fails the round-trip check.\n * `_default` maps back to the absent namespace. A name is percent-encoded on the way in, so no encoded name\n * can contain `/` and the split stays unambiguous whatever the caller named their segment.\n */\nexport function parseRegistryKey(prefix: string | undefined, objectKey: string): SegmentRef | null {\n const base = registryPrefix(prefix);\n if (!objectKey.startsWith(base) || !objectKey.endsWith(REGISTRY_SUFFIX)) return null;\n const rest = objectKey.slice(base.length, objectKey.length - REGISTRY_SUFFIX.length);\n const slash = rest.indexOf('/');\n if (slash < 0) return null;\n const nsPart = rest.slice(0, slash);\n const encodedSegment = rest.slice(slash + 1);\n if (encodedSegment.length === 0 || encodedSegment.includes('/')) return null;\n const segment = decodeNameFromKey(encodedSegment);\n const namespace = nsPart === DEFAULT_NAMESPACE ? undefined : decodeNameFromKey(nsPart);\n // The encoding must ROUND-TRIP, not merely decode: a foreign object placed under our prefix could spell a\n // name two ways, and reporting both would hand a sweep one segment under two identities.\n if (encodeNameForKey(segment) !== encodedSegment) return null;\n if (namespace !== undefined && encodeNameForKey(namespace) !== nsPart) return null;\n const ref: SegmentRef = { segment, namespace };\n try {\n validateSegmentRef(ref); // reject a hostile/foreign key that isn't a valid ref\n } catch {\n return null;\n }\n return ref;\n}\n", "/**\n * `ObjectStoreRegistry` \u2014 an {@link IRegistryDriver} over any object store with conditional writes.\n *\n * Lets a **read-mostly deployment run on one bucket alone** \u2014 storage `.crbm` generations plus the registry in\n * the same place, with no separate database. One tiny JSON object per segment at\n * `<prefix>registry/<ns>/<segment>.reg` holding the `{ deleted, record }` envelope (the same shape LocalFs\n * persists). The OCC token is a random incarnation id drawn when the row is created, a counter advanced on every\n * mutation, and a random part drawn for every write, so with overwhelming probability a deleted-then-recreated row, or\n * one restored from a backup, never re-issues an old token (ABA-safe) \u2014 identical semantics to the LocalFs registry, so it passes the same\n * conformance suite.\n *\n * **`delete` removes a row for good where the store vouches for a conditional delete** (`If-Match: <etag>`,\n * `ifGenerationMatch: <generation>`), and only a row whose token carries an incarnation id: a re-create draws a new\n * incarnation, so nothing of the old row is needed to keep its tokens apart, and a full `list()` no longer reads a\n * row for every name it ever held. Every other row is **tombstoned**: the object stays, its counter advanced, and a\n * re-create carries the counter on. A row a release before 0.12 wrote has a bare decimal token and is always\n * tombstoned, since a process still on that release, re-creating the name over nothing, would start its counter at 0\n * again and issue the old row's tokens.\n *\n * **The atomic swap is offloaded to the store's conditional writes.** `create` writes only if absent (or\n * over a tombstone under its version), and `compareAndSwap`/`delete` write (or delete) only if the object still\n * carries the version we read \u2014 so a concurrent writer between our read and our write loses, and the loss surfaces\n * as {@link WriteConflictError}. No in-process lock is needed (unlike LocalFs): the precondition fences\n * writers *across processes*.\n *\n * **Why this is shared rather than written per cloud.** Every object store worth using has the same two\n * primitives under different names \u2014 S3 `If-None-Match: *` / `If-Match: <etag>`, GCS `ifGenerationMatch: 0`\n * / `ifGenerationMatch: <generation>`, Azure `If-None-Match: *` / `If-Match: <etag>` \u2014 so the only thing\n * that differs is a few I/O calls, which is what {@link ObjectRegistryStore} abstracts. The parts that are\n * genuinely hard (the ABA-safe token, the delete and its tombstone, the bounded delete retry, the round-trip key check)\n * exist once. Three copies would be three chances for one cloud to drift, and a drifted registry is not a\n * visible bug \u2014 it is a lost `currentGen` swap.\n *\n * **Deployment requirements** (a backend that violates these silently corrupts the registry):\n * - The store **must honor the conditional write**. One that accepts the precondition header and ignores it\n * degrades compare-and-swap to last-write-wins \u2192 lost `currentGen` swaps.\n * - Reads must be **strongly consistent** \u2014 true of S3 (since 2020), GCS and Azure Blob \u2014 since the registry\n * advertises `strongRead` and generation resolution depends on it.\n * - **Do not apply a lifecycle-expiration rule to the `registry/` prefix that expires a current version.**\n * Expiring a live row loses its pointer. A tombstone `delete` left keeps the counter for a re-create; one expired\n * by a rule is no worse than a row removed for good, since a recreate draws a fresh incarnation and its tokens are\n * new but for a collision of probability 2^-128 per pair of incarnations, but a rule cannot tell a tombstone from a\n * live row.\n * - **A store's conditional delete must be honoured by its backend** before the store sets `conditionalDelete`.\n * One that ignores the precondition lets two sweepers and a re-create delete a live row.\n *\n * **`list()` is fail-closed, and one bad object stops it for everyone.** An object under the `registry/`\n * prefix whose key parses but whose body does not aborts the whole enumeration \u2014 every namespace, not just\n * the affected segment \u2014 and it stays that way until an operator removes the object. The error names the\n * offending key.\n *\n * That is the deliberate choice, and it is the *safer* one rather than the more convenient one. Skipping the\n * unreadable row would keep discovery running, but `list()` is what tells orphan generation collection which\n * segments exist: a row missing from the enumeration makes its storage `.crbm` generations look unreferenced,\n * and the next GC pass would delete them. Silently skipping turns a parse error into data loss. Refusing to\n * enumerate keeps every sweep off a set it cannot vouch for (invariant 5 \u2014 bytes from the tier are\n * untrusted). **Recovery:** read the named object, and either restore a valid envelope or delete it; nothing\n * else is damaged, and discovery resumes on its own.\n */\nimport {\n IntegrityError,\n TransientError,\n ValidationError,\n WriteConflictError,\n isWriteConflictError,\n} from '@/core/errors';\nimport type {\n IRegistryDriver,\n NewRegistryRecord,\n RegCaps,\n RegistryPatch,\n RegistryRecord,\n SegmentRef,\n Token,\n} from '@/core/ports';\nimport type { Entropy } from '@/core/determinism';\nimport { mapWithConcurrency } from '@/core/concurrency';\nimport {\n applyRegistryPatch,\n incarnationOf,\n newIncarnationToken,\n nextRegistryToken,\n parseRegistryEnvelope,\n recordFromNew,\n serializeRegistryEnvelope,\n validateNewRegistryRecord,\n validateRegistryPatch,\n type RegistryEnvelope,\n} from './registry';\nimport { entropyIsAvailable, webCryptoEntropy } from './entropy';\nimport { parseRegistryKey, registryListPrefix, registryObjectKey } from './object-registry-keys';\n\n/** Defensive cap on a single registry object read from storage, before allocation (rows are tiny; ~1 KB). */\nexport const MAX_ROW_BYTES = 1 * 1024 * 1024;\n/** Bounded retry for `delete`'s read then delete or tombstone, under cross-process contention (then fails typed). */\nconst MAX_DELETE_ATTEMPTS = 8;\n/** Bounded re-read when a store's pinned version is overwritten mid-read (see {@link ObjectVersionRaced}). */\nconst MAX_READ_ATTEMPTS = 8;\n/** In-flight reads per `list()` page \u2014 turns the serial N+1 into one list plus bounded parallel reads. */\nconst LIST_READ_CONCURRENCY = 48;\n\n/**\n * Raised by a store when the version it pinned for a read vanished before it could fetch the bytes \u2014\n * a concurrent writer overwrote or deleted the object in between.\n *\n * This is deliberately **neither** of the two things it superficially resembles. It is not absence: the row\n * is very likely still live, just one version further on. And it is not a write conflict: the caller may be\n * a read-only `get()`, which has nothing to conflict with. Either is an easy misreading for a store that reads a\n * row in two calls, metadata and then bytes pinned to it, which is why the signal is part of the port rather than\n * left to each store's judgement. {@link ObjectStoreRegistry} answers it the only way that is correct for every\n * caller: it reads again.\n *\n * Stores whose read is atomic can never raise it, and every shipped store's is: S3 serves bytes and `ETag` from one\n * `GetObject`, GCS bytes and `generation` from one GET, and Azure Blob bytes and `ETag` from one GET.\n */\nexport class ObjectVersionRaced extends Error {\n constructor(objectKey: string) {\n super(`registry object was overwritten mid-read: ${objectKey}`);\n this.name = 'ObjectVersionRaced';\n }\n}\n\n/** One object as read, with the opaque version that fences a later conditional write. */\nexport interface ObjectRow {\n readonly bytes: Uint8Array;\n /** The store's version fence \u2014 an S3/Azure ETag, a GCS generation. Opaque to this class. */\n readonly version: string;\n}\n\n/**\n * The I/O calls an object store must provide to host a registry: three, and an optional conditional delete. Everything\n * else \u2014 the OCC token, tombstones, the delete retry, key parsing \u2014 is implemented once above.\n */\nexport interface ObjectRegistryStore {\n /** A label for error messages, e.g. `GCS` or `Azure Blob`. */\n readonly label: string;\n /**\n * Read one object, or `null` when it is absent.\n *\n * **`null` means the object does not exist** \u2014 nothing weaker. A store that reads in two round trips\n * (metadata for the version fence, then the bytes pinned to it) can find that version already gone; it\n * MUST report that with {@link ObjectVersionRaced} rather than `null` or {@link WriteConflictError}, and\n * the caller re-reads. Retryable faults throw {@link TransientError}.\n *\n * `bytes` and `version` MUST describe the **same** observation of the object: `version` is the fence a\n * later conditional write is conditioned on, so a pair drawn from two different versions would either\n * fail a write that should have succeeded or, worse, pass one that should not.\n */\n read(key: string): Promise<ObjectRow | null>;\n /**\n * Write one object under a precondition: `'absent'` means create-only, a version means\n * compare-and-swap against exactly that version. A lost race MUST throw {@link WriteConflictError}.\n *\n * A store must not replay this write after a lost response without telling the replay apart: the replay meets\n * the row it just wrote and its precondition fails, which would read as a lost race. Send it once, or store a\n * write id with the row and treat a conflict whose stored row carries it as success. Retryable faults throw\n * {@link TransientError}.\n */\n write(key: string, body: Uint8Array, expect: 'absent' | { version: string }): Promise<void>;\n /** Every object key under `prefix`, paginated internally. */\n listKeys(prefix: string): AsyncIterable<string>;\n /**\n * Optional: delete one object, and only while it is still at exactly `expect.version`, by a precondition the\n * backend applies (`If-Match: <etag>`, `ifGenerationMatch: <generation>`). An object that was overwritten, or is\n * gone, MUST throw {@link WriteConflictError} and delete nothing. Retryable faults throw {@link TransientError}.\n *\n * Sending it again after a lost response is safe, unlike a write: the precondition names one version, so a second\n * copy can remove nothing the first was not allowed to. A copy that meets its own landed delete fails the\n * precondition and reports a conflict; the registry re-reads, and finds the row gone.\n *\n * The registry calls it only when {@link conditionalDelete} is `true`.\n */\n delete?(key: string, expect: { version: string }): Promise<void>;\n /**\n * Whether {@link delete} may be relied on: `true` only where the backend is known to apply the precondition. A\n * backend that accepts the header and ignores it would let a delete remove a row written after the delete read it,\n * a live one included. Absent or `false`, the registry never calls `delete` and tombstones every row instead.\n */\n readonly conditionalDelete?: boolean;\n}\n\nexport class ObjectStoreRegistry implements IRegistryDriver {\n /**\n * `entropy` draws the random parts of every token: a new row's incarnation id, and each write's own part. It\n * defaults to the platform's Web Crypto. Inject one only to make a test replayable: a seeded source in production\n * gives every process the same ids.\n */\n constructor(\n private readonly store: ObjectRegistryStore,\n private readonly prefix: string | undefined,\n private readonly now: () => number,\n private readonly entropy: Entropy = webCryptoEntropy,\n ) {}\n\n capabilities(): RegCaps {\n const conditionalDelete = this.removesRows();\n return entropyIsAvailable(this.entropy)\n ? { strongRead: true, conditionalDelete }\n : { strongRead: true, canWrite: false, conditionalDelete };\n }\n\n /** Whether a delete removes a row born with an incarnation id: the store has a delete, and vouches for it. */\n private removesRows(): boolean {\n return this.store.conditionalDelete === true && typeof this.store.delete === 'function';\n }\n\n async get(ref: SegmentRef): Promise<RegistryRecord | null> {\n const current = await this.readRow(registryObjectKey(this.prefix, ref));\n return current && !current.env.deleted ? current.env.record : null;\n }\n\n async create(ref: SegmentRef, record: NewRegistryRecord): Promise<{ token: Token }> {\n const checked = validateNewRegistryRecord(record);\n const key = registryObjectKey(this.prefix, ref);\n const current = await this.readRow(key);\n if (current !== null && !current.env.deleted) {\n throw new WriteConflictError(`registry row already exists for segment ${ref.segment}`);\n }\n // A new incarnation, whose counter continues across a tombstone, so with overwhelming probability a recreate never re-issues an old token.\n const token = newIncarnationToken(this.entropy, current?.env.record);\n const env: RegistryEnvelope = {\n deleted: false,\n record: recordFromNew(ref, checked, this.now(), token),\n };\n // Create-only when truly absent; overwrite the tombstone under its version when recreating. Either way\n // a concurrent create loses the precondition and surfaces as a conflict.\n await this.putRow(key, env, current ? { version: current.version } : 'absent');\n return { token };\n }\n\n async compareAndSwap(\n ref: SegmentRef,\n expected: Token,\n patch: RegistryPatch,\n ): Promise<{ token: Token }> {\n const checked = validateRegistryPatch(patch);\n const key = registryObjectKey(this.prefix, ref);\n const current = await this.readRow(key);\n if (current === null || current.env.deleted || current.env.record.token !== expected) {\n throw new WriteConflictError(`OCC token mismatch for registry row ${ref.segment}`);\n }\n const token = nextRegistryToken(current.env.record, this.entropy);\n const env: RegistryEnvelope = {\n deleted: false,\n record: applyRegistryPatch(current.env.record, checked, this.now(), token),\n };\n // The version we read fences a concurrent writer between that read and this write.\n await this.putRow(key, env, { version: current.version });\n return { token };\n }\n\n async *list(namespace?: string): AsyncIterable<RegistryRecord> {\n const prefix = registryListPrefix(this.prefix, namespace);\n // One list plus bounded parallel reads. A serial await-in-loop made this O(N) sequential round trips.\n let page: string[] = [];\n const flush = async function* (\n this: ObjectStoreRegistry,\n keys: string[],\n ): AsyncIterable<RegistryRecord> {\n const rows = await mapWithConcurrency(keys, LIST_READ_CONCURRENCY, (key) =>\n this.readRow(key),\n );\n for (const current of rows) {\n if (current && !current.env.deleted) yield current.env.record;\n }\n }.bind(this);\n\n for await (const key of this.store.listKeys(prefix)) {\n if (parseRegistryKey(this.prefix, key) === null) continue; // stray or foreign object\n page.push(key);\n if (page.length >= LIST_READ_CONCURRENCY) {\n yield* flush(page);\n page = [];\n }\n }\n if (page.length > 0) yield* flush(page);\n }\n\n async delete(ref: SegmentRef, expected?: Token): Promise<void> {\n const key = registryObjectKey(this.prefix, ref);\n // A row born with an incarnation id is removed from the store, by a delete the store applies only while the\n // object is still the version read here: a later create draws a new incarnation, so nothing of this row is needed\n // to keep its tokens apart. Any other row is tombstoned (the counter advanced, the object kept), so a re-create\n // carries the counter on. Retry the read and the write on a cross-process race; it converges, then fails typed\n // rather than silently leaving the row live.\n for (let attempt = 0; attempt < MAX_DELETE_ATTEMPTS; attempt++) {\n const current = await this.readRow(key);\n if (expected !== undefined) {\n // Fenced: the row must still carry the token the caller read. Re-checked on every attempt, so a row\n // that was swapped, or deleted and re-created, between two attempts is refused rather than tombstoned.\n if (current === null || current.env.deleted || current.env.record.token !== expected) {\n throw new WriteConflictError(`OCC token mismatch for registry row ${ref.segment}`);\n }\n } else if (current === null || current.env.deleted) {\n return; // idempotent \u2014 already gone\n }\n try {\n const remove = this.removesRows() ? this.store.delete : undefined;\n if (remove !== undefined && incarnationOf(current.env.record.token) !== undefined) {\n // The version read above is the row the caller's token names, so this removes that row and no later one.\n await remove.call(this.store, key, { version: current.version });\n return;\n }\n const token = nextRegistryToken(current.env.record, this.entropy);\n const env: RegistryEnvelope = {\n deleted: true,\n record: { ...current.env.record, token, updatedAt: this.now() },\n };\n await this.putRow(key, env, { version: current.version });\n return;\n } catch (err) {\n if (isWriteConflictError(err)) continue; // raced \u2014 re-read and try again\n throw err;\n }\n }\n throw new WriteConflictError(`registry delete: contention deleting \"${ref.segment}\" \u2014 retry`);\n }\n\n /**\n * Read + parse a registry object, returning its envelope and version fence, or null if absent.\n *\n * A store that pins a version to read consistently ({@link ObjectVersionRaced}) loses that pin whenever a\n * concurrent writer lands mid-read. Re-reading is the whole answer \u2014 the next read simply observes the\n * newer version \u2014 and it is bounded so a row under permanent write saturation fails typed instead of\n * spinning. Only a store that reads in two calls gets here; none of the shipped stores does.\n */\n private async readRow(\n objectKey: string,\n ): Promise<{ env: RegistryEnvelope; version: string } | null> {\n const row = await this.readRaced(objectKey);\n if (row === null) return null;\n if (row.bytes.length > MAX_ROW_BYTES) {\n throw new IntegrityError(\n `registry object ${row.bytes.length}B exceeds cap ${MAX_ROW_BYTES}B`,\n );\n }\n if (row.version.length === 0) {\n // The version is the OCC fence for CAS; a backend that omits it cannot be used safely as a registry.\n throw new IntegrityError(\n `registry object has no version fence (needed for compare-and-swap): ${objectKey}`,\n );\n }\n const env = parseRegistryEnvelope(new TextDecoder().decode(row.bytes), objectKey);\n return { env, version: row.version };\n }\n\n /** {@link ObjectRegistryStore.read}, retrying the bounded number of times a mid-read overwrite allows. */\n private async readRaced(objectKey: string): Promise<ObjectRow | null> {\n for (let attempt = 0; attempt < MAX_READ_ATTEMPTS; attempt++) {\n try {\n return await this.store.read(objectKey);\n } catch (err) {\n if (!(err instanceof ObjectVersionRaced)) throw err;\n }\n }\n throw new TransientError(\n `registry read: ${this.store.label} object \"${objectKey}\" was overwritten on every one of ` +\n `${MAX_READ_ATTEMPTS} attempts \u2014 retry`,\n );\n }\n\n /** Conditional write of an envelope. The store maps a lost precondition to {@link WriteConflictError}. */\n private async putRow(\n objectKey: string,\n env: RegistryEnvelope,\n expect: 'absent' | { version: string },\n ): Promise<void> {\n const body = new TextEncoder().encode(serializeRegistryEnvelope(env));\n if (body.length > MAX_ROW_BYTES) {\n // Belt-and-braces: the governance/DEK fields are already capped by validate*, so this cannot fire for\n // valid input \u2014 but never write a row that would later be unreadable.\n throw new ValidationError(`registry object ${body.length}B exceeds cap ${MAX_ROW_BYTES}B`);\n }\n await this.store.write(objectKey, body, expect);\n }\n}\n"],
5
5
  "mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiBA,IAAM,kBAAkB;AAGjB,SAAS,WAAW,QAAoC;AAC7D,MAAI,WAAW,OAAW,QAAO;AACjC,QAAM,UAAU,OAAO,QAAQ,cAAc,EAAE;AAC/C,SAAO,YAAY,KAAK,KAAK,GAAG,OAAO;AACzC;AAaO,SAAS,sBAAsB,QAAgD;AACpF,MAAI,WAAW,OAAW,QAAO;AACjC,aAAW,MAAM,QAAQ;AACvB,UAAM,OAAO,GAAG,WAAW,CAAC;AAE5B,QAAI,OAAO,MAAQ,SAAS,KAAM;AAChC,YAAM,IAAI,gBAAgB,4CAA4C;AAAA,IACxE;AAAA,EACF;AACA,MAAI,OAAO,SAAS,IAAI,GAAG;AACzB,UAAM,IAAI;AAAA,MACR,4EAA4E,KAAK,UAAU,MAAM,CAAC;AAAA,IACpG;AAAA,EACF;AAEA,MAAI;AACJ,MAAI;AACF,cAAU,mBAAmB,MAAM;AAAA,EACrC,QAAQ;AACN,cAAU;AAAA,EACZ;AACA,aAAW,UAAU,YAAY,SAAS,CAAC,MAAM,IAAI,CAAC,QAAQ,OAAO,GAAG;AACtE,eAAW,WAAW,OAAO,MAAM,GAAG,GAAG;AACvC,UAAI,YAAY,OAAO,YAAY,MAAM;AACvC,cAAM,IAAI;AAAA,UACR,sDAAsD,KAAK,UAAU,MAAM,CAAC;AAAA,QAC9E;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAGO,SAAS,eAAe,QAAoC;AACjE,SAAO,GAAG,WAAW,MAAM,CAAC;AAC9B;AAGO,SAAS,kBAAkB,QAA4B,KAAyB;AACrF,qBAAmB,GAAG;AACtB,SAAO,GAAG,eAAe,MAAM,CAAC,GAAG,iBAAiB,IAAI,SAAS,CAAC,IAAI,iBAAiB,IAAI,OAAO,CAAC,GAAG,eAAe;AACvH;AAGO,SAAS,mBAAmB,QAA4B,WAA4B;AACzF,QAAM,OAAO,eAAe,MAAM;AAClC,SAAO,cAAc,SAAY,OAAO,GAAG,IAAI,GAAG,iBAAiB,SAAS,CAAC;AAC/E;AAQO,SAAS,iBAAiB,QAA4B,WAAsC;AACjG,QAAM,OAAO,eAAe,MAAM;AAClC,MAAI,CAAC,UAAU,WAAW,IAAI,KAAK,CAAC,UAAU,SAAS,eAAe,EAAG,QAAO;AAChF,QAAM,OAAO,UAAU,MAAM,KAAK,QAAQ,UAAU,SAAS,gBAAgB,MAAM;AACnF,QAAM,QAAQ,KAAK,QAAQ,GAAG;AAC9B,MAAI,QAAQ,EAAG,QAAO;AACtB,QAAM,SAAS,KAAK,MAAM,GAAG,KAAK;AAClC,QAAM,iBAAiB,KAAK,MAAM,QAAQ,CAAC;AAC3C,MAAI,eAAe,WAAW,KAAK,eAAe,SAAS,GAAG,EAAG,QAAO;AACxE,QAAM,UAAU,kBAAkB,cAAc;AAChD,QAAM,YAAY,WAAW,oBAAoB,SAAY,kBAAkB,MAAM;AAGrF,MAAI,iBAAiB,OAAO,MAAM,eAAgB,QAAO;AACzD,MAAI,cAAc,UAAa,iBAAiB,SAAS,MAAM,OAAQ,QAAO;AAC9E,QAAM,MAAkB,EAAE,SAAS,UAAU;AAC7C,MAAI;AACF,uBAAmB,GAAG;AAAA,EACxB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,SAAO;AACT;;;ACtBO,IAAM,gBAAgB,IAAI,OAAO;AAExC,IAAM,sBAAsB;AAE5B,IAAM,oBAAoB;AAE1B,IAAM,wBAAwB;AAgBvB,IAAM,qBAAN,cAAiC,MAAM;AAAA,EAC5C,YAAY,WAAmB;AAC7B,UAAM,6CAA6C,SAAS,EAAE;AAC9D,SAAK,OAAO;AAAA,EACd;AACF;AA6DO,IAAM,sBAAN,MAAqD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAM1D,YACmB,OACA,QACA,KACA,UAAmB,kBACpC;AAJiB;AACA;AACA;AACA;AAAA,EAChB;AAAA,EAJgB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAGnB,eAAwB;AACtB,UAAM,oBAAoB,KAAK,YAAY;AAC3C,WAAO,mBAAmB,KAAK,OAAO,IAClC,EAAE,YAAY,MAAM,kBAAkB,IACtC,EAAE,YAAY,MAAM,UAAU,OAAO,kBAAkB;AAAA,EAC7D;AAAA;AAAA,EAGQ,cAAuB;AAC7B,WAAO,KAAK,MAAM,sBAAsB,QAAQ,OAAO,KAAK,MAAM,WAAW;AAAA,EAC/E;AAAA,EAEA,MAAM,IAAI,KAAiD;AACzD,UAAM,UAAU,MAAM,KAAK,QAAQ,kBAAkB,KAAK,QAAQ,GAAG,CAAC;AACtE,WAAO,WAAW,CAAC,QAAQ,IAAI,UAAU,QAAQ,IAAI,SAAS;AAAA,EAChE;AAAA,EAEA,MAAM,OAAO,KAAiB,QAAsD;AAClF,UAAM,UAAU,0BAA0B,MAAM;AAChD,UAAM,MAAM,kBAAkB,KAAK,QAAQ,GAAG;AAC9C,UAAM,UAAU,MAAM,KAAK,QAAQ,GAAG;AACtC,QAAI,YAAY,QAAQ,CAAC,QAAQ,IAAI,SAAS;AAC5C,YAAM,IAAI,mBAAmB,2CAA2C,IAAI,OAAO,EAAE;AAAA,IACvF;AAEA,UAAM,QAAQ,oBAAoB,KAAK,SAAS,SAAS,IAAI,MAAM;AACnE,UAAM,MAAwB;AAAA,MAC5B,SAAS;AAAA,MACT,QAAQ,cAAc,KAAK,SAAS,KAAK,IAAI,GAAG,KAAK;AAAA,IACvD;AAGA,UAAM,KAAK,OAAO,KAAK,KAAK,UAAU,EAAE,SAAS,QAAQ,QAAQ,IAAI,QAAQ;AAC7E,WAAO,EAAE,MAAM;AAAA,EACjB;AAAA,EAEA,MAAM,eACJ,KACA,UACA,OAC2B;AAC3B,UAAM,UAAU,sBAAsB,KAAK;AAC3C,UAAM,MAAM,kBAAkB,KAAK,QAAQ,GAAG;AAC9C,UAAM,UAAU,MAAM,KAAK,QAAQ,GAAG;AACtC,QAAI,YAAY,QAAQ,QAAQ,IAAI,WAAW,QAAQ,IAAI,OAAO,UAAU,UAAU;AACpF,YAAM,IAAI,mBAAmB,uCAAuC,IAAI,OAAO,EAAE;AAAA,IACnF;AACA,UAAM,QAAQ,kBAAkB,QAAQ,IAAI,QAAQ,KAAK,OAAO;AAChE,UAAM,MAAwB;AAAA,MAC5B,SAAS;AAAA,MACT,QAAQ,mBAAmB,QAAQ,IAAI,QAAQ,SAAS,KAAK,IAAI,GAAG,KAAK;AAAA,IAC3E;AAEA,UAAM,KAAK,OAAO,KAAK,KAAK,EAAE,SAAS,QAAQ,QAAQ,CAAC;AACxD,WAAO,EAAE,MAAM;AAAA,EACjB;AAAA,EAEA,OAAO,KAAK,WAAmD;AAC7D,UAAM,SAAS,mBAAmB,KAAK,QAAQ,SAAS;AAExD,QAAI,OAAiB,CAAC;AACtB,UAAM,QAAQ,iBAEZ,MAC+B;AAC/B,YAAM,OAAO,MAAM;AAAA,QAAmB;AAAA,QAAM;AAAA,QAAuB,CAAC,QAClE,KAAK,QAAQ,GAAG;AAAA,MAClB;AACA,iBAAW,WAAW,MAAM;AAC1B,YAAI,WAAW,CAAC,QAAQ,IAAI,QAAS,OAAM,QAAQ,IAAI;AAAA,MACzD;AAAA,IACF,EAAE,KAAK,IAAI;AAEX,qBAAiB,OAAO,KAAK,MAAM,SAAS,MAAM,GAAG;AACnD,UAAI,iBAAiB,KAAK,QAAQ,GAAG,MAAM,KAAM;AACjD,WAAK,KAAK,GAAG;AACb,UAAI,KAAK,UAAU,uBAAuB;AACxC,eAAO,MAAM,IAAI;AACjB,eAAO,CAAC;AAAA,MACV;AAAA,IACF;AACA,QAAI,KAAK,SAAS,EAAG,QAAO,MAAM,IAAI;AAAA,EACxC;AAAA,EAEA,MAAM,OAAO,KAAiB,UAAiC;AAC7D,UAAM,MAAM,kBAAkB,KAAK,QAAQ,GAAG;AAM9C,aAAS,UAAU,GAAG,UAAU,qBAAqB,WAAW;AAC9D,YAAM,UAAU,MAAM,KAAK,QAAQ,GAAG;AACtC,UAAI,aAAa,QAAW;AAG1B,YAAI,YAAY,QAAQ,QAAQ,IAAI,WAAW,QAAQ,IAAI,OAAO,UAAU,UAAU;AACpF,gBAAM,IAAI,mBAAmB,uCAAuC,IAAI,OAAO,EAAE;AAAA,QACnF;AAAA,MACF,WAAW,YAAY,QAAQ,QAAQ,IAAI,SAAS;AAClD;AAAA,MACF;AACA,UAAI;AACF,cAAM,SAAS,KAAK,YAAY,IAAI,KAAK,MAAM,SAAS;AACxD,YAAI,WAAW,UAAa,cAAc,QAAQ,IAAI,OAAO,KAAK,MAAM,QAAW;AAEjF,gBAAM,OAAO,KAAK,KAAK,OAAO,KAAK,EAAE,SAAS,QAAQ,QAAQ,CAAC;AAC/D;AAAA,QACF;AACA,cAAM,QAAQ,kBAAkB,QAAQ,IAAI,QAAQ,KAAK,OAAO;AAChE,cAAM,MAAwB;AAAA,UAC5B,SAAS;AAAA,UACT,QAAQ,EAAE,GAAG,QAAQ,IAAI,QAAQ,OAAO,WAAW,KAAK,IAAI,EAAE;AAAA,QAChE;AACA,cAAM,KAAK,OAAO,KAAK,KAAK,EAAE,SAAS,QAAQ,QAAQ,CAAC;AACxD;AAAA,MACF,SAAS,KAAK;AACZ,YAAI,qBAAqB,GAAG,EAAG;AAC/B,cAAM;AAAA,MACR;AAAA,IACF;AACA,UAAM,IAAI,mBAAmB,yCAAyC,IAAI,OAAO,gBAAW;AAAA,EAC9F;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,MAAc,QACZ,WAC4D;AAC5D,UAAM,MAAM,MAAM,KAAK,UAAU,SAAS;AAC1C,QAAI,QAAQ,KAAM,QAAO;AACzB,QAAI,IAAI,MAAM,SAAS,eAAe;AACpC,YAAM,IAAI;AAAA,QACR,mBAAmB,IAAI,MAAM,MAAM,iBAAiB,aAAa;AAAA,MACnE;AAAA,IACF;AACA,QAAI,IAAI,QAAQ,WAAW,GAAG;AAE5B,YAAM,IAAI;AAAA,QACR,uEAAuE,SAAS;AAAA,MAClF;AAAA,IACF;AACA,UAAM,MAAM,sBAAsB,IAAI,YAAY,EAAE,OAAO,IAAI,KAAK,GAAG,SAAS;AAChF,WAAO,EAAE,KAAK,SAAS,IAAI,QAAQ;AAAA,EACrC;AAAA;AAAA,EAGA,MAAc,UAAU,WAA8C;AACpE,aAAS,UAAU,GAAG,UAAU,mBAAmB,WAAW;AAC5D,UAAI;AACF,eAAO,MAAM,KAAK,MAAM,KAAK,SAAS;AAAA,MACxC,SAAS,KAAK;AACZ,YAAI,EAAE,eAAe,oBAAqB,OAAM;AAAA,MAClD;AAAA,IACF;AACA,UAAM,IAAI;AAAA,MACR,kBAAkB,KAAK,MAAM,KAAK,YAAY,SAAS,qCAClD,iBAAiB;AAAA,IACxB;AAAA,EACF;AAAA;AAAA,EAGA,MAAc,OACZ,WACA,KACA,QACe;AACf,UAAM,OAAO,IAAI,YAAY,EAAE,OAAO,0BAA0B,GAAG,CAAC;AACpE,QAAI,KAAK,SAAS,eAAe;AAG/B,YAAM,IAAI,gBAAgB,mBAAmB,KAAK,MAAM,iBAAiB,aAAa,GAAG;AAAA,IAC3F;AACA,UAAM,KAAK,MAAM,MAAM,WAAW,MAAM,MAAM;AAAA,EAChD;AACF;",
6
6
  "names": []
7
7
  }
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import type { Clock, Rng } from '../../core/determinism.js';
23
23
  import type { RetryPolicy } from '../../core/retry.js';
24
- import type { ChunkRef, GenerationSummary, StorageChunkSource, SegmentRef, SegmentSize } from '../../core/ports.js';
24
+ import type { ChunkRef, ChunkRead, ReadChunksOptions, GenerationSummary, StorageChunkSource, SegmentRef, SegmentSize } from '../../core/ports.js';
25
25
  export interface RetryingOptions {
26
26
  readonly clock: Clock;
27
27
  readonly rng: Rng;
@@ -43,6 +43,7 @@ export declare class RetryingStorageChunkSource implements StorageChunkSource {
43
43
  private readonly deps;
44
44
  /** Present only when the inner source supports it — so capability detection stays honest. */
45
45
  readonly sizeOf?: (ref: SegmentRef) => Promise<SegmentSize | null>;
46
+ readonly getChunks?: (ref: SegmentRef, keys: readonly number[], options?: ReadChunksOptions) => AsyncIterable<ChunkRead>;
46
47
  readonly cardinalities?: (ref: SegmentRef) => Promise<ReadonlyMap<number, number> | null>;
47
48
  readonly summary?: (ref: SegmentRef) => Promise<GenerationSummary | null>;
48
49
  readonly currentGeneration?: (ref: SegmentRef) => Promise<number | null>;