@bjornpagen/bumbledb-log 0.17.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,29 +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, PendingBatch } from "#chain.ts"
18
- import { readSidecar, writeSidecar } from "#chain.ts"
19
- import type { BatchOp } from "#codec.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"
21
+ import type { Op } from "#codec.ts"
20
22
  import { decodeBatch, encodeBatch, verifyChain } from "#codec.ts"
21
- import type { LogDescriptor, RelationInfo } from "#descriptor.ts"
23
+ import type { Braid, Descriptor, RelationInfo } from "#descriptor.ts"
22
24
  import { descriptorOf } from "#descriptor.ts"
23
- import { ErrGapDetected, ErrReplayDiverged, refuse } from "#errors.ts"
24
- import { checkpointJsonKey, checkpointMdbKey, logKey, manifestKey } from "#keys.ts"
25
+ import { ErrRefused, ErrReplayDiverged, refuse, refuseManifestMissing, wrapStore } from "#errors.ts"
26
+ import type { Generation } 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"
25
38
  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"
39
+ import { auditCatalog, parseCheckpoint, parseManifest } from "#manifest.ts"
40
+ import type { Etag, ObjectStore } from "#store.ts"
41
+ import type { Value } from "#value.ts"
42
+ import { Vector } from "#vector.ts"
29
43
 
30
- const ZERO_HASH = "0".repeat(64)
44
+ const ZERO_HASH = digest32(new Uint8Array(32))
31
45
 
32
- /** 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. */
33
47
  const HEARTBEAT_PASSES = 16
34
48
 
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. */
49
+ /** The re-poll cadence of waitFor, its one consumer. */
37
50
  const WAIT_FOR_POLL_MS = 20
38
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
+
39
73
  interface OpenReplicaOptions<Rels extends SchemaRelations> {
40
74
  readonly store: ObjectStore
41
75
  readonly prefix: string
@@ -45,9 +79,9 @@ interface OpenReplicaOptions<Rels extends SchemaRelations> {
45
79
 
46
80
  interface Replica<Rels extends SchemaRelations> extends AsyncDisposable {
47
81
  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>
82
+ readonly vector: ReadonlyMap<Braid, Generation>
83
+ refresh(braid?: Braid): Promise<ReadonlyMap<Braid, Generation>>
84
+ waitFor(vector: ReadonlyMap<Braid, Generation>): Promise<void>
51
85
  }
52
86
 
53
87
  interface Core<Rels extends SchemaRelations> {
@@ -55,19 +89,21 @@ interface Core<Rels extends SchemaRelations> {
55
89
  readonly prefix: string
56
90
  readonly dir: string
57
91
  readonly theory: Schema<Rels>
58
- readonly descriptor: LogDescriptor
92
+ readonly descriptor: Descriptor
59
93
  db: Db<Rels>
60
- chain: Map<string, ChainEntry>
61
- pending: PendingBatch | null
62
- pendingOps: readonly BatchOp[] | null
63
- pendingApplied: boolean
64
- manifestEtag: string | null
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
98
+ manifestEtag: Etag | null
65
99
  checkpoint: CheckpointFacts | null
66
- checkpointDigest: string | null
100
+ checkpointDigest: Digest32 | null
67
101
  passes: number
68
102
  closed: boolean
69
103
  storeName: string
70
104
  gate: Promise<unknown>
105
+ wedged: Map<Braid, string>
106
+ provenance: ReplicaState
71
107
  }
72
108
 
73
109
  const cores = new WeakMap<object, Core<SchemaRelations>>()
@@ -98,8 +134,12 @@ function blake3Hex(bytes: Uint8Array): string {
98
134
  return toHex(new Uint8Array(internalBlake3(bytes)))
99
135
  }
100
136
 
137
+ function blake3Digest(bytes: Uint8Array): Digest32 {
138
+ return digest32(new Uint8Array(internalBlake3(bytes)))
139
+ }
140
+
101
141
  function sidecarPath<Rels extends SchemaRelations>(core: Core<Rels>): string {
102
- return path.join(core.dir, "chain.json")
142
+ return path.join(core.dir, "chain")
103
143
  }
104
144
 
105
145
  let storeSequence = 0
@@ -117,18 +157,33 @@ function storePath<Rels extends SchemaRelations>(core: Core<Rels>): string {
117
157
  return path.join(core.dir, core.storeName)
118
158
  }
119
159
 
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 })
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
+
170
+ function zeroChain(descriptor: Descriptor): Map<Braid, ChainEntry> {
171
+ const chain = new Map<Braid, ChainEntry>()
172
+ for (const id of descriptor.braidMembers.keys()) {
173
+ chain.set(id, { g: generation(0n), prev: ZERO_HASH, ts: 0n })
124
174
  }
125
175
  return chain
126
176
  }
127
177
 
128
- function chainEntry<Rels extends SchemaRelations>(core: Core<Rels>, braid: string): ChainEntry {
129
- const entry = core.chain.get(braid)
178
+ function entriesOf<Rels extends SchemaRelations>(core: Core<Rels>): Map<Braid, ChainEntry> {
179
+ return core.chain.entries as Map<Braid, ChainEntry>
180
+ }
181
+
182
+ function chainEntry<Rels extends SchemaRelations>(core: Core<Rels>, braid: Braid): ChainEntry {
183
+ const id = braidOf(core.theory, braid)
184
+ const entry = entriesOf(core).get(id)
130
185
  if (entry === undefined) {
131
- throw errors.new(`braid ${braid} is not derived from this theory`)
186
+ throw errors.new(`braid ${id} is not derived from this theory`)
132
187
  }
133
188
  return entry
134
189
  }
@@ -139,34 +194,139 @@ function generationOf<Rels extends SchemaRelations>(core: Core<Rels>): bigint {
139
194
  })
140
195
  }
