@bjornpagen/bumbledb-log 0.19.2 → 0.20.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
@@ -1,32 +1,39 @@
1
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.
2
+ * The command codec seat (20): one implementation reads and writes the
3
+ * batch wire `crates/bumbledb-log`, reached through the sealed
4
+ * per-theory `LogCodec` handle the descriptor parse mints
5
+ * (`descriptor.codec`). This module is typed payload construction only:
6
+ * raw rows tag by the descriptor's layout on the way in, decoded rows
7
+ * cross exactly as the engine's `ValueOut` walk, and every grammar
8
+ * refusal carries the log core's own identity kind, minted through the
9
+ * bridge's `log-identities.json` table. The chain discipline
10
+ * (`verifyChain`) is pure slot algebra over decoded headers and stays
11
+ * host-side; no byte grammar lives here.
8
12
  */
9
13
 
14
+ import type {
15
+ FactValue,
16
+ LogBatchDecodeKind,
17
+ LogBatchEncodeKind,
18
+ LogOpIn,
19
+ ValueSpec,
20
+ ValueTypeSpec
21
+ } from "@bjornpagen/bumbledb"
22
+ import { internalLogDecodeBatch, internalLogEncodeBatch } from "@bjornpagen/bumbledb"
10
23
  import * as errors from "@superbuilders/errors"
11
24
  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"
25
+ import { bytesEqual, digest32 } from "#bytes.ts"
26
+ import type { ChainEntry } from "#chain.ts"
27
+ import type { Braid, Descriptor, Theory } from "#descriptor.ts"
14
28
  import { braidHex, descriptorOf } from "#descriptor.ts"
15
29
  import { refuse, refuseChain } from "#errors.ts"
16
30
  import type { Generation } from "#keys.ts"
17
31
  import { generation } from "#keys.ts"
18
- import type { TaggedRefusal, Value } from "#value.ts"
19
- import { checkAgainst, readTagged, writeTagged } from "#value.ts"
20
-
21
- const MAGIC = utf8Encoder.encode("BDBL")
22
- const VERSION = 3
23
- const OP_KIND = { insert: 1, delete: 2 } as const
24
- const U32_MAX = 0xffffffffn
25
32
 
26
33
  interface Op {
27
34
  readonly op: "insert" | "delete"
28
35
  readonly relation: string
29
- readonly rows: ReadonlyArray<readonly Value[]>
36
+ readonly rows: ReadonlyArray<readonly FactValue[]>
30
37
  }
31
38
 
