@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/src/codec.ts CHANGED
@@ -8,89 +8,141 @@
8
8
  */
9
9
 
10
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"
11
+ import type { Digest32 } from "#bytes.ts"
12
+ import { ByteReader, ByteWriter, bytesEqual, digest32, hex32, utf8Encoder } from "#bytes.ts"
13
+ import type { Braid, Theory } from "#descriptor.ts"
13
14
  import { braidHex, descriptorOf } from "#descriptor.ts"
14
15
  import { refuse, refuseChain } from "#errors.ts"
15
- import type { LogValue, TaggedRefusal } from "#value.ts"
16
- import { readTagged, writeTagged } from "#value.ts"
16
+ import type { Generation } from "#keys.ts"
17
+ import { generation } from "#keys.ts"
18
+ import type { TaggedRefusal, Value } from "#value.ts"
19
+ import { checkAgainst, readTagged, writeTagged } from "#value.ts"
17
20
 
18
21
  const MAGIC = utf8Encoder.encode("BDBL")
19
- const VERSION = 2
22
+ const VERSION = 3
20
23
  const OP_KIND = { insert: 1, delete: 2 } as const
24
+ const U32_MAX = 0xffffffffn
21
25
 
22
- interface BatchOp {
26
+ interface Op {
23
27
  readonly op: "insert" | "delete"
24
28
  readonly relation: string
25
- readonly rows: ReadonlyArray<readonly LogValue[]>
29
+ readonly rows: ReadonlyArray<readonly Value[]>
26
30
  }
27
31
 
28
32
  interface BatchHeader {
29
- readonly fingerprint: string
30
- readonly braid: string
31
- readonly braidGen: bigint
32
- readonly prev: string
33
+ readonly fingerprint: Digest32
34
+ readonly braid: Braid
35
+ readonly braidGen: Generation
36
+ readonly prev: Digest32
37
+ readonly writer: bigint
38
+ readonly timestamp: bigint
39
+ }
40
+
41
+ /**
42
+ * Encode input. Digest fields are raw bytes so a short `prev` reaches
43
+ * the named `DigestWidth` refuse at this gate instead of dying at
44
+ * `Digest32` construction. Decode still produces a branded
45
+ * `BatchHeader`.
46
+ */
47
+ interface EncodeHeader {
48
+ readonly fingerprint: Uint8Array
49
+ readonly braid: Braid
50
+ readonly braidGen: Generation
51
+ readonly prev: Uint8Array
33
52
  readonly writer: bigint
34
53
  readonly timestamp: bigint
35
54
  }
36
55
 
37
56
  interface DecodedBatch {
38
57
  readonly header: BatchHeader
39
- readonly ops: readonly BatchOp[]
58
+ readonly ops: readonly Op[]
40
59
  }
41
60
 
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)
61
+ function braidIdOf(id: Braid): number {
62
+ return Number.parseInt(id.slice(1), 16)
48
63
  }
49
64
 
50
65
  /**
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.
66
+ * Encodes one batch. Digests are branded here: a `prev` or fingerprint
67
+ * that is not 32 bytes is `DigestWidth`. The header's `braid_gen` must
68
+ * equal the slot number the object is published under; every op
69
+ * relation must belong to the header's braid — a spanning batch is
70
+ * unencodable.
54
71
  */
55
- function encodeBatch(theory: LogTheory, header: BatchHeader, ops: readonly BatchOp[]): Uint8Array {
72
+ function encodeBatch(theory: Theory, header: EncodeHeader, ops: readonly Op[]): Uint8Array {
56
73
  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}`)
74
+ const fingerprint = asDigest(header.fingerprint, "fingerprint")
75
+ const prev = asDigest(header.prev, "prev")
76
+ if (!bytesEqual(fingerprint, descriptor.fingerprintBytes)) {
77
+ throw errors.new(`encode fingerprint ${hex32(fingerprint)} is not the descriptor's ${descriptor.fingerprint}`)
59
78
  }
60
79
  const braidId = braidIdOf(header.braid)
61
80
  const members = descriptor.braidMembers.get(header.braid)