141
196
 
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
197
+ function digestOfCatalog(raw: unknown): Digest32 {
198
+ if (raw instanceof Uint8Array) {
199
+ return digest32(raw)
146
200
  }
147
- return sum
201
+ throw errors.new(`catalogDigest is not a digest: ${typeof raw}`)
148
202
  }
149
203
 
150
- function pendingTerm<Rels extends SchemaRelations>(core: Core<Rels>): bigint {
151
- 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
152
258
  }
153
259
 
154
260
  async function persistSidecar<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
155
- await writeSidecar(sidecarPath(core), { chain: core.chain, pending: core.pending })
261
+ await writeSidecar(sidecarPath(core), chainOf(core))
156
262
  }
157
263
 
158
- async function clearPending<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
159
- core.pending = null
160
- core.pendingOps = null
161
- 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)
162
289
  await persistSidecar(core)
163
290
  }
164
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
+
165
325
  /** Positional decoded row → the SDK's named fact, handles lifted for closed refs. */
166
326
  function factOf<Rels extends SchemaRelations>(
167
327
  core: Core<Rels>,
168
328
  relation: RelationInfo,
169
- row: readonly LogValue[]
329
+ row: readonly Value[]
170
330
  ): Record<string, unknown> {
171
331
  const fact: Record<string, unknown> = {}
172
332
  relation.fields.forEach(function liftCell(field, ordinal) {
@@ -191,7 +351,7 @@ function factOf<Rels extends SchemaRelations>(
191
351
  }
192
352
 
193
353
  /** 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> {
354
+ function applyOps<Rels extends SchemaRelations>(core: Core<Rels>, ops: readonly Op[]): WriteOutcome<Rels, number> {
195
355
  return core.db.write(function applyBatch(tx) {
196
356
  for (const op of ops) {
197
357
  const info = core.descriptor.relationByName.get(op.relation)
@@ -213,20 +373,17 @@ function applyOps<Rels extends SchemaRelations>(core: Core<Rels>, ops: readonly
213
373
  })
214
374
  }
215
375
 
216
- type ApplyPhase = "open" | "steady"
217
-
218
- type SlotApply = { readonly tag: "applied"; readonly generation: bigint } | { readonly tag: "discard" }
219
-
220
376
  /**
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.
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.
225
382
  */
226
383
  async function applySlot<Rels extends SchemaRelations>(
227
384
  core: Core<Rels>,
228
- braid: string,
229
- slot: bigint,
385
+ braid: Braid,
386
+ slot: Generation,
230
387
  bytes: Uint8Array,
231
388
  phase: ApplyPhase
232
389
  ): Promise<SlotApply> {
@@ -240,122 +397,228 @@ async function applySlot<Rels extends SchemaRelations>(
240
397
  }
241
398
  throw errors.wrap(ErrReplayDiverged, `braid ${braid} slot ${slot} writer ${decoded.header.writer}`)
242
399
  }
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) {
400
+ const identity = chainSum(core.chain) - entry.g + slot
401
+ if (outcome.value.generation < identity) {
247
402
  refuse(
248
403
  { kind: "NoOpSlot", braid, slot, writer: decoded.header.writer },
249
404
  `braid ${braid} slot ${slot}: a first-applied slot changed nothing — publish-law violation by writer ${decoded.header.writer}`
250
405
  )
251
406
  }
407
+ entriesOf(core).set(braid, { g: slot, prev: blake3Digest(bytes), ts: decoded.header.timestamp })
408
+ await persistSidecar(core)
252
409
  return { tag: "applied", generation: outcome.value.generation }
253
410
  }
254
411
 
255
412
  /**
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.
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.
258
415
  */
259
- function holeAt<Rels extends SchemaRelations>(core: Core<Rels>, braid: string, next: bigint): boolean {
416
+ function belowFloor<Rels extends SchemaRelations>(core: Core<Rels>, braid: Braid, slot: Generation): boolean {
417
+ const id = braidOf(core.theory, braid)
260
418
  if (core.checkpoint === null) {
261
419
  return false
262
420
  }
263
- const floor = core.checkpoint.braids.get(braid)
421
+ const floor = core.checkpoint.braids.get(id)
264
422
  if (floor === undefined) {
265
423
  return false
266
424
  }
267
- 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)
268
480
  }
269
481
 
270
- 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>(
271
487
  core: Core<Rels>,
272
- braid: string,
488
+ braid: Braid,
273
489
  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)
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" }
297
502
  }
298
- const applied = await applySlot(core, braid, next, fetched.bytes, phase)
299
- if (applied.tag === "discard") {
300
- return "discard"
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" }
301
509
  }
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 }
517
+ }
518
+ throw applied.error
519
+ }
520
+ if (applied.data.tag === "discard") {
521
+ return { tag: "reseed", cause: "rejected-in-open" }
302
522
  }
