@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/dist/writer.js ADDED
@@ -0,0 +1,677 @@
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
+ import * as crypto from "node:crypto";
16
+ import * as fs from "node:fs/promises";
17
+ import * as path from "node:path";
18
+ import { internalBlake3, rowOf } from "@bjornpagen/bumbledb";
19
+ import * as errors from "@superbuilders/errors";
20
+ import { regex } from "arkregex";
21
+ import { bytesEqual, checkedAddU64, digest32, utf8Encoder, utf8StrictDecoder } from "#bytes.ts";
22
+ import { chainSum } from "#chain.ts";
23
+ import { decodeBatch, encodeBatch } from "#codec.ts";
24
+ import { descriptorOf } from "#descriptor.ts";
25
+ import { ErrRefillNeeded, ErrSpanningCommit, refillNeededOf, refuse, refuseExhausted, refuseOverWidth, refuseSlotRetired, throwContention, throwRefillNeeded } from "#errors.ts";
26
+ import { CKPT_SCRATCH_LEASE, checkpointMdbKey, ckptDocKey, encodeCkptScratch, generation, idsKey, LEASE_NAMESPACE, logKey, manifestKey } from "#keys.ts";
27
+ import { checkpointVector, parseCheckpoint, parseManifest, renderCheckpoint, renderManifest } from "#manifest.ts";
28
+ import { applyOps, belowFloor, chainEntry, clearPending, coreOf, discardAndReopen, entriesOf, foldPending, generationOf, holdPending, maxBigint, openReplica, pendingOf, persistSidecar, readdressPending, withGate } from "#replica.ts";
29
+ /** 10 owns the width: one CAS amortizes counter traffic 4096× below slot traffic. */
30
+ const LEASE_WIDTH = 4096n;
31
+ /** The counter body's one grammar (`conformance/v3/counter/`): a
32
+ * canonical u64 decimal — no sign, no leading zero, no trailing bytes. */
33
+ const ID_LEASE_COUNTER = regex("^(?:0|[1-9][0-9]*)$");
34
+ const U64_MAX = 0xffffffffffffffffn;
35
+ /** The live-loss bound (60): consecutive losses at the live tip, history never counts. */
36
+ const LOSS_BOUND = 16;
37
+ /** Writer id in the fixed-layout header: magic + version + flags + fingerprint + braid + braid_gen + prev. */
38
+ const WRITER_AT = 4 + 2 + 2 + 32 + 4 + 8 + 32;
39
+ const BATCH_MAGIC = utf8Encoder.encode("BDBL");
40
+ function screamOf(context) {
41
+ const seen = new Set();
42
+ let attempts = 0;
43
+ return {
44
+ attempt(signature) {
45
+ attempts += 1;
46
+ if (seen.has(signature)) {
47
+ console.error(`bumbledb-log alarm: ${context} repair signature recurs: ${signature}`);
48
+ }
49
+ else {
50
+ seen.add(signature);
51
+ }
52
+ if (attempts % 8 === 0) {
53
+ console.error(`bumbledb-log warning: ${context} repair attempt ${attempts}: ${signature}`);
54
+ }
55
+ }
56
+ };
57
+ }
58
+ function poolKey(relation, field) {
59
+ return `${relation}:${field}`;
60
+ }
61
+ function remainingOf(state, relation, field) {
62
+ const pool = state.pools.get(poolKey(relation, field)) ?? [];
63
+ let remaining = 0n;
64
+ for (const range of pool) {
65
+ remaining += range.end - range.next;
66
+ }
67
+ return remaining;
68
+ }
69
+ /** The zero draw is the absence of a range (the engine's ruling). */
70
+ const EMPTY_RANGE = Object.freeze({
71
+ empty: true,
72
+ count: 0n,
73
+ at(_index) {
74
+ return undefined;
75
+ },
76
+ *[Symbol.iterator]() { }
77
+ });
78
+ /** A drawn range as the engine's FreshRange value: contiguous, frozen. */
79
+ function drawnRange(start, endExclusive) {
80
+ const count = endExclusive - start;
81
+ return Object.freeze({
82
+ empty: false,
83
+ start,
84
+ endExclusive,
85
+ count,
86
+ at(index) {
87
+ if (index < 0n || index >= count) {
88
+ return undefined;
89
+ }
90
+ return start + index;
91
+ },
92
+ *[Symbol.iterator]() {
93
+ for (let id = start; id < endExclusive; id++) {
94
+ yield id;
95
+ }
96
+ }
97
+ });
98
+ }
99
+ /** Untouched full-width blocks pooled for the key: the record loop's
100
+ * guarantee unit — k full blocks serve any k draws, each at most one
101
+ * width, whatever tails the draws abandon. */
102
+ function fullBlocksOf(state, relation, field) {
103
+ const pool = state.pools.get(poolKey(relation, field)) ?? [];
104
+ let full = 0;
105
+ for (const range of pool) {
106
+ if (range.end - range.next >= LEASE_WIDTH) {
107
+ full += 1;
108
+ }
109
+ }
110
+ return full;
111
+ }
112
+ /** `Lease.draw(count)` = OverWidth | Exhausted | Drawn, the log-Rust
113
+ * algebra: Exhausted names the u64 ceiling ONLY; a draw the pooled
114
+ * tail cannot serve abandons the tail (unique, never dense) and falls
115
+ * to the next block, and an empty pool signals a refill. */
116
+ function drawIds(state, draws, relation, field, count) {
117
+ if (count < 0n) {
118
+ throw errors.new(`id-lease count is unsigned: ${count}`);
119
+ }
120
+ if (count > LEASE_WIDTH) {
121
+ refuseOverWidth({ requested: count }, `id-lease draw ${count} exceeds the lease width ${LEASE_WIDTH}`);
122
+ }
123
+ if (count === 0n) {
124
+ return EMPTY_RANGE;
125
+ }
126
+ const key = poolKey(relation, field);
127
+ const drawn = (draws.get(key) ?? 0) + 1;
128
+ draws.set(key, drawn);
129
+ const pool = state.pools.get(key) ?? [];
130
+ while (pool.length > 0) {
131
+ const range = pool[0];
132
+ if (range === undefined) {
133
+ break;
134
+ }
135
+ const end = checkedAddU64(range.next, count);
136
+ if (end === undefined) {
137
+ refuseExhausted({ relation, field }, `id-lease relation ${relation} field ${field} would leave u64`);
138
+ }
139
+ if (end <= range.end) {
140
+ const start = range.next;
141
+ range.next = end;
142
+ if (range.next >= range.end) {
143
+ pool.shift();
144
+ }
145
+ return drawnRange(start, end);
146
+ }
147
+ pool.shift();
148
+ }
149
+ throwRefillNeeded({ relation, field, requested: count }, `id-lease relation ${relation} field ${field} pool cannot serve ${count}`);
150
+ }
151
+ /** The counter parse: canonical decimal within u64 or a typed Counter
152
+ * refusal — leading zeros, signs, trailing bytes, non-UTF-8, and
153
+ * values past u64 all refuse under the one pinned identity. */
154
+ function parseCounter(key, bytes) {
155
+ const decoded = errors.trySync(function decodeCounter() {
156
+ return utf8StrictDecoder.decode(bytes);
157
+ });
158
+ if (decoded.error) {
159
+ refuse({ kind: "Counter", key }, `id-lease counter ${key} body is not UTF-8`);
160
+ }
161
+ if (!ID_LEASE_COUNTER.test(decoded.data)) {
162
+ refuse({ kind: "Counter", key }, `id-lease counter ${key} body is not a canonical decimal`);
163
+ }
164
+ const value = BigInt(decoded.data);
165
+ if (value > U64_MAX) {
166
+ refuse({ kind: "Counter", key }, `id-lease counter ${key} value leaves u64`);
167
+ }
168
+ return value;
169
+ }
170
+ /** `ids/{relation}/{field}`: birth claims [0, width); every later lease CAS-increments. */
171
+ async function acquireLease(core, state, relation, field) {
172
+ const key = idsKey(core.prefix, relation, field);
173
+ for (;;) {
174
+ const fetched = await core.store.get(key);
175
+ if (fetched === null) {
176
+ const created = await core.store.putCreate(key, utf8Encoder.encode(String(LEASE_WIDTH)));
177
+ if (created.tag === "created") {
178
+ pushRange(state, relation, field, 0n, LEASE_WIDTH);
179
+ return;
180
+ }
181
+ continue;
182
+ }
183
+ const next = parseCounter(key, fetched.bytes);
184
+ const end = checkedAddU64(next, LEASE_WIDTH);
185
+ if (end === undefined) {
186
+ refuseExhausted({ relation, field }, `id-lease relation ${relation} field ${field} would leave u64`);
187
+ }
188
+ const swapped = await core.store.putSwap(key, utf8Encoder.encode(String(end)), fetched.etag);
189
+ if (swapped.tag === "swapped") {
190
+ pushRange(state, relation, field, next, end);
191
+ return;
192
+ }
193
+ }
194
+ }
195
+ function pushRange(state, relation, field, next, end) {
196
+ const pool = state.pools.get(poolKey(relation, field)) ?? [];
197
+ pool.push({ next, end });
198
+ state.pools.set(poolKey(relation, field), pool);
199
+ }
200
+ async function ensureFreshLeases(core, state) {
201
+ for (const info of core.descriptor.relations) {
202
+ if (info.closed) {
203
+ continue;
204
+ }
205
+ for (const [ordinal, field] of info.fields.entries()) {
206
+ if (field.fresh && remainingOf(state, info.id, ordinal) === 0n) {
207
+ await acquireLease(core, state, info.id, ordinal);
208
+ }
209
+ }
210
+ }
211
+ }
212
+ function recorderOf(core, state, draws) {
213
+ const recording = { ops: [] };
214
+ function infoOf(name) {
215
+ const info = core.descriptor.relationByName.get(name);
216
+ if (info === undefined) {
217
+ throw errors.new(`relation ${name} is not a member of this theory`);
218
+ }
219
+ if (info.closed) {
220
+ throw errors.new(`relation ${name} is closed — sealed rows never change`);
221
+ }
222
+ return info;
223
+ }
224
+ // The cell judge is the engine's `rowOf` — one marshal per language.
225
+ // The recorder applies nothing, so `changed` is 0n: change is judged
226
+ // at commit, when the recording meets the store.
227
+ function record(op, relation, facts) {
228
+ const info = infoOf(relation.name);
229
+ const rows = [];
230
+ for (const fact of facts) {
231
+ rows.push(rowOf(relation.data, fact));
232
+ }
233
+ recording.ops.push({ op, relation: info.name, rows });
234
+ return Object.freeze({ submitted: BigInt(rows.length), changed: 0n });
235
+ }
236
+ const batch = {
237
+ insert(relation, facts) {
238
+ return record("insert", relation, facts);
239
+ },
240
+ delete(relation, facts) {
241
+ return record("delete", relation, facts);
242
+ },
243
+ reserve(relation, field, count) {
244
+ const info = infoOf(relation.name);
245
+ const ordinal = info.fields.findIndex(function byName(candidate) {
246
+ return candidate.name === field;
247
+ });
248
+ const declared = info.fields[ordinal];
249
+ if (declared === undefined || !declared.fresh) {
250
+ throw errors.new(`relation ${relation.name}: field ${field} is not a fresh cell`);
251
+ }
252
+ return drawIds(state, draws, info.id, ordinal, count);
253
+ }
254
+ };
255
+ return { batch, recording };
256
+ }
257
+ /** Runs the recording body against the cached pool. A draw the pool
258
+ * cannot serve discards the attempt's recording, leases fresh blocks —
259
+ * one full block per draw the attempt made on the starved key — and
260
+ * runs the body again: the refill is a path, not an exhaustion
261
+ * (Exhausted names the u64 ceiling only). Ids drawn by a discarded
262
+ * attempt are abandoned — unique, never dense. Draw counts rise
263
+ * strictly between refills of one key, so a body with finitely many
264
+ * draws settles; a recurring signature screams. */
265
+ async function recordWithLeases(core, state, body) {
266
+ await ensureFreshLeases(core, state);
267
+ for (;;) {
268
+ const draws = new Map();
269
+ const { batch, recording } = recorderOf(core, state, draws);
270
+ const ran = await errors.try((async function runBody() {
271
+ return body(batch);
272
+ })());
273
+ if (!ran.error) {
274
+ return { value: ran.data, ops: recording.ops };
275
+ }
276
+ if (!errors.is(ran.error, ErrRefillNeeded)) {
277
+ throw ran.error;
278
+ }
279
+ const need = refillNeededOf(ran.error);
280
+ if (need === undefined) {
281
+ throw ran.error;
282
+ }
283
+ // Draws this attempt made on the starved key, the missed one included.
284
+ const drawn = draws.get(poolKey(need.relation, need.field)) ?? 0;
285
+ state.scream.attempt(`id-lease refill relation ${need.relation} field ${need.field} draws ${drawn}`);
286
+ while (fullBlocksOf(state, need.relation, need.field) < drawn) {
287
+ await acquireLease(core, state, need.relation, need.field);
288
+ }
289
+ }
290
+ }
291
+ function braidsTouched(core, ops) {
292
+ const partitioned = new Map();
293
+ for (const op of ops) {
294
+ const info = core.descriptor.relationByName.get(op.relation);
295
+ const braid = info === undefined ? undefined : core.descriptor.braidOfRelation.get(info.id);
296
+ if (braid === undefined) {
297
+ throw errors.new(`relation ${op.relation} belongs to no braid`);
298
+ }
299
+ const bucket = partitioned.get(braid);
300
+ if (bucket === undefined) {
301
+ partitioned.set(braid, [op]);
302
+ }
303
+ else {
304
+ bucket.push(op);
305
+ }
306
+ }
307
+ return partitioned;
308
+ }
309
+ function u64leAt(bytes, at) {
310
+ if (bytes.length < at + 8) {
311
+ return undefined;
312
+ }
313
+ const view = new DataView(bytes.buffer, bytes.byteOffset + at, 8);
314
+ return view.getBigUint64(0, true);
315
+ }
316
+ /** The usurper is a fact in the header. A body that refuses to decode
317
+ * does not hide the slot's owner. The Rust writer machine sniffs the
318
+ * same fixed offset (`writer/loss.rs`); the batch grammar itself has
319
+ * one reader — the codec seat. */
320
+ function headerWriter(bytes) {
321
+ if (bytes.length < BATCH_MAGIC.length || !bytesEqual(bytes.subarray(0, BATCH_MAGIC.length), BATCH_MAGIC)) {
322
+ return undefined;
323
+ }
324
+ return u64leAt(bytes, WRITER_AT);
325
+ }
326
+ /** Header prev is 32 branded bytes. */
327
+ function digestPrev(prev) {
328
+ return digest32(prev);
329
+ }
330
+ function noteDeposition(state, braid, slot, winnerBytes) {
331
+ if (state.deposition !== null) {
332
+ return;
333
+ }
334
+ const usurper = headerWriter(winnerBytes);
335
+ if (usurper === undefined) {
336
+ return;
337
+ }
338
+ state.deposition = { braid, slot, resident: state.writerId, usurper };
339
+ }
340
+ /** The terminal contention scream: the re-judgment's own rejection names
341
+ * the hot statement and carries the offending facts' raw values; an
342
+ * accepted-but-outraced terminal loss carries the racing tip. A
343
+ * rejection without a violation is refused — the empty statement is
344
+ * unrepresentable. */
345
+ function screamContention(braid, rejudged) {
346
+ if (rejudged.tag === "rejected") {
347
+ const violation = rejudged.violations[0];
348
+ if (violation === undefined) {
349
+ throw errors.new("a rejection carries at least one violation");
350
+ }
351
+ throwContention({
352
+ braid,
353
+ cause: {
354
+ kind: "hot-key",
355
+ statement: violation.canonical,
356
+ determinants: violation.facts.map(function rawOf(offending) {
357
+ return offending.fact;
358
+ })
359
+ }
360
+ }, `braid ${braid}: ${LOSS_BOUND} consecutive losses at the live tip and the terminal re-judgment rejected`);
361
+ }
362
+ throwContention({ braid, cause: { kind: "slot-race", tip: rejudged.tip } }, `braid ${braid}: ${LOSS_BOUND} consecutive losses at the live tip outraced accepted re-judgments`);
363
+ }
364
+ /**
365
+ * Publishes the applied pending batch: refuse a below-floor create,
366
+ * then slot CAS, then the one loss path on Exists. A byte-equal
367
+ * occupant is our own ambiguous PUT, absorbed. Anything else carries
368
+ * the pending through a directory discard — re-persisted into the
369
+ * fresh sidecar before any re-judgment — re-opens to the current tip,
370
+ * and re-judges the recorded ops in one db.write. An occupant that
371
+ * then vanishes is retired, not a loop that forges the swept slot.
372
+ */
373
+ async function publishPending(core, state, ops) {
374
+ let losses = 0;
375
+ for (;;) {
376
+ const pending = pendingOf(core);
377
+ if (pending === null) {
378
+ throw errors.new("publish reached with no pending batch");
379
+ }
380
+ const braid = pending.braid;
381
+ // The floor is a write precondition: a slot at or below it is
382
+ // retired. A create must not touch the store (70/116/127).
383
+ if (belowFloor(core, braid, pending.slot)) {
384
+ refuseSlotRetired({ braid, slot: pending.slot }, "the slot is retired");
385
+ }
386
+ const created = await core.store.putCreate(logKey(core.prefix, braid, pending.slot), pending.bytes);
387
+ let winnerBytes = null;
388
+ if (created.tag !== "created") {
389
+ const fetched = await core.store.get(logKey(core.prefix, braid, pending.slot));
390
+ if (fetched === null) {
391
+ // Exists then null: the occupant was swept. Refuse
392
+ // rather than loop back into putCreate.
393
+ refuseSlotRetired({ braid, slot: pending.slot }, "the slot is retired");
394
+ }
395
+ if (!bytesEqual(fetched.bytes, pending.bytes)) {
396
+ winnerBytes = fetched.bytes;
397
+ }
398
+ }
399
+ if (winnerBytes === null) {
400
+ if (pending.ts === null) {
401
+ throw errors.new("publish reached with an undecoded pending timestamp");
402
+ }
403
+ entriesOf(core).set(braid, {
404
+ g: pending.slot,
405
+ prev: digest32(new Uint8Array(internalBlake3(pending.bytes))),
406
+ ts: pending.ts
407
+ });
408
+ await clearPending(core);
409
+ return { tag: "accepted", value: { slot: pending.slot, durability: "published" } };
410
+ }
411
+ losses += 1;
412
+ state.scream.attempt("slot occupant is not ours");
413
+ noteDeposition(state, braid, pending.slot, winnerBytes);
414
+ await discardAndReopen(core);
415
+ const before = generationOf(core);
416
+ const rejudged = applyOps(core, ops);
417
+ if (rejudged.tag === "rejected") {
418
+ await clearPending(core);
419
+ if (losses >= LOSS_BOUND) {
420
+ screamContention(braid, { tag: "rejected", violations: rejudged.violations });
421
+ }
422
+ return { tag: "rejected", violations: rejudged.violations };
423
+ }
424
+ if (rejudged.value.generation === before) {
425
+ await clearPending(core);
426
+ return { tag: "accepted", value: { slot: chainEntry(core, braid).g, durability: "published" } };
427
+ }
428
+ const tip = chainEntry(core, braid);
429
+ entriesOf(core).set(braid, { g: tip.g, prev: digestPrev(tip.prev), ts: tip.ts });
430
+ await readdressPending(core, ops, state.writerId);
431
+ if (losses >= LOSS_BOUND) {
432
+ screamContention(braid, { tag: "outraced", tip: chainEntry(core, braid).g });
433
+ }
434
+ }
435
+ }
436
+ /** The commit discipline (60): encode, fsync as Pending before any
437
+ * apply, judge locally, publish only what advanced the generation.
438
+ * Settled is written only after the verdict. */
439
+ async function disciplineCommit(core, state, braid, ops) {
440
+ const entry = chainEntry(core, braid);
441
+ const timestamp = maxBigint(BigInt(Date.now()), entry.ts);
442
+ const bytes = encodeBatch(core.descriptor, {
443
+ braid,
444
+ braidGen: generation(entry.g + 1n),
445
+ prev: digestPrev(entry.prev),
446
+ writer: state.writerId,
447
+ timestamp
448
+ }, ops);
449
+ // Pending → durable, before any apply.
450
+ holdPending(core, { braid, slot: generation(entry.g + 1n), bytes }, ops, timestamp);
451
+ await persistSidecar(core);
452
+ const before = generationOf(core);
453
+ const outcome = applyOps(core, ops);
454
+ if (outcome.tag === "rejected") {
455
+ await clearPending(core);
456
+ return { tag: "rejected", violations: outcome.violations };
457
+ }
458
+ if (outcome.value.generation === before) {
459
+ await clearPending(core);
460
+ return { tag: "accepted", value: { slot: entry.g, durability: "published" } };
461
+ }
462
+ return publishPending(core, state, ops);
463
+ }
464
+ /** An inherited pending is resolved-and-published by open. A slot the
465
+ * floor already covers is published (`Clear`), not re-judged (46). */
466
+ async function settleInheritedPending(core, state) {
467
+ const pending = pendingOf(core);
468
+ if (pending === null) {
469
+ return;
470
+ }
471
+ if (foldPending(chainSum(core.chain), generationOf(core), null, pending.bytes, belowFloor(core, pending.braid, pending.slot)).tag === "below-floor") {
472
+ await clearPending(core);
473
+ return;
474
+ }
475
+ let ops = pending.ops;
476
+ if (ops === null) {
477
+ const decoded = errors.trySync(function decodePending() {
478
+ return decodeBatch(core.descriptor, pending.bytes);
479
+ });
480
+ if (decoded.error) {
481
+ await clearPending(core);
482
+ return;
483
+ }
484
+ ops = decoded.data.ops;
485
+ // The decoded arm re-holds so publish reads the header facts once.
486
+ holdPending(core, { braid: pending.braid, slot: pending.slot, bytes: pending.bytes }, ops, decoded.data.header.timestamp);
487
+ }
488
+ const published = await publishPending(core, state, ops);
489
+ if (published.tag === "rejected") {
490
+ return;
491
+ }
492
+ }
493
+ async function putCreateOnce(store, key, bytes) {
494
+ for (;;) {
495
+ const created = await store.putCreate(key, bytes);
496
+ if (created.tag === "created" || created.tag === "exists") {
497
+ return;
498
+ }
499
+ const fetched = await store.get(key);
500
+ if (fetched !== null) {
501
+ return;
502
+ }
503
+ }
504
+ }
505
+ /** The loser deletes its own `ckpt/{digest}` and `.mdb`. */
506
+ async function deleteOrphan(store, prefix, digest) {
507
+ await store.delete(ckptDocKey(prefix, digest));
508
+ await store.delete(checkpointMdbKey(prefix, digest));
509
+ }
510
+ function scratchPath(dir) {
511
+ return path.join(dir, LEASE_NAMESPACE, CKPT_SCRATCH_LEASE);
512
+ }
513
+ async function claimScratch(dir, digest) {
514
+ const target = scratchPath(dir);
515
+ await fs.mkdir(path.dirname(target), { recursive: true });
516
+ const handle = await fs.open(target, "w");
517
+ const written = await errors.try((async function writeLease() {
518
+ await handle.writeFile(encodeCkptScratch(digest));
519
+ await handle.sync();
520
+ })());
521
+ await handle.close();
522
+ if (written.error) {
523
+ await fs.rm(target, { force: true });
524
+ throw written.error;
525
+ }
526
+ }
527
+ async function releaseScratch(dir) {
528
+ await fs.rm(scratchPath(dir), { force: true });
529
+ }
530
+ async function casPublish(store, prefix, codec, candidate, digest, bytes) {
531
+ await putCreateOnce(store, ckptDocKey(prefix, digest), bytes);
532
+ for (;;) {
533
+ const fetched = await store.get(manifestKey(prefix));
534
+ if (fetched === null) {
535
+ return { tag: "refused", reason: "manifest-missing" };
536
+ }
537
+ const parsed = errors.trySync(function parse() {
538
+ return parseManifest(fetched.bytes);
539
+ });
540
+ if (parsed.error) {
541
+ return { tag: "refused", reason: "manifest" };
542
+ }
543
+ const incumbent = parsed.data.checkpoint;
544
+ if (incumbent !== null && bytesEqual(incumbent, digest)) {
545
+ return { tag: "replaced" };
546
+ }
547
+ if (incumbent !== null) {
548
+ const doc = await store.get(ckptDocKey(prefix, incumbent));
549
+ if (doc === null) {
550
+ return { tag: "refused", reason: "checkpoint-doc-missing" };
551
+ }
552
+ const incumbentDoc = errors.trySync(function parseIncumbent() {
553
+ return parseCheckpoint(codec, doc.bytes);
554
+ });
555
+ if (incumbentDoc.error) {
556
+ return { tag: "refused", reason: "checkpoint" };
557
+ }
558
+ if (checkpointVector(candidate).order(checkpointVector(incumbentDoc.data)) !== "after") {
559
+ return { tag: "kept", incumbent };
560
+ }
561
+ }
562
+ const next = renderManifest({ fingerprint: parsed.data.fingerprint, checkpoint: digest });
563
+ const swapped = await store.putSwap(manifestKey(prefix), next, fetched.etag);
564
+ if (swapped.tag === "swapped") {
565
+ return { tag: "replaced" };
566
+ }
567
+ }
568
+ }
569
+ /**
570
+ * Publishes `candidate` under the checkpoint order. The digest is the
571
+ * blake3 of the full bytes, `prev` included. A scratch lease
572
+ * `{dir}/~lease/ckpt-scratch` names the candidate for the publish
573
+ * window; a crash leaves that lease and a successor sweep reclaims
574
+ * it. `Kept` and every refused publish delete the candidate's `ckpt`
575
+ * pair.
576
+ */
577
+ async function publishCheckpoint(store, prefix, dir, theory, candidate, mdb) {
578
+ const codec = descriptorOf(theory).codec;
579
+ const bytes = renderCheckpoint(codec, candidate);
580
+ const digest = digest32(new Uint8Array(internalBlake3(bytes)));
581
+ await claimScratch(dir, digest);
582
+ const ran = await errors.try((async function publish() {
583
+ await putCreateOnce(store, checkpointMdbKey(prefix, digest), mdb);
584
+ return await casPublish(store, prefix, codec, candidate, digest, bytes);
585
+ })());
586
+ if (ran.error) {
587
+ throw ran.error;
588
+ }
589
+ if (ran.data.tag === "kept" || ran.data.tag === "refused") {
590
+ await deleteOrphan(store, prefix, digest);
591
+ }
592
+ await releaseScratch(dir);
593
+ return ran.data;
594
+ }
595
+ /** Only the writer births a store: create-only PUT of a genesis manifest. */
596
+ async function birthStore(store, prefix, fingerprint) {
597
+ const key = manifestKey(prefix);
598
+ for (;;) {
599
+ const fetched = await store.get(key);
600
+ if (fetched !== null) {
601
+ return;
602
+ }
603
+ const bytes = renderManifest({ fingerprint, checkpoint: null });
604
+ const created = await store.putCreate(key, bytes);
605
+ if (created.tag === "created") {
606
+ return;
607
+ }
608
+ }
609
+ }
610
+ function isReplica(source) {
611
+ return typeof source.refresh === "function";
612
+ }
613
+ /** Wrap a born replica: settle an inherited pending; do not birth; do not draw id leases. */
614
+ async function writerOn(replica) {
615
+ const core = coreOf(replica);
616
+ const state = {
617
+ writerId: crypto.randomBytes(8).readBigUInt64LE(),
618
+ pools: new Map(),
619
+ scream: screamOf("writer discard-and-re-pull"),
620
+ deposition: null
621
+ };
622
+ await withGate(core, async function openTransition() {
623
+ await settleInheritedPending(core, state);
624
+ });
625
+ return {
626
+ role: "writer",
627
+ replica,
628
+ deposition() {
629
+ return state.deposition;
630
+ },
631
+ async commit(body) {
632
+ return withGate(core, async function commitBody() {
633
+ const recorded = await recordWithLeases(core, state, body);
634
+ if (recorded.ops.length === 0) {
635
+ return { tag: "empty", value: recorded.value };
636
+ }
637
+ const partitioned = braidsTouched(core, recorded.ops);
638
+ if (partitioned.size > 1) {
639
+ throw errors.wrap(ErrSpanningCommit, `the recorded ops span braids ${[...partitioned.keys()].join(", ")}`);
640
+ }
641
+ const [braid, ops] = [...partitioned.entries()][0];
642
+ const outcome = await disciplineCommit(core, state, braid, ops);
643
+ if (outcome.tag === "rejected") {
644
+ return outcome;
645
+ }
646
+ return {
647
+ tag: "accepted",
648
+ value: { value: recorded.value, braid, slot: outcome.value.slot, durability: outcome.value.durability }
649
+ };
650
+ });
651
+ },
652
+ async commitSplit(body) {
653
+ return withGate(core, async function splitBody() {
654
+ const recorded = await recordWithLeases(core, state, body);
655
+ if (recorded.ops.length === 0) {
656
+ return { tag: "empty", value: recorded.value };
657
+ }
658
+ const partitioned = braidsTouched(core, recorded.ops);
659
+ const outcomes = [];
660
+ for (const [braid, ops] of partitioned) {
661
+ const admission = await disciplineCommit(core, state, braid, ops);
662
+ outcomes.push({ braid, admission });
663
+ }
664
+ return { tag: "split", value: recorded.value, outcomes };
665
+ });
666
+ }
667
+ };
668
+ }
669
+ async function openWriter(source) {
670
+ if (isReplica(source)) {
671
+ return writerOn(source);
672
+ }
673
+ await birthStore(source.store, source.prefix, digest32(descriptorOf(source.theory).fingerprintBytes));
674
+ return writerOn(await openReplica(source));
675
+ }
676
+ export { openWriter, publishCheckpoint };
677
+ //# sourceMappingURL=writer.js.map