@cloudbitmaps/core 0.11.0 → 0.11.2

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/README.md CHANGED
@@ -7,7 +7,7 @@ when you install the two packages you want. Install those instead:
7
7
  pnpm add @cloudbitmaps/roaring @cloudbitmaps/s3 # the store, and the storage you have (or /gcs, or /azure-blob)
8
8
  ```
9
9
 
10
- On pnpm 10 and later, allow the one build script first, or the package throws at `import` while the install exits 0. Put this in your `package.json`: `{ "pnpm": { "onlyBuiltDependencies": ["roaring"] } }`. npm and pnpm 9 need nothing extra. [Details](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/getting-started.md#cannot-find-module-buildreleaseroaringnode-after-a-successful-install).
10
+ On npm 12 and pnpm 10 and later, allow `roaring`'s one install script first, or the install exits 0 and the package throws at `import`. Put this in your `package.json`: `{ "allowScripts": { "roaring": true }, "pnpm": { "onlyBuiltDependencies": ["roaring"] } }`. npm 11 runs the script but warns until you allow it the same way; pnpm 9 needs nothing extra. [Details](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/getting-started.md#cannot-find-module-buildreleaseroaringnode-after-a-successful-install).
11
11
 
12
12
  Depend on `@cloudbitmaps/core` directly only to write a flavor or a driver.
13
13
 
@@ -9,8 +9,8 @@ export interface PricingProfile {
9
9
  readonly storagePerGiBMonth: number;
10
10
  /**
11
11
  * Requests one read costs when it needs the object's size first: a pointer read, and a segment's tail read.
12
- * Default **1**, S3's, whose suffix-range GET returns the size with the bytes. **2** on GCS and Azure Blob,
13
- * which read the metadata and then the bytes. A chunk read knows its range, and is one request everywhere.
12
+ * Default **1**, S3's, whose suffix-range GET returns the size with the bytes. **2** on Azure Blob,
13
+ * which reads the properties and then the bytes; GCS answers in one request, like S3. A chunk read knows its range, and is one request everywhere.
14
14
  */
15
15
  readonly requestsPerSizedRead?: number;
16
16
  };
@@ -1,12 +1,32 @@
1
1
  import type { BlobReader } from '../blob.js';
2
2
  import type { CrbmCrypto } from '../crypto.js';
3
- export interface CrbmIndexEntry {
4
- readonly chunkKey: number;
5
- readonly offset: number;
6
- readonly length: number;
7
- readonly cardinality: number;
8
- readonly crc32c: number;
3
+ /**
4
+ * A parsed index as parallel typed arrays, one slot per entry, ascending by key. Typed arrays hold each field at
5
+ * its own width with no per-entry object, so the retained heap is the arrays' byte length and nothing else.
6
+ * `cardinalityMinusOne` stores `cardinality - 1` because a chunk's cardinality runs `[1, 65536]` and 65,536 does
7
+ * not fit a `u16`; an offset is a `f64` because an object past 4 GiB puts a payload beyond a `u32`.
8
+ */
9
+ export interface ParsedIndex {
10
+ readonly keys: Uint16Array;
11
+ readonly cardinalityMinusOne: Uint16Array;
12
+ readonly lengths: Uint32Array;
13
+ readonly crcs: Uint32Array;
14
+ readonly offsets: Float64Array;
15
+ /** Σ cardinality over every entry. */
16
+ readonly cardinalitySum: number;
9
17
  }
18
+ /**
19
+ * Retained JS heap for one parsed index entry, in bytes: the `u16` key, the `u16` cardinality-minus-one, the `u32`
20
+ * length, the `u32` CRC and the `f64` offset of {@link ParsedIndex}. It is the exact byte length of the arrays
21
+ * (checked against the measured heap by a test), and weights the reader-cache byte bound
22
+ * ({@link CrbmReader.retainedIndexBytes}).
23
+ */
24
+ export declare const RETAINED_BYTES_PER_INDEX_ENTRY = 20;
25
+ /**
26
+ * How many entries `parseIndex` makes room for, from the index's length alone: what its bytes could hold at the
27
+ * smallest record, and never more than one entry per 16-bit key. A hostile index cannot make the reader allocate more.
28
+ */
29
+ export declare function indexCapacity(indexLength: number): number;
10
30
  export interface CrbmReaderOptions {
11
31
  /** Speculative tail size in bytes (default 256 KB; clamped up to at least the footer size). */
12
32
  readonly tailBytes?: number;
@@ -31,8 +51,7 @@ export declare class CrbmReader {
31
51
  /** See {@link CrbmReaderOptions.lineage} — carried, never interpreted. */
32
52
  readonly lineage: unknown;
33
53
  readonly totalCardinality: number;
34
- private readonly entries;
35
- private readonly orderedKeys;
54
+ private readonly index;
36
55
  /** True when `open()` satisfied the index from the tail GET alone (no second range read). */
37
56
  readonly servedFromTail: boolean;
38
57
  /** Set iff the object is encrypted — used to decrypt each chunk payload in {@link getChunk}. */
@@ -49,9 +68,9 @@ export declare class CrbmReader {
49
68
  /** Total object bytes (from the one-GET tail read) — for grounded storage cost. */
50
69
  get sizeBytes(): number;
51
70
  /**
52
- * Estimated retained JS heap of this reader's parsed index — the weight the storage reader cache bounds on
53
- * (a wide segment's parsed index, not its payloads, dominates the reader's footprint). `entries.size` scales with the number
54
- * of resident chunks (≤ 65536), so this is `entries.size × {@link RETAINED_BYTES_PER_INDEX_ENTRY}`.
71
+ * Retained JS heap of this reader's parsed index — the weight the storage reader cache bounds on (a wide
72
+ * segment's parsed index, not its payloads, dominates the reader's footprint): the exact byte length of the
73
+ * arrays that hold it, {@link RETAINED_BYTES_PER_INDEX_ENTRY} per entry.
55
74
  */
56
75
  get retainedIndexBytes(): number;
57
76
  /** Per-chunk cardinality (`chunkKey → count`) from the parsed index — no payload reads. */
@@ -70,6 +89,8 @@ export declare class CrbmReader {
70
89
  chunkKeys(): number[];
71
90
  /** Segment cardinality from the footer (no payload reads) — the cheap `count()` path. */
72
91
  count(): number;
92
+ /** The slot holding `chunkKey` (binary search over the ascending keys), or -1 when this generation has none. */
93
+ private slotOf;
73
94
  has(chunkKey: number): boolean;
74
95
  /**
75
96
  * Range-GET one chunk's payload, verifying its CRC32C before returning. Returns `null` if the chunk is
@@ -79,8 +100,4 @@ export declare class CrbmReader {
79
100
  */
80
101
  getChunk(chunkKey: number): Promise<Uint8Array | null>;
81
102
  }
82
- export declare function parseIndex(indexBytes: Uint8Array, payloadEnd: number, maxPayloadBytes: number, minPayloadBytes?: number): {
83
- entries: Map<number, CrbmIndexEntry>;
84
- orderedKeys: number[];
85
- cardinalitySum: number;
86
- };
103
+ export declare function parseIndex(indexBytes: Uint8Array, payloadEnd: number, maxPayloadBytes: number, minPayloadBytes?: number): ParsedIndex;
@@ -56,7 +56,7 @@ export interface CrbmStorageChunkSourceOptions extends CrbmReaderOptions {
56
56
  /**
57
57
  * Aggregate byte ceiling on the parsed `.crbm` indices resident in the reader cache (default 64 MiB) — the
58
58
  * **second half of that memory bound**. `maxOpenSegments` alone bounds by *count*, but a wide/dense segment's
59
- * parsed index can be several MB, so 1024 wide indices could pin ~GBs and blow a small heap (e.g. a 128 MB
59
+ * 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
60
60
  * Lambda) while the count is nominally "in bounds". This caps the summed {@link CrbmReader.retainedIndexBytes}
61
61
  * across cached readers; the least-recently-used reader is evicted once the total would exceed it — whichever
62
62
  * of the count/byte bounds binds first. Lower it for memory-tight deployments with wide segments; a single
@@ -89,8 +89,11 @@ export declare class SegmentEngine {
89
89
  get pointerRefreshMs(): number | undefined;
90
90
  /** Grounded Storage size of a segment's current generation (cost reporting), or null if it has no generation. */
91
91
  segmentSize(seg: SegmentRef): Promise<SegmentSize | null>;
92
+ /** The chunks of `seg` at `chunkKeys`, read through a window of {@link DEFAULT_INTERSECT_CONCURRENCY}. */
93
+ private chunkWindow;
92
94
  /**
93
- * Every id, ascending, one chunk at a time; with `range`, only the ids in `(after, through]`, fetching only the
95
+ * Every id, ascending, reading ahead through a window of up to 8 chunk fetches that opens 1, 2, 4, 8 wide, so a
96
+ * 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
94
97
  * chunks the range overlaps (see {@link IdRange}).
95
98
  *
96
99
  * Two generators, not one with a branch: a second `yield` site in the full read's generator grows its frame, and
@@ -124,7 +127,7 @@ export declare class SegmentEngine {
124
127
  * up front (before the fan-out) and threaded into every chunk read, so a concurrent load can't corrupt or tear
125
128
  * the result — every chunk read is a whole, checksum-verified, immutable generation. The edge a *long* call can
126
129
  * hit is invariant 3's: if it straddles a mid-call `cache.genTtlMs` boundary and a load has published, an
127
- * operand's not-yet-read chunks may re-resolve forward to the newer generation (a generation hop within one long
130
+ * operand's not-yet-requested chunks may re-resolve forward to the newer generation (a generation hop within one long
128
131
  * call) — the call never crashes or returns a torn object, but may mix generations. Three things hop it without
129
132
  * waiting for the TTL, so a shorter call can meet them too: **the reader cache evicting an operand mid-call**
130
133
  * (`maxOpenSegments`), whose re-read re-resolves fresh; a sweep deleting the generation it was reading, which
@@ -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 monotonic counter, advanced on every mutation and even across a `delete`\n * (which **tombstones** rather than removes the object) so a deleted-then-recreated row never re-issues an\n * old token (ABA-safe) \u2014 identical semantics to the LocalFs registry, so it passes the same conformance suite.\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 only if the object still carries\n * 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 three I/O calls, which is what {@link ObjectRegistryStore} abstracts. The parts that are\n * genuinely hard (the ABA-safe counter, the 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 * `delete` tombstones (keeps the object with an advanced counter) for ABA-safety; expiring a tombstone would let a recreate reset the\n * token to 0 and re-issue a stale one.\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 { mapWithConcurrency } from '@/core/concurrency';\nimport {\n applyRegistryPatch,\n parseRegistryEnvelope,\n recordFromNew,\n registryCounterOf,\n serializeRegistryEnvelope,\n validateNewRegistryRecord,\n validateRegistryPatch,\n type RegistryEnvelope,\n} from './registry';\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\u2192tombstone under cross-process contention (converges; 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, as the GCS and Azure Blob stores do, 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 (S3 serves bytes and `ETag` from one `GetObject`) can never raise it.\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 three I/O calls an object store must provide to host a registry. Everything else \u2014 the OCC counter,\n * 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\nexport class ObjectStoreRegistry implements IRegistryDriver {\n constructor(\n private readonly store: ObjectRegistryStore,\n private readonly prefix: string | undefined,\n private readonly now: () => number,\n ) {}\n\n capabilities(): RegCaps {\n return { strongRead: true };\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 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 // Advance across a tombstone so a recreate never re-issues an old token (ABA-safe).\n const token = String(current ? registryCounterOf(current.env.record) + 1 : 0);\n const env: RegistryEnvelope = {\n deleted: false,\n record: recordFromNew(ref, record, 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 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 = String(registryCounterOf(current.env.record) + 1);\n const env: RegistryEnvelope = {\n deleted: false,\n record: applyRegistryPatch(current.env.record, patch, 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 // Tombstone (advance the counter) rather than remove the object \u2014 keeps the token monotonic for\n // ABA-safety. Retry the read\u2192tombstone on a cross-process race; it converges, then fails typed rather\n // 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 const token = String(registryCounterOf(current.env.record) + 1);\n const env: RegistryEnvelope = {\n deleted: true,\n record: { ...current.env.record, token, updatedAt: this.now() },\n };\n try {\n await this.putRow(key, env, { version: current.version });\n return;\n } catch (err) {\n if (isWriteConflictError(err)) continue; // raced \u2014 re-read and re-tombstone\n throw err;\n }\n }\n throw new WriteConflictError(\n `registry delete: contention tombstoning \"${ref.segment}\" \u2014 retry`,\n );\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 racing itself gets here; S3 never 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 monotonic counter, advanced on every mutation and even across a `delete`\n * (which **tombstones** rather than removes the object) so a deleted-then-recreated row never re-issues an\n * old token (ABA-safe) \u2014 identical semantics to the LocalFs registry, so it passes the same conformance suite.\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 only if the object still carries\n * 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 three I/O calls, which is what {@link ObjectRegistryStore} abstracts. The parts that are\n * genuinely hard (the ABA-safe counter, the 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 * `delete` tombstones (keeps the object with an advanced counter) for ABA-safety; expiring a tombstone would let a recreate reset the\n * token to 0 and re-issue a stale one.\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 { mapWithConcurrency } from '@/core/concurrency';\nimport {\n applyRegistryPatch,\n parseRegistryEnvelope,\n recordFromNew,\n registryCounterOf,\n serializeRegistryEnvelope,\n validateNewRegistryRecord,\n validateRegistryPatch,\n type RegistryEnvelope,\n} from './registry';\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\u2192tombstone under cross-process contention (converges; 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, as the Azure Blob store does, 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 (S3 serves bytes and `ETag` from one `GetObject`, GCS bytes and `generation` from one GET) can never raise it.\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 three I/O calls an object store must provide to host a registry. Everything else \u2014 the OCC counter,\n * 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\nexport class ObjectStoreRegistry implements IRegistryDriver {\n constructor(\n private readonly store: ObjectRegistryStore,\n private readonly prefix: string | undefined,\n private readonly now: () => number,\n ) {}\n\n capabilities(): RegCaps {\n return { strongRead: true };\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 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 // Advance across a tombstone so a recreate never re-issues an old token (ABA-safe).\n const token = String(current ? registryCounterOf(current.env.record) + 1 : 0);\n const env: RegistryEnvelope = {\n deleted: false,\n record: recordFromNew(ref, record, 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 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 = String(registryCounterOf(current.env.record) + 1);\n const env: RegistryEnvelope = {\n deleted: false,\n record: applyRegistryPatch(current.env.record, patch, 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 // Tombstone (advance the counter) rather than remove the object \u2014 keeps the token monotonic for\n // ABA-safety. Retry the read\u2192tombstone on a cross-process race; it converges, then fails typed rather\n // 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 const token = String(registryCounterOf(current.env.record) + 1);\n const env: RegistryEnvelope = {\n deleted: true,\n record: { ...current.env.record, token, updatedAt: this.now() },\n };\n try {\n await this.putRow(key, env, { version: current.version });\n return;\n } catch (err) {\n if (isWriteConflictError(err)) continue; // raced \u2014 re-read and re-tombstone\n throw err;\n }\n }\n throw new WriteConflictError(\n `registry delete: contention tombstoning \"${ref.segment}\" \u2014 retry`,\n );\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 racing itself gets here; S3 never 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": "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",
6
6
  "names": []