523
+ return { tag: "applied" }
303
524
  }
304
525
 
305
- 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>(
306
531
  core: Core<Rels>,
532
+ braids: readonly Braid[],
307
533
  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"
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
313
559
  }
314
560
  }
315
- 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()]
316
572
  }
317
573
 
318
574
  function wholenessHolds<Rels extends SchemaRelations>(core: Core<Rels>): boolean {
319
- return generationOf(core) === chainSum(core) + pendingTerm(core)
575
+ return generationOf(core) === chainGeneration(core.chain)
320
576
  }
321
577
 
322
578
  async function adoptManifest<Rels extends SchemaRelations>(
323
579
  core: Core<Rels>,
324
580
  bytes: Uint8Array,
325
- etag: string
581
+ etag: Etag
326
582
  ): Promise<void> {
327
583
  const manifest = parseManifest(bytes)
328
- if (manifest.fingerprint !== core.descriptor.fingerprint) {
584
+ if (!bytesEqual(manifest.fingerprint, digest32(core.descriptor.fingerprintBytes))) {
329
585
  refuse(
330
- { kind: "FingerprintMismatch", carried: manifest.fingerprint, expected: core.descriptor.fingerprint },
586
+ {
587
+ kind: "FingerprintMismatch",
588
+ carried: hex32(manifest.fingerprint),
589
+ expected: core.descriptor.fingerprint
590
+ },
331
591
  "the store's manifest names a different theory"
332
592
  )
333
593
  }
334
- core.manifestEtag = etag
335
594
  if (manifest.checkpoint === null) {
336
595
  core.checkpoint = null
337
596
  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")
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
352
613
  }
353
- core.checkpoint = checkpoint
354
- 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
355
617
  }
356
618
 
357
- async function refreshManifest<Rels extends SchemaRelations>(core: Core<Rels>, force: boolean): Promise<void> {
358
- 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) {
359
622
  const poll = await core.store.getIfChanged(manifestKey(core.prefix), core.manifestEtag)
360
623
  if (poll.tag === "unchanged") {
361
624
  return
@@ -364,94 +627,102 @@ async function refreshManifest<Rels extends SchemaRelations>(core: Core<Rels>, f
364
627
  return
365
628
  }
366
629
  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")
630
+ if (fetched === null) {
631
+ refuseManifestMissing("the store has no manifest")
382
632
  }
383
- await adoptManifest(core, reread.bytes, reread.etag)
633
+ await adoptManifest(core, fetched.bytes, fetched.etag)
384
634
  }
385
635
 
386
636
  /** Bootstraps a fresh local store: from the current checkpoint when one
387
- * 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. */
388
639
  async function initializeStore<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
389
640
  core.storeName = freshStoreName()
390
641
  const target = storePath(core)
391
642
  await fs.rm(target, { recursive: true, force: true })
392
643
  await fs.mkdir(core.dir, { recursive: true })
393
644
  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]) {
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)
414
657
  return [braid, { g: head.g, prev: head.hash, ts: head.ts }] as const
