@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/index.ts CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  export type { Braid } from "#braids.ts"
12
12
  export { braid, braidsOf, serialAtStatementsOf } from "#braids.ts"
13
- export type { BatchHeader, ChainEntry, DecodedBatch, Op } from "#codec.ts"
13
+ export type { BatchHeader, ChainEntry, DecodedBatch, EncodeHeader, Op } from "#codec.ts"
14
14
  export { decodeBatch, encodeBatch, verifyChain } from "#codec.ts"
15
15
  export type { Descriptor, SerialStatement, Theory } from "#descriptor.ts"
16
16
  export { descriptorOf } from "#descriptor.ts"
@@ -21,14 +21,25 @@ export {
21
21
  ErrChainMismatch,
22
22
  ErrContention,
23
23
  ErrGapDetected,
24
+ ErrManifestMissing,
24
25
  ErrRefused,
25
26
  ErrReplayDiverged,
26
27
  ErrSpanningCommit,
27
28
  ErrStore,
29
+ isManifestMissing,
28
30
  refusalOf
29
31
  } from "#errors.ts"
30
32
  export type { Generation, StoreKey } from "#keys.ts"
31
- export { generation, storeKey } from "#keys.ts"
33
+ export {
34
+ CKPT_SCRATCH_LEASE,
35
+ encodeCkptScratch,
36
+ generation,
37
+ LEASE_NAMESPACE,
38
+ parseCkptScratch,
39
+ scratchCkptDigest,
40
+ scratchCkptName,
41
+ storeKey
42
+ } from "#keys.ts"
32
43
  export type { OpenReplicaOptions, Replica } from "#replica.ts"
33
44
  export { openReplica } from "#replica.ts"
34
45
  export type { Create, Etag, Fetched, ObjectStore, Poll, S3Config, S3Credentials, Swap } from "#store.ts"
@@ -36,5 +47,7 @@ export { etag, fsStore, memStore, s3Store } from "#store.ts"
36
47
  export type { OpenTenantsOptions, Tenants } from "#tenants.ts"
37
48
  export { openTenants } from "#tenants.ts"
38
49
  export type { Interval, Value } from "#value.ts"
50
+ export type { CheckpointOrder, VectorParse } from "#vector.ts"
51
+ export { Overflow, Vector } from "#vector.ts"
39
52
  export type { Batch, BraidOutcome, Commit, CommitSplit, Durability, Writer } from "#writer.ts"
40
53
  export { openWriter } from "#writer.ts"
package/src/keys.ts CHANGED
@@ -3,37 +3,164 @@
3
3
  * chars; a prefix is a store; a tenant is a prefix under `t/`. A
4
4
  * StoreKey is parsed here, once — the verbs take the proof and never
5
5
  * re-check. An empty prefix joins as the rest alone, so a leading
6
- * slash is unrepresentable.
6
+ * slash is unrepresentable. Temps and leases live under a reserved
7
+ * tilde-family first segment no StoreKey can spell — ASCII `~` and
8
+ * its lookalikes — so those namespaces stay disjoint from every
9
+ * honest key. Format characters, line and paragraph separators, and
10
+ * space separators cannot hide a reserved prefix or a `.lock` suffix.
7
11
  */
8
12
 
9
13
  import * as errors from "@superbuilders/errors"
10
- import { U64_MAX } from "#bytes.ts"
14
+ import { regex } from "arkregex"
15
+ import type { Digest32 } from "#bytes.ts"
16
+ import { digest32, hex32, U64_MAX } from "#bytes.ts"
11
17
  import type { Braid } from "#descriptor.ts"
12
18
 
13
- /** Suffix of the per-key pid-lockfile. A segment wearing it cannot be a key. */
19
+ /** A segment wearing this suffix — after format characters are stripped — is not a key. */
14
20
  const LOCK_SUFFIX = ".lock"
15
21
 
22
+ /** Reserved first-segment names no StoreKey can spell. Temps and leases live here. */
23
+ const TEMP_NAMESPACE = "~tmp"
24
+ const LEASE_NAMESPACE = "~lease"
25
+
26
+ /** ASCII tilde and lookalikes that can spell a reserved first segment. */
27
+ const TILDE_FAMILY = new Set([
28
+ "~",
29
+ "\u02DC",
30
+ "\u02F7",
31
+ "\u1FC0",
32
+ "\u2053",
33
+ "\u223C",
34
+ "\u223D",
35
+ "\u223F",
36
+ "\u2E1E",
37
+ "\u2E1F",
38
+ "\u301C",
39
+ "\u3030",
40
+ "\uFE4B",
41
+ "\uFE4F",
42
+ "\uFF5E"
43
+ ])
44
+
45
+ const FORMAT_OR_SEPARATOR = regex("[\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}\\p{Zs}]", "u")
46
+ const FORMAT_CHARS = regex("\\p{Cf}", "gu")
47
+ const SLASH_TRIM = regex("^/+|/+$", "g")
48
+
16
49
  declare const storeKeyBrand: unique symbol
