@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/manifest.ts CHANGED
@@ -1,113 +1,209 @@
1
1
  /**
2
- * The protocol's one mutable object (10): canonical single-line UTF-8
3
- * JSON, strict parse, field order fixed — strictness is proved by
4
- * re-rendering the parse and demanding byte equality, so a
5
- * non-canonical manifest is a typed refusal, never a tolerated variant.
6
- * The checkpoint json beside it is immutable and digest-keyed.
2
+ * The protocol's one mutable object: a v:3 binary record — version
3
+ * byte 3, a branded Digest32 fingerprint, an optional branded Digest32
4
+ * checkpoint. The checkpoint record is digest-keyed and immutable:
5
+ * version byte 3, a count-bounded braid roster (id, g, hash, ts) in
6
+ * ascending id order, catalog digest, writer, optional prev. Every
7
+ * digest field is Digest32. The content address is blake3 of the
8
+ * record bytes. Seed audits the catalog claim when a checkpoint is
9
+ * present.
7
10
  */
8
11
 
9
12
  import * as errors from "@superbuilders/errors"
10
- import { utf8Encoder, utf8StrictDecoder } from "#bytes.ts"
13
+ import type { Digest32 } from "#bytes.ts"
14
+ import { ByteReader, ByteWriter, bytesEqual, hex32 } from "#bytes.ts"
15
+ import type { Braid } from "#descriptor.ts"
16
+ import { braidHex } from "#descriptor.ts"
11
17
  import { refuse } from "#errors.ts"
18
+ import type { Generation } from "#keys.ts"
19
+ import { generation } from "#keys.ts"
20
+ import { Vector } from "#vector.ts"
21
+
22
+ const DOC_VERSION = 3
23
+ const U32_MAX = 0xffffffffn
24
+ /** braid u32 + g u64 + hash 32 + ts u64 */
25
+ const MIN_HEAD_BYTES = 52n
12
26
 
13
27
  interface Manifest {
14
- readonly fingerprint: string
15
- readonly checkpoint: string | null
28
+ readonly fingerprint: Digest32
29
+ readonly checkpoint: Digest32 | null
16
30
  }
17
31
 
