@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 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 }