@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/README.md +5 -4
- package/package.json +4 -3
- package/src/braids.ts +17 -2
- package/src/bytes.ts +78 -10
- package/src/chain.ts +173 -56
- package/src/codec.ts +122 -41
- package/src/descriptor.ts +182 -107
- package/src/errors.ts +133 -5
- package/src/index.ts +15 -2
- package/src/keys.ts +161 -18
- package/src/manifest.ts +165 -73
- package/src/replica.ts +577 -264
- package/src/store-s3.ts +191 -36
- package/src/store.ts +459 -85
- package/src/tenants.ts +322 -26
- package/src/value.ts +65 -14
- package/src/vector.ts +173 -0
- package/src/writer.ts +453 -92
package/src/tenants.ts
CHANGED
|
@@ -2,19 +2,36 @@
|
|
|
2
2
|
* Per-tenant replicas (50, case 4): an LRU of replicas keyed by tenant
|
|
3
3
|
* id — a tenant is a prefix (`<root>/t/<tenant>`), eviction closes and
|
|
4
4
|
* deletes the local dir (the disposable law), and `t/_shared` is
|
|
5
|
-
* pinned. Braids shard within a tenant; tenants shard the world.
|
|
5
|
+
* pinned. Braids shard within a tenant; tenants shard the world. A
|
|
6
|
+
* tenant id is one StoreKey segment. `get` returns a live handle whose
|
|
7
|
+
* refcount pins it against eviction for the duration of the borrow
|
|
8
|
+
* (findings 38, 71) — the pin outlives `get` and drops only on
|
|
9
|
+
* `release`; a disposed handle is a distinct type. One fenced lease
|
|
10
|
+
* owns each replica directory and is CAS-renewed while the slot is
|
|
11
|
+
* open, so a second live replica on that dir is unrepresentable
|
|
12
|
+
* (findings 39, 48).
|
|
6
13
|
*/
|
|
7
14
|
|
|
8
15
|
import * as fs from "node:fs/promises"
|
|
9
16
|
import * as path from "node:path"
|
|
10
17
|
import type { Schema, SchemaRelations } from "@bjornpagen/bumbledb"
|
|
11
18
|
import * as errors from "@superbuilders/errors"
|
|
19
|
+
import { regex } from "arkregex"
|
|
12
20
|
import { tenantPrefix } from "#keys.ts"
|
|
13
21
|
import type { Replica } from "#replica.ts"
|
|
14
22
|
import { openReplica } from "#replica.ts"
|
|
15
|
-
import type { ObjectStore } from "#store.ts"
|
|
23
|
+
import type { FsLease, ObjectStore } from "#store.ts"
|
|
24
|
+
import { acquireFsLease, releaseFsLease } from "#store.ts"
|
|
16
25
|
|
|
17
26
|
const PINNED_TENANT = "_shared"
|
|
27
|
+
const SIGNED_DECIMAL = regex("^-?\\d+$")
|
|
28
|
+
const UNSIGNED_DECIMAL = regex("^\\d+$")
|
|
29
|
+
const TENANT_ID = regex("^[A-Za-z0-9._-]+$")
|
|
30
|
+
|
|
31
|
+
/** Directory-lease lifetime: one owner per replica directory. The pool
|
|
32
|
+
* CAS-extends `expires` at one-third this TTL while the slot is open,
|
|
33
|
+
* so expiry cannot mint a second owner under a live replica. */
|
|
34
|
+
const DIR_LEASE_MS = 300_000
|
|
18
35
|
|
|
19
36
|
interface OpenTenantsOptions<Rels extends SchemaRelations> {
|
|
20
37
|
readonly store: ObjectStore
|
|
@@ -26,17 +43,117 @@ interface OpenTenantsOptions<Rels extends SchemaRelations> {
|
|
|
26
43
|
* admission is not re-weighed until it is evicted and re-opened. */
|
|
27
44
|
readonly budgetBytes?: number
|
|
28
45
|
readonly maxOpen?: number
|
|
46
|
+
/** Directory-lease TTL. Renewed at one-third this value while the
|
|
47
|
+
* replica is open. Default 300_000. */
|
|
48
|
+
readonly dirLeaseMs?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const liveHandleBrand: unique symbol = Symbol("liveHandle")
|
|
52
|
+
const disposedHandleBrand: unique symbol = Symbol("disposedHandle")
|
|
53
|
+
|
|
54
|
+
type LiveHandle<Rels extends SchemaRelations> = Replica<Rels> & {
|
|
55
|
+
readonly [liveHandleBrand]: typeof liveHandleBrand
|
|
56
|
+
/** Drop one borrow. LRU and `evict` become legal only when `refs`
|
|
57
|
+
* reaches 0 — not when `get` returns. */
|
|
58
|
+
release(): void
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A handle whose replica is gone. Every verb is a compile-time
|
|
62
|
+
* refusal — there is no replica field to call, so `waitFor` into a
|
|
63
|
+
* disposed replica is a type error. */
|
|
64
|
+
type DisposedHandle = {
|
|
65
|
+
readonly [disposedHandleBrand]: typeof disposedHandleBrand
|
|
29
66
|
}
|
|
30
67
|
|
|
31
68
|
interface Tenants<Rels extends SchemaRelations> extends AsyncDisposable {
|
|
32
|
-
get(tenant: string): Promise<
|
|
69
|
+
get(tenant: string): Promise<LiveHandle<Rels>>
|
|
70
|
+
/** Evicts one tenant: closes the replica and deletes its directory.
|
|
71
|
+
* `_shared` and a still-pinned borrow refuse by doing nothing. */
|
|
72
|
+
evict(tenant: string): Promise<DisposedHandle | null>
|
|
73
|
+
/** Drop one `get` borrow for `tenant`. */
|
|
74
|
+
release(tenant: string): void
|
|
33
75
|
}
|
|
34
76
|
|
|
35
77
|
interface TenantSlot<Rels extends SchemaRelations> {
|
|
36
|
-
readonly replica:
|
|
78
|
+
readonly replica: LiveHandle<Rels>
|
|
37
79
|
readonly dir: string
|
|
80
|
+
readonly lease: FsLease
|
|
38
81
|
bytes: number
|
|
39
82
|
lastUsed: number
|
|
83
|
+
refs: number
|
|
84
|
+
leaseLost?: Error
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function codeOf(error: Error): string | undefined {
|
|
88
|
+
return (error as NodeJS.ErrnoException).code
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function encodeLease(holder: bigint, token: bigint, expires: bigint): Uint8Array {
|
|
92
|
+
return new TextEncoder().encode(`${holder}\n${token}\n${expires}\n`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseLease(raw: string): { holder: bigint; token: bigint; expires: bigint } | null {
|
|
96
|
+
const lines = raw.trim().split("\n")
|
|
97
|
+
if (lines.length !== 3) {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
const [holderLine, tokenLine, expiresLine] = lines
|
|
101
|
+
if (holderLine === undefined || tokenLine === undefined || expiresLine === undefined) {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
if (!SIGNED_DECIMAL.test(holderLine) || !UNSIGNED_DECIMAL.test(tokenLine) || !SIGNED_DECIMAL.test(expiresLine)) {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
return { holder: BigInt(holderLine), token: BigInt(tokenLine), expires: BigInt(expiresLine) }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function fsyncDir(dir: string): Promise<void> {
|
|
111
|
+
const handle = await fs.open(dir, "r")
|
|
112
|
+
const synced = await errors.try(handle.sync())
|
|
113
|
+
await handle.close()
|
|
114
|
+
if (synced.error) {
|
|
115
|
+
throw errors.wrap(synced.error, `fsync directory ${dir}`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** CAS-extend `expires` of a token we already hold. A new token would
|
|
120
|
+
* be a second owner; we rewrite the same path so identity does not
|
|
121
|
+
* change. */
|
|
122
|
+
async function renewDirLease(held: FsLease, ttlMs: number): Promise<void> {
|
|
123
|
+
const read = await errors.try(fs.readFile(held.path, "utf8"))
|
|
124
|
+
if (read.error) {
|
|
125
|
+
if (codeOf(read.error) === "ENOENT") {
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
throw read.error
|
|
129
|
+
}
|
|
130
|
+
const lease = parseLease(read.data)
|
|
131
|
+
if (lease === null || lease.holder !== held.holder || lease.token !== held.token) {
|
|
132
|
+
throw errors.new("directory lease is no longer ours")
|
|
133
|
+
}
|
|
134
|
+
const body = encodeLease(held.holder, held.token, BigInt(Date.now() + ttlMs))
|
|
135
|
+
const tmp = `${held.path}.renew.${process.pid}`
|
|
136
|
+
const file = await fs.open(tmp, "w")
|
|
137
|
+
const written = await errors.try(
|
|
138
|
+
(async function writeAll() {
|
|
139
|
+
await file.writeFile(body)
|
|
140
|
+
await file.sync()
|
|
141
|
+
})()
|
|
142
|
+
)
|
|
143
|
+
await file.close()
|
|
144
|
+
if (written.error) {
|
|
145
|
+
await fs.rm(tmp, { force: true })
|
|
146
|
+
throw written.error
|
|
147
|
+
}
|
|
148
|
+
const replaced = await errors.try(fs.rename(tmp, held.path))
|
|
149
|
+
if (replaced.error) {
|
|
150
|
+
await fs.rm(tmp, { force: true })
|
|
151
|
+
if (codeOf(replaced.error) === "ENOENT") {
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
throw replaced.error
|
|
155
|
+
}
|
|
156
|
+
await fsyncDir(held.dir)
|
|
40
157
|
}
|
|
41
158
|
|
|
42
159
|
async function directoryBytes(dir: string): Promise<number> {
|
|
@@ -57,19 +174,101 @@ async function directoryBytes(dir: string): Promise<number> {
|
|
|
57
174
|
}
|
|
58
175
|
|
|
59
176
|
function checkTenantId(tenant: string): void {
|
|
60
|
-
if (
|
|
177
|
+
if (!TENANT_ID.test(tenant)) {
|
|
61
178
|
throw errors.new(`tenant id is not a single path segment: ${tenant}`)
|
|
62
179
|
}
|
|
63
180
|
}
|
|
64
181
|
|
|
182
|
+
function asDisposed(): DisposedHandle {
|
|
183
|
+
return { [disposedHandleBrand]: disposedHandleBrand }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function disposeSlot<Rels extends SchemaRelations>(slot: TenantSlot<Rels>): Promise<void> {
|
|
187
|
+
await slot.replica[Symbol.asyncDispose]()
|
|
188
|
+
await fs.rm(slot.dir, { recursive: true, force: true })
|
|
189
|
+
await releaseFsLease(slot.lease)
|
|
190
|
+
}
|
|
191
|
+
|
|
65
192
|
function openTenants<Rels extends SchemaRelations>(options: OpenTenantsOptions<Rels>): Tenants<Rels> {
|
|
66
193
|
const budgetBytes = options.budgetBytes ?? 400_000_000
|
|
67
194
|
const maxOpen = options.maxOpen ?? 32
|
|
195
|
+
const dirLeaseMs = options.dirLeaseMs ?? DIR_LEASE_MS
|
|
196
|
+
if (dirLeaseMs <= 0) {
|
|
197
|
+
throw errors.new("dirLeaseMs must be positive")
|
|
198
|
+
}
|
|
199
|
+
const renewEveryMs = Math.max(1, Math.floor(dirLeaseMs / 3))
|
|
68
200
|
const open = new Map<string, TenantSlot<Rels>>()
|
|
201
|
+
const opening = new Map<string, Promise<LiveHandle<Rels>>>()
|
|
69
202
|
let tick = 0
|
|
70
203
|
let closed = false
|
|
204
|
+
let renewTimer: ReturnType<typeof setInterval> | undefined
|
|
205
|
+
let leaseGate = Promise.resolve()
|
|
206
|
+
|
|
207
|
+
function withLeaseGate<T>(body: () => Promise<T>): Promise<T> {
|
|
208
|
+
const run = leaseGate.then(body, body)
|
|
209
|
+
leaseGate = run.then(
|
|
210
|
+
function absorb() {
|
|
211
|
+
return undefined
|
|
212
|
+
},
|
|
213
|
+
function absorbFailure() {
|
|
214
|
+
return undefined
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
return run
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function dropBorrow(tenant: string): void {
|
|
221
|
+
if (closed) {
|
|
222
|
+
throw errors.new("tenants pool is disposed")
|
|
223
|
+
}
|
|
224
|
+
const slot = open.get(tenant)
|
|
225
|
+
if (slot === undefined || slot.refs <= 0) {
|
|
226
|
+
throw errors.new(`no live borrow for tenant ${tenant}`)
|
|
227
|
+
}
|
|
228
|
+
slot.refs -= 1
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function brandLive(replica: Replica<Rels>, tenant: string): LiveHandle<Rels> {
|
|
232
|
+
const handle = replica as LiveHandle<Rels>
|
|
233
|
+
Object.defineProperty(handle, liveHandleBrand, { value: liveHandleBrand })
|
|
234
|
+
Object.defineProperty(handle, "release", {
|
|
235
|
+
value: function release() {
|
|
236
|
+
dropBorrow(tenant)
|
|
237
|
+
},
|
|
238
|
+
enumerable: false
|
|
239
|
+
})
|
|
240
|
+
return handle
|
|
241
|
+
}
|
|
71
242
|
|
|
72
|
-
|
|
243
|
+
function armRenew(): void {
|
|
244
|
+
if (renewTimer !== undefined) {
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
renewTimer = setInterval(function tickRenew() {
|
|
248
|
+
void errors.try(renewAllOpen())
|
|
249
|
+
}, renewEveryMs)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function disarmRenew(): void {
|
|
253
|
+
if (open.size > 0 || renewTimer === undefined) {
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
clearInterval(renewTimer)
|
|
257
|
+
renewTimer = undefined
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function renewAllOpen(): Promise<void> {
|
|
261
|
+
await withLeaseGate(async function renewHeld() {
|
|
262
|
+
for (const [tenant, slot] of open) {
|
|
263
|
+
const result = await errors.try(renewDirLease(slot.lease, dirLeaseMs))
|
|
264
|
+
if (result.error !== undefined && open.get(tenant) === slot) {
|
|
265
|
+
slot.leaseLost = result.error
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function evictUntilWithin(keep: string): Promise<void> {
|
|
73
272
|
for (;;) {
|
|
74
273
|
let bytes = 0
|
|
75
274
|
for (const slot of open.values()) {
|
|
@@ -81,7 +280,7 @@ function openTenants<Rels extends SchemaRelations>(options: OpenTenantsOptions<R
|
|
|
81
280
|
let victim: string | null = null
|
|
82
281
|
let oldest = Number.POSITIVE_INFINITY
|
|
83
282
|
for (const [tenant, slot] of open) {
|
|
84
|
-
if (tenant === PINNED_TENANT) {
|
|
283
|
+
if (tenant === PINNED_TENANT || tenant === keep || slot.refs > 0) {
|
|
85
284
|
continue
|
|
86
285
|
}
|
|
87
286
|
if (slot.lastUsed < oldest) {
|
|
@@ -95,12 +294,70 @@ function openTenants<Rels extends SchemaRelations>(options: OpenTenantsOptions<R
|
|
|
95
294
|
const slot = open.get(victim)
|
|
96
295
|
open.delete(victim)
|
|
97
296
|
if (slot !== undefined) {
|
|
98
|
-
await
|
|
99
|
-
|
|
297
|
+
await withLeaseGate(function disposeVictim() {
|
|
298
|
+
return disposeSlot(slot)
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Increment the borrow. The pin outlives `get` — a `finally`
|
|
305
|
+
* decrement here would make LRU/`evict` legal while the caller
|
|
306
|
+
* still holds the LiveHandle (finding 38). Decrement only if this
|
|
307
|
+
* `get` fails before the handle is in the caller's hands. */
|
|
308
|
+
async function pin(slot: TenantSlot<Rels>, tenant: string): Promise<LiveHandle<Rels>> {
|
|
309
|
+
if (slot.leaseLost !== undefined) {
|
|
310
|
+
throw slot.leaseLost
|
|
311
|
+
}
|
|
312
|
+
slot.refs += 1
|
|
313
|
+
slot.lastUsed = tick
|
|
314
|
+
try {
|
|
315
|
+
await evictUntilWithin(tenant)
|
|
316
|
+
const kept = open.get(tenant)
|
|
317
|
+
if (kept === undefined) {
|
|
318
|
+
throw errors.new("tenants pool evicted the handle it is returning")
|
|
100
319
|
}
|
|
320
|
+
await withLeaseGate(function renewOnPin() {
|
|
321
|
+
return renewDirLease(kept.lease, dirLeaseMs)
|
|
322
|
+
})
|
|
323
|
+
return kept.replica
|
|
324
|
+
} catch (error) {
|
|
325
|
+
slot.refs -= 1
|
|
326
|
+
throw error
|
|
101
327
|
}
|
|
102
328
|
}
|
|
103
329
|
|
|
330
|
+
async function openOne(tenant: string): Promise<LiveHandle<Rels>> {
|
|
331
|
+
const dir = path.join(options.dir, tenant)
|
|
332
|
+
const lease = await acquireFsLease(dir, "dir", dirLeaseMs, "refuse")
|
|
333
|
+
let replica: LiveHandle<Rels>
|
|
334
|
+
try {
|
|
335
|
+
replica = brandLive(
|
|
336
|
+
await openReplica({
|
|
337
|
+
store: options.store,
|
|
338
|
+
prefix: tenantPrefix(options.root, tenant),
|
|
339
|
+
dir,
|
|
340
|
+
theory: options.theory
|
|
341
|
+
}),
|
|
342
|
+
tenant
|
|
343
|
+
)
|
|
344
|
+
} catch (error) {
|
|
345
|
+
await releaseFsLease(lease)
|
|
346
|
+
throw error
|
|
347
|
+
}
|
|
348
|
+
const slot: TenantSlot<Rels> = {
|
|
349
|
+
replica,
|
|
350
|
+
dir,
|
|
351
|
+
lease,
|
|
352
|
+
bytes: await directoryBytes(dir),
|
|
353
|
+
lastUsed: tick,
|
|
354
|
+
refs: 0
|
|
355
|
+
}
|
|
356
|
+
open.set(tenant, slot)
|
|
357
|
+
armRenew()
|
|
358
|
+
return await pin(slot, tenant)
|
|
359
|
+
}
|
|
360
|
+
|
|
104
361
|
return {
|
|
105
362
|
async get(tenant) {
|
|
106
363
|
if (closed) {
|
|
@@ -110,31 +367,70 @@ function openTenants<Rels extends SchemaRelations>(options: OpenTenantsOptions<R
|
|
|
110
367
|
tick += 1
|
|
111
368
|
const hit = open.get(tenant)
|
|
112
369
|
if (hit !== undefined) {
|
|
113
|
-
hit
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
370
|
+
return await pin(hit, tenant)
|
|
371
|
+
}
|
|
372
|
+
const inflight = opening.get(tenant)
|
|
373
|
+
if (inflight !== undefined) {
|
|
374
|
+
const ran = await errors.try(inflight)
|
|
375
|
+
if (ran.error) {
|
|
376
|
+
throw ran.error
|
|
377
|
+
}
|
|
378
|
+
const slot = open.get(tenant)
|
|
379
|
+
if (slot === undefined) {
|
|
380
|
+
throw errors.new("tenants pool dropped the tenant during open")
|
|
381
|
+
}
|
|
382
|
+
return await pin(slot, tenant)
|
|
383
|
+
}
|
|
384
|
+
const pending = openOne(tenant)
|
|
385
|
+
opening.set(tenant, pending)
|
|
386
|
+
const ran = await errors.try(pending)
|
|
387
|
+
opening.delete(tenant)
|
|
388
|
+
if (ran.error) {
|
|
389
|
+
throw ran.error
|
|
390
|
+
}
|
|
391
|
+
return ran.data
|
|
392
|
+
},
|
|
393
|
+
|
|
394
|
+
async evict(tenant) {
|
|
395
|
+
if (closed) {
|
|
396
|
+
throw errors.new("tenants pool is disposed")
|
|
397
|
+
}
|
|
398
|
+
checkTenantId(tenant)
|
|
399
|
+
if (tenant === PINNED_TENANT) {
|
|
400
|
+
return null
|
|
401
|
+
}
|
|
402
|
+
const slot = open.get(tenant)
|
|
403
|
+
if (slot === undefined || slot.refs > 0) {
|
|
404
|
+
return null
|
|
405
|
+
}
|
|
406
|
+
open.delete(tenant)
|
|
407
|
+
await withLeaseGate(function disposeEvicted() {
|
|
408
|
+
return disposeSlot(slot)
|
|
122
409
|
})
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
410
|
+
disarmRenew()
|
|
411
|
+
return asDisposed()
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
release(tenant) {
|
|
415
|
+
dropBorrow(tenant)
|
|
127
416
|
},
|
|
128
417
|
|
|
129
418
|
async [Symbol.asyncDispose]() {
|
|
130
419
|
closed = true
|
|
131
|
-
|
|
132
|
-
|
|
420
|
+
if (renewTimer !== undefined) {
|
|
421
|
+
clearInterval(renewTimer)
|
|
422
|
+
renewTimer = undefined
|
|
133
423
|
}
|
|
134
|
-
|
|
424
|
+
await withLeaseGate(async function disposePool() {
|
|
425
|
+
for (const slot of open.values()) {
|
|
426
|
+
await releaseFsLease(slot.lease)
|
|
427
|
+
await slot.replica[Symbol.asyncDispose]()
|
|
428
|
+
}
|
|
429
|
+
open.clear()
|
|
430
|
+
})
|
|
135
431
|
}
|
|
136
432
|
}
|
|
137
433
|
}
|
|
138
434
|
|
|
139
|
-
export type { OpenTenantsOptions, Tenants }
|
|
435
|
+
export type { DisposedHandle, LiveHandle, OpenTenantsOptions, Tenants }
|
|
140
436
|
export { openTenants }
|
package/src/value.ts
CHANGED
|
@@ -19,6 +19,41 @@ interface Interval {
|
|
|
19
19
|
|
|
20
20
|
type Value = boolean | bigint | string | Uint8Array | Interval
|
|
21
21
|
|
|
22
|
+
/** A string cell: well-formed UTF-8 by construction. A lone surrogate cannot enter. */
|
|
23
|
+
declare const wellFormedUtf8Brand: unique symbol
|
|
24
|
+
type WellFormedUtf8 = string & { readonly [wellFormedUtf8Brand]: typeof wellFormedUtf8Brand }
|
|
25
|
+
|
|
26
|
+
/** Fatal encoder: refuses lone surrogates rather than emitting U+FFFD. */
|
|
27
|
+
function wellFormedUtf8(text: string): WellFormedUtf8 {
|
|
28
|
+
if (!text.isWellFormed()) {
|
|
29
|
+
throw errors.new("string cell is not well-formed UTF-8")
|
|
30
|
+
}
|
|
31
|
+
return text as WellFormedUtf8
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseWellFormedUtf8(raw: Uint8Array): WellFormedUtf8 | undefined {
|
|
35
|
+
const decoded = errors.trySync(function decodeUtf8() {
|
|
36
|
+
return utf8StrictDecoder.decode(raw)
|
|
37
|
+
})
|
|
38
|
+
if (decoded.error) {
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
return decoded.data as WellFormedUtf8
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function domainCeiling(element: "u64" | "i64"): bigint {
|
|
45
|
+
return element === "u64" ? U64_MAX : I64_MAX
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Fixed-width `[start, start + width)`; the domain ceiling is not a value. */
|
|
49
|
+
function fixedInterval(start: bigint, width: bigint, element: "u64" | "i64"): Interval | undefined {
|
|
50
|
+
const end = start + width
|
|
51
|
+
if (end <= start || end >= domainCeiling(element)) {
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
return { start, end }
|
|
55
|
+
}
|
|
56
|
+
|
|
22
57
|
/** 20's tag table: the codec's own numbering, normative to the byte. */
|
|
23
58
|
const TAG = {
|
|
24
59
|
bool: 0,
|
|
@@ -72,9 +107,10 @@ function checkAgainst(context: string, type: ValueTypeSpec, value: Value): void
|
|
|
72
107
|
return
|
|
73
108
|
}
|
|
74
109
|
case "string": {
|
|
75
|
-
if (typeof value !== "string"
|
|
110
|
+
if (typeof value !== "string") {
|
|
76
111
|
throw errors.new(`${context}: expected well-formed string`)
|
|
77
112
|
}
|
|
113
|
+
wellFormedUtf8(value)
|
|
78
114
|
return
|
|
79
115
|
}
|
|
80
116
|
case "fixedBytes": {
|
|
@@ -92,8 +128,14 @@ function checkAgainst(context: string, type: ValueTypeSpec, value: Value): void
|
|
|
92
128
|
if (value.start < lo || value.end > hi || value.start >= value.end) {
|
|
93
129
|
throw errors.new(`${context}: interval bounds out of range or empty`)
|
|
94
130
|
}
|
|
95
|
-
if (type.width !== undefined
|
|
96
|
-
|
|
131
|
+
if (type.width !== undefined) {
|
|
132
|
+
const parsed = fixedInterval(value.start, type.width, type.element)
|
|
133
|
+
if (parsed === undefined) {
|
|
134
|
+
throw errors.new(`${context}: fixed interval end is the domain ceiling`)
|
|
135
|
+
}
|
|
136
|
+
if (parsed.end !== value.end) {
|
|
137
|
+
throw errors.new(`${context}: interval width must be ${type.width}`)
|
|
138
|
+
}
|
|
97
139
|
}
|
|
98
140
|
return
|
|
99
141
|
}
|
|
@@ -119,7 +161,8 @@ function writeTagged(out: ByteWriter, type: ValueTypeSpec, value: Value): void {
|
|
|
119
161
|
return
|
|
120
162
|
}
|
|
121
163
|
case "string": {
|
|
122
|
-
const
|
|
164
|
+
const text = wellFormedUtf8(value as string)
|
|
165
|
+
const raw = utf8Encoder.encode(text)
|
|
123
166
|
out.u8(TAG.string)
|
|
124
167
|
out.u32le(raw.length)
|
|
125
168
|
out.bytes(raw)
|
|
@@ -186,13 +229,11 @@ function readTagged(reader: ByteReader, type: ValueTypeSpec, refusal: TaggedRefu
|
|
|
186
229
|
case "string": {
|
|
187
230
|
const len = reader.u32le("string length")
|
|
188
231
|
const raw = reader.bytes(len, "string payload")
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
})
|
|
192
|
-
if (decoded.error) {
|
|
232
|
+
const text = parseWellFormedUtf8(raw)
|
|
233
|
+
if (text === undefined) {
|
|
193
234
|
refusal.invalidUtf8()
|
|
194
235
|
}
|
|
195
|
-
return
|
|
236
|
+
return text
|
|
196
237
|
}
|
|
197
238
|
case "fixedBytes":
|
|
198
239
|
return reader.bytes(type.len, "fixedBytes payload")
|
|
@@ -206,11 +247,11 @@ function readTagged(reader: ByteReader, type: ValueTypeSpec, refusal: TaggedRefu
|
|
|
206
247
|
return { start, end }
|
|
207
248
|
}
|
|
208
249
|
const start = type.element === "u64" ? reader.u64le("interval start") : reader.i64le("interval start")
|
|
209
|
-
const
|
|
210
|
-
if (
|
|
250
|
+
const parsed = fixedInterval(start, type.width, type.element)
|
|
251
|
+
if (parsed === undefined) {
|
|
211
252
|
refusal.intervalOverflow()
|
|
212
253
|
}
|
|
213
|
-
return
|
|
254
|
+
return parsed
|
|
214
255
|
}
|
|
215
256
|
}
|
|
216
257
|
}
|
|
@@ -281,5 +322,15 @@ function writeCanonicalLiteral(out: ByteWriter, type: ValueTypeSpec, value: Valu
|
|
|
281
322
|
}
|
|
282
323
|
}
|
|
283
324
|
|
|
284
|
-
export type { Interval, TaggedRefusal, Value }
|
|
285
|
-
export {
|
|
325
|
+
export type { Interval, TaggedRefusal, Value, WellFormedUtf8 }
|
|
326
|
+
export {
|
|
327
|
+
checkAgainst,
|
|
328
|
+
isInterval,
|
|
329
|
+
readTagged,
|
|
330
|
+
TAG,
|
|
331
|
+
valuesEqual,
|
|
332
|
+
wellFormedUtf8,
|
|
333
|
+
wireTagOf,
|
|
334
|
+
writeCanonicalLiteral,
|
|
335
|
+
writeTagged
|
|
336
|
+
}
|