Files
abode/src/db/sqlite/SqliteInterface.ts
T
codingetandCodex 31d4636dde
CI / install-and-build (pull_request) Successful in 1m29s
CI / format (pull_request) Successful in 51s
CI / typecheck-source (pull_request) Successful in 41s
CI / typecheck-tests (pull_request) Successful in 39s
CI / test (pull_request) Successful in 51s
CI / lint (pull_request) Successful in 21s
ci: add pull request quality gates
Co-Authored-By: gpt-5.6-terra <noreply@openai.com>
2026-07-22 21:50:29 +00:00

538 lines
16 KiB
TypeScript

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,
selectPartialNotes,
selectResident,
selectResidents,
} from "./query.js";
import type {
CreateNote,
Note,
PartialNote,
UpdateNote,
} from "../types/Note.js";
import type { WrappedDb } from "./impl/types.js";
export class SqliteInterface implements BackendDbInterface {
#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<void> {
this.#db.destroy();
}
async listUsers(): Promise<ClientUser[]> {
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<ClientUser> {
return this.#getUserById(id);
}
async getUserByEmail(email: string): Promise<ClientUser> {
const user = selectClientUser(this.#db, sql`"email" = ${{ text: email }}`);
if (!user) throw new NotFoundAbodeError();
return user;
}
async getUserByLogin({ email, password }: LoginUser): Promise<ClientUser> {
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<void> {
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<ClientUser> {
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<ClientUser> {
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<Abode[]> {
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<Abode> {
return this.#getAbodeById(id);
}
async deleteAbodeById(id: string): Promise<void> {
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<Abode> {
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<Abode> {
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<Resident[]> {
return selectResidents(this.#db);
}
async listResidentsByUserId(uid: string): Promise<Resident[]> {
return selectResidents(this.#db, sql`"uid" = ${{ uuid: uid }}`);
}
async listResidentsByAbodeId(aid: string): Promise<Resident[]> {
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<Resident> {
return this.#getResidentById(uid, aid);
}
async deleteResidentById(uid: string, aid: string): Promise<void> {
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<Resident> {
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<Resident> {
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<ClientUser[]> {
return selectClientUsers(
this.#db,
sql`
JOIN "residents" r ON u."uid" = r."uid"
WHERE r."aid" = ${{ uuid: id }}
`,
);
}
async listAbodesByUserId(id: string): Promise<Abode[]> {
return selectAbodes(
this.#db,
sql`
JOIN "residents" r ON a."aid" = r."aid"
WHERE r."uid" = ${{ uuid: id }}
`,
);
}
async getUserBySession(token: `as_${string}`): Promise<ClientUser> {
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<void> {
this.#checkReadonly();
this.#db.run(sql`
DELETE FROM "sessions"
WHERE "uid" = ${{ uuid: uid }}
`);
}
async deleteSession(token: `as_${string}`): Promise<void> {
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<ClientApikey[]> {
return selectClientApikeys(this.#db, sql`"uid" = ${{ uuid: uid }}`);
}
async getApikeyById(kid: string): Promise<ClientApikey> {
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<void> {
this.#checkReadonly();
const { changes } = this.#db.run(sql`
DELETE FROM "apikeys"
WHERE "kid" = ${{ uuid: kid }}
`);
if (!changes) throw new NotFoundAbodeError();
}
async listNotes(): Promise<PartialNote[]> {
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<Note> {
return this.#getNoteById(nid);
}
async deleteNoteById(nid: string): Promise<void> {
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<Note> {
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<Note> {
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<PartialNote[]> {
return selectPartialNotes(this.#db, sql`n."aid" = ${{ uuid: aid }}`);
}
async listNotesByUserId(uid: string): Promise<PartialNote[]> {
return selectPartialNotes(this.#db, sql`n."created_by" = ${{ uuid: uid }}`);
}
}