@bjornpagen/bumbledb-log 0.19.2 → 0.20.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
@@ -17,10 +17,16 @@ import * as crypto from "node:crypto"
17
17
  import * as fs from "node:fs/promises"
18
18
  import * as path from "node:path"
19
19
  import {
20
+ type Admission,
20
21
  type Fact,
22
+ type FactValue,
21
23
  type FreshKeys,
24
+ type FreshRange,
22
25
  internalBlake3,
26
+ type LogCodecHandle,
23
27
  type MemberRelation,
28
+ type MutationReport,
29
+ rowOf,
24
30
  type SchemaRelations,
25
31
  type Violation
26
32
  } from "@bjornpagen/bumbledb"
@@ -31,10 +37,20 @@ import { bytesEqual, checkedAddU64, digest32, utf8Encoder, utf8StrictDecoder } f
31
37
  import { chainSum } from "#chain.ts"
32
38
  import type { Op } from "#codec.ts"
33
39
  import { decodeBatch, encodeBatch } from "#codec.ts"
34
- import type { Braid, RelationInfo } from "#descriptor.ts"
40
+ import type { Braid, RelationInfo, Theory } from "#descriptor.ts"
35
41
  import { descriptorOf } from "#descriptor.ts"
36
- import { ErrSpanningCommit, refuseExhausted, refuseOverWidth, refuseSlotRetired, throwContention } from "#errors.ts"
37
- import type { Generation } from "#keys.ts"
42
+ import {
43
+ ErrRefillNeeded,
44
+ ErrSpanningCommit,
45
+ refillNeededOf,
46
+ refuse,
47
+ refuseExhausted,
48
+ refuseOverWidth,
49
+ refuseSlotRetired,
50
+ throwContention,
51
+ throwRefillNeeded
52
+ } from "#errors.ts"
53
+ import type { Generation, StoreKey } from "#keys.ts"
38
54
  import {
39
55
  CKPT_SCRATCH_LEASE,
40
56
  checkpointMdbKey,
@@ -68,12 +84,14 @@ import {
68
84
  withGate
69
85
  } from "#replica.ts"
70
86
  import type { ObjectStore } from "#store.ts"
71
- import type { Value } from "#value.ts"
72
- import { checkAgainst } from "#value.ts"
73
87
 
74
88
  /** 10 owns the width: one CAS amortizes counter traffic 4096× below slot traffic. */
75
89
  const LEASE_WIDTH = 4096n
76
- const ID_LEASE_COUNTER = regex("^\\d+$")
90
+
91
+ /** The counter body's one grammar (`conformance/v3/counter/`): a
92
+ * canonical u64 decimal — no sign, no leading zero, no trailing bytes. */
93
+ const ID_LEASE_COUNTER = regex("^(?:0|[1-9][0-9]*)$")
94
+ const U64_MAX = 0xffffffffffffffffn
77
95
 
78
96
  /** The live-loss bound (60): consecutive losses at the live tip, history never counts. */
79
97
  const LOSS_BOUND = 16
@@ -85,43 +103,55 @@ const BATCH_MAGIC = utf8Encoder.encode("BDBL")
85
103
 
86
104
  type Durability = "published" | "local-pending"
87
105
 
88
- type Commit<Rels extends SchemaRelations, R> =
89
- | {
90
- readonly tag: "accepted"
91
- readonly value: R
92
- readonly braid: Braid
93
- readonly generation: Generation
94
- readonly durability: Durability
95
- }
96
- | { readonly tag: "rejected"; readonly violations: readonly Violation<Rels>[] }
97
-
98
- type BraidOutcome<Rels extends SchemaRelations> =
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>[] }
106
-
107
- interface CommitSplit<Rels extends SchemaRelations, R> {
106
+ /** Where an accepted commit landed on its braid: the slot (the braid
107
+ * position — never the store-wide sum) and how durable the batch is. */
108
+ interface Landing {
109
+ readonly slot: Generation
110
+ readonly durability: Durability
111
+ }
112
+
113
+ /** The accepted payload of `commit`: the body's value plus the landing. */
114
+ interface CommitReceipt<R> extends Landing {
108
115
  readonly value: R
109
- readonly outcomes: readonly BraidOutcome<Rels>[]
116
+ readonly braid: Braid
110
117
  }
111
118
 
119
+ /** The empty commit is not a commit: a body that records no ops names no
120
+ * braid, so the refusal is its own outcome — never a slot, never a
121
+ * thrown surprise. The body's value rides out, the way an abandoned
122
+ * write's payload does in the engine. */
123
+ interface EmptyCommit<R> {
124
+ readonly tag: "empty"
125
+ readonly value: R
126
+ }
127
+
128
+ /** The engine's Admission sum carrying the log's receipt in its accepted
129
+ * arm; the rejected arm is the engine's violations, shell and payload. */
130
+ type Commit<Rels extends SchemaRelations, R> = Admission<Rels, CommitReceipt<R>> | EmptyCommit<R>
131
+
132
+ /** One braid's verdict inside a split commit: the engine's Admission
133
+ * carrying the landing, beside the braid it judged. */
134
+ interface BraidOutcome<Rels extends SchemaRelations> {
135
+ readonly braid: Braid
136
+ readonly admission: Admission<Rels, Landing>
137
+ }
138
+
139
+ type CommitSplit<Rels extends SchemaRelations, R> =
140
+ | { readonly tag: "split"; readonly value: R; readonly outcomes: readonly BraidOutcome<Rels>[] }
141
+ | EmptyCommit<R>
142
+
112
143
  /**
113
- * The recorder: typed inserts and deletes as raw-valued ops, `reserve`
114
- * drawing on the id lease (10) — reservations never appear in the log;
115
- * the resulting inserts carry concrete values. Pure and synchronous.
144
+ * The recorder: the engine `WriteTx`'s write surface, verbatim — insert,
145
+ * delete, and reserve carry the engine's own signatures, so a write-only
146
+ * body typechecks against both dialects. `contains`/`get` are absent by
147
+ * law: the journaled dialect is a pure recorder, and change is judged at
148
+ * commit, so a report's `changed` is 0n at record time. Reservations
149
+ * never appear in the log; the resulting inserts carry concrete values.
116
150
  */
117
151
  interface Batch<Rels extends SchemaRelations> {
118
- insert<Rel extends MemberRelation<Rels>>(relation: Rel, facts: Iterable<Fact<Rel>>): void
119
- delete<Rel extends MemberRelation<Rels>>(relation: Rel, facts: Iterable<Fact<Rel>>): void
120
- reserve<Rel extends MemberRelation<Rels>>(
121
- relation: Rel,
122
- field: FreshKeys<Rel> & string,
123
- count: bigint
124
- ): readonly bigint[]
152
+ insert<Rel extends MemberRelation<Rels>>(relation: Rel, facts: Iterable<Fact<Rel>>): MutationReport
153
+ delete<Rel extends MemberRelation<Rels>>(relation: Rel, facts: Iterable<Fact<Rel>>): MutationReport
154
+ reserve<Rel extends MemberRelation<Rels>>(relation: Rel, field: FreshKeys<Rel> & string, count: bigint): FreshRange
125
155
  }
126
156
 
127
157
  /** Ownership of a slot, read from the winner's header. */
@@ -190,7 +220,63 @@ function remainingOf(state: WriterState, relation: number, field: number): bigin
190
220
  return remaining
191
221
  }
