char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somgenerate kv typedefs from typescript imple7b3c51

main
17.4 KiB538 linesraw
1import {
2  ArrayIsArray as isArray,
3  Map,
4  MapPrototypeGet as mapGet,
5  MapPrototypeSet as mapSet,
6  ObjectCreate,
7  RangeError,
8  ReflectGet as reflectGet,
9  StringPrototypeIsWellFormed as isWellFormed,
10  TypeError,
11  TypedArrayPrototypeGetTag as typedArrayTag,
12  Uint8Array as Uint8ArrayCtor,
13  WeakMap,
14  WeakMapPrototypeGet as getState,
15  WeakMapPrototypeSet as setState,
16  getter,
17  uncurryThis,
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 {
25  op_file_url_path as urlPathOp,
26  op_kv_atomic as atomicOp,
27  op_kv_clear as clearOp,
28  op_kv_close as closeOp,
29  op_kv_delete as deleteOp,
30  op_kv_get_many as getManyOp,
31  op_kv_list as listOp,
32  op_kv_list_keyspaces as keyspacesOp,
33  op_kv_open as openOp,
34  op_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> {
60  readonly keyspace: string;
61  readonly key: K;
62  readonly value: V;
63  readonly versionstamp: KvVersionstamp;
64}
65
66export type KvAccess = "shared" | "exclusive" | "read-only";
67
68export interface KvOpenOptions {
69  readonly access?: KvAccess;
70  readonly create?: boolean;
71}
72
73/** Structural subset of URL accepted wherever a file URL is allowed. */
74export interface KvPathUrl {
75  readonly href: string;
76}
77
78export interface KvListOptions {
79  readonly prefix?: KvKey;
80  readonly start?: KvKey;
81  readonly end?: KvKey;
82  readonly limit?: number;
83  readonly reverse?: boolean;
84}
85
86export interface KvAtomic {
87  check(keyspace: KvKeyspace, key: KvKey, expected: KvVersionstamp | null): void;
88  set(keyspace: KvKeyspace, key: KvKey, value: KvValue): void;
89  delete(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 {
97  readonly name: string;
98  get(key: KvKey): Promise<Uint8Array | null>;
99  getText(key: KvKey): Promise<string | null>;
100  getMany(keys: readonly KvKey[]): Promise<(Uint8Array | null)[]>;
101  getTextMany(keys: readonly KvKey[]): Promise<(string | null)[]>;
102  entry(key: KvKey): Promise<KvEntry | null>;
103  entries(keys: readonly KvKey[]): Promise<(KvEntry | null)[]>;
104  set(key: KvKey, value: KvValue): Promise<{ readonly versionstamp: KvVersionstamp }>;
105  delete(key: KvKey): Promise<void>;
106  list(options?: KvListOptions): AsyncIterable<KvEntry>;
107  clear(): Promise<void>;
108}
109
110export interface KvDatabase {
111  keyspace(name: string): KvKeyspace;
112  keyspaces(): Promise<string[]>;
113  atomic(build: (tx: KvAtomic) => void): Promise<KvAtomicResult>;
114  close(): void;
115  [Symbol.asyncDispose](): Promise<void>;
116}
117
118interface DbState {
119  id: number;
120  closed: boolean;
121  keyspaces: Map<string, Keyspace>;
122}
123
124interface KeyspaceState {
125  db: DbState;
126  name: 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) => {
161  promiseThen(closeOp(id), noop, noop);
162});
163
164function dbStateOf(database: Database): DbState {
165  const state = getState(dbStates, database);
166  if (state === undefined) throw new TypeError("Illegal invocation");
167  return state;
168}
169
170function openDbState(database: Database): DbState {
171  const state = dbStateOf(database);
172  if (state.closed) throw new TypeError("kv database is closed");
173  return state;
174}
175
176function keyspaceStateOf(space: Keyspace): KeyspaceState {
177  const state = getState(keyspaceStates, space);
178  if (state === undefined) throw new TypeError("Illegal invocation");
179  return state;
180}
181
182// Marks the database closed and returns its id, or null if already closed.
183function markClosed(database: Database): number | null {
184  const state = dbStateOf(database);
185  if (state.closed) return null;
186  state.closed = true;
187  finalizerUnregister(closers, database);
188  return state.id;
189}
190
191function openKeyspaceState(space: Keyspace): KeyspaceState {
192  const state = keyspaceStateOf(space);
193  if (state.db.closed) throw new TypeError("kv database is closed");
194  return state;
195}
196
197function toPath(value: unknown): string {
198  if (typeof value === "string") return value;
199  let href: unknown;
200  try {
201    href = urlHref(value);
202  } catch {
203    throw new TypeError("Expected a path string or file URL");
204  }
205  return 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 is Uint8Array {
211  return 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 {
218  if (typeof value === "string") {
219    if (!isWellFormed(value)) throw new TypeError("kv keys must be well-formed strings");
220    return encodeUtf8(textEncoder, value);
221  }
222  if (isUint8Array(value)) return new Uint8ArrayCtor(value);
223  throw new TypeError("kv keys must be strings or Uint8Arrays");
224}
225
226function encodeValue(value: unknown): Uint8Array {
227  let bytes: Uint8Array;
228  if (typeof value === "string") {
229    if (!isWellFormed(value)) throw new TypeError("kv values must be well-formed strings");
230    bytes = encodeUtf8(textEncoder, value);
231  } else if (isUint8Array(value)) {
232    bytes = new Uint8ArrayCtor(value);
233  } else {
234    throw new TypeError("kv values must be strings or Uint8Arrays");
235  }
236  if (bytes.length > maxValueBytes) {
237    throw new RangeError("kv value exceeds the size limit");
238  }
239  return bytes;
240}
241
242function decodeText(value: Uint8Array): string {
243  try {
244    return decodeUtf8(textDecoder, value);
245  } catch {
246    throw new TypeError("kv value is not valid UTF-8");
247  }
248}
249
250class Database implements KvDatabase {
251  constructor(token: object);
252  constructor() {
253    if (arguments[0] !== secret) throw new TypeError("Illegal constructor");
254  }
255
256  keyspace(name: unknown): Keyspace {
257    const state = openDbState(this);
258    if (typeof name !== "string" || name.length === 0) {
259      throw new TypeError("kv keyspace names must be non-empty strings");
260    }
261    if (!isWellFormed(name)) {
262      throw new TypeError("kv keyspace names must be well-formed strings");
263    }
264    const known = mapGet(state.keyspaces, name);
265    if (known !== undefined) return known;
266    const space = new Keyspace(secret, state, name);
267    mapSet(state.keyspaces, name, space);
268    return space;
269  }
270
271  async keyspaces(): Promise<string[]> {
272    return keyspacesOp(openDbState(this).id);
273  }
274
275  async atomic(build: unknown): Promise<KvAtomicResult> {
276    const state = openDbState(this);
277    if (typeof build !== "function") {
278      throw new TypeError("kv atomic callback must be a function");
279    }
280    const ops: AtomicOp[] = [];
281    let bytes = 0;
282    let active = true;
283    const append = (op: AtomicOp): void => {
284      const size = encodeUtf8(textEncoder, op.ks).length + op.key.length +
285        (op.t === "set" ? op.value.length : 0);
286      if (ops.length >= maxBatchOps) {
287        throw new RangeError("kv atomic batch exceeds the operation limit");
288      }
289      if (bytes + size > maxBatchBytes) {
290        throw new RangeError("kv atomic batch exceeds the size limit");
291      }
292      bytes += size;
293      ops[ops.length] = op;
294    };
295    const keyspaceName = (space: unknown): string => {
296      if (!active) throw new TypeError("kv atomic transaction is no longer active");
297      const found = getState(keyspaceStates, space as object);
298      if (found === undefined || found.db !== state) {
299        throw new TypeError("kv atomic keyspace must belong to this database");
300      }
301      return found.name;
302    };
303    const tx: KvAtomic = {
304      check(space: unknown, key: unknown, expected: unknown): void {
305        const ks = keyspaceName(space);
306        if (expected !== null && typeof expected !== "bigint") {
307          throw new TypeError("kv check expected must be a versionstamp or null");
308        }
309        // Template conversion of a bigint primitive never consults its prototype.
310        const stamp = expected === null ? null : `${expected}`;
311        append({ t: "check", ks, key: encodeKey(key), expected: stamp });
312      },
313      set(space: unknown, key: unknown, value: unknown): void {
314        append({ t: "set", ks: keyspaceName(space), key: encodeKey(key), value: encodeValue(value) });
315      },
316      delete(space: unknown, key: unknown): void {
317        append({ t: "delete", ks: keyspaceName(space), key: encodeKey(key) });
318      },
319    };
320    let result: unknown;
321    try {
322      result = (build as (tx: KvAtomic) => unknown)(tx);
323    } finally {
324      active = false;
325    }
326    if (
327      result !== null &&
328      (typeof result === "object" || typeof result === "function")
329    ) {
330      let then: unknown;
331      try {
332        then = reflectGet(result, "then");
333      } catch {
334        then = undefined;
335      }
336      if (typeof then === "function") {
337        throw new TypeError("kv atomic callback must be synchronous");
338      }
339    }
340    const outcome = await atomicOp([state.id, ops]);
341    if (!outcome[0]) return { ok: false };
342    return {
343      ok: true,
344      versionstamp: outcome[1] === null ? null : BigInt(outcome[1]),
345    };
346  }
347
348  close(): void {
349    const id = markClosed(this);
350    if (id !== null) promiseThen(closeOp(id), noop, noop);
351  }
352
353  async [Symbol.asyncDispose](): Promise<void> {
354    const id = markClosed(this);
355    if (id !== null) await closeOp(id);
356  }
357}
358
359class Keyspace implements KvKeyspace {
360  constructor(token: object, db: DbState, name: string);
361  constructor() {
362    if (arguments[0] !== secret) throw new TypeError("Illegal constructor");
363    setState(keyspaceStates, this, {
364      db: arguments[1] as DbState,
365      name: arguments[2] as string,
366    });
367  }
368
369  get name(): string {
370    return keyspaceStateOf(this).name;
371  }
372
373  async entry(key: unknown): Promise<KvEntry | null> {
374    return (await this.entries([key]))[0] as KvEntry | null;
375  }
376
377  async get(key: unknown): Promise<Uint8Array | null> {
378    const found = await this.entry(key);
379    return found === null ? null : found.value;
380  }
381
382  async getText(key: unknown): Promise<string | null> {
383    const found = await this.entry(key);
384    return found === null ? null : decodeText(found.value);
385  }
386
387  async entries(keys: unknown): Promise<(KvEntry | null)[]> {
388    const state = openKeyspaceState(this);
389    if (!isArray(keys)) {
390      throw new TypeError("kv getMany keys must be an array of keys");
391    }
392    const encoded: Uint8Array[] = [];
393    for (let index = 0; index < keys.length; index++) {
394      encoded[encoded.length] = encodeKey(keys[index]);
395    }
396    const rows = await getManyOp([state.db.id, state.name, encoded]);
397    const out: (KvEntry | null)[] = [];
398    for (let index = 0; index < rows.length; index++) {
399      const row = rows[index] as [Uint8Array, string] | null;
400      out[out.length] = row === null
401        ? null
402        : {
403          keyspace: state.name,
404          key: encoded[index] as Uint8Array,
405          value: row[0],
406          versionstamp: BigInt(row[1]),
407        };
408    }
409    return out;
410  }
411
412  async getMany(keys: unknown): Promise<(Uint8Array | null)[]> {
413    const rows = await this.entries(keys);
414    const out: (Uint8Array | null)[] = [];
415    for (let index = 0; index < rows.length; index++) {
416      const row = rows[index];
417      out[out.length] = row === null ? null : row.value;
418    }
419    return out;
420  }
421
422  async getTextMany(keys: unknown): Promise<(string | null)[]> {
423    const rows = await this.entries(keys);
424    const out: (string | null)[] = [];
425    for (let index = 0; index < rows.length; index++) {
426      const row = rows[index];
427      out[out.length] = row === null ? null : decodeText(row.value);
428    }
429    return out;
430  }
431
432  async set(key: unknown, value: unknown): Promise<{ versionstamp: KvVersionstamp }> {
433    const state = openKeyspaceState(this);
434    const stamp = await setOp([state.db.id, state.name, encodeKey(key), encodeValue(value)]);
435    return { versionstamp: BigInt(stamp) };
436  }
437
438  async delete(key: unknown): Promise<void> {
439    const state = openKeyspaceState(this);
440    await deleteOp([state.db.id, state.name, encodeKey(key)]);
441  }
442
443  list(options?: unknown): AsyncIterable<KvEntry> {
444    const state = openKeyspaceState(this);
445    let prefix: Uint8Array | null = null;
446    let start: Uint8Array | null = null;
447    let end: Uint8Array | null = null;
448    let limit = 10000;
449    let reverse = false;
450    if (options !== undefined) {
451      if (typeof options !== "object" || options === null) {
452        throw new TypeError("kv list options must be an object");
453      }
454      const explicit = options as KvListOptions;
455      if (explicit.prefix !== undefined) {
456        prefix = encodeKey(explicit.prefix);
457      }
458      if (explicit.start !== undefined) {
459        start = encodeKey(explicit.start);
460      }
461      if (explicit.end !== undefined) {
462        end = encodeKey(explicit.end);
463      }
464      if (explicit.limit !== undefined) {
465        if (!numberIsSafeInteger(explicit.limit) || (explicit.limit as number) <= 0) {
466          throw new TypeError("kv list limit must be a positive safe integer");
467        }
468        limit = explicit.limit as number;
469      }
470      if (explicit.reverse !== undefined) {
471        if (typeof explicit.reverse !== "boolean") {
472          throw new TypeError("kv list reverse must be a boolean");
473        }
474        reverse = explicit.reverse;
475      }
476    }
477    const args = {
478      db: state.db.id,
479      keyspace: state.name,
480      start,
481      end,
482      prefix,
483      limit,
484      reverse,
485    };
486    async function* generate(): AsyncGenerator<KvEntry> {
487      // One snapshot call, fetched on first next(); breaking early just
488      // stops yielding from the already materialized rows.
489      const rows = await listOp(args);
490      for (let index = 0; index < rows.length; index++) {
491        const row = rows[index] as [Uint8Array, Uint8Array, string];
492        yield { keyspace: state.name, key: row[0], value: row[1], versionstamp: BigInt(row[2]) };
493      }
494    }
495    return generate();
496  }
497
498  async clear(): Promise<void> {
499    const state = openKeyspaceState(this);
500    await clearOp([state.db.id, state.name]);
501  }
502}
503
504export async function openKv(
505  path: string | KvPathUrl,
506  options?: KvOpenOptions,
507): Promise<KvDatabase> {
508  let access = "shared";
509  let create: boolean | undefined;
510  if (options !== undefined) {
511    if (typeof options !== "object" || options === null) {
512      throw new TypeError("kv open options must be an object");
513    }
514    const explicit = options as { access?: unknown; create?: unknown };
515    if (explicit.access !== undefined) {
516      if (
517        explicit.access !== "shared" &&
518        explicit.access !== "exclusive" &&
519        explicit.access !== "read-only"
520      ) {
521        throw new TypeError('kv access must be "shared", "exclusive" or "read-only"');
522      }
523      access = explicit.access;
524    }
525    if (explicit.create !== undefined) {
526      if (typeof explicit.create !== "boolean") {
527        throw new TypeError("kv create option must be a boolean");
528      }
529      create = explicit.create;
530    }
531  }
532  if (create === undefined) create = access !== "read-only";
533  const id: number = await openOp([toPath(path), access, create]);
534  const database = new Database(secret);
535  setState(dbStates, database, { id, closed: false, keyspaces: new Map() });
536  finalizerRegister(closers, database, id);
537  return database;
538}