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: {
url: string;
};
mailgun: {
apiKey?: string;
domain?: string;
};
}
const defaults: Record<string, [string, (val: string) => 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;
});

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()";
@ -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<string, TableSpec> = {
"note": {
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')",
],
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<string, TableSpec> = {
},
};
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;
}

View File

@ -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<T>(
export async function queryObject<T>(
sql: string,
...args: QueryArguments[]
): Promise<QueryObjectResult<T> | null> {
@ -22,9 +24,13 @@ export async function query<T>(
try {
const connection = await pool.connect();
try {
result = connection.queryObject<T>(sql, ...args);
result = await connection.queryObject<T>({
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<T>(
}
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 { 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<UserID | LoginError | null> = {
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) {

View File

@ -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<Note[]> = {
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<Note[]>) {
</textarea>
<input class="mt-2" type="submit" value="Post" />
</form>
{notes.map(({ id, content, created_at }) => (
{notes.map(({ id, content, createdAt }) => (
<div class="my-4" key={id}>
<span>
<a href={`/note/${id}`} class="text-blue-500">Note {id}</a>{" "}
created at {created_at.toLocaleString()}
created at {createdAt.toLocaleString()}
</span>
<blockquote class="mt-2 pl-4 border-l(solid 4)">
<pre>{content}</pre>

View File

@ -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<Note> = {
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<Note>,
{ data: { id, createdAt, content } }: PageProps<Note>,
) {
return (
<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}>
<blockquote class="mt-2 pl-4 border-l(solid 4)">
<pre>{content}</pre>

View File

@ -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<NoteID> = {
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],
);

View File

@ -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<UserID | RegistrationError | null> = {
}
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<UserID | RegistrationError | null> = {
) {
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;

5
types.ts Normal file
View File

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