@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/src/codec.ts ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * The command codec (20): one binary batch format, implemented twice
3
+ * (Rust in `bumbledb-log`, TS here), pinned equal by cross-goldens. A
4
+ * batch is header + ops; commands carry raw values, never intern ids.
5
+ * Decode is a full parse before any apply: every illegal byte is a
6
+ * typed refusal, and bytes after the last op refuse as trailing
7
+ * garbage.
8
+ */
9
+
10
+ import * as errors from "@superbuilders/errors"
11
+ import { ByteReader, ByteWriter, bytesEqual, fromHex, toHex, utf8Encoder } from "#bytes.ts"
12
+ import type { LogTheory } from "#descriptor.ts"
13
+ import { braidHex, descriptorOf } from "#descriptor.ts"
14
+ import { refuse, refuseChain } from "#errors.ts"
15
+ import type { LogValue, TaggedRefusal } from "#value.ts"
16
+ import { readTagged, writeTagged } from "#value.ts"
17
+
18
+ const MAGIC = utf8Encoder.encode("BDBL")
19
+ const VERSION = 2
20
+ const OP_KIND = { insert: 1, delete: 2 } as const
21
+
22
+ interface BatchOp {
23
+ readonly op: "insert" | "delete"
24
+ readonly relation: string
25
+ readonly rows: ReadonlyArray<readonly LogValue[]>
26
+ }
27
+
28
+ interface BatchHeader {
29
+ readonly fingerprint: string
30
+ readonly braid: string
31
+ readonly braidGen: bigint
32
+ readonly prev: string
33
+ readonly writer: bigint
34
+ readonly timestamp: bigint
35
+ }
36
+
37
+ interface DecodedBatch {
38
+ readonly header: BatchHeader
39
+ readonly ops: readonly BatchOp[]
40
+ }
41
+
42
+ function braidIdOf(braid: string): number {
43
+ const match = /^c([0-9a-f]{8})$/.exec(braid)
44
+ if (match === null || match[1] === undefined) {
45
+ throw errors.new(`not a braid id: ${braid}`)
46
+ }
47
+ return Number.parseInt(match[1], 16)
48
+ }
49
+
50
+ /**
51
+ * Encodes one batch. The header's `braid_gen` must equal the slot number
52
+ * the object is published under; every op relation must belong to the
53
+ * header's braid — a spanning batch is unencodable.
54
+ */
55
+ function encodeBatch(theory: LogTheory, header: BatchHeader, ops: readonly BatchOp[]): Uint8Array {
56
+ const descriptor = descriptorOf(theory)
57
+ if (header.fingerprint !== descriptor.fingerprint) {
58
+ throw errors.new(`encode fingerprint ${header.fingerprint} is not the descriptor's ${descriptor.fingerprint}`)
59
+ }
60
+ const braidId = braidIdOf(header.braid)
61
+ const members = descriptor.braidMembers.get(header.braid)
62
+ if (members === undefined) {
63
+ throw errors.new(`braid ${header.braid} is not derived from this descriptor`)
64
+ }
65
+ for (const op of ops) {
66
+ const relation = descriptor.relationByName.get(op.relation)
67
+ if (relation === undefined) {
68
+ throw errors.new(`op cites unknown relation ${op.relation}`)
69
+ }
70
+ if (!members.includes(relation.id)) {
71
+ throw errors.new(`op relation ${op.relation} is outside braid ${header.braid} — a spanning batch is unencodable`)
72
+ }
73
+ }
74
+
75
+ const out = new ByteWriter(4096)
76
+ out.bytes(MAGIC)
77
+ out.u16le(VERSION)
78
+ out.u16le(0)
79
+ out.bytes(fromHex(header.fingerprint))
80
+ out.u32le(braidId)
81
+ out.u64le(header.braidGen)
82
+ out.bytes(fromHex(header.prev))
83
+ out.u64le(header.writer)
84
+ out.u64le(header.timestamp)
85
+ out.u32le(ops.length)
86
+ for (const op of ops) {
87
+ const relation = descriptor.relationByName.get(op.relation)
88
+ if (relation === undefined) {
89
+ throw errors.new(`op cites unknown relation ${op.relation}`)
90
+ }
91
+ out.u8(OP_KIND[op.op])
92
+ out.u32le(relation.id)
93
+ out.u32le(op.rows.length)
94
+ for (const row of op.rows) {
95
+ relation.fields.forEach(function writeCell(field, ordinal) {
96
+ const value = row[ordinal]
97
+ if (value === undefined) {
98
+ throw errors.new(`relation ${relation.name}: row cell ${ordinal} absent`)
99
+ }
100
+ writeTagged(out, field.type, value)
101
+ })
102
+ }
103
+ }
104
+ return out.finish()
105
+ }
106
+
107
+ /** Full parse of a batch object; refusals are typed, never partial reads. */
108
+ function decodeBatch(theory: LogTheory, bytes: Uint8Array): DecodedBatch {
109
+ const descriptor = descriptorOf(theory)
110
+ const reader = new ByteReader(bytes, {
111
+ fail(what: string): never {
112
+ refuse({ kind: "Truncated", at: what }, `batch truncated at ${what}`)
113
+ }
114
+ })
115
+
116
+ const magic = reader.bytes(4, "magic")
117
+ if (!bytesEqual(magic, MAGIC)) {
118
+ refuse({ kind: "BadMagic" }, "batch magic is not BDBL")
119
+ }
120
+ const version = reader.u16le("version")
121
+ if (version !== VERSION) {
122
+ refuse({ kind: "Version", version }, `batch version ${version}, consumers refuse ≠ ${VERSION}`)
123
+ }
124
+ const flags = reader.u16le("flags")
125
+ if (flags !== 0) {
126
+ refuse({ kind: "Flags", flags }, `batch flags ${flags} must be 0`)
127
+ }
128
+ const fingerprint = toHex(reader.bytes(32, "fingerprint"))
129
+ if (fingerprint !== descriptor.fingerprint) {
130
+ refuse(
131
+ { kind: "FingerprintMismatch", carried: fingerprint, expected: descriptor.fingerprint },
132
+ "batch fingerprint does not match the descriptor"
133
+ )
134
+ }
135
+ const braidId = reader.u32le("braid")
136
+ const braid = braidHex(braidId)
137
+ const members = descriptor.braidMembers.get(braid)
138
+ if (members === undefined) {
139
+ refuse({ kind: "UnknownBraid", braid: braidId }, `batch braid ${braid} is not derived from this descriptor`)
140
+ }
141
+ const braidGen = reader.u64le("braid generation")
142
+ const prev = toHex(reader.bytes(32, "prev"))
143
+ const writer = reader.u64le("writer")
144
+ const timestamp = reader.u64le("timestamp")
145
+
146
+ const opCount = reader.u32le("op count")
147
+ const ops: BatchOp[] = []
148
+ for (let opIndex = 0; opIndex < opCount; opIndex++) {
149
+ const kind = reader.u8("op kind")
150
+ if (kind !== OP_KIND.insert && kind !== OP_KIND.delete) {
151
+ refuse(
152
+ { kind: "UnknownOpKind", op: opIndex, opKind: kind },
153
+ `op ${opIndex} kind ${kind} is unknown (3 was deleted with floor bumps)`
154
+ )
155
+ }
156
+ const relationId = reader.u32le("op relation")
157
+ const relation = descriptor.relations[relationId]
158
+ if (relation === undefined) {
159
+ refuse(
160
+ { kind: "UnknownRelation", op: opIndex, relation: relationId },
161
+ `op ${opIndex} cites unknown relation ${relationId}`
162
+ )
163
+ }
164
+ if (relation.closed) {
165
+ refuse(
166
+ { kind: "ClosedRelation", op: opIndex, relation: relationId },
167
+ `op ${opIndex} writes closed relation ${relation.name}`
168
+ )
169
+ }
170
+ if (!members.includes(relationId)) {
171
+ refuse(
172
+ { kind: "OpRelationOutsideBraid", op: opIndex, relation: relationId, braid },
173
+ `op ${opIndex} relation ${relation.name} is outside braid ${braid}`
174
+ )
175
+ }
176
+ const rowCount = reader.u32le("row count")
177
+ const rows: LogValue[][] = []
178
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
179
+ const row: LogValue[] = []
180
+ relation.fields.forEach(function readCell(field) {
181
+ const at = { relation: relation.name, row: rowIndex, field: field.name }
182
+ const where = `relation ${relation.name} row ${rowIndex} field ${field.name}`
183
+ const refusal: TaggedRefusal = {
184
+ badTag(): never {
185
+ refuse({ kind: "TagMismatch", ...at }, `${where}: tag does not match the layout`)
186
+ },
187
+ boolByte(byte: number): never {
188
+ refuse({ kind: "BoolByte", ...at }, `${where}: bool byte ${byte}`)
189
+ },
190
+ invalidUtf8(): never {
191
+ refuse({ kind: "InvalidUtf8", ...at }, `${where}: string payload is not UTF-8`)
192
+ },
193
+ emptyInterval(): never {
194
+ refuse({ kind: "EmptyInterval", ...at }, `${where}: interval start does not precede its end`)
195
+ },
196
+ intervalOverflow(): never {
197
+ refuse({ kind: "IntervalOverflow", ...at }, `${where}: fixed interval end leaves the element domain`)
198
+ }
199
+ }
200
+ row.push(readTagged(reader, field.type, refusal))
201
+ })
202
+ rows.push(row)
203
+ }
204
+ ops.push({ op: kind === OP_KIND.insert ? "insert" : "delete", relation: relation.name, rows })
205
+ }
206
+
207
+ if (reader.remaining() !== 0) {
208
+ refuse(
209
+ { kind: "TrailingBytes", bytes: reader.remaining() },
210
+ `${reader.remaining()} trailing bytes after the last op`
211
+ )
212
+ }
213
+
214
+ return {
215
+ header: { fingerprint, braid, braidGen, prev, writer, timestamp },
216
+ ops
217
+ }
218
+ }
219
+
220
+ interface ChainPosition {
221
+ readonly g: bigint
222
+ readonly prev: string
223
+ readonly ts: bigint
224
+ }
225
+
226
+ /**
227
+ * The chain discipline (20 apply, step 1): one identity, three proved
228
+ * causes — the header's slot identity (braid and generation, both
229
+ * halves of the key the object was fetched from), its `prev` vs the
230
+ * chain head, its timestamp vs the head's. Corruption-class; the header
231
+ * names the misbehaving writer, and the refusal data names the fetched
232
+ * braid.
233
+ */
234
+ function verifyChain(header: BatchHeader, braid: string, slot: bigint, chain: ChainPosition): void {
235
+ if (header.braid !== braid || header.braidGen !== slot) {
236
+ refuseChain(
237
+ { cause: "slot", braid, slot, writer: header.writer },
238
+ `braid ${braid}: header slot identity ${header.braid}/${header.braidGen} ≠ the fetched key's ${braid}/${slot}`
239
+ )
240
+ }
241
+ if (header.prev !== chain.prev) {
242
+ refuseChain(
243
+ { cause: "prev", braid, slot, writer: header.writer },
244
+ `braid ${braid} slot ${slot}: prev does not cite the predecessor`
245
+ )
246
+ }
247
+ if (header.timestamp < chain.ts) {
248
+ refuseChain(
249
+ { cause: "timestamp", braid, slot, writer: header.writer },
250
+ `braid ${braid} slot ${slot}: timestamp regresses below the predecessor`
251
+ )
252
+ }
253
+ }
254
+
255
+ export type { BatchHeader, BatchOp, ChainPosition, DecodedBatch }
256
+ export { decodeBatch, encodeBatch, verifyChain }