@bjornpagen/bumbledb-log 0.17.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/README.md +119 -0
- package/package.json +58 -0
- package/src/braids.ts +38 -0
- package/src/bytes.ts +236 -0
- package/src/chain.ts +109 -0
- package/src/codec.ts +256 -0
- package/src/descriptor.ts +741 -0
- package/src/errors.ts +150 -0
- package/src/index.ts +38 -0
- package/src/keys.ts +34 -0
- package/src/manifest.ts +113 -0
- package/src/replica.ts +796 -0
- package/src/store.ts +292 -0
- package/src/tenants.ts +140 -0
- package/src/value.ts +285 -0
- package/src/writer.ts +506 -0
package/src/errors.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The driver's error identities, on the SDK's `@superbuilders/errors`
|
|
3
|
+
* idiom: exported sentinel values checked with `errors.is`, structured
|
|
4
|
+
* causes carried as data properties read back with the `*Of` accessors —
|
|
5
|
+
* never by message-string matching. There is deliberately no
|
|
6
|
+
* `ErrAlreadyApplied`: the state it would name is absorbed by idempotent
|
|
7
|
+
* replay (L10) and never surfaces.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as errors from "@superbuilders/errors"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Typed refusal of a protocol object before any apply: batch shape,
|
|
14
|
+
* version, fingerprint, manifest shape, checkpoint braid-set drift —
|
|
15
|
+
* one identity, cause data per site.
|
|
16
|
+
*/
|
|
17
|
+
const ErrRefused = errors.new("bumbledb-log refused")
|
|
18
|
+
|
|
19
|
+
/** `commit` recorded ops in more than one braid; `commitSplit` is the verb. */
|
|
20
|
+
const ErrSpanningCommit = errors.new(
|
|
21
|
+
"bumbledb-log spanningCommit: the recorded ops span braids — commitSplit is the explicit verb"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
/** 404 at or below the current checkpoint's vector: the tail was gc'd. */
|
|
25
|
+
const ErrGapDetected = errors.new("bumbledb-log gapDetected: the log tail below the checkpoint vector was collected")
|
|
26
|
+
|
|
27
|
+
/** A rejected replay on a store that passed the wholeness check. */
|
|
28
|
+
const ErrReplayDiverged = errors.new(
|
|
29
|
+
"bumbledb-log replayDiverged: a published batch rejected during steady-state replay"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
/** The chain discipline, one identity with three proved causes. */
|
|
33
|
+
const ErrChainMismatch = errors.new("bumbledb-log chainMismatch: the batch violates the braid's chain discipline")
|
|
34
|
+
|
|
35
|
+
/** Bounded live-tip losses exhausted — an operational signal, not an outcome arm. */
|
|
36
|
+
const ErrContention = errors.new("bumbledb-log contention: consecutive live-tip losses exhausted the bound")
|
|
37
|
+
|
|
38
|
+
/** The vendor channel: I/O and store infrastructure failures. */
|
|
39
|
+
const ErrStore = errors.new("bumbledb-log store failure")
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Decode refusal kinds carry the cross-implementation identity names the
|
|
43
|
+
* Rust driver's `DecodeError::identity` pins — the conformance corpus
|
|
44
|
+
* compares them string for string. The tail kinds are this driver's own
|
|
45
|
+
* replica-boundary refusals.
|
|
46
|
+
*/
|
|
47
|
+
type RefusalCause =
|
|
48
|
+
| { readonly kind: "Truncated"; readonly at: string }
|
|
49
|
+
| { readonly kind: "BadMagic" }
|
|
50
|
+
| { readonly kind: "Version"; readonly version: number }
|
|
51
|
+
| { readonly kind: "Flags"; readonly flags: number }
|
|
52
|
+
| { readonly kind: "FingerprintMismatch"; readonly carried: string; readonly expected: string }
|
|
53
|
+
| { readonly kind: "UnknownBraid"; readonly braid: number }
|
|
54
|
+
| { readonly kind: "UnknownOpKind"; readonly op: number; readonly opKind: number }
|
|
55
|
+
| { readonly kind: "UnknownRelation"; readonly op: number; readonly relation: number }
|
|
56
|
+
| { readonly kind: "ClosedRelation"; readonly op: number; readonly relation: number }
|
|
57
|
+
| { readonly kind: "OpRelationOutsideBraid"; readonly op: number; readonly relation: number; readonly braid: string }
|
|
58
|
+
| { readonly kind: "TagMismatch"; readonly relation: string; readonly row: number; readonly field: string }
|
|
59
|
+
| { readonly kind: "BoolByte"; readonly relation: string; readonly row: number; readonly field: string }
|
|
60
|
+
| { readonly kind: "InvalidUtf8"; readonly relation: string; readonly row: number; readonly field: string }
|
|
61
|
+
| { readonly kind: "EmptyInterval"; readonly relation: string; readonly row: number; readonly field: string }
|
|
62
|
+
| { readonly kind: "IntervalOverflow"; readonly relation: string; readonly row: number; readonly field: string }
|
|
63
|
+
| { readonly kind: "TrailingBytes"; readonly bytes: number }
|
|
64
|
+
| { readonly kind: "ManifestShape" }
|
|
65
|
+
| { readonly kind: "ManifestVersion"; readonly version: number }
|
|
66
|
+
| { readonly kind: "CheckpointShape" }
|
|
67
|
+
| { readonly kind: "CheckpointBraids"; readonly carried: readonly string[]; readonly derived: readonly string[] }
|
|
68
|
+
| { readonly kind: "CheckpointDigest"; readonly expected: string; readonly computed: string }
|
|
69
|
+
| { readonly kind: "NoOpSlot"; readonly braid: string; readonly slot: bigint; readonly writer: bigint }
|
|
70
|
+
|
|
71
|
+
type ChainCause = "prev" | "slot" | "timestamp"
|
|
72
|
+
|
|
73
|
+
interface ChainMismatchData {
|
|
74
|
+
readonly cause: ChainCause
|
|
75
|
+
readonly braid: string
|
|
76
|
+
readonly slot: bigint
|
|
77
|
+
readonly writer: bigint
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type ContentionCause =
|
|
81
|
+
| {
|
|
82
|
+
readonly kind: "hot-key"
|
|
83
|
+
readonly statement: string
|
|
84
|
+
readonly determinants: ReadonlyArray<Readonly<Record<string, unknown>>>
|
|
85
|
+
}
|
|
86
|
+
| { readonly kind: "slot-race"; readonly tip: bigint }
|
|
87
|
+
|
|
88
|
+
interface ContentionData {
|
|
89
|
+
readonly braid: string
|
|
90
|
+
readonly cause: ContentionCause
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const refusalData = new WeakMap<Error, RefusalCause>()
|
|
94
|
+
const chainData = new WeakMap<Error, ChainMismatchData>()
|
|
95
|
+
const contentionData = new WeakMap<Error, ContentionData>()
|
|
96
|
+
|
|
97
|
+
function refuse(cause: RefusalCause, detail: string): never {
|
|
98
|
+
const error = errors.wrap(ErrRefused, detail)
|
|
99
|
+
refusalData.set(error, cause)
|
|
100
|
+
throw error
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function refuseChain(data: ChainMismatchData, detail: string): never {
|
|
104
|
+
const error = errors.wrap(ErrChainMismatch, detail)
|
|
105
|
+
chainData.set(error, data)
|
|
106
|
+
throw error
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function throwContention(data: ContentionData, detail: string): never {
|
|
110
|
+
const error = errors.wrap(ErrContention, detail)
|
|
111
|
+
contentionData.set(error, data)
|
|
112
|
+
throw error
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function refusalOf(error: Error): RefusalCause | undefined {
|
|
116
|
+
return refusalData.get(error)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function chainMismatchOf(error: Error): ChainMismatchData | undefined {
|
|
120
|
+
return chainData.get(error)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function contentionOf(error: Error): ContentionData | undefined {
|
|
124
|
+
return contentionData.get(error)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Every store failure wraps the exported sentinel itself, so
|
|
128
|
+
* `errors.is(e, ErrStore)` matches by identity; the vendor error's
|
|
129
|
+
* message rides the detail verbatim. */
|
|
130
|
+
function wrapStore(inner: Error, detail: string): Error {
|
|
131
|
+
return errors.wrap(ErrStore, `${detail}: ${inner.message}`)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type { ChainCause, ChainMismatchData, ContentionCause, ContentionData, RefusalCause }
|
|
135
|
+
export {
|
|
136
|
+
chainMismatchOf,
|
|
137
|
+
contentionOf,
|
|
138
|
+
ErrChainMismatch,
|
|
139
|
+
ErrContention,
|
|
140
|
+
ErrGapDetected,
|
|
141
|
+
ErrRefused,
|
|
142
|
+
ErrReplayDiverged,
|
|
143
|
+
ErrSpanningCommit,
|
|
144
|
+
ErrStore,
|
|
145
|
+
refusalOf,
|
|
146
|
+
refuse,
|
|
147
|
+
refuseChain,
|
|
148
|
+
throwContention,
|
|
149
|
+
wrapStore
|
|
150
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bjornpagen/bumbledb-log — braided object-store replication for
|
|
3
|
+
* bumbledb, a thin peer of the engine SDK. Public surface: the pure
|
|
4
|
+
* protocol pair (`encodeBatch`/`decodeBatch`, `braidsOf`) mirrored
|
|
5
|
+
* byte-exactly against the Rust driver, the five-verb object store
|
|
6
|
+
* with its tier-1 `fsStore`, and `openReplica`/`openWriter`/
|
|
7
|
+
* `openTenants` composed from the engine SDK's own verbs — the replica
|
|
8
|
+
* hands out the SDK's `Db`, and no engine surface is duplicated.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type { Braid } from "#braids.ts"
|
|
12
|
+
export { braidsOf, serialAtStatementsOf } from "#braids.ts"
|
|
13
|
+
export type { BatchHeader, BatchOp, ChainPosition, DecodedBatch } from "#codec.ts"
|
|
14
|
+
export { decodeBatch, encodeBatch, verifyChain } from "#codec.ts"
|
|
15
|
+
export type { LogDescriptor, LogTheory, SerialStatement } from "#descriptor.ts"
|
|
16
|
+
export { descriptorOf } from "#descriptor.ts"
|
|
17
|
+
export type { ChainCause, ChainMismatchData, ContentionCause, ContentionData, RefusalCause } from "#errors.ts"
|
|
18
|
+
export {
|
|
19
|
+
chainMismatchOf,
|
|
20
|
+
contentionOf,
|
|
21
|
+
ErrChainMismatch,
|
|
22
|
+
ErrContention,
|
|
23
|
+
ErrGapDetected,
|
|
24
|
+
ErrRefused,
|
|
25
|
+
ErrReplayDiverged,
|
|
26
|
+
ErrSpanningCommit,
|
|
27
|
+
ErrStore,
|
|
28
|
+
refusalOf
|
|
29
|
+
} from "#errors.ts"
|
|
30
|
+
export type { OpenReplicaOptions, Replica } from "#replica.ts"
|
|
31
|
+
export { openReplica } from "#replica.ts"
|
|
32
|
+
export type { Create, Fetched, ObjectStore, Poll, Swap } from "#store.ts"
|
|
33
|
+
export { fsStore } from "#store.ts"
|
|
34
|
+
export type { OpenTenantsOptions, Tenants } from "#tenants.ts"
|
|
35
|
+
export { openTenants } from "#tenants.ts"
|
|
36
|
+
export type { LogInterval, LogValue } from "#value.ts"
|
|
37
|
+
export type { BraidOutcome, Commit, CommitSplit, Durability, LogBatch, Writer } from "#writer.ts"
|
|
38
|
+
export { openWriter } from "#writer.ts"
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The key layout (10): generation numbers zero-padded lowercase hex,
|
|
3
|
+
* 16 chars; a prefix is a store; a tenant is a prefix under `t/`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function hex16(g: bigint): string {
|
|
7
|
+
return g.toString(16).padStart(16, "0")
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function manifestKey(prefix: string): string {
|
|
11
|
+
return `${prefix}/manifest.json`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function logKey(prefix: string, braid: string, g: bigint): string {
|
|
15
|
+
return `${prefix}/log/${braid}/${hex16(g)}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function checkpointMdbKey(prefix: string, digest: string): string {
|
|
19
|
+
return `${prefix}/ckpt/${digest}.mdb`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function checkpointJsonKey(prefix: string, digest: string): string {
|
|
23
|
+
return `${prefix}/ckpt/${digest}.json`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function idsKey(prefix: string, relation: number, field: number): string {
|
|
27
|
+
return `${prefix}/ids/${relation.toString(16).padStart(8, "0")}/${field.toString(16).padStart(4, "0")}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function tenantPrefix(root: string, tenant: string): string {
|
|
31
|
+
return `${root}/t/${tenant}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { checkpointJsonKey, checkpointMdbKey, hex16, idsKey, logKey, manifestKey, tenantPrefix }
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The protocol's one mutable object (10): canonical single-line UTF-8
|
|
3
|
+
* JSON, strict parse, field order fixed — strictness is proved by
|
|
4
|
+
* re-rendering the parse and demanding byte equality, so a
|
|
5
|
+
* non-canonical manifest is a typed refusal, never a tolerated variant.
|
|
6
|
+
* The checkpoint json beside it is immutable and digest-keyed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as errors from "@superbuilders/errors"
|
|
10
|
+
import { utf8Encoder, utf8StrictDecoder } from "#bytes.ts"
|
|
11
|
+
import { refuse } from "#errors.ts"
|
|
12
|
+
|
|
13
|
+
interface Manifest {
|
|
14
|
+
readonly fingerprint: string
|
|
15
|
+
readonly checkpoint: string | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const HEX64 = /^[0-9a-f]{64}$/
|
|
19
|
+
|
|
20
|
+
function renderManifest(manifest: Manifest): Uint8Array {
|
|
21
|
+
const checkpoint = manifest.checkpoint === null ? "null" : `"${manifest.checkpoint}"`
|
|
22
|
+
return utf8Encoder.encode(`{"v":2,"fingerprint":"${manifest.fingerprint}","checkpoint":${checkpoint}}`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseManifest(bytes: Uint8Array): Manifest {
|
|
26
|
+
const decoded = errors.trySync(function decodeText() {
|
|
27
|
+
return utf8StrictDecoder.decode(bytes)
|
|
28
|
+
})
|
|
29
|
+
if (decoded.error) {
|
|
30
|
+
refuse({ kind: "ManifestShape" }, "manifest is not UTF-8")
|
|
31
|
+
}
|
|
32
|
+
const parsed = errors.trySync(function parseJson() {
|
|
33
|
+
return JSON.parse(decoded.data) as unknown
|
|
34
|
+
})
|
|
35
|
+
if (parsed.error || typeof parsed.data !== "object" || parsed.data === null) {
|
|
36
|
+
refuse({ kind: "ManifestShape" }, "manifest is not a JSON object")
|
|
37
|
+
}
|
|
38
|
+
const record = parsed.data as Record<string, unknown>
|
|
39
|
+
if (typeof record.v !== "number") {
|
|
40
|
+
refuse({ kind: "ManifestShape" }, "manifest carries no version")
|
|
41
|
+
}
|
|
42
|
+
if (record.v !== 2) {
|
|
43
|
+
refuse({ kind: "ManifestVersion", version: record.v }, `manifest version ${record.v}, consumers refuse ≠ 2`)
|
|
44
|
+
}
|
|
45
|
+
const fingerprint = record.fingerprint
|
|
46
|
+
const checkpoint = record.checkpoint
|
|
47
|
+
if (typeof fingerprint !== "string" || !HEX64.test(fingerprint)) {
|
|
48
|
+
refuse({ kind: "ManifestShape" }, "manifest fingerprint is not 64 hex")
|
|
49
|
+
}
|
|
50
|
+
if (checkpoint !== null && (typeof checkpoint !== "string" || !HEX64.test(checkpoint))) {
|
|
51
|
+
refuse({ kind: "ManifestShape" }, "manifest checkpoint is neither null nor 64 hex")
|
|
52
|
+
}
|
|
53
|
+
const manifest: Manifest = { fingerprint, checkpoint: checkpoint === null ? null : checkpoint }
|
|
54
|
+
const canonical = renderManifest(manifest)
|
|
55
|
+
if (canonical.length !== bytes.length || utf8StrictDecoder.decode(canonical) !== decoded.data) {
|
|
56
|
+
refuse({ kind: "ManifestShape" }, "manifest is not the canonical single-line rendering")
|
|
57
|
+
}
|
|
58
|
+
return manifest
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface CheckpointHead {
|
|
62
|
+
readonly g: bigint
|
|
63
|
+
readonly hash: string
|
|
64
|
+
readonly ts: bigint
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface CheckpointFacts {
|
|
68
|
+
readonly braids: ReadonlyMap<string, CheckpointHead>
|
|
69
|
+
readonly catalog: string
|
|
70
|
+
readonly writer: bigint
|
|
71
|
+
readonly prev: string | null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseCheckpoint(bytes: Uint8Array): CheckpointFacts {
|
|
75
|
+
const parsed = errors.trySync(function parseJson() {
|
|
76
|
+
return JSON.parse(utf8StrictDecoder.decode(bytes)) as unknown
|
|
77
|
+
})
|
|
78
|
+
if (parsed.error || typeof parsed.data !== "object" || parsed.data === null) {
|
|
79
|
+
refuse({ kind: "CheckpointShape" }, "checkpoint json is not an object")
|
|
80
|
+
}
|
|
81
|
+
const record = parsed.data as Record<string, unknown>
|
|
82
|
+
const rawBraids = record.braids
|
|
83
|
+
if (typeof rawBraids !== "object" || rawBraids === null) {
|
|
84
|
+
refuse({ kind: "CheckpointShape" }, "checkpoint json carries no braids map")
|
|
85
|
+
}
|
|
86
|
+
const braids = new Map<string, CheckpointHead>()
|
|
87
|
+
for (const [braid, head] of Object.entries(rawBraids as Record<string, unknown>)) {
|
|
88
|
+
if (typeof head !== "object" || head === null) {
|
|
89
|
+
refuse({ kind: "CheckpointShape" }, `checkpoint braid ${braid} head is not an object`)
|
|
90
|
+
}
|
|
91
|
+
const headRecord = head as Record<string, unknown>
|
|
92
|
+
const g = headRecord.g
|
|
93
|
+
const hash = headRecord.hash
|
|
94
|
+
const ts = headRecord.ts
|
|
95
|
+
if (typeof g !== "number" || typeof ts !== "number" || typeof hash !== "string" || !HEX64.test(hash)) {
|
|
96
|
+
refuse({ kind: "CheckpointShape" }, `checkpoint braid ${braid} head is malformed`)
|
|
97
|
+
}
|
|
98
|
+
braids.set(braid, { g: BigInt(g), hash, ts: BigInt(ts) })
|
|
99
|
+
}
|
|
100
|
+
const catalog = record.catalog
|
|
101
|
+
const writer = record.writer
|
|
102
|
+
const prev = record.prev
|
|
103
|
+
if (typeof catalog !== "string" || !HEX64.test(catalog) || typeof writer !== "number") {
|
|
104
|
+
refuse({ kind: "CheckpointShape" }, "checkpoint json catalog or writer is malformed")
|
|
105
|
+
}
|
|
106
|
+
if (prev !== null && (typeof prev !== "string" || !HEX64.test(prev))) {
|
|
107
|
+
refuse({ kind: "CheckpointShape" }, "checkpoint json prev is neither null nor a digest")
|
|
108
|
+
}
|
|
109
|
+
return { braids, catalog, writer: BigInt(writer), prev: prev === null ? null : prev }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type { CheckpointFacts, CheckpointHead, Manifest }
|
|
113
|
+
export { parseCheckpoint, parseManifest, renderManifest }
|