62
81
  if (members === undefined) {
63
82
  throw errors.new(`braid ${header.braid} is not derived from this descriptor`)
64
83
  }
65
- for (const op of ops) {
84
+ for (const [opIndex, op] of ops.entries()) {
66
85
  const relation = descriptor.relationByName.get(op.relation)
67
86
  if (relation === undefined) {
68
87
  throw errors.new(`op cites unknown relation ${op.relation}`)
69
88
  }
89
+ if (relation.closed) {
90
+ refuse(
91
+ { kind: "ClosedRelation", op: opIndex, relation: relation.id },
92
+ `op ${opIndex} writes closed relation ${relation.name}`
93
+ )
94
+ }
70
95
  if (!members.includes(relation.id)) {
71
96
  throw errors.new(`op relation ${op.relation} is outside braid ${header.braid} — a spanning batch is unencodable`)
72
97
  }
98
+ for (const [rowIndex, row] of op.rows.entries()) {
99
+ if (row.length !== relation.fields.length) {
100
+ refuse(
101
+ { kind: "Arity", op: opIndex, relation: relation.name, row: rowIndex },
102
+ `op ${opIndex} relation ${relation.name} row ${rowIndex} arity ${row.length} ≠ ${relation.fields.length}`
103
+ )
104
+ }
105
+ relation.fields.forEach(function gateCell(field, ordinal) {
106
+ const value = row[ordinal]
107
+ if (value === undefined) {
108
+ refuse(
109
+ { kind: "Arity", op: opIndex, relation: relation.name, row: rowIndex },
110
+ `op ${opIndex} relation ${relation.name} row ${rowIndex} cell ${ordinal} absent`
111
+ )
112
+ }
113
+ checkAgainst(`relation ${relation.name} field ${field.name}`, field.type, value)
114
+ })
115
+ }
116
+ }
117
+
118
+ const opCount = BigInt(ops.length)
119
+ if (opCount > U32_MAX) {
120
+ throw errors.new(`encode op count ${opCount} exceeds u32`)
73
121
  }
74
122
 
75
123
  const out = new ByteWriter(4096)
76
124
  out.bytes(MAGIC)
77
125
  out.u16le(VERSION)
78
126
  out.u16le(0)
79
- out.bytes(fromHex(header.fingerprint))
127
+ out.bytes(fingerprint)
80
128
  out.u32le(braidId)
81
129
  out.u64le(header.braidGen)
82
- out.bytes(fromHex(header.prev))
130
+ out.bytes(prev)
83
131
  out.u64le(header.writer)
84
132
  out.u64le(header.timestamp)
85
- out.u32le(ops.length)
133
+ out.u32le(Number(opCount))
86
134
  for (const op of ops) {
87
135
  const relation = descriptor.relationByName.get(op.relation)
88
136
  if (relation === undefined) {
89
137
  throw errors.new(`op cites unknown relation ${op.relation}`)
90
138
  }
139
+ const rowCount = BigInt(op.rows.length)
140
+ if (rowCount > U32_MAX) {
141
+ throw errors.new(`encode row count ${rowCount} exceeds u32`)
142
+ }
91
143
  out.u8(OP_KIND[op.op])
92
144
  out.u32le(relation.id)
93
- out.u32le(op.rows.length)
145
+ out.u32le(Number(rowCount))
94
146
  for (const row of op.rows) {
95
147
  relation.fields.forEach(function writeCell(field, ordinal) {
96
148
  const value = row[ordinal]
@@ -104,8 +156,36 @@ function encodeBatch(theory: LogTheory, header: BatchHeader, ops: readonly Batch
104
156
  return out.finish()
105
157
  }
106
158
 
159
+ function asDigest(bytes: Uint8Array, at: string): Digest32 {
160
+ if (bytes.length !== 32) {
161
+ refuse({ kind: "DigestWidth" }, `${at} is not 32 bytes`)
162
+ }
163
+ return digest32(bytes)
164
+ }
165
+
166
+ function readU32(reader: ByteReader, what: string): bigint {
167
+ return BigInt(reader.u32le(what))
168
+ }
169
+
170
+ /** Kind + relation id + row count: the shortest op the grammar admits. */
171
+ const MIN_OP_BYTES = 9n
172
+
173
+ /** A declared count the remaining bytes cannot open is Truncated
174
+ * before the loop. Counts are exact bigint so a u32::MAX row vector
175
+ * cannot wrap a JavaScript number. A zero-field relation has no row
176
+ * bytes. A nonempty layout uses one tag byte so a first-cell typed
177
+ * refusal is not swallowed. */
178
+ function refuseUnbacked(count: bigint, remaining: number, minItem: bigint, at: string): void {
179
+ if (count === 0n) {
180
+ return
181
+ }
182
+ if (minItem === 0n || BigInt(remaining) / minItem < count) {
183
+ refuse({ kind: "Truncated", at }, `declared ${at} ${count} outruns the remaining ${remaining} bytes`)
184
+ }
185
+ }
186
+
107
187
  /** Full parse of a batch object; refusals are typed, never partial reads. */
108
- function decodeBatch(theory: LogTheory, bytes: Uint8Array): DecodedBatch {
188
+ function decodeBatch(theory: Theory, bytes: Uint8Array): DecodedBatch {
109
189
  const descriptor = descriptorOf(theory)
110
190
  const reader = new ByteReader(bytes, {
111
191
  fail(what: string): never {
@@ -125,10 +205,10 @@ function decodeBatch(theory: LogTheory, bytes: Uint8Array): DecodedBatch {
125
205
  if (flags !== 0) {
126
206
  refuse({ kind: "Flags", flags }, `batch flags ${flags} must be 0`)
127
207
  }
128
- const fingerprint = toHex(reader.bytes(32, "fingerprint"))
129
- if (fingerprint !== descriptor.fingerprint) {
208
+ const fingerprint = digest32(reader.bytes(32, "fingerprint"))
209
+ if (!bytesEqual(fingerprint, descriptor.fingerprintBytes)) {
130
210
  refuse(
131
- { kind: "FingerprintMismatch", carried: fingerprint, expected: descriptor.fingerprint },
211
+ { kind: "FingerprintMismatch", carried: hex32(fingerprint), expected: descriptor.fingerprint },
132
212
  "batch fingerprint does not match the descriptor"
133
213
  )
134
214
  }
@@ -138,48 +218,47 @@ function decodeBatch(theory: LogTheory, bytes: Uint8Array): DecodedBatch {
138
218
  if (members === undefined) {
139
219
  refuse({ kind: "UnknownBraid", braid: braidId }, `batch braid ${braid} is not derived from this descriptor`)
140
220
  }
141
- const braidGen = reader.u64le("braid generation")
142
- const prev = toHex(reader.bytes(32, "prev"))
221
+ const braidGen = generation(reader.u64le("braid generation"))
222
+ const prev = digest32(reader.bytes(32, "prev"))
143
223
  const writer = reader.u64le("writer")
144
224
  const timestamp = reader.u64le("timestamp")
145
225
 
146
- const opCount = reader.u32le("op count")
147
- const ops: BatchOp[] = []
148
- for (let opIndex = 0; opIndex < opCount; opIndex++) {
226
+ const opCount = readU32(reader, "op count")
227
+ refuseUnbacked(opCount, reader.remaining(), MIN_OP_BYTES, "op count")
228
+ const ops: Op[] = []
229
+ for (let opIndex = 0n; opIndex < opCount; opIndex++) {
230
+ const op = Number(opIndex)
149
231
  const kind = reader.u8("op kind")
150
232
  if (kind !== OP_KIND.insert && kind !== OP_KIND.delete) {
151
233
  refuse(
152
- { kind: "UnknownOpKind", op: opIndex, opKind: kind },
153
- `op ${opIndex} kind ${kind} is unknown (3 was deleted with floor bumps)`
234
+ { kind: "UnknownOpKind", op, opKind: kind },
235
+ `op ${op} kind ${kind} is unknown (3 was deleted with floor bumps)`
154
236
  )
155
237
  }
156
238
  const relationId = reader.u32le("op relation")
157
239
  const relation = descriptor.relations[relationId]
158
240
  if (relation === undefined) {
159
- refuse(
160
- { kind: "UnknownRelation", op: opIndex, relation: relationId },
161
- `op ${opIndex} cites unknown relation ${relationId}`
162
- )
241
+ refuse({ kind: "UnknownRelation", op, relation: relationId }, `op ${op} cites unknown relation ${relationId}`)
163
242
  }
164
243
  if (relation.closed) {
165
- refuse(
166
- { kind: "ClosedRelation", op: opIndex, relation: relationId },
167
- `op ${opIndex} writes closed relation ${relation.name}`
168
- )
244
+ refuse({ kind: "ClosedRelation", op, relation: relationId }, `op ${op} writes closed relation ${relation.name}`)
169
245
  }
170
246
  if (!members.includes(relationId)) {
171
247
  refuse(
172
- { kind: "OpRelationOutsideBraid", op: opIndex, relation: relationId, braid },
173
- `op ${opIndex} relation ${relation.name} is outside braid ${braid}`
248
+ { kind: "OpRelationOutsideBraid", op, relation: relationId, braid },
249
+ `op ${op} relation ${relation.name} is outside braid ${braid}`
174
250
  )
175
251
  }
176
- const rowCount = reader.u32le("row count")
177
- const rows: LogValue[][] = []
178
- for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
179
- const row: LogValue[] = []
252
+ const rowCount = readU32(reader, "row count")
253
+ const minRow = relation.fields.length === 0 ? 0n : 1n
254
+ refuseUnbacked(rowCount, reader.remaining(), minRow, "row count")
255
+ const rows: Value[][] = []
256
+ for (let rowIndex = 0n; rowIndex < rowCount; rowIndex++) {
257
+ const rowAt = Number(rowIndex)
258
+ const row: Value[] = []
180
259
  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}`
260
+ const at = { relation: relation.name, row: rowAt, field: field.name }
261
+ const where = `relation ${relation.name} row ${rowAt} field ${field.name}`
183
262
  const refusal: TaggedRefusal = {
184
263
  badTag(): never {
185
264
  refuse({ kind: "TagMismatch", ...at }, `${where}: tag does not match the layout`)
@@ -217,9 +296,9 @@ function decodeBatch(theory: LogTheory, bytes: Uint8Array): DecodedBatch {
217
296
  }
218
297
  }
219
298
 
220
- interface ChainPosition {
221
- readonly g: bigint
222
- readonly prev: string
299
+ interface ChainEntry {
300
+ readonly g: Generation
301
+ readonly prev: Digest32
223
302
  readonly ts: bigint
224
303
  }
225
304
 
@@ -231,14 +310,14 @@ interface ChainPosition {
231
310
  * names the misbehaving writer, and the refusal data names the fetched
232
311
  * braid.
233
312
  */
234
- function verifyChain(header: BatchHeader, braid: string, slot: bigint, chain: ChainPosition): void {
313
+ function verifyChain(header: BatchHeader, braid: Braid, slot: Generation, chain: ChainEntry): void {
235
314
  if (header.braid !== braid || header.braidGen !== slot) {
236
315
  refuseChain(
237
316
  { cause: "slot", braid, slot, writer: header.writer },
238
317
  `braid ${braid}: header slot identity ${header.braid}/${header.braidGen} ≠ the fetched key's ${braid}/${slot}`
239
318
  )
240
319
  }
241
- if (header.prev !== chain.prev) {
320
+ if (!bytesEqual(header.prev, chain.prev)) {
242
321
  refuseChain(
243
322
  { cause: "prev", braid, slot, writer: header.writer },
244
323
  `braid ${braid} slot ${slot}: prev does not cite the predecessor`
@@ -252,5 +331,5 @@ function verifyChain(header: BatchHeader, braid: string, slot: bigint, chain: Ch
252
331
  }
253
332
  }
254
333
 
255
- export type { BatchHeader, BatchOp, ChainPosition, DecodedBatch }
334
+ export type { BatchHeader, ChainEntry, DecodedBatch, Digest32, EncodeHeader, Op }
256
335
  export { decodeBatch, encodeBatch, verifyChain }