import { Pool, PoolClient, PostgresError, Transaction, type TransactionOptions, } from "https://deno.land/x/postgres@v0.16.1/mod.ts"; import { type QueryArguments, type QueryArrayResult, type QueryObjectResult, } from "https://deno.land/x/postgres@v0.16.1/query/query.ts?s=QueryArguments"; import * as base64 from "$std/encoding/base64.ts"; import { log } from "@/log.ts"; import { type PostgresConfig } from "@/config.ts"; import { type Display, type Note, type Playlist, type Team, type TeamUser, type Token, type TokenDigest, type Ungenerated, type User, } from "@/types.ts"; import { sha256 } from "https://denopkg.com/chiefbiiko/sha256@v1.0.0/mod.ts"; export { PostgresError }; export { type QueryObjectResult }; let pool: Pool; export function initDatabaseConnectionPool({ url }: PostgresConfig) { pool = new Pool(url, 3, true); testDbConnection(); } export async function testDbConnection(): Promise { try { await dbOp((conn) => conn.queryObject("select 1")); log.info("Successfully connected to database"); return true; } catch (e) { log.critical("Failed to connect to database:", e); return false; } } type QueryResult = { rows: T[] } | null; class NoRowsError extends Error { result: QueryResult; constructor(result: QueryResult) { const message = `No rows in query result: ${result}`; super(message); this.result = result; } } class TooManyRowsError extends Error { result: QueryResult; constructor(result: QueryResult) { const message = `Too many rows in query result: ${result}`; super(message); this.result = result; } } function someRows(result: QueryResult): T[] { if (!result || result.rows.length < 1) { throw new NoRowsError(result); } else { return result.rows; } } function singleRow(result: QueryResult): T { if (!result || result.rows.length < 1) throw new NoRowsError(result); else if (result.rows.length > 1) throw new TooManyRowsError(result); else return result.rows[0]; } export async function dbOp( op: (connection: PoolClient) => Promise, ): Promise { let result: T | null = null; let exception = null; try { const connection = await pool.connect(); try { result = await op(connection); } catch (err) { log.error("Error querying database:", err); exception = err; } finally { connection.release(); } } catch (err) { exception = err; log.critical("Error connecting to database:", err); } if (exception != null) throw exception; if (result == null) { throw "Database operation failed to properly load a result"; } return result; } export async function queryObject( sql: string, args?: QueryArguments, connection?: PoolClient | Transaction, ): Promise | null> { console.debug(`queryObject: ${sql}`); if (!connection) { return await dbOp(async (connection) => { return await queryObject(sql, args, connection); }); } else { const result = await connection.queryObject({ camelcase: true, text: sql.trim(), args, }); log.debug("queryObject Result:", result); return result; } } export async function queryArray( sql: string, args?: QueryArguments, connection?: PoolClient, ): Promise | null> { console.debug(`queryArray: ${sql}`); if (!connection) { return await dbOp(async (connection) => { return await queryArray(sql, args, connection); }); } else { const result = await connection.queryArray({ text: sql.trim(), args, }); log.debug("queryArray Result:", result); return result; } } export async function listNotes(): Promise<(Note & User)[]> { return someRows( await queryObject( 'select u.username as user_username, u.display_name as user_display_name, n.* from note n left join "user" u on u.id = n.user_id order by n.created_at desc', ), ); } export async function getNote( id: string | { id: string }, ): Promise { const idVal = typeof id == "object" ? id.id : id; log.debug("getNote id =", JSON.stringify(idVal)); return singleRow( await queryObject( "select * from note where id = $1", [idVal], ), ); } export async function createNote( { content, userId }: Ungenerated, ): Promise { return singleRow( await queryObject( "insert into note (content, user_id) values ($1, $2) returning *", [content, userId], ), ); } export async function createTeamUser( { teamId, userId, status }: TeamUser, transaction?: Transaction, ): Promise { return singleRow( await queryObject( ` insert into "team_user" (user_id, team_id, status) values ( $userId, $teamId, $status ) returning *`, { userId, teamId, status }, transaction, ), ); } export async function createTeam( data: { team: Ungenerated; creator?: User; }, transaction?: Transaction, ): Promise { console.debug("createTeam tx:", transaction); if (!transaction) { return await wrapWithTransaction( "createTeam", (t) => createTeam(data, t), ); } else { try { const { team: { displayName }, creator } = data; const team = singleRow( await queryObject( `insert into "team" (display_name) values ($displayName) returning *`, { displayName }, transaction, ), ); if (creator) { await createTeamUser( { teamId: team.id, userId: creator.id, status: "owner" }, transaction, ); } return team; } catch (e) { console.error("Error creating team:", e); throw e; } } } export async function wrapWithTransaction( transactionName: string, callback: (transaction: Transaction) => Promise, transactionOptions?: TransactionOptions, ): Promise { const result = await dbOp(async (connection) => { try { const transaction = connection.createTransaction( transactionName, transactionOptions, ); try { await transaction.begin(); console.debug( `started ${transactionName} tx with options ${ JSON.stringify(transactionOptions) }:`, transaction, ); const result: T = await callback(transaction); await transaction.commit(); return result; } catch (e) { await transaction.rollback(); console.error("Failed to complete transaction:", e); throw e; } } catch (e) { console.error("Failed to create transaction"); throw e; } }); if (!result) throw "Failed to finish transactional database operation"; return result; } export async function createUser( data: Ungenerated, transaction?: Transaction, ): Promise { if (!transaction) { return await wrapWithTransaction( "createUser", (t) => createUser(data, t), ); } else { try { const { username, passwordDigest } = data; const user = singleRow( await queryObject( `insert into "user" (username, password_digest) values ($username, $passwordDigest) returning *`, { username, passwordDigest }, transaction, ), ); await createTeam({ team: { displayName: `${username}'s Team` }, creator: user, }, transaction); return user; } catch (e) { console.error("Error creating user:", e); throw e; } } } const TOKEN_SIZE = 32; export async function createToken( token: Omit, "digest">, ): Promise { const intermediateToken: Partial = { ...token }; if (!intermediateToken.bytes) { intermediateToken.bytes = new Uint8Array(TOKEN_SIZE); crypto.getRandomValues(intermediateToken.bytes); } if (!intermediateToken.digest) { const digest = sha256(intermediateToken.bytes); if (!(digest instanceof Uint8Array)) throw "token digest was non-brinary"; intermediateToken.digest = digest; } log.debug( `intermediateToken bytes: ${base64.encode(intermediateToken.bytes)}`, ); log.debug( `intermediateToken digest: ${base64.encode(intermediateToken.digest)}`, ); if (!intermediateToken.data) intermediateToken.data = null; const result = singleRow( await queryObject( ` insert into "token" (digest, user_id, data) values ($digest, $userId, $data) returning * `, intermediateToken, ), ); return { ...intermediateToken, ...result }; } export async function deleteToken( token: TokenDigest, ) { const digest = sha256(base64.decode(token)); return await queryObject( `delete from "token" where digest = $1`, [digest], ); } export async function getToken(token: TokenDigest): Promise { const digest = sha256(base64.decode(token)); return singleRow( await queryObject( `select * from "token" where digest = $1`, [digest], ), ); } export async function getUser( idOrUsername: { id: string } | { username: string } | string, ): Promise { if (typeof idOrUsername == "string") { try { return singleRow( await queryObject( `select * from "user" where "id" = $1`, [idOrUsername], ), ); } catch (_) { return singleRow( await queryObject( `select * from "user" where "username" = $1`, [idOrUsername], ), ); } } else { const column = "id" in idOrUsername ? "id" : "username"; return singleRow( await queryObject( `select * from "user" where "${column}" = $1`, [(idOrUsername as { id?: string; username?: string })[column]], ), ); } } // TODO: refresh token? export async function getUserFromNonExpiredLoginToken( token: TokenDigest, ): Promise { // TODO: if the token has expired, return a specific error? const digest = sha256(base64.decode(token)); return singleRow( await queryObject( `select u.* from "token" ut left join "user" u on u.id = ut.user_id where ut."digest" = $1 and ut."data"->>'type' = 'login' and now() < (ut.created_at + '14 days'::interval)`, [digest], ), ); } export async function getTeam( { id }: Partial, ): Promise { return singleRow( await queryObject( `select * from "team" where "id" = $1`, [id], ), ); } export async function getUserTeams( { id }: Partial, ): Promise { return someRows( await queryObject( `select t.* from "team" t left join "team_user" tu on t.id = tu.team_id where tu."user_id" = $1`, [id], ), ); } export async function getTeamUsers( { id }: Partial, ): Promise { return someRows( await queryObject( `select u.* from "user" u left join "team_user" tu on u.id = tu.user_id where tu."team_id" = $1`, [id], ), ); } // export async function createDisplay(display: Ungenerated, transaction?: Transaction) { // display // }