415
658
  })
416
659
  )
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
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
428
671
  }
429
672
  const created = await SdkDb.create(target, core.theory)
430
673
  if (created.tag === "rejected") {
431
674
  throw errors.new("the theory's ground axioms were rejected at bootstrap")
432
675
  }
433
676
  core.db = created.value
434
- core.chain = zeroChain(core.descriptor)
677
+ core.chain = { tag: "settled", entries: zeroChain(core.descriptor) }
678
+ core.provenance = { tag: "bootstrapped" }
435
679
  await persistSidecar(core)
436
680
  }
437
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
+
438
701
  /** The disposable law: the directory is cache, never truth. The local
439
702
  * 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. */
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. */
441
706
  async function discardAndReopen<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
442
- for (let attempt = 0; attempt < 3; attempt++) {
707
+ const scream = screamOf("replica discard-and-re-pull")
708
+ for (;;) {
443
709
  const old = storePath(core)
444
710
  await fs.rm(old, { recursive: true, force: true })
711
+ core.wedged.clear()
445
712
  await initializeStore(core)
446
- const outcome = await catchUpAll(core, "open")
447
- if (outcome === "discard") {
713
+ const outcome = await runPass(core, allBraids(core), "open")
714
+ if (outcome.tag === "reseed") {
715
+ scream.attempt(outcome.cause)
448
716
  continue
449
717
  }
450
- 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)) {
451
722
  return
452
723
  }
724
+ scream.attempt("wholeness")
453
725
  }
454
- throw errors.new("the store failed to reach wholeness after repeated re-pulls")
455
726
  }
456
727
 
457
728
  async function newestStoreDir(dir: string): Promise<string | null> {
@@ -474,6 +745,26 @@ async function newestStoreDir(dir: string): Promise<string | null> {
474
745
  return newest
475
746
  }
476
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
+
477
768
  /** The disposable law says cache directories do not hoard corpses: every
478
769
  * rotated `store-*` LMDB dir except the adopted one is dead — left by a
479
770
  * crashed process or a prior rotation — and is swept at open. */
@@ -491,35 +782,48 @@ async function sweepRotations<Rels extends SchemaRelations>(core: Core<Rels>): P
491
782
  }
