@bjornpagen/bumbledb-log 0.20.0 → 0.20.2

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.
Files changed (76) hide show
  1. package/dist/braids.d.ts +28 -0
  2. package/dist/braids.d.ts.map +1 -0
  3. package/{src/braids.ts → dist/braids.js} +22 -29
  4. package/dist/braids.js.map +1 -0
  5. package/dist/bytes.d.ts +26 -0
  6. package/dist/bytes.d.ts.map +1 -0
  7. package/dist/bytes.js +86 -0
  8. package/dist/bytes.js.map +1 -0
  9. package/dist/chain.d.ts +56 -0
  10. package/dist/chain.d.ts.map +1 -0
  11. package/dist/chain.js +137 -0
  12. package/dist/chain.js.map +1 -0
  13. package/dist/codec.d.ts +71 -0
  14. package/dist/codec.d.ts.map +1 -0
  15. package/dist/codec.js +179 -0
  16. package/dist/codec.js.map +1 -0
  17. package/dist/descriptor.d.ts +61 -0
  18. package/dist/descriptor.d.ts.map +1 -0
  19. package/dist/descriptor.js +160 -0
  20. package/dist/descriptor.js.map +1 -0
  21. package/dist/errors.d.ts +202 -0
  22. package/dist/errors.d.ts.map +1 -0
  23. package/dist/errors.js +122 -0
  24. package/dist/errors.js.map +1 -0
  25. package/dist/index.d.ts +34 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +24 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/keys.d.ts +52 -0
  30. package/dist/keys.d.ts.map +1 -0
  31. package/dist/keys.js +166 -0
  32. package/dist/keys.js.map +1 -0
  33. package/dist/manifest.d.ts +42 -0
  34. package/dist/manifest.d.ts.map +1 -0
  35. package/dist/manifest.js +116 -0
  36. package/dist/manifest.js.map +1 -0
  37. package/dist/replica.d.ts +154 -0
  38. package/dist/replica.d.ts.map +1 -0
  39. package/dist/replica.js +820 -0
  40. package/dist/replica.js.map +1 -0
  41. package/dist/store-s3.d.ts +31 -0
  42. package/dist/store-s3.d.ts.map +1 -0
  43. package/dist/store-s3.js +299 -0
  44. package/dist/store-s3.js.map +1 -0
  45. package/dist/store.d.ts +129 -0
  46. package/dist/store.d.ts.map +1 -0
  47. package/dist/store.js +577 -0
  48. package/dist/store.js.map +1 -0
  49. package/dist/tenants.d.ts +56 -0
  50. package/dist/tenants.d.ts.map +1 -0
  51. package/dist/tenants.js +333 -0
  52. package/dist/tenants.js.map +1 -0
  53. package/dist/tilde-family.json +21 -0
  54. package/dist/vector.d.ts +31 -0
  55. package/dist/vector.d.ts.map +1 -0
  56. package/dist/vector.js +87 -0
  57. package/dist/vector.js.map +1 -0
  58. package/dist/writer.d.ts +106 -0
  59. package/dist/writer.d.ts.map +1 -0
  60. package/dist/writer.js +677 -0
  61. package/dist/writer.js.map +1 -0
  62. package/package.json +12 -7
  63. package/src/bytes.ts +0 -115
  64. package/src/chain.ts +0 -181
  65. package/src/codec.ts +0 -252
  66. package/src/descriptor.ts +0 -240
  67. package/src/errors.ts +0 -309
  68. package/src/index.ts +0 -57
  69. package/src/keys.ts +0 -218
  70. package/src/manifest.ts +0 -161
  71. package/src/replica.ts +0 -1061
  72. package/src/store-s3.ts +0 -377
  73. package/src/store.ts +0 -727
  74. package/src/tenants.ts +0 -414
  75. package/src/vector.ts +0 -103
  76. package/src/writer.ts +0 -977
