@bjornpagen/bumbledb-log 0.17.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/store.ts ADDED
@@ -0,0 +1,292 @@
1
+ /**
2
+ * The object-store capability: exactly five verbs, outcomes as sums,
3
+ * infrastructure failures on the ErrStore channel. `fsStore` is tier-1,
4
+ * not a dev double — deployment case 5's production backend.
5
+ *
6
+ * The one on-disk protocol, shared with the Rust driver: create-only
7
+ * publishes an exclusive synced temp with link(2), where EEXIST is the
8
+ * honest exists; the etag is the blake3 of the content, lowercase hex,
9
+ * computed on every read and never stored; `putSwap` serializes under a
10
+ * pid-lockfile beside the key, published with the same exclusive
11
+ * temp-plus-link discipline so it can never exist without its body (the
12
+ * owner pid) — a contender breaks the lock iff the owner is dead, which
13
+ * is sound on one machine only, fsStore's load-bearing deployment law.
14
+ * `created` and `swapped` resolve only after fsync of the object file
15
+ * and its parent directory.
16
+ */
17
+
18
+ import * as fs from "node:fs/promises"
19
+ import * as path from "node:path"
20
+ import { internalBlake3 } from "@bjornpagen/bumbledb"
21
+ import * as errors from "@superbuilders/errors"
22
+ import { toHex } from "#bytes.ts"
23
+ import { wrapStore } from "#errors.ts"
24
+
25
+ interface Fetched {
26
+ readonly bytes: Uint8Array
27
+ readonly etag: string
28
+ }
29
+
30
+ type Poll = { readonly tag: "unchanged" } | { readonly tag: "changed"; readonly fetched: Fetched }
31
+
32
+ type Create = { readonly tag: "created"; readonly etag: string } | { readonly tag: "exists" }
33
+
34
+ type Swap = { readonly tag: "swapped"; readonly etag: string } | { readonly tag: "moved" }
35
+
36
+ interface ObjectStore {
37
+ /** GET; null on 404. */
38
+ get(key: string): Promise<Fetched | null>
39
+ /** GET with If-None-Match — the cheap manifest poll. */
40
+ getIfChanged(key: string, etag: string): Promise<Poll>
41
+ /** PUT with If-None-Match: * — the log-slot arbitration primitive. */
42
+ putCreate(key: string, bytes: Uint8Array): Promise<Create>
43
+ /** PUT with If-Match — the manifest CAS primitive. */
44
+ putSwap(key: string, bytes: Uint8Array, etag: string): Promise<Swap>
45
+ /** DELETE, unconditional — the gc verb's tool. */
46
+ delete(key: string): Promise<void>
47
+ }
48
+
49
+ /** Suffix of the per-key pid-lockfile that serializes `putSwap`. */
50
+ const LOCK_SUFFIX = ".lock"
51
+
52
+ /** Ceiling of the jittered wait between probes of a live-held lock. */
53
+ const LOCK_RETRY_MS = 10
54
+
55
+ function contentEtag(bytes: Uint8Array): string {
56
+ return toHex(new Uint8Array(internalBlake3(bytes)))
57
+ }
58
+
59
+ function checkKey(key: string): void {
60
+ if (key.length === 0 || key.startsWith("/") || key.endsWith("/")) {
61
+ throw errors.new(`store key is not a slash path: ${key}`)
62
+ }
63
+ for (const segment of key.split("/")) {
64
+ if (segment.length === 0 || segment === "." || segment === "..") {
65
+ throw errors.new(`store key segment is illegal: ${key}`)
66
+ }
67
+ if (segment.endsWith(LOCK_SUFFIX)) {
68
+ throw errors.new(`store key collides with the lockfile suffix: ${key}`)
69
+ }
70
+ }
71
+ }
72
+
73
+ function codeOf(error: Error): string | undefined {
74
+ return (error as NodeJS.ErrnoException).code
75
+ }
76
+
77
+ async function fsyncDir(dir: string): Promise<void> {
78
+ const handle = await fs.open(dir, "r")
79
+ const synced = await errors.try(handle.sync())
80
+ await handle.close()
81
+ if (synced.error) {
82
+ throw errors.wrap(synced.error, `fsync directory ${dir}`)
83
+ }
84
+ }
85
+
86
+ let tempSeq = 0
87
+
88
+ /** Write `bytes` to a fresh `wx` temp file beside `target` and fsync it.
89
+ * The caller publishes the synced temp atomically. */
90
+ async function syncedTemp(target: string, bytes: Uint8Array): Promise<string> {
91
+ const dir = path.dirname(target)
92
+ await fs.mkdir(dir, { recursive: true })
93
+ tempSeq += 1
94
+ const temp = path.join(dir, `.${path.basename(target)}.tmp.${process.pid}.${tempSeq}`)
95
+ const handle = await fs.open(temp, "wx")
96
+ const written = await errors.try(
97
+ (async function writeAll() {
98
+ await handle.writeFile(bytes)
99
+ await handle.sync()
100
+ })()
101
+ )
102
+ await handle.close()
103
+ if (written.error) {
104
+ await fs.rm(temp, { force: true })
105
+ throw errors.wrap(written.error, `write ${target}`)
106
+ }
107
+ return temp
108
+ }
109
+
110
+ /** link(2) is the exclusivity primitive: rename replaces an existing
111
+ * destination, so it cannot arbitrate create-only, while link fails
112
+ * atomically with EEXIST across processes — exactly the
113
+ * If-None-Match: * contract. */
114
+ async function publishLink(temp: string, dest: string): Promise<"linked" | "occupied"> {
115
+ const linked = await errors.try(fs.link(temp, dest))
116
+ if (linked.error === undefined) {
117
+ return "linked"
118
+ }
119
+ if (codeOf(linked.error) === "EEXIST") {
120
+ return "occupied"
121
+ }
122
+ throw linked.error
123
+ }
124
+
125
+ function pidAlive(pid: number): boolean {
126
+ const probed = errors.trySync(function probe() {
127
+ process.kill(pid, 0)
128
+ })
129
+ if (probed.error === undefined) {
130
+ return true
131
+ }
132
+ return codeOf(probed.error) !== "ESRCH"
133
+ }
134
+
135
+ async function acquireLock(lockPath: string): Promise<void> {
136
+ const body = new TextEncoder().encode(String(process.pid))
137
+ for (;;) {
138
+ const temp = await syncedTemp(lockPath, body)
139
+ const published = await errors.try(publishLink(temp, lockPath))
140
+ await fs.rm(temp, { force: true })
141
+ if (published.error) {
142
+ throw published.error
143
+ }
144
+ if (published.data === "linked") {
145
+ return
146
+ }
147
+ const read = await errors.try(fs.readFile(lockPath, "utf8"))
148
+ if (read.error) {
149
+ if (codeOf(read.error) === "ENOENT") {
150
+ continue
151
+ }
152
+ throw read.error
153
+ }
154
+ const owner = read.data.trim()
155
+ if (!/^\d+$/.test(owner)) {
156
+ throw errors.new(`lockfile body is not a pid: ${lockPath}`)
157
+ }
158
+ if (pidAlive(Number.parseInt(owner, 10))) {
159
+ await new Promise(function later(resolve) {
160
+ setTimeout(resolve, Math.random() * LOCK_RETRY_MS)
161
+ })
162
+ } else {
163
+ await fs.rm(lockPath, { force: true })
164
+ }
165
+ }
166
+ }
167
+
168
+ async function releaseLock(lockPath: string): Promise<void> {
169
+ await fs.rm(lockPath, { force: true })
170
+ }
171
+
172
+ /** The five verbs over one local directory. One machine is load-bearing. */
173
+ function fsStore(root: string): ObjectStore {
174
+ const rootPath = path.resolve(root)
175
+
176
+ function objectPath(key: string): string {
177
+ checkKey(key)
178
+ return path.join(rootPath, ...key.split("/"))
179
+ }
180
+
181
+ async function readFetched(target: string): Promise<Fetched | null> {
182
+ const read = await errors.try(fs.readFile(target))
183
+ if (read.error) {
184
+ if (codeOf(read.error) === "ENOENT") {
185
+ return null
186
+ }
187
+ throw read.error
188
+ }
189
+ const bytes = new Uint8Array(read.data)
190
+ return { bytes, etag: contentEtag(bytes) }
191
+ }
192
+
193
+ return {
194
+ async get(key) {
195
+ const target = objectPath(key)
196
+ const read = await errors.try(readFetched(target))
197
+ if (read.error) {
198
+ throw wrapStore(read.error, `get ${key}`)
199
+ }
200
+ return read.data
201
+ },
202
+
203
+ async getIfChanged(key, etag) {
204
+ const target = objectPath(key)
205
+ const read = await errors.try(readFetched(target))
206
+ if (read.error) {
207
+ throw wrapStore(read.error, `getIfChanged ${key}`)
208
+ }
209
+ if (read.data === null) {
210
+ throw wrapStore(errors.new("poll target absent"), `getIfChanged ${key}`)
211
+ }
212
+ if (read.data.etag === etag) {
213
+ return { tag: "unchanged" }
214
+ }
215
+ return { tag: "changed", fetched: read.data }
216
+ },
217
+
218
+ async putCreate(key, bytes) {
219
+ const target = objectPath(key)
220
+ const ran = await errors.try(
221
+ (async function createBody(): Promise<Create> {
222
+ const temp = await syncedTemp(target, bytes)
223
+ const published = await errors.try(publishLink(temp, target))
224
+ await fs.rm(temp, { force: true })
225
+ if (published.error) {
226
+ throw published.error
227
+ }
228
+ if (published.data === "occupied") {
229
+ return { tag: "exists" }
230
+ }
231
+ await fsyncDir(path.dirname(target))
232
+ return { tag: "created", etag: contentEtag(bytes) }
233
+ })()
234
+ )
235
+ if (ran.error) {
236
+ throw wrapStore(ran.error, `putCreate ${key}`)
237
+ }
238
+ return ran.data
239
+ },
240
+
241
+ async putSwap(key, bytes, etag) {
242
+ const target = objectPath(key)
243
+ const lock = `${target}${LOCK_SUFFIX}`
244
+ const ran = await errors.try(
245
+ (async function swapBody(): Promise<Swap> {
246
+ await acquireLock(lock)
247
+ const swapped = await errors.try(
248
+ (async function underLock(): Promise<Swap> {
249
+ const current = await readFetched(target)
250
+ if (current === null || current.etag !== etag) {
251
+ return { tag: "moved" }
252
+ }
253
+ const temp = await syncedTemp(target, bytes)
254
+ const renamed = await errors.try(fs.rename(temp, target))
255
+ if (renamed.error) {
256
+ await fs.rm(temp, { force: true })
257
+ throw renamed.error
258
+ }
259
+ await fsyncDir(path.dirname(target))
260
+ return { tag: "swapped", etag: contentEtag(bytes) }
261
+ })()
262
+ )
263
+ await releaseLock(lock)
264
+ if (swapped.error) {
265
+ throw swapped.error
266
+ }
267
+ return swapped.data
268
+ })()
269
+ )
270
+ if (ran.error) {
271
+ throw wrapStore(ran.error, `putSwap ${key}`)
272
+ }
273
+ return ran.data
274
+ },
275
+
276
+ async delete(key) {
277
+ const target = objectPath(key)
278
+ const ran = await errors.try(
279
+ (async function deleteBody() {
280
+ await fs.rm(target, { force: true })
281
+ await fs.rm(`${target}${LOCK_SUFFIX}`, { force: true })
282
+ })()
283
+ )
284
+ if (ran.error) {
285
+ throw wrapStore(ran.error, `delete ${key}`)
286
+ }
287
+ }
288
+ }
289
+ }
290
+
291
+ export type { Create, Fetched, ObjectStore, Poll, Swap }
292
+ export { fsStore }
package/src/tenants.ts ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Per-tenant replicas (50, case 4): an LRU of replicas keyed by tenant
3
+ * id — a tenant is a prefix (`<root>/t/<tenant>`), eviction closes and
4
+ * deletes the local dir (the disposable law), and `t/_shared` is
5
+ * pinned. Braids shard within a tenant; tenants shard the world.
6
+ */
7
+
8
+ import * as fs from "node:fs/promises"
9
+ import * as path from "node:path"
10
+ import type { Schema, SchemaRelations } from "@bjornpagen/bumbledb"
11
+ import * as errors from "@superbuilders/errors"
12
+ import { tenantPrefix } from "#keys.ts"
13
+ import type { Replica } from "#replica.ts"
14
+ import { openReplica } from "#replica.ts"
15
+ import type { ObjectStore } from "#store.ts"
16
+
17
+ const PINNED_TENANT = "_shared"
18
+
19
+ interface OpenTenantsOptions<Rels extends SchemaRelations> {
20
+ readonly store: ObjectStore
21
+ readonly root: string
22
+ readonly dir: string
23
+ readonly theory: Schema<Rels>
24
+ /** 50's 400 MB gate: checkpoint + working set per instance. Advisory,
25
+ * measured once at each tenant's open — a replica that grows after
26
+ * admission is not re-weighed until it is evicted and re-opened. */
27
+ readonly budgetBytes?: number
28
+ readonly maxOpen?: number
29
+ }
30
+
31
+ interface Tenants<Rels extends SchemaRelations> extends AsyncDisposable {
32
+ get(tenant: string): Promise<Replica<Rels>>
33
+ }
34
+
35
+ interface TenantSlot<Rels extends SchemaRelations> {
36
+ readonly replica: Replica<Rels>
37
+ readonly dir: string
38
+ bytes: number
39
+ lastUsed: number
40
+ }
41
+
42
+ async function directoryBytes(dir: string): Promise<number> {
43
+ let total = 0
44
+ const listed = await errors.try(fs.readdir(dir, { withFileTypes: true, recursive: true }))
45
+ if (listed.error) {
46
+ return 0
47
+ }
48
+ for (const entry of listed.data) {
49
+ if (entry.isFile()) {
50
+ const stat = await errors.try(fs.stat(path.join(entry.parentPath, entry.name)))
51
+ if (stat.error === undefined) {
52
+ total += stat.data.size
53
+ }
54
+ }
55
+ }
56
+ return total
57
+ }
58
+
59
+ function checkTenantId(tenant: string): void {
60
+ if (!/^[A-Za-z0-9._-]+$/.test(tenant)) {
61
+ throw errors.new(`tenant id is not a single path segment: ${tenant}`)
62
+ }
63
+ }
64
+
65
+ function openTenants<Rels extends SchemaRelations>(options: OpenTenantsOptions<Rels>): Tenants<Rels> {
66
+ const budgetBytes = options.budgetBytes ?? 400_000_000
67
+ const maxOpen = options.maxOpen ?? 32
68
+ const open = new Map<string, TenantSlot<Rels>>()
69
+ let tick = 0
70
+ let closed = false
71
+
72
+ async function evictUntilWithin(): Promise<void> {
73
+ for (;;) {
74
+ let bytes = 0
75
+ for (const slot of open.values()) {
76
+ bytes += slot.bytes
77
+ }
78
+ if (open.size <= maxOpen && bytes <= budgetBytes) {
79
+ return
80
+ }
81
+ let victim: string | null = null
82
+ let oldest = Number.POSITIVE_INFINITY
83
+ for (const [tenant, slot] of open) {
84
+ if (tenant === PINNED_TENANT) {
85
+ continue
86
+ }
87
+ if (slot.lastUsed < oldest) {
88
+ oldest = slot.lastUsed
89
+ victim = tenant
90
+ }
91
+ }
92
+ if (victim === null) {
93
+ return
94
+ }
95
+ const slot = open.get(victim)
96
+ open.delete(victim)
97
+ if (slot !== undefined) {
98
+ await slot.replica[Symbol.asyncDispose]()
99
+ await fs.rm(slot.dir, { recursive: true, force: true })
100
+ }
101
+ }
102
+ }
103
+
104
+ return {
105
+ async get(tenant) {
106
+ if (closed) {
107
+ throw errors.new("tenants pool is disposed")
108
+ }
109
+ checkTenantId(tenant)
110
+ tick += 1
111
+ const hit = open.get(tenant)
112
+ if (hit !== undefined) {
113
+ hit.lastUsed = tick
114
+ return hit.replica
115
+ }
116
+ const dir = path.join(options.dir, tenant)
117
+ const replica = await openReplica({
118
+ store: options.store,
119
+ prefix: tenantPrefix(options.root, tenant),
120
+ dir,
121
+ theory: options.theory
122
+ })
123
+ const slot: TenantSlot<Rels> = { replica, dir, bytes: await directoryBytes(dir), lastUsed: tick }
124
+ open.set(tenant, slot)
125
+ await evictUntilWithin()
126
+ return replica
127
+ },
128
+
129
+ async [Symbol.asyncDispose]() {
130
+ closed = true
131
+ for (const slot of open.values()) {
132
+ await slot.replica[Symbol.asyncDispose]()
133
+ }
134
+ open.clear()
135
+ }
136
+ }
137
+ }
138
+
139
+ export type { OpenTenantsOptions, Tenants }
140
+ export { openTenants }
package/src/value.ts ADDED
@@ -0,0 +1,285 @@
1
+ /**
2
+ * The driver's raw-value vocabulary and its two canonical encodings:
3
+ * the command codec's tagged little-endian form (20's tag table, the one
4
+ * shared tagged-value encoding the footprint keys also hash), and the
5
+ * engine's big-endian order-preserving literal form the fingerprint
6
+ * mirror reproduces. Values here are always RAW — strings as UTF-8,
7
+ * never intern ids — which is what makes footprint keys state-independent.
8
+ */
9
+
10
+ import type { ValueTypeSpec } from "@bjornpagen/bumbledb"
11
+ import * as errors from "@superbuilders/errors"
12
+ import type { ByteReader, ByteWriter } from "#bytes.ts"
13
+ import { bytesEqual, I64_MAX, I64_MIN, U64_MAX, utf8Encoder, utf8StrictDecoder } from "#bytes.ts"
14
+
15
+ interface LogInterval {
16
+ readonly start: bigint
17
+ readonly end: bigint
18
+ }
19
+
20
+ type LogValue = boolean | bigint | string | Uint8Array | LogInterval
21
+
22
+ /** 20's tag table: the codec's own numbering, normative to the byte. */
23
+ const TAG = {
24
+ bool: 0,
25
+ u64: 1,
26
+ i64: 2,
27
+ string: 3,
28
+ fixedBytes: 4,
29
+ interval: 5,
30
+ fixedInterval: 6
31
+ } as const
32
+
33
+ function isInterval(value: LogValue): value is LogInterval {
34
+ return typeof value === "object" && !(value instanceof Uint8Array)
35
+ }
36
+
37
+ function wireTagOf(type: ValueTypeSpec): number {
38
+ switch (type.kind) {
39
+ case "bool":
40
+ return TAG.bool
41
+ case "u64":
42
+ return TAG.u64
43
+ case "i64":
44
+ return TAG.i64
45
+ case "string":
46
+ return TAG.string
47
+ case "fixedBytes":
48
+ return TAG.fixedBytes
49
+ case "interval":
50
+ return type.width === undefined ? TAG.interval : TAG.fixedInterval
51
+ }
52
+ }
53
+
54
+ function checkAgainst(context: string, type: ValueTypeSpec, value: LogValue): void {
55
+ switch (type.kind) {
56
+ case "bool": {
57
+ if (typeof value !== "boolean") {
58
+ throw errors.new(`${context}: expected boolean`)
59
+ }
60
+ return
61
+ }
62
+ case "u64": {
63
+ if (typeof value !== "bigint" || value < 0n || value > U64_MAX) {
64
+ throw errors.new(`${context}: expected u64 bigint`)
65
+ }
66
+ return
67
+ }
68
+ case "i64": {
69
+ if (typeof value !== "bigint" || value < I64_MIN || value > I64_MAX) {
70
+ throw errors.new(`${context}: expected i64 bigint`)
71
+ }
72
+ return
73
+ }
74
+ case "string": {
75
+ if (typeof value !== "string" || !value.isWellFormed()) {
76
+ throw errors.new(`${context}: expected well-formed string`)
77
+ }
78
+ return
79
+ }
80
+ case "fixedBytes": {
81
+ if (!(value instanceof Uint8Array) || value.length !== type.len) {
82
+ throw errors.new(`${context}: expected ${type.len}-byte Uint8Array`)
83
+ }
84
+ return
85
+ }
86
+ case "interval": {
87
+ if (!(typeof value === "object") || value instanceof Uint8Array) {
88
+ throw errors.new(`${context}: expected interval value`)
89
+ }
90
+ const lo = type.element === "u64" ? 0n : I64_MIN
91
+ const hi = type.element === "u64" ? U64_MAX : I64_MAX
92
+ if (value.start < lo || value.end > hi || value.start >= value.end) {
93
+ throw errors.new(`${context}: interval bounds out of range or empty`)
94
+ }
95
+ if (type.width !== undefined && value.end - value.start !== type.width) {
96
+ throw errors.new(`${context}: interval width must be ${type.width}`)
97
+ }
98
+ return
99
+ }
100
+ }
101
+ }
102
+
103
+ /** The codec's tagged form: `tag u8` + payload, at the field's layout. */
104
+ function writeTagged(out: ByteWriter, type: ValueTypeSpec, value: LogValue): void {
105
+ switch (type.kind) {
106
+ case "bool": {
107
+ out.u8(TAG.bool)
108
+ out.u8(value === true ? 1 : 0)
109
+ return
110
+ }
111
+ case "u64": {
112
+ out.u8(TAG.u64)
113
+ out.u64le(value as bigint)
114
+ return
115
+ }
116
+ case "i64": {
117
+ out.u8(TAG.i64)
118
+ out.i64le(value as bigint)
119
+ return
120
+ }
121
+ case "string": {
122
+ const raw = utf8Encoder.encode(value as string)
123
+ out.u8(TAG.string)
124
+ out.u32le(raw.length)
125
+ out.bytes(raw)
126
+ return
127
+ }
128
+ case "fixedBytes": {
129
+ out.u8(TAG.fixedBytes)
130
+ out.bytes(value as Uint8Array)
131
+ return
132
+ }
133
+ case "interval": {
134
+ const interval = value as LogInterval
135
+ if (type.width === undefined) {
136
+ out.u8(TAG.interval)
137
+ if (type.element === "u64") {
138
+ out.u64le(interval.start)
139
+ out.u64le(interval.end)
140
+ } else {
141
+ out.i64le(interval.start)
142
+ out.i64le(interval.end)
143
+ }
144
+ return
145
+ }
146
+ out.u8(TAG.fixedInterval)
147
+ if (type.element === "u64") {
148
+ out.u64le(interval.start)
149
+ } else {
150
+ out.i64le(interval.start)
151
+ }
152
+ return
153
+ }
154
+ }
155
+ }
156
+
157
+ /** The value parser's refusal channel: one method per proved cause,
158
+ * matching the Rust decoder's cross-implementation identities. */
159
+ interface TaggedRefusal {
160
+ badTag(expected: number, actual: number): never
161
+ boolByte(byte: number): never
162
+ invalidUtf8(): never
163
+ emptyInterval(): never
164
+ intervalOverflow(): never
165
+ }
166
+
167
+ /** Full parse at the layout's type; every illegal byte is a typed refusal. */
168
+ function readTagged(reader: ByteReader, type: ValueTypeSpec, refusal: TaggedRefusal): LogValue {
169
+ const expected = wireTagOf(type)
170
+ const tag = reader.u8("value tag")
171
+ if (tag !== expected) {
172
+ refusal.badTag(expected, tag)
173
+ }
174
+ switch (type.kind) {
175
+ case "bool": {
176
+ const byte = reader.u8("bool payload")
177
+ if (byte > 1) {
178
+ refusal.boolByte(byte)
179
+ }
180
+ return byte === 1
181
+ }
182
+ case "u64":
183
+ return reader.u64le("u64 payload")
184
+ case "i64":
185
+ return reader.i64le("i64 payload")
186
+ case "string": {
187
+ const len = reader.u32le("string length")
188
+ const raw = reader.bytes(len, "string payload")
189
+ const decoded = errors.trySync(function decodeUtf8() {
190
+ return utf8StrictDecoder.decode(raw)
191
+ })
192
+ if (decoded.error) {
193
+ refusal.invalidUtf8()
194
+ }
195
+ return decoded.data
196
+ }
197
+ case "fixedBytes":
198
+ return reader.bytes(type.len, "fixedBytes payload")
199
+ case "interval": {
200
+ if (type.width === undefined) {
201
+ const start = type.element === "u64" ? reader.u64le("interval start") : reader.i64le("interval start")
202
+ const end = type.element === "u64" ? reader.u64le("interval end") : reader.i64le("interval end")
203
+ if (start >= end) {
204
+ refusal.emptyInterval()
205
+ }
206
+ return { start, end }
207
+ }
208
+ const start = type.element === "u64" ? reader.u64le("interval start") : reader.i64le("interval start")
209
+ const end = start + type.width
210
+ if (type.element === "u64" ? end > U64_MAX : end > I64_MAX) {
211
+ refusal.intervalOverflow()
212
+ }
213
+ return { start, end }
214
+ }
215
+ }
216
+ }
217
+
218
+ function valuesEqual(a: LogValue, b: LogValue): boolean {
219
+ if (typeof a === "boolean" || typeof a === "bigint" || typeof a === "string") {
220
+ return a === b
221
+ }
222
+ if (a instanceof Uint8Array) {
223
+ return b instanceof Uint8Array && bytesEqual(a, b)
224
+ }
225
+ if (typeof b !== "object" || b instanceof Uint8Array) {
226
+ return false
227
+ }
228
+ return a.start === b.start && a.end === b.end
229
+ }
230
+
231
+ /**
232
+ * The engine's canonical big-endian literal form (`encode_literal`) —
233
+ * the fingerprint mirror's alphabet. Strings never reach this encoder:
234
+ * the fingerprint's `put_literal` length-prefixes them separately, and
235
+ * closed ground axioms with string columns are the mirror's recorded gap.
236
+ */
237
+ function writeCanonicalLiteral(out: ByteWriter, type: ValueTypeSpec, value: LogValue): void {
238
+ switch (type.kind) {
239
+ case "bool": {
240
+ out.u8(value === true ? 1 : 0)
241
+ return
242
+ }
243
+ case "u64": {
244
+ out.u64be(value as bigint)
245
+ return
246
+ }
247
+ case "i64": {
248
+ out.i64beFlipped(value as bigint)
249
+ return
250
+ }
251
+ case "string":
252
+ throw errors.new("canonical literal: strings are length-prefixed by the caller, never encoded here")
253
+ case "fixedBytes": {
254
+ const raw = value as Uint8Array
255
+ const padded = Math.ceil(type.len / 8) * 8
256
+ out.bytes(raw)
257
+ for (let i = raw.length; i < padded; i++) {
258
+ out.u8(0)
259
+ }
260
+ return
261
+ }
262
+ case "interval": {
263
+ const interval = value as LogInterval
264
+ if (type.width !== undefined) {
265
+ if (type.element === "u64") {
266
+ out.u64be(interval.start)
267
+ } else {
268
+ out.i64beFlipped(interval.start)
269
+ }
270
+ return
271
+ }
272
+ if (type.element === "u64") {
273
+ out.u64be(interval.start)
274
+ out.u64be(interval.end)
275
+ } else {
276
+ out.i64beFlipped(interval.start)
277
+ out.i64beFlipped(interval.end)
278
+ }
279
+ return
280
+ }
281
+ }
282
+ }
283
+
284
+ export type { LogInterval, LogValue, TaggedRefusal }
285
+ export { checkAgainst, isInterval, readTagged, TAG, valuesEqual, wireTagOf, writeCanonicalLiteral, writeTagged }