char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
e7b3c51
main
1import { 2ArrayIsArray as isArray , 3Map , 4MapPrototypeGet as mapGet , 5MapPrototypeSet as mapSet , 6ObjectCreate , 7RangeError , 8ReflectGet as reflectGet , 9StringPrototypeIsWellFormed as isWellFormed , 10TypeError , 11TypedArrayPrototypeGetTag as typedArrayTag , 12Uint8Array as Uint8ArrayCtor , 13WeakMap , 14WeakMapPrototypeGet as getState , 15WeakMapPrototypeSet as setState , 16getter , 17uncurryThis , 18} from "ext:core/primordials" ; 19import { promiseThen } from "ext:core/promises" ; 20import { result } from "ext:file/errors.js" ; 21import { URL } from "ext:url/main.js" ; 22import { registerUncloneable } from "ext:web/serialization.js" ; 23import { TextDecoder , TextEncoder } from "ext:encoding/main.js" ; 24import { 25op_file_url_path as urlPathOp , 26op_kv_atomic as atomicOp , 27op_kv_clear as clearOp , 28op_kv_close as closeOp , 29op_kv_delete as deleteOp , 30op_kv_get_many as getManyOp , 31op_kv_list as listOp , 32op_kv_list_keyspaces as keyspacesOp , 33op_kv_open as openOp , 34op_kv_set as setOp , 35} from "ext:core/ops" ; 36 37/** 38* A key is an arbitrary byte string. Strings are their UTF-8 encoding; 39* `Uint8Array`s are copied as-is, so `"abc"` and `new Uint8Array([97, 98, 99])` 40* name the very same key. The empty string and the empty byte string are both 41* valid keys: they sort before every other key. 42*/ 43export type KvKey = string | Uint8Array ; 44 45/** 46* A value is an arbitrary byte string. Strings are their UTF-8 encoding; 47* `Uint8Array`s are copied as-is. Richer values are userland's job: serialize 48* to bytes first (JSON text, CBOR via `jsr:@char/cbor`, …), store the bytes. 49*/ 50export type KvValue = string | Uint8Array ; 51 52/** 53* The database's commit sequence number of the commit that last wrote an 54* entry. Equality is the intended use; ordering is also meaningful, but only 55* between stamps of the same database. 56*/ 57export type KvVersionstamp = bigint ; 58 59export interface KvEntry < K extends KvKey = Uint8Array , V extends KvValue = Uint8Array > { 60readonly keyspace :string ; 61readonly key :K ; 62readonly value :V ; 63readonly versionstamp :KvVersionstamp ; 64} 65 66export type KvAccess = "shared" | "exclusive" | "read-only" ; 67 68export interface KvOpenOptions { 69readonly access ?:KvAccess ; 70readonly create ?:boolean ; 71} 72 73/** Structural subset of URL accepted wherever a file URL is allowed. */ 74export interface KvPathUrl { 75readonly href :string ; 76} 77 78export interface KvListOptions { 79readonly prefix ?:KvKey ; 80readonly start ?:KvKey ; 81readonly end ?:KvKey ; 82readonly limit ?:number ; 83readonly reverse ?:boolean ; 84} 85 86export interface KvAtomic { 87check ( keyspace :KvKeyspace , key :KvKey , expected :KvVersionstamp | null ) :void ; 88set ( keyspace :KvKeyspace , key :KvKey , value :KvValue ) :void ; 89delete ( keyspace :KvKeyspace , key :KvKey ) :void ; 90} 91 92export type KvAtomicResult = 93| { readonly ok :true ; readonly versionstamp :KvVersionstamp | null } 94| { readonly ok :false }; 95 96export interface KvKeyspace { 97readonly name :string ; 98get ( key :KvKey ) :Promise < Uint8Array | null >; 99getText ( key :KvKey ) :Promise < string | null >; 100getMany ( keys :readonly KvKey []) :Promise <( Uint8Array | null )[]>; 101getTextMany ( keys :readonly KvKey []) :Promise <( string | null )[]>; 102entry ( key :KvKey ) :Promise < KvEntry | null >; 103entries ( keys :readonly KvKey []) :Promise <( KvEntry | null )[]>; 104set ( key :KvKey , value :KvValue ) :Promise <{ readonly versionstamp :KvVersionstamp }>; 105delete ( key :KvKey ) :Promise < void >; 106list ( options ?:KvListOptions ) :AsyncIterable < KvEntry >; 107clear () :Promise < void >; 108} 109 110export interface KvDatabase { 111keyspace ( name :string ) :KvKeyspace ; 112keyspaces () :Promise < string []>; 113atomic ( build :( tx :KvAtomic ) => void ) :Promise < KvAtomicResult >; 114close () :void ; 115[ Symbol . asyncDispose ]() :Promise < void >; 116} 117 118interface DbState { 119id :number ; 120closed :boolean ; 121keyspaces :Map < string , Keyspace >; 122} 123 124interface KeyspaceState { 125db :DbState ; 126name :string ; 127} 128 129const dbStates = new WeakMap < object , DbState >(); 130const keyspaceStates = new WeakMap < object , KeyspaceState >(); 131registerUncloneable ( dbStates ); 132registerUncloneable ( keyspaceStates ); 133 134const secret :object = ObjectCreate ( null ); 135const urlHref = getter ( URL . prototype , "href" ); 136const FinalizationRegistryCtor = globalThis . FinalizationRegistry ; 137const finalizerRegister = uncurryThis ( FinalizationRegistryCtor . prototype . register ); 138const finalizerUnregister = uncurryThis ( FinalizationRegistryCtor . prototype . unregister ); 139const numberIsSafeInteger = globalThis . Number . isSafeInteger ; 140const BigInt = globalThis . BigInt ; 141 142const textEncoder = new TextEncoder (); 143const encodeUtf8 = uncurryThis ( TextEncoder . prototype . encode ); 144const textDecoder = new TextDecoder ( "utf-8" , { fatal :true }); 145const decodeUtf8 = uncurryThis ( TextDecoder . prototype . decode ); 146 147const maxValueBytes = 4 * 1024 * 1024 ; 148// Mirrors avril_kv::{MAX_BATCH_OPS, MAX_BATCH_BYTES}, so a RangeError comes 149// from the offending tx call rather than from the commit. 150const maxBatchOps = 1000 ; 151const maxBatchBytes = 4 * 1024 * 1024 ; 152 153type AtomicOp = 154| { t :"check" ; ks :string ; key :Uint8Array ; expected :string | null } 155| { t :"set" ; ks :string ; key :Uint8Array ; value :Uint8Array } 156| { t :"delete" ; ks :string ; key :Uint8Array }; 157 158function noop () :void {} 159 160const closers = new FinalizationRegistryCtor (( id :number ) => { 161promiseThen ( closeOp ( id ), noop , noop ); 162}); 163 164function dbStateOf ( database :Database ) :DbState { 165const state = getState ( dbStates , database ); 166if ( state === undefined ) throw new TypeError ( "Illegal invocation" ); 167return state ; 168} 169 170function openDbState ( database :Database ) :DbState { 171const state = dbStateOf ( database ); 172if ( state . closed ) throw new TypeError ( "kv database is closed" ); 173return state ; 174} 175 176function keyspaceStateOf ( space :Keyspace ) :KeyspaceState { 177const state = getState ( keyspaceStates , space ); 178if ( state === undefined ) throw new TypeError ( "Illegal invocation" ); 179return state ; 180} 181 182// Marks the database closed and returns its id, or null if already closed. 183function markClosed ( database :Database ) :number | null { 184const state = dbStateOf ( database ); 185if ( state . closed ) return null ; 186state . closed = true ; 187finalizerUnregister ( closers , database ); 188return state . id ; 189} 190 191function openKeyspaceState ( space :Keyspace ) :KeyspaceState { 192const state = keyspaceStateOf ( space ); 193if ( state . db . closed ) throw new TypeError ( "kv database is closed" ); 194return state ; 195} 196 197function toPath ( value :unknown ) :string { 198if ( typeof value === "string" ) return value ; 199let href :unknown ; 200try { 201href = urlHref ( value ); 202} catch { 203throw new TypeError ( "Expected a path string or file URL" ); 204} 205return result ( urlPathOp ( href as string )); 206} 207 208// A brand check: genuine Uint8Arrays (subclasses included), never objects 209// that merely inherit from Uint8Array.prototype. 210function isUint8Array ( value :unknown ) :value isUint8Array { 211return typedArrayTag ( value ) === "Uint8Array" ; 212} 213 214// Canonical key bytes: UTF-8 for strings (ill-formed rejected), a copy of 215// the view's bytes for Uint8Arrays (so subclasses work and offsets are 216// honored). The empty string and empty bytes are valid keys. 217function encodeKey ( value :unknown ) :Uint8Array { 218if ( typeof value === "string" ) { 219if ( ! isWellFormed ( value )) throw new TypeError ( "kv keys must be well-formed strings" ); 220return encodeUtf8 ( textEncoder , value ); 221} 222if ( isUint8Array ( value )) return new Uint8ArrayCtor ( value ); 223throw new TypeError ( "kv keys must be strings or Uint8Arrays" ); 224} 225 226function encodeValue ( value :unknown ) :Uint8Array { 227let bytes :Uint8Array ; 228if ( typeof value === "string" ) { 229if ( ! isWellFormed ( value )) throw new TypeError ( "kv values must be well-formed strings" ); 230bytes = encodeUtf8 ( textEncoder , value ); 231} else if ( isUint8Array ( value )) { 232bytes = new Uint8ArrayCtor ( value ); 233} else { 234throw new TypeError ( "kv values must be strings or Uint8Arrays" ); 235} 236if ( bytes . length > maxValueBytes ) { 237throw new RangeError ( "kv value exceeds the size limit" ); 238} 239return bytes ; 240} 241 242function decodeText ( value :Uint8Array ) :string { 243try { 244return decodeUtf8 ( textDecoder , value ); 245} catch { 246throw new TypeError ( "kv value is not valid UTF-8" ); 247} 248} 249 250class Database implements KvDatabase { 251constructor ( token :object ); 252constructor () { 253if ( arguments [ 0 ] !== secret ) throw new TypeError ( "Illegal constructor" ); 254} 255 256keyspace ( name :unknown ) :Keyspace { 257const state = openDbState ( this ); 258if ( typeof name !== "string" || name . length === 0 ) { 259throw new TypeError ( "kv keyspace names must be non-empty strings" ); 260} 261if ( ! isWellFormed ( name )) { 262throw new TypeError ( "kv keyspace names must be well-formed strings" ); 263} 264const known = mapGet ( state . keyspaces , name ); 265if ( known !== undefined ) return known ; 266const space = new Keyspace ( secret , state , name ); 267mapSet ( state . keyspaces , name , space ); 268return space ; 269} 270 271async keyspaces () :Promise < string []> { 272return keyspacesOp ( openDbState ( this ). id ); 273} 274 275async atomic ( build :unknown ) :Promise < KvAtomicResult > { 276const state = openDbState ( this ); 277if ( typeof build !== "function" ) { 278throw new TypeError ( "kv atomic callback must be a function" ); 279} 280const ops :AtomicOp [] = []; 281let bytes = 0 ; 282let active = true ; 283const append = ( op :AtomicOp ) :void => { 284const size = encodeUtf8 ( textEncoder , op . ks ). length + op . key . length + 285( op . t === "set" ?op . value . length :0 ); 286if ( ops . length >= maxBatchOps ) { 287throw new RangeError ( "kv atomic batch exceeds the operation limit" ); 288} 289if ( bytes + size > maxBatchBytes ) { 290throw new RangeError ( "kv atomic batch exceeds the size limit" ); 291} 292bytes += size ; 293ops [ ops . length ] = op ; 294}; 295const keyspaceName = ( space :unknown ) :string => { 296if ( ! active ) throw new TypeError ( "kv atomic transaction is no longer active" ); 297const found = getState ( keyspaceStates , space as object ); 298if ( found === undefined || found . db !== state ) { 299throw new TypeError ( "kv atomic keyspace must belong to this database" ); 300} 301return found . name ; 302}; 303const tx :KvAtomic = { 304check ( space :unknown , key :unknown , expected :unknown ) :void { 305const ks = keyspaceName ( space ); 306if ( expected !== null && typeof expected !== "bigint" ) { 307throw new TypeError ( "kv check expected must be a versionstamp or null" ); 308} 309// Template conversion of a bigint primitive never consults its prototype. 310const stamp = expected === null ?null :` ${ expected } ` ; 311append ({ t :"check" , ks, key :encodeKey ( key ), expected :stamp }); 312}, 313set ( space :unknown , key :unknown , value :unknown ) :void { 314append ({ t :"set" , ks :keyspaceName ( space ), key :encodeKey ( key ), value :encodeValue ( value ) }); 315}, 316delete ( space :unknown , key :unknown ) :void { 317append ({ t :"delete" , ks :keyspaceName ( space ), key :encodeKey ( key ) }); 318}, 319}; 320let result :unknown ; 321try { 322result = ( build as ( tx :KvAtomic ) => unknown )( tx ); 323} finally { 324active = false ; 325} 326if ( 327result !== null && 328( typeof result === "object" || typeof result === "function" ) 329) { 330let then :unknown ; 331try { 332then = reflectGet ( result , "then" ); 333} catch { 334then = undefined ; 335} 336if ( typeof then === "function" ) { 337throw new TypeError ( "kv atomic callback must be synchronous" ); 338} 339} 340const outcome = await atomicOp ([ state . id , ops ]); 341if ( ! outcome [ 0 ]) return { ok :false }; 342return { 343ok :true , 344versionstamp :outcome [ 1 ] === null ?null :BigInt ( outcome [ 1 ]), 345}; 346} 347 348close () :void { 349const id = markClosed ( this ); 350if ( id !== null ) promiseThen ( closeOp ( id ), noop , noop ); 351} 352 353async [ Symbol . asyncDispose ]() :Promise < void > { 354const id = markClosed ( this ); 355if ( id !== null ) await closeOp ( id ); 356} 357} 358 359class Keyspace implements KvKeyspace { 360constructor ( token :object , db :DbState , name :string ); 361constructor () { 362if ( arguments [ 0 ] !== secret ) throw new TypeError ( "Illegal constructor" ); 363setState ( keyspaceStates , this , { 364db :arguments [ 1 ] as DbState , 365name :arguments [ 2 ] as string , 366}); 367} 368 369get name () :string { 370return keyspaceStateOf ( this ). name ; 371} 372 373async entry ( key :unknown ) :Promise < KvEntry | null > { 374return ( await this . entries ([ key ]))[ 0 ] as KvEntry | null ; 375} 376 377async get ( key :unknown ) :Promise < Uint8Array | null > { 378const found = await this . entry ( key ); 379return found === null ?null :found . value ; 380} 381 382async getText ( key :unknown ) :Promise < string | null > { 383const found = await this . entry ( key ); 384return found === null ?null :decodeText ( found . value ); 385} 386 387async entries ( keys :unknown ) :Promise <( KvEntry | null )[]> { 388const state = openKeyspaceState ( this ); 389if ( ! isArray ( keys )) { 390throw new TypeError ( "kv getMany keys must be an array of keys" ); 391} 392const encoded :Uint8Array [] = []; 393for ( let index = 0 ; index < keys . length ; index ++ ) { 394encoded [ encoded . length ] = encodeKey ( keys [ index ]); 395} 396const rows = await getManyOp ([ state . db . id , state . name , encoded ]); 397const out :( KvEntry | null )[] = []; 398for ( let index = 0 ; index < rows . length ; index ++ ) { 399const row = rows [ index ] as [ Uint8Array , string ] | null ; 400out [ out . length ] = row === null 401 ?null 402 :{ 403keyspace :state . name , 404key :encoded [ index ] as Uint8Array , 405value :row [ 0 ], 406versionstamp :BigInt ( row [ 1 ]), 407}; 408} 409return out ; 410} 411 412async getMany ( keys :unknown ) :Promise <( Uint8Array | null )[]> { 413const rows = await this . entries ( keys ); 414const out :( Uint8Array | null )[] = []; 415for ( let index = 0 ; index < rows . length ; index ++ ) { 416const row = rows [ index ]; 417out [ out . length ] = row === null ?null :row . value ; 418} 419return out ; 420} 421 422async getTextMany ( keys :unknown ) :Promise <( string | null )[]> { 423const rows = await this . entries ( keys ); 424const out :( string | null )[] = []; 425for ( let index = 0 ; index < rows . length ; index ++ ) { 426const row = rows [ index ]; 427out [ out . length ] = row === null ?null :decodeText ( row . value ); 428} 429return out ; 430} 431 432async set ( key :unknown , value :unknown ) :Promise <{ versionstamp :KvVersionstamp }> { 433const state = openKeyspaceState ( this ); 434const stamp = await setOp ([ state . db . id , state . name , encodeKey ( key ), encodeValue ( value )]); 435return { versionstamp :BigInt ( stamp ) }; 436} 437 438async delete ( key :unknown ) :Promise < void > { 439const state = openKeyspaceState ( this ); 440await deleteOp ([ state . db . id , state . name , encodeKey ( key )]); 441} 442 443list ( options ?:unknown ) :AsyncIterable < KvEntry > { 444const state = openKeyspaceState ( this ); 445let prefix :Uint8Array | null = null ; 446let start :Uint8Array | null = null ; 447let end :Uint8Array | null = null ; 448let limit = 10000 ; 449let reverse = false ; 450if ( options !== undefined ) { 451if ( typeof options !== "object" || options === null ) { 452throw new TypeError ( "kv list options must be an object" ); 453} 454const explicit = options as KvListOptions ; 455if ( explicit . prefix !== undefined ) { 456prefix = encodeKey ( explicit . prefix ); 457} 458if ( explicit . start !== undefined ) { 459start = encodeKey ( explicit . start ); 460} 461if ( explicit . end !== undefined ) { 462end = encodeKey ( explicit . end ); 463} 464if ( explicit . limit !== undefined ) { 465if ( ! numberIsSafeInteger ( explicit . limit ) || ( explicit . limit as number ) <= 0 ) { 466throw new TypeError ( "kv list limit must be a positive safe integer" ); 467} 468limit = explicit . limit as number ; 469} 470if ( explicit . reverse !== undefined ) { 471if ( typeof explicit . reverse !== "boolean" ) { 472throw new TypeError ( "kv list reverse must be a boolean" ); 473} 474reverse = explicit . reverse ; 475} 476} 477const args = { 478db :state . db . id , 479keyspace :state . name , 480 start, 481 end, 482 prefix, 483 limit, 484 reverse, 485}; 486async function * generate () :AsyncGenerator < KvEntry > { 487// One snapshot call, fetched on first next(); breaking early just 488// stops yielding from the already materialized rows. 489const rows = await listOp ( args ); 490for ( let index = 0 ; index < rows . length ; index ++ ) { 491const row = rows [ index ] as [ Uint8Array , Uint8Array , string ]; 492yield { keyspace :state . name , key :row [ 0 ], value :row [ 1 ], versionstamp :BigInt ( row [ 2 ]) }; 493} 494} 495return generate (); 496} 497 498async clear () :Promise < void > { 499const state = openKeyspaceState ( this ); 500await clearOp ([ state . db . id , state . name ]); 501} 502} 503 504export async function openKv ( 505path :string | KvPathUrl , 506options ?:KvOpenOptions , 507) :Promise < KvDatabase > { 508let access = "shared" ; 509let create :boolean | undefined ; 510if ( options !== undefined ) { 511if ( typeof options !== "object" || options === null ) { 512throw new TypeError ( "kv open options must be an object" ); 513} 514const explicit = options as { access ?:unknown ; create ?:unknown }; 515if ( explicit . access !== undefined ) { 516if ( 517explicit . access !== "shared" && 518explicit . access !== "exclusive" && 519explicit . access !== "read-only" 520) { 521throw new TypeError ( 'kv access must be "shared", "exclusive" or "read-only"' ); 522} 523access = explicit . access ; 524} 525if ( explicit . create !== undefined ) { 526if ( typeof explicit . create !== "boolean" ) { 527throw new TypeError ( "kv create option must be a boolean" ); 528} 529create = explicit . create ; 530} 531} 532if ( create === undefined ) create = access !== "read-only" ; 533const id :number = await openOp ([ toPath ( path ), access , create ]); 534const database = new Database ( secret ); 535setState ( dbStates , database , { id, closed :false , keyspaces :new Map () }); 536finalizerRegister ( closers , database , id ); 537return database ; 538}