import type { BackendDbInterface } from "../types/DbInterface.js"; import { isValidUserPassword, type ClientUser, type CreateUser, type LoginUser, type UpdateUser, type UserFlags, } from "../types/User.js"; import { sqliteToClientUser, sqliteToDate, sqliteToUuid } from "./cast.js"; import { ConflictAbodeError, InvalidAbodeError, NotAuthorizedAbodeError, NotFoundAbodeError, ReadonlyAbodeError, } from "../types/errors.js"; import type { Abode, CreateAbode, UpdateAbode } from "../types/Abode.js"; import type { CreateResident, Resident, ResidentFlags, updateResident, } from "../types/Resident.js"; import { validatePassword } from "../../util/hash.js"; import { createApikeyToken, createSessionToken } from "../../util/token.js"; import type { ClientApikey, CreateApikey } from "../types/Apikey.js"; import { calcUpdates, catSql, joinSql, sql } from "./sql.js"; import { selectAbode, selectAbodes, selectClientApikey, selectClientApikeys, selectClientUser, selectClientUsers, selectNote, selectNotes, selectPartialNotes, selectResident, selectResidents, } from "./query.js"; import type { CreateNote, Note, PartialNote, UpdateNote, } from "../types/Note.js"; import type { WrappedDb } from "./impl/types.js"; import { Readable } from "node:stream"; import readline from "node:readline"; import { EXPORT_KIND_ORDER, type Exportable, type ExportKind, type ExportOptions, type Importable, type ImportOptions, type ImportResult, } from "../types/ExportImport.js"; import { isExportKind, kindAllowed, recordAllowed } from "../export/filter.js"; export class SqliteInterface implements BackendDbInterface, Exportable, Importable { #db: WrappedDb; constructor(db: WrappedDb) { this.#db = db; } #checkReadonly(): void { if (this.#db.readonly) throw new ReadonlyAbodeError(); } get _() { return { sql, catSql, joinSql, db: this.#db, }; } get readonly(): boolean { return this.#db.readonly; } get backend(): true { return true; } get name(): "sqlite" { return "sqlite"; } async close(): Promise { this.#db.destroy(); } async listUsers(): Promise { return selectClientUsers(this.#db); } #getUserById(uid: string): ClientUser { const user = selectClientUser(this.#db, sql`"uid" = ${{ uuid: uid }}`); if (!user) throw new NotFoundAbodeError(); return user; } async getUserById(id: string): Promise { return this.#getUserById(id); } async getUserByEmail(email: string): Promise { const user = selectClientUser(this.#db, sql`"email" = ${{ text: email }}`); if (!user) throw new NotFoundAbodeError(); return user; } async getUserByLogin({ email, password }: LoginUser): Promise { const rawUser = this.#db.get<{ uid: Buffer; email: string; name: string; flags: string; created_at: string; updated_at: string; password: string; }>( sql` SELECT "uid", "email", "name", json("flags") AS "flags", "created_at", "updated_at", "password" FROM "users" WHERE "email" = ${{ text: email }} `, ); if (!rawUser) throw new NotFoundAbodeError(); if (rawUser.password.startsWith("#")) throw new ConflictAbodeError(); if (!(await validatePassword(password, rawUser.password))) throw new NotAuthorizedAbodeError(); return sqliteToClientUser(rawUser); } async deleteUserById(id: string): Promise { this.#checkReadonly(); const { changes } = this.#db.run( sql` DELETE FROM "users" WHERE "uid" = ${{ uuid: id }} `, ); if (!changes) throw new NotFoundAbodeError(); } async createUser(user: CreateUser): Promise { this.#checkReadonly(); if (!isValidUserPassword(user.password)) throw new InvalidAbodeError(); const uid = crypto.randomUUID(); return this.#db.rethrow(() => this.#db.multi(() => { this.#db.run( sql` INSERT INTO "users"("uid", "email", "name", "password", "flags") VALUES( ${{ uuid: uid }}, ${{ text: user.email }}, ${{ text: user.name }}, ${{ text: user.password }},${{ jsonb: user.flags }}) `, ); return this.#getUserById(uid); }), ); } async updateUser(user: UpdateUser): Promise { this.#checkReadonly(); return this.#db.rethrow(() => this.#db.multi(() => { const updates = calcUpdates({ email: (value: string) => sql`"email" = ${{ text: value }}`, name: (value: string) => sql`"name" = ${{ text: value }}`, password: (value: string) => { if (!isValidUserPassword(value)) throw new InvalidAbodeError(); if (value.startsWith("#")) { this.#db.run(sql` DELETE FROM "apikeys" WHERE "uid" = ${{ uuid: user.uid }} `); } this.#db.run(sql` DELETE FROM "sessions" WHERE "uid" = ${{ uuid: user.uid }} `); return sql`"password" = ${{ text: value }}`; }, flags: (value: UserFlags) => sql`"flags" = ${{ jsonb: value }}`, })(user); if (!updates.length) throw new InvalidAbodeError(); const { changes } = this.#db.run( sql` UPDATE "users" SET "updated_at" = datetime('now', 'localtime', 'subsec'), ${joinSql(updates, sql`, `)} WHERE "uid" = ${{ uuid: user.uid }} `, ); if (!changes) throw new NotFoundAbodeError(); return this.#getUserById(user.uid); }), ); } async listAbodes(): Promise { return selectAbodes(this.#db); } #getAbodeById(aid: string): Abode { const abode = selectAbode(this.#db, sql`"aid" = ${{ uuid: aid }}`); if (!abode) throw new NotFoundAbodeError(); return abode; } async getAbodeById(id: string): Promise { return this.#getAbodeById(id); } async deleteAbodeById(id: string): Promise { this.#checkReadonly(); const { changes } = this.#db.run( sql` DELETE FROM "abodes" WHERE "aid" = ${{ uuid: id }} `, ); if (!changes) throw new NotFoundAbodeError(); } async createAbode(abode: CreateAbode, ctx: { uid: string }): Promise { this.#checkReadonly(); const aid = crypto.randomUUID(); return this.#db.rethrow(() => this.#db.multi(() => { this.#db.run( sql` INSERT INTO "abodes"("aid", "name", "created_by", "updated_by") VALUES( ${{ uuid: aid }}, ${{ text: abode.name }}, ${{ uuid: ctx.uid }}, ${{ uuid: ctx.uid }} ) `, ); return this.#getAbodeById(aid); }), ); } async updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise { this.#checkReadonly(); const updates = calcUpdates({ name: (value: string) => sql`"name" = ${{ text: value }}`, })(abode); if (!updates.length) throw new InvalidAbodeError(); return this.#db.rethrow(() => this.#db.multi(() => { const { changes } = this.#db.run( sql` UPDATE "abodes" SET "updated_at" = datetime('now', 'localtime', 'subsec'), "updated_by" = ${{ uuid: ctx.uid }}, ${joinSql(updates, sql`, `)} WHERE "aid" = ${{ uuid: abode.aid }} `, ); if (!changes) throw new NotFoundAbodeError(); return this.#getAbodeById(abode.aid); }), ); } async listResidents(): Promise { return selectResidents(this.#db); } async listResidentsByUserId(uid: string): Promise { return selectResidents(this.#db, sql`"uid" = ${{ uuid: uid }}`); } async listResidentsByAbodeId(aid: string): Promise { return selectResidents(this.#db, sql`"aid" = ${{ uuid: aid }}`); } #getResidentById(uid: string, aid: string): Resident { const resident = selectResident( this.#db, sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}`, ); if (!resident) throw new NotFoundAbodeError(); return resident; } async getResidentById(uid: string, aid: string): Promise { return this.#getResidentById(uid, aid); } async deleteResidentById(uid: string, aid: string): Promise { this.#checkReadonly(); const { changes } = this.#db.run( sql` DELETE FROM "residents" WHERE "uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }} `, ); if (!changes) throw new NotFoundAbodeError(); } async createResident( resident: CreateResident, ctx: { uid: string }, ): Promise { this.#checkReadonly(); return this.#db.rethrow(() => this.#db.multi(() => { this.#db.run( sql` INSERT INTO "residents"("uid", "aid", "flags", "created_by", "updated_by") VALUES( ${{ uuid: resident.uid }}, ${{ uuid: resident.aid }}, ${{ jsonb: resident.flags }}, ${{ uuid: ctx.uid }}, ${{ uuid: ctx.uid }} ) `, ); return this.#getResidentById(resident.uid, resident.aid); }), ); } async updateResident( resident: updateResident, ctx: { uid: string }, ): Promise { this.#checkReadonly(); const updates = calcUpdates({ flags: (value: ResidentFlags) => sql`"flags" = ${{ jsonb: value }}`, })(resident); if (!updates.length) throw new InvalidAbodeError(); return this.#db.rethrow(() => this.#db.multi(() => { const { changes } = this.#db.run( sql` UPDATE "residents" SET "updated_at" = datetime('now', 'localtime', 'subsec'), "updated_by" = ${{ uuid: ctx.uid }}, ${joinSql(updates, sql`, `)} WHERE "uid" = ${{ uuid: resident.uid }} AND "aid" = ${{ uuid: resident.aid }} `, ); if (!changes) throw new NotFoundAbodeError(); return this.#getResidentById(resident.uid, resident.aid); }), ); } async listUsersByAbodeId(id: string): Promise { return selectClientUsers( this.#db, sql` JOIN "residents" r ON u."uid" = r."uid" WHERE r."aid" = ${{ uuid: id }} `, ); } async listAbodesByUserId(id: string): Promise { return selectAbodes( this.#db, sql` JOIN "residents" r ON a."aid" = r."aid" WHERE r."uid" = ${{ uuid: id }} `, ); } async getUserBySession(token: `as_${string}`): Promise { const session = this.#db.get<{ uid: Buffer; expires_at: string }>(sql` SELECT "uid", "expires_at" FROM "sessions" WHERE "token" = ${{ text: token }} `); if (!session) throw new NotFoundAbodeError(); if (new Date(sqliteToDate(session.expires_at)).getTime() < Date.now()) { if (!this.readonly) { this.#db.run(sql` DELETE FROM "sessions" WHERE "expires_at" < datetime('now', 'localtime', 'subsec') `); } throw new NotFoundAbodeError(); } if (!this.readonly) { this.#db.run(sql` UPDATE "sessions" SET "expires_at" = datetime('now', 'localtime', 'subsec', '+7 days') WHERE "token" = ${{ text: token }} `); } return this.#getUserById(sqliteToUuid(session.uid)); } async createSession(uid: string): Promise<`as_${string}`> { this.#checkReadonly(); const token = createSessionToken(); this.#db.run(sql` INSERT INTO "sessions"("uid", "token") VALUES(${{ uuid: uid }}, ${{ text: token }}) `); return token; } async deleteSessionsByUser(uid: string): Promise { this.#checkReadonly(); this.#db.run(sql` DELETE FROM "sessions" WHERE "uid" = ${{ uuid: uid }} `); } async deleteSession(token: `as_${string}`): Promise { this.#checkReadonly(); this.#db.run(sql` DELETE FROM "sessions" WHERE "token" = ${{ text: token }} `); } #getApikeyByToken(token: `at_${string}`): ClientApikey { const apikey = selectClientApikey( this.#db, sql`"token" = ${{ text: token }}`, ); if (!apikey) throw new NotFoundAbodeError(); return apikey; } async getUserByApikey( token: `at_${string}`, ): Promise<[ClientUser, ClientApikey]> { const apikey = this.#getApikeyByToken(token); if ( apikey.expires_at && new Date(apikey.expires_at).getTime() < Date.now() ) { throw new NotAuthorizedAbodeError(); } return [this.#getUserById(apikey.uid), apikey]; } async listApikeysByUser(uid: string): Promise { return selectClientApikeys(this.#db, sql`"uid" = ${{ uuid: uid }}`); } async getApikeyById(kid: string): Promise { const apikey = selectClientApikey(this.#db, sql`"kid" = ${{ uuid: kid }}`); if (!apikey) throw new NotFoundAbodeError(); return apikey; } async createApikey( apikey: CreateApikey, ): Promise<[ClientApikey, `at_${string}`]> { this.#checkReadonly(); const token = createApikeyToken(); const kid = crypto.randomUUID(); let expires = apikey.expires_at; if (expires === undefined) expires = new Date( new Date().getTime() + 1000 * 60 * 60 * 24 * 365, ).toISOString(); if (expires && new Date(expires).getTime() < Date.now()) throw new InvalidAbodeError(); this.#db.run(sql` INSERT INTO "apikeys"("uid", "kid", "token", "name", "permissions", "expires_at") VALUES( ${{ uuid: apikey.uid }}, ${{ uuid: kid }}, ${{ text: token }}, ${{ text: apikey.name }}, ${{ jsonb: apikey.permissions }}, ${expires ? { date: expires } : { null: true }} ) `); return [this.#getApikeyByToken(token), token]; } async deleteApikeyById(kid: string): Promise { this.#checkReadonly(); const { changes } = this.#db.run(sql` DELETE FROM "apikeys" WHERE "kid" = ${{ uuid: kid }} `); if (!changes) throw new NotFoundAbodeError(); } async listNotes(): Promise { return selectPartialNotes(this.#db); } #getNoteById(nid: string): Note { const note = selectNote(this.#db, sql`n."nid" = ${{ uuid: nid }}`); if (!note) throw new NotFoundAbodeError(); return note; } async getNoteById(nid: string): Promise { return this.#getNoteById(nid); } async deleteNoteById(nid: string): Promise { this.#checkReadonly(); const { changes } = this.#db.run( sql`DELETE FROM "notes" WHERE "nid" = ${{ uuid: nid }}`, ); if (!changes) throw new NotFoundAbodeError(); } async createNote(note: CreateNote, ctx: { uid: string }): Promise { this.#checkReadonly(); const nid = crypto.randomUUID(); return this.#db.rethrow(() => this.#db.multi(() => { this.#db.run(sql` INSERT INTO "notes"("nid", "aid", "name", "content", "properties", "created_by", "updated_by") VALUES( ${{ uuid: nid }}, ${{ uuid: note.aid }}, ${{ text: note.name }}, ${{ text: note.content ?? "" }}, ${{ jsonb: note.properties }}, ${{ uuid: ctx.uid }}, ${{ uuid: ctx.uid }} ) `); return this.#getNoteById(nid); }), ); } async updateNote(note: UpdateNote, ctx: { uid: string }): Promise { this.#checkReadonly(); const updates = calcUpdates({ name: (value: string) => sql`"name" = ${{ text: value }}`, content: (value: string) => sql`"content" = ${{ text: value }}`, properties: (value: object) => sql`"properties" = ${{ jsonb: value }}`, })(note); if (!updates.length) throw new InvalidAbodeError(); return this.#db.rethrow(() => this.#db.multi(() => { const { changes } = this.#db.run(sql` UPDATE "notes" SET "updated_at" = datetime('now', 'localtime', 'subsec'), "updated_by" = ${{ uuid: ctx.uid }}, ${joinSql(updates, sql`, `)} WHERE "nid" = ${{ uuid: note.nid }} `); if (!changes) throw new NotFoundAbodeError(); return this.#getNoteById(note.nid); }), ); } async listNotesByAbodeId(aid: string): Promise { return selectPartialNotes(this.#db, sql`n."aid" = ${{ uuid: aid }}`); } async listNotesByUserId(uid: string): Promise { return selectPartialNotes(this.#db, sql`n."created_by" = ${{ uuid: uid }}`); } export(options: ExportOptions = {}): NodeJS.ReadableStream { const { filter, signal } = options; const db = this.#db; const source = this.name; // Per-table full materialization + JS-side per-record yielding (each // `load()` is exactly one `WrappedDb.all()`). Kept lazy so the first query // only fires once the destination starts pulling, and skipped entirely // once the signal is aborted — no further reads after the destination // goes away. Emission follows the FK-safe EXPORT_KIND_ORDER (part of the // wire contract; see ExportImport.ts). const loaders: Record { uid?: string; aid?: string }[]> = { user: () => selectClientUsers(db), abode: () => selectAbodes(db), resident: () => selectResidents(db), apikey: () => selectClientApikeys(db), note: () => selectNotes(db), }; async function* generate(): AsyncGenerator { if (signal?.aborted) return; yield JSON.stringify({ kind: "meta", data: { v: 1, exportedAt: new Date().toISOString(), source, filter: filter ?? {}, }, }) + "\n"; try { for (const kind of EXPORT_KIND_ORDER) { if (signal?.aborted) return; if (!kindAllowed(filter, kind)) continue; for (const row of loaders[kind]()) { if (signal?.aborted) return; if (recordAllowed(filter, kind, row)) { yield JSON.stringify({ kind, data: row }) + "\n"; } } } } catch (e) { // Aborts unwind via early `return`, never here; a genuine mid-stream // failure is surfaced as a trailing sentinel line (HTTP 200 headers // are already flushed, so `convertError` can no longer apply). if (signal?.aborted) return; yield JSON.stringify({ kind: "error", data: { message: e instanceof Error ? e.message : String(e), code: e instanceof Error ? e.name : undefined, }, }) + "\n"; } } return Readable.from(generate()); } async import( source: NodeJS.ReadableStream, options: ImportOptions = {}, ): Promise { this.#checkReadonly(); const { filter, signal } = options; const db = this.#db; const counts: Partial> = {}; const rl = readline.createInterface({ input: source, crlfDelay: Infinity, signal, }); // Bulk restore trusts the export's referential integrity, and a filtered // dump may legitimately reference `created_by`/`updated_by` users outside // its scope. Suppress FK enforcement for the duration (can only be toggled // outside a transaction) and restore it in `finally`. db.run(sql`PRAGMA foreign_keys = OFF`); db.run(sql`BEGIN`); try { for await (const raw of rl) { signal?.throwIfAborted(); const line = raw.trim(); if (!line) continue; let parsed: { kind?: unknown; data?: unknown }; try { parsed = JSON.parse(line); } catch { throw new InvalidAbodeError(); } if (parsed.kind === "meta") continue; if (parsed.kind === "error") { throw new Error( `export stream reported an error: ${ (parsed.data as { message?: string })?.message ?? "unknown" }`, ); } if (!isExportKind(parsed.kind)) continue; if (!kindAllowed(filter, parsed.kind)) continue; const data = parsed.data as { uid?: string; aid?: string }; if (!recordAllowed(filter, parsed.kind, data)) continue; this.#importRecord(parsed.kind, parsed.data); counts[parsed.kind] = (counts[parsed.kind] ?? 0) + 1; } // An abort while blocked on the source closes readline without throwing, // so re-check before committing to guarantee no partial commit. signal?.throwIfAborted(); db.run(sql`COMMIT`); } catch (e) { try { db.run(sql`ROLLBACK`); } catch { /* already rolled back */ } throw e; } finally { rl.close(); db.run(sql`PRAGMA foreign_keys = ON`); } return { counts }; } #importRecord(kind: ExportKind, data: unknown): void { const db = this.#db; switch (kind) { case "user": { const u = data as ClientUser; // `password` is never exported; imported users land on the schema // default ('#unset') and must reset before they can log in. db.run(sql` INSERT INTO "users"("uid", "email", "name", "flags", "created_at", "updated_at") VALUES( ${{ uuid: u.uid }}, ${{ text: u.email }}, ${{ text: u.name }}, ${{ jsonb: u.flags }}, ${{ date: u.created_at }}, ${{ date: u.updated_at }} ) `); break; } case "abode": { const a = data as Abode; db.run(sql` INSERT INTO "abodes"("aid", "name", "created_at", "created_by", "updated_at", "updated_by") VALUES( ${{ uuid: a.aid }}, ${{ text: a.name }}, ${{ date: a.created_at }}, ${a.created_by ? { uuid: a.created_by } : { null: true }}, ${{ date: a.updated_at }}, ${a.updated_by ? { uuid: a.updated_by } : { null: true }} ) `); break; } case "resident": { const r = data as Resident; db.run(sql` INSERT INTO "residents"("uid", "aid", "flags", "created_at", "created_by", "updated_at", "updated_by") VALUES( ${{ uuid: r.uid }}, ${{ uuid: r.aid }}, ${{ jsonb: r.flags }}, ${{ date: r.created_at }}, ${r.created_by ? { uuid: r.created_by } : { null: true }}, ${{ date: r.updated_at }}, ${r.updated_by ? { uuid: r.updated_by } : { null: true }} ) `); break; } case "apikey": { const k = data as ClientApikey; // `token` is never exported; mint a fresh unique one so the record's // metadata (kid/permissions/expiry) survives even though the original // secret cannot. db.run(sql` INSERT INTO "apikeys"("uid", "kid", "token", "name", "permissions", "created_at", "expires_at") VALUES( ${{ uuid: k.uid }}, ${{ uuid: k.kid }}, ${{ text: createApikeyToken() }}, ${{ text: k.name }}, ${{ jsonb: k.permissions }}, ${{ date: k.created_at }}, ${k.expires_at ? { date: k.expires_at } : { null: true }} ) `); break; } case "note": { const n = data as Note; db.run(sql` INSERT INTO "notes"("nid", "aid", "name", "content", "properties", "created_at", "created_by", "updated_at", "updated_by") VALUES( ${{ uuid: n.nid }}, ${{ uuid: n.aid }}, ${{ text: n.name }}, ${{ text: n.content ?? "" }}, ${{ jsonb: n.properties }}, ${{ date: n.created_at }}, ${n.created_by ? { uuid: n.created_by } : { null: true }}, ${{ date: n.updated_at }}, ${n.updated_by ? { uuid: n.updated_by } : { null: true }} ) `); break; } } } }