@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/writer.ts CHANGED
@@ -1,62 +1,108 @@
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"
17
- import type { BatchOp } from "#codec.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"
32
+ import type { Op } from "#codec.ts"
18
33
  import { decodeBatch, encodeBatch } from "#codec.ts"
19
- import type { RelationInfo } from "#descriptor.ts"
20
- import { ErrSpanningCommit, throwContention } from "#errors.ts"
21
- import { idsKey, logKey } from "#keys.ts"
22
- import type { Core, Replica } from "#replica.ts"
34
+ import type { Braid, RelationInfo } from "#descriptor.ts"
35
+ import { descriptorOf } from "#descriptor.ts"
36
+ import { ErrSpanningCommit, refuseExhausted, refuseOverWidth, refuseSlotRetired, throwContention } from "#errors.ts"
37
+ import type { Generation } from "#keys.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"
23
52
  import {
24
53
  applyOps,
25
- blake3Hex,
54
+ belowFloor,
26
55
  chainEntry,
27
56
  clearPending,
28
57
  coreOf,
29
58
  discardAndReopen,
59
+ entriesOf,
60
+ foldPending,
30
61
  generationOf,
62
+ holdPending,
31
63
  maxBigint,
64
+ openReplica,
65
+ pendingOf,
32
66
  persistSidecar,
33
67
  readdressPending,
34
68
  withGate
35
69
  } from "#replica.ts"
36
- import type { LogValue } from "#value.ts"
70
+ import type { ObjectStore } from "#store.ts"
71
+ import type { Value } from "#value.ts"
37
72
  import { checkAgainst } from "#value.ts"
38
73
 
39
74
  /** 10 owns the width: one CAS amortizes counter traffic 4096× below slot traffic. */
40
75
  const LEASE_WIDTH = 4096n
76
+ const ID_LEASE_COUNTER = regex("^\\d+$")
41
77
 
42
78
  /** The live-loss bound (60): consecutive losses at the live tip, history never counts. */
43
79
  const LOSS_BOUND = 16
44
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
+
45
86
  type Durability = "published" | "local-pending"
46
87
 
47
88
  type Commit<Rels extends SchemaRelations, R> =
48
89
  | {
49
90
  readonly tag: "accepted"
50
91
  readonly value: R
51
- readonly braid: string
52
- readonly generation: bigint
92
+ readonly braid: Braid
93
+ readonly generation: Generation
53
94
  readonly durability: Durability
54
95
  }
55
96
  | { readonly tag: "rejected"; readonly violations: readonly Violation<Rels>[] }
56
97
 
57
98
  type BraidOutcome<Rels extends SchemaRelations> =
58
- | { readonly tag: "accepted"; readonly braid: string; readonly generation: bigint; readonly durability: Durability }
59
- | { readonly tag: "rejected"; readonly braid: string; readonly violations: readonly Violation<Rels>[] }
99
+ | {
100
+ readonly tag: "accepted"
101
+ readonly braid: Braid
102
+ readonly generation: Generation
103
+ readonly durability: Durability
104
+ }
105
+ | { readonly tag: "rejected"; readonly braid: Braid; readonly violations: readonly Violation<Rels>[] }
60
106
 
