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