492
783
 
493
784
  /**
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.
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.
499
788
  */
500
789
  async function resolvePendingAtOpen<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
501
- 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)
502
805
  return
503
806
  }
504
807
  const decoded = errors.trySync(function decodePending() {
505
- return decodeBatch(core.descriptor, (core.pending as PendingBatch).bytes)
808
+ return decodeBatch(core.descriptor, chain.batch.bytes)
506
809
  })
507
810
  if (decoded.error) {
508
- await clearPending(core)
811
+ await settle(core)
509
812
  return
510
813
  }
511
- const before = generationOf(core)
512
814
  const outcome = applyOps(core, decoded.data.ops)
513
815
  if (outcome.tag === "rejected") {
514
- await clearPending(core)
816
+ await settle(core)
515
817
  return
516
818
  }
517
- if (outcome.value.generation === before && before === chainSum(core)) {
518
- 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)
519
824
  return
520
825
  }
521
- core.pendingOps = decoded.data.ops
522
- core.pendingApplied = true
826
+ holdPending(core, chain.batch, decoded.data.ops)
523
827
  }
524
828
 
525
829
  /**
@@ -527,21 +831,26 @@ async function resolvePendingAtOpen<Rels extends SchemaRelations>(core: Core<Rel
527
831
  * unopenable (or discarded), so the sidecar's pending is resolved
528
832
  * against a freshly caught-up store — a byte-equal published slot
529
833
  * absorbs it (L10's idempotent replay), and everything else takes the
530
- * 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).
531
836
  */
532
- async function resolveColdPending<Rels extends SchemaRelations>(
533
- core: Core<Rels>,
534
- pending: PendingBatch
535
- ): Promise<void> {
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
+ }
536
846
  const decoded = errors.trySync(function decodePending() {
537
847
  return decodeBatch(core.descriptor, pending.bytes)
538
848
  })
539
849
  if (decoded.error) {
540
850
  return
541
851
  }
542
- const braid = decoded.data.header.braid
543
852
  const slot = decoded.data.header.braidGen