61
107
  interface CommitSplit<Rels extends SchemaRelations, R> {
62
108
  readonly value: R
@@ -68,7 +114,7 @@ interface CommitSplit<Rels extends SchemaRelations, R> {
68
114
  * drawing on the id lease (10) — reservations never appear in the log;
69
115
  * the resulting inserts carry concrete values. Pure and synchronous.
70
116
  */
71
- interface LogBatch<Rels extends SchemaRelations> {
117
+ interface Batch<Rels extends SchemaRelations> {
72
118
  insert<Rel extends MemberRelation<Rels>>(relation: Rel, facts: Iterable<Fact<Rel>>): void
73
119
  delete<Rel extends MemberRelation<Rels>>(relation: Rel, facts: Iterable<Fact<Rel>>): void
74
120
  reserve<Rel extends MemberRelation<Rels>>(
@@ -78,9 +124,20 @@ interface LogBatch<Rels extends SchemaRelations> {
78
124
  ): readonly bigint[]
79
125
  }
80
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
+
81
135
  interface Writer<Rels extends SchemaRelations> {
82
- commit<R>(body: (batch: LogBatch<Rels>) => R): Promise<Commit<Rels, R>>
83
- commitSplit<R>(body: (batch: LogBatch<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>>
84
141
  }
85
142
 
86
143
  interface LeaseRange {
@@ -88,37 +145,81 @@ interface LeaseRange {
88
145
  readonly end: bigint
89
146
  }
90
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
+
91
173
  interface WriterState {
92
174
  readonly writerId: bigint
93
175
  readonly pools: Map<string, LeaseRange[]>
176
+ readonly scream: Scream
177
+ deposition: Deposition | null
94
178
  }
95
179
 
96
- const ErrLeaseDrained = errors.new("bumbledb-log lease pool drained mid-recording")
97
- const leaseDemand = new WeakMap<Error, { relation: number; field: number }>()
98
-
99
180
  function poolKey(relation: number, field: number): string {
100
181
  return `${relation}:${field}`
101
182
  }
102
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
+
103
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
+ }
104
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
+ }
105
214
  const ids: bigint[] = []
106
215
  let remaining = count
107
- while (remaining > 0n) {
108
- const range = pool[0]
109
- if (range === undefined) {
110
- const fault = errors.wrap(ErrLeaseDrained, `relation ${relation} field ${field} needs ${remaining} more ids`)
111
- leaseDemand.set(fault, { relation, field })
112
- throw fault
113
- }
114
- while (remaining > 0n && range.next < range.end) {
115
- ids.push(range.next)
116
- range.next += 1n
117
- remaining -= 1n
118
- }
119
- if (range.next >= range.end) {
120
- pool.shift()
121
- }
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()
122
223
  }
123
224
  state.pools.set(poolKey(relation, field), pool)
124
225
  return ids
@@ -143,13 +244,17 @@ async function acquireLease<Rels extends SchemaRelations>(
143
244
  continue
144
245
  }
145
246
  const body = utf8StrictDecoder.decode(fetched.bytes)
146
- if (!/^\d+$/.test(body)) {
247
+ if (!ID_LEASE_COUNTER.test(body)) {
147
248
  throw errors.new(`id-lease counter ${key} is not a canonical decimal: ${body}`)
148
249
  }
149
250
  const next = BigInt(body)
150
- 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)
151
256
  if (swapped.tag === "swapped") {
152
- pushRange(state, relation, field, next, next + LEASE_WIDTH)
257
+ pushRange(state, relation, field, next, end)
153
258
  return
154
259
  }
155
260
  }
@@ -161,21 +266,34 @@ function pushRange(state: WriterState, relation: number, field: number, next: bi
161
266
  state.pools.set(poolKey(relation, field), pool)
162
267
  }
163
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
+
164
282
  interface Recording {
165
- readonly ops: BatchOp[]
283
+ readonly ops: Op[]
166
284
  }
167
285
 
168
286
  function lowerFact<Rels extends SchemaRelations>(
169
287
  core: Core<Rels>,
170
288
  info: RelationInfo,
171
289
  fact: Record<string, unknown>
172
- ): LogValue[] {
290
+ ): Value[] {
173
291
  return info.fields.map(function lowerCell(field) {
174
292
  const raw = fact[field.name]
175
293
  if (raw === undefined) {
176
294
  throw errors.new(`relation ${info.name}: fact is missing field ${field.name}`)
177
295
  }
178
- let value: LogValue
296
+ let value: Value
179
297
  if (field.closedRef !== undefined) {
180
298
  if (typeof raw !== "string") {
181
299
  throw errors.new(`relation ${info.name} field ${field.name}: expected a ${field.closedRef} handle name`)
@@ -193,7 +311,7 @@ function lowerFact<Rels extends SchemaRelations>(
193
311
  }
194
312
  value = { start: interval.start, end: interval.end }
195
313
  } else {
196
- value = raw as LogValue
314
+ value = raw as Value
197
315
  }
198
316
  checkAgainst(`relation ${info.name} field ${field.name}`, field.type, value)
199
317
  return value
@@ -203,7 +321,7 @@ function lowerFact<Rels extends SchemaRelations>(
203
321
  function recorderOf<Rels extends SchemaRelations>(
204
322
  core: Core<Rels>,
205
323
  state: WriterState
206
- ): { batch: LogBatch<Rels>; recording: Recording } {
324
+ ): { batch: Batch<Rels>; recording: Recording } {
207
325
  const recording: Recording = { ops: [] }
208
326
  function infoOf(name: string): RelationInfo {
209
327
  const info = core.descriptor.relationByName.get(name)
@@ -217,7 +335,7 @@ function recorderOf<Rels extends SchemaRelations>(
217
335
  }
218
336
  function record(op: "insert" | "delete", relationName: string, facts: Iterable<unknown>): void {
219
337
  const info = infoOf(relationName)
220
- const rows: LogValue[][] = []
338
+ const rows: Value[][] = []
221
339
  for (const fact of facts) {
222
340
  if (typeof fact !== "object" || fact === null) {
223
341
  throw errors.new(`relation ${relationName}: a fact is not an object`)
@@ -226,7 +344,7 @@ function recorderOf<Rels extends SchemaRelations>(
226
344
  }
227
345
  recording.ops.push({ op, relation: relationName, rows })
228
346
  }
229
- const batch: LogBatch<Rels> = {
347
+ const batch: Batch<Rels> = {
230
348
  insert(relation, facts) {
231
349
  record("insert", relation.name, facts)
232
350
  },
@@ -248,36 +366,22 @@ function recorderOf<Rels extends SchemaRelations>(
248
366
  return { batch, recording }
249
367
  }
250
368
 
251
- /** Runs the recording body, refilling the id-lease pool on a drained
252
- * draw and re-running — recording is pure, so a re-run before any
253
- * 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). */
254
372
  async function recordWithLeases<Rels extends SchemaRelations, R>(
255
373
  core: Core<Rels>,
256
374
  state: WriterState,
257
- body: (batch: LogBatch<Rels>) => R
258
- ): Promise<{ value: R; ops: BatchOp[] }> {
259
- for (let attempt = 0; attempt < 16; attempt++) {
260
- const { batch, recording } = recorderOf(core, state)
261
- const ran = errors.trySync(function runBody() {
262
- return body(batch)
263
- })
264
- if (ran.error === undefined) {
265
- return { value: ran.data, ops: recording.ops }
266
- }
267
- const demand = leaseDemand.get(ran.error) ?? leaseDemand.get(errors.cause(ran.error))
268
- if (demand === undefined) {
269
- throw ran.error
270
- }
271
- await acquireLease(core, state, demand.relation, demand.field)
272
- }
273
- throw errors.new("the id-lease pool could not satisfy the recording after 16 refills")
375
+ body: (batch: Batch<Rels>) => R | Promise<R>
376
+ ): Promise<{ value: R; ops: Op[] }> {
377
+ await ensureFreshLeases(core, state)
378
+ const { batch, recording } = recorderOf(core, state)
379
+ const value = await body(batch)
380
+ return { value, ops: recording.ops }
274
381
  }
275
382
 
276
- function braidsTouched<Rels extends SchemaRelations>(
277
- core: Core<Rels>,
278
- ops: readonly BatchOp[]
279
- ): Map<string, BatchOp[]> {
280
- const partitioned = new Map<string, BatchOp[]>()
383
+ function braidsTouched<Rels extends SchemaRelations>(core: Core<Rels>, ops: readonly Op[]): Map<Braid, Op[]> {
384
+ const partitioned = new Map<Braid, Op[]>()
281
385
  for (const op of ops) {
282
386
  const info = core.descriptor.relationByName.get(op.relation)
283
387
  const braid = info === undefined ? undefined : core.descriptor.braidOfRelation.get(info.id)
@@ -294,24 +398,66 @@ function braidsTouched<Rels extends SchemaRelations>(
294
398
  return partitioned
295
399
  }
296
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
+
297
438
  /** The terminal contention scream: the re-judgment's own rejection names
298
439
  * the hot statement and carries the offending facts' raw values; an
299
- * 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. */
300
443
  function screamContention<Rels extends SchemaRelations>(
301
- braid: string,
444
+ braid: Braid,
302
445
  rejudged:
303
446
  | { readonly tag: "rejected"; readonly violations: readonly Violation<Rels>[] }
304
- | { readonly tag: "outraced"; readonly tip: bigint }
447
+ | { readonly tag: "outraced"; readonly tip: Generation }
305
448
  ): never {
306
449
  if (rejudged.tag === "rejected") {
307
450
  const violation = rejudged.violations[0]
451
+ if (violation === undefined) {
452
+ throw errors.new("a rejection carries at least one violation")
453
+ }
308
454
  throwContention(
309
455
  {
310
456
  braid,
311
457
  cause: {
312
458
  kind: "hot-key",
313
- statement: violation === undefined ? "" : violation.canonical,
314
- determinants: (violation?.facts ?? []).map(function rawOf(offending) {
459
+ statement: violation.canonical,
460
+ determinants: violation.facts.map(function rawOf(offending) {
315
461
  return offending.fact
316
462
  })
317
463
  }
@@ -326,48 +472,61 @@ function screamContention<Rels extends SchemaRelations>(
326
472
  }
327
473
 
328
474
  /**
329
- * Publishes the applied pending batch: slot CAS, then the one loss path
330
- * on Exists. A byte-equal occupant is our own ambiguous PUT, absorbed.
331
- * Anything else carries the pending through a directory discard —
332
- * re-persisted into the fresh sidecar before any re-judgment, so a
333
- * crash mid-loss resolves it at the next open — re-opens to the
334
- * current tip, and re-judges the recorded ops in one db.write: publish
335
- * on accepted-and-state-changing, Accepted at the current generation
336
- * on a net no-op (the publish law), or the serial Rejected. Each
337
- * 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.
338
482
  */
339
483
  async function publishPending<Rels extends SchemaRelations>(
340
484
  core: Core<Rels>,
341
485
  state: WriterState,
342
- ops: readonly BatchOp[]
486
+ ops: readonly Op[]
343
487
  ): Promise<Commit<Rels, undefined>> {
344
488
  let losses = 0
345
489
  for (;;) {
346
- const pending = core.pending
490
+ const pending = pendingOf(core)
347
491
  if (pending === null) {
348
492
  throw errors.new("publish reached with no pending batch")
349
493
  }
350
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
+ }
351
500
  const created = await core.store.putCreate(logKey(core.prefix, braid, pending.gen), pending.bytes)
352
501
  let winnerBytes: Uint8Array | null = null
353
- if (created.tag === "exists") {
502
+ if (created.tag !== "created") {
354
503
  const fetched = await core.store.get(logKey(core.prefix, braid, pending.gen))
355
504
  if (fetched === null) {
356
- 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")
357
508
  }
358
509
  if (!bytesEqual(fetched.bytes, pending.bytes)) {
359
510
  winnerBytes = fetched.bytes
360
511
  }
361
512
  }
362
513
  if (winnerBytes === null) {
363
- const header = decodeBatch(core.descriptor, pending.bytes).header
364
- 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
+ })
365
523
  await clearPending(core)
366
524
  return { tag: "accepted", value: undefined, braid, generation: pending.gen, durability: "published" }
367
525
  }
368
526
 
369
527
  losses += 1
370
- core.pendingApplied = false
528
+ state.scream.attempt("slot occupant is not ours")
529
+ noteDeposition(state, braid, pending.gen, winnerBytes)
371
530
  await discardAndReopen(core)
372
531
  const before = generationOf(core)
373
532
  const rejudged = applyOps(core, ops)
@@ -388,6 +547,8 @@ async function publishPending<Rels extends SchemaRelations>(
388
547
  durability: "published"
389
548
  }
390
549
  }
550
+ const tip = chainEntry(core, braid)
551
+ entriesOf(core).set(braid, { g: tip.g, prev: digestPrev(tip.prev), ts: tip.ts })
391
552
  await readdressPending(core, ops, state.writerId)
392
553
  if (losses >= LOSS_BOUND) {
393
554
  screamContention(braid, { tag: "outraced", tip: chainEntry(core, braid).g })
@@ -395,31 +556,31 @@ async function publishPending<Rels extends SchemaRelations>(
395
556
  }
396
557
  }
397
558
 
398
- /** The commit discipline (60): encode, fsync the pending, judge locally,
399
- * 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. */
400
562
  async function disciplineCommit<Rels extends SchemaRelations>(
401
563
  core: Core<Rels>,
402
564
  state: WriterState,
403
- braid: string,
404
- ops: readonly BatchOp[]
565
+ braid: Braid,
566
+ ops: readonly Op[]
405
567
  ): Promise<Commit<Rels, undefined>> {
406
568
  const entry = chainEntry(core, braid)
407
569
  const timestamp = maxBigint(BigInt(Date.now()), entry.ts)
408
570
  const bytes = encodeBatch(
409
571
  core.descriptor,
410
572
  {
411
- fingerprint: core.descriptor.fingerprint,
573
+ fingerprint: digest32FromHex(core.descriptor.fingerprint),
412
574
  braid,
413
- braidGen: entry.g + 1n,
414
- prev: entry.prev,
575
+ braidGen: generation(entry.g + 1n),
576
+ prev: digestPrev(entry.prev),
415
577
  writer: state.writerId,
416
578
  timestamp
417
579
  },
418
580
  ops
419
581
  )
420
- core.pending = { braid, gen: entry.g + 1n, bytes }
421
- core.pendingOps = ops
422
- core.pendingApplied = false
582
+ // Pending → durable, before any apply.
583
+ holdPending(core, { braid, gen: generation(entry.g + 1n), bytes }, ops)
423
584
  await persistSidecar(core)
424
585
 
425
586
  const before = generationOf(core)
@@ -432,31 +593,223 @@ async function disciplineCommit<Rels extends SchemaRelations>(
432
593
  await clearPending(core)
433
594
  return { tag: "accepted", value: undefined, braid, generation: entry.g, durability: "published" }
434
595
  }
435
- core.pendingApplied = true
436
596
  return publishPending(core, state, ops)
437
597
  }
438
598
 
439
- /** 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). */
440
601
  async function settleInheritedPending<Rels extends SchemaRelations>(
441
602
  core: Core<Rels>,
442
603
  state: WriterState
443
604
  ): Promise<void> {
444
- if (core.pending === null || !core.pendingApplied || core.pendingOps === null) {
605
+ const pending = pendingOf(core)
606
+ if (pending === null) {
445
607
  return
446
608
  }
447
- await publishPending(core, state, core.pendingOps)
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)
619
+ return
620
+ }
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
+ }
656
+ }
657
+
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
448
769
  }
449
770
 
450
- function openWriter<Rels extends SchemaRelations>(replica: Replica<Rels>): Writer<Rels> {
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>> {
451
795
  const core = coreOf(replica)
452
796
  const state: WriterState = {
453
797
  writerId: crypto.randomBytes(8).readBigUInt64LE(),
454
- pools: new Map()
798
+ pools: new Map(),
799
+ scream: screamOf("writer discard-and-re-pull"),
800
+ deposition: null
455
801
  }
802
+ await withGate(core, async function openTransition() {
803
+ await settleInheritedPending(core, state)
804
+ })
456
805
  return {
806
+ role: "writer",
807
+ replica,
808
+ deposition() {
809
+ return state.deposition
810
+ },
457
811
  async commit(body) {
458
812
  return withGate(core, async function commitBody() {
459
- await settleInheritedPending(core, state)
460
813
  const recorded = await recordWithLeases(core, state, body)
461
814
  if (recorded.ops.length === 0) {
462
815
  throw errors.new("commit recorded no ops — an empty transaction names no braid")
@@ -465,7 +818,7 @@ function openWriter<Rels extends SchemaRelations>(replica: Replica<Rels>): Write
465
818
  if (partitioned.size > 1) {
466
819
  throw errors.wrap(ErrSpanningCommit, `the recorded ops span braids ${[...partitioned.keys()].join(", ")}`)
467
820
  }
468
- const [braid, ops] = [...partitioned.entries()][0] as [string, BatchOp[]]
821
+ const [braid, ops] = [...partitioned.entries()][0] as [Braid, Op[]]
469
822
  const outcome = await disciplineCommit(core, state, braid, ops)
470
823
  if (outcome.tag === "rejected") {
471
824
  return outcome
@@ -476,7 +829,6 @@ function openWriter<Rels extends SchemaRelations>(replica: Replica<Rels>): Write
476
829
 
477
830
  async commitSplit(body) {
478
831
  return withGate(core, async function splitBody() {
479
- await settleInheritedPending(core, state)
480
832
  const recorded = await recordWithLeases(core, state, body)
481
833
  if (recorded.ops.length === 0) {
482
834
  throw errors.new("commitSplit recorded no ops — an empty transaction names no braid")
@@ -502,5 +854,17 @@ function openWriter<Rels extends SchemaRelations>(replica: Replica<Rels>): Write
502
854
  }
503
855
  }
504
856
 
505
- export type { BraidOutcome, Commit, CommitSplit, Durability, LogBatch, Writer }
506
- 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 }