192
222
 
193
- function drawIds(state: WriterState, relation: number, field: number, count: bigint): bigint[] {
223
+ /** The zero draw is the absence of a range (the engine's ruling). */
224
+ const EMPTY_RANGE: FreshRange = Object.freeze({
225
+ empty: true,
226
+ count: 0n,
227
+ at(_index: bigint) {
228
+ return undefined
229
+ },
230
+ *[Symbol.iterator](): IterableIterator<bigint> {}
231
+ })
232
+
233
+ /** A drawn range as the engine's FreshRange value: contiguous, frozen. */
234
+ function drawnRange(start: bigint, endExclusive: bigint): FreshRange {
235
+ const count = endExclusive - start
236
+ return Object.freeze({
237
+ empty: false,
238
+ start,
239
+ endExclusive,
240
+ count,
241
+ at(index: bigint) {
242
+ if (index < 0n || index >= count) {
243
+ return undefined
244
+ }
245
+ return start + index
246
+ },
247
+ *[Symbol.iterator](): IterableIterator<bigint> {
248
+ for (let id = start; id < endExclusive; id++) {
249
+ yield id
250
+ }
251
+ }
252
+ })
253
+ }
254
+
255
+ /** Untouched full-width blocks pooled for the key: the record loop's
256
+ * guarantee unit — k full blocks serve any k draws, each at most one
257
+ * width, whatever tails the draws abandon. */
258
+ function fullBlocksOf(state: WriterState, relation: number, field: number): number {
259
+ const pool = state.pools.get(poolKey(relation, field)) ?? []
260
+ let full = 0
261
+ for (const range of pool) {
262
+ if (range.end - range.next >= LEASE_WIDTH) {
263
+ full += 1
264
+ }
265
+ }
266
+ return full
267
+ }
268
+
269
+ /** `Lease.draw(count)` = OverWidth | Exhausted | Drawn, the log-Rust
270
+ * algebra: Exhausted names the u64 ceiling ONLY; a draw the pooled
271
+ * tail cannot serve abandons the tail (unique, never dense) and falls
272
+ * to the next block, and an empty pool signals a refill. */
273
+ function drawIds(
274
+ state: WriterState,
275
+ draws: Map<string, number>,
276
+ relation: number,
277
+ field: number,
278
+ count: bigint
279
+ ): FreshRange {
194
280
  if (count < 0n) {
195
281
  throw errors.new(`id-lease count is unsigned: ${count}`)
196
282
  }
@@ -198,31 +284,55 @@ function drawIds(state: WriterState, relation: number, field: number, count: big
198
284
  refuseOverWidth({ requested: count }, `id-lease draw ${count} exceeds the lease width ${LEASE_WIDTH}`)
199
285
  }
200
286
  if (count === 0n) {
201
- return []
202
- }
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
- )
287
+ return EMPTY_RANGE
288
+ }
289
+ const key = poolKey(relation, field)
290
+ const drawn = (draws.get(key) ?? 0) + 1
291
+ draws.set(key, drawn)
292
+ const pool = state.pools.get(key) ?? []
293
+ while (pool.length > 0) {
294
+ const range = pool[0]
295
+ if (range === undefined) {
296
+ break
297
+ }
298
+ const end = checkedAddU64(range.next, count)
299
+ if (end === undefined) {
300
+ refuseExhausted({ relation, field }, `id-lease relation ${relation} field ${field} would leave u64`)
301
+ }
302
+ if (end <= range.end) {
303
+ const start = range.next
304
+ range.next = end
305
+ if (range.next >= range.end) {
306
+ pool.shift()
307
+ }
308
+ return drawnRange(start, end)
309
+ }
310
+ pool.shift()
210
311
  }
