@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/writer.ts CHANGED
@@ -1,48 +1,88 @@
1
1
  /**
2
- * The writer (60): a replica plus the right to create log objects. One
3
- * commit path and one loss path: a lost slot's byte-equal occupant is
4
- * an ambiguous PUT absorbed; anything else discards the local
5
- * directory, re-opens through the replica to the current tip, and
6
- * re-judges the recorded ops once — the verdict IS a serial execution,
7
- * performed. Each loop iteration races once at the then-tip, so a
8
- * historical loss is structurally uncountable, and bounded live-tip
9
- * losses surface as ErrContention carrying the terminal re-judgment's
10
- * own violation or the racing tip.
2
+ * The writer (60): a replica plus the right to create log objects. Role
3
+ * is a field on the handle. `openWriter(options)` births the store;
4
+ * `openWriter(replica)` wraps a born replica and settles an inherited
5
+ * pending without drawing id leases. A replica never births —
6
+ * ManifestMissing is its refusal. One commit path and one loss path: a
7
+ * lost slot's byte-equal occupant is an ambiguous PUT absorbed; anything
8
+ * else discards the local directory, re-opens through the replica to the
9
+ * current tip, and re-judges the recorded ops once — the verdict IS a
10
+ * serial execution, performed. Each loop iteration races once at the
11
+ * then-tip, so a historical loss is structurally uncountable, and bounded
12
+ * live-tip losses surface as ErrContention carrying the terminal
13
+ * re-judgment's own violation or the racing tip.
11
14
  */
12
15
 
13
16
  import * as crypto from "node:crypto"
14
- import type { Fact, FreshKeys, MemberRelation, SchemaRelations, Violation } from "@bjornpagen/bumbledb"
17
+ import * as fs from "node:fs/promises"
18
+ import * as path from "node:path"
19
+ import {
20
+ type Fact,
21
+ type FreshKeys,
22
+ internalBlake3,
23
+ type MemberRelation,
24
+ type SchemaRelations,
25
+ type Violation
26
+ } from "@bjornpagen/bumbledb"
15
27
  import * as errors from "@superbuilders/errors"
16
- import { bytesEqual, utf8Encoder, utf8StrictDecoder } from "#bytes.ts"
28
+ import { regex } from "arkregex"
29
+ import type { Digest32 } from "#bytes.ts"
30
+ import { bytesEqual, checkedAddU64, digest32, digest32FromHex, utf8Encoder, utf8StrictDecoder } from "#bytes.ts"
31
+ import { chainSum } from "#chain.ts"
17
32
  import type { Op } from "#codec.ts"
18
33
  import { decodeBatch, encodeBatch } from "#codec.ts"
19
34
  import type { Braid, RelationInfo } from "#descriptor.ts"
20
- import { ErrSpanningCommit, throwContention } from "#errors.ts"
35
+ import { descriptorOf } from "#descriptor.ts"
36
+ import { ErrSpanningCommit, refuseExhausted, refuseOverWidth, refuseSlotRetired, throwContention } from "#errors.ts"
21
37
  import type { Generation } from "#keys.ts"
22
- import { generation, idsKey, logKey } from "#keys.ts"
23
- import type { Core, Replica } from "#replica.ts"
38
+ import {
39
+ CKPT_SCRATCH_LEASE,
40
+ checkpointMdbKey,
41
+ ckptDocKey,
42
+ encodeCkptScratch,
43
+ generation,
44
+ idsKey,
45
+ LEASE_NAMESPACE,
46
+ logKey,
47
+ manifestKey
48
+ } from "#keys.ts"
49
+ import type { CheckpointFacts } from "#manifest.ts"
50
+ import { checkpointVector, parseCheckpoint, parseManifest, renderCheckpoint, renderManifest } from "#manifest.ts"
51
+ import type { Core, OpenReplicaOptions, Replica } from "#replica.ts"
24
52
  import {
25
53
  applyOps,
26
- blake3Hex,
54
+ belowFloor,
27
55
  chainEntry,
28
56
  clearPending,
29
57
  coreOf,
30
58
  discardAndReopen,
59
+ entriesOf,
60
+ foldPending,
31
61
  generationOf,
62
+ holdPending,
32
63
  maxBigint,
64
+ openReplica,
65
+ pendingOf,
33
66
  persistSidecar,
34
67
  readdressPending,
35
68
  withGate
36
69
  } from "#replica.ts"
