@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/replica.ts
ADDED
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The replica (50): a local store that is a materialized view of the
|
|
3
|
+
* braids' prefixes, plus the loop that keeps it current. Disposable by
|
|
4
|
+
* construction — the only local protocol state is the chain sidecar,
|
|
5
|
+
* a floor cache with one wholeness check; recovery IS the catch-up
|
|
6
|
+
* loop (L10), never a procedure. The store's engine generation is the
|
|
7
|
+
* vector sum; `generation == Σ chain + |applied pending|` is the one
|
|
8
|
+
* instrument, and anything else discards the directory and re-pulls.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "node:fs/promises"
|
|
12
|
+
import * as path from "node:path"
|
|
13
|
+
import type { Db, Fact, MemberRelation, Schema, SchemaRelations, WriteOutcome } from "@bjornpagen/bumbledb"
|
|
14
|
+
import { internalBlake3, Db as SdkDb } from "@bjornpagen/bumbledb"
|
|
15
|
+
import * as errors from "@superbuilders/errors"
|
|
16
|
+
import { bytesEqual, toHex } from "#bytes.ts"
|
|
17
|
+
import type { ChainEntry, PendingBatch } from "#chain.ts"
|
|
18
|
+
import { readSidecar, writeSidecar } from "#chain.ts"
|
|
19
|
+
import type { BatchOp } from "#codec.ts"
|
|
20
|
+
import { decodeBatch, encodeBatch, verifyChain } from "#codec.ts"
|
|
21
|
+
import type { LogDescriptor, RelationInfo } from "#descriptor.ts"
|
|
22
|
+
import { descriptorOf } from "#descriptor.ts"
|
|
23
|
+
import { ErrGapDetected, ErrReplayDiverged, refuse } from "#errors.ts"
|
|
24
|
+
import { checkpointJsonKey, checkpointMdbKey, logKey, manifestKey } from "#keys.ts"
|
|
25
|
+
import type { CheckpointFacts } from "#manifest.ts"
|
|
26
|
+
import { parseCheckpoint, parseManifest, renderManifest } from "#manifest.ts"
|
|
27
|
+
import type { ObjectStore } from "#store.ts"
|
|
28
|
+
import type { LogValue } from "#value.ts"
|
|
29
|
+
|
|
30
|
+
const ZERO_HASH = "0".repeat(64)
|
|
31
|
+
|
|
32
|
+
/** The gc-safety heartbeat cadence (50): every N-th refresh pass re-reads the manifest. */
|
|
33
|
+
const HEARTBEAT_PASSES = 16
|
|
34
|
+
|
|
35
|
+
/** The re-poll cadence between waitFor's catch-up passes; the
|
|
36
|
+
* read-your-writes waiter in `waitFor` is this number's one consumer. */
|
|
37
|
+
const WAIT_FOR_POLL_MS = 20
|
|
38
|
+
|
|
39
|
+
interface OpenReplicaOptions<Rels extends SchemaRelations> {
|
|
40
|
+
readonly store: ObjectStore
|
|
41
|
+
readonly prefix: string
|
|
42
|
+
readonly dir: string
|
|
43
|
+
readonly theory: Schema<Rels>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Replica<Rels extends SchemaRelations> extends AsyncDisposable {
|
|
47
|
+
readonly db: Db<Rels>
|
|
48
|
+
readonly vector: ReadonlyMap<string, bigint>
|
|
49
|
+
refresh(braid?: string): Promise<ReadonlyMap<string, bigint>>
|
|
50
|
+
waitFor(vector: ReadonlyMap<string, bigint>): Promise<void>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Core<Rels extends SchemaRelations> {
|
|
54
|
+
readonly store: ObjectStore
|
|
55
|
+
readonly prefix: string
|
|
56
|
+
readonly dir: string
|
|
57
|
+
readonly theory: Schema<Rels>
|
|
58
|
+
readonly descriptor: LogDescriptor
|
|
59
|
+
db: Db<Rels>
|
|
60
|
+
chain: Map<string, ChainEntry>
|
|
61
|
+
pending: PendingBatch | null
|
|
62
|
+
pendingOps: readonly BatchOp[] | null
|
|
63
|
+
pendingApplied: boolean
|
|
64
|
+
manifestEtag: string | null
|
|
65
|
+
checkpoint: CheckpointFacts | null
|
|
66
|
+
checkpointDigest: string | null
|
|
67
|
+
passes: number
|
|
68
|
+
closed: boolean
|
|
69
|
+
storeName: string
|
|
70
|
+
gate: Promise<unknown>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const cores = new WeakMap<object, Core<SchemaRelations>>()
|
|
74
|
+
|
|
75
|
+
function coreOf<Rels extends SchemaRelations>(replica: Replica<Rels>): Core<Rels> {
|
|
76
|
+
const core = cores.get(replica)
|
|
77
|
+
if (core === undefined) {
|
|
78
|
+
throw errors.new("not a replica of this driver")
|
|
79
|
+
}
|
|
80
|
+
return core as unknown as Core<Rels>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One asynchronous door per replica: refreshes, commits, and disposal serialize. */
|
|
84
|
+
function withGate<Rels extends SchemaRelations, R>(core: Core<Rels>, body: () => Promise<R>): Promise<R> {
|
|
85
|
+
const run = core.gate.then(body, body)
|
|
86
|
+
core.gate = run.then(
|
|
87
|
+
function absorb() {
|
|
88
|
+
return undefined
|
|
89
|
+
},
|
|
90
|
+
function absorbFailure() {
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
return run
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function blake3Hex(bytes: Uint8Array): string {
|
|
98
|
+
return toHex(new Uint8Array(internalBlake3(bytes)))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sidecarPath<Rels extends SchemaRelations>(core: Core<Rels>): string {
|
|
102
|
+
return path.join(core.dir, "chain.json")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let storeSequence = 0
|
|
106
|
+
|
|
107
|
+
/** LMDB registers environments per canonical path for the life of the
|
|
108
|
+
* process and the engine has no close verb, so a store path is never
|
|
109
|
+
* reused: every bootstrap gets a fresh name and discards leave the old
|
|
110
|
+
* environment to GC. */
|
|
111
|
+
function freshStoreName(): string {
|
|
112
|
+
storeSequence += 1
|
|
113
|
+
return `store-${process.pid.toString(36)}-${Date.now().toString(36)}-${storeSequence}`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function storePath<Rels extends SchemaRelations>(core: Core<Rels>): string {
|
|
117
|
+
return path.join(core.dir, core.storeName)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function zeroChain(descriptor: LogDescriptor): Map<string, ChainEntry> {
|
|
121
|
+
const chain = new Map<string, ChainEntry>()
|
|
122
|
+
for (const braid of descriptor.braidMembers.keys()) {
|
|
123
|
+
chain.set(braid, { g: 0n, prev: ZERO_HASH, ts: 0n })
|
|
124
|
+
}
|
|
125
|
+
return chain
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function chainEntry<Rels extends SchemaRelations>(core: Core<Rels>, braid: string): ChainEntry {
|
|
129
|
+
const entry = core.chain.get(braid)
|
|
130
|
+
if (entry === undefined) {
|
|
131
|
+
throw errors.new(`braid ${braid} is not derived from this theory`)
|
|
132
|
+
}
|
|
133
|
+
return entry
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function generationOf<Rels extends SchemaRelations>(core: Core<Rels>): bigint {
|
|
137
|
+
return core.db.read(function readGeneration(instance) {
|
|
138
|
+
return instance.generation
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function chainSum<Rels extends SchemaRelations>(core: Core<Rels>): bigint {
|
|
143
|
+
let sum = 0n
|
|
144
|
+
for (const entry of core.chain.values()) {
|
|
145
|
+
sum += entry.g
|
|
146
|
+
}
|
|
147
|
+
return sum
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function pendingTerm<Rels extends SchemaRelations>(core: Core<Rels>): bigint {
|
|
151
|
+
return core.pendingApplied ? 1n : 0n
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function persistSidecar<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
155
|
+
await writeSidecar(sidecarPath(core), { chain: core.chain, pending: core.pending })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function clearPending<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
159
|
+
core.pending = null
|
|
160
|
+
core.pendingOps = null
|
|
161
|
+
core.pendingApplied = false
|
|
162
|
+
await persistSidecar(core)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Positional decoded row → the SDK's named fact, handles lifted for closed refs. */
|
|
166
|
+
function factOf<Rels extends SchemaRelations>(
|
|
167
|
+
core: Core<Rels>,
|
|
168
|
+
relation: RelationInfo,
|
|
169
|
+
row: readonly LogValue[]
|
|
170
|
+
): Record<string, unknown> {
|
|
171
|
+
const fact: Record<string, unknown> = {}
|
|
172
|
+
relation.fields.forEach(function liftCell(field, ordinal) {
|
|
173
|
+
const value = row[ordinal]
|
|
174
|
+
if (value === undefined) {
|
|
175
|
+
throw errors.new(`relation ${relation.name}: decoded row cell ${ordinal} absent`)
|
|
176
|
+
}
|
|
177
|
+
if (field.closedRef !== undefined && typeof value === "bigint") {
|
|
178
|
+
const roster = core.descriptor.relationByName.get(field.closedRef)
|
|
179
|
+
const handle = roster?.handles[Number(value)]
|
|
180
|
+
if (handle === undefined) {
|
|
181
|
+
throw errors.new(
|
|
182
|
+
`relation ${relation.name}.${field.name}: id ${value} is outside the ${field.closedRef} roster`
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
fact[field.name] = handle
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
fact[field.name] = value
|
|
189
|
+
})
|
|
190
|
+
return fact
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** One `db.write` applying ops in listed order, rows in listed order. */
|
|
194
|
+
function applyOps<Rels extends SchemaRelations>(core: Core<Rels>, ops: readonly BatchOp[]): WriteOutcome<Rels, number> {
|
|
195
|
+
return core.db.write(function applyBatch(tx) {
|
|
196
|
+
for (const op of ops) {
|
|
197
|
+
const info = core.descriptor.relationByName.get(op.relation)
|
|
198
|
+
const member = core.theory.relations[op.relation]
|
|
199
|
+
if (info === undefined || member === undefined) {
|
|
200
|
+
throw errors.new(`batch op cites unknown relation ${op.relation}`)
|
|
201
|
+
}
|
|
202
|
+
const relation = member as MemberRelation<Rels>
|
|
203
|
+
const facts = op.rows.map(function liftRow(row) {
|
|
204
|
+
return factOf(core, info, row)
|
|
205
|
+
}) as unknown as Iterable<Fact<MemberRelation<Rels>>>
|
|
206
|
+
if (op.op === "insert") {
|
|
207
|
+
tx.insert(relation, facts)
|
|
208
|
+
} else {
|
|
209
|
+
tx.delete(relation, facts)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return 0
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
type ApplyPhase = "open" | "steady"
|
|
217
|
+
|
|
218
|
+
type SlotApply = { readonly tag: "applied"; readonly generation: bigint } | { readonly tag: "discard" }
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The two-step apply discipline: `db.write` the batch, then advance the
|
|
222
|
+
* sidecar. Chain and publish-law refusals live here; a rejected replay
|
|
223
|
+
* is phase-scoped (50): discard before the store has proven itself
|
|
224
|
+
* whole, `ErrReplayDiverged` after.
|
|
225
|
+
*/
|
|
226
|
+
async function applySlot<Rels extends SchemaRelations>(
|
|
227
|
+
core: Core<Rels>,
|
|
228
|
+
braid: string,
|
|
229
|
+
slot: bigint,
|
|
230
|
+
bytes: Uint8Array,
|
|
231
|
+
phase: ApplyPhase
|
|
232
|
+
): Promise<SlotApply> {
|
|
233
|
+
const decoded = decodeBatch(core.descriptor, bytes)
|
|
234
|
+
const entry = chainEntry(core, braid)
|
|
235
|
+
verifyChain(decoded.header, braid, slot, { g: entry.g, prev: entry.prev, ts: entry.ts })
|
|
236
|
+
const outcome = applyOps(core, decoded.ops)
|
|
237
|
+
if (outcome.tag === "rejected") {
|
|
238
|
+
if (phase === "open") {
|
|
239
|
+
return { tag: "discard" }
|
|
240
|
+
}
|
|
241
|
+
throw errors.wrap(ErrReplayDiverged, `braid ${braid} slot ${slot} writer ${decoded.header.writer}`)
|
|
242
|
+
}
|
|
243
|
+
core.chain.set(braid, { g: slot, prev: blake3Hex(bytes), ts: decoded.header.timestamp })
|
|
244
|
+
await persistSidecar(core)
|
|
245
|
+
const expected = chainSum(core) + pendingTerm(core)
|
|
246
|
+
if (outcome.value.generation < expected) {
|
|
247
|
+
refuse(
|
|
248
|
+
{ kind: "NoOpSlot", braid, slot, writer: decoded.header.writer },
|
|
249
|
+
`braid ${braid} slot ${slot}: a first-applied slot changed nothing — publish-law violation by writer ${decoded.header.writer}`
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
return { tag: "applied", generation: outcome.value.generation }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The gc floor rule (10): below the current checkpoint's vector a 404 is
|
|
257
|
+
* a collected hole, at or above it the honest tip.
|
|
258
|
+
*/
|
|
259
|
+
function holeAt<Rels extends SchemaRelations>(core: Core<Rels>, braid: string, next: bigint): boolean {
|
|
260
|
+
if (core.checkpoint === null) {
|
|
261
|
+
return false
|
|
262
|
+
}
|
|
263
|
+
const floor = core.checkpoint.braids.get(braid)
|
|
264
|
+
if (floor === undefined) {
|
|
265
|
+
return false
|
|
266
|
+
}
|
|
267
|
+
return next <= floor.g
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function catchUpBraid<Rels extends SchemaRelations>(
|
|
271
|
+
core: Core<Rels>,
|
|
272
|
+
braid: string,
|
|
273
|
+
phase: ApplyPhase
|
|
274
|
+
): Promise<"tip" | "discard"> {
|
|
275
|
+
for (;;) {
|
|
276
|
+
const next = chainEntry(core, braid).g + 1n
|
|
277
|
+
const fetched = await core.store.get(logKey(core.prefix, braid, next))
|
|
278
|
+
if (fetched === null) {
|
|
279
|
+
if (holeAt(core, braid, next)) {
|
|
280
|
+
throw errors.wrap(ErrGapDetected, `braid ${braid} slot ${next} is below the checkpoint vector`)
|
|
281
|
+
}
|
|
282
|
+
return "tip"
|
|
283
|
+
}
|
|
284
|
+
if (core.pendingApplied && core.pending !== null && braid === core.pending.braid && next === core.pending.gen) {
|
|
285
|
+
// An applied pending contests exactly its own slot: byte-equal
|
|
286
|
+
// means our slot was already published and the pending ends
|
|
287
|
+
// here; any other occupant is a lost race, and the store —
|
|
288
|
+
// carrying the pending's effects on a stale base — is
|
|
289
|
+
// discarded so the one loss path re-judges at the tip (L10).
|
|
290
|
+
if (!bytesEqual(fetched.bytes, core.pending.bytes)) {
|
|
291
|
+
return "discard"
|
|
292
|
+
}
|
|
293
|
+
core.pending = null
|
|
294
|
+
core.pendingOps = null
|
|
295
|
+
core.pendingApplied = false
|
|
296
|
+
await persistSidecar(core)
|
|
297
|
+
}
|
|
298
|
+
const applied = await applySlot(core, braid, next, fetched.bytes, phase)
|
|
299
|
+
if (applied.tag === "discard") {
|
|
300
|
+
return "discard"
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function catchUpAll<Rels extends SchemaRelations>(
|
|
306
|
+
core: Core<Rels>,
|
|
307
|
+
phase: ApplyPhase
|
|
308
|
+
): Promise<"tip" | "discard"> {
|
|
309
|
+
for (const braid of core.descriptor.braidMembers.keys()) {
|
|
310
|
+
const outcome = await catchUpBraid(core, braid, phase)
|
|
311
|
+
if (outcome === "discard") {
|
|
312
|
+
return "discard"
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return "tip"
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function wholenessHolds<Rels extends SchemaRelations>(core: Core<Rels>): boolean {
|
|
319
|
+
return generationOf(core) === chainSum(core) + pendingTerm(core)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function adoptManifest<Rels extends SchemaRelations>(
|
|
323
|
+
core: Core<Rels>,
|
|
324
|
+
bytes: Uint8Array,
|
|
325
|
+
etag: string
|
|
326
|
+
): Promise<void> {
|
|
327
|
+
const manifest = parseManifest(bytes)
|
|
328
|
+
if (manifest.fingerprint !== core.descriptor.fingerprint) {
|
|
329
|
+
refuse(
|
|
330
|
+
{ kind: "FingerprintMismatch", carried: manifest.fingerprint, expected: core.descriptor.fingerprint },
|
|
331
|
+
"the store's manifest names a different theory"
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
core.manifestEtag = etag
|
|
335
|
+
if (manifest.checkpoint === null) {
|
|
336
|
+
core.checkpoint = null
|
|
337
|
+
core.checkpointDigest = null
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
if (manifest.checkpoint === core.checkpointDigest) {
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
const facts = await core.store.get(checkpointJsonKey(core.prefix, manifest.checkpoint))
|
|
344
|
+
if (facts === null) {
|
|
345
|
+
throw errors.new(`manifest points at absent checkpoint ${manifest.checkpoint}`)
|
|
346
|
+
}
|
|
347
|
+
const checkpoint = parseCheckpoint(facts.bytes)
|
|
348
|
+
const carried = [...checkpoint.braids.keys()].sort()
|
|
349
|
+
const derived = [...core.descriptor.braidMembers.keys()].sort()
|
|
350
|
+
if (carried.join(",") !== derived.join(",")) {
|
|
351
|
+
refuse({ kind: "CheckpointBraids", carried, derived }, "checkpoint braid set drifted from the derived braids")
|
|
352
|
+
}
|
|
353
|
+
core.checkpoint = checkpoint
|
|
354
|
+
core.checkpointDigest = manifest.checkpoint
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function refreshManifest<Rels extends SchemaRelations>(core: Core<Rels>, force: boolean): Promise<void> {
|
|
358
|
+
if (!force && core.manifestEtag !== null) {
|
|
359
|
+
const poll = await core.store.getIfChanged(manifestKey(core.prefix), core.manifestEtag)
|
|
360
|
+
if (poll.tag === "unchanged") {
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
await adoptManifest(core, poll.fetched.bytes, poll.fetched.etag)
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
const fetched = await core.store.get(manifestKey(core.prefix))
|
|
367
|
+
if (fetched !== null) {
|
|
368
|
+
await adoptManifest(core, fetched.bytes, fetched.etag)
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
const birth = renderManifest({ fingerprint: core.descriptor.fingerprint, checkpoint: null })
|
|
372
|
+
const created = await core.store.putCreate(manifestKey(core.prefix), birth)
|
|
373
|
+
if (created.tag === "created") {
|
|
374
|
+
core.manifestEtag = created.etag
|
|
375
|
+
core.checkpoint = null
|
|
376
|
+
core.checkpointDigest = null
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
const reread = await core.store.get(manifestKey(core.prefix))
|
|
380
|
+
if (reread === null) {
|
|
381
|
+
throw errors.new("manifest vanished between create refusal and re-read")
|
|
382
|
+
}
|
|
383
|
+
await adoptManifest(core, reread.bytes, reread.etag)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Bootstraps a fresh local store: from the current checkpoint when one
|
|
387
|
+
* exists, else `Db.create` at the zero vector. */
|
|
388
|
+
async function initializeStore<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
389
|
+
core.storeName = freshStoreName()
|
|
390
|
+
const target = storePath(core)
|
|
391
|
+
await fs.rm(target, { recursive: true, force: true })
|
|
392
|
+
await fs.mkdir(core.dir, { recursive: true })
|
|
393
|
+
if (core.checkpoint !== null && core.checkpointDigest !== null) {
|
|
394
|
+
for (let attempt = 0; ; attempt++) {
|
|
395
|
+
const mdb = await core.store.get(checkpointMdbKey(core.prefix, core.checkpointDigest))
|
|
396
|
+
if (mdb === null) {
|
|
397
|
+
throw errors.new(`checkpoint ${core.checkpointDigest} names an absent .mdb`)
|
|
398
|
+
}
|
|
399
|
+
const digest = blake3Hex(mdb.bytes)
|
|
400
|
+
if (digest !== core.checkpointDigest) {
|
|
401
|
+
if (attempt === 0) {
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
refuse(
|
|
405
|
+
{ kind: "CheckpointDigest", expected: core.checkpointDigest, computed: digest },
|
|
406
|
+
"checkpoint bytes do not hash to their own name"
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
await fs.mkdir(target, { recursive: true })
|
|
410
|
+
await fs.writeFile(path.join(target, "data.mdb"), mdb.bytes)
|
|
411
|
+
core.db = await SdkDb.open(target, core.theory)
|
|
412
|
+
core.chain = new Map(
|
|
413
|
+
[...core.checkpoint.braids.entries()].map(function seed([braid, head]) {
|
|
414
|
+
return [braid, { g: head.g, prev: head.hash, ts: head.ts }] as const
|
|
415
|
+
})
|
|
416
|
+
)
|
|
417
|
+
let sum = 0n
|
|
418
|
+
for (const head of core.checkpoint.braids.values()) {
|
|
419
|
+
sum += head.g
|
|
420
|
+
}
|
|
421
|
+
const generation = generationOf(core)
|
|
422
|
+
if (generation !== sum) {
|
|
423
|
+
throw errors.new(`checkpoint store opened at generation ${generation}, its vector sums to ${sum}`)
|
|
424
|
+
}
|
|
425
|
+
await persistSidecar(core)
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const created = await SdkDb.create(target, core.theory)
|
|
430
|
+
if (created.tag === "rejected") {
|
|
431
|
+
throw errors.new("the theory's ground axioms were rejected at bootstrap")
|
|
432
|
+
}
|
|
433
|
+
core.db = created.value
|
|
434
|
+
core.chain = zeroChain(core.descriptor)
|
|
435
|
+
await persistSidecar(core)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The disposable law: the directory is cache, never truth. The local
|
|
439
|
+
* LMDB path rotates because the engine has no close verb — the old
|
|
440
|
+
* environment is left for GC while the fresh pull takes a new path. */
|
|
441
|
+
async function discardAndReopen<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
442
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
443
|
+
const old = storePath(core)
|
|
444
|
+
await fs.rm(old, { recursive: true, force: true })
|
|
445
|
+
await initializeStore(core)
|
|
446
|
+
const outcome = await catchUpAll(core, "open")
|
|
447
|
+
if (outcome === "discard") {
|
|
448
|
+
continue
|
|
449
|
+
}
|
|
450
|
+
if (generationOf(core) === chainSum(core)) {
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
throw errors.new("the store failed to reach wholeness after repeated re-pulls")
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function newestStoreDir(dir: string): Promise<string | null> {
|
|
458
|
+
const listed = await errors.try(fs.readdir(dir))
|
|
459
|
+
if (listed.error) {
|
|
460
|
+
return null
|
|
461
|
+
}
|
|
462
|
+
let newest: string | null = null
|
|
463
|
+
let newestAt = -1
|
|
464
|
+
for (const name of listed.data) {
|
|
465
|
+
if (!name.startsWith("store-")) {
|
|
466
|
+
continue
|
|
467
|
+
}
|
|
468
|
+
const stat = await errors.try(fs.stat(path.join(dir, name)))
|
|
469
|
+
if (stat.error === undefined && stat.data.mtimeMs > newestAt) {
|
|
470
|
+
newestAt = stat.data.mtimeMs
|
|
471
|
+
newest = name
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return newest
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** The disposable law says cache directories do not hoard corpses: every
|
|
478
|
+
* rotated `store-*` LMDB dir except the adopted one is dead — left by a
|
|
479
|
+
* crashed process or a prior rotation — and is swept at open. */
|
|
480
|
+
async function sweepRotations<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
481
|
+
const listed = await errors.try(fs.readdir(core.dir))
|
|
482
|
+
if (listed.error) {
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
for (const name of listed.data) {
|
|
486
|
+
if (!name.startsWith("store-") || name === core.storeName) {
|
|
487
|
+
continue
|
|
488
|
+
}
|
|
489
|
+
await fs.rm(path.join(core.dir, name), { recursive: true, force: true })
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Pending recovery, first half (60): apply the resurrected batch; the
|
|
495
|
+
* verdict plus the wholeness instrument force all three arms. A batch
|
|
496
|
+
* rejected here was never acked (fsync preceded its first judgment); a
|
|
497
|
+
* born-no-op publishes nothing; anything else is a real unpublished
|
|
498
|
+
* commit that catch-up and the tip attempt will place.
|
|
499
|
+
*/
|
|
500
|
+
async function resolvePendingAtOpen<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
501
|
+
if (core.pending === null) {
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
const decoded = errors.trySync(function decodePending() {
|
|
505
|
+
return decodeBatch(core.descriptor, (core.pending as PendingBatch).bytes)
|
|
506
|
+
})
|
|
507
|
+
if (decoded.error) {
|
|
508
|
+
await clearPending(core)
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
const before = generationOf(core)
|
|
512
|
+
const outcome = applyOps(core, decoded.data.ops)
|
|
513
|
+
if (outcome.tag === "rejected") {
|
|
514
|
+
await clearPending(core)
|
|
515
|
+
return
|
|
516
|
+
}
|
|
517
|
+
if (outcome.value.generation === before && before === chainSum(core)) {
|
|
518
|
+
await clearPending(core)
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
core.pendingOps = decoded.data.ops
|
|
522
|
+
core.pendingApplied = true
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Pending resurrection through a cold re-pull: the store directory was
|
|
527
|
+
* unopenable (or discarded), so the sidecar's pending is resolved
|
|
528
|
+
* against a freshly caught-up store — a byte-equal published slot
|
|
529
|
+
* absorbs it (L10's idempotent replay), and everything else takes the
|
|
530
|
+
* one loss path's re-judgment at the tip.
|
|
531
|
+
*/
|
|
532
|
+
async function resolveColdPending<Rels extends SchemaRelations>(
|
|
533
|
+
core: Core<Rels>,
|
|
534
|
+
pending: PendingBatch
|
|
535
|
+
): Promise<void> {
|
|
536
|
+
const decoded = errors.trySync(function decodePending() {
|
|
537
|
+
return decodeBatch(core.descriptor, pending.bytes)
|
|
538
|
+
})
|
|
539
|
+
if (decoded.error) {
|
|
540
|
+
return
|
|
541
|
+
}
|
|
542
|
+
const braid = decoded.data.header.braid
|
|
543
|
+
const slot = decoded.data.header.braidGen
|
|
544
|
+
if (core.chain.has(braid) && chainEntry(core, braid).g >= slot) {
|
|
545
|
+
const published = await core.store.get(logKey(core.prefix, braid, slot))
|
|
546
|
+
if (published !== null && bytesEqual(published.bytes, pending.bytes)) {
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const before = generationOf(core)
|
|
551
|
+
const outcome = applyOps(core, decoded.data.ops)
|
|
552
|
+
if (outcome.tag === "accepted" && outcome.value.generation > before) {
|
|
553
|
+
core.pendingOps = decoded.data.ops
|
|
554
|
+
core.pendingApplied = true
|
|
555
|
+
await readdressPending(core, decoded.data.ops, decoded.data.header.writer)
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function openCore<Rels extends SchemaRelations>(options: OpenReplicaOptions<Rels>): Promise<Core<Rels>> {
|
|
560
|
+
const descriptor = descriptorOf(options.theory)
|
|
561
|
+
const core: Core<Rels> = {
|
|
562
|
+
store: options.store,
|
|
563
|
+
prefix: options.prefix,
|
|
564
|
+
dir: path.resolve(options.dir),
|
|
565
|
+
theory: options.theory,
|
|
566
|
+
descriptor,
|
|
567
|
+
db: undefined as unknown as Db<Rels>,
|
|
568
|
+
chain: zeroChain(descriptor),
|
|
569
|
+
pending: null,
|
|
570
|
+
pendingOps: null,
|
|
571
|
+
pendingApplied: false,
|
|
572
|
+
manifestEtag: null,
|
|
573
|
+
checkpoint: null,
|
|
574
|
+
checkpointDigest: null,
|
|
575
|
+
passes: 0,
|
|
576
|
+
closed: false,
|
|
577
|
+
storeName: freshStoreName(),
|
|
578
|
+
gate: Promise.resolve()
|
|
579
|
+
}
|
|
580
|
+
await fs.mkdir(core.dir, { recursive: true })
|
|
581
|
+
await refreshManifest(core, true)
|
|
582
|
+
|
|
583
|
+
const existing = await newestStoreDir(core.dir)
|
|
584
|
+
const sidecar = await readSidecar(sidecarPath(core))
|
|
585
|
+
let opened = false
|
|
586
|
+
let coldPending: PendingBatch | null = null
|
|
587
|
+
if (existing !== null) {
|
|
588
|
+
core.storeName = existing
|
|
589
|
+
const openedDb = await errors.try(SdkDb.open(storePath(core), options.theory))
|
|
590
|
+
if (openedDb.error === undefined && sidecar !== null) {
|
|
591
|
+
core.db = openedDb.data
|
|
592
|
+
core.chain = new Map(sidecar.chain)
|
|
593
|
+
for (const braid of descriptor.braidMembers.keys()) {
|
|
594
|
+
if (!core.chain.has(braid)) {
|
|
595
|
+
core.chain.set(braid, { g: 0n, prev: ZERO_HASH, ts: 0n })
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
core.pending = sidecar.pending
|
|
599
|
+
opened = true
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (!opened) {
|
|
603
|
+
coldPending = sidecar?.pending ?? null
|
|
604
|
+
await initializeStore(core)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (opened) {
|
|
608
|
+
await resolvePendingAtOpen(core)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const outcome = await catchUpAll(core, "open")
|
|
612
|
+
if (outcome === "discard" || !wholenessHolds(core)) {
|
|
613
|
+
coldPending = core.pending ?? coldPending
|
|
614
|
+
await clearPending(core)
|
|
615
|
+
await discardAndReopen(core)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (coldPending !== null) {
|
|
619
|
+
await resolveColdPending(core, coldPending)
|
|
620
|
+
}
|
|
621
|
+
await sweepRotations(core)
|
|
622
|
+
return core
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* The steady-state discard route: a catch-up that met a contested
|
|
627
|
+
* pending slot (or a rejected replay at open phase) surrenders the
|
|
628
|
+
* directory; the sidecar's pending rides through as a cold pending and
|
|
629
|
+
* takes the one loss path's re-judgment at the fresh tip.
|
|
630
|
+
*/
|
|
631
|
+
async function repairDiscard<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
632
|
+
const coldPending = core.pending
|
|
633
|
+
await clearPending(core)
|
|
634
|
+
await discardAndReopen(core)
|
|
635
|
+
if (coldPending !== null) {
|
|
636
|
+
await resolveColdPending(core, coldPending)
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Re-addresses an applied-but-unpublished batch at the braid's current
|
|
642
|
+
* tip: fresh slot, `prev` citing the new predecessor, timestamp
|
|
643
|
+
* re-clamped — the ops are exactly the recorded ops the re-judgment
|
|
644
|
+
* just accepted. Publication itself retries on the next commit (60).
|
|
645
|
+
*/
|
|
646
|
+
async function readdressPending<Rels extends SchemaRelations>(
|
|
647
|
+
core: Core<Rels>,
|
|
648
|
+
ops: readonly BatchOp[],
|
|
649
|
+
writerId: bigint
|
|
650
|
+
): Promise<void> {
|
|
651
|
+
const first = ops[0]
|
|
652
|
+
if (first === undefined) {
|
|
653
|
+
await clearPending(core)
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
const relation = core.descriptor.relationByName.get(first.relation)
|
|
657
|
+
const braid = relation === undefined ? undefined : core.descriptor.braidOfRelation.get(relation.id)
|
|
658
|
+
if (braid === undefined) {
|
|
659
|
+
await clearPending(core)
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
const entry = chainEntry(core, braid)
|
|
663
|
+
const timestamp = maxBigint(BigInt(Date.now()), entry.ts)
|
|
664
|
+
const bytes = encodeBatch(
|
|
665
|
+
core.descriptor,
|
|
666
|
+
{
|
|
667
|
+
fingerprint: core.descriptor.fingerprint,
|
|
668
|
+
braid,
|
|
669
|
+
braidGen: entry.g + 1n,
|
|
670
|
+
prev: entry.prev,
|
|
671
|
+
writer: writerId,
|
|
672
|
+
timestamp
|
|
673
|
+
},
|
|
674
|
+
ops
|
|
675
|
+
)
|
|
676
|
+
core.pending = { braid, gen: entry.g + 1n, bytes }
|
|
677
|
+
core.pendingOps = ops
|
|
678
|
+
core.pendingApplied = true
|
|
679
|
+
await persistSidecar(core)
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function maxBigint(a: bigint, b: bigint): bigint {
|
|
683
|
+
return a > b ? a : b
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function vectorOf<Rels extends SchemaRelations>(core: Core<Rels>): ReadonlyMap<string, bigint> {
|
|
687
|
+
const vector = new Map<string, bigint>()
|
|
688
|
+
for (const [braid, entry] of core.chain) {
|
|
689
|
+
vector.set(braid, entry.g)
|
|
690
|
+
}
|
|
691
|
+
return vector
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function dominates(have: ReadonlyMap<string, bigint>, want: ReadonlyMap<string, bigint>): boolean {
|
|
695
|
+
for (const [braid, generation] of want) {
|
|
696
|
+
if ((have.get(braid) ?? -1n) < generation) {
|
|
697
|
+
return false
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return true
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function refreshPass<Rels extends SchemaRelations>(core: Core<Rels>, braid?: string): Promise<void> {
|
|
704
|
+
core.passes += 1
|
|
705
|
+
if (core.passes % HEARTBEAT_PASSES === 0) {
|
|
706
|
+
await refreshManifest(core, false)
|
|
707
|
+
}
|
|
708
|
+
if (braid !== undefined) {
|
|
709
|
+
chainEntry(core, braid)
|
|
710
|
+
if ((await catchUpBraid(core, braid, "steady")) === "discard") {
|
|
711
|
+
await repairDiscard(core)
|
|
712
|
+
}
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
if ((await catchUpAll(core, "steady")) === "discard") {
|
|
716
|
+
await repairDiscard(core)
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
async function openReplica<Rels extends SchemaRelations>(options: OpenReplicaOptions<Rels>): Promise<Replica<Rels>> {
|
|
721
|
+
const core = await openCore(options)
|
|
722
|
+
const replica: Replica<Rels> = {
|
|
723
|
+
get db() {
|
|
724
|
+
if (core.closed) {
|
|
725
|
+
throw errors.new("replica is disposed")
|
|
726
|
+
}
|
|
727
|
+
return core.db
|
|
728
|
+
},
|
|
729
|
+
get vector() {
|
|
730
|
+
return vectorOf(core)
|
|
731
|
+
},
|
|
732
|
+
async refresh(braid?: string) {
|
|
733
|
+
return withGate(core, async function refreshBody() {
|
|
734
|
+
if (core.closed) {
|
|
735
|
+
throw errors.new("replica is disposed")
|
|
736
|
+
}
|
|
737
|
+
await refreshPass(core, braid)
|
|
738
|
+
return vectorOf(core)
|
|
739
|
+
})
|
|
740
|
+
},
|
|
741
|
+
async waitFor(vector) {
|
|
742
|
+
for (const braid of vector.keys()) {
|
|
743
|
+
chainEntry(core, braid)
|
|
744
|
+
}
|
|
745
|
+
for (;;) {
|
|
746
|
+
if (dominates(vectorOf(core), vector)) {
|
|
747
|
+
return
|
|
748
|
+
}
|
|
749
|
+
await withGate(core, async function waitPass() {
|
|
750
|
+
for (const braid of vector.keys()) {
|
|
751
|
+
if ((await catchUpBraid(core, braid, "steady")) === "discard") {
|
|
752
|
+
await repairDiscard(core)
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
})
|
|
756
|
+
if (dominates(vectorOf(core), vector)) {
|
|
757
|
+
return
|
|
758
|
+
}
|
|
759
|
+
await new Promise(function later(resolve) {
|
|
760
|
+
setTimeout(resolve, WAIT_FOR_POLL_MS)
|
|
761
|
+
})
|
|
762
|
+
}
|
|
763
|
+
},
|
|
764
|
+
async [Symbol.asyncDispose]() {
|
|
765
|
+
await withGate(core, async function disposeBody() {
|
|
766
|
+
core.closed = true
|
|
767
|
+
await persistSidecar(core)
|
|
768
|
+
})
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
cores.set(replica, core as unknown as Core<SchemaRelations>)
|
|
772
|
+
return replica
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
export type { ApplyPhase, Core, OpenReplicaOptions, Replica, SlotApply }
|
|
776
|
+
export {
|
|
777
|
+
applyOps,
|
|
778
|
+
applySlot,
|
|
779
|
+
blake3Hex,
|
|
780
|
+
catchUpBraid,
|
|
781
|
+
chainEntry,
|
|
782
|
+
chainSum,
|
|
783
|
+
clearPending,
|
|
784
|
+
coreOf,
|
|
785
|
+
discardAndReopen,
|
|
786
|
+
generationOf,
|
|
787
|
+
maxBigint,
|
|
788
|
+
openReplica,
|
|
789
|
+
pendingTerm,
|
|
790
|
+
persistSidecar,
|
|
791
|
+
readdressPending,
|
|
792
|
+
vectorOf,
|
|
793
|
+
wholenessHolds,
|
|
794
|
+
withGate,
|
|
795
|
+
ZERO_HASH
|
|
796
|
+
}
|