@bjornpagen/bumbledb-log 0.18.0 → 0.19.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 CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
- * The replica (50): a local store that is a materialized view of the
2
+ * The replica: a local store that is a materialized view of the
3
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.
4
+ * construction — the sidecar is a floor cache with one wholeness
5
+ * check; recovery IS the catch-up loop (L10). Generation is a total
6
+ * function of the chain: Settled sums the vector, Pending is that sum
7
+ * plus one. A replica that finds no manifest refuses; only a writer
8
+ * births a store.
9
9
  */
10
10
 
11
11
  import * as fs from "node:fs/promises"
@@ -13,30 +13,63 @@ import * as path from "node:path"
13
13
  import type { Db, Fact, MemberRelation, Schema, SchemaRelations, WriteOutcome } from "@bjornpagen/bumbledb"
14
14
  import { internalBlake3, Db as SdkDb } from "@bjornpagen/bumbledb"
15
15
  import * as errors from "@superbuilders/errors"
16
- import { bytesEqual, toHex } from "#bytes.ts"
17
- import type { ChainEntry, Pending } from "#chain.ts"
18
- import { readSidecar, writeSidecar } from "#chain.ts"
16
+ import { parse } from "#braids.ts"
17
+ import type { Digest32 } from "#bytes.ts"
18
+ import { bytesEqual, digest32, hex32, saturatingAddU64, toHex } from "#bytes.ts"
19
+ import type { Chain, ChainEntry, Pending } from "#chain.ts"
20
+ import { chainGeneration, chainSum, readSidecar, writeSidecar } from "#chain.ts"
19
21
  import type { Op } from "#codec.ts"
20
22
  import { decodeBatch, encodeBatch, verifyChain } from "#codec.ts"
21
23
  import type { Braid, Descriptor, RelationInfo } from "#descriptor.ts"
22
24
  import { descriptorOf } from "#descriptor.ts"
23
- import { ErrGapDetected, ErrReplayDiverged, refuse } from "#errors.ts"
25
+ import { ErrRefused, ErrReplayDiverged, refuse, refuseManifestMissing, wrapStore } from "#errors.ts"
24
26
  import type { Generation } from "#keys.ts"
25
- import { checkpointJsonKey, checkpointMdbKey, generation, logKey, manifestKey } from "#keys.ts"
27
+ import {
28
+ CKPT_SCRATCH_LEASE,
29
+ checkpointMdbKey,
30
+ ckptDocKey,
31
+ generation,
32
+ LEASE_NAMESPACE,
33
+ logKey,
34
+ manifestKey,
35
+ parseCkptScratch,
36
+ TEMP_NAMESPACE
37
+ } from "#keys.ts"
26
38
  import type { CheckpointFacts } from "#manifest.ts"
27
- import { parseCheckpoint, parseManifest, renderManifest } from "#manifest.ts"
39
+ import { auditCatalog, parseCheckpoint, parseManifest } from "#manifest.ts"
28
40
  import type { Etag, ObjectStore } from "#store.ts"
29
41
  import type { Value } from "#value.ts"
42
+ import { Vector } from "#vector.ts"
30
43
 
31
- const ZERO_HASH = "0".repeat(64)
44
+ const ZERO_HASH = digest32(new Uint8Array(32))
32
45
 
33
- /** The gc-safety heartbeat cadence (50): every N-th refresh pass re-reads the manifest. */
46
+ /** The gc-safety heartbeat cadence: every N-th pass re-reads the manifest. */
34
47
  const HEARTBEAT_PASSES = 16
35
48
 
36
- /** The re-poll cadence between waitFor's catch-up passes; the
37
- * read-your-writes waiter in `waitFor` is this number's one consumer. */
49
+ /** The re-poll cadence of waitFor, its one consumer. */
38
50
  const WAIT_FOR_POLL_MS = 20
39
51
 