package/src/writer.ts DELETED
@@ -1,977 +0,0 @@
1
- /**
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.
14
- */
15
-
16
- import * as crypto from "node:crypto"
17
- import * as fs from "node:fs/promises"
18
- import * as path from "node:path"
19
- import {
20
- type Admission,
21
- type Fact,
22
- type FactValue,
23
- type FreshKeys,
24
- type FreshRange,
25
- internalBlake3,
26
- type LogCodecHandle,
27
- type MemberRelation,
28
- type MutationReport,
29
- rowOf,
30
- type SchemaRelations,
31
- type Violation
32
- } from "@bjornpagen/bumbledb"
33
- import * as errors from "@superbuilders/errors"
34
- import { regex } from "arkregex"
35
- import type { Digest32 } from "#bytes.ts"
36
- import { bytesEqual, checkedAddU64, digest32, utf8Encoder, utf8StrictDecoder } from "#bytes.ts"
37
- import { chainSum } from "#chain.ts"
38
- import type { Op } from "#codec.ts"
39
- import { decodeBatch, encodeBatch } from "#codec.ts"
40
- import type { Braid, RelationInfo, Theory } from "#descriptor.ts"
41
- import { descriptorOf } from "#descriptor.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"
54
- import {
55
- CKPT_SCRATCH_LEASE,
56
- checkpointMdbKey,
57
- ckptDocKey,
58
- encodeCkptScratch,
59
- generation,
60
- idsKey,
61
- LEASE_NAMESPACE,
62
- logKey,
63
- manifestKey
64
- } from "#keys.ts"
65
- import type { CheckpointFacts } from "#manifest.ts"
66
- import { checkpointVector, parseCheckpoint, parseManifest, renderCheckpoint, renderManifest } from "#manifest.ts"
67
- import type { Core, OpenReplicaOptions, Replica } from "#replica.ts"
68
- import {
69
- applyOps,
70
- belowFloor,
71
- chainEntry,
72
- clearPending,
73
- coreOf,
74
- discardAndReopen,
75
- entriesOf,
76
- foldPending,
77
- generationOf,
78
- holdPending,
79
- maxBigint,
80
- openReplica,
81
- pendingOf,
82
- persistSidecar,
83
- readdressPending,
84
- withGate
85
- } from "#replica.ts"
86
- import type { ObjectStore } from "#store.ts"
87
-
88
- /** 10 owns the width: one CAS amortizes counter traffic 4096× below slot traffic. */
89
- const LEASE_WIDTH = 4096n
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
95
-
96
- /** The live-loss bound (60): consecutive losses at the live tip, history never counts. */
97
- const LOSS_BOUND = 16
98
-
99
- /** Writer id in the fixed-layout header: magic + version + flags + fingerprint + braid + braid_gen + prev. */
100
- const WRITER_AT = 4 + 2 + 2 + 32 + 4 + 8 + 32
101
-
102
- const BATCH_MAGIC = utf8Encoder.encode("BDBL")
103
-
104
- type Durability = "published" | "local-pending"
105
-
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 {
115
- readonly value: R
116
- readonly braid: Braid
117
- }
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
-
143
- /**
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.
150
- */
151
- interface Batch<Rels extends SchemaRelations> {
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
155
- }
156
-
157
- /** Ownership of a slot, read from the winner's header. */
158
- interface Deposition {
159
- readonly braid: Braid
160
- readonly slot: Generation
161
- readonly resident: bigint
162
- readonly usurper: bigint
163
- }
164
-
165
- interface Writer<Rels extends SchemaRelations> {
166
- readonly role: "writer"
167
- readonly replica: Replica<Rels>
168
- deposition(): Deposition | null
169
- commit<R>(body: (batch: Batch<Rels>) => R | Promise<R>): Promise<Commit<Rels, R>>
170
- commitSplit<R>(body: (batch: Batch<Rels>) => R | Promise<R>): Promise<CommitSplit<Rels, R>>
171
- }
172
-
173
- interface LeaseRange {
174
- next: bigint
175
- readonly end: bigint
176
- }
177
-
178
- /** The scream of an unbounded repair loop: the set of recent signatures,
179
- * a warning every eighth attempt, and an alarm the moment any
180
- * signature recurs. */
181
- interface Scream {
182
- attempt(signature: string): void
183
- }
184
-
185
- function screamOf(context: string): Scream {
186
- const seen = new Set<string>()
187
- let attempts = 0
188
- return {
189
- attempt(signature) {
190
- attempts += 1
191
- if (seen.has(signature)) {
192
- console.error(`bumbledb-log alarm: ${context} repair signature recurs: ${signature}`)
193
- } else {
194
- seen.add(signature)
195
- }
196
- if (attempts % 8 === 0) {
197
- console.error(`bumbledb-log warning: ${context} repair attempt ${attempts}: ${signature}`)
198
- }
199
- }
200
- }
201
- }
202
-
203
- interface WriterState {
204
- readonly writerId: bigint
205
- readonly pools: Map<string, LeaseRange[]>
206
- readonly scream: Scream
207
- deposition: Deposition | null
208
- }
209
-
210
- function poolKey(relation: number, field: number): string {
211
- return `${relation}:${field}`
212
- }
213
-
214
- function remainingOf(state: WriterState, relation: number, field: number): bigint {
215
- const pool = state.pools.get(poolKey(relation, field)) ?? []
216
- let remaining = 0n
217
- for (const range of pool) {
218
- remaining += range.end - range.next
219
- }
220
- return remaining
221
- }
222
-
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 {
280
- if (count < 0n) {
281
- throw errors.new(`id-lease count is unsigned: ${count}`)
282
- }
283
- if (count > LEASE_WIDTH) {
284
- refuseOverWidth({ requested: count }, `id-lease draw ${count} exceeds the lease width ${LEASE_WIDTH}`)
285
- }
286
- if (count === 0n) {
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()
311
- }
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`)
327
- }
328
- if (!ID_LEASE_COUNTER.test(decoded.data)) {
329
- refuse({ kind: "Counter", key }, `id-lease counter ${key} body is not a canonical decimal`)
330
- }
331
- const value = BigInt(decoded.data)
332
- if (value > U64_MAX) {
333
- refuse({ kind: "Counter", key }, `id-lease counter ${key} value leaves u64`)
334
- }
335
- return value
336
- }
337
-
338
- /** `ids/{relation}/{field}`: birth claims [0, width); every later lease CAS-increments. */
339
- async function acquireLease<Rels extends SchemaRelations>(
340
- core: Core<Rels>,
341
- state: WriterState,
342
- relation: number,
343
- field: number
344
- ): Promise<void> {
345
- const key = idsKey(core.prefix, relation, field)
346
- for (;;) {
347
- const fetched = await core.store.get(key)
348
- if (fetched === null) {
349
- const created = await core.store.putCreate(key, utf8Encoder.encode(String(LEASE_WIDTH)))
350
- if (created.tag === "created") {
351
- pushRange(state, relation, field, 0n, LEASE_WIDTH)
352
- return
353
- }
354
- continue
355
- }
356
- const next = parseCounter(key, fetched.bytes)
357
- const end = checkedAddU64(next, LEASE_WIDTH)
358
- if (end === undefined) {
359
- refuseExhausted({ relation, field }, `id-lease relation ${relation} field ${field} would leave u64`)
360
- }
361
- const swapped = await core.store.putSwap(key, utf8Encoder.encode(String(end)), fetched.etag)
362
- if (swapped.tag === "swapped") {
363
- pushRange(state, relation, field, next, end)
364
- return
365
- }
366
- }
367
- }
368
-
369
- function pushRange(state: WriterState, relation: number, field: number, next: bigint, end: bigint): void {
370
- const pool = state.pools.get(poolKey(relation, field)) ?? []
371
- pool.push({ next, end })
372
- state.pools.set(poolKey(relation, field), pool)
373
- }
374
-
375
- async function ensureFreshLeases<Rels extends SchemaRelations>(core: Core<Rels>, state: WriterState): Promise<void> {
376
- for (const info of core.descriptor.relations) {
377
- if (info.closed) {
378
- continue
379
- }
380
- for (const [ordinal, field] of info.fields.entries()) {
381
- if (field.fresh && remainingOf(state, info.id, ordinal) === 0n) {
382
- await acquireLease(core, state, info.id, ordinal)
383
- }
384
- }
385
- }
386
- }
387
-
388
- interface Recording {
389
- readonly ops: Op[]
390
- }
391
-
392
- function recorderOf<Rels extends SchemaRelations>(
393
- core: Core<Rels>,
394
- state: WriterState,
395
- draws: Map<string, number>
396
- ): { batch: Batch<Rels>; recording: Recording } {
397
- const recording: Recording = { ops: [] }
398
- function infoOf(name: string): RelationInfo {
399
- const info = core.descriptor.relationByName.get(name)
400
- if (info === undefined) {
401
- throw errors.new(`relation ${name} is not a member of this theory`)
402
- }
403
- if (info.closed) {
404
- throw errors.new(`relation ${name} is closed — sealed rows never change`)
405
- }
406
- return info
407
- }
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[][] = []
418
- for (const fact of facts) {
419
- rows.push(rowOf(relation.data, fact))
420
- }
421
- recording.ops.push({ op, relation: info.name, rows })
422
- return Object.freeze({ submitted: BigInt(rows.length), changed: 0n })
423
- }
424
- const batch: Batch<Rels> = {
425
- insert(relation, facts) {
426
- return record("insert", relation, facts)
427
- },
428
- delete(relation, facts) {
429
- return record("delete", relation, facts)
430
- },
431
- reserve(relation, field, count) {
432
- const info = infoOf(relation.name)
433
- const ordinal = info.fields.findIndex(function byName(candidate) {
434
- return candidate.name === field
435
- })
436
- const declared = info.fields[ordinal]
437
- if (declared === undefined || !declared.fresh) {
438
- throw errors.new(`relation ${relation.name}: field ${field} is not a fresh cell`)
439
- }
440
- return drawIds(state, draws, info.id, ordinal, count)
441
- }
442
- }
443
- return { batch, recording }
444
- }
445
-
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. */
454
- async function recordWithLeases<Rels extends SchemaRelations, R>(
455
- core: Core<Rels>,
456
- state: WriterState,
457
- body: (batch: Batch<Rels>) => R | Promise<R>
458
- ): Promise<{ value: R; ops: Op[] }> {
459
- await ensureFreshLeases(core, state)
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
- }
485
- }
486
-
487
- function braidsTouched<Rels extends SchemaRelations>(core: Core<Rels>, ops: readonly Op[]): Map<Braid, Op[]> {
488
- const partitioned = new Map<Braid, Op[]>()
489
- for (const op of ops) {
490
- const info = core.descriptor.relationByName.get(op.relation)
491
- const braid = info === undefined ? undefined : core.descriptor.braidOfRelation.get(info.id)
492
- if (braid === undefined) {
493
- throw errors.new(`relation ${op.relation} belongs to no braid`)
494
- }
495
- const bucket = partitioned.get(braid)
496
- if (bucket === undefined) {
497
- partitioned.set(braid, [op])
498
- } else {
499
- bucket.push(op)
500
- }
501
- }
502
- return partitioned
503
- }
504
-
505
- function u64leAt(bytes: Uint8Array, at: number): bigint | undefined {
506
- if (bytes.length < at + 8) {
507
- return undefined
508
- }
509
- const view = new DataView(bytes.buffer, bytes.byteOffset + at, 8)
510
- return view.getBigUint64(0, true)
511
- }
512
-
513
- /** The usurper is a fact in the header. A body that refuses to decode
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. */
517
- function headerWriter(bytes: Uint8Array): bigint | undefined {
518
- if (bytes.length < BATCH_MAGIC.length || !bytesEqual(bytes.subarray(0, BATCH_MAGIC.length), BATCH_MAGIC)) {
519
- return undefined
520
- }
521
- return u64leAt(bytes, WRITER_AT)
522
- }
523
-
524
- /** Header prev is 32 branded bytes. */
525
- function digestPrev(prev: Digest32 | Uint8Array): Digest32 {
526
- return digest32(prev)
527
- }
528
-
529
- function noteDeposition(state: WriterState, braid: Braid, slot: Generation, winnerBytes: Uint8Array): void {
530
- if (state.deposition !== null) {
531
- return
532
- }
533
- const usurper = headerWriter(winnerBytes)
534
- if (usurper === undefined) {
535
- return
536
- }
537
- state.deposition = { braid, slot, resident: state.writerId, usurper }
538
- }
539
-
540
- /** The terminal contention scream: the re-judgment's own rejection names
541
- * the hot statement and carries the offending facts' raw values; an
542
- * accepted-but-outraced terminal loss carries the racing tip. A
543
- * rejection without a violation is refused — the empty statement is
544
- * unrepresentable. */
545
- function screamContention<Rels extends SchemaRelations>(
546
- braid: Braid,
547
- rejudged:
548
- | { readonly tag: "rejected"; readonly violations: readonly Violation<Rels>[] }
549
- | { readonly tag: "outraced"; readonly tip: Generation }
550
- ): never {
551
- if (rejudged.tag === "rejected") {
552
- const violation = rejudged.violations[0]
553
- if (violation === undefined) {
554
- throw errors.new("a rejection carries at least one violation")
555
- }
556
- throwContention(
557
- {
558
- braid,
559
- cause: {
560
- kind: "hot-key",
561
- statement: violation.canonical,
562
- determinants: violation.facts.map(function rawOf(offending) {
563
- return offending.fact
564
- })
565
- }
566
- },
567
- `braid ${braid}: ${LOSS_BOUND} consecutive losses at the live tip and the terminal re-judgment rejected`
568
- )
569
- }
570
- throwContention(
571
- { braid, cause: { kind: "slot-race", tip: rejudged.tip } },
572
- `braid ${braid}: ${LOSS_BOUND} consecutive losses at the live tip outraced accepted re-judgments`
573
- )
574
- }
575
-
576
- /**
577
- * Publishes the applied pending batch: refuse a below-floor create,
578
- * then slot CAS, then the one loss path on Exists. A byte-equal
579
- * occupant is our own ambiguous PUT, absorbed. Anything else carries
580
- * the pending through a directory discard — re-persisted into the
581
- * fresh sidecar before any re-judgment — re-opens to the current tip,
582
- * and re-judges the recorded ops in one db.write. An occupant that
583
- * then vanishes is retired, not a loop that forges the swept slot.
584
- */
585
- async function publishPending<Rels extends SchemaRelations>(
586
- core: Core<Rels>,
587
- state: WriterState,
588
- ops: readonly Op[]
589
- ): Promise<Admission<Rels, Landing>> {
590
- let losses = 0
591
- for (;;) {
592
- const pending = pendingOf(core)
593
- if (pending === null) {
594
- throw errors.new("publish reached with no pending batch")
595
- }
596
- const braid = pending.braid
597
- // The floor is a write precondition: a slot at or below it is
598
- // retired. A create must not touch the store (70/116/127).
599
- if (belowFloor(core, braid, pending.slot)) {
600
- refuseSlotRetired({ braid, slot: pending.slot }, "the slot is retired")
601
- }
602
- const created = await core.store.putCreate(logKey(core.prefix, braid, pending.slot), pending.bytes)
603
- let winnerBytes: Uint8Array | null = null
604
- if (created.tag !== "created") {
605
- const fetched = await core.store.get(logKey(core.prefix, braid, pending.slot))
606
- if (fetched === null) {
607
- // Exists then null: the occupant was swept. Refuse
608
- // rather than loop back into putCreate.
609
- refuseSlotRetired({ braid, slot: pending.slot }, "the slot is retired")
610
- }
611
- if (!bytesEqual(fetched.bytes, pending.bytes)) {
612
- winnerBytes = fetched.bytes
613
- }
614
- }
615
- if (winnerBytes === null) {
616
- if (pending.ts === null) {
617
- throw errors.new("publish reached with an undecoded pending timestamp")
618
- }
619
- entriesOf(core).set(braid, {
620
- g: pending.slot,
621
- prev: digest32(new Uint8Array(internalBlake3(pending.bytes))),
622
- ts: pending.ts
623
- })
624
- await clearPending(core)
625
- return { tag: "accepted", value: { slot: pending.slot, durability: "published" } }
626
- }
627
-
628
- losses += 1
629
- state.scream.attempt("slot occupant is not ours")
630
- noteDeposition(state, braid, pending.slot, winnerBytes)
631
- await discardAndReopen(core)
632
- const before = generationOf(core)
633
- const rejudged = applyOps(core, ops)
634
- if (rejudged.tag === "rejected") {
635
- await clearPending(core)
636
- if (losses >= LOSS_BOUND) {
637
- screamContention(braid, { tag: "rejected", violations: rejudged.violations })
638
- }
639
- return { tag: "rejected", violations: rejudged.violations }
640
- }
641
- if (rejudged.value.generation === before) {
642
- await clearPending(core)
643
- return { tag: "accepted", value: { slot: chainEntry(core, braid).g, durability: "published" } }
644
- }
645
- const tip = chainEntry(core, braid)
646
- entriesOf(core).set(braid, { g: tip.g, prev: digestPrev(tip.prev), ts: tip.ts })
647
- await readdressPending(core, ops, state.writerId)
648
- if (losses >= LOSS_BOUND) {
649
- screamContention(braid, { tag: "outraced", tip: chainEntry(core, braid).g })
650
- }
651
- }
652
- }
653
-
654
- /** The commit discipline (60): encode, fsync as Pending before any
655
- * apply, judge locally, publish only what advanced the generation.
656
- * Settled is written only after the verdict. */
657
- async function disciplineCommit<Rels extends SchemaRelations>(
658
- core: Core<Rels>,
659
- state: WriterState,
660
- braid: Braid,
661
- ops: readonly Op[]
662
- ): Promise<Admission<Rels, Landing>> {
663
- const entry = chainEntry(core, braid)
664
- const timestamp = maxBigint(BigInt(Date.now()), entry.ts)
665
- const bytes = encodeBatch(
666
- core.descriptor,
667
- {
668
- braid,
669
- braidGen: generation(entry.g + 1n),
670
- prev: digestPrev(entry.prev),
671
- writer: state.writerId,
672
- timestamp
673
- },
674
- ops
675
- )
676
- // Pending → durable, before any apply.
677
- holdPending(core, { braid, slot: generation(entry.g + 1n), bytes }, ops, timestamp)
678
- await persistSidecar(core)
679
-
680
- const before = generationOf(core)
681
- const outcome = applyOps(core, ops)
682
- if (outcome.tag === "rejected") {
683
- await clearPending(core)
684
- return { tag: "rejected", violations: outcome.violations }
685
- }
686
- if (outcome.value.generation === before) {
687
- await clearPending(core)
688
- return { tag: "accepted", value: { slot: entry.g, durability: "published" } }
689
- }
690
- return publishPending(core, state, ops)
691
- }
692
-
693
- /** An inherited pending is resolved-and-published by open. A slot the
694
- * floor already covers is published (`Clear`), not re-judged (46). */
695
- async function settleInheritedPending<Rels extends SchemaRelations>(
696
- core: Core<Rels>,
697
- state: WriterState
698
- ): Promise<void> {
699
- const pending = pendingOf(core)
700
- if (pending === null) {
701
- return
702
- }
703
- if (
704
- foldPending(
705
- chainSum(core.chain),
706
- generationOf(core),
707
- null,
708
- pending.bytes,
709
- belowFloor(core, pending.braid, pending.slot)
710
- ).tag === "below-floor"
711
- ) {
712
- await clearPending(core)
713
- return
714
- }
715
- let ops = pending.ops
716
- if (ops === null) {
717
- const decoded = errors.trySync(function decodePending() {
718
- return decodeBatch(core.descriptor, pending.bytes)
719
- })
720
- if (decoded.error) {
721
- await clearPending(core)
722
- return
723
- }
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
- )
732
- }
733
- const published = await publishPending(core, state, ops)
734
- if (published.tag === "rejected") {
735
- return
736
- }
737
- }
738
-
739
- type PublishRefusal = "manifest-missing" | "manifest" | "checkpoint-doc-missing" | "checkpoint"
740
-
741
- type Published =
742
- | { readonly tag: "replaced" }
743
- | { readonly tag: "kept"; readonly incumbent: Digest32 }
744
- | { readonly tag: "refused"; readonly reason: PublishRefusal }
745
-
746
- async function putCreateOnce(store: ObjectStore, key: ReturnType<typeof ckptDocKey>, bytes: Uint8Array): Promise<void> {
747
- for (;;) {
748
- const created = await store.putCreate(key, bytes)
749
- if (created.tag === "created" || created.tag === "exists") {
750
- return
751
- }
752
- const fetched = await store.get(key)
753
- if (fetched !== null) {
754
- return
755
- }
756
- }
757
- }
758
-
759
- /** The loser deletes its own `ckpt/{digest}` and `.mdb`. */
760
- async function deleteOrphan(store: ObjectStore, prefix: string, digest: Digest32): Promise<void> {
761
- await store.delete(ckptDocKey(prefix, digest))
762
- await store.delete(checkpointMdbKey(prefix, digest))
763
- }
764
-
765
- function scratchPath(dir: string): string {
766
- return path.join(dir, LEASE_NAMESPACE, CKPT_SCRATCH_LEASE)
767
- }
768
-
769
- async function claimScratch(dir: string, digest: Digest32): Promise<void> {
770
- const target = scratchPath(dir)
771
- await fs.mkdir(path.dirname(target), { recursive: true })
772
- const handle = await fs.open(target, "w")
773
- const written = await errors.try(
774
- (async function writeLease() {
775
- await handle.writeFile(encodeCkptScratch(digest))
776
- await handle.sync()
777
- })()
778
- )
779
- await handle.close()
780
- if (written.error) {
781
- await fs.rm(target, { force: true })
782
- throw written.error
783
- }
784
- }
785
-
786
- async function releaseScratch(dir: string): Promise<void> {
787
- await fs.rm(scratchPath(dir), { force: true })
788
- }
789
-
790
- async function casPublish(
791
- store: ObjectStore,
792
- prefix: string,
793
- codec: LogCodecHandle,
794
- candidate: CheckpointFacts,
795
- digest: Digest32,
796
- bytes: Uint8Array
797
- ): Promise<Published> {
798
- await putCreateOnce(store, ckptDocKey(prefix, digest), bytes)
799
- for (;;) {
800
- const fetched = await store.get(manifestKey(prefix))
801
- if (fetched === null) {
802
- return { tag: "refused", reason: "manifest-missing" }
803
- }
804
- const parsed = errors.trySync(function parse() {
805
- return parseManifest(fetched.bytes)
806
- })
807
- if (parsed.error) {
808
- return { tag: "refused", reason: "manifest" }
809
- }
810
- const incumbent = parsed.data.checkpoint
811
- if (incumbent !== null && bytesEqual(incumbent, digest)) {
812
- return { tag: "replaced" }
813
- }
814
- if (incumbent !== null) {
815
- const doc = await store.get(ckptDocKey(prefix, incumbent))
816
- if (doc === null) {
817
- return { tag: "refused", reason: "checkpoint-doc-missing" }
818
- }
819
- const incumbentDoc = errors.trySync(function parseIncumbent() {
820
- return parseCheckpoint(codec, doc.bytes)
821
- })
822
- if (incumbentDoc.error) {
823
- return { tag: "refused", reason: "checkpoint" }
824
- }
825
- if (checkpointVector(candidate).order(checkpointVector(incumbentDoc.data)) !== "after") {
826
- return { tag: "kept", incumbent }
827
- }
828
- }
829
- const next = renderManifest({ fingerprint: parsed.data.fingerprint, checkpoint: digest })
830
- const swapped = await store.putSwap(manifestKey(prefix), next, fetched.etag)
831
- if (swapped.tag === "swapped") {
832
- return { tag: "replaced" }
833
- }
834
- }
835
- }
836
-
837
- /**
838
- * Publishes `candidate` under the checkpoint order. The digest is the
839
- * blake3 of the full bytes, `prev` included. A scratch lease
840
- * `{dir}/~lease/ckpt-scratch` names the candidate for the publish
841
- * window; a crash leaves that lease and a successor sweep reclaims
842
- * it. `Kept` and every refused publish delete the candidate's `ckpt`
843
- * pair.
844
- */
845
- async function publishCheckpoint(
846
- store: ObjectStore,
847
- prefix: string,
848
- dir: string,
849
- theory: Theory,
850
- candidate: CheckpointFacts,
851
- mdb: Uint8Array
852
- ): Promise<Published> {
853
- const codec = descriptorOf(theory).codec
854
- const bytes = renderCheckpoint(codec, candidate)
855
- const digest = digest32(new Uint8Array(internalBlake3(bytes)))
856
- await claimScratch(dir, digest)
857
- const ran = await errors.try(
858
- (async function publish() {
859
- await putCreateOnce(store, checkpointMdbKey(prefix, digest), mdb)
860
- return await casPublish(store, prefix, codec, candidate, digest, bytes)
861
- })()
862
- )
863
- if (ran.error) {
864
- throw ran.error
865
- }
866
- if (ran.data.tag === "kept" || ran.data.tag === "refused") {
867
- await deleteOrphan(store, prefix, digest)
868
- }
869
- await releaseScratch(dir)
870
- return ran.data
871
- }
872
-
873
- /** Only the writer births a store: create-only PUT of a genesis manifest. */
874
- async function birthStore(store: ObjectStore, prefix: string, fingerprint: Digest32): Promise<void> {
875
- const key = manifestKey(prefix)
876
- for (;;) {
877
- const fetched = await store.get(key)
878
- if (fetched !== null) {
879
- return
880
- }
881
- const bytes = renderManifest({ fingerprint, checkpoint: null })
882
- const created = await store.putCreate(key, bytes)
883
- if (created.tag === "created") {
884
- return
885
- }
886
- }
887
- }
888
-
889
- function isReplica<Rels extends SchemaRelations>(
890
- source: Replica<Rels> | OpenReplicaOptions<Rels>
891
- ): source is Replica<Rels> {
892
- return typeof (source as Replica<Rels>).refresh === "function"
893
- }
894
-
895
- /** Wrap a born replica: settle an inherited pending; do not birth; do not draw id leases. */
896
- async function writerOn<Rels extends SchemaRelations>(replica: Replica<Rels>): Promise<Writer<Rels>> {
897
- const core = coreOf(replica)
898
- const state: WriterState = {
899
- writerId: crypto.randomBytes(8).readBigUInt64LE(),
900
- pools: new Map(),
901
- scream: screamOf("writer discard-and-re-pull"),
902
- deposition: null
903
- }
904
- await withGate(core, async function openTransition() {
905
- await settleInheritedPending(core, state)
906
- })
907
- return {
908
- role: "writer",
909
- replica,
910
- deposition() {
911
- return state.deposition
912
- },
913
- async commit(body) {
914
- return withGate(core, async function commitBody() {
915
- const recorded = await recordWithLeases(core, state, body)
916
- if (recorded.ops.length === 0) {
917
- return { tag: "empty" as const, value: recorded.value }
918
- }
919
- const partitioned = braidsTouched(core, recorded.ops)
920
- if (partitioned.size > 1) {
921
- throw errors.wrap(ErrSpanningCommit, `the recorded ops span braids ${[...partitioned.keys()].join(", ")}`)
922
- }
923
- const [braid, ops] = [...partitioned.entries()][0] as [Braid, Op[]]
924
- const outcome = await disciplineCommit(core, state, braid, ops)
925
- if (outcome.tag === "rejected") {
926
- return outcome
927
- }
928
- return {
929
- tag: "accepted" as const,
930
- value: { value: recorded.value, braid, slot: outcome.value.slot, durability: outcome.value.durability }
931
- }
932
- })
933
- },
934
-
935
- async commitSplit(body) {
936
- return withGate(core, async function splitBody() {
937
- const recorded = await recordWithLeases(core, state, body)
938
- if (recorded.ops.length === 0) {
939
- return { tag: "empty" as const, value: recorded.value }
940
- }
941
- const partitioned = braidsTouched(core, recorded.ops)
942
- const outcomes: BraidOutcome<Rels>[] = []
943
- for (const [braid, ops] of partitioned) {
944
- const admission = await disciplineCommit(core, state, braid, ops)
945
- outcomes.push({ braid, admission })
946
- }
947
- return { tag: "split" as const, value: recorded.value, outcomes }
948
- })
949
- }
950
- }
951
- }
952
-
953
- async function openWriter<Rels extends SchemaRelations>(replica: Replica<Rels>): Promise<Writer<Rels>>
954
- async function openWriter<Rels extends SchemaRelations>(options: OpenReplicaOptions<Rels>): Promise<Writer<Rels>>
955
- async function openWriter<Rels extends SchemaRelations>(
956
- source: Replica<Rels> | OpenReplicaOptions<Rels>
957
- ): Promise<Writer<Rels>> {
958
- if (isReplica(source)) {
959
- return writerOn(source)
960
- }
961
- await birthStore(source.store, source.prefix, digest32(descriptorOf(source.theory).fingerprintBytes))
962
- return writerOn(await openReplica(source))
963
- }
964
-
965
- export type {
966
- Batch,
967
- BraidOutcome,
968
- Commit,
969
- CommitReceipt,
970
- CommitSplit,
971
- Deposition,
972
- Durability,
973
- EmptyCommit,
974
- Landing,
975
- Writer
976
- }
977
- export { openWriter, publishCheckpoint }