Blue like jazz

This commit is contained in:
Daniel Flanagan 2022-10-07 23:22:35 -05:00
parent b186a40103
commit a3ad382437
Signed by: lytedev
GPG key ID: 5B2020A0F9921EF4
9 changed files with 163 additions and 72 deletions

View file

@ -1,11 +1,24 @@
import file from "@/config.json" assert { type: "json" }; export interface Config {
postgres: {
const defaults: Record<string, [string, (val: string) => unknown]> = { url: string;
DATABASE_URL: ["postgresql://postgres:@127.0.0.1:5432/lyricscreen",
}; };
mailgun: {
apiKey?: string;
domain?: string;
};
}
export const envs = Object.entries(defaults).map(([key, default]) => { function envOrWarn(key: string, fallback?: string): string | undefined {
const val: string | null = Deno.env.get(key) || null; const val = Deno.env.get(key);
if (!val) console.warn(`${key} not set!`); if (!val) console.warn(`${key} is not set!`);
return val; return val || fallback;
}); }
export const config = {
postgres: {
url: envOrWarn(
"POSTGRES_URL",
"postgresql://postgres:@127.0.0.1:5432/lyricscreen",
),
},
};

View file

@ -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()"; 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 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<string, TableSpec> = { const tables: Record<string, TableSpec> = {
"note": { "note": {
columns: [id, "content text not null", ...timestamps], columns: [id, "content text not null", ...timestamps],
@ -41,8 +57,7 @@ const tables: Record<string, TableSpec> = {
"create type user_token_type as enum ('session', 'reset')", "create type user_token_type as enum ('session', 'reset')",
], ],
columns: [ columns: [
id, "token_digest bytea unique",
"token_digest bytea not null unique",
"type user_token_type not null", "type user_token_type not null",
"sent_to text not null", "sent_to text not null",
createdAtTimestamp, createdAtTimestamp,
@ -86,45 +101,60 @@ const tables: Record<string, TableSpec> = {
}, },
}; };
const dropTables = Object.entries(tables).reverse().map(([name, _meta]) => const createExtensions = extensions.map((s) =>
`drop table if exists "${name}";` `create extension if not exists "${s.trim()}";`
).join("\n"); ).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} -- CREATE TABLE ${name}
${(meta.prepStatements || []).map((s) => `${s};`).join("\n")} ${(meta.prepStatements || []).map((s) => `${s.trim()};`).join("\n")}
create table "${name}" ( 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")} ${(meta.additionalStatements || []).map((s) => `${s.trim()};`).join("\n")}
`).map((s) => s.trim()).join("\n\n"); `;
}).map((s) => s.trim()).join("\n\n");
const queryString = ` const queryString = `
begin; begin;
${dropTables} ${dropTables}
create extension if not exists "uuid-ossp"; ${createExtensions}
create extension if not exists "pgcrypto";
create or replace function generate_ulid() returns uuid ${createFunctions}
as $$
select (lpad(to_hex(floor(extract(epoch from clock_timestamp()) * 1000)::bigint), 12, '0') || encode(gen_random_bytes(10), 'hex'))::uuid;
$$ language sql;
${createTables} ${createTables}
commit; commit;
`; `;
const setupResult = await query(queryString);
console.debug(setupResult);
console.log(queryString); 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 = ` const seedQuery = `
insert into note (content) values ('Hello, notes!');
-- TODO: create reserved usernames? -- TODO: create reserved usernames?
with new_user as ( 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); 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;
}

View file

