@bjornpagen/bumbledb-log 0.18.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,18 +8,20 @@
8
8
  */
9
9
 
10
10
  import * as errors from "@superbuilders/errors"
11
- import { ByteReader, ByteWriter, bytesEqual, fromHex, toHex, utf8Encoder } from "#bytes.ts"
11
+ import type { Digest32 } from "#bytes.ts"
12
+ import { ByteReader, ByteWriter, bytesEqual, digest32, hex32, utf8Encoder } from "#bytes.ts"
12
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
16
  import type { Generation } from "#keys.ts"
16
17
  import { generation } from "#keys.ts"
17
18
  import type { TaggedRefusal, Value } from "#value.ts"
18
- import { readTagged, writeTagged } from "#value.ts"
19
+ import { checkAgainst, readTagged, writeTagged } from "#value.ts"
19
20
 
20
21
  const MAGIC = utf8Encoder.encode("BDBL")
21
- const VERSION = 2
22
+ const VERSION = 3
22
23
  const OP_KIND = { insert: 1, delete: 2 } as const
24
+ const U32_MAX = 0xffffffffn
23
25
 
24
26
  interface Op {
25
27
  readonly op: "insert" | "delete"
@@ -28,10 +30,25 @@ interface Op {
28
30
  }
29
31
 
30
32
  interface BatchHeader {
31
- readonly fingerprint: string
33
+ readonly fingerprint: Digest32
32
34
  readonly braid: Braid
33
35
  readonly braidGen: Generation
34
- readonly prev: string
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
35
52
  readonly writer: bigint
36
53
  readonly timestamp: bigint
37
54
  }
@@ -46,49 +63,86 @@ function braidIdOf(id: Braid): number {
46
63
  }
47
64
 
48
65
  /**
49
- * Encodes one batch. The header's `braid_gen` must equal the slot number
50
- * the object is published under; every op relation must belong to the
51
- * 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.
52
71
  */
53
- function encodeBatch(theory: Theory, header: BatchHeader, ops: readonly Op[]): Uint8Array {
72
+ function encodeBatch(theory: Theory, header: EncodeHeader, ops: readonly Op[]): Uint8Array {
54
73
  const descriptor = descriptorOf(theory)
55
- if (header.fingerprint !== descriptor.fingerprint) {
56
- 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}`)
57
78
  }
58
79
  const braidId = braidIdOf(header.braid)
59
80
  const members = descriptor.braidMembers.get(header.braid)
60
81
  if (members === undefined) {
61
82
  throw errors.new(`braid ${header.braid} is not derived from this descriptor`)
62
83
  }
63
- for (const op of ops) {
84
+ for (const [opIndex, op] of ops.entries()) {
64
85
  const relation = descriptor.relationByName.get(op.relation)
65
86
  if (relation === undefined) {
66
87
  throw errors.new(`op cites unknown relation ${op.relation}`)
67
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
+ }
68
95
  if (!members.includes(relation.id)) {
69
96
  throw errors.new(`op relation ${op.relation} is outside braid ${header.braid} — a spanning batch is unencodable`)
70
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`)
71
121
  }
72
122
 
73
123
  const out = new ByteWriter(4096)
74
124
  out.bytes(MAGIC)
75
125
  out.u16le(VERSION)
76
126
  out.u16le(0)
77
- out.bytes(fromHex(header.fingerprint))
127
+ out.bytes(fingerprint)
78
128
  out.u32le(braidId)
79
129
  out.u64le(header.braidGen)
80
- out.bytes(fromHex(header.prev))
130
+ out.bytes(prev)
81
131
  out.u64le(header.writer)
82
132
  out.u64le(header.timestamp)
83
- out.u32le(ops.length)
133
+ out.u32le(Number(opCount))
84
134
  for (const op of ops) {
85
135
  const relation = descriptor.relationByName.get(op.relation)
86
136
  if (relation === undefined) {
87
137
  throw errors.new(`op cites unknown relation ${op.relation}`)
88
138
  }
139
+ const rowCount = BigInt(op.rows.length)
140
+ if (rowCount > U32_MAX) {
141
+ throw errors.new(`encode row count ${rowCount} exceeds u32`)
142
+ }
89
143
  out.u8(OP_KIND[op.op])
90
144
  out.u32le(relation.id)
91
- out.u32le(op.rows.length)
145
+ out.u32le(Number(rowCount))
92
146
  for (const row of op.rows) {
93
147
  relation.fields.forEach(function writeCell(field, ordinal) {
94
148
  const value = row[ordinal]
@@ -102,6 +156,34 @@ function encodeBatch(theory: Theory, header: BatchHeader, ops: readonly Op[]): U
102
156
  return out.finish()
103
157
  }
104
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
+
105
187
  /** Full parse of a batch object; refusals are typed, never partial reads. */
106
188
  function decodeBatch(theory: Theory, bytes: Uint8Array): DecodedBatch {
107
189
  const descriptor = descriptorOf(theory)
@@ -123,10 +205,10 @@ function decodeBatch(theory: Theory, bytes: Uint8Array): DecodedBatch {
123
205
  if (flags !== 0) {
124
206
  refuse({ kind: "Flags", flags }, `batch flags ${flags} must be 0`)
125
207
  }
126
- const fingerprint = toHex(reader.bytes(32, "fingerprint"))
127
- if (fingerprint !== descriptor.fingerprint) {
208
+ const fingerprint = digest32(reader.bytes(32, "fingerprint"))
209
+ if (!bytesEqual(fingerprint, descriptor.fingerprintBytes)) {
128
210
  refuse(
129
- { kind: "FingerprintMismatch", carried: fingerprint, expected: descriptor.fingerprint },
211
+ { kind: "FingerprintMismatch", carried: hex32(fingerprint), expected: descriptor.fingerprint },
130
212
  "batch fingerprint does not match the descriptor"
131
213
  )
132
214
  }
@@ -137,47 +219,46 @@ function decodeBatch(theory: Theory, bytes: Uint8Array): DecodedBatch {
137
219
  refuse({ kind: "UnknownBraid", braid: braidId }, `batch braid ${braid} is not derived from this descriptor`)
138
220
  }
139
221
  const braidGen = generation(reader.u64le("braid generation"))
140
- const prev = toHex(reader.bytes(32, "prev"))
222
+ const prev = digest32(reader.bytes(32, "prev"))
141
223
  const writer = reader.u64le("writer")
142
224
  const timestamp = reader.u64le("timestamp")
143
225
 
144
- const opCount = reader.u32le("op count")
226
+ const opCount = readU32(reader, "op count")
227
+ refuseUnbacked(opCount, reader.remaining(), MIN_OP_BYTES, "op count")
145
228
  const ops: Op[] = []
146
- for (let opIndex = 0; opIndex < opCount; opIndex++) {
229
+ for (let opIndex = 0n; opIndex < opCount; opIndex++) {
230
+ const op = Number(opIndex)
147
231
  const kind = reader.u8("op kind")
148
232
  if (kind !== OP_KIND.insert && kind !== OP_KIND.delete) {
149
233
  refuse(
150
- { kind: "UnknownOpKind", op: opIndex, opKind: kind },
151
- `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)`
152
236
  )
153
237
  }
154
238
  const relationId = reader.u32le("op relation")
155
239
  const relation = descriptor.relations[relationId]
156
240
  if (relation === undefined) {
157
- refuse(
158
- { kind: "UnknownRelation", op: opIndex, relation: relationId },
159
- `op ${opIndex} cites unknown relation ${relationId}`
160
- )
241
+ refuse({ kind: "UnknownRelation", op, relation: relationId }, `op ${op} cites unknown relation ${relationId}`)
161
242
  }
162
243
  if (relation.closed) {
163
- refuse(
164
- { kind: "ClosedRelation", op: opIndex, relation: relationId },
165
- `op ${opIndex} writes closed relation ${relation.name}`
166
- )
244
+ refuse({ kind: "ClosedRelation", op, relation: relationId }, `op ${op} writes closed relation ${relation.name}`)
167
245
  }
168
246
  if (!members.includes(relationId)) {
169
247
  refuse(
170
- { kind: "OpRelationOutsideBraid", op: opIndex, relation: relationId, braid },
171
- `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}`
172
250
  )
173
251
  }
174
- const rowCount = reader.u32le("row count")
252
+ const rowCount = readU32(reader, "row count")
253
+ const minRow = relation.fields.length === 0 ? 0n : 1n
254
+ refuseUnbacked(rowCount, reader.remaining(), minRow, "row count")
175
255
  const rows: Value[][] = []
176
- for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
256
+ for (let rowIndex = 0n; rowIndex < rowCount; rowIndex++) {
257
+ const rowAt = Number(rowIndex)
177
258
  const row: Value[] = []
178
259
  relation.fields.forEach(function readCell(field) {
179
- const at = { relation: relation.name, row: rowIndex, field: field.name }
180
- 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}`
181
262
  const refusal: TaggedRefusal = {
182
263
  badTag(): never {
183
264
  refuse({ kind: "TagMismatch", ...at }, `${where}: tag does not match the layout`)
@@ -217,7 +298,7 @@ function decodeBatch(theory: Theory, bytes: Uint8Array): DecodedBatch {
217
298
 
218
299
  interface ChainEntry {
219
300
  readonly g: Generation
220
- readonly prev: string
301
+ readonly prev: Digest32
221
302
  readonly ts: bigint
222
303
  }
223
304
 
@@ -236,7 +317,7 @@ function verifyChain(header: BatchHeader, braid: Braid, slot: Generation, chain:
236
317
  `braid ${braid}: header slot identity ${header.braid}/${header.braidGen} ≠ the fetched key's ${braid}/${slot}`
237
318
  )
238
319
  }
239
- if (header.prev !== chain.prev) {
320
+ if (!bytesEqual(header.prev, chain.prev)) {
240
321
  refuseChain(
241
322
  { cause: "prev", braid, slot, writer: header.writer },
242
323
  `braid ${braid} slot ${slot}: prev does not cite the predecessor`
@@ -250,5 +331,5 @@ function verifyChain(header: BatchHeader, braid: Braid, slot: Generation, chain:
250
331
  }
251
332
  }
252
333
 
253
- export type { BatchHeader, ChainEntry, DecodedBatch, Op }
334
+ export type { BatchHeader, ChainEntry, DecodedBatch, Digest32, EncodeHeader, Op }
254
335
  export { decodeBatch, encodeBatch, verifyChain }