import { sql, type Kysely, type SqlBool } from "kysely"; import type { Database, OptionTable } from "../types.js"; function escapeLike(value: string): string { return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_"); } /** * Options repository for key-value settings storage * * Used for site settings, plugin configuration, and other arbitrary key-value data. * Values are stored as JSON for flexibility. */ export class OptionsRepository { constructor(private db: Kysely) {} /** * Get an option value */ async get(name: string): Promise { const row = await this.db .selectFrom("options") .select("value") .where("name", "=", name) .executeTakeFirst(); if (!row) return null; // eslint-disable-next-line typescript/no-unsafe-type-assertion -- JSON.parse returns any; generic callers provide T return JSON.parse(row.value) as T; } /** * Get an option value with a default */ async getOrDefault(name: string, defaultValue: T): Promise { const value = await this.get(name); return value ?? defaultValue; } /** * Set an option value (creates or updates) */ async set(name: string, value: T): Promise { const row: OptionTable = { name, value: JSON.stringify(value), }; // Upsert: insert or replace await this.db .insertInto("options") .values(row) .onConflict((oc) => oc.column("name").doUpdateSet({ value: row.value })) .execute(); } /** * Set an option value only if no row with that name exists. Atomic at the * database level via INSERT ... ON CONFLICT DO NOTHING, so concurrent * callers can't race past the check. * * Returns true when the row was inserted, false when a row already * existed (regardless of its value — even an empty string or null). */ async setIfAbsent(name: string, value: T): Promise { const row: OptionTable = { name, value: JSON.stringify(value), }; const result = await this.db .insertInto("options") .values(row) .onConflict((oc) => oc.column("name").doNothing()) .executeTakeFirst(); // SQLite reports numInsertedOrUpdatedRows; Postgres reports the same. // When the ON CONFLICT branch fires and does nothing, the count is 0. return (result.numInsertedOrUpdatedRows ?? 0n) > 0n; } /** * Delete an option */ async delete(name: string): Promise { const result = await this.db.deleteFrom("options").where("name", "=", name).executeTakeFirst(); return (result.numDeletedRows ?? 0) > 0; } /** * Check if an option exists */ async exists(name: string): Promise { const row = await this.db .selectFrom("options") .select("name") .where("name", "=", name) .executeTakeFirst(); return !!row; } /** * Get multiple options at once */ async getMany(names: string[]): Promise> { if (names.length === 0) return new Map(); const rows = await this.db .selectFrom("options") .select(["name", "value"]) .where("name", "in", names) .execute(); const result = new Map(); for (const row of rows) { // eslint-disable-next-line typescript/no-unsafe-type-assertion -- JSON.parse returns any; generic callers provide T result.set(row.name, JSON.parse(row.value) as T); } return result; } /** * Set multiple options at once */ async setMany(options: Record): Promise { const entries = Object.entries(options); if (entries.length === 0) return; for (const [name, value] of entries) { await this.set(name, value); } } /** * Get all options (use sparingly) */ async getAll(): Promise> { const rows = await this.db.selectFrom("options").select(["name", "value"]).execute(); const result = new Map(); for (const row of rows) { result.set(row.name, JSON.parse(row.value)); } return result; } /** * Get all options matching a prefix */ async getByPrefix(prefix: string): Promise> { const pattern = `${escapeLike(prefix)}%`; const rows = await this.db .selectFrom("options") .select(["name", "value"]) .where(sql`name LIKE ${pattern} ESCAPE '\\'`) .execute(); const result = new Map(); for (const row of rows) { // eslint-disable-next-line typescript/no-unsafe-type-assertion -- JSON.parse returns any; generic callers provide T result.set(row.name, JSON.parse(row.value) as T); } return result; } /** * Delete all options matching a prefix */ async deleteByPrefix(prefix: string): Promise { const pattern = `${escapeLike(prefix)}%`; const result = await this.db .deleteFrom("options") .where(sql`name LIKE ${pattern} ESCAPE '\\'`) .executeTakeFirst(); return Number(result.numDeletedRows ?? 0); } }