import { ArrayIsArray as isArray, Map, MapPrototypeGet as mapGet, MapPrototypeSet as mapSet, ObjectCreate, RangeError, ReflectGet as reflectGet, StringPrototypeIsWellFormed as isWellFormed, TypeError, TypedArrayPrototypeGetTag as typedArrayTag, Uint8Array as Uint8ArrayCtor, WeakMap, WeakMapPrototypeGet as getState, WeakMapPrototypeSet as setState, getter, uncurryThis, } from "ext:core/primordials"; import { promiseThen } from "ext:core/promises"; import { result } from "ext:file/errors.js"; import { URL } from "ext:url/main.js"; import { registerUncloneable } from "ext:web/serialization.js"; import { TextDecoder, TextEncoder } from "ext:encoding/main.js"; import { op_file_url_path as urlPathOp, op_kv_atomic as atomicOp, op_kv_clear as clearOp, op_kv_close as closeOp, op_kv_delete as deleteOp, op_kv_get_many as getManyOp, op_kv_list as listOp, op_kv_list_keyspaces as keyspacesOp, op_kv_open as openOp, op_kv_set as setOp, } from "ext:core/ops"; /** * A key is an arbitrary byte string. Strings are their UTF-8 encoding; * `Uint8Array`s are copied as-is, so `"abc"` and `new Uint8Array([97, 98, 99])` * name the very same key. The empty string and the empty byte string are both * valid keys: they sort before every other key. */ export type KvKey = string | Uint8Array; /** * A value is an arbitrary byte string. Strings are their UTF-8 encoding; * `Uint8Array`s are copied as-is. Richer values are userland's job: serialize * to bytes first (JSON text, CBOR via `jsr:@char/cbor`, …), store the bytes. */ export type KvValue = string | Uint8Array; /** * The database's commit sequence number of the commit that last wrote an * entry. Equality is the intended use; ordering is also meaningful, but only * between stamps of the same database. */ export type KvVersionstamp = bigint; export interface KvEntry { readonly keyspace: string; readonly key: K; readonly value: V; readonly versionstamp: KvVersionstamp; } export type KvAccess = "shared" | "exclusive" | "read-only"; export interface KvOpenOptions { readonly access?: KvAccess; readonly create?: boolean; } /** Structural subset of URL accepted wherever a file URL is allowed. */ export interface KvPathUrl { readonly href: string; } export interface KvListOptions { readonly prefix?: KvKey; readonly start?: KvKey; readonly end?: KvKey; readonly limit?: number; readonly reverse?: boolean; } export interface KvAtomic { check(keyspace: KvKeyspace, key: KvKey, expected: KvVersionstamp | null): void; set(keyspace: KvKeyspace, key: KvKey, value: KvValue): void; delete(keyspace: KvKeyspace, key: KvKey): void; } export type KvAtomicResult = | { readonly ok: true; readonly versionstamp: KvVersionstamp | null } | { readonly ok: false }; export interface KvKeyspace { readonly name: string; get(key: KvKey): Promise; getText(key: KvKey): Promise; getMany(keys: readonly KvKey[]): Promise<(Uint8Array | null)[]>; getTextMany(keys: readonly KvKey[]): Promise<(string | null)[]>; entry(key: KvKey): Promise; entries(keys: readonly KvKey[]): Promise<(KvEntry | null)[]>; set(key: KvKey, value: KvValue): Promise<{ readonly versionstamp: KvVersionstamp }>; delete(key: KvKey): Promise; list(options?: KvListOptions): AsyncIterable; clear(): Promise; } export interface KvDatabase { keyspace(name: string): KvKeyspace; keyspaces(): Promise; atomic(build: (tx: KvAtomic) => void): Promise; close(): void; [Symbol.asyncDispose](): Promise; } interface DbState { id: number; closed: boolean; keyspaces: Map; } interface KeyspaceState { db: DbState; name: string; } const dbStates = new WeakMap(); const keyspaceStates = new WeakMap(); registerUncloneable(dbStates); registerUncloneable(keyspaceStates); const secret: object = ObjectCreate(null); const urlHref = getter(URL.prototype, "href"); const FinalizationRegistryCtor = globalThis.FinalizationRegistry; const finalizerRegister = uncurryThis(FinalizationRegistryCtor.prototype.register); const finalizerUnregister = uncurryThis(FinalizationRegistryCtor.prototype.unregister); const numberIsSafeInteger = globalThis.Number.isSafeInteger; const BigInt = globalThis.BigInt; const textEncoder = new TextEncoder(); const encodeUtf8 = uncurryThis(TextEncoder.prototype.encode); const textDecoder = new TextDecoder("utf-8", { fatal: true }); const decodeUtf8 = uncurryThis(TextDecoder.prototype.decode); const maxValueBytes = 4 * 1024 * 1024; // Mirrors avril_kv::{MAX_BATCH_OPS, MAX_BATCH_BYTES}, so a RangeError comes // from the offending tx call rather than from the commit. const maxBatchOps = 1000; const maxBatchBytes = 4 * 1024 * 1024; type AtomicOp = | { t: "check"; ks: string; key: Uint8Array; expected: string | null } | { t: "set"; ks: string; key: Uint8Array; value: Uint8Array } | { t: "delete"; ks: string; key: Uint8Array }; function noop(): void {} const closers = new FinalizationRegistryCtor((id: number) => { promiseThen(closeOp(id), noop, noop); }); function dbStateOf(database: Database): DbState { const state = getState(dbStates, database); if (state === undefined) throw new TypeError("Illegal invocation"); return state; } function openDbState(database: Database): DbState { const state = dbStateOf(database); if (state.closed) throw new TypeError("kv database is closed"); return state; } function keyspaceStateOf(space: Keyspace): KeyspaceState { const state = getState(keyspaceStates, space); if (state === undefined) throw new TypeError("Illegal invocation"); return state; } // Marks the database closed and returns its id, or null if already closed. function markClosed(database: Database): number | null { const state = dbStateOf(database); if (state.closed) return null; state.closed = true; finalizerUnregister(closers, database); return state.id; } function openKeyspaceState(space: Keyspace): KeyspaceState { const state = keyspaceStateOf(space); if (state.db.closed) throw new TypeError("kv database is closed"); return state; } function toPath(value: unknown): string { if (typeof value === "string") return value; let href: unknown; try { href = urlHref(value); } catch { throw new TypeError("Expected a path string or file URL"); } return result(urlPathOp(href as string)); } // A brand check: genuine Uint8Arrays (subclasses included), never objects // that merely inherit from Uint8Array.prototype. function isUint8Array(value: unknown): value is Uint8Array { return typedArrayTag(value) === "Uint8Array"; } // Canonical key bytes: UTF-8 for strings (ill-formed rejected), a copy of // the view's bytes for Uint8Arrays (so subclasses work and offsets are // honored). The empty string and empty bytes are valid keys. function encodeKey(value: unknown): Uint8Array { if (typeof value === "string") { if (!isWellFormed(value)) throw new TypeError("kv keys must be well-formed strings"); return encodeUtf8(textEncoder, value); } if (isUint8Array(value)) return new Uint8ArrayCtor(value); throw new TypeError("kv keys must be strings or Uint8Arrays"); } function encodeValue(value: unknown): Uint8Array { let bytes: Uint8Array; if (typeof value === "string") { if (!isWellFormed(value)) throw new TypeError("kv values must be well-formed strings"); bytes = encodeUtf8(textEncoder, value); } else if (isUint8Array(value)) { bytes = new Uint8ArrayCtor(value); } else { throw new TypeError("kv values must be strings or Uint8Arrays"); } if (bytes.length > maxValueBytes) { throw new RangeError("kv value exceeds the size limit"); } return bytes; } function decodeText(value: Uint8Array): string { try { return decodeUtf8(textDecoder, value); } catch { throw new TypeError("kv value is not valid UTF-8"); } } class Database implements KvDatabase { constructor(token: object); constructor() { if (arguments[0] !== secret) throw new TypeError("Illegal constructor"); } keyspace(name: unknown): Keyspace { const state = openDbState(this); if (typeof name !== "string" || name.length === 0) { throw new TypeError("kv keyspace names must be non-empty strings"); } if (!isWellFormed(name)) { throw new TypeError("kv keyspace names must be well-formed strings"); } const known = mapGet(state.keyspaces, name); if (known !== undefined) return known; const space = new Keyspace(secret, state, name); mapSet(state.keyspaces, name, space); return space; } async keyspaces(): Promise { return keyspacesOp(openDbState(this).id); } async atomic(build: unknown): Promise { const state = openDbState(this); if (typeof build !== "function") { throw new TypeError("kv atomic callback must be a function"); } const ops: AtomicOp[] = []; let bytes = 0; let active = true; const append = (op: AtomicOp): void => { const size = encodeUtf8(textEncoder, op.ks).length + op.key.length + (op.t === "set" ? op.value.length : 0); if (ops.length >= maxBatchOps) { throw new RangeError("kv atomic batch exceeds the operation limit"); } if (bytes + size > maxBatchBytes) { throw new RangeError("kv atomic batch exceeds the size limit"); } bytes += size; ops[ops.length] = op; }; const keyspaceName = (space: unknown): string => { if (!active) throw new TypeError("kv atomic transaction is no longer active"); const found = getState(keyspaceStates, space as object); if (found === undefined || found.db !== state) { throw new TypeError("kv atomic keyspace must belong to this database"); } return found.name; }; const tx: KvAtomic = { check(space: unknown, key: unknown, expected: unknown): void { const ks = keyspaceName(space); if (expected !== null && typeof expected !== "bigint") { throw new TypeError("kv check expected must be a versionstamp or null"); } // Template conversion of a bigint primitive never consults its prototype. const stamp = expected === null ? null : `${expected}`; append({ t: "check", ks, key: encodeKey(key), expected: stamp }); }, set(space: unknown, key: unknown, value: unknown): void { append({ t: "set", ks: keyspaceName(space), key: encodeKey(key), value: encodeValue(value) }); }, delete(space: unknown, key: unknown): void { append({ t: "delete", ks: keyspaceName(space), key: encodeKey(key) }); }, }; let result: unknown; try { result = (build as (tx: KvAtomic) => unknown)(tx); } finally { active = false; } if ( result !== null && (typeof result === "object" || typeof result === "function") ) { let then: unknown; try { then = reflectGet(result, "then"); } catch { then = undefined; } if (typeof then === "function") { throw new TypeError("kv atomic callback must be synchronous"); } } const outcome = await atomicOp([state.id, ops]); if (!outcome[0]) return { ok: false }; return { ok: true, versionstamp: outcome[1] === null ? null : BigInt(outcome[1]), }; } close(): void { const id = markClosed(this); if (id !== null) promiseThen(closeOp(id), noop, noop); } async [Symbol.asyncDispose](): Promise { const id = markClosed(this); if (id !== null) await closeOp(id); } } class Keyspace implements KvKeyspace { constructor(token: object, db: DbState, name: string); constructor() { if (arguments[0] !== secret) throw new TypeError("Illegal constructor"); setState(keyspaceStates, this, { db: arguments[1] as DbState, name: arguments[2] as string, }); } get name(): string { return keyspaceStateOf(this).name; } async entry(key: unknown): Promise { return (await this.entries([key]))[0] as KvEntry | null; } async get(key: unknown): Promise { const found = await this.entry(key); return found === null ? null : found.value; } async getText(key: unknown): Promise { const found = await this.entry(key); return found === null ? null : decodeText(found.value); } async entries(keys: unknown): Promise<(KvEntry | null)[]> { const state = openKeyspaceState(this); if (!isArray(keys)) { throw new TypeError("kv getMany keys must be an array of keys"); } const encoded: Uint8Array[] = []; for (let index = 0; index < keys.length; index++) { encoded[encoded.length] = encodeKey(keys[index]); } const rows = await getManyOp([state.db.id, state.name, encoded]); const out: (KvEntry | null)[] = []; for (let index = 0; index < rows.length; index++) { const row = rows[index] as [Uint8Array, string] | null; out[out.length] = row === null ? null : { keyspace: state.name, key: encoded[index] as Uint8Array, value: row[0], versionstamp: BigInt(row[1]), }; } return out; } async getMany(keys: unknown): Promise<(Uint8Array | null)[]> { const rows = await this.entries(keys); const out: (Uint8Array | null)[] = []; for (let index = 0; index < rows.length; index++) { const row = rows[index]; out[out.length] = row === null ? null : row.value; } return out; } async getTextMany(keys: unknown): Promise<(string | null)[]> { const rows = await this.entries(keys); const out: (string | null)[] = []; for (let index = 0; index < rows.length; index++) { const row = rows[index]; out[out.length] = row === null ? null : decodeText(row.value); } return out; } async set(key: unknown, value: unknown): Promise<{ versionstamp: KvVersionstamp }> { const state = openKeyspaceState(this); const stamp = await setOp([state.db.id, state.name, encodeKey(key), encodeValue(value)]); return { versionstamp: BigInt(stamp) }; } async delete(key: unknown): Promise { const state = openKeyspaceState(this); await deleteOp([state.db.id, state.name, encodeKey(key)]); } list(options?: unknown): AsyncIterable { const state = openKeyspaceState(this); let prefix: Uint8Array | null = null; let start: Uint8Array | null = null; let end: Uint8Array | null = null; let limit = 10000; let reverse = false; if (options !== undefined) { if (typeof options !== "object" || options === null) { throw new TypeError("kv list options must be an object"); } const explicit = options as KvListOptions; if (explicit.prefix !== undefined) { prefix = encodeKey(explicit.prefix); } if (explicit.start !== undefined) { start = encodeKey(explicit.start); } if (explicit.end !== undefined) { end = encodeKey(explicit.end); } if (explicit.limit !== undefined) { if (!numberIsSafeInteger(explicit.limit) || (explicit.limit as number) <= 0) { throw new TypeError("kv list limit must be a positive safe integer"); } limit = explicit.limit as number; } if (explicit.reverse !== undefined) { if (typeof explicit.reverse !== "boolean") { throw new TypeError("kv list reverse must be a boolean"); } reverse = explicit.reverse; } } const args = { db: state.db.id, keyspace: state.name, start, end, prefix, limit, reverse, }; async function* generate(): AsyncGenerator { // One snapshot call, fetched on first next(); breaking early just // stops yielding from the already materialized rows. const rows = await listOp(args); for (let index = 0; index < rows.length; index++) { const row = rows[index] as [Uint8Array, Uint8Array, string]; yield { keyspace: state.name, key: row[0], value: row[1], versionstamp: BigInt(row[2]) }; } } return generate(); } async clear(): Promise { const state = openKeyspaceState(this); await clearOp([state.db.id, state.name]); } } export async function openKv( path: string | KvPathUrl, options?: KvOpenOptions, ): Promise { let access = "shared"; let create: boolean | undefined; if (options !== undefined) { if (typeof options !== "object" || options === null) { throw new TypeError("kv open options must be an object"); } const explicit = options as { access?: unknown; create?: unknown }; if (explicit.access !== undefined) { if ( explicit.access !== "shared" && explicit.access !== "exclusive" && explicit.access !== "read-only" ) { throw new TypeError('kv access must be "shared", "exclusive" or "read-only"'); } access = explicit.access; } if (explicit.create !== undefined) { if (typeof explicit.create !== "boolean") { throw new TypeError("kv create option must be a boolean"); } create = explicit.create; } } if (create === undefined) create = access !== "read-only"; const id: number = await openOp([toPath(path), access, create]); const database = new Database(secret); setState(dbStates, database, { id, closed: false, keyspaces: new Map() }); finalizerRegister(closers, database, id); return database; }