52
+ type ReplicaState =
53
+ | { readonly tag: "bootstrapped" }
54
+ | { readonly tag: "checkpoint-seeded"; readonly catalog: Digest32 }
55
+ | { readonly tag: "sidecar-resumed"; readonly floor: ReadonlyMap<Braid, Generation> }
56
+
57
+ type RefreshOutcome =
58
+ | { readonly tag: "advanced"; readonly vector: ReadonlyMap<Braid, Generation> }
59
+ | { readonly tag: "wedged"; readonly braid: Braid; readonly cause: string }
60
+ | { readonly tag: "reseed"; readonly cause: string }
61
+ | { readonly tag: "refused"; readonly detail: string }
62
+
63
+ type Step =
64
+ | { readonly tag: "applied" }
65
+ | { readonly tag: "tip" }
66
+ | { readonly tag: "wedged"; readonly braid: Braid; readonly cause: string }
67
+ | { readonly tag: "reseed"; readonly cause: string }
68
+
69
+ type ApplyPhase = "open" | "steady"
70
+
71
+ type SlotApply = { readonly tag: "applied"; readonly generation: bigint } | { readonly tag: "discard" }
72
+
40
73
  interface OpenReplicaOptions<Rels extends SchemaRelations> {
41
74
  readonly store: ObjectStore
42
75
  readonly prefix: string
@@ -58,17 +91,19 @@ interface Core<Rels extends SchemaRelations> {
58
91
  readonly theory: Schema<Rels>
59
92
  readonly descriptor: Descriptor
60
93
  db: Db<Rels>
61
- chain: Map<Braid, ChainEntry>
62
- pending: Pending | null
63
- pendingOps: readonly Op[] | null
64
- pendingApplied: boolean
94
+ /** Settled|Pending. Generation is chainGeneration(this.chain) — a
95
+ * total function of the sum. There is no pending Option beside the
96
+ * vector and no 0|1 addend a reader can skip (§30). */
97
+ chain: Chain
65
98
  manifestEtag: Etag | null
66
99
  checkpoint: CheckpointFacts | null
67
- checkpointDigest: string | null
100
+ checkpointDigest: Digest32 | null
68
101
  passes: number
69
102
  closed: boolean
70
103
  storeName: string
71
104
  gate: Promise<unknown>
105
+ wedged: Map<Braid, string>
106
+ provenance: ReplicaState
72
107
  }
73
108
 
74
109
  const cores = new WeakMap<object, Core<SchemaRelations>>()
@@ -99,8 +134,12 @@ function blake3Hex(bytes: Uint8Array): string {
99
134
  return toHex(new Uint8Array(internalBlake3(bytes)))
100
135
  }
101
136
 
137
+ function blake3Digest(bytes: Uint8Array): Digest32 {
138
+ return digest32(new Uint8Array(internalBlake3(bytes)))
139
+ }
140
+
102
141
  function sidecarPath<Rels extends SchemaRelations>(core: Core<Rels>): string {
103
- return path.join(core.dir, "chain.json")
142
+ return path.join(core.dir, "chain")
104
143
  }
105
144
 
106
145
  let storeSequence = 0
@@ -118,6 +157,16 @@ function storePath<Rels extends SchemaRelations>(core: Core<Rels>): string {
118
157
  return path.join(core.dir, core.storeName)
119
158
  }
120
159
 
160
+ /** A wire braid id is a braid of this theory or a typed refuse. */
161
+ function braidOf(theory: Schema<SchemaRelations> | Schema<never>, raw: Braid): Braid {
162
+ const id = Number.parseInt(raw.slice(1), 16)
163
+ const parsed = parse(theory, id)
164
+ if (parsed === undefined) {
165
+ refuse({ kind: "UnknownBraid", braid: Number.isFinite(id) ? id : 0 }, `unknown braid ${raw}`)
166
+ }
167
+ return parsed
168
+ }
169
+
121
170
  function zeroChain(descriptor: Descriptor): Map<Braid, ChainEntry> {
122
171
  const chain = new Map<Braid, ChainEntry>()
123
172
  for (const id of descriptor.braidMembers.keys()) {
@@ -126,10 +175,15 @@ function zeroChain(descriptor: Descriptor): Map<Braid, ChainEntry> {
126
175
  return chain
127
176
  }
128
177
 
178
+ function entriesOf<Rels extends SchemaRelations>(core: Core<Rels>): Map<Braid, ChainEntry> {
179
+ return core.chain.entries as Map<Braid, ChainEntry>
180
+ }
181
+
129
182
  function chainEntry<Rels extends SchemaRelations>(core: Core<Rels>, braid: Braid): ChainEntry {
130
- const entry = core.chain.get(braid)
183
+ const id = braidOf(core.theory, braid)
184
+ const entry = entriesOf(core).get(id)
131
185
  if (entry === undefined) {
132
- throw errors.new(`braid ${braid} is not derived from this theory`)
186
+ throw errors.new(`braid ${id} is not derived from this theory`)
133
187
  }
134
188
  return entry
135
189
  }
@@ -140,29 +194,134 @@ function generationOf<Rels extends SchemaRelations>(core: Core<Rels>): bigint {
140
194
  })
141
195
  }
142
196
 
143
- function chainSum<Rels extends SchemaRelations>(core: Core<Rels>): bigint {
144
- let sum = 0n
145
- for (const entry of core.chain.values()) {
146
- sum += entry.g
197
+ function digestOfCatalog(raw: unknown): Digest32 {
198
+ if (raw instanceof Uint8Array) {
199
+ return digest32(raw)
147
200
  }
148
- return sum
201
+ throw errors.new(`catalogDigest is not a digest: ${typeof raw}`)
149
202
  }
150
203
 
151
- function pendingTerm<Rels extends SchemaRelations>(core: Core<Rels>): bigint {
152
- return core.pendingApplied ? 1n : 0n
204
+ /** The opened store's catalog digest. The SDK may expose it on the
205
+ * handle or on the read instance; either spelling is the computed claim. */
206
+ function catalogDigestOf<Rels extends SchemaRelations>(core: Core<Rels>): Digest32 {
207
+ const handle = core.db as unknown as { catalogDigest?: () => unknown }
208
+ if (typeof handle.catalogDigest === "function") {
209
+ return digestOfCatalog(handle.catalogDigest())
210
+ }
211
+ return core.db.read(function readCatalog(instance) {
212
+ const carrier = instance as { catalogDigest?: unknown }
213
+ if (typeof carrier.catalogDigest === "function") {
214
+ return digestOfCatalog(carrier.catalogDigest())
215
+ }
216
+ throw errors.new("the opened store does not expose catalogDigest")
217
+ })
218
+ }
219
+
220
+ function chainOf<Rels extends SchemaRelations>(core: Core<Rels>): Chain {
221
+ return core.chain
222
+ }
223
+
224
+ /** Pending-arm payload: recorded ops ride on the batch, not a third
225
+ * Option beside the vector. Wire Pending is {braid,gen,bytes}; ops is
226
+ * the in-memory field of that arm (§30). */
227
+ type HeldBatch = Pending & { readonly ops: readonly Op[] | null }
228
+
229
+ function holdPending<Rels extends SchemaRelations>(core: Core<Rels>, batch: Pending, ops: readonly Op[] | null): void {
230
+ const held: HeldBatch = { braid: batch.braid, gen: batch.gen, bytes: batch.bytes, ops }
231
+ core.chain = { tag: "pending", entries: entriesOf(core), batch: held }
232
+ }
233
+
234
+ function pendingOf<Rels extends SchemaRelations>(core: Core<Rels>): HeldBatch | null {
235
+ if (core.chain.tag !== "pending") {
236
+ return null
237
+ }
238
+ return core.chain.batch as HeldBatch
239
+ }
240
+
241
+ function settleHeld<Rels extends SchemaRelations>(core: Core<Rels>): void {
242
+ core.chain = { tag: "settled", entries: entriesOf(core) }
243
+ }
244
+
245
+ /** The local vector equals the published checkpoint vector and the
246
+ * chain is Settled — the seed/open floor the catalog claim is audited
247
+ * against. */
248
+ function atCheckpointFloor<Rels extends SchemaRelations>(core: Core<Rels>): boolean {
249
+ if (core.checkpoint === null || core.chain.tag === "pending") {
250
+ return false
251
+ }
252
+ for (const [braid, head] of core.checkpoint.braids) {
253
+ if (chainEntry(core, braid).g !== head.g) {
254
+ return false
255
+ }
256
+ }
257
+ return true
153
258
  }
154
259
 
155
260
  async function persistSidecar<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
156
- await writeSidecar(sidecarPath(core), { chain: core.chain, pending: core.pending })
261
+ await writeSidecar(sidecarPath(core), chainOf(core))
157
262
  }
158
263
 
159
- async function clearPending<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
160
- core.pending = null
161
- core.pendingOps = null
162
- core.pendingApplied = false
264
+ /** Writes the checkpoint store file and fsyncs the file and its parent
265
+ * directory — the mdb is durable before the sidecar. */
266
+ async function writeCheckpointMdb(dir: string, bytes: Uint8Array): Promise<void> {
267
+ const data = path.join(dir, "data.mdb")
268
+ const handle = await fs.open(data, "w")
269
+ const written = await errors.try(
270
+ (async function writeAll() {
271
+ await handle.writeFile(bytes)
272
+ await handle.sync()
273
+ })()
274
+ )
275
+ await handle.close()
276
+ if (written.error) {
277
+ throw errors.wrap(written.error, `write checkpoint store ${data}`)
278
+ }
279
+ const parent = await fs.open(dir, "r")
280
+ const synced = await errors.try(parent.sync())
281
+ await parent.close()
282
+ if (synced.error) {
283
+ throw errors.wrap(synced.error, `fsync checkpoint store directory ${dir}`)
284
+ }
285
+ }
286
+
287
+ async function settle<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
288
+ settleHeld(core)
163
289
  await persistSidecar(core)
164
290
  }
165
291
 
292
+ async function clearPending<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
293
+ await settle(core)
294
+ }
295
+
296
+ function disposed<Rels extends SchemaRelations>(core: Core<Rels>): void {
297
+ if (core.closed) {
298
+ throw errors.new("replica is disposed")
299
+ }
300
+ }
301
+
302
+ function adoptChain<Rels extends SchemaRelations>(core: Core<Rels>, chain: Chain): void {
303
+ const entries = new Map<Braid, ChainEntry>()
304
+ for (const [raw, entry] of chain.entries) {
305
+ entries.set(braidOf(core.theory, raw), entry)
306
+ }
307
+ for (const id of core.descriptor.braidMembers.keys()) {
308
+ if (!entries.has(id)) {
309
+ entries.set(id, { g: generation(0n), prev: ZERO_HASH, ts: 0n })
310
+ }
311
+ }
312
+ if (chain.tag === "pending") {
313
+ const held: HeldBatch = {
314
+ braid: braidOf(core.theory, chain.batch.braid),
315
+ gen: chain.batch.gen,
316
+ bytes: chain.batch.bytes,
317
+ ops: null
318
+ }
319
+ core.chain = { tag: "pending", entries, batch: held }
320
+ } else {
321
+ core.chain = { tag: "settled", entries }
322
+ }
323
+ }
324
+
166
325
  /** Positional decoded row → the SDK's named fact, handles lifted for closed refs. */
167
326
  function factOf<Rels extends SchemaRelations>(
168
327
  core: Core<Rels>,
@@ -214,15 +373,12 @@ function applyOps<Rels extends SchemaRelations>(core: Core<Rels>, ops: readonly
214
373
  })
215
374
  }