211
- if (checkedAddU64(range.next, count) === undefined) {
212
- refuseExhausted({ relation, field }, `id-lease relation ${relation} field ${field} would leave u64`)
312
+ throwRefillNeeded(
313
+ { relation, field, requested: count },
314
+ `id-lease relation ${relation} field ${field} pool cannot serve ${count}`
315
+ )
316
+ }
317
+
318
+ /** The counter parse: canonical decimal within u64 or a typed Counter
319
+ * refusal — leading zeros, signs, trailing bytes, non-UTF-8, and
320
+ * values past u64 all refuse under the one pinned identity. */
321
+ function parseCounter(key: StoreKey, bytes: Uint8Array): bigint {
322
+ const decoded = errors.trySync(function decodeCounter() {
323
+ return utf8StrictDecoder.decode(bytes)
324
+ })
325
+ if (decoded.error) {
326
+ refuse({ kind: "Counter", key }, `id-lease counter ${key} body is not UTF-8`)
213
327
  }
214
- const ids: bigint[] = []
215
- let remaining = count
216
- while (remaining > 0n && range.next < range.end) {
217
- ids.push(range.next)
218
- range.next += 1n
219
- remaining -= 1n
328
+ if (!ID_LEASE_COUNTER.test(decoded.data)) {
329
+ refuse({ kind: "Counter", key }, `id-lease counter ${key} body is not a canonical decimal`)
220
330
  }
221
- if (range.next >= range.end) {
222
- pool.shift()
331
+ const value = BigInt(decoded.data)
332
+ if (value > U64_MAX) {
333
+ refuse({ kind: "Counter", key }, `id-lease counter ${key} value leaves u64`)
223
334
  }
224
- state.pools.set(poolKey(relation, field), pool)
225
- return ids
335
+ return value
226
336
  }
227
337
 
228
338
  /** `ids/{relation}/{field}`: birth claims [0, width); every later lease CAS-increments. */
@@ -243,11 +353,7 @@ async function acquireLease<Rels extends SchemaRelations>(
243
353
  }
244
354
  continue
245
355
  }
246
- const body = utf8StrictDecoder.decode(fetched.bytes)
247
- if (!ID_LEASE_COUNTER.test(body)) {
248
- throw errors.new(`id-lease counter ${key} is not a canonical decimal: ${body}`)
249
- }
250
- const next = BigInt(body)
356
+ const next = parseCounter(key, fetched.bytes)
251
357
  const end = checkedAddU64(next, LEASE_WIDTH)
252
358
  if (end === undefined) {
253
359
  refuseExhausted({ relation, field }, `id-lease relation ${relation} field ${field} would leave u64`)
@@ -283,44 +389,10 @@ interface Recording {
283
389
  readonly ops: Op[]
284
390
  }
285
391
 
286
- function lowerFact<Rels extends SchemaRelations>(
287
- core: Core<Rels>,
288
- info: RelationInfo,
289
- fact: Record<string, unknown>
290
- ): Value[] {
291
- return info.fields.map(function lowerCell(field) {
292
- const raw = fact[field.name]
293
- if (raw === undefined) {
294
- throw errors.new(`relation ${info.name}: fact is missing field ${field.name}`)
295
- }
296
- let value: Value
297
- if (field.closedRef !== undefined) {
298
- if (typeof raw !== "string") {
299
- throw errors.new(`relation ${info.name} field ${field.name}: expected a ${field.closedRef} handle name`)
300
- }
301
- const roster = core.descriptor.relationByName.get(field.closedRef)
302
- const id = roster?.handles.indexOf(raw) ?? -1
303
- if (id === -1) {
304
- throw errors.new(`relation ${info.name} field ${field.name}: "${raw}" is not in the ${field.closedRef} roster`)
305
- }
306
- value = BigInt(id)
307
- } else if (typeof raw === "object" && raw !== null && !(raw instanceof Uint8Array)) {
308
- const interval = raw as { start?: unknown; end?: unknown }
309
- if (typeof interval.start !== "bigint" || typeof interval.end !== "bigint") {
310
- throw errors.new(`relation ${info.name} field ${field.name}: expected an interval of bigints`)
311
- }
312
- value = { start: interval.start, end: interval.end }
313
- } else {
314
- value = raw as Value
315
- }
316
- checkAgainst(`relation ${info.name} field ${field.name}`, field.type, value)
317
- return value
318
- })
319
- }
320
-
321
392
  function recorderOf<Rels extends SchemaRelations>(
322
393
  core: Core<Rels>,
323
- state: WriterState
394
+ state: WriterState,
395
+ draws: Map<string, number>
324
396
  ): { batch: Batch<Rels>; recording: Recording } {
325
397
  const recording: Recording = { ops: [] }
326
398
  function infoOf(name: string): RelationInfo {
@@ -333,23 +405,28 @@ function recorderOf<Rels extends SchemaRelations>(
333
405
  }
334
406
  return info
335
407
  }
336
- function record(op: "insert" | "delete", relationName: string, facts: Iterable<unknown>): void {
337
- const info = infoOf(relationName)
338
- const rows: Value[][] = []
408
+ // The cell judge is the engine's `rowOf` — one marshal per language.
409
+ // The recorder applies nothing, so `changed` is 0n: change is judged
410
+ // at commit, when the recording meets the store.
411
+ function record<Rel extends MemberRelation<Rels>>(
412
+ op: "insert" | "delete",
413
+ relation: Rel,
414
+ facts: Iterable<Fact<Rel>>
415
+ ): MutationReport {
416
+ const info = infoOf(relation.name)
417
+ const rows: FactValue[][] = []
339
418
  for (const fact of facts) {
340
- if (typeof fact !== "object" || fact === null) {
341
- throw errors.new(`relation ${relationName}: a fact is not an object`)
342
- }
343
- rows.push(lowerFact(core, info, fact as Record<string, unknown>))
419
+ rows.push(rowOf(relation.data, fact))
344
420
  }
345
- recording.ops.push({ op, relation: relationName, rows })
421
+ recording.ops.push({ op, relation: info.name, rows })
422
+ return Object.freeze({ submitted: BigInt(rows.length), changed: 0n })
346
423
  }
347
424
  const batch: Batch<Rels> = {
348
425
  insert(relation, facts) {
349
- record("insert", relation.name, facts)
426
+ return record("insert", relation, facts)
350
427
  },
351
428
  delete(relation, facts) {
352
- record("delete", relation.name, facts)
429
+ return record("delete", relation, facts)
353
430
  },
354
431
  reserve(relation, field, count) {
355
432
  const info = infoOf(relation.name)
@@ -360,24 +437,51 @@ function recorderOf<Rels extends SchemaRelations>(
360
437
  if (declared === undefined || !declared.fresh) {
361
438
  throw errors.new(`relation ${relation.name}: field ${field} is not a fresh cell`)
362
439
  }
363
- return drawIds(state, info.id, ordinal, count)
440
+ return drawIds(state, draws, info.id, ordinal, count)
364
441
  }
365
442
  }
366
443
  return { batch, recording }
367
444
  }
368
445
 
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). */
446
+ /** Runs the recording body against the cached pool. A draw the pool
447
+ * cannot serve discards the attempt's recording, leases fresh blocks —
448
+ * one full block per draw the attempt made on the starved key — and
449
+ * runs the body again: the refill is a path, not an exhaustion
450
+ * (Exhausted names the u64 ceiling only). Ids drawn by a discarded
451
+ * attempt are abandoned — unique, never dense. Draw counts rise
452
+ * strictly between refills of one key, so a body with finitely many
453
+ * draws settles; a recurring signature screams. */
372
454
  async function recordWithLeases<Rels extends SchemaRelations, R>(
373
455
  core: Core<Rels>,
374
456
  state: WriterState,
375
457
  body: (batch: Batch<Rels>) => R | Promise<R>
376
458
  ): Promise<{ value: R; ops: Op[] }> {
377
459
  await ensureFreshLeases(core, state)
378
- const { batch, recording } = recorderOf(core, state)
379
- const value = await body(batch)
380
- return { value, ops: recording.ops }
460
+ for (;;) {
461
+ const draws = new Map<string, number>()
462
+ const { batch, recording } = recorderOf(core, state, draws)
463
+ const ran = await errors.try(
464
+ (async function runBody() {
465
+ return body(batch)
466
+ })()
467
+ )
468
+ if (!ran.error) {
469
+ return { value: ran.data, ops: recording.ops }
470
+ }
471
+ if (!errors.is(ran.error, ErrRefillNeeded)) {
472
+ throw ran.error
473
+ }
474
+ const need = refillNeededOf(ran.error)
475
+ if (need === undefined) {
476
+ throw ran.error
477
+ }
478
+ // Draws this attempt made on the starved key, the missed one included.
479
+ const drawn = draws.get(poolKey(need.relation, need.field)) ?? 0
480
+ state.scream.attempt(`id-lease refill relation ${need.relation} field ${need.field} draws ${drawn}`)
481
+ while (fullBlocksOf(state, need.relation, need.field) < drawn) {
482
+ await acquireLease(core, state, need.relation, need.field)
483
+ }
484
+ }
381
485
  }
382
486
 
383
487
  function braidsTouched<Rels extends SchemaRelations>(core: Core<Rels>, ops: readonly Op[]): Map<Braid, Op[]> {
@@ -407,7 +511,9 @@ function u64leAt(bytes: Uint8Array, at: number): bigint | undefined {
407
511
  }
408
512
 
409
513
  /** The usurper is a fact in the header. A body that refuses to decode
410
- * does not hide the slot's owner. */
514
+ * does not hide the slot's owner. The Rust writer machine sniffs the
515
+ * same fixed offset (`writer/loss.rs`); the batch grammar itself has
516
+ * one reader — the codec seat. */
411
517
  function headerWriter(bytes: Uint8Array): bigint | undefined {
412
518
  if (bytes.length < BATCH_MAGIC.length || !bytesEqual(bytes.subarray(0, BATCH_MAGIC.length), BATCH_MAGIC)) {
413
519
  return undefined
@@ -415,10 +521,6 @@ function headerWriter(bytes: Uint8Array): bigint | undefined {
415
521
  return u64leAt(bytes, WRITER_AT)
416
522
  }
417
523
 
418
- function headerTimestamp(bytes: Uint8Array): bigint | undefined {
419
- return u64leAt(bytes, WRITER_AT + 8)
420
- }
421
-
422
524
  /** Header prev is 32 branded bytes. */
423
525
  function digestPrev(prev: Digest32 | Uint8Array): Digest32 {
424
526
  return digest32(prev)
@@ -484,7 +586,7 @@ async function publishPending<Rels extends SchemaRelations>(
484
586
  core: Core<Rels>,
485
587
  state: WriterState,
486
588
  ops: readonly Op[]
487
- ): Promise<Commit<Rels, undefined>> {
589
+ ): Promise<Admission<Rels, Landing>> {
488
590
  let losses = 0
489
591
  for (;;) {
490
592
  const pending = pendingOf(core)
@@ -494,39 +596,38 @@ async function publishPending<Rels extends SchemaRelations>(
494
596
  const braid = pending.braid
495
597
  // The floor is a write precondition: a slot at or below it is
496
598
  // 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")
599
+ if (belowFloor(core, braid, pending.slot)) {
600
+ refuseSlotRetired({ braid, slot: pending.slot }, "the slot is retired")
499
601
  }
500
- const created = await core.store.putCreate(logKey(core.prefix, braid, pending.gen), pending.bytes)
602
+ const created = await core.store.putCreate(logKey(core.prefix, braid, pending.slot), pending.bytes)
501
603
  let winnerBytes: Uint8Array | null = null
502
604
  if (created.tag !== "created") {
503
- const fetched = await core.store.get(logKey(core.prefix, braid, pending.gen))
605
+ const fetched = await core.store.get(logKey(core.prefix, braid, pending.slot))
504
606
  if (fetched === null) {
505
607
  // Exists then null: the occupant was swept. Refuse
506
608
  // rather than loop back into putCreate.
507
- refuseSlotRetired({ braid, slot: pending.gen }, "the slot is retired")
609
+ refuseSlotRetired({ braid, slot: pending.slot }, "the slot is retired")
508
610
  }
509
611
  if (!bytesEqual(fetched.bytes, pending.bytes)) {
510
612
  winnerBytes = fetched.bytes
511
613
  }
512
614
  }
513
615
  if (winnerBytes === null) {
514
- const timestamp = headerTimestamp(pending.bytes)
515
- if (timestamp === undefined) {
516
- throw errors.new("pending batch header is shorter than the fixed layout")
616
+ if (pending.ts === null) {
617
+ throw errors.new("publish reached with an undecoded pending timestamp")
517
618
  }
518
619
  entriesOf(core).set(braid, {
519
- g: pending.gen,
620
+ g: pending.slot,
520
621
  prev: digest32(new Uint8Array(internalBlake3(pending.bytes))),
521
- ts: timestamp
622
+ ts: pending.ts
522
623
  })
523
624
  await clearPending(core)
524
- return { tag: "accepted", value: undefined, braid, generation: pending.gen, durability: "published" }
625
+ return { tag: "accepted", value: { slot: pending.slot, durability: "published" } }
525
626
  }
526
627
 
527
628
  losses += 1
528
629
  state.scream.attempt("slot occupant is not ours")
529
- noteDeposition(state, braid, pending.gen, winnerBytes)
630
+ noteDeposition(state, braid, pending.slot, winnerBytes)
530
631
  await discardAndReopen(core)
531
632
  const before = generationOf(core)
532
633
  const rejudged = applyOps(core, ops)
@@ -539,13 +640,7 @@ async function publishPending<Rels extends SchemaRelations>(
539
640
  }
540
641
  if (rejudged.value.generation === before) {
541
642
  await clearPending(core)
542
- return {
543
- tag: "accepted",
544
- value: undefined,
545
- braid,
546
- generation: chainEntry(core, braid).g,
547
- durability: "published"
548
- }
643
+ return { tag: "accepted", value: { slot: chainEntry(core, braid).g, durability: "published" } }
549
644
  }
550
645
  const tip = chainEntry(core, braid)
551
646
  entriesOf(core).set(braid, { g: tip.g, prev: digestPrev(tip.prev), ts: tip.ts })
@@ -564,13 +659,12 @@ async function disciplineCommit<Rels extends SchemaRelations>(
564
659
  state: WriterState,
565
660
  braid: Braid,
566
661
  ops: readonly Op[]
567
- ): Promise<Commit<Rels, undefined>> {
662
+ ): Promise<Admission<Rels, Landing>> {
568
663
  const entry = chainEntry(core, braid)
569
664
  const timestamp = maxBigint(BigInt(Date.now()), entry.ts)
570
665
  const bytes = encodeBatch(
571
666
  core.descriptor,
572
667
  {
573
- fingerprint: digest32(core.descriptor.fingerprintBytes),
574
668
  braid,
575
669
  braidGen: generation(entry.g + 1n),
576
670
  prev: digestPrev(entry.prev),
@@ -580,7 +674,7 @@ async function disciplineCommit<Rels extends SchemaRelations>(
580
674
  ops
581
675
  )
582
676
  // Pending → durable, before any apply.
583
- holdPending(core, { braid, gen: generation(entry.g + 1n), bytes }, ops)
677
+ holdPending(core, { braid, slot: generation(entry.g + 1n), bytes }, ops, timestamp)
584
678
  await persistSidecar(core)
585
679
 
586
680
  const before = generationOf(core)
@@ -591,7 +685,7 @@ async function disciplineCommit<Rels extends SchemaRelations>(
591
685
  }
592
686
  if (outcome.value.generation === before) {
593
687
  await clearPending(core)
594
- return { tag: "accepted", value: undefined, braid, generation: entry.g, durability: "published" }
688
+ return { tag: "accepted", value: { slot: entry.g, durability: "published" } }
595
689
  }
596
690
  return publishPending(core, state, ops)
597
691
  }
@@ -612,7 +706,7 @@ async function settleInheritedPending<Rels extends SchemaRelations>(
612
706
  generationOf(core),
613
707
  null,
614
708
  pending.bytes,
615
- belowFloor(core, pending.braid, pending.gen)
709
+ belowFloor(core, pending.braid, pending.slot)
616
710
  ).tag === "below-floor"
617
711
  ) {
618
712
  await clearPending(core)
@@ -628,6 +722,13 @@ async function settleInheritedPending<Rels extends SchemaRelations>(
628
722
  return
629
723
  }
630
724
  ops = decoded.data.ops
725
+ // The decoded arm re-holds so publish reads the header facts once.
726
+ holdPending(
727
+ core,
728
+ { braid: pending.braid, slot: pending.slot, bytes: pending.bytes },
729
+ ops,
730
+ decoded.data.header.timestamp
731
+ )
631
732
  }
632
733
  const published = await publishPending(core, state, ops)
633
734
  if (published.tag === "rejected") {
@@ -689,7 +790,7 @@ async function releaseScratch(dir: string): Promise<void> {
689
790
  async function casPublish(
690
791
  store: ObjectStore,
691
792
  prefix: string,
692
- known: ReadonlySet<Braid>,
793
+ codec: LogCodecHandle,
693
794
  candidate: CheckpointFacts,
694
795
  digest: Digest32,
695
796
  bytes: Uint8Array
@@ -716,7 +817,7 @@ async function casPublish(
716
817
  return { tag: "refused", reason: "checkpoint-doc-missing" }
717
818
  }
718
819
  const incumbentDoc = errors.trySync(function parseIncumbent() {
719
- return parseCheckpoint(doc.bytes, known)
820
+ return parseCheckpoint(codec, doc.bytes)
720
821
  })
721
822
  if (incumbentDoc.error) {
722
823
  return { tag: "refused", reason: "checkpoint" }
@@ -745,17 +846,18 @@ async function publishCheckpoint(
745
846
  store: ObjectStore,
746
847
  prefix: string,
747
848
  dir: string,
748
- known: ReadonlySet<Braid>,
849
+ theory: Theory,
749
850
  candidate: CheckpointFacts,
750
851
  mdb: Uint8Array
751
852
  ): Promise<Published> {
752
- const bytes = renderCheckpoint(candidate)
853
+ const codec = descriptorOf(theory).codec
854
+ const bytes = renderCheckpoint(codec, candidate)
753
855
  const digest = digest32(new Uint8Array(internalBlake3(bytes)))
754
856
  await claimScratch(dir, digest)
755
857
  const ran = await errors.try(
756
858
  (async function publish() {
757
859
  await putCreateOnce(store, checkpointMdbKey(prefix, digest), mdb)
758
- return await casPublish(store, prefix, known, candidate, digest, bytes)
860
+ return await casPublish(store, prefix, codec, candidate, digest, bytes)
759
861
  })()
760
862
  )
761
863
  if (ran.error) {
@@ -812,7 +914,7 @@ async function writerOn<Rels extends SchemaRelations>(replica: Replica<Rels>): P
812
914
  return withGate(core, async function commitBody() {
813
915
  const recorded = await recordWithLeases(core, state, body)
814
916
  if (recorded.ops.length === 0) {
815
- throw errors.new("commit recorded no ops — an empty transaction names no braid")
917
+ return { tag: "empty" as const, value: recorded.value }
816
918
  }
817
919
  const partitioned = braidsTouched(core, recorded.ops)
818
920
  if (partitioned.size > 1) {
@@ -823,7 +925,10 @@ async function writerOn<Rels extends SchemaRelations>(replica: Replica<Rels>): P
823
925
  if (outcome.tag === "rejected") {
824
926
  return outcome
825
927
  }
826
- return { ...outcome, value: recorded.value }
928
+ return {
929
+ tag: "accepted" as const,
930
+ value: { value: recorded.value, braid, slot: outcome.value.slot, durability: outcome.value.durability }
931
+ }
827
932
  })
828
933
  },
829
934
 
@@ -831,24 +936,15 @@ async function writerOn<Rels extends SchemaRelations>(replica: Replica<Rels>): P
831
936
  return withGate(core, async function splitBody() {
832
937
  const recorded = await recordWithLeases(core, state, body)
833
938
  if (recorded.ops.length === 0) {
834
- throw errors.new("commitSplit recorded no ops — an empty transaction names no braid")
939
+ return { tag: "empty" as const, value: recorded.value }
835
940
  }
836
941
  const partitioned = braidsTouched(core, recorded.ops)
837
942
  const outcomes: BraidOutcome<Rels>[] = []
838
943
  for (const [braid, ops] of partitioned) {
839
- const outcome = await disciplineCommit(core, state, braid, ops)
840
- if (outcome.tag === "rejected") {
841
- outcomes.push({ tag: "rejected", braid, violations: outcome.violations })
842
- } else {
843
- outcomes.push({
844
- tag: "accepted",
845
- braid: outcome.braid,
846
- generation: outcome.generation,
847
- durability: outcome.durability
848
- })
849
- }
944
+ const admission = await disciplineCommit(core, state, braid, ops)
945
+ outcomes.push({ braid, admission })
850
946
  }
851
- return { value: recorded.value, outcomes }
947
+ return { tag: "split" as const, value: recorded.value, outcomes }
852
948
  })
853
949
  }
854
950
  }
@@ -866,5 +962,16 @@ async function openWriter<Rels extends SchemaRelations>(
866
962
  return writerOn(await openReplica(source))
867
963
  }
868
964
 
869
- export type { Batch, BraidOutcome, Commit, CommitSplit, Deposition, Durability, Published, PublishRefusal, Writer }
965
+ export type {
966
+ Batch,
967
+ BraidOutcome,
968
+ Commit,
969
+ CommitReceipt,
970
+ CommitSplit,
971
+ Deposition,
972
+ Durability,
973
+ EmptyCommit,
974
+ Landing,
975
+ Writer
976
+ }
870
977
  export { openWriter, publishCheckpoint }