diff --git a/config.ts b/config.ts index cc45973..33370f2 100644 --- a/config.ts +++ b/config.ts @@ -1,11 +1,24 @@ -import file from "@/config.json" assert { type: "json" }; +export interface Config { + postgres: { + url: string; + }; + mailgun: { + apiKey?: string; + domain?: string; + }; +} -const defaults: Record unknown]> = { - DATABASE_URL: ["postgresql://postgres:@127.0.0.1:5432/lyricscreen", +function envOrWarn(key: string, fallback?: string): string | undefined { + const val = Deno.env.get(key); + if (!val) console.warn(`${key} is not set!`); + return val || fallback; +} + +export const config = { + postgres: { + url: envOrWarn( + "POSTGRES_URL", + "postgresql://postgres:@127.0.0.1:5432/lyricscreen", + ), + }, }; - -export const envs = Object.entries(defaults).map(([key, default]) => { - const val: string | null = Deno.env.get(key) || null; - if (!val) console.warn(`${key} not set!`); - return val; -}); diff --git a/db/migrations.ts b/db/migrations.ts index 98a5405..cc170cd 100644 --- a/db/migrations.ts +++ b/db/migrations.ts @@ -1,4 +1,4 @@ -import { query } from "@/db/mod.ts"; +import { queryArray } from "@/db/mod.ts"; const id = "id uuid primary key default generate_ulid()"; @@ -14,6 +14,22 @@ const updatedAtTimestamp = "updated_at timestamptz not null default now()"; const timestamps = [createdAtTimestamp, updatedAtTimestamp]; +const extensions = [ + "uuid-ossp", + "pgcrypto", +]; + +const functions = [ + ` + create or replace function generate_ulid() returns uuid + as $$ + select (lpad(to_hex(floor(extract(epoch from clock_timestamp()) * 1000)::bigint), 12, '0') || encode(gen_random_bytes(10), 'hex'))::uuid; + $$ language sql + `, + ` + `, +]; + const tables: Record = { "note": { columns: [id, "content text not null", ...timestamps], @@ -41,8 +57,7 @@ const tables: Record = { "create type user_token_type as enum ('session', 'reset')", ], columns: [ - id, - "token_digest bytea not null unique", + "token_digest bytea unique", "type user_token_type not null", "sent_to text not null", createdAtTimestamp, @@ -86,45 +101,60 @@ const tables: Record = { }, }; -const dropTables = Object.entries(tables).reverse().map(([name, _meta]) => - `drop table if exists "${name}";` +const createExtensions = extensions.map((s) => + `create extension if not exists "${s.trim()}";` ).join("\n"); -const createTables = Object.entries(tables).map(([name, meta]) => ` +const createFunctions = functions.map((s) => s.trim() + ";").join("\n"); +const dropTables = Object.entries(tables).reverse().map(([name, _meta]) => + `drop table if exists "${name.trim()}";` +).join("\n"); + +const createTables = Object.entries(tables).map(([rawName, meta]) => { + const name = rawName.trim(); + return ` -- CREATE TABLE ${name} -${(meta.prepStatements || []).map((s) => `${s};`).join("\n")} +${(meta.prepStatements || []).map((s) => `${s.trim()};`).join("\n")} create table "${name}" ( - ${meta.columns.concat(meta.additionalTableStatements || []).join(",\n ")} + ${ + meta.columns.concat(meta.additionalTableStatements || []).map((s) => + s.trim() + ).join(",\n ") + } ); -${(meta.additionalStatements || []).map((s) => `${s};`).join("\n")} -`).map((s) => s.trim()).join("\n\n"); +${(meta.additionalStatements || []).map((s) => `${s.trim()};`).join("\n")} +`; +}).map((s) => s.trim()).join("\n\n"); const queryString = ` begin; ${dropTables} -create extension if not exists "uuid-ossp"; -create extension if not exists "pgcrypto"; +${createExtensions} -create or replace function generate_ulid() returns uuid - as $$ - select (lpad(to_hex(floor(extract(epoch from clock_timestamp()) * 1000)::bigint), 12, '0') || encode(gen_random_bytes(10), 'hex'))::uuid; - $$ language sql; +${createFunctions} ${createTables} commit; `; -const setupResult = await query(queryString); - -console.debug(setupResult); console.log(queryString); +try { + const setupResult = await queryArray(queryString); + console.debug(setupResult); +} catch (err) { + console.log("Failed to run migration setup query:", { ...err }); + throw err; +} + const seedQuery = ` +insert into note (content) values ('Hello, notes!'); + -- TODO: create reserved usernames? with new_user as ( @@ -145,7 +175,12 @@ insert into "team_user" (user_id, team_id, status) `; -const seedResult = await query(seedQuery); - -console.debug(seedResult); console.log(seedQuery); + +try { + const seedResult = await queryArray(seedQuery); + console.debug(seedResult); +} catch (err) { + console.log("Failed to run migration seed query:", { ...err }); + throw err; +} diff --git a/db/mod.ts b/db/mod.ts index 96cbbd9..21ea497 100644 --- a/db/mod.ts +++ b/db/mod.ts @@ -4,17 +4,19 @@ import { } 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 { config } from "@/config.ts"; + +import { type Note } from "@/types.ts"; export { PostgresError }; export { type QueryObjectResult }; -const databaseUrl = Deno.env.get("DATABASE_URL") || - "postgresql://postgres:@127.0.0.1:5432/lyricscreen"; -const pool = new Pool(databaseUrl, 3, true); +const pool = new Pool(config.postgres.url, 3, true); -export async function query( +export async function queryObject( sql: string, ...args: QueryArguments[] ): Promise | null> { @@ -22,9 +24,13 @@ export async function query( try { const connection = await pool.connect(); try { - result = connection.queryObject(sql, ...args); + result = await connection.queryObject({ + camelcase: true, + text: sql, + args, + }); } catch (err) { - console.error("Error querying database:", err); + console.error("Error querying database:", { ...err }); } finally { connection.release(); } @@ -33,3 +39,48 @@ export async function query( } return result; } + +export async function queryArray( + sql: string, + ...args: QueryArguments[] +): Promise | null> { + let result = null; + try { + const connection = await pool.connect(); + try { + result = await connection.queryArray({ + text: sql, + args, + }); + } catch (err) { + console.error("Error querying database:", { ...err }); + } finally { + connection.release(); + } + } catch (err) { + console.error("Error connecting to database:", err); + } + return result; +} + +export async function listNotes() { + return await queryObject( + "select * from note order by created_at desc", + ); +} + +export async function getNote(id: string | { id: string }) { + const idVal = typeof id == "object" ? id.id : id; + console.debug("getNote id =", idVal); + return await queryObject( + "select * from note where id = $1", + [idVal], + ); +} + +export async function createNote({ content }: Omit) { + return await queryObject( + "insert into note (content) values ($1) returning *", + [content], + ); +} diff --git a/routes/login.tsx b/routes/login.tsx index a397319..342d7f9 100644 --- a/routes/login.tsx +++ b/routes/login.tsx @@ -1,7 +1,7 @@ import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts"; import { compare } from "https://deno.land/x/bcrypt@v0.4.1/mod.ts"; import { Page } from "@/components/Page.tsx"; -import { query } from "@/db/mod.ts"; +import { queryObject } from "@/db/mod.ts"; type UserID = string; @@ -25,10 +25,10 @@ export const handler: Handlers = { return await context.render({ message: "no password provided" }); } - const result = await query< + const result = await queryObject< { id: string; username: string; password_digest: string } >( - `select * from "user" where username = $1`, + `select id, username, password_digest from "user" where username = $1`, [username], ); if (result == null || result.rows.length < 1) { diff --git a/routes/note.tsx b/routes/note.tsx index 4534033..294e0d3 100644 --- a/routes/note.tsx +++ b/routes/note.tsx @@ -1,19 +1,13 @@ import { Handlers, PageProps } from "$fresh/server.ts"; -import { query } from "@/db/mod.ts"; +import { listNotes } from "@/db/mod.ts"; import { Page } from "@/components/Page.tsx"; - -interface Note { - id: string; - created_at: Date; - content: string; -} +import { type Note } from "@/types.ts"; export const handler: Handlers = { async GET(_request, context) { - const result = await query("select * from note order by created_at desc"); - if (result == null) throw "unable to fetch from database"; - const notes = result.rows as Note[]; - return await context.render(notes); + const result = await listNotes(); + if (!result) throw "unable to fetch from database"; + return await context.render(result.rows); }, }; @@ -27,11 +21,11 @@ export default function NotesPage({ data: notes }: PageProps) { - {notes.map(({ id, content, created_at }) => ( + {notes.map(({ id, content, createdAt }) => (
Note {id}{" "} - created at {created_at.toLocaleString()} + created at {createdAt.toLocaleString()}
{content}
diff --git a/routes/note/[id].tsx b/routes/note/[id].tsx index c5d4374..a941199 100644 --- a/routes/note/[id].tsx +++ b/routes/note/[id].tsx @@ -1,32 +1,25 @@ import { Handlers, PageProps } from "$fresh/server.ts"; -import { query } from "@/db/mod.ts"; +import { getNote } from "@/db/mod.ts"; import { Page } from "@/components/Page.tsx"; - -interface Note { - id: string; - created_at: Date; - content: string; -} +import { type Note } from "@/types.ts"; export const handler: Handlers = { async GET(request, context) { console.debug({ request, context }); - const result = await query( - "select * from note where id = $1 order by created_at desc", - [context.params["id"]], - ); - if (result == null) throw "unable to fetch from database"; + const result = await getNote(context.params.id); + if (!result) throw "unable to fetch from database"; const [note] = result.rows as Note[]; return await context.render(note); }, }; export default function NotesPage( - { data: { id, created_at, content } }: PageProps, + { data: { id, createdAt, content } }: PageProps, ) { return ( -

Note {id} created at {created_at.toLocaleString()}

+ Back to notes +

Note {id} created at {createdAt.toLocaleString()}

{content}
diff --git a/routes/note/create.tsx b/routes/note/create.tsx index dae53f9..f6c48d1 100644 --- a/routes/note/create.tsx +++ b/routes/note/create.tsx @@ -1,5 +1,5 @@ import { Handlers, PageProps } from "$fresh/server.ts"; -import { query } from "@/db/mod.ts"; +import { queryObject } from "@/db/mod.ts"; import { Page } from "@/components/Page.tsx"; type NoteID = string; @@ -8,7 +8,7 @@ export const handler: Handlers = { async POST(request, context) { const content = (await request.formData()).get("content"); if (!content) throw "no content provided"; - const result = await query<{ id: string }>( + const result = await queryObject<{ id: string }>( "insert into note (content) values ($1) returning id", [content], ); diff --git a/routes/register.tsx b/routes/register.tsx index a2966b2..d3059d3 100644 --- a/routes/register.tsx +++ b/routes/register.tsx @@ -1,6 +1,6 @@ import { Handlers, PageProps } from "$fresh/server.ts"; import { Page } from "@/components/Page.tsx"; -import { PostgresError, query } from "@/db/mod.ts"; +import { PostgresError, queryObject } from "@/db/mod.ts"; import { hash } from "https://deno.land/x/bcrypt@v0.4.1/mod.ts"; type UserID = string; @@ -23,7 +23,7 @@ export const handler: Handlers = { } const password_digest = await hash(password.toString()); try { - const result = await query<{ user_id: string }>( + const result = await queryObject<{ user_id: string }>( ` with new_user as ( insert into "user" (username, password_digest, status) @@ -62,7 +62,7 @@ export const handler: Handlers = { ) { return await context.render({ message: - `Username must ONLY be comprised of letters, number, dashes, and underscores`, + `Username must ONLY be comprised of letters, number, dashes, and underscores and must be 2 to 79 characters long`, }); } throw err; diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..9d6bf44 --- /dev/null +++ b/types.ts @@ -0,0 +1,5 @@ +export interface Note { + id: string; + createdAt: Date; + content: string; +}