@cloudbitmaps/core 0.8.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +4 -4
- package/README.md +28 -6
- package/dist/chunk-BUPDGZRR.js +461 -0
- package/dist/chunk-BUPDGZRR.js.map +7 -0
- package/dist/core/audit.d.ts +127 -0
- package/dist/core/bit-route.d.ts +21 -0
- package/dist/core/blob.d.ts +34 -0
- package/dist/core/budget.d.ts +66 -0
- package/dist/core/codec.d.ts +114 -0
- package/dist/core/concurrency.d.ts +15 -0
- package/dist/core/consistency.d.ts +73 -0
- package/dist/core/cooperative.d.ts +78 -0
- package/dist/core/cost.d.ts +102 -0
- package/dist/core/crbm/crc32c.d.ts +10 -0
- package/dist/core/crbm/format.d.ts +116 -0
- package/dist/core/crbm/reader.d.ts +69 -0
- package/dist/core/crbm/varint.d.ts +13 -0
- package/dist/core/crbm/writer.d.ts +39 -0
- package/dist/core/crbm-storage-source.d.ts +432 -0
- package/dist/core/crypto.d.ts +80 -0
- package/dist/core/determinism.d.ts +42 -0
- package/dist/core/discover.d.ts +75 -0
- package/dist/core/due-index.d.ts +55 -0
- package/dist/core/engine.d.ts +204 -0
- package/dist/core/erase-id.d.ts +141 -0
- package/dist/core/erasure.d.ts +179 -0
- package/dist/core/errors.d.ts +120 -0
- package/dist/core/generation-gc.d.ts +52 -0
- package/dist/core/keys.d.ts +23 -0
- package/dist/core/load.d.ts +102 -0
- package/dist/core/lru.d.ts +69 -0
- package/dist/core/metrics.d.ts +132 -0
- package/dist/core/name-codec.d.ts +79 -0
- package/dist/core/pinned-storage-source.d.ts +69 -0
- package/dist/core/ports.d.ts +340 -0
- package/dist/core/registry-scan.d.ts +38 -0
- package/dist/core/retention-sweep.d.ts +190 -0
- package/dist/core/retention.d.ts +67 -0
- package/dist/core/retry.d.ts +65 -0
- package/dist/core/rollback.d.ts +71 -0
- package/dist/core/validate.d.ts +20 -0
- package/dist/driver-kit.d.ts +10 -0
- package/dist/driver-kit.js +314 -0
- package/dist/driver-kit.js.map +7 -0
- package/dist/drivers/_shared/aws-errors.d.ts +18 -0
- package/dist/drivers/_shared/keys.d.ts +27 -0
- package/dist/drivers/_shared/object-registry-keys.d.ts +28 -0
- package/dist/drivers/_shared/object-registry.d.ts +84 -0
- package/dist/drivers/_shared/registry.d.ts +62 -0
- package/dist/drivers/backends.d.ts +49 -0
- package/dist/drivers/crypto.d.ts +38 -0
- package/dist/drivers/localfs/fs-util.d.ts +20 -0
- package/dist/drivers/localfs/paths.d.ts +33 -0
- package/dist/drivers/localfs/registry.d.ts +28 -0
- package/dist/drivers/localfs/storage.d.ts +19 -0
- package/dist/drivers/memory.d.ts +57 -0
- package/dist/drivers/retry/retrying-drivers.d.ts +90 -0
- package/dist/export/index.d.ts +92 -0
- package/dist/index.d.ts +85 -2534
- package/dist/index.js +3076 -2978
- package/dist/index.js.map +7 -1
- package/dist/testing/fuzz-core.d.ts +14 -0
- package/package.json +19 -115
- package/dist/azure/index.cjs +0 -368
- package/dist/azure/index.cjs.map +0 -1
- package/dist/azure/index.d.cts +0 -48
- package/dist/azure/index.d.ts +0 -48
- package/dist/azure/index.js +0 -304
- package/dist/azure/index.js.map +0 -1
- package/dist/cassandra/index.cjs +0 -265
- package/dist/cassandra/index.cjs.map +0 -1
- package/dist/cassandra/index.d.cts +0 -52
- package/dist/cassandra/index.d.ts +0 -52
- package/dist/cassandra/index.js +0 -204
- package/dist/cassandra/index.js.map +0 -1
- package/dist/chunk-7LMLYSVJ.js +0 -43
- package/dist/chunk-7LMLYSVJ.js.map +0 -1
- package/dist/chunk-L54FL5DW.js +0 -203
- package/dist/chunk-L54FL5DW.js.map +0 -1
- package/dist/chunk-NUIDEEFZ.js +0 -91
- package/dist/chunk-NUIDEEFZ.js.map +0 -1
- package/dist/chunk-SNJVZ227.js +0 -35
- package/dist/chunk-SNJVZ227.js.map +0 -1
- package/dist/chunk-YEHMG6W2.js +0 -6
- package/dist/chunk-YEHMG6W2.js.map +0 -1
- package/dist/dynamodb/index.cjs +0 -740
- package/dist/dynamodb/index.cjs.map +0 -1
- package/dist/dynamodb/index.d.cts +0 -106
- package/dist/dynamodb/index.d.ts +0 -106
- package/dist/dynamodb/index.js +0 -469
- package/dist/dynamodb/index.js.map +0 -1
- package/dist/gcs/index.cjs +0 -343
- package/dist/gcs/index.cjs.map +0 -1
- package/dist/gcs/index.d.cts +0 -46
- package/dist/gcs/index.d.ts +0 -46
- package/dist/gcs/index.js +0 -279
- package/dist/gcs/index.js.map +0 -1
- package/dist/index.cjs +0 -4608
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -2535
- package/dist/mongodb/index.cjs +0 -260
- package/dist/mongodb/index.cjs.map +0 -1
- package/dist/mongodb/index.d.cts +0 -45
- package/dist/mongodb/index.d.ts +0 -45
- package/dist/mongodb/index.js +0 -199
- package/dist/mongodb/index.js.map +0 -1
- package/dist/mysql/index.cjs +0 -281
- package/dist/mysql/index.cjs.map +0 -1
- package/dist/mysql/index.d.cts +0 -56
- package/dist/mysql/index.d.ts +0 -56
- package/dist/mysql/index.js +0 -211
- package/dist/mysql/index.js.map +0 -1
- package/dist/ports-5BQTGKze.d.cts +0 -356
- package/dist/ports-5BQTGKze.d.ts +0 -356
- package/dist/postgres/index.cjs +0 -273
- package/dist/postgres/index.cjs.map +0 -1
- package/dist/postgres/index.d.cts +0 -54
- package/dist/postgres/index.d.ts +0 -54
- package/dist/postgres/index.js +0 -203
- package/dist/postgres/index.js.map +0 -1
- package/dist/redis/index.cjs +0 -257
- package/dist/redis/index.cjs.map +0 -1
- package/dist/redis/index.d.cts +0 -42
- package/dist/redis/index.d.ts +0 -42
- package/dist/redis/index.js +0 -197
- package/dist/redis/index.js.map +0 -1
- package/dist/s3/index.cjs +0 -891
- package/dist/s3/index.cjs.map +0 -1
- package/dist/s3/index.d.cts +0 -112
- package/dist/s3/index.d.ts +0 -112
- package/dist/s3/index.js +0 -555
- package/dist/s3/index.js.map +0 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed errors — callers learn *why* something failed, never by parsing strings. Retry is the driver
|
|
3
|
+
* decorators' job, not the engine's: a driver classifies its backend's failures into this vocabulary, and
|
|
4
|
+
* `withRetry` decides what is transient.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Registry-symbol brands, so an error stays classifiable when the class object is not shared.
|
|
8
|
+
*
|
|
9
|
+
* In an ordinary install there is ONE copy of these classes and `instanceof` holds everywhere — across the
|
|
10
|
+
* flavor, the driver packages and core itself — because every package leaves `@cloudbitmaps/*` external and
|
|
11
|
+
* resolves core at runtime rather than inlining it.
|
|
12
|
+
*
|
|
13
|
+
* What still breaks it is a SECOND copy: a version skew between our packages that the installer could not
|
|
14
|
+
* dedupe, or a bundler that emits core twice. Then a driver throws a class the catching side's `instanceof`
|
|
15
|
+
* does not match — and silently, because a `catch` that stops matching just falls through, defeating
|
|
16
|
+
* transient retry and the publish path's conflict handling with no error of its own.
|
|
17
|
+
*
|
|
18
|
+
* `Symbol.for` is identity-stable across copies, bundles and realms, so classify errors with the exported
|
|
19
|
+
* predicates below (never `instanceof`) anywhere an error may cross that boundary.
|
|
20
|
+
*/
|
|
21
|
+
declare const ERROR_BRAND: unique symbol;
|
|
22
|
+
declare const TRANSIENT_BRAND: unique symbol;
|
|
23
|
+
/** Base class for every error CloudRoaring throws. */
|
|
24
|
+
export declare class CloudRoaringError extends Error {
|
|
25
|
+
/** Cross-bundle brand — see the predicates ({@link isCloudRoaringError}, …). Non-enumerable-ish (symbol key ⇒ not in JSON). */
|
|
26
|
+
readonly [ERROR_BRAND]: true;
|
|
27
|
+
constructor(message: string);
|
|
28
|
+
}
|
|
29
|
+
/** Invalid caller input (bad id, segment name, options). Raised before any storage call. */
|
|
30
|
+
export declare class ValidationError extends CloudRoaringError {
|
|
31
|
+
}
|
|
32
|
+
/** An OCC conditional write/delete lost the race — the row changed since it was read. */
|
|
33
|
+
export declare class WriteConflictError extends CloudRoaringError {
|
|
34
|
+
}
|
|
35
|
+
/** Bytes from a tier are corrupt, oversized, or fail a checksum/format check. */
|
|
36
|
+
export declare class IntegrityError extends CloudRoaringError {
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A requested object/row does not exist. Part of the driver error vocabulary; thrown by
|
|
40
|
+
* the persistent drivers (S3, GCS, Azure Blob, local filesystem) — the in-memory drivers return
|
|
41
|
+
* `null` instead.
|
|
42
|
+
*/
|
|
43
|
+
export declare class NotFoundError extends CloudRoaringError {
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* This build/configuration cannot perform the requested operation, though nothing is malformed. Two uses:
|
|
47
|
+
* (1) **format** — the bytes are well-formed but unreadable here (an unknown `.crbm` major version, an
|
|
48
|
+
* encrypted file before the crypto path exists) — distinct from `IntegrityError` (corruption); and (2)
|
|
49
|
+
* **store configuration** — an operation this store's wiring doesn't support (e.g. a lifecycle helper like
|
|
50
|
+
* `eraseSubject`/`retireExpired` called on a store built without a storage backend). Raised at
|
|
51
|
+
* operation time, before any mutation.
|
|
52
|
+
*/
|
|
53
|
+
export declare class UnsupportedError extends CloudRoaringError {
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A driver cannot meet a capability the chosen topology requires (e.g. a Storage driver without range
|
|
57
|
+
* reads). Raised fail-fast at wiring time, never mid-operation.
|
|
58
|
+
*/
|
|
59
|
+
export declare class CapabilityError extends CloudRoaringError {
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* An operation would exceed its per-op **denial-of-wallet budget** — too many backend requests for a single
|
|
63
|
+
* `count`/`iterate`/`intersect`/`subjectReport`/`eraseSubject` call — so it is refused **before** fanning out
|
|
64
|
+
* (hard invariant 6). Default-on but generous (normal ops never hit it); tune it
|
|
65
|
+
* per store (`budget`) or per op, or disable with `budget: false`. Deterministic (never retried): the op is too
|
|
66
|
+
* big by policy, not by luck. Each request's bytes are separately capped (the safe-deserialize ceiling), so
|
|
67
|
+
* bounding the request count transitively bounds bytes. Carries the projected count + the limit, never data.
|
|
68
|
+
*/
|
|
69
|
+
export declare class BudgetExceededError extends CloudRoaringError {
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* An encrypted segment's data key (DEK) cannot be unwrapped because the keystore holds none of the
|
|
73
|
+
* key-encryption-keys (KEKs) its wrappings reference — the KEK was never configured, rotated away without
|
|
74
|
+
* keeping the old key, or lost. Deterministic (never retried): without a KEK the ciphertext is unreadable by
|
|
75
|
+
* design. The flip side of crypto-shred — when this is *intended* (a destroyed segment) the registry row is
|
|
76
|
+
* already a `destroyed` tombstone; when it's *not*, restore the missing KEK (or its recovery KEK). Carries no
|
|
77
|
+
* key material.
|
|
78
|
+
*/
|
|
79
|
+
export declare class KeyUnavailableError extends CloudRoaringError {
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A **transient** infrastructure fault that is safe to retry — throttling, a 5xx, a dropped connection,
|
|
83
|
+
* a client-side request timeout. Drivers classify their backend's retryable faults and raise this (the
|
|
84
|
+
* SDK-specific knowledge stays in the SDK-specific driver); the retry layer (`core/retry`) retries **only**
|
|
85
|
+
* this class, never a deterministic error like {@link ValidationError}, {@link IntegrityError},
|
|
86
|
+
* {@link NotFoundError}, or {@link WriteConflictError} (retrying those is pointless or wrong). The original
|
|
87
|
+
* error is preserved in `cause` so callers can still inspect it.
|
|
88
|
+
*
|
|
89
|
+
* Note for logging hygiene: `cause` is the **raw SDK error**, which may carry operational
|
|
90
|
+
* metadata (endpoint host, request IDs, `$metadata`). The library's own `message` is identifier-only and safe
|
|
91
|
+
* to log; if you serialize the whole error *chain*, be aware you're including that metadata.
|
|
92
|
+
*/
|
|
93
|
+
export declare class TransientError extends CloudRoaringError {
|
|
94
|
+
/** A second brand so the whole transient subtree (incl. {@link TimeoutError}) is classifiable cross-bundle. */
|
|
95
|
+
readonly [TRANSIENT_BRAND]: true;
|
|
96
|
+
constructor(message: string, options?: {
|
|
97
|
+
cause?: unknown;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A single attempt exceeded its time budget. Subclass of {@link TransientError} so the retry layer treats a
|
|
102
|
+
* timeout as retryable by default — a stalled request often succeeds on a fresh connection. Raised by a
|
|
103
|
+
* driver whose injected client reports a request timeout — setting one on your injected client is the
|
|
104
|
+
* recommended way to bound a hang.
|
|
105
|
+
*/
|
|
106
|
+
export declare class TimeoutError extends TransientError {
|
|
107
|
+
}
|
|
108
|
+
/** Any error thrown by CloudRoaring (any tier, any bundle). */
|
|
109
|
+
export declare function isCloudRoaringError(err: unknown): err is CloudRoaringError;
|
|
110
|
+
/** An OCC conditional write/delete lost the race — retry the read-modify-write, don't fail. */
|
|
111
|
+
export declare function isWriteConflictError(err: unknown): err is WriteConflictError;
|
|
112
|
+
/** A retryable transient infrastructure fault (incl. {@link TimeoutError}). The retry layer keys on this. */
|
|
113
|
+
export declare function isTransientError(err: unknown): err is TransientError;
|
|
114
|
+
/** A requested object/row does not exist. */
|
|
115
|
+
export declare function isNotFoundError(err: unknown): err is NotFoundError;
|
|
116
|
+
/** Corrupt/oversized/failed-checksum bytes from a tier. */
|
|
117
|
+
export declare function isIntegrityError(err: unknown): err is IntegrityError;
|
|
118
|
+
/** Invalid caller input. */
|
|
119
|
+
export declare function isValidationError(err: unknown): err is ValidationError;
|
|
120
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { IStorageDriver, IRegistryDriver, SegmentRef } from './ports.js';
|
|
2
|
+
/** The two ports generation bookkeeping needs: the objects, and the pointer that says which one is current. */
|
|
3
|
+
export interface GenerationDeps {
|
|
4
|
+
readonly storage: IStorageDriver;
|
|
5
|
+
readonly registry: IRegistryDriver;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The generation number a writer should use for the segment's **next** object: one above the highest generation
|
|
9
|
+
* the registry points at *or* that is present in Storage — whichever is higher.
|
|
10
|
+
*
|
|
11
|
+
* Both are consulted because they can disagree. A load that wrote its object and crashed before publishing leaves
|
|
12
|
+
* an object *above* `currentGen`; a writer that consulted only the pointer would pick that same number and hit the
|
|
13
|
+
* write-once conflict on every retry. Skipping past it costs one `list` and keeps the retry trivial — the crashed
|
|
14
|
+
* object is an orphan {@link gcOrphanGenerations} collects once the pointer moves on. Reusing such an object
|
|
15
|
+
* instead of skipping it (verify-and-publish) is `load()`'s job, not a numbering helper's.
|
|
16
|
+
*
|
|
17
|
+
* A segment with no row and no objects starts at 0. An admin/write-path helper, never on the read path.
|
|
18
|
+
*/
|
|
19
|
+
export declare function nextGeneration(ref: SegmentRef, deps: GenerationDeps): Promise<number>;
|
|
20
|
+
/**
|
|
21
|
+
* Garbage-collect superseded Storage generations for a segment: everything strictly below `currentGen`, keeping
|
|
22
|
+
* the most recent `keep` of them as a grace window, so a read still fetching from a just-superseded
|
|
23
|
+
* generation need not re-resolve mid-call (**invariant 4**). It is a window, not a lock: a read whose
|
|
24
|
+
* generation is swept anyway re-resolves and retries once rather than failing (see `withFreshSnapshot`), so
|
|
25
|
+
* `keep` trades storage for round trips. Generations ≥ `currentGen` are never touched. Returns the generations
|
|
26
|
+
* deleted.
|
|
27
|
+
*
|
|
28
|
+
* **Except on a `destroyed` segment, where EVERY generation is garbage** and the grace window is meaningless.
|
|
29
|
+
* A tombstoned segment resolves no generation for any reader, pinned or not — a pin cannot be taken on one, and
|
|
30
|
+
* a pin taken while it was still active re-checks the status on every open — and nothing else in the library
|
|
31
|
+
* would ever collect them, so without this those objects are billed forever.
|
|
32
|
+
*
|
|
33
|
+
* **Throws {@link WriteConflictError} if the segment changed underneath the pass** — not to be confused with
|
|
34
|
+
* the empty array it still returns when there was genuinely nothing to collect (no row at all, or no pointer
|
|
35
|
+
* yet), which is why an empty array is not a receipt. The row is read before the listing and acted on after it, so a name that was purged and
|
|
36
|
+
* re-created in that window is a *different* segment wearing the same name, and its live object must not be
|
|
37
|
+
* collected on the strength of the old row. Refusing is safe — the objects are not going anywhere and the next
|
|
38
|
+
* pass reads a consistent row — but it has to be **distinguishable** from "there was nothing to collect", which
|
|
39
|
+
* an empty array is not: `eraseIdFromSegment` reads the returned list as the physical half of its erasure
|
|
40
|
+
* receipt, and would otherwise report `erased: true` over bytes still in the bucket. Re-run it.
|
|
41
|
+
*
|
|
42
|
+
* That state is reachable in practice: a `dropSegment` whose Storage sweep threw part-way, or a load that was
|
|
43
|
+
* already writing its object when the tombstone landed and finished the write afterwards. `dropSegment` re-sweeps
|
|
44
|
+
* and reports whatever it could not reclaim in `generationsRemaining`, but a drop that was never re-run leaves a
|
|
45
|
+
* residual, and this is what eventually collects it from the retention sweep.
|
|
46
|
+
*
|
|
47
|
+
* `keep: 0` is how a subject-erasure rewrite makes a bit **physically** gone on return: the generation that held
|
|
48
|
+
* it is collected the moment the rewrite is current. A reader pinned to it re-resolves on its next read.
|
|
49
|
+
*/
|
|
50
|
+
export declare function gcOrphanGenerations(ref: SegmentRef, deps: GenerationDeps, options?: {
|
|
51
|
+
keep?: number;
|
|
52
|
+
}): Promise<number[]>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ChunkRef, SegmentRef } from './ports.js';
|
|
2
|
+
export declare function segmentKey(ref: SegmentRef): string;
|
|
3
|
+
export declare function segmentPrefix(ref: SegmentRef): string;
|
|
4
|
+
export declare function chunkRefKey(ref: ChunkRef): string;
|
|
5
|
+
/**
|
|
6
|
+
* CACHE-cache key for a chunk scoped to a specific **version** of the segment — {@link chunkRefKey} plus that
|
|
7
|
+
* version (space-delimited, injection-proof exactly as above — the name parts are already encoded). A publish bumps the version, so it naturally
|
|
8
|
+
* misses the cache instead of serving a stale superseded chunk, and the superseded entries age out under the
|
|
9
|
+
* LRU ceiling (no active purge needed).
|
|
10
|
+
*
|
|
11
|
+
* `version` is whatever the Storage source reports as identifying the bytes a read will see — its
|
|
12
|
+
* `currentVersion` where it has one, otherwise the bare generation number. **A generation number alone is not
|
|
13
|
+
* an identity**: `nextGeneration` restarts at 0 once a registry row is purged and the bucket emptied, so a
|
|
14
|
+
* retired-and-re-created name serves different data at the same `currentGen`, and a key built on the number
|
|
15
|
+
* hands the new incarnation the old one's decoded chunks.
|
|
16
|
+
*/
|
|
17
|
+
export declare function chunkGenKey(ref: ChunkRef, version: string | number): string;
|
|
18
|
+
/**
|
|
19
|
+
* Deterministic FNV-1a shard assignment for a segment key — dependency-free, stable across workers and
|
|
20
|
+
* restarts. One definition, so every sharded fleet operation (today the retention sweep) agrees on it exactly:
|
|
21
|
+
* two definitions that disagreed would make the union across workers neither disjoint nor complete.
|
|
22
|
+
*/
|
|
23
|
+
export declare function shardOf(key: string, totalShards: number): number;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loadSegment` — the loaded store's primary write path, as one call.
|
|
3
|
+
*
|
|
4
|
+
* A load is always the same four steps: take the next generation number, write one immutable object, move the
|
|
5
|
+
* pointer, collect what the move superseded. Done by hand they are `nextGeneration` + `bulkLoadCrbmGeneration` +
|
|
6
|
+
* `publishGeneration` + `gcOrphanGenerations`, and the step people leave out is the last one — so stores that
|
|
7
|
+
* compose it themselves accumulate superseded generations they keep paying for. Composing it here is the point.
|
|
8
|
+
*
|
|
9
|
+
* The other reason it is one call is the **guard**. A load REPLACES a segment: whatever the stream contains is
|
|
10
|
+
* what the segment contains afterwards. That makes an upstream query returning fewer rows than usual — or none —
|
|
11
|
+
* a silent, successful-looking wipe, and nothing downstream can tell it from a legitimate shrink. The guard is
|
|
12
|
+
* the place to say what a plausible result looks like, and it has to sit **between the write and the publish**,
|
|
13
|
+
* because that is the only moment where the new content is known and the old one is still authoritative.
|
|
14
|
+
*
|
|
15
|
+
* Refusing therefore has to clean up after itself. The object is already durable at that point, and it sits
|
|
16
|
+
* ABOVE `currentGen`, where generation collection deliberately never looks (it deletes strictly below the
|
|
17
|
+
* pointer). Nothing else would ever reclaim it, so a refused load deletes its own object before returning.
|
|
18
|
+
*/
|
|
19
|
+
import { type IAuditSink } from './audit.js';
|
|
20
|
+
import type { CodecInterface } from './codec.js';
|
|
21
|
+
import type { Clock } from './determinism.js';
|
|
22
|
+
import type { IKeystore } from './crypto.js';
|
|
23
|
+
import type { IStorageDriver, IRegistryDriver, SegmentRef } from './ports.js';
|
|
24
|
+
/** What {@link loadSegment} needs: the objects, the pointer, the codec, and key material if encrypted. */
|
|
25
|
+
export interface LoadDeps {
|
|
26
|
+
readonly storage: IStorageDriver;
|
|
27
|
+
readonly registry: IRegistryDriver;
|
|
28
|
+
readonly codec?: CodecInterface;
|
|
29
|
+
readonly keystore?: IKeystore;
|
|
30
|
+
readonly requireEncryption?: boolean;
|
|
31
|
+
readonly clock?: Clock;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* What a plausible result looks like, so an implausible one is refused instead of published.
|
|
35
|
+
*
|
|
36
|
+
* Both bounds are compared against the **current** generation's cardinality, read from the `.crbm` index rather
|
|
37
|
+
* than its payload — one object-header read, and only when a bound is actually set.
|
|
38
|
+
*/
|
|
39
|
+
export interface LoadGuard {
|
|
40
|
+
/** Refuse a generation with fewer than this many ids. Use for "this segment is never legitimately tiny". */
|
|
41
|
+
readonly minCardinality?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Refuse a generation that retains less than this fraction of the current one — `0.5` means "at least half the
|
|
44
|
+
* ids must survive". A *growing* load is never refused by this bound, and neither is the first load of a
|
|
45
|
+
* segment, which has nothing to shrink from.
|
|
46
|
+
*
|
|
47
|
+
* Phrased as a floor rather than a ceiling on the loss so that `0` means the same thing here as it does on
|
|
48
|
+
* `minCardinality` above: **no bound**. The inverse spelling (`maxShrink`) would make `0` mean "refuse any
|
|
49
|
+
* shrink at all" — maximally strict — sitting in the same object literal as a field where `0` is maximally
|
|
50
|
+
* permissive, which is where an inversion would eventually be written and not noticed.
|
|
51
|
+
*/
|
|
52
|
+
readonly minRetained?: number;
|
|
53
|
+
}
|
|
54
|
+
export interface LoadOptions {
|
|
55
|
+
/**
|
|
56
|
+
* Publish an empty generation over a non-empty one. Off by default: an empty result is far more often an
|
|
57
|
+
* upstream failure than an intent, and the failure is indistinguishable from success once it lands.
|
|
58
|
+
*/
|
|
59
|
+
readonly allowEmpty?: boolean;
|
|
60
|
+
readonly guard?: LoadGuard;
|
|
61
|
+
/**
|
|
62
|
+
* Generations to keep below the new pointer as a grace window (default 1). Keep at least one: a read that is
|
|
63
|
+
* still fetching chunks from the just-superseded generation would otherwise have it deleted out from under it
|
|
64
|
+
* and pay a re-resolve. A wider window costs storage and buys nothing a pinned read would not do better.
|
|
65
|
+
*/
|
|
66
|
+
readonly keep?: number;
|
|
67
|
+
readonly audit?: IAuditSink;
|
|
68
|
+
}
|
|
69
|
+
/** Why a load did not become current. */
|
|
70
|
+
export type LoadRefusal = 'empty' | 'min-cardinality' | 'min-retained'
|
|
71
|
+
/** Another writer published a higher generation first. The object was written, then removed. */
|
|
72
|
+
| 'superseded';
|
|
73
|
+
export interface LoadResult {
|
|
74
|
+
/** The generation written. Present even when refused — it is what was deleted again. */
|
|
75
|
+
readonly generation: number;
|
|
76
|
+
/** Whether this generation is now the segment's current one. */
|
|
77
|
+
readonly published: boolean;
|
|
78
|
+
/** Set only when `published` is false. */
|
|
79
|
+
readonly reason?: LoadRefusal;
|
|
80
|
+
readonly size: number;
|
|
81
|
+
readonly sha256: string;
|
|
82
|
+
readonly chunkCount: number;
|
|
83
|
+
/** Distinct ids in the generation that was written, post-dedup. */
|
|
84
|
+
readonly cardinality: number;
|
|
85
|
+
/**
|
|
86
|
+
* What the segment held when the guard judged it — `null` when there was no current generation, or when no
|
|
87
|
+
* bound needed it. Returned because an alert on `reason: 'min-retained'` is useless without it: "refused" is a
|
|
88
|
+
* page, "refused: 4 → 1" is a diagnosis, and re-reading the segment to find out costs a round trip and races
|
|
89
|
+
* whatever happens next.
|
|
90
|
+
*/
|
|
91
|
+
readonly cardinalityBefore: number | null;
|
|
92
|
+
/** The superseded generations collected after publishing. Empty when nothing was published. */
|
|
93
|
+
readonly collected: readonly number[];
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Replace a segment's contents with `ids`, as one immutable generation, and make it current.
|
|
97
|
+
*
|
|
98
|
+
* Returns what was written and whether it became current. A refusal is **not** an exception: `published: false`
|
|
99
|
+
* with a `reason` is a normal outcome a caller branches on, in the same shape as a successful load, because the
|
|
100
|
+
* interesting cases (a guard tripped, a racing writer won) are operational facts rather than faults.
|
|
101
|
+
*/
|
|
102
|
+
export declare function loadSegment(ref: SegmentRef, ids: Iterable<number> | AsyncIterable<number>, deps: LoadDeps, options?: LoadOptions): Promise<LoadResult>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded LRU cache with optional TTL — the cache tier's memory ceiling.
|
|
3
|
+
*
|
|
4
|
+
* Time comes from an injected `Clock` (never `Date.now()`) so the cache is deterministic
|
|
5
|
+
* under simulation.
|
|
6
|
+
*
|
|
7
|
+
* The cache can be bounded by entry **count** (`maxEntries`, always) and, optionally, by an aggregate
|
|
8
|
+
* **byte weight** (`maxBytes`). The byte bound exists for values whose footprint is large and uneven — the
|
|
9
|
+
* storage reader cache, where one entry pins a fully-parsed `.crbm` index that can be ~6 MB for a wide segment
|
|
10
|
+
* yet a few hundred bytes for a small one (a count-only bound lets 1024 wide indices blow a Lambda's
|
|
11
|
+
* memory even though the count is "in bounds"). Weights arrive **after** insertion (a cached reader is a
|
|
12
|
+
* pending promise; its size is known only once it resolves), so weight is reported via {@link setWeight} and
|
|
13
|
+
* eviction re-runs then.
|
|
14
|
+
*/
|
|
15
|
+
import type { Clock } from './determinism.js';
|
|
16
|
+
export interface LruOptions {
|
|
17
|
+
/** Hard ceiling on entries; the least-recently-used is evicted past it. */
|
|
18
|
+
readonly maxEntries: number;
|
|
19
|
+
/**
|
|
20
|
+
* Optional aggregate byte ceiling across all entries' reported weights (see {@link BoundedLru.setWeight}).
|
|
21
|
+
* When set, the least-recently-used entries are evicted until the total is within budget — in addition to
|
|
22
|
+
* the `maxEntries` bound. Omit for a pure count-bounded cache (weights stay 0, no byte eviction).
|
|
23
|
+
*/
|
|
24
|
+
readonly maxBytes?: number;
|
|
25
|
+
/** Optional time-to-live in ms; an entry older than this is treated as absent. */
|
|
26
|
+
readonly ttlMs?: number;
|
|
27
|
+
/** Only `now()` is used (never `sleep`), so a count-only cache can pass a minimal time source. */
|
|
28
|
+
readonly clock: Pick<Clock, 'now'>;
|
|
29
|
+
}
|
|
30
|
+
export declare class BoundedLru<K, V> {
|
|
31
|
+
private readonly map;
|
|
32
|
+
private readonly maxEntries;
|
|
33
|
+
private readonly maxBytes;
|
|
34
|
+
private readonly ttlMs;
|
|
35
|
+
private readonly clock;
|
|
36
|
+
private totalBytes;
|
|
37
|
+
constructor(options: LruOptions);
|
|
38
|
+
get(key: K): V | undefined;
|
|
39
|
+
/** Read a value **without** counting as an access (recency unchanged, no purge on expiry). */
|
|
40
|
+
peek(key: K): V | undefined;
|
|
41
|
+
set(key: K, value: V): void;
|
|
42
|
+
/**
|
|
43
|
+
* Report an entry's byte weight (typically once a cached promise resolves and its size is known). Updates
|
|
44
|
+
* the running total and evicts the least-recently-used entries if the aggregate now exceeds `maxBytes`.
|
|
45
|
+
* Recency is **not** changed. The `key` being weighted is protected from this eviction pass (we just
|
|
46
|
+
* populated it) — so a single entry larger than the whole budget is kept rather than immediately dropped.
|
|
47
|
+
*/
|
|
48
|
+
setWeight(key: K, bytes: number): void;
|
|
49
|
+
private evictToBudget;
|
|
50
|
+
/** The least-recently-used key that is not `protectKey`, or undefined if none other exists. */
|
|
51
|
+
private oldestExcept;
|
|
52
|
+
private dropKey;
|
|
53
|
+
private drop;
|
|
54
|
+
/** Membership test that does NOT count as an access (recency is unchanged). Purges if expired. */
|
|
55
|
+
has(key: K): boolean;
|
|
56
|
+
delete(key: K): boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Delete every entry whose key matches `pred`, returning how many went. Used to drop one segment's decoded
|
|
59
|
+
* chunks when something destroys the generation they came from — a deletion the generation-keyed cache
|
|
60
|
+
* cannot express on its own, because a *destroyed* segment has no newer generation whose key would miss.
|
|
61
|
+
* Linear in the cache size; called on admin paths (erasure, drop, shred, retirement), never on a read.
|
|
62
|
+
*/
|
|
63
|
+
deleteWhere(pred: (key: K) => boolean): number;
|
|
64
|
+
clear(): void;
|
|
65
|
+
/** Current entry count (may include not-yet-evicted expired entries). */
|
|
66
|
+
get size(): number;
|
|
67
|
+
/** Current aggregate reported weight (bytes) across all entries. */
|
|
68
|
+
get weightBytes(): number;
|
|
69
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metrics seam — an injected sink the engine pushes typed events to, exactly like the `Clock`/driver seams
|
|
3
|
+
* keep `core/` pure and storage-agnostic.
|
|
4
|
+
*
|
|
5
|
+
* Design: the library emits **neutral domain events** and stays vendor-agnostic — you (or a ~12-line
|
|
6
|
+
* adapter) map them to OpenTelemetry / Datadog / a log line. The default is a **no-op**, and emission is
|
|
7
|
+
* skipped entirely when no sink is wired (near-zero overhead, no telemetry dependency). Events carry **raw
|
|
8
|
+
* observations** (bytes, counts, ms); turning those into billable units / dollars is the cost model's job,
|
|
9
|
+
* which keeps rates pluggable.
|
|
10
|
+
*
|
|
11
|
+
* Two caveats for sink authors: `onEvent` runs **synchronously on the I/O path**, so keep it cheap and
|
|
12
|
+
* non-blocking (offload batching/network to your own async queue); and `segment`/`namespace` are
|
|
13
|
+
* caller-controlled strings that may be PII and are **unbounded-cardinality** — do not map them to
|
|
14
|
+
* per-series metric labels unless they're known low-cardinality and PII-free. See the getting-started
|
|
15
|
+
* "Observability" section.
|
|
16
|
+
*/
|
|
17
|
+
/** The segment operations that emit an `op` latency event (timed with the injected clock, at the facade). */
|
|
18
|
+
export type MetricOpName = 'has' | 'count' | 'intersectInto' | 'unionInto' | 'andNotInto';
|
|
19
|
+
/**
|
|
20
|
+
* One observability event. A discriminated union on `kind` — new variants can be added over time without
|
|
21
|
+
* breaking consumers (they switch on the kinds they care about). `namespace` is the segment's namespace
|
|
22
|
+
* (absent ⇒ the default namespace). Segment names are identifiers, never bitmap contents (threat model:
|
|
23
|
+
* never log PII / bitmap bits).
|
|
24
|
+
*/
|
|
25
|
+
export type MetricEvent = {
|
|
26
|
+
readonly kind: 'storage.get';
|
|
27
|
+
readonly namespace?: string;
|
|
28
|
+
readonly segment: string;
|
|
29
|
+
/** Bytes returned (0 if the chunk was absent — a GET still happened). */
|
|
30
|
+
readonly bytes: number;
|
|
31
|
+
/**
|
|
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).
|
|
34
|
+
*/
|
|
35
|
+
readonly ms: number;
|
|
36
|
+
} | {
|
|
37
|
+
readonly kind: 'cache';
|
|
38
|
+
readonly hit: boolean;
|
|
39
|
+
} | {
|
|
40
|
+
readonly kind: 'retry';
|
|
41
|
+
/** Infrastructure-fault backoff (throttling, 5xx, a dropped connection) — the one kind of retry the store does. */
|
|
42
|
+
readonly reason: 'transient';
|
|
43
|
+
/** 1-based number of the attempt about to be retried. */
|
|
44
|
+
readonly attempt: number;
|
|
45
|
+
readonly delayMs: number;
|
|
46
|
+
} | {
|
|
47
|
+
readonly kind: 'intersect';
|
|
48
|
+
/**
|
|
49
|
+
* Which chunk-aligned combine this was. **Optional for backward compatibility** — absent means
|
|
50
|
+
* `'intersect'`, which is all this event reported before `union`/`andNot` existed.
|
|
51
|
+
*
|
|
52
|
+
* For `'union'` over its *include* operands alone, `skippedChunks` is 0 by construction — union reads
|
|
53
|
+
* every chunk of every operand, so there is nothing to prune. It can still be non-zero when a union
|
|
54
|
+
* carries `exclude` operands, because a suppression list may hold keys no include does and those are
|
|
55
|
+
* legitimately never fetched. So: 0 is the expected shape for a plain union, not a guarantee.
|
|
56
|
+
*/
|
|
57
|
+
readonly op?: 'intersect' | 'union' | 'andNot';
|
|
58
|
+
/** Operand count, including any `exclude` (suppression) operands. */
|
|
59
|
+
readonly operands: number;
|
|
60
|
+
/**
|
|
61
|
+
* Distinct chunk **keys** selected to fetch — for `'intersect'`, those present in every operand.
|
|
62
|
+
*
|
|
63
|
+
* **Keys, not requests.** The actual storage GETs are roughly `fetchedChunks × operands`, plus one per
|
|
64
|
+
* `exclude` that holds a given key, which is why this number is smaller than what the per-op budget
|
|
65
|
+
* charges for the same call. The two are different units by design; if you are reconciling a bill, the
|
|
66
|
+
* budget's accounting is the one that models requests.
|
|
67
|
+
*/
|
|
68
|
+
readonly fetchedChunks: number;
|
|
69
|
+
/**
|
|
70
|
+
* **Distinct** chunk-keys pruned — never fetched (the chunk-skipping saving). Counts distinct keys,
|
|
71
|
+
* not per-operand GETs, so it under-states the true GET saving when 3+ operands partially overlap.
|
|
72
|
+
*/
|
|
73
|
+
readonly skippedChunks: number;
|
|
74
|
+
} | {
|
|
75
|
+
readonly kind: 'op';
|
|
76
|
+
readonly name: MetricOpName;
|
|
77
|
+
readonly ms: number;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* The sink you plug in. One method, synchronous, fire-and-forget. It must never throw back into the
|
|
81
|
+
* caller — `CloudRoaring` wraps a user sink with {@link safeMetrics} so a buggy sink can't break I/O.
|
|
82
|
+
*/
|
|
83
|
+
export interface IMetricsSink {
|
|
84
|
+
onEvent(event: MetricEvent): void;
|
|
85
|
+
}
|
|
86
|
+
/** The default sink: discards everything, zero overhead. Used whenever no sink is wired. */
|
|
87
|
+
export declare const NOOP_METRICS: IMetricsSink;
|
|
88
|
+
/**
|
|
89
|
+
* Wrap a sink so a throwing/buggy `onEvent` can never break the data path — observability is strictly
|
|
90
|
+
* best-effort. `CloudRoaring` applies this to the user-supplied sink at construction.
|
|
91
|
+
*/
|
|
92
|
+
export declare function safeMetrics(sink: IMetricsSink): IMetricsSink;
|
|
93
|
+
/** Accumulated totals — the shape returned by {@link CountingMetricsSink.snapshot}. */
|
|
94
|
+
export interface MetricsSnapshot {
|
|
95
|
+
readonly storage: {
|
|
96
|
+
readonly gets: number;
|
|
97
|
+
readonly bytes: number;
|
|
98
|
+
readonly totalMs: number;
|
|
99
|
+
};
|
|
100
|
+
readonly cache: {
|
|
101
|
+
readonly hits: number;
|
|
102
|
+
readonly misses: number;
|
|
103
|
+
};
|
|
104
|
+
readonly retries: {
|
|
105
|
+
readonly transient: number;
|
|
106
|
+
};
|
|
107
|
+
readonly intersect: {
|
|
108
|
+
readonly calls: number;
|
|
109
|
+
readonly fetchedChunks: number;
|
|
110
|
+
readonly skippedChunks: number;
|
|
111
|
+
};
|
|
112
|
+
readonly ops: Readonly<Record<MetricOpName, {
|
|
113
|
+
readonly count: number;
|
|
114
|
+
readonly totalMs: number;
|
|
115
|
+
}>>;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* A ready-made sink that tallies events into a {@link MetricsSnapshot} — handy for tests, quick scripts,
|
|
119
|
+
* and as the grounded-request-cost source behind `costReport()`. `snapshot()` returns an independent copy;
|
|
120
|
+
* `reset()` zeroes the counters.
|
|
121
|
+
*/
|
|
122
|
+
export declare class CountingMetricsSink implements IMetricsSink {
|
|
123
|
+
private storage;
|
|
124
|
+
private cache;
|
|
125
|
+
private retries;
|
|
126
|
+
private intersect;
|
|
127
|
+
private ops;
|
|
128
|
+
private static zeroOps;
|
|
129
|
+
onEvent(event: MetricEvent): void;
|
|
130
|
+
snapshot(): MetricsSnapshot;
|
|
131
|
+
reset(): void;
|
|
132
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Name → physical-key encoding. The library validates *length*, and encodes everything else.
|
|
3
|
+
*
|
|
4
|
+
* Lives in `core/` rather than beside the drivers because it is pure string logic — no I/O, no SDK, no
|
|
5
|
+
* `node:` builtin — and because `validate.ts` needs it to measure a name against the key budget. Core may
|
|
6
|
+
* not import a driver (invariant 7), and a driver may import core, so this is the only layer both can see.
|
|
7
|
+
*
|
|
8
|
+
* The old grammar was an allowlist — `[A-Za-z0-9._:-]`. Widening it one character at a time is what exposed the
|
|
9
|
+
* real problem: the colon had been missing from it by accident rather than by decision, and nothing in the
|
|
10
|
+
* process would have caught the next omission either. An allowlist rejects names for the storage layer's
|
|
11
|
+
* convenience, and the storage layer's convenience is the library's job, not the caller's. So a name is **any
|
|
12
|
+
* non-empty string**, and each physical boundary escapes what *it* cannot take literally.
|
|
13
|
+
*
|
|
14
|
+
* It also fixes a hazard the allowlist *permitted*: `con`, `nul` and `com1` are Windows device names that
|
|
15
|
+
* validated cleanly and failed only on a user's machine. A grammar can be both too narrow and too permissive
|
|
16
|
+
* at once, which is the argument against having one.
|
|
17
|
+
*
|
|
18
|
+
* Percent-encoding, because it is the one escape everybody already reads. `%` escapes itself as `%25` and is
|
|
19
|
+
* encoded **first**, which is what makes the transform injective: every `%` in an encoded string starts an
|
|
20
|
+
* escape, so decoding is unambiguous and two distinct names can never collide on one key.
|
|
21
|
+
*
|
|
22
|
+
* There are two alphabets, because the boundaries differ:
|
|
23
|
+
*
|
|
24
|
+
* - **Object keys** (S3, GCS, Azure Blob) take almost anything. They need `/` escaped so a name cannot
|
|
25
|
+
* invent hierarchy or break a key parser that splits on it, `#` and `|` (see below), and control characters
|
|
26
|
+
* because they are not legal in the XML an S3 LIST returns.
|
|
27
|
+
* - **Filesystem paths** need all of that plus `:` (an NTFS alternate-data-stream separator, where a write can
|
|
28
|
+
* *succeed* while `readdir` never lists the result), plus three hazards that are about the component as a
|
|
29
|
+
* whole rather than its characters — see {@link encodeNameForPath}.
|
|
30
|
+
*
|
|
31
|
+
* **`#` and `|` are escaped for compatibility, not for any backend still here.** They were reserved because
|
|
32
|
+
* the DynamoDB registry composed its partition key from `<namespace>#<segment>|…`, and that driver is gone.
|
|
33
|
+
* The escaping stays anyway: it is baked into every key already written, and un-reserving the two characters
|
|
34
|
+
* would change what `encodeNameForKey` emits for a name containing either — silently moving those segments
|
|
35
|
+
* to a key nothing looks up. A cheap two-character reservation is the right price for not migrating a
|
|
36
|
+
* bucket; the property test below pins it.
|
|
37
|
+
*
|
|
38
|
+
* **Every name that was legal before encodes to itself on the OBJECT-KEY alphabet** — so S3, GCS and Azure
|
|
39
|
+
* keys are byte-identical and there is nothing to migrate there. That is the property to preserve if this
|
|
40
|
+
* file is ever edited, and a property test asserts it over the whole old grammar.
|
|
41
|
+
*
|
|
42
|
+
* The **path** alphabet is not identical, and the difference is a breaking change for an existing LocalFs
|
|
43
|
+
* store. Two classes move: a Windows device-name stem (`con`, `nul`, `com1`, `con.backup`) and a trailing dot
|
|
44
|
+
* (`a.`). Both were legal before and are escaped now — deliberately, since both silently alias or fail on
|
|
45
|
+
* Windows — but a 0.9 store holding one will not find it after the upgrade, because the readers' round-trip
|
|
46
|
+
* guard skips a spelling the driver would never have written. See the upgrade note in `CHANGELOG.md`.
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* Encode a name for use inside an **object key** (S3, GCS, Azure Blob).
|
|
50
|
+
*
|
|
51
|
+
* Leaves the historically-legal alphabet literal so existing keys do not move, and escapes everything else —
|
|
52
|
+
* including `/` (which would invent hierarchy and break a parser that splits on it), `#` and `|` (reserved by
|
|
53
|
+
* the former DynamoDB registry and kept reserved so existing keys stay valid), and control characters (not
|
|
54
|
+
* legal in S3's XML responses).
|
|
55
|
+
*/
|
|
56
|
+
export declare function encodeNameForKey(name: string): string;
|
|
57
|
+
/** Inverse of {@link encodeNameForKey}. */
|
|
58
|
+
export declare function decodeNameFromKey(encoded: string): string;
|
|
59
|
+
/**
|
|
60
|
+
* Encode a name for use as a **filesystem path component**.
|
|
61
|
+
*
|
|
62
|
+
* Everything {@link encodeNameForKey} escapes, plus `:`, plus three hazards that are properties of the whole
|
|
63
|
+
* component rather than of any character in it. All three are silent corruption rather than errors, which is
|
|
64
|
+
* why they are handled here instead of being left to the OS:
|
|
65
|
+
*
|
|
66
|
+
* 1. **`.` and `..`** are traversal. `join(root, '..')` leaves the storage root.
|
|
67
|
+
* 2. **Windows reserved device names** — `CON`, `PRN`, `AUX`, `NUL`, `COM1`–`COM9`, `LPT1`–`LPT9`, case
|
|
68
|
+
* insensitive and *with or without an extension*, so `con.0.crbm` is reserved too. Opening one addresses a
|
|
69
|
+
* device rather than a file. Note the old grammar **permitted** these: `store.segment('con')` validated
|
|
70
|
+
* cleanly and broke only on a user's Windows box.
|
|
71
|
+
* 3. **A trailing `.` or space**, which Windows silently strips — so `a.` and `a` would become the same
|
|
72
|
+
* directory, quietly merging two segments.
|
|
73
|
+
*
|
|
74
|
+
* Each is fixed by escaping the one character that defuses it, which keeps the encoding injective: a name that
|
|
75
|
+
* needed defusing produces a `%XX` no other name produces.
|
|
76
|
+
*/
|
|
77
|
+
export declare function encodeNameForPath(name: string): string;
|
|
78
|
+
/** Inverse of {@link encodeNameForPath}. */
|
|
79
|
+
export declare function decodeNameFromPath(encoded: string): string;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A {@link StorageChunkSource} view that holds **one** segment at **one** generation, and passes everything else
|
|
3
|
+
* through to the live source.
|
|
4
|
+
*
|
|
5
|
+
* A pin exists so a long job describes a single instant. An ordinary handle re-resolves on `cache.genTtlMs`, so a
|
|
6
|
+
* publish part-way through an export, a send or a reconciliation means the second half of the job describes a
|
|
7
|
+
* different instant than the first — and nothing in the result says so.
|
|
8
|
+
*
|
|
9
|
+
* **A pin is a generation NUMBER, not a captured reader.** The number is immutable, so the reader behind it can
|
|
10
|
+
* live in the shared bounded LRU and be evicted freely: re-opening at the same number reproduces the same
|
|
11
|
+
* bytes. Holding the reader instead put every live pin outside the library's memory ceiling.
|
|
12
|
+
*
|
|
13
|
+
* **Other segments are not pinned, and that is the contract, not an omission.** `snap.intersect([other])` reads
|
|
14
|
+
* `snap` at its pinned generation and `other` at whatever is current. Pinning the whole query means pinning
|
|
15
|
+
* each segment. The alternative shapes are both worse: refusing the combine outright makes a pin useless to the
|
|
16
|
+
* export and reconciliation jobs it exists for, and silently pinning the operands would quietly change what
|
|
17
|
+
* `other` means to a caller who did not ask.
|
|
18
|
+
*
|
|
19
|
+
* What this must never do is the inverse — read a pinned handle's segment **live** because the call was made
|
|
20
|
+
* on some other handle. That is why the routing is by segment identity here rather than by which object the
|
|
21
|
+
* caller happened to start from.
|
|
22
|
+
*/
|
|
23
|
+
import type { ChunkRef, StorageChunkSource, SegmentRef, SegmentSize } from './ports.js';
|
|
24
|
+
import type { CrbmStorageChunkSource } from './crbm-storage-source.js';
|
|
25
|
+
/** What a pin holds for one segment: the generation, and the version identifying those exact bytes. */
|
|
26
|
+
export interface PinnedAt {
|
|
27
|
+
readonly generation: number | null;
|
|
28
|
+
readonly version: string | null;
|
|
29
|
+
}
|
|
30
|
+
export declare class PinnedStorageChunkSource implements StorageChunkSource {
|
|
31
|
+
private readonly inner;
|
|
32
|
+
/**
|
|
33
|
+
* The pinned segments, keyed by {@link segmentKey}. A **set** rather than one entry because a combine may
|
|
34
|
+
* involve several pinned handles, and each must be read at its own pin — including when the call was made
|
|
35
|
+
* on a different handle. Reading a pinned handle live because the caller started elsewhere is the silent
|
|
36
|
+
* wrong answer this exists to prevent.
|
|
37
|
+
*/
|
|
38
|
+
private readonly pins;
|
|
39
|
+
constructor(inner: CrbmStorageChunkSource,
|
|
40
|
+
/**
|
|
41
|
+
* The pinned segments, keyed by {@link segmentKey}. A **set** rather than one entry because a combine may
|
|
42
|
+
* involve several pinned handles, and each must be read at its own pin — including when the call was made
|
|
43
|
+
* on a different handle. Reading a pinned handle live because the caller started elsewhere is the silent
|
|
44
|
+
* wrong answer this exists to prevent.
|
|
45
|
+
*/
|
|
46
|
+
pins: ReadonlyMap<string, PinnedAt>);
|
|
47
|
+
/**
|
|
48
|
+
* The pin for this segment, or `undefined` if it is not pinned here.
|
|
49
|
+
*
|
|
50
|
+
* A pin whose `generation` is `null` is a segment that had none when it was pinned: that handle reads empty
|
|
51
|
+
* for its lifetime, which is exactly what an unpinned read of it does. That is **not** the same as "this
|
|
52
|
+
* source cannot pin" — a source that genuinely cannot is a capability gap and fails fast at `pin()`.
|
|
53
|
+
*/
|
|
54
|
+
private pinFor;
|
|
55
|
+
getChunk(ref: ChunkRef): Promise<Uint8Array | null>;
|
|
56
|
+
listChunkKeys(ref: SegmentRef): Promise<number[]>;
|
|
57
|
+
sizeOf(ref: SegmentRef): Promise<SegmentSize | null>;
|
|
58
|
+
cardinalities(ref: SegmentRef): Promise<ReadonlyMap<number, number> | null>;
|
|
59
|
+
currentGeneration(ref: SegmentRef): Promise<number | null>;
|
|
60
|
+
/**
|
|
61
|
+
* A pinned segment reports the version captured when it was pinned, so its decoded chunks are cached under a
|
|
62
|
+
* key that cannot collide with the live generation's. That is what lets a pinned handle share the store's
|
|
63
|
+
* chunk cache safely; sharing it on a generation-only key was how a pinned read could resurrect an id that
|
|
64
|
+
* `eraseIdFromSegment` had reported physically gone.
|
|
65
|
+
*/
|
|
66
|
+
currentVersion(ref: SegmentRef): Promise<string | null>;
|
|
67
|
+
exists(ref: SegmentRef): Promise<boolean>;
|
|
68
|
+
invalidate(ref: SegmentRef): void;
|
|
69
|
+
}
|