import { log, ulid } from "./deps.ts"; const logger = () => log.getLogger("kvStore"); export type validIndexKey = { [K in keyof T]: K extends string ? (T[K] extends Deno.KvKeyPart ? K : T[K] extends readonly unknown[] ? never : T[K] extends object ? `${K}.${validIndexKey}` : never) : never; }[keyof T]; export type indexValue> = I extends `${infer K}.${infer Rest}` ? K extends keyof T ? Rest extends validIndexKey ? indexValue : never : never : I extends keyof T ? T[I] : never; export class Schema {} interface StoreOptions { readonly schema: Schema; readonly indices: readonly I[]; } export class Store> { readonly #db: Deno.Kv; readonly #key: Deno.KvKeyPart; readonly #indices: readonly I[]; constructor(db: Deno.Kv, key: Deno.KvKeyPart, options: StoreOptions) { this.#db = db; this.#key = key; this.#indices = options.indices; } async create(value: T): Promise> { const id = ulid(); await this.#db.set([this.#key, "id", id], value); logger().debug(["created", this.#key, "id", id].join(" ")); for (const index of this.#indices) { const indexValue: Deno.KvKeyPart = index .split(".") .reduce((value, key) => value[key], value as any); await this.#db.set([this.#key, index, indexValue, id], value); logger().debug(["created", this.#key, index, indexValue, id].join(" ")); } return new Model(this.#db, this.#key, this.#indices, id, value); } async get(id: Deno.KvKeyPart): Promise | null> { const entry = await this.#db.get([this.#key, "id", id]); if (entry.versionstamp == null) return null; return new Model(this.#db, this.#key, this.#indices, id, entry.value); } async getBy( index: J, value: indexValue, options?: Deno.KvListOptions, ): Promise>> { const models: Model[] = []; for await ( const entry of this.#db.list( { prefix: [this.#key, index, value as Deno.KvKeyPart] }, options, ) ) { models.push(new Model(this.#db, this.#key, this.#indices, entry.key[3], entry.value)); } return models; } async getAll( opts?: { limit?: number; reverse?: boolean }, ): Promise>> { const { limit, reverse } = opts ?? {}; const models: Array> = []; for await ( const entry of this.#db.list({ prefix: [this.#key, "id"], }, { limit, reverse }) ) { models.push(new Model(this.#db, this.#key, this.#indices, entry.key[2], entry.value)); } return models; } async deleteAll(): Promise { for await (const entry of this.#db.list({ prefix: [this.#key] })) { await this.#db.delete(entry.key); logger().debug(["deleted", ...entry.key].join(" ")); } } } export class Model { readonly #db: Deno.Kv; readonly #key: Deno.KvKeyPart; readonly #indices: readonly string[]; readonly #id: Deno.KvKeyPart; value: T; constructor( db: Deno.Kv, key: Deno.KvKeyPart, indices: readonly string[], id: Deno.KvKeyPart, value: T, ) { this.#db = db; this.#key = key; this.#indices = indices; this.#id = id; this.value = value; } get id(): Deno.KvKeyPart { return this.#id; } async update(updater: Partial | ((value: T) => T)): Promise { // get current main entry const oldEntry = await this.#db.get([this.#key, "id", this.#id]); // get all current index entries const oldIndexEntries: Record> = {}; for (const index of this.#indices) { const indexKey: Deno.KvKeyPart = index .split(".") .reduce((value, key) => value[key], oldEntry.value as any); oldIndexEntries[index] = await this.#db.get([this.#key, index, indexKey, this.#id]); } // compute new value if (typeof updater === "function") { this.value = updater(this.value); } else { this.value = { ...this.value, ...updater }; } // begin transaction const transaction = this.#db.atomic(); // set the main entry transaction .check(oldEntry) .set([this.#key, "id", this.#id], this.value); logger().debug(["updated", this.#key, "id", this.#id].join(" ")); // delete and create all changed index entries for (const index of this.#indices) { const oldIndexKey: Deno.KvKeyPart = index .split(".") .reduce((value, key) => value[key], oldIndexEntries[index].value as any); const newIndexKey: Deno.KvKeyPart = index .split(".") .reduce((value, key) => value[key], this.value as any); if (newIndexKey !== oldIndexKey) { transaction .check(oldIndexEntries[index]) .delete([this.#key, index, oldIndexKey, this.#id]) .set([this.#key, index, newIndexKey, this.#id], this.value); logger().debug(["deleted", this.#key, index, oldIndexKey, this.#id].join(" ")); logger().debug(["created", this.#key, index, newIndexKey, this.#id].join(" ")); } } // commit const result = await transaction.commit(); if (!result.ok) throw new Error(`Failed to update ${this.#key} ${this.#id}`); return this.value; } async delete(): Promise { // get current main entry const entry = await this.#db.get([this.#key, "id", this.#id]); // begin transaction const transaction = this.#db.atomic(); // delete main entry transaction .check(entry) .delete([this.#key, "id", this.#id]); logger().debug(["deleted", this.#key, "id", this.#id].join(" ")); // delete all index entries for (const index of this.#indices) { const indexKey: Deno.KvKeyPart = index .split(".") .reduce((value, key) => value[key], entry.value as any); transaction .delete([this.#key, index, indexKey, this.#id]); logger().debug(["deleted", this.#key, index, indexKey, this.#id].join(" ")); } // commit const result = await transaction.commit(); if (!result.ok) throw new Error(`Failed to delete ${this.#key} ${this.#id}`); } } export async function retry( fn: () => Promise, options: { maxAttempts?: number; delayMs?: number } = {}, ): Promise { const { maxAttempts = 3, delayMs = 1000 } = options; let error: unknown; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { return await fn(); } catch (err) { error = err; await new Promise((resolve) => setTimeout(resolve, delayMs)); } } throw error; } export async function collectIterator( iterator: AsyncIterableIterator, options: { maxItems?: number; timeoutMs?: number } = {}, ): Promise { const { maxItems = 1000, timeoutMs = 2000 } = options; const result: T[] = []; const timeout = setTimeout(() => iterator.return?.(), timeoutMs); try { for await (const item of iterator) { result.push(item); if (result.length >= maxItems) { iterator.return?.(); break; } } } finally { clearTimeout(timeout); } return result; }