@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/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# @bjornpagen/bumbledb-log
|
|
2
|
+
|
|
3
|
+
Braided object-store replication for [bumbledb](https://github.com/bjornpagen/bumbledb):
|
|
4
|
+
a thin peer of `@bjornpagen/bumbledb` (peer dependency, 0.17.x lockstep).
|
|
5
|
+
The package is three things:
|
|
6
|
+
|
|
7
|
+
1. **The mirrored pure pair**, byte-exact against the Rust driver and
|
|
8
|
+
pinned by cross-language goldens: `encodeBatch`/`decodeBatch` (the
|
|
9
|
+
BDBL v2 command codec — a batch is header + ops, nothing else) and
|
|
10
|
+
`braidsOf(descriptor)` (the schema's own shard map, as data — with
|
|
11
|
+
`serialAtStatementsOf` naming the degenerate-serial statements beside it).
|
|
12
|
+
2. **The five-verb object store** — `get`, `getIfChanged`, `putCreate`,
|
|
13
|
+
`putSwap`, `delete` — with `fsStore` as the tier-1 local-directory
|
|
14
|
+
implementation (deployment case 5's production backend, not a dev
|
|
15
|
+
double). The S3/R2/OCI store rides `aws4fetch` and is not yet in this
|
|
16
|
+
build (the dependency was unfetchable offline).
|
|
17
|
+
3. **Replica and writer** composed from the engine SDK's existing verbs:
|
|
18
|
+
`openReplica` hands out the SDK's own `Db`; `openWriter` adds the
|
|
19
|
+
right to create log objects; `openTenants` is an LRU of per-tenant
|
|
20
|
+
replicas. No engine surface is duplicated.
|
|
21
|
+
|
|
22
|
+
Async ⟺ network: `openReplica`, `refresh`, `waitFor`, `commit`,
|
|
23
|
+
`commitSplit`, and disposal await store verbs; everything on
|
|
24
|
+
`replica.db`, the `batch.*` recorders, and the pure pair are synchronous.
|
|
25
|
+
|
|
26
|
+
## The Vercel recipe (documented example, not framework code)
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// lib/db.ts — module scope; Fluid shares this across the instance's requests
|
|
30
|
+
import { fsStore, openReplica, openWriter } from "@bjornpagen/bumbledb-log"
|
|
31
|
+
|
|
32
|
+
export const replica = await openReplica({ store: s3(env), prefix: "prod/main", dir: "/tmp/store", theory: Ledger })
|
|
33
|
+
export const writer = openWriter(replica)
|
|
34
|
+
|
|
35
|
+
// route handler
|
|
36
|
+
const out = await writer.commit((b) => b.insert(Booking, [row]))
|
|
37
|
+
if (out.tag === "accepted") ctx.waitUntil(replica.refresh(out.braid))
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- **The `/tmp` budget gate**: checkpoint plus working set must stay
|
|
41
|
+
≤ 400 MB (100 MB headroom under the 500 MB instance limit); the
|
|
42
|
+
leaf-blob pattern keeps metadata stores in the tens of MB. Per-tenant
|
|
43
|
+
fleets get the same gate through `openTenants({ budgetBytes, maxOpen })`.
|
|
44
|
+
- **Cross-instance read-your-writes**: a commit returns
|
|
45
|
+
`(braid, generation)`; a session token is the pointwise max of every
|
|
46
|
+
pair a flow has seen; `replica.waitFor(vector)` refreshes until the
|
|
47
|
+
local vector dominates it. The committing instance always reads its
|
|
48
|
+
own writes without waiting. A singleton map is the single-braid form.
|
|
49
|
+
- **The `ErrContention` runbook**: the error carries its cause, sourced
|
|
50
|
+
from the terminal re-judgment itself — `{ kind: "hot-key", statement,
|
|
51
|
+
determinants }` names the statement and carries the offending facts'
|
|
52
|
+
raw values from the engine's own violation; the remedies are a
|
|
53
|
+
reservation relation on the hot capacity (an ordinary weighted child
|
|
54
|
+
row — the schema idiom) or resident mode. `{ kind: "slot-race", tip }`
|
|
55
|
+
means the terminal losses were accepted but out-raced: an operational
|
|
56
|
+
signal to shard the theory into more braids or move the hot braid to
|
|
57
|
+
a resident Rust writer, whose group commit batches the queue. This
|
|
58
|
+
package ships no group commit of its own; the recorded reopen trigger
|
|
59
|
+
is a measured TS deployment at Turso-density write rates where a
|
|
60
|
+
deliberate batching delay would amortize many writers into one PUT.
|
|
61
|
+
|
|
62
|
+
## The local-fleet recipe (deployment case 5)
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// one process per scope loop; all processes share one FsStore prefix
|
|
66
|
+
import { fsStore, openReplica, openWriter } from "@bjornpagen/bumbledb-log"
|
|
67
|
+
|
|
68
|
+
const replica = await openReplica({
|
|
69
|
+
store: fsStore("/data/primer/log"), // the five verbs over a directory
|
|
70
|
+
prefix: "world/v1",
|
|
71
|
+
dir: `/data/primer/replicas/${scopeName}`, // per-process local dir — never shared
|
|
72
|
+
theory: Explanation
|
|
73
|
+
})
|
|
74
|
+
const writer = openWriter(replica)
|
|
75
|
+
|
|
76
|
+
// one pass = refresh, render, emit, lower, one commit
|
|
77
|
+
await replica.refresh()
|
|
78
|
+
const out = await writer.commit((batch) => {
|
|
79
|
+
batch.insert(Explanation, growth.explanations) // ids from batch.reserve
|
|
80
|
+
batch.insert(Case, growth.cases)
|
|
81
|
+
return growth.summary
|
|
82
|
+
})
|
|
83
|
+
// rejected ⇒ the host re-renders against the moved world and re-lowers —
|
|
84
|
+
// a K-conflict double-mint resolves to the winner's row on the next pass.
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
What makes the case easy: an insert-only theory has no delete races to
|
|
88
|
+
lose; content-keyed determinants keep concurrent scope loops off each
|
|
89
|
+
other's obligations, so a lost slot re-judges to the same accepted
|
|
90
|
+
verdict at the moved base; a one-braid theory serializes slot claims on
|
|
91
|
+
a link publication, which at document-per-minutes commit rates is free.
|
|
92
|
+
Each process owns its local replica directory outright.
|
|
93
|
+
|
|
94
|
+
**`fsStore`'s discipline, stated** — the one on-disk protocol the Rust
|
|
95
|
+
`FsStore` also speaks, raced against it in the interop conformance
|
|
96
|
+
lane: `putCreate` writes an exclusive synced temp and publishes it with
|
|
97
|
+
`fs.link` (EEXIST is the honest `exists`); etags are the blake3 of the
|
|
98
|
+
content, computed on every read and never stored; `putSwap` serializes
|
|
99
|
+
under a pid-lockfile beside the key, published with the same
|
|
100
|
+
temp-plus-link discipline, and a lock whose owner pid is dead is broken
|
|
101
|
+
and retaken. One machine is load-bearing, not descriptive — pid
|
|
102
|
+
liveness and link exclusivity are the arbitration primitives, and
|
|
103
|
+
network filesystems weaken both; an `fsStore` prefix on a network mount
|
|
104
|
+
is a misdeployment. `putCreate` and `putSwap` resolve only after fsync
|
|
105
|
+
of the object file and its parent directory.
|
|
106
|
+
|
|
107
|
+
## Error identity
|
|
108
|
+
|
|
109
|
+
Exported sentinel values on the SDK idiom, checked with `errors.is`,
|
|
110
|
+
never by message strings: `ErrRefused` (typed per cause — batch shape,
|
|
111
|
+
version, fingerprint, manifest shape, checkpoint braid-set drift),
|
|
112
|
+
`ErrSpanningCommit`, `ErrGapDetected`, `ErrReplayDiverged`,
|
|
113
|
+
`ErrChainMismatch` (cause `"prev" | "slot" |
|
|
114
|
+
"timestamp"`), `ErrContention` (cause `hot-key` or `slot-race`),
|
|
115
|
+
`ErrStore` (the vendor channel, present in every wrapped store
|
|
116
|
+
failure's cause chain so the `errors.is` match is by identity). There
|
|
117
|
+
is deliberately no
|
|
118
|
+
`ErrAlreadyApplied`: the state it would name is absorbed by idempotent
|
|
119
|
+
replay and never surfaces.
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bjornpagen/bumbledb-log",
|
|
3
|
+
"version": "0.17.0",
|
|
4
|
+
"description": "Braided object-store replication for bumbledb: the command codec, theory-derived braids, and replica/writer over five store verbs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"default": "./src/index.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"imports": {
|
|
13
|
+
"#test/*.ts": "./test/*.ts",
|
|
14
|
+
"#*.ts": "./src/*.ts"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"keywords": [
|
|
21
|
+
"database",
|
|
22
|
+
"replication",
|
|
23
|
+
"object-storage",
|
|
24
|
+
"bumbledb"
|
|
25
|
+
],
|
|
26
|
+
"author": "Bjorn Pagen",
|
|
27
|
+
"license": "0BSD",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/bjornpagen/bumbledb.git",
|
|
31
|
+
"directory": "ts-log"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/bjornpagen/bumbledb#readme",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@superbuilders/errors": "^4.0.2"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@bjornpagen/bumbledb": "^0.17.1"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@biomejs/biome": "2.5.4",
|
|
42
|
+
"@bjornpagen/bumbledb": "link:../ts",
|
|
43
|
+
"@types/node": "26.1.1",
|
|
44
|
+
"typescript": "7.0.2"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=24"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"private": false,
|
|
53
|
+
"scripts": {
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"lint": "biome check .",
|
|
56
|
+
"test": "node --test 'test/**/*.test.ts'"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/braids.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Braid derivation as data (10): connected components of the statement
|
|
3
|
+
* graph over ordinary relations, the braid id the smallest RelationId in
|
|
4
|
+
* the component rendered `c{id:08x}`. Assignment is a pure function of
|
|
5
|
+
* the descriptor, pinned cross-language by the codec goldens.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { LogTheory, SerialStatement } from "#descriptor.ts"
|
|
9
|
+
import { descriptorOf } from "#descriptor.ts"
|
|
10
|
+
|
|
11
|
+
/** Braid id: `c{smallest RelationId:08x}`, scoped to the schema fingerprint. */
|
|
12
|
+
type Braid = string
|
|
13
|
+
|
|
14
|
+
/** The schema's own shard map: ordinary relation name → braid id. */
|
|
15
|
+
function braidsOf(theory: LogTheory): ReadonlyMap<string, Braid> {
|
|
16
|
+
const descriptor = descriptorOf(theory)
|
|
17
|
+
const out = new Map<string, Braid>()
|
|
18
|
+
for (const relation of descriptor.relations) {
|
|
19
|
+
const braid = descriptor.braidOfRelation.get(relation.id)
|
|
20
|
+
if (braid !== undefined) {
|
|
21
|
+
out.set(relation.name, braid)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return out
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The degenerate-serial roster (15): key or capacity statements whose
|
|
29
|
+
* determinant projection is empty name one global group, so their braid
|
|
30
|
+
* serializes at that statement. Typed data beside the braid map, one
|
|
31
|
+
* question per verb.
|
|
32
|
+
*/
|
|
33
|
+
function serialAtStatementsOf(theory: LogTheory): readonly SerialStatement[] {
|
|
34
|
+
return descriptorOf(theory).serialAtStatements
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type { Braid }
|
|
38
|
+
export { braidsOf, serialAtStatementsOf }
|
package/src/bytes.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Little-endian byte primitives shared by the codec, the footprint keys,
|
|
3
|
+
* and the fingerprint mirror. Every multi-byte integer on the batch wire
|
|
4
|
+
* is little-endian; the fingerprint's canonical literal encoding is the
|
|
5
|
+
* engine's big-endian order-preserving form — both live here so no third
|
|
6
|
+
* spelling can appear.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as errors from "@superbuilders/errors"
|
|
10
|
+
|
|
11
|
+
const U64_MAX = 0xffffffffffffffffn
|
|
12
|
+
const I64_MIN = -0x8000000000000000n
|
|
13
|
+
const I64_MAX = 0x7fffffffffffffffn
|
|
14
|
+
const I64_SIGN_BIT = 0x8000000000000000n
|
|
15
|
+
|
|
16
|
+
class ByteWriter {
|
|
17
|
+
private buf: Uint8Array
|
|
18
|
+
private len = 0
|
|
19
|
+
|
|
20
|
+
constructor(capacity = 256) {
|
|
21
|
+
this.buf = new Uint8Array(capacity)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private grow(need: number): void {
|
|
25
|
+
if (this.len + need <= this.buf.length) {
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
let capacity = this.buf.length * 2
|
|
29
|
+
while (capacity < this.len + need) {
|
|
30
|
+
capacity *= 2
|
|
31
|
+
}
|
|
32
|
+
const next = new Uint8Array(capacity)
|
|
33
|
+
next.set(this.buf.subarray(0, this.len))
|
|
34
|
+
this.buf = next
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
u8(value: number): void {
|
|
38
|
+
this.grow(1)
|
|
39
|
+
this.buf[this.len] = value
|
|
40
|
+
this.len += 1
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
bytes(raw: Uint8Array): void {
|
|
44
|
+
this.grow(raw.length)
|
|
45
|
+
this.buf.set(raw, this.len)
|
|
46
|
+
this.len += raw.length
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
u16le(value: number): void {
|
|
50
|
+
this.grow(2)
|
|
51
|
+
this.buf[this.len] = value & 0xff
|
|
52
|
+
this.buf[this.len + 1] = (value >>> 8) & 0xff
|
|
53
|
+
this.len += 2
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
u32le(value: number): void {
|
|
57
|
+
this.grow(4)
|
|
58
|
+
this.buf[this.len] = value & 0xff
|
|
59
|
+
this.buf[this.len + 1] = (value >>> 8) & 0xff
|
|
60
|
+
this.buf[this.len + 2] = (value >>> 16) & 0xff
|
|
61
|
+
this.buf[this.len + 3] = (value >>> 24) & 0xff
|
|
62
|
+
this.len += 4
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
u64le(value: bigint): void {
|
|
66
|
+
if (value < 0n || value > U64_MAX) {
|
|
67
|
+
throw errors.new(`u64 out of range: ${value}`)
|
|
68
|
+
}
|
|
69
|
+
this.grow(8)
|
|
70
|
+
let v = value
|
|
71
|
+
for (let i = 0; i < 8; i++) {
|
|
72
|
+
this.buf[this.len + i] = Number(v & 0xffn)
|
|
73
|
+
v >>= 8n
|
|
74
|
+
}
|
|
75
|
+
this.len += 8
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
i64le(value: bigint): void {
|
|
79
|
+
if (value < I64_MIN || value > I64_MAX) {
|
|
80
|
+
throw errors.new(`i64 out of range: ${value}`)
|
|
81
|
+
}
|
|
82
|
+
this.u64le(value & U64_MAX)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
u64be(value: bigint): void {
|
|
86
|
+
if (value < 0n || value > U64_MAX) {
|
|
87
|
+
throw errors.new(`u64 out of range: ${value}`)
|
|
88
|
+
}
|
|
89
|
+
this.grow(8)
|
|
90
|
+
let v = value
|
|
91
|
+
for (let i = 7; i >= 0; i--) {
|
|
92
|
+
this.buf[this.len + i] = Number(v & 0xffn)
|
|
93
|
+
v >>= 8n
|
|
94
|
+
}
|
|
95
|
+
this.len += 8
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The engine's sign-flipped big-endian i64 (lexicographic = numeric). */
|
|
99
|
+
i64beFlipped(value: bigint): void {
|
|
100
|
+
if (value < I64_MIN || value > I64_MAX) {
|
|
101
|
+
throw errors.new(`i64 out of range: ${value}`)
|
|
102
|
+
}
|
|
103
|
+
this.u64be((value & U64_MAX) ^ I64_SIGN_BIT)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
finish(): Uint8Array {
|
|
107
|
+
return this.buf.slice(0, this.len)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface ReadFailure {
|
|
112
|
+
fail(what: string): never
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
class ByteReader {
|
|
116
|
+
private readonly buf: Uint8Array
|
|
117
|
+
private pos = 0
|
|
118
|
+
private readonly refusal: ReadFailure
|
|
119
|
+
|
|
120
|
+
constructor(buf: Uint8Array, refusal: ReadFailure) {
|
|
121
|
+
this.buf = buf
|
|
122
|
+
this.refusal = refusal
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
remaining(): number {
|
|
126
|
+
return this.buf.length - this.pos
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private take(count: number, what: string): Uint8Array {
|
|
130
|
+
if (this.pos + count > this.buf.length) {
|
|
131
|
+
this.refusal.fail(what)
|
|
132
|
+
}
|
|
133
|
+
const out = this.buf.subarray(this.pos, this.pos + count)
|
|
134
|
+
this.pos += count
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
u8(what: string): number {
|
|
139
|
+
const raw = this.take(1, what)
|
|
140
|
+
const byte = raw[0]
|
|
141
|
+
if (byte === undefined) {
|
|
142
|
+
this.refusal.fail(what)
|
|
143
|
+
}
|
|
144
|
+
return byte
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
bytes(count: number, what: string): Uint8Array {
|
|
148
|
+
return new Uint8Array(this.take(count, what))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
u16le(what: string): number {
|
|
152
|
+
const raw = this.take(2, what)
|
|
153
|
+
return (raw[0] ?? 0) | ((raw[1] ?? 0) << 8)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
u32le(what: string): number {
|
|
157
|
+
const raw = this.take(4, what)
|
|
158
|
+
return (((raw[0] ?? 0) | ((raw[1] ?? 0) << 8) | ((raw[2] ?? 0) << 16)) + (raw[3] ?? 0) * 0x1000000) >>> 0
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
u64le(what: string): bigint {
|
|
162
|
+
const raw = this.take(8, what)
|
|
163
|
+
let value = 0n
|
|
164
|
+
for (let i = 7; i >= 0; i--) {
|
|
165
|
+
value = (value << 8n) | BigInt(raw[i] ?? 0)
|
|
166
|
+
}
|
|
167
|
+
return value
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
i64le(what: string): bigint {
|
|
171
|
+
const unsigned = this.u64le(what)
|
|
172
|
+
return unsigned > I64_MAX ? unsigned - (U64_MAX + 1n) : unsigned
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
177
|
+
if (a.length !== b.length) {
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
for (let i = 0; i < a.length; i++) {
|
|
181
|
+
if (a[i] !== b[i]) {
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return true
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function bytesCompare(a: Uint8Array, b: Uint8Array): number {
|
|
189
|
+
const shared = Math.min(a.length, b.length)
|
|
190
|
+
for (let i = 0; i < shared; i++) {
|
|
191
|
+
const delta = (a[i] ?? 0) - (b[i] ?? 0)
|
|
192
|
+
if (delta !== 0) {
|
|
193
|
+
return delta
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return a.length - b.length
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const HEX_DIGITS = "0123456789abcdef"
|
|
200
|
+
|
|
201
|
+
function toHex(bytes: Uint8Array): string {
|
|
202
|
+
let out = ""
|
|
203
|
+
for (const byte of bytes) {
|
|
204
|
+
out += HEX_DIGITS[byte >>> 4]
|
|
205
|
+
out += HEX_DIGITS[byte & 0xf]
|
|
206
|
+
}
|
|
207
|
+
return out
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function fromHex(hex: string): Uint8Array {
|
|
211
|
+
if (hex.length % 2 !== 0 || /[^0-9a-f]/.test(hex)) {
|
|
212
|
+
throw errors.new(`not lowercase hex: ${hex}`)
|
|
213
|
+
}
|
|
214
|
+
const out = new Uint8Array(hex.length / 2)
|
|
215
|
+
for (let i = 0; i < out.length; i++) {
|
|
216
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16)
|
|
217
|
+
}
|
|
218
|
+
return out
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const utf8Encoder = new TextEncoder()
|
|
222
|
+
const utf8StrictDecoder = new TextDecoder("utf-8", { fatal: true })
|
|
223
|
+
|
|
224
|
+
export {
|
|
225
|
+
ByteReader,
|
|
226
|
+
ByteWriter,
|
|
227
|
+
bytesCompare,
|
|
228
|
+
bytesEqual,
|
|
229
|
+
fromHex,
|
|
230
|
+
I64_MAX,
|
|
231
|
+
I64_MIN,
|
|
232
|
+
toHex,
|
|
233
|
+
U64_MAX,
|
|
234
|
+
utf8Encoder,
|
|
235
|
+
utf8StrictDecoder
|
|
236
|
+
}
|
package/src/chain.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chain sidecar (50): the per-braid split and chain position in
|
|
3
|
+
* `dir/chain.json`, written atomically (temp + rename, fsync). A floor
|
|
4
|
+
* cache, never a truth the store reconciles against — recovery is the
|
|
5
|
+
* catch-up loop, and the one wholeness check lives at the replica.
|
|
6
|
+
* `pending` is the writer's one extra field: the encoded batch bytes a
|
|
7
|
+
* local commit owes the log, present until its slot exists.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as crypto from "node:crypto"
|
|
11
|
+
import * as fs from "node:fs/promises"
|
|
12
|
+
import * as path from "node:path"
|
|
13
|
+
import * as errors from "@superbuilders/errors"
|
|
14
|
+
import { utf8StrictDecoder } from "#bytes.ts"
|
|
15
|
+
|
|
16
|
+
interface ChainEntry {
|
|
17
|
+
readonly g: bigint
|
|
18
|
+
readonly prev: string
|
|
19
|
+
readonly ts: bigint
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface PendingBatch {
|
|
23
|
+
readonly braid: string
|
|
24
|
+
readonly gen: bigint
|
|
25
|
+
readonly bytes: Uint8Array
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Sidecar {
|
|
29
|
+
readonly chain: ReadonlyMap<string, ChainEntry>
|
|
30
|
+
readonly pending: PendingBatch | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function renderSidecar(sidecar: Sidecar): string {
|
|
34
|
+
const braids = [...sidecar.chain.keys()].sort()
|
|
35
|
+
const chain = braids
|
|
36
|
+
.map(function renderEntry(braid) {
|
|
37
|
+
const entry = sidecar.chain.get(braid)
|
|
38
|
+
if (entry === undefined) {
|
|
39
|
+
throw errors.new(`sidecar chain lost braid ${braid}`)
|
|
40
|
+
}
|
|
41
|
+
return `"${braid}":{"g":${entry.g},"prev":"${entry.prev}","ts":${entry.ts}}`
|
|
42
|
+
})
|
|
43
|
+
.join(",")
|
|
44
|
+
const pending =
|
|
45
|
+
sidecar.pending === null
|
|
46
|
+
? "null"
|
|
47
|
+
: `{"braid":"${sidecar.pending.braid}","gen":${sidecar.pending.gen},"bytes":"${Buffer.from(sidecar.pending.bytes).toString("base64")}"}`
|
|
48
|
+
return `{"v":2,"chain":{${chain}},"pending":${pending}}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseSidecar(text: string): Sidecar {
|
|
52
|
+
const parsed = JSON.parse(text) as {
|
|
53
|
+
v: number
|
|
54
|
+
chain: Record<string, { g: number; prev: string; ts: number }>
|
|
55
|
+
pending: { braid: string; gen: number; bytes: string } | null
|
|
56
|
+
}
|
|
57
|
+
if (parsed.v !== 2 || typeof parsed.chain !== "object" || parsed.chain === null) {
|
|
58
|
+
throw errors.new("sidecar is not a v2 chain file")
|
|
59
|
+
}
|
|
60
|
+
const chain = new Map<string, ChainEntry>()
|
|
61
|
+
for (const [braid, entry] of Object.entries(parsed.chain)) {
|
|
62
|
+
chain.set(braid, { g: BigInt(entry.g), prev: entry.prev, ts: BigInt(entry.ts) })
|
|
63
|
+
}
|
|
64
|
+
const pending =
|
|
65
|
+
parsed.pending === null
|
|
66
|
+
? null
|
|
67
|
+
: {
|
|
68
|
+
braid: parsed.pending.braid,
|
|
69
|
+
gen: BigInt(parsed.pending.gen),
|
|
70
|
+
bytes: new Uint8Array(Buffer.from(parsed.pending.bytes, "base64"))
|
|
71
|
+
}
|
|
72
|
+
return { chain, pending }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readSidecar(file: string): Promise<Sidecar | null> {
|
|
76
|
+
const read = await errors.try(fs.readFile(file))
|
|
77
|
+
if (read.error) {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
return parseSidecar(utf8StrictDecoder.decode(read.data))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function writeSidecar(file: string, sidecar: Sidecar): Promise<void> {
|
|
84
|
+
const dir = path.dirname(file)
|
|
85
|
+
await fs.mkdir(dir, { recursive: true })
|
|
86
|
+
const temp = path.join(dir, `.chain-${process.pid}-${crypto.randomBytes(4).toString("hex")}`)
|
|
87
|
+
const handle = await fs.open(temp, "wx")
|
|
88
|
+
const written = await errors.try(
|
|
89
|
+
(async function writeAll() {
|
|
90
|
+
await handle.writeFile(renderSidecar(sidecar))
|
|
91
|
+
await handle.sync()
|
|
92
|
+
})()
|
|
93
|
+
)
|
|
94
|
+
await handle.close()
|
|
95
|
+
if (written.error) {
|
|
96
|
+
await fs.rm(temp, { force: true })
|
|
97
|
+
throw errors.wrap(written.error, `write sidecar ${file}`)
|
|
98
|
+
}
|
|
99
|
+
await fs.rename(temp, file)
|
|
100
|
+
const dirHandle = await fs.open(dir, "r")
|
|
101
|
+
const synced = await errors.try(dirHandle.sync())
|
|
102
|
+
await dirHandle.close()
|
|
103
|
+
if (synced.error) {
|
|
104
|
+
throw errors.wrap(synced.error, `fsync sidecar directory ${dir}`)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type { ChainEntry, PendingBatch, Sidecar }
|
|
109
|
+
export { readSidecar, renderSidecar, writeSidecar }
|