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