32
39
  interface BatchHeader {
@@ -39,13 +46,13 @@ interface BatchHeader {
39
46
  }
40
47
 
41
48
  /**
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`.
49
+ * Encode input. The handle is the fingerprint authority, so no
50
+ * fingerprint field exists here encode fills the wire's from the
51
+ * sealed codec. `prev` is raw bytes so a short digest reaches the named
52
+ * `DigestWidth` refuse at this gate instead of dying at `Digest32`
53
+ * construction. Decode still produces a branded `BatchHeader`.
46
54
  */
47
55
  interface EncodeHeader {
48
- readonly fingerprint: Uint8Array
49
56
  readonly braid: Braid
50
57
  readonly braidGen: Generation
51
58
  readonly prev: Uint8Array
@@ -62,246 +69,156 @@ function braidIdOf(id: Braid): number {
62
69
  return Number.parseInt(id.slice(1), 16)
63
70
  }
64
71
 
72
+ function asDigest(bytes: Uint8Array, at: string): Digest32 {
73
+ if (bytes.length !== 32) {
74
+ refuse({ kind: "DigestWidth" }, `${at} is not 32 bytes`)
75
+ }
76
+ return digest32(bytes)
77
+ }
78
+
79
+ /** A bridge refusal row surfaces as `ErrRefused` carrying the core's identity kind. */
80
+ function refuseBridge(kind: LogBatchDecodeKind | LogBatchEncodeKind, message: string): never {
81
+ refuse({ kind }, message)
82
+ }
83
+
65
84
  /**
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.
85
+ * Tags one raw cell by the field's declared layout the bridge's
86
+ * inbound spelling. Only the JS shape is judged here (the tag must be
87
+ * constructible); range, width, and interval emptiness are the core's
88
+ * own `Value` refusal.
71
89
  */
72
- function encodeBatch(theory: Theory, header: EncodeHeader, ops: readonly Op[]): Uint8Array {
73
- const descriptor = descriptorOf(theory)
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}`)
78
- }
79
- const braidId = braidIdOf(header.braid)
80
- const members = descriptor.braidMembers.get(header.braid)
81
- if (members === undefined) {
82
- throw errors.new(`braid ${header.braid} is not derived from this descriptor`)
83
- }
84
- for (const [opIndex, op] of ops.entries()) {
85
- const relation = descriptor.relationByName.get(op.relation)
86
- if (relation === undefined) {
87
- throw errors.new(`op cites unknown relation ${op.relation}`)
90
+ function taggedCell(where: string, type: ValueTypeSpec, value: FactValue): ValueSpec {
91
+ switch (type.kind) {
92
+ case "bool": {
93
+ if (typeof value !== "boolean") {
94
+ throw errors.new(`${where}: expected boolean`)
95
+ }
96
+ return { kind: "bool", value }
97
+ }
98
+ case "u64": {
99
+ if (typeof value !== "bigint") {
100
+ throw errors.new(`${where}: expected u64 bigint`)
101
+ }
102
+ return { kind: "u64", value }
88
103
  }
89
- if (relation.closed) {
90
- refuse(
91
- { kind: "ClosedRelation", op: opIndex, relation: relation.id },
92
- `op ${opIndex} writes closed relation ${relation.name}`
93
- )
104
+ case "i64": {
105
+ if (typeof value !== "bigint") {
106
+ throw errors.new(`${where}: expected i64 bigint`)
107
+ }
108
+ return { kind: "i64", value }
94
109
  }
95
- if (!members.includes(relation.id)) {
96
- throw errors.new(`op relation ${op.relation} is outside braid ${header.braid} — a spanning batch is unencodable`)
110
+ case "string": {
111
+ if (typeof value !== "string") {
112
+ throw errors.new(`${where}: expected well-formed string`)
113
+ }
114
+ if (!value.isWellFormed()) {
115
+ throw errors.new(`${where}: string cell is not well-formed UTF-8`)
116
+ }
117
+ return { kind: "string", value }
97
118
  }
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
- )
119
+ case "fixedBytes": {
120
+ if (!(value instanceof Uint8Array)) {
121
+ throw errors.new(`${where}: expected ${type.len}-byte Uint8Array`)
104
122
  }
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
- })
123
+ return { kind: "fixedBytes", value }
124
+ }
125
+ case "interval": {
126
+ if (typeof value !== "object" || value instanceof Uint8Array) {
127
+ throw errors.new(`${where}: expected interval value`)
128
+ }
129
+ return type.element === "u64"
130
+ ? { kind: "intervalU64", start: value.start, end: value.end }
131
+ : { kind: "intervalI64", start: value.start, end: value.end }
115
132
  }
116
133
  }
134
+ }
117
135
 
118
- const opCount = BigInt(ops.length)
119
- if (opCount > U32_MAX) {
120
- throw errors.new(`encode op count ${opCount} exceeds u32`)
121
- }
122
-
123
- const out = new ByteWriter(4096)
124
- out.bytes(MAGIC)
125
- out.u16le(VERSION)
126
- out.u16le(0)
127
- out.bytes(fingerprint)
128
- out.u32le(braidId)
129
- out.u64le(header.braidGen)
130
- out.bytes(prev)
131
- out.u64le(header.writer)
132
- out.u64le(header.timestamp)
133
- out.u32le(Number(opCount))
134
- for (const op of ops) {
136
+ /**
137
+ * Ops to the bridge's spelling: relation names resolve through the
138
+ * descriptor's vocabulary (the core never sees a name), cells tag by
139
+ * the layout. A cell past the layout's width has no type to tag by and
140
+ * refuses `Arity` here; every other judgment is the core's.
141
+ */
142
+ function opsIn(descriptor: Descriptor, ops: readonly Op[]): LogOpIn[] {
143
+ return ops.map(function opIn(op, opIndex) {
135
144
  const relation = descriptor.relationByName.get(op.relation)
136
145
  if (relation === undefined) {
137
146
  throw errors.new(`op cites unknown relation ${op.relation}`)
138
147
  }
139
- const rowCount = BigInt(op.rows.length)
140
- if (rowCount > U32_MAX) {
141
- throw errors.new(`encode row count ${rowCount} exceeds u32`)
142
- }
143
- out.u8(OP_KIND[op.op])
144
- out.u32le(relation.id)
145
- out.u32le(Number(rowCount))
146
- for (const row of op.rows) {
147
- relation.fields.forEach(function writeCell(field, ordinal) {
148
- const value = row[ordinal]
149
- if (value === undefined) {
150
- throw errors.new(`relation ${relation.name}: row cell ${ordinal} absent`)
148
+ const rows = op.rows.map(function rowIn(row, rowIndex) {
149
+ return row.map(function cellIn(value, ordinal) {
150
+ const field = relation.fields[ordinal]
151
+ if (field === undefined) {
152
+ refuse(
153
+ { kind: "Arity", op: opIndex, relation: relation.name, row: rowIndex },
154
+ `op ${opIndex} relation ${relation.name} row ${rowIndex} cell ${ordinal} is outside the ${relation.fields.length}-field layout`
155
+ )
151
156
  }
152
- writeTagged(out, field.type, value)
157
+ return taggedCell(`relation ${relation.name} field ${field.name}`, field.type, value)
153
158
  })
154
- }
155
- }
156
- return out.finish()
157
- }
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))
159
+ })
160
+ return { kind: op.op, relation: relation.id, rows }
161
+ })
168
162
  }
169
163
 
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
- }
164
+ /**
165
+ * Encodes one batch through the sealed codec. The handle is the
166
+ * fingerprint authority: encode fills the wire's fingerprint from the
167
+ * sealed codec, so none rides the bridge (a short `prev` is
168
+ * `DigestWidth` at this gate). Braid membership, closedness, arity, and
169
+ * value validity are the core's refusals, crossing with their identity
170
+ * kinds.
171
+ */
172
+ function encodeBatch(theory: Theory, header: EncodeHeader, ops: readonly Op[]): Uint8Array {
173
+ const descriptor = descriptorOf(theory)
174
+ const prev = asDigest(header.prev, "prev")
175
+ const outcome = internalLogEncodeBatch(
176
+ descriptor.codec,
177
+ {
178
+ braid: braidIdOf(header.braid),
179
+ braidGen: header.braidGen,
180
+ prev,
181
+ writer: header.writer,
182
+ timestamp: header.timestamp
183
+ },
184
+ opsIn(descriptor, ops)
185
+ )
186
+ if (!outcome.ok) {
187
+ refuseBridge(outcome.kind, outcome.message)
188
+ }
189
+ return outcome.value
185
190
  }
186
191
 
187
- /** Full parse of a batch object; refusals are typed, never partial reads. */
192
+ /** Full parse of a batch object by the one grammar; refusals cross typed, never partial reads. */
188
193
  function decodeBatch(theory: Theory, bytes: Uint8Array): DecodedBatch {
189
194
  const descriptor = descriptorOf(theory)
190
- const reader = new ByteReader(bytes, {
191
- fail(what: string): never {
192
- refuse({ kind: "Truncated", at: what }, `batch truncated at ${what}`)
193
- }
194
- })
195
-
196
- const magic = reader.bytes(4, "magic")
197
- if (!bytesEqual(magic, MAGIC)) {
198
- refuse({ kind: "BadMagic" }, "batch magic is not BDBL")
195
+ const outcome = internalLogDecodeBatch(descriptor.codec, bytes)
196
+ if (!outcome.ok) {
197
+ refuseBridge(outcome.kind, outcome.message)
199
198
  }
200
- const version = reader.u16le("version")
201
- if (version !== VERSION) {
202
- refuse({ kind: "Version", version }, `batch version ${version}, consumers refuse ≠ ${VERSION}`)
203
- }
204
- const flags = reader.u16le("flags")
205
- if (flags !== 0) {
206
- refuse({ kind: "Flags", flags }, `batch flags ${flags} must be 0`)
207
- }
208
- const fingerprint = digest32(reader.bytes(32, "fingerprint"))
209
- if (!bytesEqual(fingerprint, descriptor.fingerprintBytes)) {
210
- refuse(
211
- { kind: "FingerprintMismatch", carried: hex32(fingerprint), expected: descriptor.fingerprint },
212
- "batch fingerprint does not match the descriptor"
213
- )
214
- }
215
- const braidId = reader.u32le("braid")
216
- const braid = braidHex(braidId)
217
- const members = descriptor.braidMembers.get(braid)
218
- if (members === undefined) {
219
- refuse({ kind: "UnknownBraid", braid: braidId }, `batch braid ${braid} is not derived from this descriptor`)
220
- }
221
- const braidGen = generation(reader.u64le("braid generation"))
222
- const prev = digest32(reader.bytes(32, "prev"))
223
- const writer = reader.u64le("writer")
224
- const timestamp = reader.u64le("timestamp")
225
-
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)
231
- const kind = reader.u8("op kind")
232
- if (kind !== OP_KIND.insert && kind !== OP_KIND.delete) {
233
- refuse(
234
- { kind: "UnknownOpKind", op, opKind: kind },
235
- `op ${op} kind ${kind} is unknown (3 was deleted with floor bumps)`
236
- )
237
- }
238
- const relationId = reader.u32le("op relation")
239
- const relation = descriptor.relations[relationId]
199
+ const batch = outcome.value
200
+ const ops = batch.ops.map(function opOut(op) {
201
+ const relation = descriptor.relations[op.relation]
240
202
  if (relation === undefined) {
241
- refuse({ kind: "UnknownRelation", op, relation: relationId }, `op ${op} cites unknown relation ${relationId}`)
242
- }
243
- if (relation.closed) {
244
- refuse({ kind: "ClosedRelation", op, relation: relationId }, `op ${op} writes closed relation ${relation.name}`)
245
- }
246
- if (!members.includes(relationId)) {
247
- refuse(
248
- { kind: "OpRelationOutsideBraid", op, relation: relationId, braid },
249
- `op ${op} relation ${relation.name} is outside braid ${braid}`
250
- )
203
+ throw errors.new(`decoded op cites relation ${op.relation} outside the descriptor`)
251
204
  }
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[] = []
259
- relation.fields.forEach(function readCell(field) {
260
- const at = { relation: relation.name, row: rowAt, field: field.name }
261
- const where = `relation ${relation.name} row ${rowAt} field ${field.name}`
262
- const refusal: TaggedRefusal = {
263
- badTag(): never {
264
- refuse({ kind: "TagMismatch", ...at }, `${where}: tag does not match the layout`)
265
- },
266
- boolByte(byte: number): never {
267
- refuse({ kind: "BoolByte", ...at }, `${where}: bool byte ${byte}`)
268
- },
269
- invalidUtf8(): never {
270
- refuse({ kind: "InvalidUtf8", ...at }, `${where}: string payload is not UTF-8`)
271
- },
272
- emptyInterval(): never {
273
- refuse({ kind: "EmptyInterval", ...at }, `${where}: interval start does not precede its end`)
274
- },
275
- intervalOverflow(): never {
276
- refuse({ kind: "IntervalOverflow", ...at }, `${where}: fixed interval end leaves the element domain`)
277
- }
278
- }
279
- row.push(readTagged(reader, field.type, refusal))
280
- })
281
- rows.push(row)
282
- }
283
- ops.push({ op: kind === OP_KIND.insert ? "insert" : "delete", relation: relation.name, rows })
284
- }
285
-
286
- if (reader.remaining() !== 0) {
287
- refuse(
288
- { kind: "TrailingBytes", bytes: reader.remaining() },
289
- `${reader.remaining()} trailing bytes after the last op`
290
- )
291
- }
292
-
205
+ return { op: op.kind, relation: relation.name, rows: op.rows }
206
+ })
293
207
  return {
294
- header: { fingerprint, braid, braidGen, prev, writer, timestamp },
208
+ header: {
209
+ // Decode already refused any batch whose fingerprint is not
210
+ // the handle's own, so the descriptor's is the batch's.
211
+ fingerprint: digest32(descriptor.fingerprintBytes),
212
+ braid: braidHex(batch.header.braid),
213
+ braidGen: generation(batch.header.braidGen),
214
+ prev: digest32(batch.header.prev),
215
+ writer: batch.header.writer,
216
+ timestamp: batch.header.timestamp
217
+ },
295
218
  ops
296
219
  }
297
220
  }
298
221
 
299
- interface ChainEntry {
300
- readonly g: Generation
301
- readonly prev: Digest32
302
- readonly ts: bigint
303
- }
304
-
305
222
  /**
306
223
  * The chain discipline (20 apply, step 1): one identity, three proved
307
224
  * causes — the header's slot identity (braid and generation, both
@@ -331,5 +248,5 @@ function verifyChain(header: BatchHeader, braid: Braid, slot: Generation, chain:
331
248
  }
332
249
  }
333
250
 
334
- export type { BatchHeader, ChainEntry, DecodedBatch, Digest32, EncodeHeader, Op }
251
+ export type { BatchHeader, ChainEntry, DecodedBatch, EncodeHeader, Op }
335
252
  export { decodeBatch, encodeBatch, verifyChain }