@ -4,17 +4,19 @@ import {
} from "https://deno.land/x/postgres@v0.16.1/mod.ts"; } from "https://deno.land/x/postgres@v0.16.1/mod.ts";
import { import {
type QueryArguments, type QueryArguments,
type QueryArrayResult,
type QueryObjectResult, type QueryObjectResult,
} from "https://deno.land/x/postgres@v0.16.1/query/query.ts?s=QueryArguments"; } 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 { PostgresError };
export { type QueryObjectResult }; export { type QueryObjectResult };
const databaseUrl = Deno.env.get("DATABASE_URL") || const pool = new Pool(config.postgres.url, 3, true);
"postgresql://postgres:@127.0.0.1:5432/lyricscreen";
const pool = new Pool(databaseUrl, 3, true);
export async function query<T>( export async function queryObject<T>(
sql: string, sql: string,
...args: QueryArguments[] ...args: QueryArguments[]
): Promise<QueryObjectResult<T> | null> { ): Promise<QueryObjectResult<T> | null> {
@ -22,9 +24,13 @@ export async function query<T>(
try { try {
const connection = await pool.connect(); const connection = await pool.connect();
try { try {
result = connection.queryObject<T>(sql, ...args); result = await connection.queryObject<T>({
camelcase: true,
text: sql,
args,
});
} catch (err) { } catch (err) {
console.error("Error querying database:", err); console.error("Error querying database:", { ...err });
} finally { } finally {
connection.release(); connection.release();
} }
@ -33,3 +39,48 @@ export async function query<T>(
} }
return result; return result;
} }
export async function queryArray<T extends unknown>(
sql: string,
...args: QueryArguments[]
): Promise<QueryArrayResult<T[]> | null> {
let result = null;
try {
const connection = await pool.connect();
try {
result = await connection.queryArray<T[]>({
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<Note>(
"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<Note>(
"select * from note where id = $1",
[idVal],
);
}
export async function createNote({ content }: Omit<Note, "id" | "createdAt">) {
return await queryObject<Note>(
"insert into note (content) values ($1) returning *",
[content],
);
}

View file

@ -1,7 +1,7 @@
import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts"; import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts";
import { compare } from "https://deno.land/x/bcrypt@v0.4.1/mod.ts"; import { compare } from "https://deno.land/x/bcrypt@v0.4.1/mod.ts";
import { Page } from "@/components/Page.tsx"; import { Page } from "@/components/Page.tsx";
import { query } from "@/db/mod.ts"; import { queryObject } from "@/db/mod.ts";
type UserID = string; type UserID = string;
@ -25,10 +25,10 @@ export const handler: Handlers<UserID | LoginError | null> = {
return await context.render({ message: "no password provided" }); return await context.render({ message: "no password provided" });
} }
const result = await query< const result = await queryObject<
{ id: string; username: string; password_digest: string } { id: string; username: string; password_digest: string }
>( >(
`select * from "user" where username = $1`, `select id, username, password_digest from "user" where username = $1`,
[username], [username],
); );
if (result == null || result.rows.length < 1) { if (result == null || result.rows.length < 1) {

View file

@ -1,19 +1,13 @@
import { Handlers, PageProps } from "$fresh/server.ts"; 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"; import { Page } from "@/components/Page.tsx";
import { type Note } from "@/types.ts";
interface Note {
id: string;
created_at: Date;
content: string;
}
export const handler: Handlers<Note[]> = { export const handler: Handlers<Note[]> = {
async GET(_request, context) { async GET(_request, context) {
const result = await query("select * from note order by created_at desc"); const result = await listNotes();
if (result == null) throw "unable to fetch from database"; if (!result) throw "unable to fetch from database";
const notes = result.rows as Note[]; return await context.render(result.rows);
return await context.render(notes);
}, },
}; };
@ -27,11 +21,11 @@ export default function NotesPage({ data: notes }: PageProps<Note[]>) {
</textarea> </textarea>
<input class="mt-2" type="submit" value="Post" /> <input class="mt-2" type="submit" value="Post" />
</form> </form>
{notes.map(({ id, content, created_at }) => ( {notes.map(({ id, content, createdAt }) => (
<div class="my-4" key={id}> <div class="my-4" key={id}>
<span> <span>
<a href={`/note/${id}`} class="text-blue-500">Note {id}</a>{" "} <a href={`/note/${id}`} class="text-blue-500">Note {id}</a>{" "}
created at {created_at.toLocaleString()} created at {createdAt.toLocaleString()}
</span> </span>
<blockquote class="mt-2 pl-4 border-l(solid 4)"> <blockquote class="mt-2 pl-4 border-l(solid 4)">
<pre>{content}</pre> <pre>{content}</pre>

View file

@ -1,32 +1,25 @@
import { Handlers, PageProps } from "$fresh/server.ts"; 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"; import { Page } from "@/components/Page.tsx";
import { type Note } from "@/types.ts";
interface Note {
id: string;
created_at: Date;
content: string;
}
export const handler: Handlers<Note> = { export const handler: Handlers<Note> = {
async GET(request, context) { async GET(request, context) {
console.debug({ request, context }); console.debug({ request, context });
const result = await query( const result = await getNote(context.params.id);
"select * from note where id = $1 order by created_at desc", if (!result) throw "unable to fetch from database";
[context.params["id"]],
);
if (result == null) throw "unable to fetch from database";
const [note] = result.rows as Note[]; const [note] = result.rows as Note[];
return await context.render(note); return await context.render(note);
}, },
}; };
export default function NotesPage( export default function NotesPage(
{ data: { id, created_at, content } }: PageProps<Note>, { data: { id, createdAt, content } }: PageProps<Note>,
) { ) {
return ( return (
<Page> <Page>
<h1>Note {id} created at {created_at.toLocaleString()}</h1> <a href="/note">Back to notes</a>
<h1>Note {id} created at {createdAt.toLocaleString()}</h1>
<div class="my-4" key={id}> <div class="my-4" key={id}>
<blockquote class="mt-2 pl-4 border-l(solid 4)"> <blockquote class="mt-2 pl-4 border-l(solid 4)">
<pre>{content}</pre> <pre>{content}</pre>

View file

@ -1,5 +1,5 @@
import { Handlers, PageProps } from "$fresh/server.ts"; 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"; import { Page } from "@/components/Page.tsx";
type NoteID = string; type NoteID = string;
@ -8,7 +8,7 @@ export const handler: Handlers<NoteID> = {
async POST(request, context) { async POST(request, context) {
const content = (await request.formData()).get("content"); const content = (await request.formData()).get("content");
if (!content) throw "no content provided"; 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", "insert into note (content) values ($1) returning id",
[content], [content],
); );

View file

@ -1,6 +1,6 @@
import { Handlers, PageProps } from "$fresh/server.ts"; import { Handlers, PageProps } from "$fresh/server.ts";
import { Page } from "@/components/Page.tsx"; 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"; import { hash } from "https://deno.land/x/bcrypt@v0.4.1/mod.ts";
type UserID = string; type UserID = string;
@ -23,7 +23,7 @@ export const handler: Handlers<UserID | RegistrationError | null> = {
} }
const password_digest = await hash(password.toString()); const password_digest = await hash(password.toString());
try { try {
const result = await query<{ user_id: string }>( const result = await queryObject<{ user_id: string }>(
` `
with new_user as ( with new_user as (
insert into "user" (username, password_digest, status) insert into "user" (username, password_digest, status)
@ -62,7 +62,7 @@ export const handler: Handlers<UserID | RegistrationError | null> = {
) { ) {
return await context.render({ return await context.render({
message: 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; throw err;

5
types.ts Normal file
View file

@ -0,0 +1,5 @@
export interface Note {
id: string;
createdAt: Date;
content: string;
}