@bjornpagen/bumbledb-log 0.17.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 +119 -0
- package/package.json +58 -0
- package/src/braids.ts +38 -0
- package/src/bytes.ts +236 -0
- package/src/chain.ts +109 -0
- package/src/codec.ts +256 -0
- package/src/descriptor.ts +741 -0
- package/src/errors.ts +150 -0
- package/src/index.ts +38 -0
- package/src/keys.ts +34 -0
- package/src/manifest.ts +113 -0
- package/src/replica.ts +796 -0
- package/src/store.ts +292 -0
- package/src/tenants.ts +140 -0
- package/src/value.ts +285 -0
- package/src/writer.ts +506 -0
package/src/writer.ts
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The writer (60): a replica plus the right to create log objects. One
|
|
3
|
+
* commit path and one loss path: a lost slot's byte-equal occupant is
|
|
4
|
+
* an ambiguous PUT absorbed; anything else discards the local
|
|
5
|
+
* directory, re-opens through the replica to the current tip, and
|
|
6
|
+
* re-judges the recorded ops once — the verdict IS a serial execution,
|
|
7
|
+
* performed. Each loop iteration races once at the then-tip, so a
|
|
8
|
+
* historical loss is structurally uncountable, and bounded live-tip
|
|
9
|
+
* losses surface as ErrContention carrying the terminal re-judgment's
|
|
10
|
+
* own violation or the racing tip.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as crypto from "node:crypto"
|
|
14
|
+
import type { Fact, FreshKeys, MemberRelation, SchemaRelations, Violation } from "@bjornpagen/bumbledb"
|
|
15
|
+
import * as errors from "@superbuilders/errors"
|
|
16
|
+
import { bytesEqual, utf8Encoder, utf8StrictDecoder } from "#bytes.ts"
|
|
17
|
+
import type { BatchOp } from "#codec.ts"
|
|
18
|
+
import { decodeBatch, encodeBatch } from "#codec.ts"
|
|
19
|
+
import type { RelationInfo } from "#descriptor.ts"
|
|
20
|
+
import { ErrSpanningCommit, throwContention } from "#errors.ts"
|
|
21
|
+
import { idsKey, logKey } from "#keys.ts"
|
|
22
|
+
import type { Core, Replica } from "#replica.ts"
|
|
23
|
+
import {
|
|
24
|
+
applyOps,
|
|
25
|
+
blake3Hex,
|
|
26
|
+
chainEntry,
|
|
27
|
+
clearPending,
|
|
28
|
+
coreOf,
|
|
29
|
+
discardAndReopen,
|
|
30
|
+
generationOf,
|
|
31
|
+
maxBigint,
|
|
32
|
+
persistSidecar,
|
|
33
|
+
readdressPending,
|
|
34
|
+
withGate
|
|
35
|
+
} from "#replica.ts"
|
|
36
|
+
import type { LogValue } from "#value.ts"
|
|
37
|
+
import { checkAgainst } from "#value.ts"
|
|
38
|
+
|
|
39
|
+
/** 10 owns the width: one CAS amortizes counter traffic 4096× below slot traffic. */
|
|
40
|
+
const LEASE_WIDTH = 4096n
|
|
41
|
+
|
|
42
|
+
/** The live-loss bound (60): consecutive losses at the live tip, history never counts. */
|
|
43
|
+
const LOSS_BOUND = 16
|
|
44
|
+
|
|
45
|
+
type Durability = "published" | "local-pending"
|
|
46
|
+
|
|
47
|
+
type Commit<Rels extends SchemaRelations, R> =
|
|
48
|
+
| {
|
|
49
|
+
readonly tag: "accepted"
|
|
50
|
+
readonly value: R
|
|
51
|
+
readonly braid: string
|
|
52
|
+
readonly generation: bigint
|
|
53
|
+
readonly durability: Durability
|
|
54
|
+
}
|
|
55
|
+
| { readonly tag: "rejected"; readonly violations: readonly Violation<Rels>[] }
|
|
56
|
+
|
|
57
|
+
type BraidOutcome<Rels extends SchemaRelations> =
|
|
58
|
+
| { readonly tag: "accepted"; readonly braid: string; readonly generation: bigint; readonly durability: Durability }
|
|
59
|
+
| { readonly tag: "rejected"; readonly braid: string; readonly violations: readonly Violation<Rels>[] }
|
|
60
|
+
|
|
61
|
+
interface CommitSplit<Rels extends SchemaRelations, R> {
|
|
62
|
+
readonly value: R
|
|
63
|
+
readonly outcomes: readonly BraidOutcome<Rels>[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The recorder: typed inserts and deletes as raw-valued ops, `reserve`
|
|
68
|
+
* drawing on the id lease (10) — reservations never appear in the log;
|
|
69
|
+
* the resulting inserts carry concrete values. Pure and synchronous.
|
|
70
|
+
*/
|
|
71
|
+
interface LogBatch<Rels extends SchemaRelations> {
|
|
72
|
+
insert<Rel extends MemberRelation<Rels>>(relation: Rel, facts: Iterable<Fact<Rel>>): void
|
|
73
|
+
delete<Rel extends MemberRelation<Rels>>(relation: Rel, facts: Iterable<Fact<Rel>>): void
|
|
74
|
+
reserve<Rel extends MemberRelation<Rels>>(
|
|
75
|
+
relation: Rel,
|
|
76
|
+
field: FreshKeys<Rel> & string,
|
|
77
|
+
count: bigint
|
|
78
|
+
): readonly bigint[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface Writer<Rels extends SchemaRelations> {
|
|
82
|
+
commit<R>(body: (batch: LogBatch<Rels>) => R): Promise<Commit<Rels, R>>
|
|
83
|
+
commitSplit<R>(body: (batch: LogBatch<Rels>) => R): Promise<CommitSplit<Rels, R>>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface LeaseRange {
|
|
87
|
+
next: bigint
|
|
88
|
+
readonly end: bigint
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface WriterState {
|
|
92
|
+
readonly writerId: bigint
|
|
93
|
+
readonly pools: Map<string, LeaseRange[]>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ErrLeaseDrained = errors.new("bumbledb-log lease pool drained mid-recording")
|
|
97
|
+
const leaseDemand = new WeakMap<Error, { relation: number; field: number }>()
|
|
98
|
+
|
|
99
|
+
function poolKey(relation: number, field: number): string {
|
|
100
|
+
return `${relation}:${field}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function drawIds(state: WriterState, relation: number, field: number, count: bigint): bigint[] {
|
|
104
|
+
const pool = state.pools.get(poolKey(relation, field)) ?? []
|
|
105
|
+
const ids: bigint[] = []
|
|
106
|
+
let remaining = count
|
|
107
|
+
while (remaining > 0n) {
|
|
108
|
+
const range = pool[0]
|
|
109
|
+
if (range === undefined) {
|
|
110
|
+
const fault = errors.wrap(ErrLeaseDrained, `relation ${relation} field ${field} needs ${remaining} more ids`)
|
|
111
|
+
leaseDemand.set(fault, { relation, field })
|
|
112
|
+
throw fault
|
|
113
|
+
}
|
|
114
|
+
while (remaining > 0n && range.next < range.end) {
|
|
115
|
+
ids.push(range.next)
|
|
116
|
+
range.next += 1n
|
|
117
|
+
remaining -= 1n
|
|
118
|
+
}
|
|
119
|
+
if (range.next >= range.end) {
|
|
120
|
+
pool.shift()
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
state.pools.set(poolKey(relation, field), pool)
|
|
124
|
+
return ids
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `ids/{relation}/{field}`: birth claims [0, width); every later lease CAS-increments. */
|
|
128
|
+
async function acquireLease<Rels extends SchemaRelations>(
|
|
129
|
+
core: Core<Rels>,
|
|
130
|
+
state: WriterState,
|
|
131
|
+
relation: number,
|
|
132
|
+
field: number
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
const key = idsKey(core.prefix, relation, field)
|
|
135
|
+
for (;;) {
|
|
136
|
+
const fetched = await core.store.get(key)
|
|
137
|
+
if (fetched === null) {
|
|
138
|
+
const created = await core.store.putCreate(key, utf8Encoder.encode(String(LEASE_WIDTH)))
|
|
139
|
+
if (created.tag === "created") {
|
|
140
|
+
pushRange(state, relation, field, 0n, LEASE_WIDTH)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
const body = utf8StrictDecoder.decode(fetched.bytes)
|
|
146
|
+
if (!/^\d+$/.test(body)) {
|
|
147
|
+
throw errors.new(`id-lease counter ${key} is not a canonical decimal: ${body}`)
|
|
148
|
+
}
|
|
149
|
+
const next = BigInt(body)
|
|
150
|
+
const swapped = await core.store.putSwap(key, utf8Encoder.encode(String(next + LEASE_WIDTH)), fetched.etag)
|
|
151
|
+
if (swapped.tag === "swapped") {
|
|
152
|
+
pushRange(state, relation, field, next, next + LEASE_WIDTH)
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function pushRange(state: WriterState, relation: number, field: number, next: bigint, end: bigint): void {
|
|
159
|
+
const pool = state.pools.get(poolKey(relation, field)) ?? []
|
|
160
|
+
pool.push({ next, end })
|
|
161
|
+
state.pools.set(poolKey(relation, field), pool)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface Recording {
|
|
165
|
+
readonly ops: BatchOp[]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function lowerFact<Rels extends SchemaRelations>(
|
|
169
|
+
core: Core<Rels>,
|
|
170
|
+
info: RelationInfo,
|
|
171
|
+
fact: Record<string, unknown>
|
|
172
|
+
): LogValue[] {
|
|
173
|
+
return info.fields.map(function lowerCell(field) {
|
|
174
|
+
const raw = fact[field.name]
|
|
175
|
+
if (raw === undefined) {
|
|
176
|
+
throw errors.new(`relation ${info.name}: fact is missing field ${field.name}`)
|
|
177
|
+
}
|
|
178
|
+
let value: LogValue
|
|
179
|
+
if (field.closedRef !== undefined) {
|
|
180
|
+
if (typeof raw !== "string") {
|
|
181
|
+
throw errors.new(`relation ${info.name} field ${field.name}: expected a ${field.closedRef} handle name`)
|
|
182
|
+
}
|
|
183
|
+
const roster = core.descriptor.relationByName.get(field.closedRef)
|
|
184
|
+
const id = roster?.handles.indexOf(raw) ?? -1
|
|
185
|
+
if (id === -1) {
|
|
186
|
+
throw errors.new(`relation ${info.name} field ${field.name}: "${raw}" is not in the ${field.closedRef} roster`)
|
|
187
|
+
}
|
|
188
|
+
value = BigInt(id)
|
|
189
|
+
} else if (typeof raw === "object" && raw !== null && !(raw instanceof Uint8Array)) {
|
|
190
|
+
const interval = raw as { start?: unknown; end?: unknown }
|
|
191
|
+
if (typeof interval.start !== "bigint" || typeof interval.end !== "bigint") {
|
|
192
|
+
throw errors.new(`relation ${info.name} field ${field.name}: expected an interval of bigints`)
|
|
193
|
+
}
|
|
194
|
+
value = { start: interval.start, end: interval.end }
|
|
195
|
+
} else {
|
|
196
|
+
value = raw as LogValue
|
|
197
|
+
}
|
|
198
|
+
checkAgainst(`relation ${info.name} field ${field.name}`, field.type, value)
|
|
199
|
+
return value
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function recorderOf<Rels extends SchemaRelations>(
|
|
204
|
+
core: Core<Rels>,
|
|
205
|
+
state: WriterState
|
|
206
|
+
): { batch: LogBatch<Rels>; recording: Recording } {
|
|
207
|
+
const recording: Recording = { ops: [] }
|
|
208
|
+
function infoOf(name: string): RelationInfo {
|
|
209
|
+
const info = core.descriptor.relationByName.get(name)
|
|
210
|
+
if (info === undefined) {
|
|
211
|
+
throw errors.new(`relation ${name} is not a member of this theory`)
|
|
212
|
+
}
|
|
213
|
+
if (info.closed) {
|
|
214
|
+
throw errors.new(`relation ${name} is closed — sealed rows never change`)
|
|
215
|
+
}
|
|
216
|
+
return info
|
|
217
|
+
}
|
|
218
|
+
function record(op: "insert" | "delete", relationName: string, facts: Iterable<unknown>): void {
|
|
219
|
+
const info = infoOf(relationName)
|
|
220
|
+
const rows: LogValue[][] = []
|
|
221
|
+
for (const fact of facts) {
|
|
222
|
+
if (typeof fact !== "object" || fact === null) {
|
|
223
|
+
throw errors.new(`relation ${relationName}: a fact is not an object`)
|
|
224
|
+
}
|
|
225
|
+
rows.push(lowerFact(core, info, fact as Record<string, unknown>))
|
|
226
|
+
}
|
|
227
|
+
recording.ops.push({ op, relation: relationName, rows })
|
|
228
|
+
}
|
|
229
|
+
const batch: LogBatch<Rels> = {
|
|
230
|
+
insert(relation, facts) {
|
|
231
|
+
record("insert", relation.name, facts)
|
|
232
|
+
},
|
|
233
|
+
delete(relation, facts) {
|
|
234
|
+
record("delete", relation.name, facts)
|
|
235
|
+
},
|
|
236
|
+
reserve(relation, field, count) {
|
|
237
|
+
const info = infoOf(relation.name)
|
|
238
|
+
const ordinal = info.fields.findIndex(function byName(candidate) {
|
|
239
|
+
return candidate.name === field
|
|
240
|
+
})
|
|
241
|
+
const declared = info.fields[ordinal]
|
|
242
|
+
if (declared === undefined || !declared.fresh) {
|
|
243
|
+
throw errors.new(`relation ${relation.name}: field ${field} is not a fresh cell`)
|
|
244
|
+
}
|
|
245
|
+
return drawIds(state, info.id, ordinal, count)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { batch, recording }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Runs the recording body, refilling the id-lease pool on a drained
|
|
252
|
+
* draw and re-running — recording is pure, so a re-run before any
|
|
253
|
+
* judgment invokes nothing twice that the store could observe. */
|
|
254
|
+
async function recordWithLeases<Rels extends SchemaRelations, R>(
|
|
255
|
+
core: Core<Rels>,
|
|
256
|
+
state: WriterState,
|
|
257
|
+
body: (batch: LogBatch<Rels>) => R
|
|
258
|
+
): Promise<{ value: R; ops: BatchOp[] }> {
|
|
259
|
+
for (let attempt = 0; attempt < 16; attempt++) {
|
|
260
|
+
const { batch, recording } = recorderOf(core, state)
|
|
261
|
+
const ran = errors.trySync(function runBody() {
|
|
262
|
+
return body(batch)
|
|
263
|
+
})
|
|
264
|
+
if (ran.error === undefined) {
|
|
265
|
+
return { value: ran.data, ops: recording.ops }
|
|
266
|
+
}
|
|
267
|
+
const demand = leaseDemand.get(ran.error) ?? leaseDemand.get(errors.cause(ran.error))
|
|
268
|
+
if (demand === undefined) {
|
|
269
|
+
throw ran.error
|
|
270
|
+
}
|
|
271
|
+
await acquireLease(core, state, demand.relation, demand.field)
|
|
272
|
+
}
|
|
273
|
+
throw errors.new("the id-lease pool could not satisfy the recording after 16 refills")
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function braidsTouched<Rels extends SchemaRelations>(
|
|
277
|
+
core: Core<Rels>,
|
|
278
|
+
ops: readonly BatchOp[]
|
|
279
|
+
): Map<string, BatchOp[]> {
|
|
280
|
+
const partitioned = new Map<string, BatchOp[]>()
|
|
281
|
+
for (const op of ops) {
|
|
282
|
+
const info = core.descriptor.relationByName.get(op.relation)
|
|
283
|
+
const braid = info === undefined ? undefined : core.descriptor.braidOfRelation.get(info.id)
|
|
284
|
+
if (braid === undefined) {
|
|
285
|
+
throw errors.new(`relation ${op.relation} belongs to no braid`)
|
|
286
|
+
}
|
|
287
|
+
const bucket = partitioned.get(braid)
|
|
288
|
+
if (bucket === undefined) {
|
|
289
|
+
partitioned.set(braid, [op])
|
|
290
|
+
} else {
|
|
291
|
+
bucket.push(op)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return partitioned
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The terminal contention scream: the re-judgment's own rejection names
|
|
298
|
+
* the hot statement and carries the offending facts' raw values; an
|
|
299
|
+
* accepted-but-outraced terminal loss carries the racing tip. */
|
|
300
|
+
function screamContention<Rels extends SchemaRelations>(
|
|
301
|
+
braid: string,
|
|
302
|
+
rejudged:
|
|
303
|
+
| { readonly tag: "rejected"; readonly violations: readonly Violation<Rels>[] }
|
|
304
|
+
| { readonly tag: "outraced"; readonly tip: bigint }
|
|
305
|
+
): never {
|
|
306
|
+
if (rejudged.tag === "rejected") {
|
|
307
|
+
const violation = rejudged.violations[0]
|
|
308
|
+
throwContention(
|
|
309
|
+
{
|
|
310
|
+
braid,
|
|
311
|
+
cause: {
|
|
312
|
+
kind: "hot-key",
|
|
313
|
+
statement: violation === undefined ? "" : violation.canonical,
|
|
314
|
+
determinants: (violation?.facts ?? []).map(function rawOf(offending) {
|
|
315
|
+
return offending.fact
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
`braid ${braid}: ${LOSS_BOUND} consecutive losses at the live tip and the terminal re-judgment rejected`
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
throwContention(
|
|
323
|
+
{ braid, cause: { kind: "slot-race", tip: rejudged.tip } },
|
|
324
|
+
`braid ${braid}: ${LOSS_BOUND} consecutive losses at the live tip outraced accepted re-judgments`
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Publishes the applied pending batch: slot CAS, then the one loss path
|
|
330
|
+
* on Exists. A byte-equal occupant is our own ambiguous PUT, absorbed.
|
|
331
|
+
* Anything else carries the pending through a directory discard —
|
|
332
|
+
* re-persisted into the fresh sidecar before any re-judgment, so a
|
|
333
|
+
* crash mid-loss resolves it at the next open — re-opens to the
|
|
334
|
+
* current tip, and re-judges the recorded ops in one db.write: publish
|
|
335
|
+
* on accepted-and-state-changing, Accepted at the current generation
|
|
336
|
+
* on a net no-op (the publish law), or the serial Rejected. Each
|
|
337
|
+
* iteration races once at the then-tip; the bound counts iterations.
|
|
338
|
+
*/
|
|
339
|
+
async function publishPending<Rels extends SchemaRelations>(
|
|
340
|
+
core: Core<Rels>,
|
|
341
|
+
state: WriterState,
|
|
342
|
+
ops: readonly BatchOp[]
|
|
343
|
+
): Promise<Commit<Rels, undefined>> {
|
|
344
|
+
let losses = 0
|
|
345
|
+
for (;;) {
|
|
346
|
+
const pending = core.pending
|
|
347
|
+
if (pending === null) {
|
|
348
|
+
throw errors.new("publish reached with no pending batch")
|
|
349
|
+
}
|
|
350
|
+
const braid = pending.braid
|
|
351
|
+
const created = await core.store.putCreate(logKey(core.prefix, braid, pending.gen), pending.bytes)
|
|
352
|
+
let winnerBytes: Uint8Array | null = null
|
|
353
|
+
if (created.tag === "exists") {
|
|
354
|
+
const fetched = await core.store.get(logKey(core.prefix, braid, pending.gen))
|
|
355
|
+
if (fetched === null) {
|
|
356
|
+
continue
|
|
357
|
+
}
|
|
358
|
+
if (!bytesEqual(fetched.bytes, pending.bytes)) {
|
|
359
|
+
winnerBytes = fetched.bytes
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (winnerBytes === null) {
|
|
363
|
+
const header = decodeBatch(core.descriptor, pending.bytes).header
|
|
364
|
+
core.chain.set(braid, { g: pending.gen, prev: blake3Hex(pending.bytes), ts: header.timestamp })
|
|
365
|
+
await clearPending(core)
|
|
366
|
+
return { tag: "accepted", value: undefined, braid, generation: pending.gen, durability: "published" }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
losses += 1
|
|
370
|
+
core.pendingApplied = false
|
|
371
|
+
await discardAndReopen(core)
|
|
372
|
+
const before = generationOf(core)
|
|
373
|
+
const rejudged = applyOps(core, ops)
|
|
374
|
+
if (rejudged.tag === "rejected") {
|
|
375
|
+
await clearPending(core)
|
|
376
|
+
if (losses >= LOSS_BOUND) {
|
|
377
|
+
screamContention(braid, { tag: "rejected", violations: rejudged.violations })
|
|
378
|
+
}
|
|
379
|
+
return { tag: "rejected", violations: rejudged.violations }
|
|
380
|
+
}
|
|
381
|
+
if (rejudged.value.generation === before) {
|
|
382
|
+
await clearPending(core)
|
|
383
|
+
return {
|
|
384
|
+
tag: "accepted",
|
|
385
|
+
value: undefined,
|
|
386
|
+
braid,
|
|
387
|
+
generation: chainEntry(core, braid).g,
|
|
388
|
+
durability: "published"
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
await readdressPending(core, ops, state.writerId)
|
|
392
|
+
if (losses >= LOSS_BOUND) {
|
|
393
|
+
screamContention(braid, { tag: "outraced", tip: chainEntry(core, braid).g })
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The commit discipline (60): encode, fsync the pending, judge locally,
|
|
399
|
+
* publish only what advanced the generation. */
|
|
400
|
+
async function disciplineCommit<Rels extends SchemaRelations>(
|
|
401
|
+
core: Core<Rels>,
|
|
402
|
+
state: WriterState,
|
|
403
|
+
braid: string,
|
|
404
|
+
ops: readonly BatchOp[]
|
|
405
|
+
): Promise<Commit<Rels, undefined>> {
|
|
406
|
+
const entry = chainEntry(core, braid)
|
|
407
|
+
const timestamp = maxBigint(BigInt(Date.now()), entry.ts)
|
|
408
|
+
const bytes = encodeBatch(
|
|
409
|
+
core.descriptor,
|
|
410
|
+
{
|
|
411
|
+
fingerprint: core.descriptor.fingerprint,
|
|
412
|
+
braid,
|
|
413
|
+
braidGen: entry.g + 1n,
|
|
414
|
+
prev: entry.prev,
|
|
415
|
+
writer: state.writerId,
|
|
416
|
+
timestamp
|
|
417
|
+
},
|
|
418
|
+
ops
|
|
419
|
+
)
|
|
420
|
+
core.pending = { braid, gen: entry.g + 1n, bytes }
|
|
421
|
+
core.pendingOps = ops
|
|
422
|
+
core.pendingApplied = false
|
|
423
|
+
await persistSidecar(core)
|
|
424
|
+
|
|
425
|
+
const before = generationOf(core)
|
|
426
|
+
const outcome = applyOps(core, ops)
|
|
427
|
+
if (outcome.tag === "rejected") {
|
|
428
|
+
await clearPending(core)
|
|
429
|
+
return { tag: "rejected", violations: outcome.violations }
|
|
430
|
+
}
|
|
431
|
+
if (outcome.value.generation === before) {
|
|
432
|
+
await clearPending(core)
|
|
433
|
+
return { tag: "accepted", value: undefined, braid, generation: entry.g, durability: "published" }
|
|
434
|
+
}
|
|
435
|
+
core.pendingApplied = true
|
|
436
|
+
return publishPending(core, state, ops)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** A recovered pending owed from a previous life publishes before any new commit. */
|
|
440
|
+
async function settleInheritedPending<Rels extends SchemaRelations>(
|
|
441
|
+
core: Core<Rels>,
|
|
442
|
+
state: WriterState
|
|
443
|
+
): Promise<void> {
|
|
444
|
+
if (core.pending === null || !core.pendingApplied || core.pendingOps === null) {
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
await publishPending(core, state, core.pendingOps)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function openWriter<Rels extends SchemaRelations>(replica: Replica<Rels>): Writer<Rels> {
|
|
451
|
+
const core = coreOf(replica)
|
|
452
|
+
const state: WriterState = {
|
|
453
|
+
writerId: crypto.randomBytes(8).readBigUInt64LE(),
|
|
454
|
+
pools: new Map()
|
|
455
|
+
}
|
|
456
|
+
return {
|
|
457
|
+
async commit(body) {
|
|
458
|
+
return withGate(core, async function commitBody() {
|
|
459
|
+
await settleInheritedPending(core, state)
|
|
460
|
+
const recorded = await recordWithLeases(core, state, body)
|
|
461
|
+
if (recorded.ops.length === 0) {
|
|
462
|
+
throw errors.new("commit recorded no ops — an empty transaction names no braid")
|
|
463
|
+
}
|
|
464
|
+
const partitioned = braidsTouched(core, recorded.ops)
|
|
465
|
+
if (partitioned.size > 1) {
|
|
466
|
+
throw errors.wrap(ErrSpanningCommit, `the recorded ops span braids ${[...partitioned.keys()].join(", ")}`)
|
|
467
|
+
}
|
|
468
|
+
const [braid, ops] = [...partitioned.entries()][0] as [string, BatchOp[]]
|
|
469
|
+
const outcome = await disciplineCommit(core, state, braid, ops)
|
|
470
|
+
if (outcome.tag === "rejected") {
|
|
471
|
+
return outcome
|
|
472
|
+
}
|
|
473
|
+
return { ...outcome, value: recorded.value }
|
|
474
|
+
})
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
async commitSplit(body) {
|
|
478
|
+
return withGate(core, async function splitBody() {
|
|
479
|
+
await settleInheritedPending(core, state)
|
|
480
|
+
const recorded = await recordWithLeases(core, state, body)
|
|
481
|
+
if (recorded.ops.length === 0) {
|
|
482
|
+
throw errors.new("commitSplit recorded no ops — an empty transaction names no braid")
|
|
483
|
+
}
|
|
484
|
+
const partitioned = braidsTouched(core, recorded.ops)
|
|
485
|
+
const outcomes: BraidOutcome<Rels>[] = []
|
|
486
|
+
for (const [braid, ops] of partitioned) {
|
|
487
|
+
const outcome = await disciplineCommit(core, state, braid, ops)
|
|
488
|
+
if (outcome.tag === "rejected") {
|
|
489
|
+
outcomes.push({ tag: "rejected", braid, violations: outcome.violations })
|
|
490
|
+
} else {
|
|
491
|
+
outcomes.push({
|
|
492
|
+
tag: "accepted",
|
|
493
|
+
braid: outcome.braid,
|
|
494
|
+
generation: outcome.generation,
|
|
495
|
+
durability: outcome.durability
|
|
496
|
+
})
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return { value: recorded.value, outcomes }
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export type { BraidOutcome, Commit, CommitSplit, Durability, LogBatch, Writer }
|
|
506
|
+
export { openWriter }
|