216
375
 
217
- type ApplyPhase = "open" | "steady"
218
-
219
- type SlotApply = { readonly tag: "applied"; readonly generation: bigint } | { readonly tag: "discard" }
220
-
221
376
  /**
222
- * The two-step apply discipline: `db.write` the batch, then advance the
223
- * sidecar. Chain and publish-law refusals live here; a rejected replay
224
- * is phase-scoped (50): discard before the store has proven itself
225
- * whole, `ErrReplayDiverged` after.
377
+ * Decode, verify the chain, `db.write` the batch, then refuse a
378
+ * first-applied no-op against the identity. The sidecar advances
379
+ * only after that check; a rejected replay is phase-scoped:
380
+ * discard before the store has proven itself whole,
381
+ * `ErrReplayDiverged` after.
226
382
  */
227
383
  async function applySlot<Rels extends SchemaRelations>(
228
384
  core: Core<Rels>,
@@ -241,83 +397,182 @@ async function applySlot<Rels extends SchemaRelations>(
241
397
  }
242
398
  throw errors.wrap(ErrReplayDiverged, `braid ${braid} slot ${slot} writer ${decoded.header.writer}`)
243
399
  }
244
- core.chain.set(braid, { g: slot, prev: blake3Hex(bytes), ts: decoded.header.timestamp })
245
- await persistSidecar(core)
246
- const expected = chainSum(core) + pendingTerm(core)
247
- if (outcome.value.generation < expected) {
400
+ const identity = chainSum(core.chain) - entry.g + slot
401
+ if (outcome.value.generation < identity) {
248
402
  refuse(
249
403
  { kind: "NoOpSlot", braid, slot, writer: decoded.header.writer },
250
404
  `braid ${braid} slot ${slot}: a first-applied slot changed nothing — publish-law violation by writer ${decoded.header.writer}`
251
405
  )
252
406
  }
407
+ entriesOf(core).set(braid, { g: slot, prev: blake3Digest(bytes), ts: decoded.header.timestamp })
408
+ await persistSidecar(core)
253
409
  return { tag: "applied", generation: outcome.value.generation }
254
410
  }
255
411
 
256
412
  /**
257
- * The gc floor rule (10): below the current checkpoint's vector a 404 is
258
- * a collected hole, at or above it the honest tip.
413
+ * The published checkpoint vector is the one floor: a slot at or
414
+ * below it is retired, and a create must not touch the store.
259
415
  */