70
+ import type { ObjectStore } from "#store.ts"
37
71
  import type { Value } from "#value.ts"
38
72
  import { checkAgainst } from "#value.ts"
39
73
 
40
74
  /** 10 owns the width: one CAS amortizes counter traffic 4096× below slot traffic. */
41
75
  const LEASE_WIDTH = 4096n
76
+ const ID_LEASE_COUNTER = regex("^\\d+$")
42
77
 
43
78
  /** The live-loss bound (60): consecutive losses at the live tip, history never counts. */
44
79
  const LOSS_BOUND = 16
45
80
 
81
+ /** Writer id in the fixed-layout header: magic + version + flags + fingerprint + braid + braid_gen + prev. */
82
+ const WRITER_AT = 4 + 2 + 2 + 32 + 4 + 8 + 32
83
+
84
+ const BATCH_MAGIC = utf8Encoder.encode("BDBL")
85
+
46
86
  type Durability = "published" | "local-pending"
47
87
 
48
88
  type Commit<Rels extends SchemaRelations, R> =
@@ -84,9 +124,20 @@ interface Batch<Rels extends SchemaRelations> {
84
124
  ): readonly bigint[]
85
125
  }
86
126
 
127
+ /** Ownership of a slot, read from the winner's header. */
128
+ interface Deposition {
129
+ readonly braid: Braid
130
+ readonly slot: Generation
131
+ readonly resident: bigint
132
+ readonly usurper: bigint
133
+ }
134
+
87
135
  interface Writer<Rels extends SchemaRelations> {
88
- commit<R>(body: (batch: Batch<Rels>) => R): Promise<Commit<Rels, R>>
89
- commitSplit<R>(body: (batch: Batch<Rels>) => R): Promise<CommitSplit<Rels, R>>
136
+ readonly role: "writer"
137
+ readonly replica: Replica<Rels>
138
+ deposition(): Deposition | null
139
+ commit<R>(body: (batch: Batch<Rels>) => R | Promise<R>): Promise<Commit<Rels, R>>
140
+ commitSplit<R>(body: (batch: Batch<Rels>) => R | Promise<R>): Promise<CommitSplit<Rels, R>>
90
141
  }
91
142
 