7
7
  }
@@ -8,11 +8,11 @@ export declare const MAX_ROW_BYTES: number;
8
8
  * This is deliberately **neither** of the two things it superficially resembles. It is not absence: the row
9
9
  * is very likely still live, just one version further on. And it is not a write conflict: the caller may be
10
10
  * a read-only `get()`, which has nothing to conflict with. Either is an easy misreading for a store that reads a
11
- * row in two calls, as the GCS and Azure Blob stores do, which is why the signal is part of the port rather than
11
+ * row in two calls, as the Azure Blob store does, which is why the signal is part of the port rather than
12
12
  * left to each store's judgement. {@link ObjectStoreRegistry} answers it the only way that is correct for every
13
13
  * caller: it reads again.
14
14
  *
15
- * Stores whose read is atomic (S3 serves bytes and `ETag` from one `GetObject`) can never raise it.
15
+ * Stores whose read is atomic (S3 serves bytes and `ETag` from one `GetObject`, GCS bytes and `generation` from one GET) can never raise it.
16
16
  */
17
17
  export declare class ObjectVersionRaced extends Error {
18
18
  constructor(objectKey: string);
package/dist/index.js CHANGED
@@ -255,6 +255,37 @@ function keysWithin(keys, w) {
255
255
  };
256
256
  return keys.slice(firstAtLeast(w.loKey), firstAtLeast(w.hiKey + 1));
257
257
  }