260
- function holeAt<Rels extends SchemaRelations>(core: Core<Rels>, braid: Braid, next: Generation): boolean {
416
+ function belowFloor<Rels extends SchemaRelations>(core: Core<Rels>, braid: Braid, slot: Generation): boolean {
417
+ const id = braidOf(core.theory, braid)
261
418
  if (core.checkpoint === null) {
262
419
  return false
263
420
  }
264
- const floor = core.checkpoint.braids.get(braid)
421
+ const floor = core.checkpoint.braids.get(id)
265
422
  if (floor === undefined) {
266
423
  return false
267
424
  }
268
- return next <= floor.g
425
+ return slot <= floor.g
426
+ }
427
+
428
+ /**
429
+ * The gc floor rule: below the current checkpoint's vector a 404 is a
430
+ * collected hole, at or above it the honest tip.
431
+ */
432
+ function holeAt<Rels extends SchemaRelations>(core: Core<Rels>, braid: Braid, next: Generation): boolean {
433
+ return belowFloor(core, braid, next)
434
+ }
435
+
436
+ /**
437
+ * Classification of a pending batch against occupant, store
438
+ * generation, and the floor. BelowFloor is published (`Clear`) — the
439
+ * slot is already in the trusted history, not a candidate to re-judge.
440
+ */
441
+ type PendingFold =
442
+ | { readonly tag: "ours" }
443
+ | { readonly tag: "theirs-unapplied" }
444
+ | { readonly tag: "theirs-applied" }
445
+ | { readonly tag: "absent-unapplied" }
446
+ | { readonly tag: "absent-applied" }
447
+ | { readonly tag: "below-floor" }
448
+ | { readonly tag: "phantom" }
449
+
450
+ function foldPending(
451
+ sum: bigint,
452
+ generation: bigint,
453
+ occupant: Uint8Array | null,
454
+ pendingBytes: Uint8Array,
455
+ covered: boolean
456
+ ): PendingFold {
457
+ if (covered) {
458
+ return { tag: "below-floor" }
459
+ }
460
+ if (occupant !== null && bytesEqual(occupant, pendingBytes)) {
461
+ return { tag: "ours" }
462
+ }
463
+ if (occupant !== null) {
464
+ return generation === sum ? { tag: "theirs-unapplied" } : { tag: "theirs-applied" }
465
+ }
466
+ if (generation === sum) {
467
+ return { tag: "absent-unapplied" }
468
+ }
469
+ if (generation === saturatingAddU64(sum, 1n)) {
470
+ return { tag: "absent-applied" }
471
+ }
472
+ return { tag: "phantom" }
473
+ }
474
+
475
+ function isCorruption(error: unknown): boolean {
476
+ if (!(error instanceof Error)) {
477
+ return false
478
+ }
479
+ return errors.is(error, ErrReplayDiverged) || errors.is(error, ErrRefused)
269
480
  }
270
481
 
271
- async function catchUpBraid<Rels extends SchemaRelations>(
482
+ /**
483
+ * One braid, one slot. Catch-up, refresh, waitFor, and open all call
484
+ * this; a hot braid cannot drain past one step per round.
485
+ */
486
+ async function stepBraid<Rels extends SchemaRelations>(
272
487
  core: Core<Rels>,
273
488
  braid: Braid,
274
489
  phase: ApplyPhase
275
- ): Promise<"tip" | "discard"> {
276
- for (;;) {
277
- const next = generation(chainEntry(core, braid).g + 1n)
278
- const fetched = await core.store.get(logKey(core.prefix, braid, next))
279
- if (fetched === null) {
280
- if (holeAt(core, braid, next)) {
281
- throw errors.wrap(ErrGapDetected, `braid ${braid} slot ${next} is below the checkpoint vector`)
282
- }
283
- return "tip"
284
- }
285
- if (core.pendingApplied && core.pending !== null && braid === core.pending.braid && next === core.pending.gen) {
286
- // An applied pending contests exactly its own slot: byte-equal
287
- // means our slot was already published and the pending ends
288
- // here; any other occupant is a lost race, and the store —
289
- // carrying the pending's effects on a stale base — is
290
- // discarded so the one loss path re-judges at the tip (L10).
291
- if (!bytesEqual(fetched.bytes, core.pending.bytes)) {
292
- return "discard"
293
- }
294
- core.pending = null
295
- core.pendingOps = null
296
- core.pendingApplied = false
297
- await persistSidecar(core)
490
+ ): Promise<Step> {
491
+ disposed(core)
492
+ const id = braidOf(core.theory, braid)
493
+ const wedged = core.wedged.get(id)
494
+ if (wedged !== undefined) {
495
+ return { tag: "wedged", braid: id, cause: wedged }
496
+ }
497
+ const next = generation(chainEntry(core, id).g + 1n)
498
+ const fetched = await core.store.get(logKey(core.prefix, id, next))
499
+ if (fetched === null) {
500
+ if (holeAt(core, id, next)) {
501
+ return { tag: "reseed", cause: "gap-below-floor" }
502
+ }
503
+ return { tag: "tip" }
504
+ }
505
+ const chain = core.chain
506
+ if (chain.tag === "pending" && id === chain.batch.braid && next === chain.batch.gen) {
507
+ if (!bytesEqual(fetched.bytes, chain.batch.bytes)) {
508
+ return { tag: "reseed", cause: "lost-pending-fork" }
298
509
  }
299
- const applied = await applySlot(core, braid, next, fetched.bytes, phase)
300
- if (applied.tag === "discard") {
301
- return "discard"
510
+ await settle(core)
511
+ }
512
+ const applied = await errors.try(applySlot(core, id, next, fetched.bytes, phase))
513
+ if (applied.error) {
514
+ if (phase === "steady" && isCorruption(applied.error)) {
515
+ core.wedged.set(id, applied.error.message)
516
+ return { tag: "wedged", braid: id, cause: applied.error.message }
302
517
  }
518
+ throw applied.error
519
+ }
520
+ if (applied.data.tag === "discard") {
521
+ return { tag: "reseed", cause: "rejected-in-open" }
303
522
  }
523
+ return { tag: "applied" }
304
524
  }
305
525
 
306
- async function catchUpAll<Rels extends SchemaRelations>(
526
+ /**
527
+ * One pass: heartbeat, one slot per braid, wholeness, disposed. The
528
+ * same function refresh, waitFor, catch-up, and open execute.
529
+ */
530
+ async function runPass<Rels extends SchemaRelations>(
307
531
  core: Core<Rels>,
532
+ braids: readonly Braid[],
308
533
  phase: ApplyPhase
309
- ): Promise<"tip" | "discard"> {
310
- for (const braid of core.descriptor.braidMembers.keys()) {
311
- const outcome = await catchUpBraid(core, braid, phase)
312
- if (outcome === "discard") {
313
- return "discard"
534
+ ): Promise<RefreshOutcome> {
535
+ disposed(core)
536
+ core.passes += 1
537
+ if (core.passes % HEARTBEAT_PASSES === 0) {
538
+ await refreshManifest(core)
539
+ }
540
+ const remaining = new Set(braids)
541
+ while (remaining.size > 0) {
542
+ disposed(core)
543
+ let progressed = false
544
+ for (const braid of braids) {
545
+ if (!remaining.has(braid)) {
546
+ continue
547
+ }
548
+ const step = await stepBraid(core, braid, phase)
549
+ if (step.tag === "applied") {
550
+ progressed = true
551
+ } else if (step.tag === "tip" || step.tag === "wedged") {
552
+ remaining.delete(braid)
553
+ } else {
554
+ return { tag: "reseed", cause: step.cause }
555
+ }
556
+ }
557
+ if (!progressed) {
558
+ break
314
559
  }
315
560
  }
316
- return "tip"
561
+ if (!wholenessHolds(core)) {
562
+ return { tag: "reseed", cause: "wholeness" }
563
+ }
564
+ for (const [braid, cause] of core.wedged) {
565
+ return { tag: "wedged", braid, cause }
566
+ }
567
+ return { tag: "advanced", vector: vectorOf(core) }
568
+ }
569
+
570
+ function allBraids<Rels extends SchemaRelations>(core: Core<Rels>): Braid[] {
571
+ return [...core.descriptor.braidMembers.keys()]
317
572
  }
318
573
 
319
574
  function wholenessHolds<Rels extends SchemaRelations>(core: Core<Rels>): boolean {
320
- return generationOf(core) === chainSum(core) + pendingTerm(core)
575
+ return generationOf(core) === chainGeneration(core.chain)
321
576
  }
322
577
 
323
578
  async function adoptManifest<Rels extends SchemaRelations>(
@@ -326,37 +581,44 @@ async function adoptManifest<Rels extends SchemaRelations>(
326
581
  etag: Etag
327
582
  ): Promise<void> {
328
583
  const manifest = parseManifest(bytes)
329
- if (manifest.fingerprint !== core.descriptor.fingerprint) {
584
+ if (!bytesEqual(manifest.fingerprint, digest32(core.descriptor.fingerprintBytes))) {
330
585
  refuse(
331
- { kind: "FingerprintMismatch", carried: manifest.fingerprint, expected: core.descriptor.fingerprint },
586
+ {
587
+ kind: "FingerprintMismatch",
588
+ carried: hex32(manifest.fingerprint),
589
+ expected: core.descriptor.fingerprint
590
+ },
332
591
  "the store's manifest names a different theory"
333
592
  )
334
593
  }
335
- core.manifestEtag = etag
336
594
  if (manifest.checkpoint === null) {
337
595
  core.checkpoint = null
338
596
  core.checkpointDigest = null
339
- return
340
- }
341
- if (manifest.checkpoint === core.checkpointDigest) {
342
- return
343
- }
344
- const facts = await core.store.get(checkpointJsonKey(core.prefix, manifest.checkpoint))
345
- if (facts === null) {
346
- throw errors.new(`manifest points at absent checkpoint ${manifest.checkpoint}`)
347
- }
348
- const checkpoint = parseCheckpoint(facts.bytes)
349
- const carried = [...checkpoint.braids.keys()].sort()
350
- const derived = [...core.descriptor.braidMembers.keys()].sort()
351
- if (carried.join(",") !== derived.join(",")) {
352
- refuse({ kind: "CheckpointBraids", carried, derived }, "checkpoint braid set drifted from the derived braids")
597
+ } else if (core.checkpointDigest === null || !bytesEqual(manifest.checkpoint, core.checkpointDigest)) {
598
+ const facts = await core.store.get(ckptDocKey(core.prefix, manifest.checkpoint))
599
+ if (facts === null) {
600
+ throw errors.new(`manifest points at absent checkpoint ${hex32(manifest.checkpoint)}`)
601
+ }
602
+ const checkpoint = parseCheckpoint(facts.bytes)
603
+ for (const id of checkpoint.braids.keys()) {
604
+ braidOf(core.theory, id)
605
+ }
606
+ const carried = [...checkpoint.braids.keys()].sort()
607
+ const derived = [...core.descriptor.braidMembers.keys()].sort()
608
+ if (carried.join(",") !== derived.join(",")) {
609
+ refuse({ kind: "CheckpointBraids", carried, derived }, "checkpoint braid set drifted from the derived braids")
610
+ }
611
+ core.checkpoint = checkpoint
612
+ core.checkpointDigest = manifest.checkpoint
353
613
  }
354
- core.checkpoint = checkpoint
355
- core.checkpointDigest = manifest.checkpoint
614
+ // The pointer is adopted only after the checkpoint it names is in
615
+ // hand. A failed fetch leaves the old etag and the old floor (40/67).
616
+ core.manifestEtag = etag
356
617
  }
357
618
 
358
- async function refreshManifest<Rels extends SchemaRelations>(core: Core<Rels>, force: boolean): Promise<void> {
359
- if (!force && core.manifestEtag !== null) {
619
+ /** A replica never births a manifest. Absence is ManifestMissing. */
620
+ async function refreshManifest<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
621
+ if (core.manifestEtag !== null) {
360
622
  const poll = await core.store.getIfChanged(manifestKey(core.prefix), core.manifestEtag)
361
623
  if (poll.tag === "unchanged") {
362
624
  return
@@ -365,94 +627,102 @@ async function refreshManifest<Rels extends SchemaRelations>(core: Core<Rels>, f
365
627
  return
366
628
  }
367
629
  const fetched = await core.store.get(manifestKey(core.prefix))
368
- if (fetched !== null) {
369
- await adoptManifest(core, fetched.bytes, fetched.etag)
370
- return
630
+ if (fetched === null) {
631
+ refuseManifestMissing("the store has no manifest")
371
632
  }
372
- const birth = renderManifest({ fingerprint: core.descriptor.fingerprint, checkpoint: null })
373
- const created = await core.store.putCreate(manifestKey(core.prefix), birth)
374
- if (created.tag === "created") {
375
- core.manifestEtag = created.etag
376
- core.checkpoint = null
377
- core.checkpointDigest = null
378
- return
379
- }
380
- const reread = await core.store.get(manifestKey(core.prefix))
381
- if (reread === null) {
382
- throw errors.new("manifest vanished between create refusal and re-read")
383
- }
384
- await adoptManifest(core, reread.bytes, reread.etag)
633
+ await adoptManifest(core, fetched.bytes, fetched.etag)
385
634
  }
386
635
 
387
636
  /** Bootstraps a fresh local store: from the current checkpoint when one
388
- * exists, else `Db.create` at the zero vector. */
637
+ * exists, else `Db.create` at the zero vector. Always holds Settled —
638
+ * a prior Pending arm cannot survive beside the new vector. */
389
639
  async function initializeStore<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
390
640
  core.storeName = freshStoreName()
391
641
  const target = storePath(core)
392
642
  await fs.rm(target, { recursive: true, force: true })
393
643
  await fs.mkdir(core.dir, { recursive: true })
394
644
  if (core.checkpoint !== null && core.checkpointDigest !== null) {
395
- for (let attempt = 0; ; attempt++) {
396
- const mdb = await core.store.get(checkpointMdbKey(core.prefix, core.checkpointDigest))
397
- if (mdb === null) {
398
- throw errors.new(`checkpoint ${core.checkpointDigest} names an absent .mdb`)
399
- }
400
- const digest = blake3Hex(mdb.bytes)
401
- if (digest !== core.checkpointDigest) {
402
- if (attempt === 0) {
403
- continue
404
- }
405
- refuse(
406
- { kind: "CheckpointDigest", expected: core.checkpointDigest, computed: digest },
407
- "checkpoint bytes do not hash to their own name"
408
- )
409
- }
410
- await fs.mkdir(target, { recursive: true })
411
- await fs.writeFile(path.join(target, "data.mdb"), mdb.bytes)
412
- core.db = await SdkDb.open(target, core.theory)
413
- core.chain = new Map(
414
- [...core.checkpoint.braids.entries()].map(function seed([braid, head]) {
645
+ const mdb = await core.store.get(checkpointMdbKey(core.prefix, core.checkpointDigest))
646
+ if (mdb === null) {
647
+ throw errors.new(`checkpoint ${hex32(core.checkpointDigest)} names an absent .mdb`)
648
+ }
649
+ await fs.mkdir(target, { recursive: true })
650
+ await writeCheckpointMdb(target, mdb.bytes)
651
+ core.db = await SdkDb.open(target, core.theory)
652
+ core.chain = {
653
+ tag: "settled",
654
+ entries: new Map(
655
+ [...core.checkpoint.braids.entries()].map(function seed([raw, head]) {
656
+ const braid = braidOf(core.theory, raw)
415
657
  return [braid, { g: head.g, prev: head.hash, ts: head.ts }] as const
416
658
  })
417
659
  )
418
- let sum = 0n
419
- for (const head of core.checkpoint.braids.values()) {
420
- sum += head.g
421
- }
422
- const generation = generationOf(core)
423
- if (generation !== sum) {
424
- throw errors.new(`checkpoint store opened at generation ${generation}, its vector sums to ${sum}`)
425
- }
426
- await persistSidecar(core)
427
- return
428
660
  }
661
+ const opened = generationOf(core)
662
+ if (opened !== chainGeneration(core.chain)) {
663
+ throw errors.new(
664
+ `checkpoint store opened at generation ${opened}, chain generation is ${chainGeneration(core.chain)}`
665
+ )
666
+ }
667
+ auditCatalog(core.checkpoint, catalogDigestOf(core))
668
+ core.provenance = { tag: "checkpoint-seeded", catalog: core.checkpointDigest }
669
+ await persistSidecar(core)
670
+ return
429
671
  }
430
672
  const created = await SdkDb.create(target, core.theory)
431
673
  if (created.tag === "rejected") {
432
674
  throw errors.new("the theory's ground axioms were rejected at bootstrap")
433
675
  }
434
676
  core.db = created.value
435
- core.chain = zeroChain(core.descriptor)
677
+ core.chain = { tag: "settled", entries: zeroChain(core.descriptor) }
678
+ core.provenance = { tag: "bootstrapped" }
436
679
  await persistSidecar(core)
437
680
  }
438
681
 
682
+ /** The scream tracks the set of repair signatures; a recurrence alarms. */
683
+ function screamOf(context: string): { attempt(signature: string): void } {
684
+ const seen = new Set<string>()
685
+ let attempts = 0
686
+ return {
687
+ attempt(signature: string): void {
688
+ attempts += 1
689
+ if (seen.has(signature)) {
690
+ console.error(`bumbledb-log alarm: ${context} repair signature recurs: ${signature}`)
691
+ } else {
692
+ seen.add(signature)
693
+ }
694
+ if (attempts % 8 === 0) {
695
+ console.error(`bumbledb-log warning: ${context} repair attempt ${attempts}: ${signature}`)
696
+ }
697
+ }
698
+ }
699
+ }
700
+
439
701
  /** The disposable law: the directory is cache, never truth. The local
440
702
  * LMDB path rotates because the engine has no close verb — the old
441
- * environment is left for GC while the fresh pull takes a new path. */
703
+ * environment is left for GC while the fresh pull takes a new path.
704
+ * initializeStore writes Settled, so the open-identity is
705
+ * chainGeneration(core.chain) — there is no addend a reader can skip. */
442
706
  async function discardAndReopen<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
443
- for (let attempt = 0; attempt < 3; attempt++) {
707
+ const scream = screamOf("replica discard-and-re-pull")
708
+ for (;;) {
444
709
  const old = storePath(core)
445
710
  await fs.rm(old, { recursive: true, force: true })
711
+ core.wedged.clear()
446
712
  await initializeStore(core)
447
- const outcome = await catchUpAll(core, "open")
448
- if (outcome === "discard") {
713
+ const outcome = await runPass(core, allBraids(core), "open")
714
+ if (outcome.tag === "reseed") {
715
+ scream.attempt(outcome.cause)
449
716
  continue
450
717
  }
451
- if (generationOf(core) === chainSum(core)) {
718
+ if (outcome.tag === "refused") {
719
+ throw errors.wrap(ErrRefused, outcome.detail)
720
+ }
721
+ if (generationOf(core) === chainGeneration(core.chain)) {
452
722
  return
453
723
  }
724
+ scream.attempt("wholeness")
454
725
  }
455
- throw errors.new("the store failed to reach wholeness after repeated re-pulls")
456
726
  }
457
727
 
458
728
  async function newestStoreDir(dir: string): Promise<string | null> {
@@ -475,6 +745,26 @@ async function newestStoreDir(dir: string): Promise<string | null> {
475
745
  return newest
476
746
  }
477
747
 
748
+ /**
749
+ * Open reclaims the reserved `~tmp`/`~lease` namespace. The known
750
+ * document `{dir}/~lease/ckpt-scratch` names a crash-stranded
751
+ * candidate; when that digest is not the live head the ckpt pair is
752
+ * deleted with the lease.
753
+ */
754
+ async function sweepReservedKeys<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
755
+ const lease = path.join(core.dir, LEASE_NAMESPACE, CKPT_SCRATCH_LEASE)
756
+ const read = await errors.try(fs.readFile(lease))
757
+ if (read.error === undefined) {
758
+ const digest = parseCkptScratch(read.data)
759
+ if (digest !== null && (core.checkpointDigest === null || !bytesEqual(digest, core.checkpointDigest))) {
760
+ await core.store.delete(ckptDocKey(core.prefix, digest))
761
+ await core.store.delete(checkpointMdbKey(core.prefix, digest))
762
+ }
763
+ }
764
+ await fs.rm(path.join(core.dir, TEMP_NAMESPACE), { recursive: true, force: true })
765
+ await fs.rm(path.join(core.dir, LEASE_NAMESPACE), { recursive: true, force: true })
766
+ }
767
+
478
768
  /** The disposable law says cache directories do not hoard corpses: every
479
769
  * rotated `store-*` LMDB dir except the adopted one is dead — left by a
480
770
  * crashed process or a prior rotation — and is swept at open. */
@@ -492,35 +782,48 @@ async function sweepRotations<Rels extends SchemaRelations>(core: Core<Rels>): P
492
782
  }
493
783
 
494
784
  /**
495
- * Pending recovery, first half (60): apply the resurrected batch; the
496
- * verdict plus the wholeness instrument force all three arms. A batch
497
- * rejected here was never acked (fsync preceded its first judgment); a
498
- * born-no-op publishes nothing; anything else is a real unpublished
499
- * commit that catch-up and the tip attempt will place.
785
+ * Pending recovery: apply the resurrected batch. A batch rejected here
786
+ * was never acked; a born-no-op settles; anything else stays Pending
787
+ * so generation(chain) accounts for the unpublished apply.
500
788
  */
501
789
  async function resolvePendingAtOpen<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
502
- if (core.pending === null) {
790
+ const chain = core.chain
791
+ if (chain.tag === "settled") {
792
+ return
793
+ }
794
+ // A slot the floor already covers is published (`Clear`), not re-judged (46).
795
+ if (
796
+ foldPending(
797
+ chainSum(core.chain),
798
+ generationOf(core),
799
+ null,
800
+ chain.batch.bytes,
801
+ belowFloor(core, chain.batch.braid, chain.batch.gen)
802
+ ).tag === "below-floor"
803
+ ) {
804
+ await settle(core)
503
805
  return
504
806
  }
505
807
  const decoded = errors.trySync(function decodePending() {
506
- return decodeBatch(core.descriptor, (core.pending as Pending).bytes)
808
+ return decodeBatch(core.descriptor, chain.batch.bytes)
507
809
  })
508
810
  if (decoded.error) {
509
- await clearPending(core)
811
+ await settle(core)
510
812
  return
511
813
  }
512
- const before = generationOf(core)
513
814
  const outcome = applyOps(core, decoded.data.ops)
514
815
  if (outcome.tag === "rejected") {
515
- await clearPending(core)
816
+ await settle(core)
516
817
  return
517
818
  }
518
- if (outcome.value.generation === before && before === chainSum(core)) {
519
- await clearPending(core)
819
+ // Store matches chainGeneration of the held Pending (sum+1) when the
820
+ // apply is in the db — just now, or already. A born-no-op leaves the
821
+ // store at the Settled generation, so the identity fails and we settle.
822
+ if (generationOf(core) !== chainGeneration(core.chain)) {
823
+ await settle(core)
520
824
  return
521
825
  }
522
- core.pendingOps = decoded.data.ops
523
- core.pendingApplied = true
826
+ holdPending(core, chain.batch, decoded.data.ops)
524
827
  }
525
828
 
526
829
  /**
@@ -528,18 +831,26 @@ async function resolvePendingAtOpen<Rels extends SchemaRelations>(core: Core<Rel
528
831
  * unopenable (or discarded), so the sidecar's pending is resolved
529
832
  * against a freshly caught-up store — a byte-equal published slot
530
833
  * absorbs it (L10's idempotent replay), and everything else takes the
531
- * one loss path's re-judgment at the tip.
834
+ * one loss path's re-judgment at the tip. A slot the floor already
835
+ * covers is published (`Clear`), not re-judged (46).
532
836
  */
533
837
  async function resolveColdPending<Rels extends SchemaRelations>(core: Core<Rels>, pending: Pending): Promise<void> {
838
+ const braid = braidOf(core.theory, pending.braid)
839
+ if (
840
+ foldPending(chainSum(core.chain), generationOf(core), null, pending.bytes, belowFloor(core, braid, pending.gen))
841
+ .tag === "below-floor"
842
+ ) {
843
+ await settle(core)
844
+ return
845
+ }
534
846
  const decoded = errors.trySync(function decodePending() {
535
847
  return decodeBatch(core.descriptor, pending.bytes)
536
848
  })
537
849
  if (decoded.error) {
538
850
  return
539
851
  }
540
- const braid = decoded.data.header.braid
541
852
  const slot = decoded.data.header.braidGen
542
- if (core.chain.has(braid) && chainEntry(core, braid).g >= slot) {
853
+ if (entriesOf(core).has(braid) && chainEntry(core, braid).g >= slot) {
543
854
  const published = await core.store.get(logKey(core.prefix, braid, slot))
544
855
  if (published !== null && bytesEqual(published.bytes, pending.bytes)) {
545
856
  return
@@ -548,8 +859,7 @@ async function resolveColdPending<Rels extends SchemaRelations>(core: Core<Rels>
548
859
  const before = generationOf(core)
549
860
  const outcome = applyOps(core, decoded.data.ops)
550
861
  if (outcome.tag === "accepted" && outcome.value.generation > before) {
551
- core.pendingOps = decoded.data.ops
552
- core.pendingApplied = true
862
+ holdPending(core, { ...pending, braid }, decoded.data.ops)
553
863
  await readdressPending(core, decoded.data.ops, decoded.data.header.writer)
554
864
  }
555
865
  }
@@ -563,42 +873,56 @@ async function openCore<Rels extends SchemaRelations>(options: OpenReplicaOption
563
873
  theory: options.theory,
564
874
  descriptor,
565
875
  db: undefined as unknown as Db<Rels>,
566
- chain: zeroChain(descriptor),
567
- pending: null,
568
- pendingOps: null,
569
- pendingApplied: false,
876
+ chain: { tag: "settled", entries: zeroChain(descriptor) },
570
877
  manifestEtag: null,
571
878
  checkpoint: null,
572
879
  checkpointDigest: null,
573
880
  passes: 0,
574
881
  closed: false,
575
882
  storeName: freshStoreName(),
576
- gate: Promise.resolve()
883
+ gate: Promise.resolve(),
884
+ wedged: new Map(),
885
+ provenance: { tag: "bootstrapped" }
577
886
  }
578
887
  await fs.mkdir(core.dir, { recursive: true })
579
- await refreshManifest(core, true)
888
+ const fetched = await core.store.get(manifestKey(core.prefix))
889
+ if (fetched === null) {
890
+ refuseManifestMissing("the store has no manifest")
891
+ }
892
+ await adoptManifest(core, fetched.bytes, fetched.etag)
580
893
 
581
894
  const existing = await newestStoreDir(core.dir)
582
895
  const sidecar = await readSidecar(sidecarPath(core))
583
896
  let opened = false
584
897
  let coldPending: Pending | null = null
585
- if (existing !== null) {
898
+ if (sidecar.tag === "fault") {
899
+ throw wrapStore(sidecar.io, `read sidecar ${sidecarPath(core)}`)
900
+ }
901
+ if (sidecar.tag === "read") {
902
+ for (const id of sidecar.chain.entries.keys()) {
903
+ braidOf(core.theory, id)
904
+ }
905
+ if (sidecar.chain.tag === "pending") {
906
+ braidOf(core.theory, sidecar.chain.batch.braid)
907
+ }
908
+ }
909
+ if (sidecar.tag === "corrupt") {
910
+ coldPending = null
911
+ } else if (existing !== null && sidecar.tag === "read") {
586
912
  core.storeName = existing
587
913
  const openedDb = await errors.try(SdkDb.open(storePath(core), options.theory))
588
- if (openedDb.error === undefined && sidecar !== null) {
914
+ if (openedDb.error === undefined) {
589
915
  core.db = openedDb.data
590
- core.chain = new Map(sidecar.chain)
591
- for (const braid of descriptor.braidMembers.keys()) {
592
- if (!core.chain.has(braid)) {
593
- core.chain.set(braid, { g: generation(0n), prev: ZERO_HASH, ts: 0n })
594
- }
595
- }
596
- core.pending = sidecar.pending
916
+ adoptChain(core, sidecar.chain)
917
+ core.provenance = { tag: "sidecar-resumed", floor: vectorOf(core) }
597
918
  opened = true
598
919
  }
599
920
  }
600
921
  if (!opened) {
601
- coldPending = sidecar?.pending ?? null
922
+ if (sidecar.tag === "read" && sidecar.chain.tag === "pending") {
923
+ coldPending = sidecar.chain.batch
924
+ braidOf(core.theory, coldPending.braid)
925
+ }
602
926
  await initializeStore(core)
603
927
  }
604
928
 
@@ -606,29 +930,31 @@ async function openCore<Rels extends SchemaRelations>(options: OpenReplicaOption
606
930
  await resolvePendingAtOpen(core)
607
931
  }
608
932
 
609
- const outcome = await catchUpAll(core, "open")
610
- if (outcome === "discard" || !wholenessHolds(core)) {
611
- coldPending = core.pending ?? coldPending
612
- await clearPending(core)
933
+ const outcome = await runPass(core, allBraids(core), "open")
934
+ if (outcome.tag === "reseed" || outcome.tag === "refused" || !wholenessHolds(core)) {
935
+ coldPending = core.chain.tag === "pending" ? core.chain.batch : coldPending
613
936
  await discardAndReopen(core)
614
937
  }
615
938
 
616
939
  if (coldPending !== null) {
617
940
  await resolveColdPending(core, coldPending)
618
941
  }
942
+ if (atCheckpointFloor(core)) {
943
+ auditCatalog(core.checkpoint, catalogDigestOf(core))
944
+ }
619
945
  await sweepRotations(core)
946
+ await sweepReservedKeys(core)
620
947
  return core
621
948
  }
622
949
 
623
950
  /**
624
- * The steady-state discard route: a catch-up that met a contested
625
- * pending slot (or a rejected replay at open phase) surrenders the
626
- * directory; the sidecar's pending rides through as a cold pending and
627
- * takes the one loss path's re-judgment at the fresh tip.
951
+ * The steady-state discard route: a contested pending slot surrenders
952
+ * the directory. The pending batch rides in memory — the sidecar is
953
+ * not settled ahead of the re-judgment — and takes the one loss path
954
+ * at the fresh tip.
628
955
  */
629
956
  async function repairDiscard<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
630
- const coldPending = core.pending
631
- await clearPending(core)
957
+ const coldPending = core.chain.tag === "pending" ? core.chain.batch : null
632
958
  await discardAndReopen(core)
633
959
  if (coldPending !== null) {
634
960
  await resolveColdPending(core, coldPending)
@@ -648,21 +974,22 @@ async function readdressPending<Rels extends SchemaRelations>(
648
974
  ): Promise<void> {
649
975
  const first = ops[0]
650
976
  if (first === undefined) {
651
- await clearPending(core)
977
+ await settle(core)
652
978
  return
653
979
  }
654
980
  const relation = core.descriptor.relationByName.get(first.relation)
655
- const braid = relation === undefined ? undefined : core.descriptor.braidOfRelation.get(relation.id)
656
- if (braid === undefined) {
657
- await clearPending(core)
981
+ const raw = relation === undefined ? undefined : core.descriptor.braidOfRelation.get(relation.id)
982
+ if (raw === undefined) {
983
+ await settle(core)
658
984
  return
659
985
  }
986
+ const braid = braidOf(core.theory, raw)
660
987
  const entry = chainEntry(core, braid)
661
988
  const timestamp = maxBigint(BigInt(Date.now()), entry.ts)
662
989
  const bytes = encodeBatch(
663
990
  core.descriptor,
664
991
  {
665
- fingerprint: core.descriptor.fingerprint,
992
+ fingerprint: digest32(core.descriptor.fingerprintBytes),
666
993
  braid,
667
994
  braidGen: generation(entry.g + 1n),
668
995
  prev: entry.prev,
@@ -671,9 +998,7 @@ async function readdressPending<Rels extends SchemaRelations>(
671
998
  },
672
999
  ops
673
1000
  )
674
- core.pending = { braid, gen: generation(entry.g + 1n), bytes }
675
- core.pendingOps = ops
676
- core.pendingApplied = true
1001
+ holdPending(core, { braid, gen: generation(entry.g + 1n), bytes }, ops)
677
1002
  await persistSidecar(core)
678
1003
  }
679
1004
 
@@ -683,35 +1008,39 @@ function maxBigint(a: bigint, b: bigint): bigint {
683
1008
 
684
1009
  function vectorOf<Rels extends SchemaRelations>(core: Core<Rels>): ReadonlyMap<Braid, Generation> {
685
1010
  const vector = new Map<Braid, Generation>()
686
- for (const [id, entry] of core.chain) {
1011
+ for (const [id, entry] of core.chain.entries) {
687
1012
  vector.set(id, entry.g)
688
1013
  }
689
1014
  return vector
690
1015
  }
691
1016
 
692
- function dominates(have: ReadonlyMap<Braid, Generation>, want: ReadonlyMap<Braid, Generation>): boolean {
693
- for (const [braid, generation] of want) {
694
- if ((have.get(braid) ?? -1n) < generation) {
695
- return false
696
- }
1017
+ async function refreshPass<Rels extends SchemaRelations>(core: Core<Rels>, braid?: Braid): Promise<RefreshOutcome> {
1018
+ const braids = braid === undefined ? allBraids(core) : [braidOf(core.theory, braid)]
1019
+ const outcome = await runPass(core, braids, "steady")
1020
+ if (outcome.tag === "reseed") {
1021
+ await repairDiscard(core)
1022
+ return { tag: "reseed", cause: outcome.cause }
697
1023
  }
698
- return true
1024
+ return outcome
699
1025
  }
700
1026
 
701
- async function refreshPass<Rels extends SchemaRelations>(core: Core<Rels>, braid?: Braid): Promise<void> {
702
- core.passes += 1
703
- if (core.passes % HEARTBEAT_PASSES === 0) {
704
- await refreshManifest(core, false)
705
- }
706
- if (braid !== undefined) {
707
- chainEntry(core, braid)
708
- if ((await catchUpBraid(core, braid, "steady")) === "discard") {
709
- await repairDiscard(core)
1027
+ /** waitFor is refresh with a predicate: the same pass, then the check. */
1028
+ async function refreshUntil<Rels extends SchemaRelations>(core: Core<Rels>, predicate: () => boolean): Promise<void> {
1029
+ for (;;) {
1030
+ disposed(core)
1031
+ if (predicate()) {
1032
+ return
710
1033
  }
711
- return
712
- }
713
- if ((await catchUpAll(core, "steady")) === "discard") {
714
- await repairDiscard(core)
1034
+ await withGate(core, async function waitPass() {
1035
+ disposed(core)
1036
+ await refreshPass(core)
1037
+ })
1038
+ if (predicate()) {
1039
+ return
1040
+ }
1041
+ await new Promise(function later(resolve) {
1042
+ setTimeout(resolve, WAIT_FOR_POLL_MS)
1043
+ })
715
1044
  }
716
1045
  }
717
1046
 
@@ -719,9 +1048,7 @@ async function openReplica<Rels extends SchemaRelations>(options: OpenReplicaOpt
719
1048
  const core = await openCore(options)
720
1049
  const replica: Replica<Rels> = {
721
1050
  get db() {
722
- if (core.closed) {
723
- throw errors.new("replica is disposed")
724
- }
1051
+ disposed(core)
725
1052
  return core.db
726
1053
  },
727
1054
  get vector() {
@@ -729,9 +1056,7 @@ async function openReplica<Rels extends SchemaRelations>(options: OpenReplicaOpt
729
1056
  },
730
1057
  async refresh(braid?: Braid) {
731
1058
  return withGate(core, async function refreshBody() {
732
- if (core.closed) {
733
- throw errors.new("replica is disposed")
734
- }
1059
+ disposed(core)
735
1060
  await refreshPass(core, braid)
736
1061
  return vectorOf(core)
737
1062
  })
@@ -740,24 +1065,9 @@ async function openReplica<Rels extends SchemaRelations>(options: OpenReplicaOpt
740
1065
  for (const braid of vector.keys()) {
741
1066
  chainEntry(core, braid)
742
1067
  }
743
- for (;;) {
744
- if (dominates(vectorOf(core), vector)) {
745
- return
746
- }
747
- await withGate(core, async function waitPass() {
748
- for (const braid of vector.keys()) {
749
- if ((await catchUpBraid(core, braid, "steady")) === "discard") {
750
- await repairDiscard(core)
751
- }
752
- }
753
- })
754
- if (dominates(vectorOf(core), vector)) {
755
- return
756
- }
757
- await new Promise(function later(resolve) {
758
- setTimeout(resolve, WAIT_FOR_POLL_MS)
759
- })
760
- }
1068
+ await refreshUntil(core, function reached() {
1069
+ return Vector.from(vectorOf(core)).dominates(Vector.from(vector))
1070
+ })
761
1071
  },
762
1072
  async [Symbol.asyncDispose]() {
763
1073
  await withGate(core, async function disposeBody() {
@@ -770,21 +1080,24 @@ async function openReplica<Rels extends SchemaRelations>(options: OpenReplicaOpt
770
1080
  return replica
771
1081
  }
772
1082
 
773
- export type { ApplyPhase, Core, OpenReplicaOptions, Replica, SlotApply }
1083
+ export type { ApplyPhase, Core, OpenReplicaOptions, PendingFold, RefreshOutcome, Replica, ReplicaState, SlotApply }
774
1084
  export {
775
1085
  applyOps,
776
1086
  applySlot,
1087
+ belowFloor,
777
1088
  blake3Hex,
778
- catchUpBraid,
779
1089
  chainEntry,
780
- chainSum,
1090
+ chainOf,
781
1091
  clearPending,
782
1092
  coreOf,
783
1093
  discardAndReopen,
1094
+ entriesOf,
1095
+ foldPending,
784
1096
  generationOf,
1097
+ holdPending,
785
1098
  maxBigint,
786
1099
  openReplica,
787
- pendingTerm,
1100
+ pendingOf,
788
1101
  persistSidecar,
789
1102
  readdressPending,
790
1103
  vectorOf,