@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/README.md +119 -0
- package/package.json +58 -0
- package/src/braids.ts +38 -0
- package/src/bytes.ts +236 -0
- package/src/chain.ts +109 -0
- package/src/codec.ts +256 -0
- package/src/descriptor.ts +741 -0
- package/src/errors.ts +150 -0
- package/src/index.ts +38 -0
- package/src/keys.ts +34 -0
- package/src/manifest.ts +113 -0
- package/src/replica.ts +796 -0
- package/src/store.ts +292 -0
- package/src/tenants.ts +140 -0
- package/src/value.ts +285 -0
- package/src/writer.ts +506 -0
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 }
|