17
50
  type StoreKey = string & { readonly [storeKeyBrand]: typeof storeKeyBrand }
18
51
 
19
52
  declare const generationBrand: unique symbol
20
53
  type Generation = bigint & { readonly [generationBrand]: typeof generationBrand }
21
54
 
55
+ function firstCodePoint(seg: string): string | undefined {
56
+ for (const ch of seg) {
57
+ return ch
58
+ }
59
+ return undefined
60
+ }
61
+
62
+ function tildeFamilyPrefix(seg: string): boolean {
63
+ const first = firstCodePoint(seg)
64
+ if (first === undefined) {
65
+ return false
66
+ }
67
+ return TILDE_FAMILY.has(first) || first.normalize("NFKC") === "~"
68
+ }
69
+
70
+ function stripFormat(seg: string): string {
71
+ return seg.replace(FORMAT_CHARS, "")
72
+ }
73
+
74
+ /** One path segment of a key or a tenant id: the same grammar. */
75
+ function segmentOk(seg: string): boolean {
76
+ if (seg.length === 0 || seg.includes("/") || seg === "." || seg === "..") {
77
+ return false
78
+ }
79
+ if (FORMAT_OR_SEPARATOR.test(seg)) {
80
+ return false
81
+ }
82
+ const stripped = stripFormat(seg)
83
+ if (stripped.length === 0 || stripped === "." || stripped === "..") {
84
+ return false
85
+ }
86
+ return !tildeFamilyPrefix(stripped) && !stripped.endsWith(LOCK_SUFFIX)
87
+ }
88
+
22
89
  function storeKey(raw: string): StoreKey {
23
- if (raw.length === 0 || raw.startsWith("/") || raw.endsWith("/")) {
90
+ const wellFormed = raw.length > 0 && !raw.startsWith("/") && !raw.endsWith("/") && raw.split("/").every(segmentOk)
91
+ if (!wellFormed) {
24
92
  throw errors.new(`store key is not a slash path: ${raw}`)
25
93
  }
26
- for (const segment of raw.split("/")) {
27
- if (segment.length === 0 || segment === "." || segment === "..") {
28
- throw errors.new(`store key segment is illegal: ${raw}`)
29
- }
30
- if (segment.endsWith(LOCK_SUFFIX)) {
31
- throw errors.new(`store key collides with the lockfile suffix: ${raw}`)
32
- }
33
- }
34
94
  return raw as StoreKey
35
95
  }
36
96
 
97
+ /** Empty, or a StoreKey spelling (the same segment grammar, no leading or trailing slash). */
98
+ function parsePrefix(raw: string): string {
99
+ if (raw.length === 0) {
100
+ return ""
101
+ }
102
+ return storeKey(raw.replace(SLASH_TRIM, ""))
103
+ }
104
+
105
+ function isReservedName(name: string): boolean {
106
+ return tildeFamilyPrefix(stripFormat(name))
107
+ }
108
+
109
+ function reservedTemp(pid: number, seq: number): string {
110
+ return `${TEMP_NAMESPACE}/${String(pid)}.${String(seq)}`
111
+ }
112
+
113
+ function reservedLease(key: string, token: bigint): string {
114
+ return `${LEASE_NAMESPACE}/${key}/${String(token)}`
115
+ }
116
+
117
+ /** A slash path whose first segment is `~tmp` or `~lease`. Not a StoreKey. */
118
+ function reservedName(raw: string): string {
119
+ const segs = raw.split("/")
120
+ if (segs.length === 0 || segs.some((seg) => seg.length === 0 || seg === "." || seg === "..")) {
121
+ throw errors.new(`reserved name is not a slash path: ${raw}`)
122
+ }
123
+ const first = segs[0]
124
+ if (first !== TEMP_NAMESPACE && first !== LEASE_NAMESPACE) {
125
+ throw errors.new(`reserved name is not under ${TEMP_NAMESPACE} or ${LEASE_NAMESPACE}: ${raw}`)
126
+ }
127
+ return raw
128
+ }
129
+
130
+ /** Known scratch-lease document under `~lease`. One path, no LIST. */
131
+ const CKPT_SCRATCH_LEASE = "ckpt-scratch"
132
+
133
+ /** The reserved scratch name: `~lease/ckpt-scratch`. */
134
+ function scratchCkptName(): string {
135
+ return reservedName(`${LEASE_NAMESPACE}/${CKPT_SCRATCH_LEASE}`)
136
+ }
137
+
138
+ const SCRATCH_VERSION = 3
139
+
140
+ /** The scratch-lease body: version byte 3, then the 32-byte digest. */
141
+ function encodeCkptScratch(digest: Digest32): Uint8Array {
142
+ const out = new Uint8Array(33)
143
+ out[0] = SCRATCH_VERSION
144
+ out.set(digest, 1)
145
+ return out
146
+ }
147
+
148
+ /** The digest a scratch-lease body names, or null. */
149
+ function scratchCkptDigest(bytes: Uint8Array): Digest32 | null {
150
+ if (bytes.length !== 33) {
151
+ return null
152
+ }
153
+ const version = bytes[0]
154
+ if (version !== SCRATCH_VERSION) {
155
+ return null
156
+ }
157
+ return digest32(bytes.subarray(1))
158
+ }
159
+
160
+ function parseCkptScratch(bytes: Uint8Array): Digest32 | null {
161
+ return scratchCkptDigest(bytes)
162
+ }
163
+
37
164
  function generation(raw: bigint): Generation {
38
165
  if (raw < 0n || raw > U64_MAX) {
39
166
  throw errors.new(`generation is a u64: ${raw}`)
@@ -50,19 +177,19 @@ function assemble(prefix: string, rest: string): StoreKey {
50
177
  }
51
178
 
52
179
  function manifestKey(prefix: string): StoreKey {
53
- return assemble(prefix, "manifest.json")
180
+ return assemble(prefix, "manifest")
54
181
  }
55
182
 
56
183
  function logKey(prefix: string, braid: Braid, g: Generation): StoreKey {
57
184
  return assemble(prefix, `log/${braid}/${hex16(g)}`)
58
185
  }
59
186
 
60
- function checkpointMdbKey(prefix: string, digest: string): StoreKey {
61
- return assemble(prefix, `ckpt/${digest}.mdb`)
187
+ function checkpointMdbKey(prefix: string, digest: Digest32): StoreKey {
188
+ return assemble(prefix, `ckpt/${hex32(digest)}.mdb`)
62
189
  }
63
190
 
64
- function checkpointJsonKey(prefix: string, digest: string): StoreKey {
65
- return assemble(prefix, `ckpt/${digest}.json`)
191
+ function ckptDocKey(prefix: string, digest: Digest32): StoreKey {
192
+ return assemble(prefix, `ckpt/${hex32(digest)}`)
66
193
  }
67
194
 
68
195
  function idsKey(prefix: string, relation: number, field: number): StoreKey {
@@ -70,19 +197,35 @@ function idsKey(prefix: string, relation: number, field: number): StoreKey {
70
197
  }
71
198
 
72
199
  function tenantPrefix(root: string, tenant: string): string {
200
+ if (!segmentOk(tenant)) {
201
+ throw errors.new(`tenant id is not a single path segment: ${tenant}`)
202
+ }
73
203
  return assemble(root, `t/${tenant}`)
74
204
  }
75
205
 
76
206
  export type { Generation, StoreKey }
77
207
  export {
78
- checkpointJsonKey,
208
+ CKPT_SCRATCH_LEASE,
79
209
  checkpointMdbKey,
210
+ ckptDocKey,
211
+ encodeCkptScratch,
80
212
  generation,
81
213
  hex16,
82
214
  idsKey,
215
+ isReservedName,
216
+ LEASE_NAMESPACE,
83
217
  LOCK_SUFFIX,
84
218
  logKey,
85
219
  manifestKey,
220
+ parseCkptScratch,
221
+ parsePrefix,
222
+ reservedLease,
223
+ reservedName,
224
+ reservedTemp,
225
+ scratchCkptDigest,
226
+ scratchCkptName,
227
+ segmentOk,
86
228
  storeKey,
229
+ TEMP_NAMESPACE,
87
230
  tenantPrefix
88
231
  }
package/src/manifest.ts CHANGED
@@ -1,117 +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"
11
15
  import type { Braid } from "#descriptor.ts"
12
- import { braid } from "#descriptor.ts"
16
+ import { braidHex } from "#descriptor.ts"
13
17
  import { refuse } from "#errors.ts"
14
18
  import type { Generation } from "#keys.ts"
15
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
16
26
 
17
27
  interface Manifest {
18
- readonly fingerprint: string
19
- readonly checkpoint: string | null
28
+ readonly fingerprint: Digest32
29
+ readonly checkpoint: Digest32 | null
20
30
  }
21
31
 
22
- const HEX64 = /^[0-9a-f]{64}$/
23
-
24
- function renderManifest(manifest: Manifest): Uint8Array {
25
- const checkpoint = manifest.checkpoint === null ? "null" : `"${manifest.checkpoint}"`
26
- 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
+ })
27
38
  }
28
39
 
29
- function parseManifest(bytes: Uint8Array): Manifest {
30
- const decoded = errors.trySync(function decodeText() {
31
- return utf8StrictDecoder.decode(bytes)
32
- })
33
- if (decoded.error) {
34
- 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}`)
35
43
  }
36
- const parsed = errors.trySync(function parseJson() {
37
- return JSON.parse(decoded.data) as unknown
38
- })
39
- if (parsed.error || typeof parsed.data !== "object" || parsed.data === null) {
40
- 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
41
49
  }
42
- const record = parsed.data as Record<string, unknown>
43
- if (typeof record.v !== "number") {
44
- 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`)
45
52
  }
46
- if (record.v !== 2) {
47
- 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
48
59
  }
49
- const fingerprint = record.fingerprint
50
- const checkpoint = record.checkpoint
51
- if (typeof fingerprint !== "string" || !HEX64.test(fingerprint)) {
52
- 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
53
68
  }
54
- if (checkpoint !== null && (typeof checkpoint !== "string" || !HEX64.test(checkpoint))) {
55
- refuse({ kind: "ManifestShape" }, "manifest checkpoint is neither null nor 64 hex")
69
+ if (tag === 1) {
70
+ return reader.array32(at)
56
71
  }
57
- const manifest: Manifest = { fingerprint, checkpoint: checkpoint === null ? null : checkpoint }
58
- const canonical = renderManifest(manifest)
59
- if (canonical.length !== bytes.length || utf8StrictDecoder.decode(canonical) !== decoded.data) {
60
- 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
+ )
61
82
  }
62
- 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 }
63
104
  }
64
105
 
65
106
  interface CheckpointHead {
66
107
  readonly g: Generation
67
- readonly hash: string
108
+ readonly hash: Digest32
68
109
  readonly ts: bigint
69
110
  }
70
111
 
71
112
  interface CheckpointFacts {
72
113
  readonly braids: ReadonlyMap<Braid, CheckpointHead>
73
- readonly catalog: string
114
+ readonly catalog: Digest32
74
115
  readonly writer: bigint
75
- readonly prev: string | null
116
+ readonly prev: Digest32 | null
117
+ readonly sum: bigint
76
118
  }
77
119
 
78
- function parseCheckpoint(bytes: Uint8Array): CheckpointFacts {
79
- const parsed = errors.trySync(function parseJson() {
80
- return JSON.parse(utf8StrictDecoder.decode(bytes)) as unknown
81
- })
82
- if (parsed.error || typeof parsed.data !== "object" || parsed.data === null) {
83
- 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`)
84
137
  }
85
- const record = parsed.data as Record<string, unknown>
86
- const rawBraids = record.braids
87
- if (typeof rawBraids !== "object" || rawBraids === null) {
88
- 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)
89
150
  }
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")
90
162
  const braids = new Map<Braid, CheckpointHead>()
91
- for (const [name, head] of Object.entries(rawBraids as Record<string, unknown>)) {
92
- if (typeof head !== "object" || head === null) {
93
- refuse({ kind: "CheckpointShape" }, `checkpoint braid ${name} head is not an object`)
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}`)
94
168
  }
95
- const headRecord = head as Record<string, unknown>
96
- const g = headRecord.g
97
- const hash = headRecord.hash
98
- const ts = headRecord.ts
99
- if (typeof g !== "number" || typeof ts !== "number" || typeof hash !== "string" || !HEX64.test(hash)) {
100
- refuse({ kind: "CheckpointShape" }, `checkpoint braid ${name} 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
+ )
101
178
  }
102
- braids.set(braid(name), { g: generation(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")
103
188
  }
104
- const catalog = record.catalog
105
- const writer = record.writer
106
- const prev = record.prev
107
- if (typeof catalog !== "string" || !HEX64.test(catalog) || typeof writer !== "number") {
108
- 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
109
198
  }
110
- if (prev !== null && (typeof prev !== "string" || !HEX64.test(prev))) {
111
- refuse({ kind: "CheckpointShape" }, "checkpoint json prev is neither null nor a digest")
199
+ if (bytesEqual(facts.catalog, computed)) {
200
+ return
112
201
  }
113
- 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
+ )
114
206
  }
115
207
 
116
208
  export type { CheckpointFacts, CheckpointHead, Manifest }
117
- export { parseCheckpoint, parseManifest, renderManifest }
209
+ export { auditCatalog, checkpointVector, DOC_VERSION, parseCheckpoint, parseManifest, renderCheckpoint, renderManifest }