Blue like jazz
This commit is contained in:
parent
b186a40103
commit
a3ad382437
31
config.ts
31
config.ts
|
@ -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",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
63
db/mod.ts
63
db/mod.ts
|
@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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],
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in a new issue