544
- if (core.chain.has(braid) && chainEntry(core, braid).g >= slot) {
853
+ if (entriesOf(core).has(braid) && chainEntry(core, braid).g >= slot) {
545
854
  const published = await core.store.get(logKey(core.prefix, braid, slot))
546
855
  if (published !== null && bytesEqual(published.bytes, pending.bytes)) {
547
856
  return
@@ -550,8 +859,7 @@ async function resolveColdPending<Rels extends SchemaRelations>(
550
859
  const before = generationOf(core)
551
860
  const outcome = applyOps(core, decoded.data.ops)
552
861
  if (outcome.tag === "accepted" && outcome.value.generation > before) {
553
- core.pendingOps = decoded.data.ops
554
- core.pendingApplied = true
862
+ holdPending(core, { ...pending, braid }, decoded.data.ops)
555
863
  await readdressPending(core, decoded.data.ops, decoded.data.header.writer)
556
864
  }
557
865
  }
@@ -565,42 +873,56 @@ async function openCore<Rels extends SchemaRelations>(options: OpenReplicaOption
565
873
  theory: options.theory,
566
874
  descriptor,
567
875
  db: undefined as unknown as Db<Rels>,
568
- chain: zeroChain(descriptor),
569
- pending: null,
570
- pendingOps: null,
571
- pendingApplied: false,
876
+ chain: { tag: "settled", entries: zeroChain(descriptor) },
572
877
  manifestEtag: null,
573
878
  checkpoint: null,
574
879
  checkpointDigest: null,
575
880
  passes: 0,
576
881
  closed: false,
577
882
  storeName: freshStoreName(),
578
- gate: Promise.resolve()
883
+ gate: Promise.resolve(),
884
+ wedged: new Map(),
885
+ provenance: { tag: "bootstrapped" }
579
886
  }
580
887
  await fs.mkdir(core.dir, { recursive: true })
581
- 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)
582
893
 
583
894
  const existing = await newestStoreDir(core.dir)
584
895
  const sidecar = await readSidecar(sidecarPath(core))
585
896
  let opened = false
586
- let coldPending: PendingBatch | null = null
587
- if (existing !== null) {
897
+ let coldPending: Pending | null = 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") {
588
912
  core.storeName = existing
589
913
  const openedDb = await errors.try(SdkDb.open(storePath(core), options.theory))
590
- if (openedDb.error === undefined && sidecar !== null) {
914
+ if (openedDb.error === undefined) {
591
915
  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
916
+ adoptChain(core, sidecar.chain)
917
+ core.provenance = { tag: "sidecar-resumed", floor: vectorOf(core) }
599
918
  opened = true
600
919
  }
601
920
  }
602
921
  if (!opened) {
603
- 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
+ }
604
926
  await initializeStore(core)
605
927
  }
606
928
 
@@ -608,29 +930,31 @@ async function openCore<Rels extends SchemaRelations>(options: OpenReplicaOption
608
930
  await resolvePendingAtOpen(core)
609
931
  }
610
932
 
611
- const outcome = await catchUpAll(core, "open")
612
- if (outcome === "discard" || !wholenessHolds(core)) {
613
- coldPending = core.pending ?? coldPending
614
- 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
615
936
  await discardAndReopen(core)
616
937
  }
617
938
 
618
939
  if (coldPending !== null) {
619
940
  await resolveColdPending(core, coldPending)
620
941
  }
942
+ if (atCheckpointFloor(core)) {
943
+ auditCatalog(core.checkpoint, catalogDigestOf(core))
944
+ }
621
945
  await sweepRotations(core)
946
+ await sweepReservedKeys(core)
622
947
  return core
623
948
  }
624
949
 
625
950
  /**
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.
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.
630
955
  */
631
956
  async function repairDiscard<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
632
- const coldPending = core.pending
633
- await clearPending(core)
957
+ const coldPending = core.chain.tag === "pending" ? core.chain.batch : null
634
958
  await discardAndReopen(core)
635
959
  if (coldPending !== null) {
636
960
  await resolveColdPending(core, coldPending)
@@ -645,37 +969,36 @@ async function repairDiscard<Rels extends SchemaRelations>(core: Core<Rels>): Pr
645
969
  */
646
970
  async function readdressPending<Rels extends SchemaRelations>(
647
971
  core: Core<Rels>,
648
- ops: readonly BatchOp[],
972
+ ops: readonly Op[],
649
973
  writerId: bigint
650
974
  ): Promise<void> {
651
975
  const first = ops[0]
652
976
  if (first === undefined) {
653
- await clearPending(core)
977
+ await settle(core)
654
978
  return
655
979
  }
656
980
  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)
981
+ const raw = relation === undefined ? undefined : core.descriptor.braidOfRelation.get(relation.id)
982
+ if (raw === undefined) {
983
+ await settle(core)
660
984
  return
661
985
  }
986
+ const braid = braidOf(core.theory, raw)
662
987
  const entry = chainEntry(core, braid)
663
988
  const timestamp = maxBigint(BigInt(Date.now()), entry.ts)
664
989
  const bytes = encodeBatch(
665
990
  core.descriptor,
666
991
  {
667
- fingerprint: core.descriptor.fingerprint,
992
+ fingerprint: digest32(core.descriptor.fingerprintBytes),
668
993
  braid,
669
- braidGen: entry.g + 1n,
994
+ braidGen: generation(entry.g + 1n),
670
995
  prev: entry.prev,
671
996
  writer: writerId,
672
997
  timestamp
673
998
  },
674
999
  ops
675
1000
  )
676
- core.pending = { braid, gen: entry.g + 1n, bytes }
677
- core.pendingOps = ops
678
- core.pendingApplied = true
1001
+ holdPending(core, { braid, gen: generation(entry.g + 1n), bytes }, ops)
679
1002
  await persistSidecar(core)
680
1003
  }
681
1004
 
@@ -683,37 +1006,41 @@ function maxBigint(a: bigint, b: bigint): bigint {
683
1006
  return a > b ? a : b
684
1007
  }
685
1008
 
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)
1009
+ function vectorOf<Rels extends SchemaRelations>(core: Core<Rels>): ReadonlyMap<Braid, Generation> {
1010
+ const vector = new Map<Braid, Generation>()
1011
+ for (const [id, entry] of core.chain.entries) {
1012
+ vector.set(id, entry.g)
690
1013
  }
691
1014
  return vector
692
1015
  }
