ls-deno/db/mod.ts

442 lines
11 KiB
TypeScript
Raw Normal View History

2022-09-30 15:14:57 -05:00
import {
Pool,
2022-10-08 00:00:45 -05:00
PoolClient,
2022-09-30 15:14:57 -05:00
PostgresError,
2022-10-21 03:06:37 -05:00
Transaction,
type TransactionOptions,
2022-09-30 15:14:57 -05:00
} from "https://deno.land/x/postgres@v0.16.1/mod.ts";
import {
type QueryArguments,
2022-10-07 23:22:35 -05:00
type QueryArrayResult,
2022-09-30 15:14:57 -05:00
type QueryObjectResult,
} from "https://deno.land/x/postgres@v0.16.1/query/query.ts?s=QueryArguments";
2022-10-08 02:01:48 -05:00
import * as base64 from "$std/encoding/base64.ts";
2022-10-11 12:20:25 -05:00
import { log } from "@/log.ts";
2022-10-07 23:22:35 -05:00
2022-11-09 16:55:27 -06:00
import { type PostgresConfig } from "@/config.ts";
2022-10-08 00:00:45 -05:00
import {
2022-10-21 03:06:37 -05:00
type Display,
2022-10-08 00:00:45 -05:00
type Note,
2022-10-21 03:06:37 -05:00
type Playlist,
2022-10-08 00:24:03 -05:00
type Team,
2022-10-21 03:06:37 -05:00
type TeamUser,
2022-10-08 02:01:48 -05:00
type Token,
type TokenDigest,
2022-10-21 03:06:37 -05:00
type Ungenerated,
2022-10-08 00:00:45 -05:00
type User,
} from "@/types.ts";
2022-09-27 15:49:41 -05:00
2022-10-08 02:01:48 -05:00
import { sha256 } from "https://denopkg.com/chiefbiiko/sha256@v1.0.0/mod.ts";
2022-09-30 15:14:57 -05:00
export { PostgresError };
export { type QueryObjectResult };
2022-09-27 15:49:41 -05:00
2022-11-09 16:55:27 -06:00
let pool: Pool;
export function initDatabaseConnectionPool({ url }: PostgresConfig) {
pool = new Pool(url, 3, true);
}
2022-09-27 15:49:41 -05:00
2022-10-21 03:06:37 -05:00
type QueryResult<T> = { rows: T[] } | null;
class NoRowsError<T> extends Error {
result: QueryResult<T>;
constructor(result: QueryResult<T>) {
const message = `No rows in query result: ${result}`;
super(message);
this.result = result;
}
}
class TooManyRowsError<T> extends Error {
result: QueryResult<T>;
constructor(result: QueryResult<T>) {
const message = `Too many rows in query result: ${result}`;
super(message);
this.result = result;
}
}
function someRows<T>(result: QueryResult<T>): T[] {
if (!result || result.rows.length < 1) {
throw new NoRowsError(result);
} else {
return result.rows;
}
}
function singleRow<T>(result: QueryResult<T>): 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<T>(
op: (connection: PoolClient) => Promise<T>,
): Promise<T> {
let result: T | null = null;
2022-10-08 00:00:45 -05:00
let exception = null;
2022-09-27 15:49:41 -05:00
try {
const connection = await pool.connect();
try {
2022-10-08 00:00:45 -05:00
result = await op(connection);
2022-09-27 15:49:41 -05:00
} catch (err) {
2022-11-09 16:55:27 -06:00
log.error("Error querying database:", err);
2022-10-08 00:00:45 -05:00
exception = err;
2022-09-27 15:49:41 -05:00
} finally {
connection.release();
}
} catch (err) {
2022-10-08 00:00:45 -05:00
exception = err;
2022-11-09 16:55:27 -06:00
log.critical("Error connecting to database:", err);
2022-09-27 15:49:41 -05:00
}
2022-10-08 00:00:45 -05:00
if (exception != null) throw exception;
2022-10-21 03:06:37 -05:00
if (result == null) {
throw "Database operation failed to properly load a result";
}
2022-09-27 15:49:41 -05:00
return result;
}
2022-10-07 23:22:35 -05:00
2022-10-08 00:00:45 -05:00
export async function queryObject<T>(
2022-10-07 23:22:35 -05:00
sql: string,
2022-10-08 00:00:45 -05:00
args?: QueryArguments,
2022-10-21 03:06:37 -05:00
connection?: PoolClient | Transaction,
2022-10-08 00:00:45 -05:00
): Promise<QueryObjectResult<T> | null> {
2022-10-21 03:06:37 -05:00
console.debug(`queryObject: ${sql}`);
if (!connection) {
return await dbOp(async (connection) => {
return await queryObject(sql, args, connection);
});
} else {
2022-10-08 02:53:13 -05:00
const result = await connection.queryObject<T>({
2022-10-08 00:00:45 -05:00
camelcase: true,
text: sql.trim(),
args,
2022-10-08 02:53:13 -05:00
});
2022-10-21 03:06:37 -05:00
log.debug("queryObject Result:", result);
2022-10-08 02:53:13 -05:00
return result;
2022-10-21 03:06:37 -05:00
}
2022-10-08 00:00:45 -05:00
}
export async function queryArray<T extends []>(
sql: string,
args?: QueryArguments,
2022-10-21 03:06:37 -05:00
connection?: PoolClient,
2022-10-08 00:00:45 -05:00
): Promise<QueryArrayResult<T> | null> {
2022-10-21 03:06:37 -05:00
console.debug(`queryArray: ${sql}`);
if (!connection) {
return await dbOp(async (connection) => {
return await queryArray<T>(sql, args, connection);
});
} else {
const result = await connection.queryArray<T>({
2022-10-08 00:00:45 -05:00
text: sql.trim(),
args,
2022-10-21 03:06:37 -05:00
});
log.debug("queryArray Result:", result);
return result;
}
2022-10-07 23:22:35 -05:00
}
2022-10-21 03:06:37 -05:00
export async function listNotes(): Promise<(Note & User)[]> {
2022-10-08 00:24:03 -05:00
return someRows(
2022-10-08 02:53:13 -05:00
await queryObject<Note & User>(
'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',
2022-10-08 00:24:03 -05:00
),
2022-10-07 23:22:35 -05:00
);
}
2022-10-08 00:24:03 -05:00
export async function getNote(
id: string | { id: string },
2022-10-21 03:06:37 -05:00
): Promise<Note> {
2022-10-07 23:22:35 -05:00
const idVal = typeof id == "object" ? id.id : id;
2022-10-11 12:20:25 -05:00
log.debug("getNote id =", JSON.stringify(idVal));
2022-10-08 00:24:03 -05:00
return singleRow(
await queryObject<Note>(
"select * from note where id = $1",
[idVal],
),
2022-10-07 23:22:35 -05:00
);
}
2022-10-08 00:24:03 -05:00
export async function createNote(
2022-10-08 02:53:13 -05:00
{ content, userId }: Ungenerated<Note>,
2022-10-08 00:24:03 -05:00
): Promise<Note | null> {
return singleRow(
await queryObject<Note>(
2022-10-08 02:53:13 -05:00
"insert into note (content, user_id) values ($1, $2) returning *",
[content, userId],
2022-10-08 00:24:03 -05:00
),
2022-10-07 23:22:35 -05:00
);
}
2022-10-08 00:00:45 -05:00
2022-10-21 03:06:37 -05:00
export async function createTeamUser(
{ teamId, userId, status }: TeamUser,
transaction?: Transaction,
): Promise<TeamUser | null> {
return singleRow(
await queryObject<TeamUser>(
2022-10-08 00:24:03 -05:00
`
2022-10-21 03:06:37 -05:00
insert into "team_user" (user_id, team_id, status)
values (
$userId,
$teamId,
$status
) returning *`,
{ userId, teamId, status },
transaction,
2022-10-08 00:24:03 -05:00
),
2022-10-08 00:00:45 -05:00
);
2022-10-21 03:06:37 -05:00
}
export async function createTeam(
data: {
team: Ungenerated<Team>;
creator?: User;
},
transaction?: Transaction,
): Promise<Team> {
console.debug("createTeam tx:", transaction);
if (!transaction) {
return await wrapWithTransaction<Team>(
"createTeam",
(t) => createTeam(data, t),
);
} else {
try {
const { team: { displayName }, creator } = data;
const team = singleRow(
await queryObject<Team>(
`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<T>(
transactionName: string,
callback: (transaction: Transaction) => Promise<T>,
transactionOptions?: TransactionOptions,
): Promise<T> {
const result = await dbOp<T>(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<User>,
transaction?: Transaction,
): Promise<User> {
if (!transaction) {
return await wrapWithTransaction<User>(
"createUser",
(t) => createUser(data, t),
);
} else {
try {
const { username, passwordDigest } = data;
const user = singleRow(
await queryObject<User>(
`insert into "user" (username, password_digest)
values ($username, $passwordDigest)
returning *`,
{ username, passwordDigest },
transaction,
),
);
await createTeam({
2022-11-09 16:55:27 -06:00
team: { displayName: `${username}'s Team` },
2022-10-21 03:06:37 -05:00
creator: user,
}, transaction);
return user;
} catch (e) {
console.error("Error creating user:", e);
throw e;
}
}
2022-10-08 00:00:45 -05:00
}
2022-10-08 02:01:48 -05:00
const TOKEN_SIZE = 32;
export async function createToken(
token: Omit<Ungenerated<Token>, "digest">,
2022-10-21 03:06:37 -05:00
): Promise<Token> {
2022-10-08 02:01:48 -05:00
const intermediateToken: Partial<Token> = { ...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;
}
2022-10-12 03:31:26 -05:00
log.debug(
`intermediateToken bytes: ${base64.encode(intermediateToken.bytes)}`,
);
log.debug(
`intermediateToken digest: ${base64.encode(intermediateToken.digest)}`,
);
2022-10-08 02:01:48 -05:00
if (!intermediateToken.data) intermediateToken.data = null;
const result = singleRow(
await queryObject<Token>(
`
2022-10-21 03:06:37 -05:00
insert into "token" (digest, user_id, data)
2022-10-08 02:01:48 -05:00
values ($digest, $userId, $data)
returning *
`,
intermediateToken,
),
);
2022-10-21 03:06:37 -05:00
return { ...intermediateToken, ...result };
2022-10-08 02:01:48 -05:00
}
2022-10-12 03:31:26 -05:00
export async function deleteToken(
token: TokenDigest,
) {
const digest = sha256(base64.decode(token));
return await queryObject(
2022-10-21 03:06:37 -05:00
`delete from "token" where digest = $1`,
2022-10-12 03:31:26 -05:00
[digest],
);
}
2022-10-21 03:06:37 -05:00
export async function getToken(token: TokenDigest): Promise<Token> {
2022-10-12 03:31:26 -05:00
const digest = sha256(base64.decode(token));
2022-10-08 02:01:48 -05:00
return singleRow(
await queryObject(
2022-10-21 03:06:37 -05:00
`select * from "token" where digest = $1`,
2022-10-08 02:01:48 -05:00
[digest],
),
);
}
2022-10-08 00:24:03 -05:00
export async function getUser(
2022-10-21 03:06:37 -05:00
idOrUsername: { id: string } | { username: string } | string,
): Promise<User> {
if (typeof idOrUsername == "string") {
try {
return singleRow(
await queryObject<User>(
`select * from "user" where "id" = $1`,
[idOrUsername],
),
);
} catch (_) {
return singleRow(
await queryObject<User>(
`select * from "user" where "username" = $1`,
[idOrUsername],
),
);
}
} else {
const column = "id" in idOrUsername ? "id" : "username";
return singleRow(
await queryObject<User>(
`select * from "user" where "${column}" = $1`,
[(idOrUsername as { id?: string; username?: string })[column]],
),
);
}
2022-10-08 00:00:45 -05:00
}
2022-10-08 00:24:03 -05:00
2022-10-08 02:01:48 -05:00
export async function getUserFromNonExpiredLoginToken(
token: TokenDigest,
2022-10-21 03:06:37 -05:00
): Promise<User> {
2022-10-12 03:31:26 -05:00
// TODO: if the token has expired, return a specific error?
2022-10-08 02:01:48 -05:00
const digest = sha256(base64.decode(token));
return singleRow(
await queryObject<User>(
2022-10-21 03:06:37 -05:00
`select u.* from "token" ut
2022-10-08 02:01:48 -05:00
left join "user" u on u.id = ut.user_id
where ut."digest" = $1
and ut."data"->>'type' = 'login'
2022-10-21 03:06:37 -05:00
and now() < (ut.created_at + '14 days'::interval)`,
2022-10-08 02:01:48 -05:00
[digest],
),
);
}
2022-10-08 00:24:03 -05:00
export async function getTeam(
{ id }: Partial<Team>,
2022-10-21 03:06:37 -05:00
): Promise<Team> {
2022-10-08 00:24:03 -05:00
return singleRow(
await queryObject<Team>(
`select * from "team" where "id" = $1`,
[id],
),
);
}
2022-10-21 03:06:37 -05:00
export async function getUserTeams(
{ id }: Partial<User>,
): Promise<Team[]> {
return someRows(
await queryObject<Team>(
`select t.* from "team" t
left join "team_user" tu on t.id = tu.team_id
where tu."user_id" = $1`,
[id],
),
);
2022-10-08 00:24:03 -05:00
}
2022-10-21 03:06:37 -05:00
export async function getTeamUsers(
{ id }: Partial<Team>,
): Promise<User[]> {
return someRows(
await queryObject<User>(
`select u.* from "user" u
left join "team_user" tu on u.id = tu.user_id
where tu."team_id" = $1`,
[id],
),
);
2022-10-08 00:24:03 -05:00
}
2022-10-21 03:06:37 -05:00
// export async function createDisplay(display: Ungenerated<Display>, transaction?: Transaction) {
// display
// }