92
143
  interface LeaseRange {
@@ -94,37 +145,81 @@ interface LeaseRange {
94
145
  readonly end: bigint
95
146
  }
96
147
 
148
+ /** The scream of an unbounded repair loop: the set of recent signatures,
149
+ * a warning every eighth attempt, and an alarm the moment any
150
+ * signature recurs. */
151
+ interface Scream {
152
+ attempt(signature: string): void
153
+ }
154
+
155
+ function screamOf(context: string): Scream {
156
+ const seen = new Set<string>()
157
+ let attempts = 0
158
+ return {
159
+ attempt(signature) {
160
+ attempts += 1
161
+ if (seen.has(signature)) {
162
+ console.error(`bumbledb-log alarm: ${context} repair signature recurs: ${signature}`)
163
+ } else {
164
+ seen.add(signature)
165
+ }
166
+ if (attempts % 8 === 0) {
167
+ console.error(`bumbledb-log warning: ${context} repair attempt ${attempts}: ${signature}`)
168
+ }
169
+ }
170
+ }
171
+ }
172
+
97
173
  interface WriterState {
98
174
  readonly writerId: bigint
99
175
  readonly pools: Map<string, LeaseRange[]>
176
+ readonly scream: Scream
177
+ deposition: Deposition | null
100
178
  }
101
179
 
102
- const ErrLeaseDrained = errors.new("bumbledb-log lease pool drained mid-recording")
103
- const leaseDemand = new WeakMap<Error, { relation: number; field: number }>()
104
-
105
180
  function poolKey(relation: number, field: number): string {
106
181
  return `${relation}:${field}`
107
182
  }
108
183
 
184
+ function remainingOf(state: WriterState, relation: number, field: number): bigint {
185
+ const pool = state.pools.get(poolKey(relation, field)) ?? []
186
+ let remaining = 0n
187
+ for (const range of pool) {
188
+ remaining += range.end - range.next
189
+ }
190
+ return remaining
191
+ }
192
+
109
193
  function drawIds(state: WriterState, relation: number, field: number, count: bigint): bigint[] {
194
+ if (count < 0n) {
195
+ throw errors.new(`id-lease count is unsigned: ${count}`)
196
+ }
197
+ if (count > LEASE_WIDTH) {
198
+ refuseOverWidth({ requested: count }, `id-lease draw ${count} exceeds the lease width ${LEASE_WIDTH}`)
199
+ }
200
+ if (count === 0n) {
201
+ return []
202
+ }
110
203
  const pool = state.pools.get(poolKey(relation, field)) ?? []
204
+ const range = pool[0]
205
+ if (range === undefined || range.end - range.next < count) {
206
+ refuseExhausted(
207
+ { relation, field },
208
+ `id-lease relation ${relation} field ${field} cannot draw ${count} from the cached block`
209
+ )
210
+ }
211
+ if (checkedAddU64(range.next, count) === undefined) {
212
+ refuseExhausted({ relation, field }, `id-lease relation ${relation} field ${field} would leave u64`)
213
+ }
111
214
  const ids: bigint[] = []
112
215
  let remaining = count
113
- while (remaining > 0n) {
114
- const range = pool[0]
115
- if (range === undefined) {
116
- const fault = errors.wrap(ErrLeaseDrained, `relation ${relation} field ${field} needs ${remaining} more ids`)
117
- leaseDemand.set(fault, { relation, field })
118
- throw fault
119
- }
120
- while (remaining > 0n && range.next < range.end) {
121
- ids.push(range.next)
122
- range.next += 1n
123
- remaining -= 1n
124
- }
125
- if (range.next >= range.end) {
126
- pool.shift()
127
- }
216
+ while (remaining > 0n && range.next < range.end) {
217
+ ids.push(range.next)
218
+ range.next += 1n
219
+ remaining -= 1n
220
+ }
221
+ if (range.next >= range.end) {
222
+ pool.shift()
128
223
  }
129
224
  state.pools.set(poolKey(relation, field), pool)
130
225
  return ids
@@ -149,13 +244,17 @@ async function acquireLease<Rels extends SchemaRelations>(
149
244
  continue
150
245
  }
151
246
  const body = utf8StrictDecoder.decode(fetched.bytes)
152
- if (!/^\d+$/.test(body)) {
247
+ if (!ID_LEASE_COUNTER.test(body)) {
153
248
  throw errors.new(`id-lease counter ${key} is not a canonical decimal: ${body}`)
154
249
  }
155
250
  const next = BigInt(body)
156
- const swapped = await core.store.putSwap(key, utf8Encoder.encode(String(next + LEASE_WIDTH)), fetched.etag)
251
+ const end = checkedAddU64(next, LEASE_WIDTH)
252
+ if (end === undefined) {
253
+ refuseExhausted({ relation, field }, `id-lease relation ${relation} field ${field} would leave u64`)
254
+ }
255
+ const swapped = await core.store.putSwap(key, utf8Encoder.encode(String(end)), fetched.etag)
157
256
  if (swapped.tag === "swapped") {
158
- pushRange(state, relation, field, next, next + LEASE_WIDTH)
257
+ pushRange(state, relation, field, next, end)
159
258
  return
160
259
  }
161
260
  }
@@ -167,6 +266,19 @@ function pushRange(state: WriterState, relation: number, field: number, next: bi
167
266
  state.pools.set(poolKey(relation, field), pool)
168
267
  }
169
268
 
269
+ async function ensureFreshLeases<Rels extends SchemaRelations>(core: Core<Rels>, state: WriterState): Promise<void> {
270
+ for (const info of core.descriptor.relations) {
271
+ if (info.closed) {
272
+ continue
273
+ }
274
+ for (const [ordinal, field] of info.fields.entries()) {
275
+ if (field.fresh && remainingOf(state, info.id, ordinal) === 0n) {
276
+ await acquireLease(core, state, info.id, ordinal)
277
+ }
278
+ }
279
+ }
280
+ }
281
+
170
282
  interface Recording {
171
283
  readonly ops: Op[]
172
284
  }
@@ -254,29 +366,18 @@ function recorderOf<Rels extends SchemaRelations>(
254
366
  return { batch, recording }
255
367
  }
256
368
 
257
- /** Runs the recording body, refilling the id-lease pool on a drained
258
- * draw and re-running — recording is pure, so a re-run before any
259
- * judgment invokes nothing twice that the store could observe. */
369
+ /** Runs the recording body exactly once. The body is awaited to
370
+ * completion before the batch is sealed; reserve draws from the
371
+ * cached block (OverWidth | Exhausted | Drawn). */
260
372
  async function recordWithLeases<Rels extends SchemaRelations, R>(
261
373
  core: Core<Rels>,
262
374
  state: WriterState,
263
- body: (batch: Batch<Rels>) => R
375
+ body: (batch: Batch<Rels>) => R | Promise<R>
264
376
  ): Promise<{ value: R; ops: Op[] }> {
265
- for (let attempt = 0; attempt < 16; attempt++) {
266
- const { batch, recording } = recorderOf(core, state)
267
- const ran = errors.trySync(function runBody() {
268
- return body(batch)
269
- })
270
- if (ran.error === undefined) {
271
- return { value: ran.data, ops: recording.ops }
272
- }
273
- const demand = leaseDemand.get(ran.error) ?? leaseDemand.get(errors.cause(ran.error))
274
- if (demand === undefined) {
275
- throw ran.error
276
- }
277
- await acquireLease(core, state, demand.relation, demand.field)
278
- }
279
- throw errors.new("the id-lease pool could not satisfy the recording after 16 refills")
377
+ await ensureFreshLeases(core, state)
378
+ const { batch, recording } = recorderOf(core, state)
379
+ const value = await body(batch)
380
+ return { value, ops: recording.ops }
280
381
  }
281
382
 
282
383
  function braidsTouched<Rels extends SchemaRelations>(core: Core<Rels>, ops: readonly Op[]): Map<Braid, Op[]> {
@@ -297,9 +398,48 @@ function braidsTouched<Rels extends SchemaRelations>(core: Core<Rels>, ops: read
297
398
  return partitioned
298
399
  }
299
400
 
401
+ function u64leAt(bytes: Uint8Array, at: number): bigint | undefined {
402
+ if (bytes.length < at + 8) {
403
+ return undefined
404
+ }
405
+ const view = new DataView(bytes.buffer, bytes.byteOffset + at, 8)
406
+ return view.getBigUint64(0, true)
407
+ }
408
+
409
+ /** The usurper is a fact in the header. A body that refuses to decode
410
+ * does not hide the slot's owner. */
411
+ function headerWriter(bytes: Uint8Array): bigint | undefined {
412
+ if (bytes.length < BATCH_MAGIC.length || !bytesEqual(bytes.subarray(0, BATCH_MAGIC.length), BATCH_MAGIC)) {
413
+ return undefined
414
+ }
415
+ return u64leAt(bytes, WRITER_AT)
416
+ }
417
+
418
+ function headerTimestamp(bytes: Uint8Array): bigint | undefined {
419
+ return u64leAt(bytes, WRITER_AT + 8)
420
+ }
421
+
422
+ /** Header prev is 32 branded bytes. */
423
+ function digestPrev(prev: Digest32 | Uint8Array): Digest32 {
424
+ return digest32(prev)
425
+ }
426
+
427
+ function noteDeposition(state: WriterState, braid: Braid, slot: Generation, winnerBytes: Uint8Array): void {
428
+ if (state.deposition !== null) {
429
+ return
430
+ }
431
+ const usurper = headerWriter(winnerBytes)
432
+ if (usurper === undefined) {
433
+ return
434
+ }
435
+ state.deposition = { braid, slot, resident: state.writerId, usurper }
436
+ }
437
+
300
438
  /** The terminal contention scream: the re-judgment's own rejection names
301
439
  * the hot statement and carries the offending facts' raw values; an
302
- * accepted-but-outraced terminal loss carries the racing tip. */
440
+ * accepted-but-outraced terminal loss carries the racing tip. A
441
+ * rejection without a violation is refused — the empty statement is
442
+ * unrepresentable. */
303
443
  function screamContention<Rels extends SchemaRelations>(
304
444
  braid: Braid,
305
445
  rejudged:
@@ -308,13 +448,16 @@ function screamContention<Rels extends SchemaRelations>(
308
448
  ): never {
309
449
  if (rejudged.tag === "rejected") {
310
450
  const violation = rejudged.violations[0]
451
+ if (violation === undefined) {
452
+ throw errors.new("a rejection carries at least one violation")
453
+ }
311
454
  throwContention(
312
455
  {
313
456
  braid,
314
457
  cause: {
315
458
  kind: "hot-key",
316
- statement: violation === undefined ? "" : violation.canonical,
317
- determinants: (violation?.facts ?? []).map(function rawOf(offending) {
459
+ statement: violation.canonical,
460
+ determinants: violation.facts.map(function rawOf(offending) {
318
461
  return offending.fact
319
462
  })
320
463
  }
@@ -329,15 +472,13 @@ function screamContention<Rels extends SchemaRelations>(
329
472
  }
330
473
 
331
474
  /**
332
- * Publishes the applied pending batch: slot CAS, then the one loss path
333
- * on Exists. A byte-equal occupant is our own ambiguous PUT, absorbed.
334
- * Anything else carries the pending through a directory discard —
335
- * re-persisted into the fresh sidecar before any re-judgment, so a
336
- * crash mid-loss resolves it at the next open — re-opens to the
337
- * current tip, and re-judges the recorded ops in one db.write: publish
338
- * on accepted-and-state-changing, Accepted at the current generation
339
- * on a net no-op (the publish law), or the serial Rejected. Each
340
- * iteration races once at the then-tip; the bound counts iterations.
475
+ * Publishes the applied pending batch: refuse a below-floor create,
476
+ * then slot CAS, then the one loss path on Exists. A byte-equal
477
+ * occupant is our own ambiguous PUT, absorbed. Anything else carries
478
+ * the pending through a directory discard — re-persisted into the
479
+ * fresh sidecar before any re-judgment — re-opens to the current tip,
480
+ * and re-judges the recorded ops in one db.write. An occupant that
481
+ * then vanishes is retired, not a loop that forges the swept slot.
341
482
  */
342
483
  async function publishPending<Rels extends SchemaRelations>(
343
484
  core: Core<Rels>,
@@ -346,31 +487,46 @@ async function publishPending<Rels extends SchemaRelations>(
346
487
  ): Promise<Commit<Rels, undefined>> {
347
488
  let losses = 0
348
489
  for (;;) {
349
- const pending = core.pending
490
+ const pending = pendingOf(core)
350
491
  if (pending === null) {
351
492
  throw errors.new("publish reached with no pending batch")
352
493
  }
353
494
  const braid = pending.braid
495
+ // The floor is a write precondition: a slot at or below it is
496
+ // retired. A create must not touch the store (70/116/127).
497
+ if (belowFloor(core, braid, pending.gen)) {
498
+ refuseSlotRetired({ braid, slot: pending.gen }, "the slot is retired")
499
+ }
354
500
  const created = await core.store.putCreate(logKey(core.prefix, braid, pending.gen), pending.bytes)
355
501
  let winnerBytes: Uint8Array | null = null
356
- if (created.tag === "exists") {
502
+ if (created.tag !== "created") {
357
503
  const fetched = await core.store.get(logKey(core.prefix, braid, pending.gen))
358
504
  if (fetched === null) {
359
- continue
505
+ // Exists then null: the occupant was swept. Refuse
506
+ // rather than loop back into putCreate.
507
+ refuseSlotRetired({ braid, slot: pending.gen }, "the slot is retired")
360
508
  }
361
509
  if (!bytesEqual(fetched.bytes, pending.bytes)) {
362
510
  winnerBytes = fetched.bytes
363
511
  }
364
512
  }
365
513
  if (winnerBytes === null) {
366
- const header = decodeBatch(core.descriptor, pending.bytes).header
367
- core.chain.set(braid, { g: pending.gen, prev: blake3Hex(pending.bytes), ts: header.timestamp })
514
+ const timestamp = headerTimestamp(pending.bytes)
515
+ if (timestamp === undefined) {
516
+ throw errors.new("pending batch header is shorter than the fixed layout")
517
+ }
518
+ entriesOf(core).set(braid, {
519
+ g: pending.gen,
520
+ prev: digest32(new Uint8Array(internalBlake3(pending.bytes))),
521
+ ts: timestamp
522
+ })
368
523
  await clearPending(core)
369
524
  return { tag: "accepted", value: undefined, braid, generation: pending.gen, durability: "published" }
370
525
  }
371
526
 
372
527
  losses += 1
373
- core.pendingApplied = false
528
+ state.scream.attempt("slot occupant is not ours")
529
+ noteDeposition(state, braid, pending.gen, winnerBytes)
374
530
  await discardAndReopen(core)
375
531
  const before = generationOf(core)
376
532
  const rejudged = applyOps(core, ops)
@@ -391,6 +547,8 @@ async function publishPending<Rels extends SchemaRelations>(
391
547
  durability: "published"
392
548
  }
393
549
  }
550
+ const tip = chainEntry(core, braid)
551
+ entriesOf(core).set(braid, { g: tip.g, prev: digestPrev(tip.prev), ts: tip.ts })
394
552
  await readdressPending(core, ops, state.writerId)
395
553
  if (losses >= LOSS_BOUND) {
396
554
  screamContention(braid, { tag: "outraced", tip: chainEntry(core, braid).g })
@@ -398,8 +556,9 @@ async function publishPending<Rels extends SchemaRelations>(
398
556
  }
399
557
  }
400
558
 
401
- /** The commit discipline (60): encode, fsync the pending, judge locally,
402
- * publish only what advanced the generation. */
559
+ /** The commit discipline (60): encode, fsync as Pending before any
560
+ * apply, judge locally, publish only what advanced the generation.
561
+ * Settled is written only after the verdict. */
403
562
  async function disciplineCommit<Rels extends SchemaRelations>(
404
563
  core: Core<Rels>,
405
564
  state: WriterState,
@@ -411,18 +570,17 @@ async function disciplineCommit<Rels extends SchemaRelations>(
411
570
  const bytes = encodeBatch(
412
571
  core.descriptor,
413
572
  {
414
- fingerprint: core.descriptor.fingerprint,
573
+ fingerprint: digest32FromHex(core.descriptor.fingerprint),
415
574
  braid,
416
575
  braidGen: generation(entry.g + 1n),
417
- prev: entry.prev,
576
+ prev: digestPrev(entry.prev),
418
577
  writer: state.writerId,
419
578
  timestamp
420
579
  },
421
580
  ops
422
581
  )
423
- core.pending = { braid, gen: generation(entry.g + 1n), bytes }
424
- core.pendingOps = ops
425
- core.pendingApplied = false
582
+ // Pending → durable, before any apply.
583
+ holdPending(core, { braid, gen: generation(entry.g + 1n), bytes }, ops)
426
584
  await persistSidecar(core)
427
585
 
428
586
  const before = generationOf(core)
@@ -435,31 +593,223 @@ async function disciplineCommit<Rels extends SchemaRelations>(
435
593
  await clearPending(core)
436
594
  return { tag: "accepted", value: undefined, braid, generation: entry.g, durability: "published" }
437
595
  }
438
- core.pendingApplied = true
439
596
  return publishPending(core, state, ops)
440
597
  }
441
598
 
442
- /** A recovered pending owed from a previous life publishes before any new commit. */
599
+ /** An inherited pending is resolved-and-published by open. A slot the
600
+ * floor already covers is published (`Clear`), not re-judged (46). */
443
601
  async function settleInheritedPending<Rels extends SchemaRelations>(
444
602
  core: Core<Rels>,
445
603
  state: WriterState
446
604
  ): Promise<void> {
447
- if (core.pending === null || !core.pendingApplied || core.pendingOps === null) {
605
+ const pending = pendingOf(core)
606
+ if (pending === null) {
607
+ return
608
+ }
609
+ if (
610
+ foldPending(
611
+ chainSum(core.chain),
612
+ generationOf(core),
613
+ null,
614
+ pending.bytes,
615
+ belowFloor(core, pending.braid, pending.gen)
616
+ ).tag === "below-floor"
617
+ ) {
618
+ await clearPending(core)
448
619
  return
449
620
  }
450
- await publishPending(core, state, core.pendingOps)
621
+ let ops = pending.ops
622
+ if (ops === null) {
623
+ const decoded = errors.trySync(function decodePending() {
624
+ return decodeBatch(core.descriptor, pending.bytes)
625
+ })
626
+ if (decoded.error) {
627
+ await clearPending(core)
628
+ return
629
+ }
630
+ ops = decoded.data.ops
631
+ }
632
+ const published = await publishPending(core, state, ops)
633
+ if (published.tag === "rejected") {
634
+ return
635
+ }
636
+ }
637
+
638
+ type PublishRefusal = "manifest-missing" | "manifest" | "checkpoint-doc-missing" | "checkpoint"
639
+
640
+ type Published =
641
+ | { readonly tag: "replaced" }
642
+ | { readonly tag: "kept"; readonly incumbent: Digest32 }
643
+ | { readonly tag: "refused"; readonly reason: PublishRefusal }
644
+
645
+ async function putCreateOnce(store: ObjectStore, key: ReturnType<typeof ckptDocKey>, bytes: Uint8Array): Promise<void> {
646
+ for (;;) {
647
+ const created = await store.putCreate(key, bytes)
648
+ if (created.tag === "created" || created.tag === "exists") {
649
+ return
650
+ }
651
+ const fetched = await store.get(key)
652
+ if (fetched !== null) {
653
+ return
654
+ }
655
+ }
451
656
  }
452
657
 
453
- function openWriter<Rels extends SchemaRelations>(replica: Replica<Rels>): Writer<Rels> {
658
+ /** The loser deletes its own `ckpt/{digest}` and `.mdb`. */
659
+ async function deleteOrphan(store: ObjectStore, prefix: string, digest: Digest32): Promise<void> {
660
+ await store.delete(ckptDocKey(prefix, digest))
661
+ await store.delete(checkpointMdbKey(prefix, digest))
662
+ }
663
+
664
+ function scratchPath(dir: string): string {
665
+ return path.join(dir, LEASE_NAMESPACE, CKPT_SCRATCH_LEASE)
666
+ }
667
+
668
+ async function claimScratch(dir: string, digest: Digest32): Promise<void> {
669
+ const target = scratchPath(dir)
670
+ await fs.mkdir(path.dirname(target), { recursive: true })
671
+ const handle = await fs.open(target, "w")
672
+ const written = await errors.try(
673
+ (async function writeLease() {
674
+ await handle.writeFile(encodeCkptScratch(digest))
675
+ await handle.sync()
676
+ })()
677
+ )
678
+ await handle.close()
679
+ if (written.error) {
680
+ await fs.rm(target, { force: true })
681
+ throw written.error
682
+ }
683
+ }
684
+
685
+ async function releaseScratch(dir: string): Promise<void> {
686
+ await fs.rm(scratchPath(dir), { force: true })
687
+ }
688
+
689
+ async function casPublish(
690
+ store: ObjectStore,
691
+ prefix: string,
692
+ known: ReadonlySet<Braid>,
693
+ candidate: CheckpointFacts,
694
+ digest: Digest32,
695
+ bytes: Uint8Array
696
+ ): Promise<Published> {
697
+ await putCreateOnce(store, ckptDocKey(prefix, digest), bytes)
698
+ for (;;) {
699
+ const fetched = await store.get(manifestKey(prefix))
700
+ if (fetched === null) {
701
+ return { tag: "refused", reason: "manifest-missing" }
702
+ }
703
+ const parsed = errors.trySync(function parse() {
704
+ return parseManifest(fetched.bytes)
705
+ })
706
+ if (parsed.error) {
707
+ return { tag: "refused", reason: "manifest" }
708
+ }
709
+ const incumbent = parsed.data.checkpoint
710
+ if (incumbent !== null && bytesEqual(incumbent, digest)) {
711
+ return { tag: "replaced" }
712
+ }
713
+ if (incumbent !== null) {
714
+ const doc = await store.get(ckptDocKey(prefix, incumbent))
715
+ if (doc === null) {
716
+ return { tag: "refused", reason: "checkpoint-doc-missing" }
717
+ }
718
+ const incumbentDoc = errors.trySync(function parseIncumbent() {
719
+ return parseCheckpoint(doc.bytes, known)
720
+ })
721
+ if (incumbentDoc.error) {
722
+ return { tag: "refused", reason: "checkpoint" }
723
+ }
724
+ if (checkpointVector(candidate).order(checkpointVector(incumbentDoc.data)) !== "after") {
725
+ return { tag: "kept", incumbent }
726
+ }
727
+ }
728
+ const next = renderManifest({ fingerprint: parsed.data.fingerprint, checkpoint: digest })
729
+ const swapped = await store.putSwap(manifestKey(prefix), next, fetched.etag)
730
+ if (swapped.tag === "swapped") {
731
+ return { tag: "replaced" }
732
+ }
733
+ }
734
+ }
735
+
736
+ /**
737
+ * Publishes `candidate` under the checkpoint order. The digest is the
738
+ * blake3 of the full bytes, `prev` included. A scratch lease
739
+ * `{dir}/~lease/ckpt-scratch` names the candidate for the publish
740
+ * window; a crash leaves that lease and a successor sweep reclaims
741
+ * it. `Kept` and every refused publish delete the candidate's `ckpt`
742
+ * pair.
743
+ */
744
+ async function publishCheckpoint(
745
+ store: ObjectStore,
746
+ prefix: string,
747
+ dir: string,
748
+ known: ReadonlySet<Braid>,
749
+ candidate: CheckpointFacts,
750
+ mdb: Uint8Array
751
+ ): Promise<Published> {
752
+ const bytes = renderCheckpoint(candidate)
753
+ const digest = digest32(new Uint8Array(internalBlake3(bytes)))
754
+ await claimScratch(dir, digest)
755
+ const ran = await errors.try(
756
+ (async function publish() {
757
+ await putCreateOnce(store, checkpointMdbKey(prefix, digest), mdb)
758
+ return await casPublish(store, prefix, known, candidate, digest, bytes)
759
+ })()
760
+ )
761
+ if (ran.error) {
762
+ throw ran.error
763
+ }
764
+ if (ran.data.tag === "kept" || ran.data.tag === "refused") {
765
+ await deleteOrphan(store, prefix, digest)
766
+ }
767
+ await releaseScratch(dir)
768
+ return ran.data
769
+ }
770
+
771
+ /** Only the writer births a store: create-only PUT of a genesis manifest. */
772
+ async function birthStore(store: ObjectStore, prefix: string, fingerprint: Digest32): Promise<void> {
773
+ const key = manifestKey(prefix)
774
+ for (;;) {
775
+ const fetched = await store.get(key)
776
+ if (fetched !== null) {
777
+ return
778
+ }
779
+ const bytes = renderManifest({ fingerprint, checkpoint: null })
780
+ const created = await store.putCreate(key, bytes)
781
+ if (created.tag === "created") {
782
+ return
783
+ }
784
+ }
785
+ }
786
+
787
+ function isReplica<Rels extends SchemaRelations>(
788
+ source: Replica<Rels> | OpenReplicaOptions<Rels>
789
+ ): source is Replica<Rels> {
790
+ return typeof (source as Replica<Rels>).refresh === "function"
791
+ }
792
+
793
+ /** Wrap a born replica: settle an inherited pending; do not birth; do not draw id leases. */
794
+ async function writerOn<Rels extends SchemaRelations>(replica: Replica<Rels>): Promise<Writer<Rels>> {
454
795
  const core = coreOf(replica)
455
796
  const state: WriterState = {
456
797
  writerId: crypto.randomBytes(8).readBigUInt64LE(),
457
- pools: new Map()
798
+ pools: new Map(),
799
+ scream: screamOf("writer discard-and-re-pull"),
800
+ deposition: null
458
801
  }
802
+ await withGate(core, async function openTransition() {
803
+ await settleInheritedPending(core, state)
804
+ })
459
805
  return {
806
+ role: "writer",
807
+ replica,
808
+ deposition() {
809
+ return state.deposition
810
+ },
460
811
  async commit(body) {
461
812
  return withGate(core, async function commitBody() {
462
- await settleInheritedPending(core, state)
463
813
  const recorded = await recordWithLeases(core, state, body)
464
814
  if (recorded.ops.length === 0) {
465
815
  throw errors.new("commit recorded no ops — an empty transaction names no braid")
@@ -479,7 +829,6 @@ function openWriter<Rels extends SchemaRelations>(replica: Replica<Rels>): Write
479
829
 
480
830
  async commitSplit(body) {
481
831
  return withGate(core, async function splitBody() {
482
- await settleInheritedPending(core, state)
483
832
  const recorded = await recordWithLeases(core, state, body)
484
833
  if (recorded.ops.length === 0) {
485
834
  throw errors.new("commitSplit recorded no ops — an empty transaction names no braid")
@@ -505,5 +854,17 @@ function openWriter<Rels extends SchemaRelations>(replica: Replica<Rels>): Write
505
854
  }
506
855
  }
507
856
 
508
- export type { Batch, BraidOutcome, Commit, CommitSplit, Durability, Writer }
509
- export { openWriter }
857
+ async function openWriter<Rels extends SchemaRelations>(replica: Replica<Rels>): Promise<Writer<Rels>>
858
+ async function openWriter<Rels extends SchemaRelations>(options: OpenReplicaOptions<Rels>): Promise<Writer<Rels>>
859
+ async function openWriter<Rels extends SchemaRelations>(
860
+ source: Replica<Rels> | OpenReplicaOptions<Rels>
861
+ ): Promise<Writer<Rels>> {
862
+ if (isReplica(source)) {
863
+ return writerOn(source)
864
+ }
865
+ await birthStore(source.store, source.prefix, digest32FromHex(descriptorOf(source.theory).fingerprint))
866
+ return writerOn(await openReplica(source))
867
+ }
868
+
869
+ export type { Batch, BraidOutcome, Commit, CommitSplit, Deposition, Durability, Published, PublishRefusal, Writer }
870
+ export { openWriter, publishCheckpoint }