693
1016
 
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
- }
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 }
699
1023
  }
700
- return true
1024
+ return outcome
701
1025
  }
702
1026
 
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)
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
712
1033
  }
713
- return
714
- }
715
- if ((await catchUpAll(core, "steady")) === "discard") {
716
- 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
+ })
717
1044
  }
718
1045
  }
719
1046
 
@@ -721,19 +1048,15 @@ async function openReplica<Rels extends SchemaRelations>(options: OpenReplicaOpt
721
1048
  const core = await openCore(options)
722
1049
  const replica: Replica<Rels> = {
723
1050
  get db() {
724
- if (core.closed) {
725
- throw errors.new("replica is disposed")
726
- }
1051
+ disposed(core)
727
1052
  return core.db
728
1053
  },
729
1054
  get vector() {
730
1055
  return vectorOf(core)
731
1056
  },
732
- async refresh(braid?: string) {
1057
+ async refresh(braid?: Braid) {
733
1058
  return withGate(core, async function refreshBody() {
734
- if (core.closed) {
735
- throw errors.new("replica is disposed")
736
- }
1059
+ disposed(core)
737
1060
  await refreshPass(core, braid)
738
1061
  return vectorOf(core)
739
1062
  })
@@ -742,24 +1065,9 @@ async function openReplica<Rels extends SchemaRelations>(options: OpenReplicaOpt
742
1065
  for (const braid of vector.keys()) {
743
1066
  chainEntry(core, braid)
744
1067
  }
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
- }
1068
+ await refreshUntil(core, function reached() {
1069
+ return Vector.from(vectorOf(core)).dominates(Vector.from(vector))
1070
+ })
763
1071
  },
764
1072
  async [Symbol.asyncDispose]() {
765
1073
  await withGate(core, async function disposeBody() {
@@ -772,21 +1080,24 @@ async function openReplica<Rels extends SchemaRelations>(options: OpenReplicaOpt
772
1080
  return replica
773
1081
  }
774
1082
 
775
- export type { ApplyPhase, Core, OpenReplicaOptions, Replica, SlotApply }
1083
+ export type { ApplyPhase, Core, OpenReplicaOptions, PendingFold, RefreshOutcome, Replica, ReplicaState, SlotApply }
776
1084
  export {
777
1085
  applyOps,
778
1086
  applySlot,
1087
+ belowFloor,
779
1088
  blake3Hex,
780
- catchUpBraid,
781
1089
  chainEntry,
782
- chainSum,
1090
+ chainOf,
783
1091
  clearPending,
784
1092
  coreOf,
785
1093
  discardAndReopen,
1094
+ entriesOf,
1095
+ foldPending,
786
1096
  generationOf,
1097
+ holdPending,
787
1098
  maxBigint,
788
1099
  openReplica,
789
- pendingTerm,
1100
+ pendingOf,
790
1101
  persistSidecar,
791
1102
  readdressPending,
792
1103
  vectorOf,