diff --git a/package.json b/package.json index 958b9f7..ea79874 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", + "@types/pg": "^8.20.0", "@types/koa": "^3.0.0", "@types/koa__router": "^12.0.4", "@types/react": "^19.1.12", diff --git a/src/db/dbSources.static.ts b/src/db/dbSources.static.ts index 85bc40b..3a6432a 100644 --- a/src/db/dbSources.static.ts +++ b/src/db/dbSources.static.ts @@ -1,8 +1,9 @@ import getApiStatic from "./api/getdb.static.js"; +import getPgStatic from "./postgres/getdb.static.js"; import getSqliteStatic from "./sqlite/getdb.static.js"; import type { GetDbStatic } from "./types/GetDb.js"; -const dbSources: GetDbStatic[] = [getSqliteStatic, getApiStatic]; +const dbSources: GetDbStatic[] = [getSqliteStatic, getPgStatic, getApiStatic]; export async function getDbSources(url: string): Promise { void url; return dbSources; diff --git a/src/db/postgres/PostgresInterface.ts b/src/db/postgres/PostgresInterface.ts new file mode 100644 index 0000000..27776ff --- /dev/null +++ b/src/db/postgres/PostgresInterface.ts @@ -0,0 +1,502 @@ +import type { BackendDbInterface } from "../types/DbInterface.js"; +import { + isValidUserPassword, + type ClientUser, + type CreateUser, + type LoginUser, + type UpdateUser, + type UserFlags, +} from "../types/User.js"; +import { pgToClientUser, pgToDate } 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, + selectResident, + selectResidents, +} from "./query.js"; +import type { + CreateNote, + Note, + PartialNote, + UpdateNote, +} from "../types/Note.js"; +import type { WrappedPgClient } from "./pool.js"; + +export class PostgresInterface implements BackendDbInterface { + #db: WrappedPgClient; + + constructor(db: WrappedPgClient) { + 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(): "postgres" { + return "postgres"; + } + + async close(): Promise { + await this.#db.destroy(); + } + + async listUsers(): Promise { + return selectClientUsers(this.#db); + } + async #getUserById(uid: string, db: WrappedPgClient): Promise { + const user = await selectClientUser(db, sql`u."uid" = ${{ uuid: uid }}`); + if (!user) throw new NotFoundAbodeError(); + return user; + } + async getUserById(id: string): Promise { + return this.#getUserById(id, this.#db); + } + async getUserByEmail(email: string): Promise { + const user = await selectClientUser( + this.#db, + sql`u."email" = ${{ text: email }}` + ); + if (!user) throw new NotFoundAbodeError(); + return user; + } + async getUserByLogin({ email, password }: LoginUser): Promise { + const rawUser = await this.#db.get<{ + uid: string; + email: string; + name: string; + flags: unknown; + created_at: Date; + updated_at: Date; + password: string; + }>( + sql` + SELECT "uid", "email", "name", "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 pgToClientUser(rawUser); + } + async deleteUserById(id: string): Promise { + this.#checkReadonly(); + const { changes } = await 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(async (tx) => { + await tx.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, tx); + }) + ); + } + async updateUser(user: UpdateUser): Promise { + this.#checkReadonly(); + return this.#db.rethrow(() => + this.#db.multi(async (tx) => { + const updates = []; + if ("email" in user && user.email !== undefined) + updates.push(sql`"email" = ${{ text: user.email }}`); + if ("name" in user && user.name !== undefined) + updates.push(sql`"name" = ${{ text: user.name }}`); + if ("password" in user && user.password !== undefined) { + if (!isValidUserPassword(user.password)) throw new InvalidAbodeError(); + if (user.password.startsWith("#")) { + await tx.run(sql` + DELETE FROM "apikeys" + WHERE "uid" = ${{ uuid: user.uid }} + `); + } + await tx.run(sql` + DELETE FROM "sessions" + WHERE "uid" = ${{ uuid: user.uid }} + `); + updates.push(sql`"password" = ${{ text: user.password }}`); + } + if ("flags" in user && user.flags !== undefined) + updates.push(sql`"flags" = ${{ jsonb: user.flags as UserFlags }}`); + + if (!updates.length) throw new InvalidAbodeError(); + const { changes } = await tx.run( + sql` + UPDATE "users" + SET + "updated_at" = NOW(), + ${joinSql(updates, sql`, `)} + WHERE "uid" = ${{ uuid: user.uid }} + ` + ); + if (!changes) throw new NotFoundAbodeError(); + return this.#getUserById(user.uid, tx); + }) + ); + } + + async listAbodes(): Promise { + return selectAbodes(this.#db); + } + async #getAbodeById(aid: string, db: WrappedPgClient): Promise { + const abode = await selectAbode(db, sql`a."aid" = ${{ uuid: aid }}`); + if (!abode) throw new NotFoundAbodeError(); + return abode; + } + async getAbodeById(id: string): Promise { + return this.#getAbodeById(id, this.#db); + } + async deleteAbodeById(id: string): Promise { + this.#checkReadonly(); + const { changes } = await 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(async (tx) => { + await tx.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, tx); + }) + ); + } + 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(async (tx) => { + const { changes } = await tx.run( + sql` + UPDATE "abodes" + SET + "updated_at" = NOW(), + "updated_by" = ${{ uuid: ctx.uid }}, + ${joinSql(updates, sql`, `)} + WHERE "aid" = ${{ uuid: abode.aid }} + ` + ); + if (!changes) throw new NotFoundAbodeError(); + return this.#getAbodeById(abode.aid, tx); + }) + ); + } + + 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 }}`); + } + async #getResidentById( + uid: string, + aid: string, + db: WrappedPgClient + ): Promise { + const resident = await selectResident( + 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, this.#db); + } + async deleteResidentById(uid: string, aid: string): Promise { + this.#checkReadonly(); + const { changes } = await 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(async (tx) => { + await tx.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, tx); + }) + ); + } + 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(async (tx) => { + const { changes } = await tx.run( + sql` + UPDATE "residents" + SET + "updated_at" = NOW(), + "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, tx); + }) + ); + } + + 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 = await this.#db.get<{ + uid: string; + expires_at: Date; + }>(sql` + SELECT "uid", "expires_at" + FROM "sessions" + WHERE "token" = ${{ text: token }} + `); + if (!session) throw new NotFoundAbodeError(); + if (new Date(pgToDate(session.expires_at)).getTime() < Date.now()) { + if (!this.readonly) { + await this.#db.run(sql` + DELETE FROM "sessions" + WHERE "expires_at" < NOW() + `); + } + throw new NotFoundAbodeError(); + } + if (!this.readonly) { + await this.#db.run(sql` + UPDATE "sessions" + SET "expires_at" = NOW() + INTERVAL '7 days' + WHERE "token" = ${{ text: token }} + `); + } + return this.#getUserById(session.uid, this.#db); + } + async createSession(uid: string): Promise<`as_${string}`> { + this.#checkReadonly(); + const token = createSessionToken(); + await this.#db.run(sql` + INSERT INTO "sessions"("uid", "token") + VALUES(${{ uuid: uid }}, ${{ text: token }}) + `); + return token; + } + async deleteSessionsByUser(uid: string): Promise { + this.#checkReadonly(); + await this.#db.run(sql` + DELETE FROM "sessions" + WHERE "uid" = ${{ uuid: uid }} + `); + } + + async #getApikeyByToken( + token: `at_${string}`, + db: WrappedPgClient + ): Promise { + const apikey = await selectClientApikey( + db, + sql`k."token" = ${{ text: token }}` + ); + if (!apikey) throw new NotFoundAbodeError(); + return apikey; + } + async getUserByApikey( + token: `at_${string}` + ): Promise<[ClientUser, ClientApikey]> { + const apikey = await this.#getApikeyByToken(token, this.#db); + if ( + apikey.expires_at && + new Date(apikey.expires_at).getTime() < Date.now() + ) { + throw new NotAuthorizedAbodeError(); + } + return [await this.#getUserById(apikey.uid, this.#db), apikey]; + } + async listApikeysByUser(uid: string): Promise { + return selectClientApikeys(this.#db, sql`k."uid" = ${{ uuid: uid }}`); + } + async getApikeyById(kid: string): Promise { + const apikey = await selectClientApikey( + this.#db, + sql`k."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(); + + await 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 [await this.#getApikeyByToken(token, this.#db), token]; + } + async deleteApikeyById(kid: string): Promise { + this.#checkReadonly(); + const { changes } = await this.#db.run(sql` + DELETE FROM "apikeys" + WHERE "kid" = ${{ uuid: kid }} + `); + if (!changes) throw new NotFoundAbodeError(); + } + + async listNotes(): Promise { + throw new Error("Unimplemented"); + } + async getNoteById(_nid: string): Promise { + throw new Error("Unimplemented"); + } + async deleteNoteById(_nid: string): Promise { + throw new Error("Unimplemented"); + } + async createNote(_note: CreateNote, _ctx: { uid: string }): Promise { + throw new Error("Unimplemented"); + } + async updateNote(_note: UpdateNote, _ctx: { uid: string }): Promise { + throw new Error("Unimplemented"); + } + async listNotesByAbodeId(_aid: string): Promise { + throw new Error("Unimplemented"); + } + async listNotesByUserId(_uid: string): Promise { + throw new Error("Unimplemented"); + } +} diff --git a/src/db/postgres/PostgresMigrator.ts b/src/db/postgres/PostgresMigrator.ts new file mode 100644 index 0000000..01c85cd --- /dev/null +++ b/src/db/postgres/PostgresMigrator.ts @@ -0,0 +1,126 @@ +import type { + AppliedMigration, + AvailableMigration, + Migrator, +} from "../types/Migrator.js"; +import { init, migrations } from "./migrations/index.js"; +import { pgToDate } from "./cast.js"; +import { WrappedPool } from "./pool.js"; +import { sql, toPositional } from "./sql.js"; + +export class PostgresMigrator implements Migrator { + #pool: WrappedPool; + + constructor(pool: WrappedPool) { + this.#pool = pool; + } + + async #listAppliedMigrations(): Promise< + { id: number; name: string; applied_at: string }[] | null + > { + const client = await this.#pool._pool.connect(); + try { + const existsResult = await client.query<{ exists: boolean }>( + `SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = '_migrations' + ) AS "exists"` + ); + if (!existsResult.rows[0]?.exists) return null; + + const result = await client.query<{ + id: number; + name: string; + applied_at: Date; + }>( + `SELECT "id", "name", "applied_at" FROM "_migrations" ORDER BY "id" ASC` + ); + return result.rows.map((x) => ({ + ...x, + applied_at: pgToDate(x.applied_at), + })); + } finally { + client.release(); + } + } + + async listAppliedMigrations(): Promise { + return (await this.#listAppliedMigrations()) ?? []; + } + + listAvailableMigrations(): AvailableMigration[] { + return migrations.map((m) => ({ id: m.id, name: m.name })); + } + + async migrateTo(id: number): Promise { + const target = migrations.find((x) => x.id === id); + if (!target) throw new Error(`No known migration with id ${id}`); + + let current = await this.#listAppliedMigrations(); + if (!current) { + const client = await this.#pool._pool.connect(); + try { + await client.query(init); + } finally { + client.release(); + } + current = []; + } + + for (const { id, name } of current) { + const migration = migrations.find((x) => x.id === id); + if (!migration) + throw new Error(`Applied migration ${id} (${name}) not known`); + if (migration.name !== name) + throw new Error( + `Applied migration ${id} (${name}) has a different name from expected (${migration.name})` + ); + } + + const start = + migrations.findIndex((x) => x.id === current.at(-1)?.id) + 1; + const end = migrations.indexOf(target) + 1; + + if (end < start) { + throw new Error( + `Cannot migrate backward, at ${current.at(-1)?.id ?? 0}, going to ${target.id}` + ); + } + + const toApply = migrations.slice(start, end); + + if (!toApply.length) { + console.log("Nothing to do"); + return; + } + + for (const migration of toApply) { + console.log(`Applying migration ${migration.id} (${migration.name})`); + const client = await this.#pool._pool.connect(); + try { + await client.query("BEGIN"); + for (const part of migration.parts) { + console.log(`- Applying part ${part.id} (${part.name})`); + if ("sql" in part) { + await client.query(part.sql); + } else { + await part.apply(this.#pool); + } + } + const recordSql = sql` + INSERT INTO "_migrations"("id", "name") + VALUES (${{ int: migration.id }}, ${{ text: migration.name }}) + `; + await client.query(toPositional(recordSql._sql), recordSql._vars); + await client.query("COMMIT"); + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } + } + + console.log("Done migrating database"); + } +} diff --git a/src/db/postgres/cast.ts b/src/db/postgres/cast.ts new file mode 100644 index 0000000..ffe1195 --- /dev/null +++ b/src/db/postgres/cast.ts @@ -0,0 +1,137 @@ +import type { Abode } from "../types/Abode.js"; +import type { ApikeyPermissions, ClientApikey } from "../types/Apikey.js"; +import type { Resident, ResidentFlags } from "../types/Resident.js"; +import type { ClientUser, PartialUser, UserFlags } from "../types/User.js"; + +export function pgToDate(d: Date | string): string { + return d instanceof Date ? d.toISOString() : new Date(d).toISOString(); +} + +const defaultUserFlags: UserFlags = {}; +export function pgToUserFlags(flags: unknown): UserFlags { + const out = { ...defaultUserFlags }; + if (typeof flags !== "object" || !flags || Array.isArray(flags)) return out; + const f = flags as Record; + if (f.admin === true) out.admin = true; + return out; +} + +export function pgToPartialUser(user: { + uid: string; + name: string; + flags: unknown; + created_at: Date | string; + updated_at: Date | string; +}): PartialUser { + return { + uid: user.uid, + name: user.name, + flags: pgToUserFlags(user.flags), + created_at: pgToDate(user.created_at), + updated_at: pgToDate(user.updated_at), + }; +} +export function pgToClientUser(user: { + uid: string; + email: string; + name: string; + flags: unknown; + created_at: Date | string; + updated_at: Date | string; +}): ClientUser { + return { + ...pgToPartialUser(user), + email: user.email, + }; +} + +export function pgToAbode(abode: { + aid: string; + name: string; + created_at: Date | string; + created_by: string | null; + updated_at: Date | string; + updated_by: string | null; +}): Abode { + return { + aid: abode.aid, + name: abode.name, + created_at: pgToDate(abode.created_at), + created_by: abode.created_by, + updated_at: pgToDate(abode.updated_at), + updated_by: abode.updated_by, + }; +} + +const defaultResidentFlags: ResidentFlags = {}; +export function pgToResidentFlags(flags: unknown): ResidentFlags { + const out = { ...defaultResidentFlags }; + if (typeof flags !== "object" || !flags || Array.isArray(flags)) return out; + const f = flags as Record; + if (f.admin === true) out.admin = true; + return out; +} + +export function pgToResident(resident: { + uid: string; + aid: string; + flags: unknown; + created_at: Date | string; + created_by: string | null; + updated_at: Date | string; + updated_by: string | null; +}): Resident { + return { + uid: resident.uid, + aid: resident.aid, + flags: pgToResidentFlags(resident.flags), + created_at: pgToDate(resident.created_at), + created_by: resident.created_by, + updated_at: pgToDate(resident.updated_at), + updated_by: resident.updated_by, + }; +} + +const defaultApikeyPermissions: ApikeyPermissions = {}; +export function pgToApikeyPermissions(permissions: unknown): ApikeyPermissions { + const out = { ...defaultApikeyPermissions }; + if ( + typeof permissions !== "object" || + !permissions || + Array.isArray(permissions) + ) + return out; + const p = permissions as Record; + if (p.admin === true) out.admin = true; + if (p.all === true) out.all = true; + for (const key of ["users", "residents", "abodes"] as const) { + if (p[key] === "r" || p[key] === "rw") out[key] = p[key] as "r" | "rw"; + } + for (const key of ["restrict_users", "restrict_abodes"] as const) { + if ( + Array.isArray(p[key]) && + (p[key] as unknown[]).every((x) => typeof x === "string") + ) { + out[key] = p[key] as string[]; + } + } + return out; +} + +export function pgToClientApikey(apikey: { + uid: string; + kid: string; + name: string; + permissions: unknown; + created_at: Date | string; + expires_at: Date | string | null; +}): ClientApikey { + return { + uid: apikey.uid, + kid: apikey.kid, + name: apikey.name, + permissions: pgToApikeyPermissions(apikey.permissions), + created_at: pgToDate(apikey.created_at), + expires_at: apikey.expires_at ? pgToDate(apikey.expires_at) : null, + }; +} diff --git a/src/db/postgres/getdb.static.ts b/src/db/postgres/getdb.static.ts new file mode 100644 index 0000000..1726f43 --- /dev/null +++ b/src/db/postgres/getdb.static.ts @@ -0,0 +1,20 @@ +import type { GetDbStatic } from "../types/GetDb.js"; +import { WrappedPool } from "./pool.js"; +import { PostgresInterface } from "./PostgresInterface.js"; +import { PostgresMigrator } from "./PostgresMigrator.js"; +import { isPgUrl, parsePgUrl, pgProtocols } from "./url.js"; + +const getPgStatic: GetDbStatic = { + name: "postgres", + protocols: pgProtocols, + checkUrl: isPgUrl, + getDbInterface: async (url) => { + const { connectionString, readonly } = parsePgUrl(url); + return new PostgresInterface(new WrappedPool(connectionString, readonly)); + }, + getMigrator: async (url) => { + const { connectionString } = parsePgUrl(url); + return new PostgresMigrator(new WrappedPool(connectionString)); + }, +}; +export default getPgStatic; diff --git a/src/db/postgres/migrations/1.init/1.users.pg.sql b/src/db/postgres/migrations/1.init/1.users.pg.sql new file mode 100644 index 0000000..c4da5a6 --- /dev/null +++ b/src/db/postgres/migrations/1.init/1.users.pg.sql @@ -0,0 +1,9 @@ +CREATE TABLE "users" ( + "uid" UUID NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL UNIQUE, + "name" TEXT NOT NULL, + "password" TEXT NOT NULL DEFAULT '#unset', + "flags" JSONB NOT NULL DEFAULT '{}', + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/src/db/postgres/migrations/1.init/2.abodes.pg.sql b/src/db/postgres/migrations/1.init/2.abodes.pg.sql new file mode 100644 index 0000000..8f778f5 --- /dev/null +++ b/src/db/postgres/migrations/1.init/2.abodes.pg.sql @@ -0,0 +1,8 @@ +CREATE TABLE "abodes" ( + "aid" UUID NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "created_by" UUID REFERENCES "users"("uid") ON DELETE SET NULL, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_by" UUID REFERENCES "users"("uid") ON DELETE SET NULL +); diff --git a/src/db/postgres/migrations/1.init/3.residents.pg.sql b/src/db/postgres/migrations/1.init/3.residents.pg.sql new file mode 100644 index 0000000..51fc811 --- /dev/null +++ b/src/db/postgres/migrations/1.init/3.residents.pg.sql @@ -0,0 +1,11 @@ +CREATE TABLE "residents" ( + "uid" UUID NOT NULL REFERENCES "users"("uid") ON DELETE CASCADE, + "aid" UUID NOT NULL REFERENCES "abodes"("aid") ON DELETE CASCADE, + "flags" JSONB NOT NULL DEFAULT '{}', + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "created_by" UUID REFERENCES "users"("uid") ON DELETE SET NULL, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_by" UUID REFERENCES "users"("uid") ON DELETE SET NULL, + + PRIMARY KEY("uid", "aid") +); diff --git a/src/db/postgres/migrations/1.init/index.ts b/src/db/postgres/migrations/1.init/index.ts new file mode 100644 index 0000000..4f8389d --- /dev/null +++ b/src/db/postgres/migrations/1.init/index.ts @@ -0,0 +1,14 @@ +import type { PgMigration } from "../types.js"; +import p1 from "./1.users.pg.sql"; +import p2 from "./2.abodes.pg.sql"; +import p3 from "./3.residents.pg.sql"; + +export const m1: PgMigration = { + id: 1, + name: "init", + parts: [ + { id: 1, name: "users", sql: p1 }, + { id: 2, name: "abodes", sql: p2 }, + { id: 3, name: "residents", sql: p3 }, + ], +}; diff --git a/src/db/postgres/migrations/2.auth/1.sessions.pg.sql b/src/db/postgres/migrations/2.auth/1.sessions.pg.sql new file mode 100644 index 0000000..f6b7126 --- /dev/null +++ b/src/db/postgres/migrations/2.auth/1.sessions.pg.sql @@ -0,0 +1,7 @@ +CREATE TABLE "sessions" ( + "uid" UUID NOT NULL REFERENCES "users"("uid") ON DELETE CASCADE, + "token" TEXT NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "expires_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days' +); diff --git a/src/db/postgres/migrations/2.auth/2.apikeys.pg.sql b/src/db/postgres/migrations/2.auth/2.apikeys.pg.sql new file mode 100644 index 0000000..1ca0723 --- /dev/null +++ b/src/db/postgres/migrations/2.auth/2.apikeys.pg.sql @@ -0,0 +1,9 @@ +CREATE TABLE "apikeys" ( + "uid" UUID NOT NULL REFERENCES "users"("uid") ON DELETE CASCADE, + "kid" UUID NOT NULL PRIMARY KEY, + "token" TEXT NOT NULL UNIQUE, + "name" TEXT NOT NULL, + "permissions" JSONB NOT NULL DEFAULT '{}', + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "expires_at" TIMESTAMPTZ +); diff --git a/src/db/postgres/migrations/2.auth/index.ts b/src/db/postgres/migrations/2.auth/index.ts new file mode 100644 index 0000000..64dd8eb --- /dev/null +++ b/src/db/postgres/migrations/2.auth/index.ts @@ -0,0 +1,12 @@ +import type { PgMigration } from "../types.js"; +import p1 from "./1.sessions.pg.sql"; +import p2 from "./2.apikeys.pg.sql"; + +export const m2: PgMigration = { + id: 2, + name: "auth", + parts: [ + { id: 1, name: "sessions", sql: p1 }, + { id: 2, name: "apikeys", sql: p2 }, + ], +}; diff --git a/src/db/postgres/migrations/3.notes/1.notes.pg.sql b/src/db/postgres/migrations/3.notes/1.notes.pg.sql new file mode 100644 index 0000000..fe0c49b --- /dev/null +++ b/src/db/postgres/migrations/3.notes/1.notes.pg.sql @@ -0,0 +1,11 @@ +CREATE TABLE "notes" ( + "nid" UUID NOT NULL PRIMARY KEY, + "aid" UUID NOT NULL REFERENCES "abodes"("aid") ON DELETE CASCADE, + "name" TEXT NOT NULL, + "content" TEXT NOT NULL DEFAULT '', + "properties" JSONB NOT NULL DEFAULT '{}', + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "created_by" UUID REFERENCES "users"("uid") ON DELETE SET NULL, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_by" UUID REFERENCES "users"("uid") ON DELETE SET NULL +); diff --git a/src/db/postgres/migrations/3.notes/index.ts b/src/db/postgres/migrations/3.notes/index.ts new file mode 100644 index 0000000..c60bf87 --- /dev/null +++ b/src/db/postgres/migrations/3.notes/index.ts @@ -0,0 +1,8 @@ +import type { PgMigration } from "../types.js"; +import p1 from "./1.notes.pg.sql"; + +export const m3: PgMigration = { + id: 3, + name: "notes", + parts: [{ id: 1, name: "notes", sql: p1 }], +}; diff --git a/src/db/postgres/migrations/index.ts b/src/db/postgres/migrations/index.ts new file mode 100644 index 0000000..77d6659 --- /dev/null +++ b/src/db/postgres/migrations/index.ts @@ -0,0 +1,7 @@ +import { m1 } from "./1.init/index.js"; +import { m2 } from "./2.auth/index.js"; +import { m3 } from "./3.notes/index.js"; +import type { PgMigration } from "./types.js"; + +export { default as init } from "./init.pg.sql"; +export const migrations: PgMigration[] = [m1, m2, m3]; diff --git a/src/db/postgres/migrations/init.pg.sql b/src/db/postgres/migrations/init.pg.sql new file mode 100644 index 0000000..0df0367 --- /dev/null +++ b/src/db/postgres/migrations/init.pg.sql @@ -0,0 +1,5 @@ +CREATE TABLE "_migrations" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "applied_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/src/db/postgres/migrations/types.ts b/src/db/postgres/migrations/types.ts new file mode 100644 index 0000000..9bcee59 --- /dev/null +++ b/src/db/postgres/migrations/types.ts @@ -0,0 +1,19 @@ +import type { WrappedPgClient } from "../pool.js"; + +export type PgMigrationPart = { + id: number; + name: string; +} & ( + | { + sql: string; + } + | { + apply: (client: WrappedPgClient) => Promise; + } +); + +export type PgMigration = { + id: number; + name: string; + parts: PgMigrationPart[]; +}; diff --git a/src/db/postgres/pool.ts b/src/db/postgres/pool.ts new file mode 100644 index 0000000..397519a --- /dev/null +++ b/src/db/postgres/pool.ts @@ -0,0 +1,127 @@ +import pg from "pg"; +import { ConflictAbodeError, NotFoundAbodeError } from "../types/errors.js"; +import type { SqlCode } from "./sql.js"; +import { toPositional } from "./sql.js"; + +export interface WrappedPgClient { + readonly: boolean; + destroy(): Promise; + all(stmt: SqlCode): Promise; + get(stmt: SqlCode): Promise; + run(stmt: SqlCode): Promise<{ changes: number }>; + multi(fn: (tx: WrappedPgClient) => Promise): Promise; + rethrow(fn: () => Promise): Promise; +} + +async function rethrow(fn: () => Promise): Promise { + try { + return await fn(); + } catch (e) { + if (e instanceof Error && "code" in e) { + switch ((e as NodeJS.ErrnoException).code) { + case "23505": + throw new ConflictAbodeError(); + case "23503": + throw new NotFoundAbodeError(); + } + } + throw e; + } +} + +class WrappedPgTx implements WrappedPgClient { + #client: pg.PoolClient; + #readonly: boolean; + + constructor(client: pg.PoolClient, readonly_: boolean) { + this.#client = client; + this.#readonly = readonly_; + } + + get readonly(): boolean { + return this.#readonly; + } + + async destroy(): Promise {} + + async all(stmt: SqlCode): Promise { + const result = await this.#client.query(toPositional(stmt._sql), stmt._vars); + return result.rows as R[]; + } + + async get(stmt: SqlCode): Promise { + const rows = await this.all(stmt); + if (rows.length > 1) throw new Error("Multiple results"); + return rows[0] ?? null; + } + + async run(stmt: SqlCode): Promise<{ changes: number }> { + const result = await this.#client.query(toPositional(stmt._sql), stmt._vars); + return { changes: result.rowCount ?? 0 }; + } + + multi(_fn: (tx: WrappedPgClient) => Promise): Promise { + throw new Error("Nested transactions not supported"); + } + + rethrow = rethrow; +} + +export class WrappedPool implements WrappedPgClient { + #pool: pg.Pool; + #readonly: boolean; + + constructor(connectionStringOrPool: string | pg.Pool, readonly_ = false) { + this.#pool = + typeof connectionStringOrPool === "string" + ? new pg.Pool({ connectionString: connectionStringOrPool }) + : connectionStringOrPool; + this.#readonly = readonly_; + } + + get _pool(): pg.Pool { + return this.#pool; + } + + get readonly(): boolean { + return this.#readonly; + } + + async destroy(): Promise { + await this.#pool.end(); + } + + async all(stmt: SqlCode): Promise { + const result = await this.#pool.query(toPositional(stmt._sql), stmt._vars); + return result.rows as R[]; + } + + async get(stmt: SqlCode): Promise { + const rows = await this.all(stmt); + if (rows.length > 1) throw new Error("Multiple results"); + return rows[0] ?? null; + } + + async run(stmt: SqlCode): Promise<{ changes: number }> { + const result = await this.#pool.query(toPositional(stmt._sql), stmt._vars); + return { changes: result.rowCount ?? 0 }; + } + + async multi(fn: (tx: WrappedPgClient) => Promise): Promise { + const client = await this.#pool.connect(); + const tx = new WrappedPgTx(client, this.#readonly); + try { + await client.query("BEGIN"); + const result = await fn(tx); + await client.query("COMMIT"); + return result; + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } + } + + rethrow = rethrow; +} diff --git a/src/db/postgres/query.ts b/src/db/postgres/query.ts new file mode 100644 index 0000000..bb2d4b3 --- /dev/null +++ b/src/db/postgres/query.ts @@ -0,0 +1,141 @@ +import type { Abode } from "../types/Abode.js"; +import type { ClientApikey } from "../types/Apikey.js"; +import type { Resident } from "../types/Resident.js"; +import type { ClientUser } from "../types/User.js"; +import { + pgToAbode, + pgToClientApikey, + pgToClientUser, + pgToResident, +} from "./cast.js"; +import type { WrappedPgClient } from "./pool.js"; +import { sql, type SqlCode } from "./sql.js"; + +type RawClientUser = { + uid: string; + email: string; + name: string; + flags: unknown; + created_at: Date; + updated_at: Date; +}; +const sqlClientUser = sql` + SELECT u."uid", u."email", u."name", u."flags", u."created_at", u."updated_at" + FROM "users" u +`; + +export async function selectClientUser( + db: WrappedPgClient, + where: SqlCode +): Promise { + const raw = await db.get( + sql`${sqlClientUser} WHERE ${where}` + ); + if (raw) return pgToClientUser(raw); + return null; +} +export async function selectClientUsers( + db: WrappedPgClient, + rest?: SqlCode +): Promise { + const rows = await db.all( + rest ? sql`${sqlClientUser} ${rest}` : sqlClientUser + ); + return rows.map(pgToClientUser); +} + +type RawAbode = { + aid: string; + name: string; + created_at: Date; + created_by: string | null; + updated_at: Date; + updated_by: string | null; +}; +const sqlAbode = sql` + SELECT a."aid", a."name", a."created_at", a."created_by", a."updated_at", a."updated_by" + FROM "abodes" a +`; + +export async function selectAbode( + db: WrappedPgClient, + where: SqlCode +): Promise { + const raw = await db.get(sql`${sqlAbode} WHERE ${where}`); + if (raw) return pgToAbode(raw); + return null; +} +export async function selectAbodes( + db: WrappedPgClient, + rest?: SqlCode +): Promise { + const rows = await db.all( + rest ? sql`${sqlAbode} ${rest}` : sqlAbode + ); + return rows.map(pgToAbode); +} + +type RawResident = { + uid: string; + aid: string; + flags: unknown; + created_at: Date; + created_by: string | null; + updated_at: Date; + updated_by: string | null; +}; +const sqlResident = sql` + SELECT "uid", "aid", "flags", "created_at", "created_by", "updated_at", "updated_by" + FROM "residents" +`; + +export async function selectResident( + db: WrappedPgClient, + where: SqlCode +): Promise { + const raw = await db.get(sql`${sqlResident} WHERE ${where}`); + if (raw) return pgToResident(raw); + return null; +} +export async function selectResidents( + db: WrappedPgClient, + where?: SqlCode +): Promise { + const rows = await db.all( + where ? sql`${sqlResident} WHERE ${where}` : sqlResident + ); + return rows.map(pgToResident); +} + +type RawClientApikey = { + uid: string; + kid: string; + name: string; + permissions: unknown; + created_at: Date; + expires_at: Date | null; +}; +const sqlClientApikey = sql` + SELECT k."uid", k."kid", k."name", k."permissions", k."created_at", k."expires_at" + FROM "apikeys" k +`; + +export async function selectClientApikey( + db: WrappedPgClient, + where: SqlCode +): Promise { + const raw = await db.get( + sql`${sqlClientApikey} WHERE ${where}` + ); + if (raw) return pgToClientApikey(raw); + return null; +} +export async function selectClientApikeys( + db: WrappedPgClient, + where: SqlCode +): Promise { + const rows = await db.all( + sql`${sqlClientApikey} WHERE ${where}` + ); + return rows.map(pgToClientApikey); +} diff --git a/src/db/postgres/sql.ts b/src/db/postgres/sql.ts new file mode 100644 index 0000000..65a9e6a --- /dev/null +++ b/src/db/postgres/sql.ts @@ -0,0 +1,79 @@ +export type SqlVar = string | number; +export type SqlCode = { _sql: string; _vars: SqlVar[] }; +type SqlArg = + | { uuid: string } + | { text: string } + | { jsonb: unknown } + | { date: string } + | { int: number } + | { null: true } + | SqlCode; + +export function sql(text: TemplateStringsArray, ...args: SqlArg[]): SqlCode { + let code = ""; + const vars: SqlVar[] = []; + for (const [i, part] of text.entries()) { + code += part; + if (i < args.length) { + const arg = args[i]; + if ("uuid" in arg) { + code += "?"; + vars.push(arg.uuid); + } else if ("text" in arg) { + code += "?"; + vars.push(arg.text); + } else if ("jsonb" in arg) { + code += "?::jsonb"; + vars.push(JSON.stringify(arg.jsonb)); + } else if ("date" in arg) { + code += "?::timestamptz"; + vars.push(arg.date); + } else if ("int" in arg) { + if (arg.int % 1) throw new Error("Not an integer"); + code += "?"; + vars.push(arg.int); + } else if ("null" in arg) { + code += "NULL"; + } else { + code += arg._sql; + for (const v of arg._vars) vars.push(v); + } + } + } + return { _sql: code, _vars: vars }; +} + +export function catSql(a: SqlCode, b: SqlCode): SqlCode { + return { + _sql: a._sql + b._sql, + _vars: [...a._vars, ...b._vars], + }; +} +export function joinSql(parts: SqlCode[], joiner: SqlCode): SqlCode { + return parts.reduce((a, b) => catSql(catSql(a, joiner), b)); +} + +export function unsafeSql(s: string): SqlCode { + return { _sql: s, _vars: [] }; +} + +export function calcUpdates(updater: { + [K in keyof T]: (value: NonNullable) => SqlCode; +}): (obj: Partial) => SqlCode[] { + return (obj) => { + const updates: SqlCode[] = []; + for (const [prop, update] of Object.entries(updater)) { + if (prop in obj) { + updates.push( + (update as (value: unknown) => SqlCode)(obj[prop as keyof T]!) + ); + } + } + return updates; + }; +} + +export function toPositional(sql: string): string { + let i = 0; + return sql.replace(/\?/g, () => `$${++i}`); +} diff --git a/src/db/postgres/url.ts b/src/db/postgres/url.ts new file mode 100644 index 0000000..ccb2eb6 --- /dev/null +++ b/src/db/postgres/url.ts @@ -0,0 +1,18 @@ +export const pgProtocols = ["postgres:", "postgresql:"]; + +export function isPgUrl(url: string): boolean { + try { + const urlObj = new URL(url); + return pgProtocols.includes(urlObj.protocol); + } catch { + return false; + } +} + +export function parsePgUrl(url: string): { connectionString: string; readonly: boolean } { + if (!isPgUrl(url)) throw new Error("Not a postgres: URL"); + const urlObj = new URL(url); + const readonly = (urlObj.searchParams.get("readonly") ?? "0") !== "0"; + urlObj.searchParams.delete("readonly"); + return { connectionString: urlObj.toString(), readonly }; +}