Blue like jazz
This commit is contained in:
parent
b186a40103
commit
a3ad382437
9 changed files with 163 additions and 72 deletions
31
config.ts
31
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<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;
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
63
db/mod.ts
63
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<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],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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],
|
||||
);
|
||||
|
|
|
@ -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
5
types.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export interface Note {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
content: string;
|
||||
}
|
Loading…
Reference in a new issue