258
+ var ChunkWindow = class {
259
+ constructor(keys, fetch, max, ramp) {
260
+ this.keys = keys;
261
+ this.fetch = fetch;
262
+ this.max = max;
263
+ this.ramp = ramp;
264
+ }
265
+ keys;
266
+ fetch;
267
+ max;
268
+ ramp;
269
+ open = [];
270
+ launched = 0;
271
+ taken = 0;
272
+ /** The next chunk in key order (null if the source holds none), or the error its read raised. */
273
+ async take() {
274
+ const width = this.ramp ? Math.min(this.max, 2 ** Math.min(this.taken, 30)) : this.max;
275
+ while (this.launched < this.keys.length && this.launched - this.taken < width) {
276
+ this.open.push(
277
+ this.fetch(this.keys[this.launched++]).then(
278
+ (chunk) => ({ chunk }),
279
+ (cause) => ({ chunk: null, error: { cause } })
280
+ )
281
+ );
282
+ }
283
+ const slot = await this.open.shift();
284
+ this.taken += 1;
285
+ if (slot.error) throw slot.error.cause;
286
+ return slot.chunk;
287
+ }
288
+ };
258
289
  var SegmentEngine = class {
259
290
  storage;
260
291
  cache;
@@ -303,10 +334,9 @@ var SegmentEngine = class {
303
334
  const chunkKeys = await this.chunkKeys(seg);
304
335
  checkBudget(this.budget, chunkKeys.length, "count");
305
336
  const gen = await this.cacheVersion(seg);
337
+ const window = this.chunkWindow(seg, chunkKeys, gen, false);
306
338
  let total = 0;
307
- for (const chunkKey of chunkKeys) {
308
- total += (await this.storageChunk({ ...seg, chunkKey }, gen))?.size ?? 0;
309
- }
339
+ for (let i = 0; i < chunkKeys.length; i++) total += (await window.take())?.size ?? 0;
310
340
  return total;
311
341
  }
312
342
  /** Whether the Storage source can measure segment size (for grounded cost); false ⇒ storage isn't grounded. */
@@ -321,8 +351,18 @@ var SegmentEngine = class {
321
351
  segmentSize(seg) {
322
352
  return this.storage.sizeOf ? this.storage.sizeOf(seg) : Promise.resolve(null);
323
353
  }
354
+ /** The chunks of `seg` at `chunkKeys`, read through a window of {@link DEFAULT_INTERSECT_CONCURRENCY}. */
355
+ chunkWindow(seg, chunkKeys, gen, ramp) {
356
+ return new ChunkWindow(
357
+ chunkKeys,
358
+ (chunkKey) => this.storageChunk({ ...seg, chunkKey }, gen),
359
+ DEFAULT_INTERSECT_CONCURRENCY,
360
+ ramp
361
+ );
362
+ }
324
363
  /**
325
- * Every id, ascending, one chunk at a time; with `range`, only the ids in `(after, through]`, fetching only the
364
+ * Every id, ascending, reading ahead through a window of up to 8 chunk fetches that opens 1, 2, 4, 8 wide, so a
365
+ * 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
326
366
  * chunks the range overlaps (see {@link IdRange}).
327
367
  *
328
368
  * Two generators, not one with a branch: a second `yield` site in the full read's generator grows its frame, and
@@ -336,8 +376,9 @@ var SegmentEngine = class {
336
376
  const chunkKeys = await this.chunkKeys(seg);
337
377
  checkBudget(this.budget, chunkKeys.length, "iterate");
338
378
  const gen = await this.cacheVersion(seg);
379
+ const window = this.chunkWindow(seg, chunkKeys, gen, true);
339
380
  for (const chunkKey of chunkKeys) {
340
- const chunk = await this.storageChunk({ ...seg, chunkKey }, gen);
381
+ const chunk = await window.take();
341
382
  if (chunk === null) continue;
342
383
  for (const remainder of chunk) yield joinId(chunkKey, remainder);
343
384
  }
@@ -348,8 +389,9 @@ var SegmentEngine = class {
348
389
  const chunkKeys = keysWithin(await this.chunkKeys(seg), w);
349
390
  checkBudget(this.budget, chunkKeys.length, "iterate");
350
391
  const gen = await this.cacheVersion(seg);
392
+ const window = this.chunkWindow(seg, chunkKeys, gen, true);
351
393
  for (const chunkKey of chunkKeys) {
352
- const chunk = await this.storageChunk({ ...seg, chunkKey }, gen);
394
+ const chunk = await window.take();
353
395
  if (chunk === null) continue;
354
396
  if (isEdge(chunkKey, w)) {
355
397
  for (const id of edgeIds(chunk, chunkKey, w)) yield id;
@@ -382,7 +424,7 @@ var SegmentEngine = class {
382
424
  * up front (before the fan-out) and threaded into every chunk read, so a concurrent load can't corrupt or tear
383
425
  * the result — every chunk read is a whole, checksum-verified, immutable generation. The edge a *long* call can
384
426
  * hit is invariant 3's: if it straddles a mid-call `cache.genTtlMs` boundary and a load has published, an
385
- * operand's not-yet-read chunks may re-resolve forward to the newer generation (a generation hop within one long
427
+ * operand's not-yet-requested chunks may re-resolve forward to the newer generation (a generation hop within one long
386
428
  * call) — the call never crashes or returns a torn object, but may mix generations. Three things hop it without
387
429
  * waiting for the TTL, so a shorter call can meet them too: **the reader cache evicting an operand mid-call**
388
430
  * (`maxOpenSegments`), whose re-read re-resolves fresh; a sweep deleting the generation it was reading, which
@@ -2097,7 +2139,7 @@ function readVarint(bytes, offset) {
2097
2139
  if (i === 4 && byte > 15) {
2098
2140
  throw new IntegrityError("varint exceeds u32 range");
2099
2141
  }
2100
- result += (byte & 127) * 2 ** shift;
2142
+ result = (result | (byte & 127) << shift) >>> 0;
2101
2143
  if ((byte & 128) === 0) {
2102
2144
  return { value: result, next: pos };
2103
2145
  }
@@ -2171,7 +2213,10 @@ var AEAD_NONCE_BYTES = 12;
2171
2213
  var AEAD_TAG_BYTES = 16;
2172
2214
 
2173
2215
  // src/core/crbm/reader.ts
2174
- var RETAINED_BYTES_PER_INDEX_ENTRY = 160;
2216
+ var MIN_INDEX_RECORD_BYTES = 4 + CRC32C_BYTES;
2217
+ function indexCapacity(indexLength) {
2218
+ return Math.min(65536, Math.floor(indexLength / MIN_INDEX_RECORD_BYTES));
2219
+ }
2175
2220
  function magicMatches(bytes, offset) {
2176
2221
  return bytes[offset] === MAGIC[0] && bytes[offset + 1] === MAGIC[1] && bytes[offset + 2] === MAGIC[2] && bytes[offset + 3] === MAGIC[3];
2177
2222
  }
@@ -2206,14 +2251,13 @@ function readU64(view, offset, field) {
2206
2251
  return Number(big);
2207
2252
  }
2208
2253
  var CrbmReader = class _CrbmReader {
2209
- constructor(blob, objectSize, generation, lineage, totalCardinality, entries, orderedKeys, servedFromTail, crypto, footerCrc) {
2254
+ constructor(blob, objectSize, generation, lineage, totalCardinality, index, servedFromTail, crypto, footerCrc) {
2210
2255
  this.blob = blob;
2211
2256
  this.objectSize = objectSize;
2212
2257
  this.generation = generation;
2213
2258
  this.lineage = lineage;
2214
2259
  this.totalCardinality = totalCardinality;
2215
- this.entries = entries;
2216
- this.orderedKeys = orderedKeys;
2260
+ this.index = index;
2217
2261
  this.servedFromTail = servedFromTail;
2218
2262
  this.crypto = crypto;
2219
2263
  this.footerCrc = footerCrc;
@@ -2223,8 +2267,7 @@ var CrbmReader = class _CrbmReader {
2223
2267
  generation;
2224
2268
  lineage;
2225
2269
  totalCardinality;
2226
- entries;
2227
- orderedKeys;
2270
+ index;
2228
2271
  servedFromTail;
2229
2272
  crypto;
2230
2273
  footerCrc;
@@ -2241,17 +2284,19 @@ var CrbmReader = class _CrbmReader {
2241
2284
  return this.objectSize;
2242
2285
  }
2243
2286
  /**
2244
- * Estimated retained JS heap of this reader's parsed index — the weight the storage reader cache bounds on
2245
- * (a wide segment's parsed index, not its payloads, dominates the reader's footprint). `entries.size` scales with the number
2246
- * of resident chunks (≤ 65536), so this is `entries.size × {@link RETAINED_BYTES_PER_INDEX_ENTRY}`.
2287
+ * Retained JS heap of this reader's parsed index — the weight the storage reader cache bounds on (a wide
2288
+ * segment's parsed index, not its payloads, dominates the reader's footprint): the exact byte length of the
2289
+ * arrays that hold it, {@link RETAINED_BYTES_PER_INDEX_ENTRY} per entry.
2247
2290
  */
2248
2291
  get retainedIndexBytes() {
2249
- return this.entries.size * RETAINED_BYTES_PER_INDEX_ENTRY;
2292
+ const { keys, cardinalityMinusOne, lengths, crcs, offsets } = this.index;
2293
+ return keys.byteLength + cardinalityMinusOne.byteLength + lengths.byteLength + crcs.byteLength + offsets.byteLength;
2250
2294
  }
2251
2295
  /** Per-chunk cardinality (`chunkKey → count`) from the parsed index — no payload reads. */
2252
2296
  cardinalities() {
2297
+ const { keys, cardinalityMinusOne } = this.index;
2253
2298
  const out = /* @__PURE__ */ new Map();
2254
- for (const [chunkKey, entry] of this.entries) out.set(chunkKey, entry.cardinality);
2299
+ for (let i = 0; i < keys.length; i++) out.set(keys[i], cardinalityMinusOne[i] + 1);
2255
2300
  return out;
2256
2301
  }
2257
2302
  /**
@@ -2348,7 +2393,7 @@ var CrbmReader = class _CrbmReader {
2348
2393
  },
2349
2394
  options.crypto.aadFor("index")
2350
2395
  ) : indexBytes;
2351
- const { entries, orderedKeys, cardinalitySum } = parseIndex(
2396
+ const index = parseIndex(
2352
2397
  indexForParse,
2353
2398
  indexOffset,
2354
2399
  maxPayloadBytes,
@@ -2361,14 +2406,14 @@ var CrbmReader = class _CrbmReader {
2361
2406
  );
2362
2407
  }
2363
2408
  } else {
2364
- if (entries.size !== chunkCount) {
2409
+ if (index.keys.length !== chunkCount) {
2365
2410
  throw new IntegrityError(
2366
- `.crbm chunk_count ${chunkCount} != ${entries.size} index entries`
2411
+ `.crbm chunk_count ${chunkCount} != ${index.keys.length} index entries`
2367
2412
  );
2368
2413
  }
2369
- if (cardinalitySum !== totalCardinality) {
2414
+ if (index.cardinalitySum !== totalCardinality) {
2370
2415
  throw new IntegrityError(
2371
- `.crbm total_cardinality ${totalCardinality} != \u03A3 index cardinality ${cardinalitySum}`
2416
+ `.crbm total_cardinality ${totalCardinality} != \u03A3 index cardinality ${index.cardinalitySum}`
2372
2417
  );
2373
2418
  }
2374
2419
  }
@@ -2377,9 +2422,8 @@ var CrbmReader = class _CrbmReader {
2377
2422
  size,
2378
2423
  generation,
2379
2424
  options.lineage,
2380
- cardinalitySum,
2381
- entries,
2382
- orderedKeys,
2425
+ index.cardinalitySum,
2426
+ index,
2383
2427
  servedFromTail,
2384
2428
  options.crypto,
2385
2429
  storedFooterCrc
@@ -2387,14 +2431,28 @@ var CrbmReader = class _CrbmReader {
2387
2431
  }
2388
2432
  /** Chunk keys present in this generation, ascending. */
2389
2433
  chunkKeys() {
2390
- return this.orderedKeys.slice();
2434
+ return Array.from(this.index.keys);
2391
2435
  }
2392
2436
  /** Segment cardinality from the footer (no payload reads) — the cheap `count()` path. */
2393
2437
  count() {
2394
2438
  return this.totalCardinality;
2395
2439
  }
2440
+ /** The slot holding `chunkKey` (binary search over the ascending keys), or -1 when this generation has none. */
2441
+ slotOf(chunkKey) {
2442
+ const keys = this.index.keys;
2443
+ let lo = 0;
2444
+ let hi = keys.length - 1;
2445
+ while (lo <= hi) {
2446
+ const mid = lo + hi >>> 1;
2447
+ const k = keys[mid];
2448
+ if (k === chunkKey) return mid;
2449
+ if (k < chunkKey) lo = mid + 1;
2450
+ else hi = mid - 1;
2451
+ }
2452
+ return -1;
2453
+ }
2396
2454
  has(chunkKey) {
2397
- return this.entries.has(chunkKey);
2455
+ return this.slotOf(chunkKey) >= 0;
2398
2456
  }
2399
2457
  /**
2400
2458
  * Range-GET one chunk's payload, verifying its CRC32C before returning. Returns `null` if the chunk is
@@ -2403,13 +2461,15 @@ var CrbmReader = class _CrbmReader {
2403
2461
  * not mutate it.
2404
2462
  */
2405
2463
  async getChunk(chunkKey) {
2406
- const e = this.entries.get(chunkKey);
2407
- if (e === void 0) return null;
2408
- if (e.offset < PAYLOAD_START || e.offset + e.length > this.objectSize - FOOTER_BYTES) {
2464
+ const slot = this.slotOf(chunkKey);
2465
+ if (slot < 0) return null;
2466
+ const offset = this.index.offsets[slot];
2467
+ const length = this.index.lengths[slot];
2468
+ if (offset < PAYLOAD_START || offset + length > this.objectSize - FOOTER_BYTES) {
2409
2469
  throw new IntegrityError(`chunk ${chunkKey} payload out of bounds`);
2410
2470
  }
2411
- const bytes = await this.blob.getRange(e.offset, e.length);
2412
- if (crc32c(bytes) !== e.crc32c) {
2471
+ const bytes = await this.blob.getRange(offset, length);
2472
+ if (crc32c(bytes) !== this.index.crcs[slot]) {
2413
2473
  throw new IntegrityError(`chunk ${chunkKey} payload CRC mismatch`);
2414
2474
  }
2415
2475
  if (this.crypto === void 0) return bytes;
@@ -2427,51 +2487,68 @@ var CrbmReader = class _CrbmReader {
2427
2487
  }
2428
2488
  };
2429
2489
  function parseIndex(indexBytes, payloadEnd, maxPayloadBytes, minPayloadBytes = 1) {
2430
- const entries = /* @__PURE__ */ new Map();
2431
- const orderedKeys = [];
2490
+ const capacity = indexCapacity(indexBytes.length);
2491
+ const keys = new Uint16Array(capacity);
2492
+ const cardinalityMinusOne = new Uint16Array(capacity);
2493
+ const lengths = new Uint32Array(capacity);
2494
+ const crcs = new Uint32Array(capacity);
2495
+ const offsets = new Float64Array(capacity);
2496
+ let count = 0;
2432
2497
  let pos = 0;
2433
2498
  let prevKey = 0;
2434
2499
  let prevEnd = PAYLOAD_START;
2435
2500
  let cardinalitySum = 0;
2436
2501
  for (let i = 0; pos < indexBytes.length; i++) {
2437
- const keyDelta = readVarint(indexBytes, pos);
2438
- const offDelta = readVarint(indexBytes, keyDelta.next);
2439
- const len = readVarint(indexBytes, offDelta.next);
2440
- const card = readVarint(indexBytes, len.next);
2441
- pos = card.next;
2502
+ const keyDeltaRead = readVarint(indexBytes, pos);
2503
+ const offDeltaRead = readVarint(indexBytes, keyDeltaRead.next);
2504
+ const lenRead = readVarint(indexBytes, offDeltaRead.next);
2505
+ const cardRead = readVarint(indexBytes, lenRead.next);
2506
+ const keyDelta = keyDeltaRead.value;
2507
+ const offDelta = offDeltaRead.value;
2508
+ const len = lenRead.value;
2509
+ const card = cardRead.value;
2510
+ pos = cardRead.next;
2442
2511
  if (pos + CRC32C_BYTES > indexBytes.length) {
2443
2512
  throw new IntegrityError(".crbm index truncated (missing payload CRC)");
2444
2513
  }
2445
2514
  const crc = (indexBytes[pos] | indexBytes[pos + 1] << 8 | indexBytes[pos + 2] << 16 | indexBytes[pos + 3] << 24) >>> 0;
2446
2515
  pos += CRC32C_BYTES;
2447
- const chunkKey = prevKey + keyDelta.value;
2448
- const offset = prevEnd + offDelta.value;
2449
- if (i > 0 && keyDelta.value === 0) {
2516
+ const chunkKey = prevKey + keyDelta;
2517
+ const offset = prevEnd + offDelta;
2518
+ if (i > 0 && keyDelta === 0) {
2450
2519
  throw new IntegrityError(`.crbm index has a duplicate chunkKey ${chunkKey}`);
2451
2520
  }
2452
2521
  if (chunkKey > 65535) throw new IntegrityError(`.crbm chunkKey ${chunkKey} out of range`);
2453
- if (len.value < minPayloadBytes || len.value > maxPayloadBytes) {
2454
- throw new IntegrityError(`.crbm chunk ${chunkKey} length ${len.value} invalid`);
2522
+ if (len < minPayloadBytes || len > maxPayloadBytes) {
2523
+ throw new IntegrityError(`.crbm chunk ${chunkKey} length ${len} invalid`);
2455
2524
  }
2456
- if (card.value < 1 || card.value > MAX_CHUNK_CARDINALITY) {
2457
- throw new IntegrityError(`.crbm chunk ${chunkKey} cardinality ${card.value} invalid`);
2525
+ if (card < 1 || card > MAX_CHUNK_CARDINALITY) {
2526
+ throw new IntegrityError(`.crbm chunk ${chunkKey} cardinality ${card} invalid`);
2458
2527
  }
2459
- if (offset + len.value > payloadEnd) {
2528
+ if (offset + len > payloadEnd) {
2460
2529
  throw new IntegrityError(`.crbm chunk ${chunkKey} payload out of bounds`);
2461
2530
  }
2462
- entries.set(chunkKey, {
2463
- chunkKey,
2464
- offset,
2465
- length: len.value,
2466
- cardinality: card.value,
2467
- crc32c: crc
2468
- });
2469
- orderedKeys.push(chunkKey);
2470
- cardinalitySum += card.value;
2531
+ keys[count] = chunkKey;
2532
+ cardinalityMinusOne[count] = card - 1;
2533
+ lengths[count] = len;
2534
+ crcs[count] = crc;
2535
+ offsets[count] = offset;
2536
+ count++;
2537
+ cardinalitySum += card;
2471
2538
  prevKey = chunkKey;
2472
- prevEnd = offset + len.value;
2539
+ prevEnd = offset + len;
2540
+ }
2541
+ if (count === capacity) {
2542
+ return { keys, cardinalityMinusOne, lengths, crcs, offsets, cardinalitySum };
2473
2543
  }
2474
- return { entries, orderedKeys, cardinalitySum };
2544
+ return {
2545
+ keys: keys.slice(0, count),
2546
+ cardinalityMinusOne: cardinalityMinusOne.slice(0, count),
2547
+ lengths: lengths.slice(0, count),
2548
+ crcs: crcs.slice(0, count),
2549
+ offsets: offsets.slice(0, count),
2550
+ cardinalitySum
2551
+ };
2475
2552
  }
2476
2553
 
2477
2554
  // src/core/crbm/writer.ts