18
- const HEX64 = /^[0-9a-f]{64}$/
19
-
20
- function renderManifest(manifest: Manifest): Uint8Array {
21
- const checkpoint = manifest.checkpoint === null ? "null" : `"${manifest.checkpoint}"`
22
- return utf8Encoder.encode(`{"v":2,"fingerprint":"${manifest.fingerprint}","checkpoint":${checkpoint}}`)
32
+ function readerOf(bytes: Uint8Array): ByteReader {
33
+ return new ByteReader(bytes, {
34
+ fail(what: string): never {
35
+ refuse({ kind: "Malformed", at: bytes.length }, `document truncated at ${what}`)
36
+ }
37
+ })
23
38
  }
24
39
 
25
- function parseManifest(bytes: Uint8Array): Manifest {
26
- const decoded = errors.trySync(function decodeText() {
27
- return utf8StrictDecoder.decode(bytes)
28
- })
29
- if (decoded.error) {
30
- refuse({ kind: "ManifestShape" }, "manifest is not UTF-8")
40
+ function finish(reader: ByteReader, at: string): void {
41
+ if (reader.remaining() !== 0) {
42
+ refuse({ kind: "Malformed", at: reader.remaining() }, `${reader.remaining()} trailing bytes after ${at}`)
31
43
  }
32
- const parsed = errors.trySync(function parseJson() {
33
- return JSON.parse(decoded.data) as unknown
34
- })
35
- if (parsed.error || typeof parsed.data !== "object" || parsed.data === null) {
36
- refuse({ kind: "ManifestShape" }, "manifest is not a JSON object")
44
+ }
45
+
46
+ function refuseUnbacked(count: bigint, remaining: number, minItem: bigint, at: string): void {
47
+ if (count === 0n) {
48
+ return
37
49
  }
38
- const record = parsed.data as Record<string, unknown>
39
- if (typeof record.v !== "number") {
40
- refuse({ kind: "ManifestShape" }, "manifest carries no version")
50
+ if (minItem === 0n || BigInt(remaining) / minItem < count) {
51
+ refuse({ kind: "Malformed", at: remaining }, `declared ${at} ${count} outruns the remaining ${remaining} bytes`)
41
52
  }
42
- if (record.v !== 2) {
43
- refuse({ kind: "ManifestVersion", version: record.v }, `manifest version ${record.v}, consumers refuse ≠ 2`)
53
+ }
54
+
55
+ function writeOptionalDigest(out: ByteWriter, digest: Digest32 | null): void {
56
+ if (digest === null) {
57
+ out.u8(0)
58
+ return
44
59
  }
45
- const fingerprint = record.fingerprint
46
- const checkpoint = record.checkpoint
47
- if (typeof fingerprint !== "string" || !HEX64.test(fingerprint)) {
48
- refuse({ kind: "ManifestShape" }, "manifest fingerprint is not 64 hex")
60
+ out.u8(1)
61
+ out.array32(digest)
62
+ }
63
+
64
+ function readOptionalDigest(reader: ByteReader, at: string): Digest32 | null {
65
+ const tag = reader.u8(at)
66
+ if (tag === 0) {
67
+ return null
49
68
  }
50
- if (checkpoint !== null && (typeof checkpoint !== "string" || !HEX64.test(checkpoint))) {
51
- refuse({ kind: "ManifestShape" }, "manifest checkpoint is neither null nor 64 hex")
69
+ if (tag === 1) {
70
+ return reader.array32(at)
52
71
  }
53
- const manifest: Manifest = { fingerprint, checkpoint: checkpoint === null ? null : checkpoint }
54
- const canonical = renderManifest(manifest)
55
- if (canonical.length !== bytes.length || utf8StrictDecoder.decode(canonical) !== decoded.data) {
56
- refuse({ kind: "ManifestShape" }, "manifest is not the canonical single-line rendering")
72
+ refuse({ kind: "Flags", flags: tag }, `${at} presence is not 0 or 1`)
73
+ }
74
+
75
+ function readVersion(reader: ByteReader, at: string): void {
76
+ const version = reader.u8("version")
77
+ if (version !== DOC_VERSION) {
78
+ refuse(
79
+ { kind: "Version", version },
80
+ `${at} version ${version}, consumers refuse every version other than ${DOC_VERSION}`
81
+ )
57
82
  }
58
- return manifest
83
+ }
84
+
85
+ function braidIdOf(id: Braid): number {
86
+ return Number.parseInt(id.slice(1), 16)
87
+ }
88
+
89
+ function renderManifest(manifest: Manifest): Uint8Array {
90
+ const out = new ByteWriter(66)
91
+ out.u8(DOC_VERSION)
92
+ out.array32(manifest.fingerprint)
93
+ writeOptionalDigest(out, manifest.checkpoint)
94
+ return out.finish()
95
+ }
96
+
97
+ function parseManifest(bytes: Uint8Array): Manifest {
98
+ const reader = readerOf(bytes)
99
+ readVersion(reader, "manifest")
100
+ const fingerprint = reader.array32("fingerprint")
101
+ const checkpoint = readOptionalDigest(reader, "checkpoint")
102
+ finish(reader, "the manifest")
103
+ return { fingerprint, checkpoint }
59
104
  }
60
105
 
61
106
  interface CheckpointHead {
62
- readonly g: bigint
63
- readonly hash: string
107
+ readonly g: Generation
108
+ readonly hash: Digest32
64
109
  readonly ts: bigint
65
110
  }
66
111
 
67
112
  interface CheckpointFacts {
68
- readonly braids: ReadonlyMap<string, CheckpointHead>
69
- readonly catalog: string
113
+ readonly braids: ReadonlyMap<Braid, CheckpointHead>
114
+ readonly catalog: Digest32
70
115
  readonly writer: bigint
71
- readonly prev: string | null
116
+ readonly prev: Digest32 | null
117
+ readonly sum: bigint
72
118
  }
73
119
 
74
- function parseCheckpoint(bytes: Uint8Array): CheckpointFacts {
75
- const parsed = errors.trySync(function parseJson() {
76
- return JSON.parse(utf8StrictDecoder.decode(bytes)) as unknown
77
- })
78
- if (parsed.error || typeof parsed.data !== "object" || parsed.data === null) {
79
- refuse({ kind: "CheckpointShape" }, "checkpoint json is not an object")
120
+ function vectorOfHeads(braids: ReadonlyMap<Braid, CheckpointHead>): Vector {
121
+ const counts = new Map<Braid, bigint>()
122
+ for (const [id, head] of braids) {
123
+ counts.set(id, head.g)
124
+ }
125
+ return Vector.from(counts)
126
+ }
127
+
128
+ function checkpointVector(facts: CheckpointFacts): Vector {
129
+ return vectorOfHeads(facts.braids)
130
+ }
131
+
132
+ function renderCheckpoint(facts: CheckpointFacts): Uint8Array {
133
+ const braids = [...facts.braids.keys()].sort()
134
+ const count = BigInt(braids.length)
135
+ if (count > U32_MAX) {
136
+ throw errors.new(`checkpoint braid count ${count} exceeds u32`)
80
137
  }
81
- const record = parsed.data as Record<string, unknown>
82
- const rawBraids = record.braids
83
- if (typeof rawBraids !== "object" || rawBraids === null) {
84
- refuse({ kind: "CheckpointShape" }, "checkpoint json carries no braids map")
138
+ const out = new ByteWriter(1 + 4 + braids.length * 52 + 32 + 8 + 1 + 32)
139
+ out.u8(DOC_VERSION)
140
+ out.u32le(Number(count))
141
+ for (const id of braids) {
142
+ const head = facts.braids.get(id)
143
+ if (head === undefined) {
144
+ throw errors.new(`checkpoint lost braid ${id}`)
145
+ }
146
+ out.u32le(braidIdOf(id))
147
+ out.u64le(head.g)
148
+ out.array32(head.hash)
149
+ out.u64le(head.ts)
85
150
  }
86
- const braids = new Map<string, CheckpointHead>()
87
- for (const [braid, head] of Object.entries(rawBraids as Record<string, unknown>)) {
88
- if (typeof head !== "object" || head === null) {
89
- refuse({ kind: "CheckpointShape" }, `checkpoint braid ${braid} head is not an object`)
151
+ out.array32(facts.catalog)
152
+ out.u64le(facts.writer)
153
+ writeOptionalDigest(out, facts.prev)
154
+ return out.finish()
155
+ }
156
+
157
+ function parseCheckpoint(bytes: Uint8Array, known?: ReadonlySet<Braid>): CheckpointFacts {
158
+ const reader = readerOf(bytes)
159
+ readVersion(reader, "checkpoint")
160
+ const count = BigInt(reader.u32le("braid count"))
161
+ refuseUnbacked(count, reader.remaining(), MIN_HEAD_BYTES, "braid count")
162
+ const braids = new Map<Braid, CheckpointHead>()
163
+ for (let i = 0n; i < count; i++) {
164
+ const raw = reader.u32le("braid")
165
+ const name = braidHex(raw)
166
+ if (known !== undefined && !known.has(name)) {
167
+ refuse({ kind: "UnknownBraid", braid: raw }, `checkpoint cites unknown braid ${name}`)
90
168
  }
91
- const headRecord = head as Record<string, unknown>
92
- const g = headRecord.g
93
- const hash = headRecord.hash
94
- const ts = headRecord.ts
95
- if (typeof g !== "number" || typeof ts !== "number" || typeof hash !== "string" || !HEX64.test(hash)) {
96
- refuse({ kind: "CheckpointShape" }, `checkpoint braid ${braid} head is malformed`)
169
+ const g = reader.u64le("generation")
170
+ const hash = reader.array32("hash")
171
+ const ts = reader.u64le("timestamp")
172
+ const last = [...braids.keys()].at(-1)
173
+ if (last !== undefined && last >= name) {
174
+ refuse(
175
+ { kind: "Malformed", at: bytes.length - reader.remaining() },
176
+ "checkpoint braid roster is not strictly ascending"
177
+ )
97
178
  }
98
- braids.set(braid, { g: BigInt(g), hash, ts: BigInt(ts) })
179
+ braids.set(name, { g: generation(g), hash, ts })
180
+ }
181
+ const catalog = reader.array32("catalog")
182
+ const writer = reader.u64le("writer")
183
+ const prev = readOptionalDigest(reader, "prev")
184
+ finish(reader, "the checkpoint")
185
+ const summed = vectorOfHeads(braids).sum()
186
+ if (typeof summed !== "bigint") {
187
+ refuse({ kind: "Overflow" }, "checkpoint vector sum overflows u64")
99
188
  }
100
- const catalog = record.catalog
101
- const writer = record.writer
102
- const prev = record.prev
103
- if (typeof catalog !== "string" || !HEX64.test(catalog) || typeof writer !== "number") {
104
- refuse({ kind: "CheckpointShape" }, "checkpoint json catalog or writer is malformed")
189
+ return { braids, catalog, writer, prev, sum: summed }
190
+ }
191
+
192
+ /** The seed transition's catalog claim: a present checkpoint
193
+ * compares the opened store's `catalog_digest` against the document.
194
+ * Genesis (no checkpoint) has no claim. A mismatch names the publisher. */
195
+ function auditCatalog(facts: CheckpointFacts | null, computed: Digest32): void {
196
+ if (facts === null) {
197
+ return
105
198
  }
106
- if (prev !== null && (typeof prev !== "string" || !HEX64.test(prev))) {
107
- refuse({ kind: "CheckpointShape" }, "checkpoint json prev is neither null nor a digest")
199
+ if (bytesEqual(facts.catalog, computed)) {
200
+ return
108
201
  }
109
- return { braids, catalog, writer: BigInt(writer), prev: prev === null ? null : prev }
202
+ refuse(
203
+ { kind: "CheckpointDigest", expected: hex32(facts.catalog), computed: hex32(computed) },
204
+ `checkpoint catalog ${hex32(facts.catalog)} disagrees with the opened store ${hex32(computed)}; publisher ${facts.writer}`
205
+ )
110
206
  }
111
207
 
112
208
  export type { CheckpointFacts, CheckpointHead, Manifest }
113
- export { parseCheckpoint, parseManifest, renderManifest }
209
+ export { auditCatalog, checkpointVector, DOC_VERSION, parseCheckpoint, parseManifest, renderCheckpoint, renderManifest }