From 70aafccf9485d8273cce244bcbb1b045e4eb13db Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Thu, 21 Mar 2024 21:58:00 -0700 Subject: [PATCH 01/18] WIP: drizzle orm --- .env.example | 3 + README.md | 4 + bun.lockb | Bin 21360 -> 69092 bytes config/database.ts | 6 ++ config/index.ts | 2 + drizzle/0000_charming_elektra.sql | 24 ++++++ drizzle/meta/0000_snapshot.json | 130 ++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 13 +++ initializers/drizzle.ts | 73 +++++++++++++++++ package.json | 11 ++- schema/messages.ts | 10 +++ schema/users.ts | 26 ++++++ 12 files changed, 299 insertions(+), 3 deletions(-) create mode 100644 config/database.ts create mode 100644 drizzle/0000_charming_elektra.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 initializers/drizzle.ts create mode 100644 schema/messages.ts create mode 100644 schema/users.ts diff --git a/.env.example b/.env.example index 9623647..e78134d 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,6 @@ servers.web.assetRoute="/assets" servers.web.pageRoute="/pages" servers.web.closeActiveConnectionsOnStop=false servers.web.closeActiveConnectionsOnStop.test=true + +db.connectionString="postgres://evan@localhost:5432/bun" +db.autoMigrate=true diff --git a/README.md b/README.md index 1158977..d8b02d0 100644 --- a/README.md +++ b/README.md @@ -90,3 +90,7 @@ This project was created using `bun init` in bun v1.0.29. [Bun](https://bun.sh) **Testing** - No mock server. Let's make real API requests. Now that bun has `fetch` included, it's easy. + +**ORM** + +- we use drizzle for the ORM and migrations. diff --git a/bun.lockb b/bun.lockb index 1a86ca03052f3cac8ab33ea9ce448891338e28b0..a17ac0487cbb1e7e91284795cc32c21a8d8aefbf 100755 GIT binary patch literal 69092 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�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 awAzVtqYY@_5f}gR6^)#K$LjyzzyAl%FE920 delta 3498 zcmb_fdr*|u760xhAPf5dp>#hMl!qWh`Cwn{Cg?5^Wzbbb1lDCnD=JM$3=mPGaS~Qz zgmE-AJYl#1(58B`Pb~m*howk{r z*>iv2xzBU%x%b?&H@_Qo=ZrX}=9k~S{hcen%5V5(@u6nNkrzr&RYnKwmzTDyh3{;x z9QgR$WVNJG@yq@N9lv*mgix6vNx?FF>VP{N8@BDjcPCg6d4UMkQ@Gs$G@<^e9BSXz z*0yVJYol}aUAB`FEG1q~^{ za$p=#Q>37&EhR>h43KT8j|Wx(d7->;zj$CeWGUPpS#W#f)4LmXK-t*PIy2$*B*-;v zK?TfZ3wO2cXxa*^X6A-EZfn`mva?N+7NU+9N(Hh)mr?KzDbRw^2xNthq2PHaLpIwo zp(;o_^w5)60eRM|00Fdsx@9^qr5_pXj5JxsTIt<154( zQuN;FlhD^r>G~4!8|u+}MIMcz+)9eUD^5`W<@c$_;FV`(8aH@^gA}7zv{S(7)%DGh zq+)0lMN{+KVh)WPJ))KrlUMwh0w%Bg$0#~s@`z#@L)D9 zE0C5!n!}Q~MoN-b7%70VoqAB7qp>8f{0AMSCVQgi!#xfnf3CESsy@r@TBSx;r$-xg z81p3wR}zE|2@aDA!91}I%yci}p0SU#b{98-7|IcOL_!2$~FZ4gTtasCj zk$C!htq-wk?{j_{sWQ+H_WKZ#KHcx9J?jlrd!S4wq*>D}lwU-D3i>Ip+CXd3C!bc; z`Dql=TXjCMm_CQpwZTA}{60}YZ}|P>t}&2dqfZo4V56U=A@xJDk}lw<KG`-GF4>etan@ui}(ckIAtG9ptHy?H2BH-Ykqe7-Y?WYT&tCkgC0b|Ob1tjF(|HpM(jKcC%!Fst1nzFD>4IQ6tQEF5XkF-|CzqAD(8H!$i&U#rM%I2sbkh4Mi5mOup>Inh7n}a! zVbk^NF|D#N!M(hS);BGZQl-phtsFf2;Y*m&rP^$&Q<@R9W!NkvvJ5DeNXGPw#^h_6 zraQCf=1?6f4n-cV8&I5)OzX%7|Le!k_6(xk3C*!XTJnbFYOHkb`uIqXrm{_iCFOJq zGe>f>*IqdHEwi)nJld@)Hbj!P(A5D2&-SeWdr~BIO#NC;;{NOn?)r?nV_cz75TPIU4u64N#CHd?!Gn`t8n*!E+Y#kUIJd=!De_U zdx&=%Gb2tsJ*3Dt1sxeOTkg9j>?^6028R?;PGcBYMuv;*IIc;dM=#oWc>BemI7$WA zl?V8T_h)7wn_Ii1!R6Q$(9m+k$5I?GDjF@av_mKSf(PBs_rxCK?45A0(Q1kI zjaJ2m{&{R~>75ykiKC;_3O#$*LRG(;+x@yulyzU!izMCF-OUC3zo(5d$1UA|){8|^ j%`Mv-yG;hMth+r{q*3csO!p&t(b#)tIzEc79y statement-breakpoint +CREATE TABLE IF NOT EXISTS "users" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar(256), + "email" text, + "password_hash" text, + "created_at" timestamp, + "updated_at" timestamp +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "name_idx" ON "users" ("name");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "email_idx" ON "users" ("email");--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "messages" ADD CONSTRAINT "messages_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..182253a --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,130 @@ +{ + "id": "97d9f76b-2181-4f7a-a463-b372ec9859e1", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "5", + "dialect": "pg", + "tables": { + "messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "messages_user_id_users_id_fk": { + "name": "messages_user_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "name_idx": { + "name": "name_idx", + "columns": [ + "name" + ], + "isUnique": true + }, + "email_idx": { + "name": "email_idx", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..8fcaf62 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "5", + "dialect": "pg", + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1711082298720, + "tag": "0000_charming_elektra", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/initializers/drizzle.ts b/initializers/drizzle.ts new file mode 100644 index 0000000..a707326 --- /dev/null +++ b/initializers/drizzle.ts @@ -0,0 +1,73 @@ +import { api, logger } from "../api"; +import { Initializer } from "../classes/Initializer"; +import { config } from "../config"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { Pool } from "pg"; +import path from "path"; +import { type Config as DrizzleMigrateConfig } from "drizzle-kit"; +import { unlink } from "node:fs/promises"; +import { $ } from "bun"; + +const namespace = "drizzle"; + +declare module "../classes/API" { + export interface API { + [namespace]: Awaited>; + } +} + +export class Drizzle extends Initializer { + constructor() { + super(namespace); + this.startPriority = 100; + } + + async initialize() { + return {} as { db?: ReturnType }; + } + + async start() { + if (config.database.autoMigrate) { + await this.generateMigrations(); + // await migrate(api.drizzle.db, { migrationsFolder: "./drizzle" }); + } + + const pool = new Pool({ + connectionString: config.database.connectionString, + }); + + api.drizzle.db = drizzle(pool); + logger.info("database connection established"); + } + + /** + * Generate migrations for the database schema. + * Learn more @ https://orm.drizzle.team/kit-docs/overview + */ + async generateMigrations() { + const migrationConfig = { + schema: path.join("schema", "*"), + dbCredentials: { + uri: config.database.connectionString, + }, + out: path.join("drizzle"), + } satisfies DrizzleMigrateConfig; + + const fileContent = `export default ${JSON.stringify(migrationConfig, null, 2)}`; + const tmpfilePath = path.join(api.rootDir, "drizzle", "config.tmp.ts"); + + try { + // TODO: subshell hack... + await Bun.write(tmpfilePath, fileContent); + const { exitCode } = + await $`bun drizzle-kit generate:pg --config ${tmpfilePath}`; + if (exitCode !== 0) { + throw new Error("Failed to generate migrations"); + } + } finally { + const filePointer = Bun.file(tmpfilePath); + if (await filePointer.exists()) await unlink(tmpfilePath); + } + } +} diff --git a/package.json b/package.json index 640747f..308a26e 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,10 @@ "type": "module", "license": "MIT", "devDependencies": { + "@types/bun": "^1.0.8", + "@types/pg": "^8.11.4", + "@types/react-dom": "^18.2.22", + "drizzle-kit": "^0.20.14", "prettier": "^3.2.5", "typedoc": "^0.25.12" }, @@ -12,9 +16,9 @@ "typescript": "^5.0.0" }, "dependencies": { - "@types/bun": "^1.0.8", - "@types/react-dom": "^18.2.22", "colors": "^1.4.0", + "drizzle-orm": "^0.30.4", + "pg": "^8.11.3", "react": "^18.2.0", "react-bootstrap": "^2.10.1", "react-dom": "^18.2.0" @@ -23,6 +27,7 @@ "lint": "prettier --check .", "pretty": "prettier --write .", "ci": "bun run type_doc && bun run lint && bun test", - "type_doc": "bun run typedoc api.ts" + "type_doc": "bun run typedoc api.ts", + "generate-migrations": "drizzle-kit generate:pg" } } diff --git a/schema/messages.ts b/schema/messages.ts new file mode 100644 index 0000000..2a29601 --- /dev/null +++ b/schema/messages.ts @@ -0,0 +1,10 @@ +import { pgTable, serial, text, integer, timestamp } from "drizzle-orm/pg-core"; +import { users } from "./users"; + +export const messages = pgTable("messages", { + id: serial("id").primaryKey(), + body: text("body"), + user_id: integer("user_id").references(() => users.id), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), +}); diff --git a/schema/users.ts b/schema/users.ts new file mode 100644 index 0000000..930dd67 --- /dev/null +++ b/schema/users.ts @@ -0,0 +1,26 @@ +import { + pgTable, + serial, + uniqueIndex, + varchar, + timestamp, + text, +} from "drizzle-orm/pg-core"; + +export const users = pgTable( + "users", + { + id: serial("id").primaryKey(), + name: varchar("name", { length: 256 }), + email: text("email"), + password_hash: text("password_hash"), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), + }, + (users) => { + return { + nameIndex: uniqueIndex("name_idx").on(users.name), + emailIndex: uniqueIndex("email_idx").on(users.email), + }; + }, +); From 809b58f68dd4858493c88d5c8a6e85024bd6d8c7 Mon Sep 17 00:00:00 2001 From: evantahler Date: Fri, 22 Mar 2024 08:32:39 -0700 Subject: [PATCH 02/18] migrations work --- initializers/drizzle.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/initializers/drizzle.ts b/initializers/drizzle.ts index a707326..8dd86d8 100644 --- a/initializers/drizzle.ts +++ b/initializers/drizzle.ts @@ -30,7 +30,7 @@ export class Drizzle extends Initializer { async start() { if (config.database.autoMigrate) { await this.generateMigrations(); - // await migrate(api.drizzle.db, { migrationsFolder: "./drizzle" }); + logger.info("migration files generated from models"); } const pool = new Pool({ @@ -38,6 +38,12 @@ export class Drizzle extends Initializer { }); api.drizzle.db = drizzle(pool); + + if (config.database.autoMigrate) { + await migrate(api.drizzle.db, { migrationsFolder: "./drizzle" }); + logger.info("database migrated successfully"); + } + logger.info("database connection established"); } @@ -58,12 +64,16 @@ export class Drizzle extends Initializer { const tmpfilePath = path.join(api.rootDir, "drizzle", "config.tmp.ts"); try { - // TODO: subshell hack... await Bun.write(tmpfilePath, fileContent); - const { exitCode } = - await $`bun drizzle-kit generate:pg --config ${tmpfilePath}`; + const { exitCode, stdout, stderr } = + await $`bun drizzle-kit generate:pg --config ${tmpfilePath}`.quiet(); + logger.trace(stdout.toString()); if (exitCode !== 0) { - throw new Error("Failed to generate migrations"); + { + throw new Error( + `Failed to generate migrations: ${stderr.toString()}`, + ); + } } } finally { const filePointer = Bun.file(tmpfilePath); From 3d7c9a7158b9f8752751d9738e13aa65f0f81602 Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Fri, 22 Mar 2024 20:59:36 -0700 Subject: [PATCH 03/18] validator returns error messages now --- actions/hello.ts | 3 ++- actions/user.ts | 33 +++++++++++++++++++++++++++++++++ classes/Connection.ts | 8 +++++--- classes/Input.ts | 2 +- 4 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 actions/user.ts diff --git a/actions/hello.ts b/actions/hello.ts index e94b2e5..a81338d 100644 --- a/actions/hello.ts +++ b/actions/hello.ts @@ -9,7 +9,8 @@ export class Hello extends Action { inputs: { name: { required: true, - validator: (p) => p.length > 0, + validator: (p) => + p.length < 0 ? "Name must be at least 1 character" : undefined, formatter: ensureString, }, }, diff --git a/actions/user.ts b/actions/user.ts new file mode 100644 index 0000000..1c391ea --- /dev/null +++ b/actions/user.ts @@ -0,0 +1,33 @@ +import { api, Action, type ActionParams } from "../api"; +import { ensureString } from "../util/formatters"; + +export class UserCreate extends Action { + constructor() { + super({ + name: "status", + web: { route: "/status", method: "GET" }, + inputs: { + name: { + required: true, + validator: (p: string) => + p.length < 3 ? "Name must be at least 3 characters" : undefined, + formatter: ensureString, + }, + email: { + required: true, + validator: (p: string) => + p.length < 3 || !p.includes("@") ? "Email invalids" : undefined, + formatter: ensureString, + }, + password: { + required: true, + validator: (p: string) => + p.length < 3 ? "Password must be at least 3 characters" : undefined, + formatter: ensureString, + }, + }, + }); + } + + async run(params: ActionParams) {} +} diff --git a/classes/Connection.ts b/classes/Connection.ts index 0e31689..bf5a738 100644 --- a/classes/Connection.ts +++ b/classes/Connection.ts @@ -92,9 +92,11 @@ export class Connection { } if (paramDefinition.validator) { - const valid = paramDefinition.validator(value); - if (!valid) { - throw new Error(`Invalid value for param: ${key}: ${value}`); + const validationResponse = paramDefinition.validator(value); + if (validationResponse) { + throw new Error( + `Invalid value for param: ${key}: ${value}: ${validationResponse}`, + ); } } diff --git a/classes/Input.ts b/classes/Input.ts index 63b8b9a..d1d352a 100644 --- a/classes/Input.ts +++ b/classes/Input.ts @@ -7,4 +7,4 @@ export interface Input { export type InputDefault = ((p?: any) => any) | any; export type InputFormatter = (arg: any) => any; -export type InputValidator = (p: any) => Boolean; +export type InputValidator = (p: any) => String | undefined; // returning anything truthy means there is an error From 19f15d27e23a5bbb1dfd43f96d02d0b87d68d050 Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Sat, 23 Mar 2024 14:18:18 -0700 Subject: [PATCH 04/18] Typed Formatters --- actions/hello.ts | 2 +- actions/user.ts | 8 +++--- classes/API.ts | 35 +++++++++++++++++-------- classes/Action.ts | 10 +++++-- classes/Connection.ts | 56 ++++++++++++++++++++++++++++++---------- classes/Initializer.ts | 13 +++++++--- classes/Input.ts | 2 +- classes/TypedError.ts | 41 +++++++++++++++++++++++++++++ initializers/actionts.ts | 13 ++++++++++ initializers/drizzle.ts | 4 ++- servers/web.ts | 26 ++++++++++++++----- util/config.ts | 4 ++- util/glob.ts | 6 ++++- 13 files changed, 176 insertions(+), 44 deletions(-) create mode 100644 classes/TypedError.ts diff --git a/actions/hello.ts b/actions/hello.ts index a81338d..328e84f 100644 --- a/actions/hello.ts +++ b/actions/hello.ts @@ -10,7 +10,7 @@ export class Hello extends Action { name: { required: true, validator: (p) => - p.length < 0 ? "Name must be at least 1 character" : undefined, + p.length <= 0 ? "Name must be at least 1 character" : undefined, formatter: ensureString, }, }, diff --git a/actions/user.ts b/actions/user.ts index 1c391ea..b72e913 100644 --- a/actions/user.ts +++ b/actions/user.ts @@ -4,8 +4,8 @@ import { ensureString } from "../util/formatters"; export class UserCreate extends Action { constructor() { super({ - name: "status", - web: { route: "/status", method: "GET" }, + name: "userCreate", + web: { route: "/user", method: "PUT" }, inputs: { name: { required: true, @@ -29,5 +29,7 @@ export class UserCreate extends Action { }); } - async run(params: ActionParams) {} + async run(params: ActionParams) { + console.log(params); + } } diff --git a/classes/API.ts b/classes/API.ts index 483848f..ccfeb2f 100644 --- a/classes/API.ts +++ b/classes/API.ts @@ -3,6 +3,7 @@ import { config } from "../config"; import { globLoader } from "../util/glob"; import type { Initializer, InitializerSortKeys } from "./Initializer"; import { Logger } from "./Logger"; +import { TypedError } from "./TypedError"; export class API { rootDir: string; @@ -36,11 +37,15 @@ export class API { this.sortInitializers("loadPriority"); for (const initializer of this.initializers) { - this.logger.debug(`Initializing initializer ${initializer.name}`); - await initializer.validate(); - const response = await initializer.initialize?.(); - if (response) this[initializer.name] = response; - this.logger.debug(`Initialized initializer ${initializer.name}`); + try { + this.logger.debug(`Initializing initializer ${initializer.name}`); + await initializer.validate(); + const response = await initializer.initialize?.(); + if (response) this[initializer.name] = response; + this.logger.debug(`Initialized initializer ${initializer.name}`); + } catch (e) { + throw new TypedError(`${e}`, "SERVER_INITIALIZATION"); + } } this.initialized = true; @@ -57,9 +62,13 @@ export class API { this.sortInitializers("startPriority"); for (const initializer of this.initializers) { - this.logger.debug(`Starting initializer ${initializer.name}`); - const response = await initializer.start?.(); - this.logger.debug(`Started initializer ${initializer.name}`); + try { + this.logger.debug(`Starting initializer ${initializer.name}`); + await initializer.start?.(); + this.logger.debug(`Started initializer ${initializer.name}`); + } catch (e) { + throw new TypedError(`${e}`, "SERVER_START"); + } } this.started = true; @@ -74,9 +83,13 @@ export class API { this.sortInitializers("stopPriority"); for (const initializer of this.initializers) { - this.logger.debug(`Stopping initializer ${initializer.name}`); - await initializer.stop?.(); - this.logger.debug(`Stopped initializer ${initializer.name}`); + try { + this.logger.debug(`Stopping initializer ${initializer.name}`); + await initializer.stop?.(); + this.logger.debug(`Stopped initializer ${initializer.name}`); + } catch (e) { + throw new TypedError(`${e}`, "SERVER_STOP"); + } } this.stopped = true; diff --git a/classes/Action.ts b/classes/Action.ts index b4b8a78..cf1fd72 100644 --- a/classes/Action.ts +++ b/classes/Action.ts @@ -1,6 +1,7 @@ import type { Inputs } from "./Inputs"; import type { Connection } from "./Connection"; import type { Input } from "./Input"; +import { TypedError } from "./TypedError"; export const httpMethods = [ "GET", @@ -53,8 +54,13 @@ export abstract class Action { ): Promise; async validate() { - if (!this.name) throw new Error("Action name is required"); - if (!this.description) throw new Error("Action description is required"); + if (!this.name) + throw new TypedError("Action name is required", "ACTION_VALIDATION"); + if (!this.description) + throw new TypedError( + "Action description is required", + "ACTION_VALIDATION", + ); } } diff --git a/classes/Connection.ts b/classes/Connection.ts index bf5a738..86f90f2 100644 --- a/classes/Connection.ts +++ b/classes/Connection.ts @@ -2,6 +2,7 @@ import { api, logger } from "../api"; import { config } from "../config"; import colors from "colors"; import type { Action, ActionParams } from "./Action"; +import { TypedError } from "./TypedError"; export class Connection { type: string; @@ -23,25 +24,29 @@ export class Connection { params: FormData, // note: params are not constant for all connections - some are long-lived, like websockets method: Request["method"] = "", url: string = "", - ): Promise<{ response: Object; error?: Error }> { + ): Promise<{ response: Object; error?: TypedError }> { const reqStartTime = new Date().getTime(); let loggerResponsePrefix: "OK" | "ERROR" = "OK"; let response: Object = {}; - let error: Error | undefined; + let error: TypedError | undefined; try { const action = this.findAction(actionName); if (!action) { - throw new Error( + throw new TypedError( `Action not found${actionName ? `: ${actionName}` : ""}`, + "CONNECTION_ACTION_NOT_FOUND", ); } const formattedParams = await this.formatParams(params, action); response = await action.run(formattedParams, this); - } catch (e: any) { + } catch (e) { loggerResponsePrefix = "ERROR"; - error = e; + error = + e instanceof TypedError + ? e + : new TypedError(`${e}`, "CONNECTION_ACTION_RUN"); } // Note: we want the params object to remain on the same line as the message, so we stringify @@ -76,26 +81,49 @@ export class Connection { for (const [key, paramDefinition] of Object.entries(action.inputs)) { let value = params.get(key); // TODO: handle getAll for multiple values - if (!value && paramDefinition.default) { - value = - typeof paramDefinition.default === "function" - ? paramDefinition.default(value) - : paramDefinition.default; + try { + if (!value && paramDefinition.default) { + value = + typeof paramDefinition.default === "function" + ? paramDefinition.default(value) + : paramDefinition.default; + } + } catch (e) { + throw new TypedError( + `Error creating default value for for param ${key}: ${e}`, + "CONNECTION_ACTION_PARAM_DEFAULT", + ); } if ((paramDefinition.required && value === undefined) || value === null) { - throw new Error(`Missing required param: ${key}`); + throw new TypedError( + `Missing required param: ${key}`, + "CONNECTION_ACTION_PARAM_REQUIRED", + key, + ); } if (paramDefinition.formatter) { - value = paramDefinition.formatter(value); + try { + value = paramDefinition.formatter(value); + } catch (e) { + throw new TypedError( + `${e}`, + "CONNECTION_ACTION_PARAM_FORMATTING", + key, + value, + ); + } } if (paramDefinition.validator) { const validationResponse = paramDefinition.validator(value); if (validationResponse) { - throw new Error( - `Invalid value for param: ${key}: ${value}: ${validationResponse}`, + throw new TypedError( + validationResponse, + "CONNECTION_ACTION_PARAM_VALIDATION", + key, + value, ); } } diff --git a/classes/Initializer.ts b/classes/Initializer.ts index 2bdd290..11c5d4b 100644 --- a/classes/Initializer.ts +++ b/classes/Initializer.ts @@ -1,3 +1,5 @@ +import { TypedError } from "./TypedError"; + export const InitializerPriorities = [ "loadPriority", "startPriority", @@ -43,19 +45,24 @@ export abstract class Initializer { async validate() { if (!this.name) { - throw new Error("name is required for this initializer"); + throw new TypedError( + "name is required for this initializer", + "INITIALIZER_VALIDATION", + ); } for (const priority of InitializerPriorities) { const p = this[priority]; if (!p) { - throw new Error( + throw new TypedError( `${priority} is a required property for the initializer \`${this.name}\``, + "INITIALIZER_VALIDATION", ); } else if (typeof p !== "number" || p < 0) { - throw new Error( + throw new TypedError( `${priority} is not a positive integer for the initializer \`${this.name}\``, + "INITIALIZER_VALIDATION", ); } } diff --git a/classes/Input.ts b/classes/Input.ts index d1d352a..bfd32d1 100644 --- a/classes/Input.ts +++ b/classes/Input.ts @@ -7,4 +7,4 @@ export interface Input { export type InputDefault = ((p?: any) => any) | any; export type InputFormatter = (arg: any) => any; -export type InputValidator = (p: any) => String | undefined; // returning anything truthy means there is an error +export type InputValidator = (p: any) => string | undefined; // returning anything truthy means there is an error diff --git a/classes/TypedError.ts b/classes/TypedError.ts new file mode 100644 index 0000000..6217509 --- /dev/null +++ b/classes/TypedError.ts @@ -0,0 +1,41 @@ +export const typedErrorTypes = [ + // general + "SERVER_INITIALIZATION", + "SERVER_START", + "SERVER_STOP", + + // init + "CONFIG_ERROR", + "INITIALIZER_VALIDATION", + "ACTION_VALIDATION", + "TASK_VALIDATION", + "SERVER_VALIDATION", + + // actions + "CONNECTION_SERVER_ERROR", + "CONNECTION_ACTION_NOT_FOUND", + "CONNECTION_ACTION_PARAM_REQUIRED", + "CONNECTION_ACTION_PARAM_DEFAULT", + "CONNECTION_ACTION_PARAM_VALIDATION", + "CONNECTION_ACTION_PARAM_FORMATTING", + "CONNECTION_ACTION_RUN", +] as const; +export type TypedErrorType = (typeof typedErrorTypes)[number]; + +export class TypedError extends Error { + type: TypedErrorType; + key?: string; + value?: any; + + constructor( + message: string, + type: TypedErrorType, + key?: string, + value?: any, + ) { + super(message); + this.type = type; + this.key = key; + this.value = value; + } +} diff --git a/initializers/actionts.ts b/initializers/actionts.ts index 759f97d..3ea1e4f 100644 --- a/initializers/actionts.ts +++ b/initializers/actionts.ts @@ -1,6 +1,7 @@ import { logger } from "../api"; import type { Action } from "../classes/Action"; import { Initializer } from "../classes/Initializer"; +import { TypedError } from "../classes/TypedError"; import { globLoader } from "../util/glob"; const namespace = "actions"; @@ -19,6 +20,18 @@ export class Actions extends Initializer { async initialize() { const actions = await globLoader("actions"); + + try { + for (const action of Object.values(actions)) { + await action.validate(); + } + } catch (e) { + throw new TypedError( + `Action validation failed: ${e}`, + "ACTION_VALIDATION", + ); + } + logger.info(`loaded ${Object.keys(actions).length} actions`); return { actions }; } diff --git a/initializers/drizzle.ts b/initializers/drizzle.ts index 8dd86d8..cd576a5 100644 --- a/initializers/drizzle.ts +++ b/initializers/drizzle.ts @@ -8,6 +8,7 @@ import path from "path"; import { type Config as DrizzleMigrateConfig } from "drizzle-kit"; import { unlink } from "node:fs/promises"; import { $ } from "bun"; +import { TypedError } from "../classes/TypedError"; const namespace = "drizzle"; @@ -70,8 +71,9 @@ export class Drizzle extends Initializer { logger.trace(stdout.toString()); if (exitCode !== 0) { { - throw new Error( + throw new TypedError( `Failed to generate migrations: ${stderr.toString()}`, + "SERVER_INITIALIZATION", ); } } diff --git a/servers/web.ts b/servers/web.ts index 04ee50f..601d311 100644 --- a/servers/web.ts +++ b/servers/web.ts @@ -6,6 +6,7 @@ import path from "path"; import { type HTTP_METHOD } from "../classes/Action"; import { renderToReadableStream } from "react-dom/server"; import type { BunFile } from "bun"; +import { TypedError } from "../classes/TypedError"; type URLParsed = import("url").URL; @@ -32,7 +33,9 @@ export class WebServer extends Server> { fetch: async (request) => this.fetch(request), error: async (error) => { logger.error(`uncaught web server error: ${error.message}`); - return this.buildError(error); + return this.buildError( + new TypedError(`${error}`, "CONNECTION_SERVER_ERROR"), + ); }, }); } @@ -68,7 +71,8 @@ export class WebServer extends Server> { } async handleAction(request: Request, url: URLParsed) { - if (!this.server) throw new Error("server not started"); + if (!this.server) + throw new TypedError("Serb server not started", "SERVER_START"); let errorStatusCode = 500; const ipAddress = this.server.requestIP(request)?.address || "unknown"; @@ -103,7 +107,11 @@ export class WebServer extends Server> { const requestedAsset = await this.findAsset(url); if (requestedAsset) { return new Response(requestedAsset); - } else return this.buildError(new Error("Asset not found"), 404); + } else + return this.buildError( + new TypedError("Asset not found", "CONNECTION_SERVER_ERROR"), + 404, + ); } async handlePage(request: Request, url: URLParsed) { @@ -113,7 +121,10 @@ export class WebServer extends Server> { } else if (requestedAsset && !isReact) { return new Response(requestedAsset); } else { - return this.buildError(new Error("Page not found"), 404); + return this.buildError( + new TypedError("Page not found", "CONNECTION_SERVER_ERROR"), + 404, + ); } } @@ -177,7 +188,7 @@ export class WebServer extends Server> { const matcher = action.web.route instanceof RegExp ? action.web.route - : new RegExp(`^/${action.name}$`); + : new RegExp(`^${action.web.route}$`); if ( pathToMatch.match(matcher) && @@ -195,11 +206,14 @@ export class WebServer extends Server> { }); } - async buildError(error: Error, status = 500): Promise { + async buildError(error: TypedError, status = 500): Promise { return new Response( JSON.stringify({ error: { message: error.message, + type: error.type, + key: error.key !== undefined ? error.key : undefined, + value: error.value !== undefined ? error.value : undefined, stack: error.stack, }, }) + "\n", diff --git a/util/config.ts b/util/config.ts index 5ab4b7b..48ebf1a 100644 --- a/util/config.ts +++ b/util/config.ts @@ -1,5 +1,6 @@ import { $, sleep } from "bun"; import { EOL } from "os"; +import { TypedError } from "../classes/TypedError"; /** Loads a value from the environment, if it's set, otherwise returns the default value. @@ -29,8 +30,9 @@ export async function loadFromEnvIfSet( if (ensureUnique) { if (!["string", "number"].includes(typeof val)) { - throw new Error( + throw new TypedError( "Only config values of number or string can be made unique.", + "CONFIG_ERROR", ); } diff --git a/util/glob.ts b/util/glob.ts index 98984a3..c457e64 100644 --- a/util/glob.ts +++ b/util/glob.ts @@ -1,6 +1,7 @@ import path from "path"; import { Glob } from "bun"; import { api } from "../api"; +import { TypedError } from "../classes/TypedError"; /** * @@ -22,7 +23,10 @@ export async function globLoader(searchDir: string) { const instance = new klass(); results.push(instance); } catch (error) { - throw new Error(`Error loading from ${dir} - ${name} - ${error}`); + throw new TypedError( + `Error loading from ${dir} - ${name} - ${error}`, + "SERVER_INITIALIZATION", + ); } } } From ca0ae18d864415cc2a5724819a73612092224be3 Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Sun, 24 Mar 2024 18:28:45 -0700 Subject: [PATCH 05/18] generally works --- .env.example | 1 + README.md | 4 + actions/user.ts | 20 ++- ...ektra.sql => 0000_eminent_tyger_tiger.sql} | 18 +-- drizzle/0001_nebulous_mephistopheles.sql | 1 + drizzle/meta/0000_snapshot.json | 24 +-- drizzle/meta/0001_snapshot.json | 142 ++++++++++++++++++ drizzle/meta/_journal.json | 11 +- initializers/drizzle.ts | 11 +- migrations.ts | 5 + ops/UserOps.ts | 21 +++ package.json | 4 +- schema/messages.ts | 13 +- schema/users.ts | 13 +- 14 files changed, 248 insertions(+), 40 deletions(-) rename drizzle/{0000_charming_elektra.sql => 0000_eminent_tyger_tiger.sql} (64%) create mode 100644 drizzle/0001_nebulous_mephistopheles.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 migrations.ts create mode 100644 ops/UserOps.ts diff --git a/.env.example b/.env.example index e78134d..ff0538a 100644 --- a/.env.example +++ b/.env.example @@ -19,4 +19,5 @@ servers.web.closeActiveConnectionsOnStop=false servers.web.closeActiveConnectionsOnStop.test=true db.connectionString="postgres://evan@localhost:5432/bun" +db.connectionString.test="postgres://evan@localhost:5432/bun-test" db.autoMigrate=true diff --git a/README.md b/README.md index d8b02d0..5e4d49f 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ bun run prettier --write . This project was created using `bun init` in bun v1.0.29. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. +## Databases and Migrations + +This project uses Drizzle as the ORM. Migrations are derived from the schemas. To create a migration from changes in `scheams/*.ts` run `bun run migrations.ts`. Then, restart the server - pending migrations are auto-applied. + ## Intentional changes from ActionHero **Process** diff --git a/actions/user.ts b/actions/user.ts index b72e913..e8195c9 100644 --- a/actions/user.ts +++ b/actions/user.ts @@ -1,4 +1,6 @@ import { api, Action, type ActionParams } from "../api"; +import { hashPassword, serializeUser } from "../ops/UserOps"; +import { users } from "../schema/users"; import { ensureString } from "../util/formatters"; export class UserCreate extends Action { @@ -16,7 +18,7 @@ export class UserCreate extends Action { email: { required: true, validator: (p: string) => - p.length < 3 || !p.includes("@") ? "Email invalids" : undefined, + p.length < 3 || !p.includes("@") ? "Email invalid" : undefined, formatter: ensureString, }, password: { @@ -30,6 +32,20 @@ export class UserCreate extends Action { } async run(params: ActionParams) { - console.log(params); + console.log("userCreate", params); + + const user = ( + await api.drizzle.db + .insert(users) + .values({ + name: params.name, + email: params.email, + password_hash: await hashPassword(params.password), + }) + .returning() + )[0]; + + console.log(params, user); + return serializeUser(user); } } diff --git a/drizzle/0000_charming_elektra.sql b/drizzle/0000_eminent_tyger_tiger.sql similarity index 64% rename from drizzle/0000_charming_elektra.sql rename to drizzle/0000_eminent_tyger_tiger.sql index 928b603..a395c24 100644 --- a/drizzle/0000_charming_elektra.sql +++ b/drizzle/0000_eminent_tyger_tiger.sql @@ -1,18 +1,18 @@ CREATE TABLE IF NOT EXISTS "messages" ( "id" serial PRIMARY KEY NOT NULL, - "body" text, - "user_id" integer, - "created_at" timestamp, - "updated_at" timestamp + "body" text NOT NULL, + "user_id" integer NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL ); --> statement-breakpoint CREATE TABLE IF NOT EXISTS "users" ( "id" serial PRIMARY KEY NOT NULL, - "name" varchar(256), - "email" text, - "password_hash" text, - "created_at" timestamp, - "updated_at" timestamp + "name" varchar(256) NOT NULL, + "email" text NOT NULL, + "password_hash" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL ); --> statement-breakpoint CREATE UNIQUE INDEX IF NOT EXISTS "name_idx" ON "users" ("name");--> statement-breakpoint diff --git a/drizzle/0001_nebulous_mephistopheles.sql b/drizzle/0001_nebulous_mephistopheles.sql new file mode 100644 index 0000000..5a05355 --- /dev/null +++ b/drizzle/0001_nebulous_mephistopheles.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD CONSTRAINT "users_email_unique" UNIQUE("email"); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 182253a..05d341e 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "97d9f76b-2181-4f7a-a463-b372ec9859e1", + "id": "9fab8cf1-d31c-4bb9-afa8-25f57ab50d2e", "prevId": "00000000-0000-0000-0000-000000000000", "version": "5", "dialect": "pg", @@ -18,25 +18,27 @@ "name": "body", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, "user_id": { "name": "user_id", "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, - "notNull": false + "notNull": true, + "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, - "notNull": false + "notNull": true, + "default": "now()" } }, "indexes": {}, @@ -72,31 +74,33 @@ "name": "name", "type": "varchar(256)", "primaryKey": false, - "notNull": false + "notNull": true }, "email": { "name": "email", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, "password_hash": { "name": "password_hash", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, - "notNull": false + "notNull": true, + "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, - "notNull": false + "notNull": true, + "default": "now()" } }, "indexes": { diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..937d940 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,142 @@ +{ + "id": "1612f43a-fb50-4e0b-b5dc-0b6ee2bb5624", + "prevId": "9fab8cf1-d31c-4bb9-afa8-25f57ab50d2e", + "version": "5", + "dialect": "pg", + "tables": { + "messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "messages_user_id_users_id_fk": { + "name": "messages_user_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "name_idx": { + "name": "name_idx", + "columns": [ + "name" + ], + "isUnique": true + }, + "email_idx": { + "name": "email_idx", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 8fcaf62..e135117 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,8 +5,15 @@ { "idx": 0, "version": "5", - "when": 1711082298720, - "tag": "0000_charming_elektra", + "when": 1711324460394, + "tag": "0000_eminent_tyger_tiger", + "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1711329063081, + "tag": "0001_nebulous_mephistopheles", "breakpoints": true } ] diff --git a/initializers/drizzle.ts b/initializers/drizzle.ts index cd576a5..bf27206 100644 --- a/initializers/drizzle.ts +++ b/initializers/drizzle.ts @@ -25,15 +25,14 @@ export class Drizzle extends Initializer { } async initialize() { - return {} as { db?: ReturnType }; + const dbContainer = {} as { db: ReturnType }; + return Object.assign( + { generateMigrations: this.generateMigrations }, + dbContainer, + ); } async start() { - if (config.database.autoMigrate) { - await this.generateMigrations(); - logger.info("migration files generated from models"); - } - const pool = new Pool({ connectionString: config.database.connectionString, }); diff --git a/migrations.ts b/migrations.ts new file mode 100644 index 0000000..f7c95fc --- /dev/null +++ b/migrations.ts @@ -0,0 +1,5 @@ +import { api } from "./api"; + +await api.initialize(); +await api.drizzle.generateMigrations(); +await api.stop(); diff --git a/ops/UserOps.ts b/ops/UserOps.ts new file mode 100644 index 0000000..77c333d --- /dev/null +++ b/ops/UserOps.ts @@ -0,0 +1,21 @@ +import { type User } from "../schema/users"; + +export async function hashPassword(password: string) { + const hash = await Bun.password.hash(password); + return hash; +} + +export async function checkPassword(user: User, password: string) { + const isMatch = await Bun.password.verify(password, user.password_hash); + return isMatch; +} + +export function serializeUser(user: User) { + return { + id: user.id, + name: user.name, + email: user.email, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; +} diff --git a/package.json b/package.json index 308a26e..62fa4b4 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,10 @@ "react-dom": "^18.2.0" }, "scripts": { + "migrations": "bun run migrations.ts", "lint": "prettier --check .", "pretty": "prettier --write .", "ci": "bun run type_doc && bun run lint && bun test", - "type_doc": "bun run typedoc api.ts", - "generate-migrations": "drizzle-kit generate:pg" + "type_doc": "bun run typedoc api.ts" } } diff --git a/schema/messages.ts b/schema/messages.ts index 2a29601..44c090c 100644 --- a/schema/messages.ts +++ b/schema/messages.ts @@ -3,8 +3,13 @@ import { users } from "./users"; export const messages = pgTable("messages", { id: serial("id").primaryKey(), - body: text("body"), - user_id: integer("user_id").references(() => users.id), - createdAt: timestamp("created_at"), - updatedAt: timestamp("updated_at"), + body: text("body").notNull(), + user_id: integer("user_id") + .references(() => users.id) + .notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), }); + +export type Message = typeof messages.$inferSelect; +export type NewMessage = typeof messages.$inferInsert; diff --git a/schema/users.ts b/schema/users.ts index 930dd67..380239c 100644 --- a/schema/users.ts +++ b/schema/users.ts @@ -11,11 +11,11 @@ export const users = pgTable( "users", { id: serial("id").primaryKey(), - name: varchar("name", { length: 256 }), - email: text("email"), - password_hash: text("password_hash"), - createdAt: timestamp("created_at"), - updatedAt: timestamp("updated_at"), + name: varchar("name", { length: 256 }).notNull(), + email: text("email").notNull().unique(), + password_hash: text("password_hash").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), }, (users) => { return { @@ -24,3 +24,6 @@ export const users = pgTable( }; }, ); + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; From 6565dda3f238833e32c5900be6d198081cb80e2a Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Sun, 24 Mar 2024 19:23:32 -0700 Subject: [PATCH 06/18] enums and typed actions --- actions/hello.ts | 27 +++++++++----------- actions/status.ts | 14 +++++------ actions/swagger.ts | 14 +++++------ actions/user.ts | 54 ++++++++++++++++++---------------------- classes/API.ts | 9 +++---- classes/Action.ts | 32 ++++++++---------------- classes/Connection.ts | 14 +++++------ classes/Initializer.ts | 35 -------------------------- classes/Logger.ts | 47 +++++++++++++++++----------------- classes/TypedError.ts | 14 +++-------- config/logger.ts | 4 +-- initializers/actionts.ts | 12 ++------- initializers/drizzle.ts | 4 +-- util/config.ts | 4 +-- 14 files changed, 105 insertions(+), 179 deletions(-) diff --git a/actions/hello.ts b/actions/hello.ts index 328e84f..05967a9 100644 --- a/actions/hello.ts +++ b/actions/hello.ts @@ -1,21 +1,18 @@ import { Action, type ActionParams } from "../api"; +import { HTTP_METHOD } from "../classes/Action"; import { ensureString } from "../util/formatters"; -export class Hello extends Action { - constructor() { - super({ - name: "hello", - web: { route: "/hello", method: "POST" }, - inputs: { - name: { - required: true, - validator: (p) => - p.length <= 0 ? "Name must be at least 1 character" : undefined, - formatter: ensureString, - }, - }, - }); - } +export class Hello implements Action { + name = "hello"; + web = { route: "/hello", method: HTTP_METHOD.POST }; + inputs = { + name: { + required: true, + validator: (p: string) => + p.length <= 0 ? "Name must be at least 1 character" : undefined, + formatter: ensureString, + }, + }; async run(params: ActionParams) { return { message: `Hello, ${params.name}!` }; diff --git a/actions/status.ts b/actions/status.ts index 0aaddda..52634cd 100644 --- a/actions/status.ts +++ b/actions/status.ts @@ -1,13 +1,11 @@ -import { api, Action } from "../api"; +import { api, Action, type Inputs } from "../api"; +import { HTTP_METHOD } from "../classes/Action"; import packageJSON from "../package.json"; -export class Status extends Action { - constructor() { - super({ - name: "status", - web: { route: "/status", method: "GET" }, - }); - } +export class Status implements Action { + name = "status"; + inputs = {}; + web = { route: "/status", method: HTTP_METHOD.GET }; async run() { const consumedMemoryMB = diff --git a/actions/swagger.ts b/actions/swagger.ts index a34e7e4..9d5dbfc 100644 --- a/actions/swagger.ts +++ b/actions/swagger.ts @@ -1,4 +1,5 @@ import { Action, config, api } from "../api"; +import { HTTP_METHOD } from "../classes/Action"; import packageJSON from "../package.json"; const SWAGGER_VERSION = "2.0"; @@ -28,14 +29,11 @@ type SwaggerPath = { }; }; -export class Swagger extends Action { - constructor() { - super({ - name: "swagger", - description: "return API documentation in the OpenAPI specification", - web: { route: "/swagger", method: "GET" }, - }); - } +export class Swagger implements Action { + name = "swagger"; + description = "return API documentation in the OpenAPI specification"; + inputs = {}; + web = { route: "/swagger", method: HTTP_METHOD.GET }; async run() { const swaggerPaths = buildSwaggerPaths(); diff --git a/actions/user.ts b/actions/user.ts index e8195c9..be4e5e4 100644 --- a/actions/user.ts +++ b/actions/user.ts @@ -1,39 +1,34 @@ import { api, Action, type ActionParams } from "../api"; +import { HTTP_METHOD } from "../classes/Action"; import { hashPassword, serializeUser } from "../ops/UserOps"; import { users } from "../schema/users"; import { ensureString } from "../util/formatters"; -export class UserCreate extends Action { - constructor() { - super({ - name: "userCreate", - web: { route: "/user", method: "PUT" }, - inputs: { - name: { - required: true, - validator: (p: string) => - p.length < 3 ? "Name must be at least 3 characters" : undefined, - formatter: ensureString, - }, - email: { - required: true, - validator: (p: string) => - p.length < 3 || !p.includes("@") ? "Email invalid" : undefined, - formatter: ensureString, - }, - password: { - required: true, - validator: (p: string) => - p.length < 3 ? "Password must be at least 3 characters" : undefined, - formatter: ensureString, - }, - }, - }); - } +export class UserCreate implements Action { + name = "userCreate"; + web = { route: "/user", method: HTTP_METHOD.PUT }; + inputs = { + name: { + required: true, + validator: (p: string) => + p.length < 3 ? "Name must be at least 3 characters" : undefined, + formatter: ensureString, + }, + email: { + required: true, + validator: (p: string) => + p.length < 3 || !p.includes("@") ? "Email invalid" : undefined, + formatter: ensureString, + }, + password: { + required: true, + validator: (p: string) => + p.length < 3 ? "Password must be at least 3 characters" : undefined, + formatter: ensureString, + }, + }; async run(params: ActionParams) { - console.log("userCreate", params); - const user = ( await api.drizzle.db .insert(users) @@ -45,7 +40,6 @@ export class UserCreate extends Action { .returning() )[0]; - console.log(params, user); return serializeUser(user); } } diff --git a/classes/API.ts b/classes/API.ts index ccfeb2f..2152d57 100644 --- a/classes/API.ts +++ b/classes/API.ts @@ -3,7 +3,7 @@ import { config } from "../config"; import { globLoader } from "../util/glob"; import type { Initializer, InitializerSortKeys } from "./Initializer"; import { Logger } from "./Logger"; -import { TypedError } from "./TypedError"; +import { ErrorType, TypedError } from "./TypedError"; export class API { rootDir: string; @@ -39,12 +39,11 @@ export class API { for (const initializer of this.initializers) { try { this.logger.debug(`Initializing initializer ${initializer.name}`); - await initializer.validate(); const response = await initializer.initialize?.(); if (response) this[initializer.name] = response; this.logger.debug(`Initialized initializer ${initializer.name}`); } catch (e) { - throw new TypedError(`${e}`, "SERVER_INITIALIZATION"); + throw new TypedError(`${e}`, ErrorType.SERVER_INITIALIZATION); } } @@ -67,7 +66,7 @@ export class API { await initializer.start?.(); this.logger.debug(`Started initializer ${initializer.name}`); } catch (e) { - throw new TypedError(`${e}`, "SERVER_START"); + throw new TypedError(`${e}`, ErrorType.SERVER_START); } } @@ -88,7 +87,7 @@ export class API { await initializer.stop?.(); this.logger.debug(`Stopped initializer ${initializer.name}`); } catch (e) { - throw new TypedError(`${e}`, "SERVER_STOP"); + throw new TypedError(`${e}`, ErrorType.SERVER_STOP); } } diff --git a/classes/Action.ts b/classes/Action.ts index cf1fd72..77fa91c 100644 --- a/classes/Action.ts +++ b/classes/Action.ts @@ -1,17 +1,15 @@ import type { Inputs } from "./Inputs"; import type { Connection } from "./Connection"; import type { Input } from "./Input"; -import { TypedError } from "./TypedError"; -export const httpMethods = [ - "GET", - "POST", - "PUT", - "DELETE", - "PATCH", - "OPTIONS", -] as const; -export type HTTP_METHOD = (typeof httpMethods)[number]; +export enum HTTP_METHOD { + "GET" = "GET", + "POST" = "POST", + "PUT" = "PUT", + "DELETE" = "DELETE", + "PATCH" = "PATCH", + "OPTIONS" = "OPTIONS", +} export type ActionConstructorInputs = { name: string; @@ -25,7 +23,7 @@ export type ActionConstructorInputs = { export abstract class Action { name: string; - description: string; + description?: string; inputs: Inputs; web: { route: RegExp | string; @@ -38,7 +36,7 @@ export abstract class Action { this.inputs = args.inputs ?? ({} as Inputs); this.web = { route: args.web?.route ?? `/${this.name}`, - method: args.web?.method ?? "GET", + method: args.web?.method ?? HTTP_METHOD.GET, }; } @@ -52,16 +50,6 @@ export abstract class Action { params: ActionParams, connection: Connection, // ): ActionResponse; ): Promise; - - async validate() { - if (!this.name) - throw new TypedError("Action name is required", "ACTION_VALIDATION"); - if (!this.description) - throw new TypedError( - "Action description is required", - "ACTION_VALIDATION", - ); - } } export type ActionParams = { diff --git a/classes/Connection.ts b/classes/Connection.ts index 86f90f2..ba35075 100644 --- a/classes/Connection.ts +++ b/classes/Connection.ts @@ -2,7 +2,7 @@ import { api, logger } from "../api"; import { config } from "../config"; import colors from "colors"; import type { Action, ActionParams } from "./Action"; -import { TypedError } from "./TypedError"; +import { ErrorType, TypedError } from "./TypedError"; export class Connection { type: string; @@ -35,7 +35,7 @@ export class Connection { if (!action) { throw new TypedError( `Action not found${actionName ? `: ${actionName}` : ""}`, - "CONNECTION_ACTION_NOT_FOUND", + ErrorType.CONNECTION_ACTION_NOT_FOUND, ); } @@ -46,7 +46,7 @@ export class Connection { error = e instanceof TypedError ? e - : new TypedError(`${e}`, "CONNECTION_ACTION_RUN"); + : new TypedError(`${e}`, ErrorType.CONNECTION_ACTION_RUN); } // Note: we want the params object to remain on the same line as the message, so we stringify @@ -91,14 +91,14 @@ export class Connection { } catch (e) { throw new TypedError( `Error creating default value for for param ${key}: ${e}`, - "CONNECTION_ACTION_PARAM_DEFAULT", + ErrorType.CONNECTION_ACTION_PARAM_DEFAULT, ); } if ((paramDefinition.required && value === undefined) || value === null) { throw new TypedError( `Missing required param: ${key}`, - "CONNECTION_ACTION_PARAM_REQUIRED", + ErrorType.CONNECTION_ACTION_PARAM_REQUIRED, key, ); } @@ -109,7 +109,7 @@ export class Connection { } catch (e) { throw new TypedError( `${e}`, - "CONNECTION_ACTION_PARAM_FORMATTING", + ErrorType.CONNECTION_ACTION_PARAM_FORMATTING, key, value, ); @@ -121,7 +121,7 @@ export class Connection { if (validationResponse) { throw new TypedError( validationResponse, - "CONNECTION_ACTION_PARAM_VALIDATION", + ErrorType.CONNECTION_ACTION_PARAM_VALIDATION, key, value, ); diff --git a/classes/Initializer.ts b/classes/Initializer.ts index 11c5d4b..b1f6a54 100644 --- a/classes/Initializer.ts +++ b/classes/Initializer.ts @@ -1,13 +1,3 @@ -import { TypedError } from "./TypedError"; - -export const InitializerPriorities = [ - "loadPriority", - "startPriority", - "stopPriority", -] as const; - -export type InitializerPriority = (typeof InitializerPriorities)[number]; - /** * Create a new Initializer. The required properties of an initializer. These can be defined statically (this.name) or as methods which return a value. */ @@ -42,31 +32,6 @@ export abstract class Initializer { * Method run as part of the `initialize` lifecycle of your process. Usually disconnects from remote servers or processes. */ async stop?(): Promise; - - async validate() { - if (!this.name) { - throw new TypedError( - "name is required for this initializer", - "INITIALIZER_VALIDATION", - ); - } - - for (const priority of InitializerPriorities) { - const p = this[priority]; - - if (!p) { - throw new TypedError( - `${priority} is a required property for the initializer \`${this.name}\``, - "INITIALIZER_VALIDATION", - ); - } else if (typeof p !== "number" || p < 0) { - throw new TypedError( - `${priority} is not a positive integer for the initializer \`${this.name}\``, - "INITIALIZER_VALIDATION", - ); - } - } - } } export type InitializerSortKeys = diff --git a/classes/Logger.ts b/classes/Logger.ts index 1aa4953..8564d7a 100644 --- a/classes/Logger.ts +++ b/classes/Logger.ts @@ -2,16 +2,14 @@ import colors from "colors"; import type { configLogger } from "../config/logger"; -export const LogLevels = [ - "trace", - "debug", - "info", - "warn", - "error", - "fatal", -] as const; - -export type LogLevel = (typeof LogLevels)[number]; +export enum LogLevel { + "trace" = "trace", + "debug" = "debug", + "info" = "info", + "warn" = "warn", + "error" = "error", + "fatal" = "fatal", +} export type LoggerStream = "stdout" | "stderr"; @@ -34,7 +32,10 @@ export class Logger { } log(level: LogLevel, message: string, object?: any) { - if (LogLevels.indexOf(level) < LogLevels.indexOf(this.level)) { + if ( + Object.values(LogLevel).indexOf(level) < + Object.values(LogLevel).indexOf(this.level) + ) { return; } @@ -62,42 +63,42 @@ export class Logger { } trace(message: string, object?: any) { - this.log("trace", message, object); + this.log(LogLevel.trace, message, object); } debug(message: string, object?: any) { - this.log("debug", message, object); + this.log(LogLevel.debug, message, object); } info(message: string, object?: any) { - this.log("info", message, object); + this.log(LogLevel.info, message, object); } warn(message: string, object?: any) { - this.log("warn", message, object); + this.log(LogLevel.warn, message, object); } error(message: string, object?: any) { - this.log("error", message, object); + this.log(LogLevel.error, message, object); } fatal(message: string, object?: any) { - this.log("fatal", message, object); + this.log(LogLevel.fatal, message, object); } private colorFromLopLevel(level: LogLevel) { switch (level) { - case "trace": + case LogLevel.trace: return colors.gray; - case "debug": + case LogLevel.debug: return colors.blue; - case "info": + case LogLevel.info: return colors.green; - case "warn": + case LogLevel.warn: return colors.yellow; - case "error": + case LogLevel.error: return colors.red; - case "fatal": + case LogLevel.fatal: return colors.magenta; } } diff --git a/classes/TypedError.ts b/classes/TypedError.ts index 6217509..64de56a 100644 --- a/classes/TypedError.ts +++ b/classes/TypedError.ts @@ -1,4 +1,4 @@ -export const typedErrorTypes = [ +export enum ErrorType { // general "SERVER_INITIALIZATION", "SERVER_START", @@ -19,20 +19,14 @@ export const typedErrorTypes = [ "CONNECTION_ACTION_PARAM_VALIDATION", "CONNECTION_ACTION_PARAM_FORMATTING", "CONNECTION_ACTION_RUN", -] as const; -export type TypedErrorType = (typeof typedErrorTypes)[number]; +} export class TypedError extends Error { - type: TypedErrorType; + type: ErrorType; key?: string; value?: any; - constructor( - message: string, - type: TypedErrorType, - key?: string, - value?: any, - ) { + constructor(message: string, type: ErrorType, key?: string, value?: any) { super(message); this.type = type; this.key = key; diff --git a/config/logger.ts b/config/logger.ts index 6f57fed..28c5aa6 100644 --- a/config/logger.ts +++ b/config/logger.ts @@ -1,8 +1,8 @@ -import type { LogLevel, LoggerStream } from "../classes/Logger"; +import { LogLevel, type LoggerStream } from "../classes/Logger"; import { loadFromEnvIfSet } from "../util/config"; export const configLogger = { - level: await loadFromEnvIfSet("logger.level", "info"), + level: await loadFromEnvIfSet("logger.level", LogLevel.info), includeTimestamps: await loadFromEnvIfSet("logger.includeTimestamps", true), colorize: await loadFromEnvIfSet("logger.colorize", true), stream: await loadFromEnvIfSet("logger.stream", "stdout"), diff --git a/initializers/actionts.ts b/initializers/actionts.ts index 3ea1e4f..e990d6c 100644 --- a/initializers/actionts.ts +++ b/initializers/actionts.ts @@ -1,7 +1,6 @@ import { logger } from "../api"; import type { Action } from "../classes/Action"; import { Initializer } from "../classes/Initializer"; -import { TypedError } from "../classes/TypedError"; import { globLoader } from "../util/glob"; const namespace = "actions"; @@ -21,15 +20,8 @@ export class Actions extends Initializer { async initialize() { const actions = await globLoader("actions"); - try { - for (const action of Object.values(actions)) { - await action.validate(); - } - } catch (e) { - throw new TypedError( - `Action validation failed: ${e}`, - "ACTION_VALIDATION", - ); + for (const a of actions) { + if (!a.description) a.description = `An Action: ${a.name}`; } logger.info(`loaded ${Object.keys(actions).length} actions`); diff --git a/initializers/drizzle.ts b/initializers/drizzle.ts index bf27206..2c9f865 100644 --- a/initializers/drizzle.ts +++ b/initializers/drizzle.ts @@ -8,7 +8,7 @@ import path from "path"; import { type Config as DrizzleMigrateConfig } from "drizzle-kit"; import { unlink } from "node:fs/promises"; import { $ } from "bun"; -import { TypedError } from "../classes/TypedError"; +import { ErrorType, TypedError } from "../classes/TypedError"; const namespace = "drizzle"; @@ -72,7 +72,7 @@ export class Drizzle extends Initializer { { throw new TypedError( `Failed to generate migrations: ${stderr.toString()}`, - "SERVER_INITIALIZATION", + ErrorType.SERVER_INITIALIZATION, ); } } diff --git a/util/config.ts b/util/config.ts index 48ebf1a..d364beb 100644 --- a/util/config.ts +++ b/util/config.ts @@ -1,6 +1,6 @@ import { $, sleep } from "bun"; import { EOL } from "os"; -import { TypedError } from "../classes/TypedError"; +import { ErrorType, TypedError } from "../classes/TypedError"; /** Loads a value from the environment, if it's set, otherwise returns the default value. @@ -32,7 +32,7 @@ export async function loadFromEnvIfSet( if (!["string", "number"].includes(typeof val)) { throw new TypedError( "Only config values of number or string can be made unique.", - "CONFIG_ERROR", + ErrorType.CONFIG_ERROR, ); } From 4847004d032d9af82eb00e3ece102342c17ac073 Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Sun, 24 Mar 2024 19:24:39 -0700 Subject: [PATCH 07/18] pretty --- drizzle/meta/0000_snapshot.json | 18 +++++------------- drizzle/meta/0001_snapshot.json | 22 ++++++---------------- drizzle/meta/_journal.json | 2 +- 3 files changed, 12 insertions(+), 30 deletions(-) diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 05d341e..3cd8349 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -47,12 +47,8 @@ "name": "messages_user_id_users_id_fk", "tableFrom": "messages", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -106,16 +102,12 @@ "indexes": { "name_idx": { "name": "name_idx", - "columns": [ - "name" - ], + "columns": ["name"], "isUnique": true }, "email_idx": { "name": "email_idx", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -131,4 +123,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json index 937d940..5064b2c 100644 --- a/drizzle/meta/0001_snapshot.json +++ b/drizzle/meta/0001_snapshot.json @@ -47,12 +47,8 @@ "name": "messages_user_id_users_id_fk", "tableFrom": "messages", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -106,16 +102,12 @@ "indexes": { "name_idx": { "name": "name_idx", - "columns": [ - "name" - ], + "columns": ["name"], "isUnique": true }, "email_idx": { "name": "email_idx", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -125,9 +117,7 @@ "users_email_unique": { "name": "users_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] } } } @@ -139,4 +129,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e135117..184d4a3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -17,4 +17,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} From 188610d47689498a6d2eca2693eb1b64279cbdd4 Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Sun, 24 Mar 2024 19:25:59 -0700 Subject: [PATCH 08/18] fix types --- servers/web.ts | 10 +++++----- util/glob.ts | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/servers/web.ts b/servers/web.ts index 601d311..66867bf 100644 --- a/servers/web.ts +++ b/servers/web.ts @@ -6,7 +6,7 @@ import path from "path"; import { type HTTP_METHOD } from "../classes/Action"; import { renderToReadableStream } from "react-dom/server"; import type { BunFile } from "bun"; -import { TypedError } from "../classes/TypedError"; +import { ErrorType, TypedError } from "../classes/TypedError"; type URLParsed = import("url").URL; @@ -34,7 +34,7 @@ export class WebServer extends Server> { error: async (error) => { logger.error(`uncaught web server error: ${error.message}`); return this.buildError( - new TypedError(`${error}`, "CONNECTION_SERVER_ERROR"), + new TypedError(`${error}`, ErrorType.CONNECTION_SERVER_ERROR), ); }, }); @@ -72,7 +72,7 @@ export class WebServer extends Server> { async handleAction(request: Request, url: URLParsed) { if (!this.server) - throw new TypedError("Serb server not started", "SERVER_START"); + throw new TypedError("Serb server not started", ErrorType.SERVER_START); let errorStatusCode = 500; const ipAddress = this.server.requestIP(request)?.address || "unknown"; @@ -109,7 +109,7 @@ export class WebServer extends Server> { return new Response(requestedAsset); } else return this.buildError( - new TypedError("Asset not found", "CONNECTION_SERVER_ERROR"), + new TypedError("Asset not found", ErrorType.CONNECTION_SERVER_ERROR), 404, ); } @@ -122,7 +122,7 @@ export class WebServer extends Server> { return new Response(requestedAsset); } else { return this.buildError( - new TypedError("Page not found", "CONNECTION_SERVER_ERROR"), + new TypedError("Page not found", ErrorType.CONNECTION_SERVER_ERROR), 404, ); } diff --git a/util/glob.ts b/util/glob.ts index c457e64..f58e993 100644 --- a/util/glob.ts +++ b/util/glob.ts @@ -1,7 +1,7 @@ import path from "path"; import { Glob } from "bun"; import { api } from "../api"; -import { TypedError } from "../classes/TypedError"; +import { ErrorType, TypedError } from "../classes/TypedError"; /** * @@ -25,7 +25,7 @@ export async function globLoader(searchDir: string) { } catch (error) { throw new TypedError( `Error loading from ${dir} - ${name} - ${error}`, - "SERVER_INITIALIZATION", + ErrorType.SERVER_INITIALIZATION, ); } } From b748d751ae8f45f7ffc46b47a489429a5ed7f34d Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Sun, 24 Mar 2024 19:29:29 -0700 Subject: [PATCH 09/18] test services --- .github/workflows/test.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 86b48e7..bd0f1a7 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -34,6 +34,13 @@ jobs: test: runs-on: ubuntu-latest + + services: + redis: + image: redis + postgres: + image: postgres + steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v1 From af9a2854106d1e96d45543bb4e3b22a44550c04f Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Sun, 24 Mar 2024 19:35:51 -0700 Subject: [PATCH 10/18] env test? --- .env.example | 3 +++ .github/workflows/test.yaml | 12 ++++++++++++ config/index.ts | 2 ++ config/redis.ts | 5 +++++ 4 files changed, 22 insertions(+) create mode 100644 config/redis.ts diff --git a/.env.example b/.env.example index ff0538a..a17bcef 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,6 @@ servers.web.closeActiveConnectionsOnStop.test=true db.connectionString="postgres://evan@localhost:5432/bun" db.connectionString.test="postgres://evan@localhost:5432/bun-test" db.autoMigrate=true + +redis.connectionString="redis://localhost:6379/0" +redis.connectionString.test="redis://localhost:6379/1" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index bd0f1a7..ec2608a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -40,6 +40,15 @@ jobs: image: redis postgres: image: postgres + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 steps: - uses: actions/checkout@v4 @@ -47,6 +56,9 @@ jobs: - run: bun install - run: cp .env.example .env - run: bun test + env: + "db.connectionString": postgres://postgres:postgres@localhost:5432/postgres + "redis.connectionString": redis://localhost:6379/1 complete: runs-on: ubuntu-latest diff --git a/config/index.ts b/config/index.ts index 9c19ddd..49cb2ab 100644 --- a/config/index.ts +++ b/config/index.ts @@ -2,10 +2,12 @@ import { configLogger } from "./logger"; import { configProcess } from "./process"; import { configServerWeb } from "./server/web"; import { configDatabase } from "./database"; +import { configRedis } from "./redis"; export const config = { process: configProcess, logger: configLogger, database: configDatabase, + redis: configRedis, server: { web: configServerWeb }, }; diff --git a/config/redis.ts b/config/redis.ts new file mode 100644 index 0000000..ae2cf6c --- /dev/null +++ b/config/redis.ts @@ -0,0 +1,5 @@ +import { loadFromEnvIfSet } from "../util/config"; + +export const configRedis = { + connectionString: await loadFromEnvIfSet("redis.connectionString", "x"), +}; From baa34e2305afdd04d7d8eabefc594cdd2f4e44eb Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Sun, 24 Mar 2024 19:37:58 -0700 Subject: [PATCH 11/18] quotes? --- .github/workflows/test.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index ec2608a..0f61d49 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -57,8 +57,8 @@ jobs: - run: cp .env.example .env - run: bun test env: - "db.connectionString": postgres://postgres:postgres@localhost:5432/postgres - "redis.connectionString": redis://localhost:6379/1 + db.connectionString: postgres://postgres:postgres@localhost:5432/postgres + redis.connectionString: redis://localhost:6379/1 complete: runs-on: ubuntu-latest From a991ed2937f38b5646a1602489f9655eedf2f45e Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Sun, 24 Mar 2024 19:39:47 -0700 Subject: [PATCH 12/18] log connection --- config/database.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/database.ts b/config/database.ts index 77cc824..ef7ecec 100644 --- a/config/database.ts +++ b/config/database.ts @@ -4,3 +4,5 @@ export const configDatabase = { connectionString: await loadFromEnvIfSet("db.connectionString", "x"), autoMigrate: await loadFromEnvIfSet("db.autoMigrate", true), }; + +console.log({ configDatabase }); From 74540b76611d6de85e85df7601d2a1840f29b6c7 Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Sun, 24 Mar 2024 19:44:11 -0700 Subject: [PATCH 13/18] defaults --- .env.example | 6 ++---- .github/workflows/test.yaml | 3 --- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index a17bcef..3f2af2a 100644 --- a/.env.example +++ b/.env.example @@ -15,11 +15,9 @@ servers.web.host=0.0.0.0 servers.web.apiRoute="/api" servers.web.assetRoute="/assets" servers.web.pageRoute="/pages" -servers.web.closeActiveConnectionsOnStop=false -servers.web.closeActiveConnectionsOnStop.test=true -db.connectionString="postgres://evan@localhost:5432/bun" -db.connectionString.test="postgres://evan@localhost:5432/bun-test" +db.connectionString="postgres://postgres:postgres@localhost:5432/bun" +db.connectionString.test="postgres://postgres:postgres@localhost:5432/bun-test" db.autoMigrate=true redis.connectionString="redis://localhost:6379/0" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 0f61d49..3d5a5ce 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -56,9 +56,6 @@ jobs: - run: bun install - run: cp .env.example .env - run: bun test - env: - db.connectionString: postgres://postgres:postgres@localhost:5432/postgres - redis.connectionString: redis://localhost:6379/1 complete: runs-on: ubuntu-latest From eb6ac47bb7f93cb187300a29cb39fd2cf5c62480 Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Sun, 24 Mar 2024 19:45:47 -0700 Subject: [PATCH 14/18] createdb --- .github/workflows/test.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 3d5a5ce..2a5262f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -53,6 +53,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v1 + - run: createdb -U postgres bun-test - run: bun install - run: cp .env.example .env - run: bun test From 9a4da759cec2dcd94e70a87a87bbfca8daee55ba Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Sun, 24 Mar 2024 19:47:56 -0700 Subject: [PATCH 15/18] bun-test --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2a5262f..16d5a16 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -42,6 +42,7 @@ jobs: image: postgres env: POSTGRES_PASSWORD: postgres + POSTGRES_DB: bun-test options: >- --health-cmd pg_isready --health-interval 10s @@ -53,7 +54,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v1 - - run: createdb -U postgres bun-test - run: bun install - run: cp .env.example .env - run: bun test From d9588b1d5203ee1c5d21ed19ca759bf4b0aeadc9 Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Sun, 24 Mar 2024 20:00:52 -0700 Subject: [PATCH 16/18] body parsing and test --- __tests__/actions/user.test.ts | 31 +++++++++++++++++++++++++++++++ config/database.ts | 2 -- servers/web.ts | 8 ++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 __tests__/actions/user.test.ts diff --git a/__tests__/actions/user.test.ts b/__tests__/actions/user.test.ts new file mode 100644 index 0000000..f28d23c --- /dev/null +++ b/__tests__/actions/user.test.ts @@ -0,0 +1,31 @@ +import { test, expect, beforeAll, afterAll } from "bun:test"; +import { api, type ActionResponse } from "../../api"; +import type { UserCreate } from "../../actions/user"; +import { config } from "../../config"; + +const url = `http://${config.server.web.host}:${config.server.web.port}`; + +beforeAll(async () => { + await api.start(); +}); + +afterAll(async () => { + await api.stop(); +}); + +test("user can be created", async () => { + const res = await fetch(url + "/api/user", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "person 1", + email: "person1@example.com", + password: "password", + }), + }); + const response = (await res.json()) as ActionResponse; + expect(res.status).toBe(200); + + expect(response.id).toEqual(1); + expect(response.email).toEqual("person1@example.com"); +}); diff --git a/config/database.ts b/config/database.ts index ef7ecec..77cc824 100644 --- a/config/database.ts +++ b/config/database.ts @@ -4,5 +4,3 @@ export const configDatabase = { connectionString: await loadFromEnvIfSet("db.connectionString", "x"), autoMigrate: await loadFromEnvIfSet("db.autoMigrate", true), }; - -console.log({ configDatabase }); diff --git a/servers/web.ts b/servers/web.ts index 66867bf..7cd3dac 100644 --- a/servers/web.ts +++ b/servers/web.ts @@ -90,6 +90,14 @@ export class WebServer extends Server> { params = new FormData(); } + const body = await request.text(); + try { + const bodyContent = JSON.parse(body) as Record; + for (const [key, value] of Object.entries(bodyContent)) { + params.set(key, value); + } + } catch {} + // TODO: fork for files vs actions vs pages const { response, error } = await connection.act( actionName, From 54edffb3f2cb271149104629adc5d65115c1bae3 Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Sun, 24 Mar 2024 23:00:32 -0700 Subject: [PATCH 17/18] truncation --- __tests__/actions/user.test.ts | 16 ++++++++++++ actions/user.ts | 2 +- initializers/{drizzle.ts => db.ts} | 41 +++++++++++++++++++++++++----- migrations.ts | 2 +- servers/web.ts | 15 ++++++----- 5 files changed, 62 insertions(+), 14 deletions(-) rename initializers/{drizzle.ts => db.ts} (65%) diff --git a/__tests__/actions/user.test.ts b/__tests__/actions/user.test.ts index f28d23c..092930a 100644 --- a/__tests__/actions/user.test.ts +++ b/__tests__/actions/user.test.ts @@ -7,6 +7,7 @@ const url = `http://${config.server.web.host}:${config.server.web.port}`; beforeAll(async () => { await api.start(); + await api.db.clearDatabase(); }); afterAll(async () => { @@ -29,3 +30,18 @@ test("user can be created", async () => { expect(response.id).toEqual(1); expect(response.email).toEqual("person1@example.com"); }); + +test("email must be unique", async () => { + const res = await fetch(url + "/api/user", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "person 1", + email: "person1@example.com", + password: "password", + }), + }); + const response = (await res.json()) as ActionResponse; + expect(res.status).toBe(500); + expect(response.error?.message).toMatch(/violates unique constraint/); +}); diff --git a/actions/user.ts b/actions/user.ts index be4e5e4..552fd5b 100644 --- a/actions/user.ts +++ b/actions/user.ts @@ -30,7 +30,7 @@ export class UserCreate implements Action { async run(params: ActionParams) { const user = ( - await api.drizzle.db + await api.db.db .insert(users) .values({ name: params.name, diff --git a/initializers/drizzle.ts b/initializers/db.ts similarity index 65% rename from initializers/drizzle.ts rename to initializers/db.ts index 2c9f865..93f0538 100644 --- a/initializers/drizzle.ts +++ b/initializers/db.ts @@ -3,6 +3,7 @@ import { Initializer } from "../classes/Initializer"; import { config } from "../config"; import { drizzle } from "drizzle-orm/node-postgres"; import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { sql } from "drizzle-orm"; import { Pool } from "pg"; import path from "path"; import { type Config as DrizzleMigrateConfig } from "drizzle-kit"; @@ -10,15 +11,15 @@ import { unlink } from "node:fs/promises"; import { $ } from "bun"; import { ErrorType, TypedError } from "../classes/TypedError"; -const namespace = "drizzle"; +const namespace = "db"; declare module "../classes/API" { export interface API { - [namespace]: Awaited>; + [namespace]: Awaited>; } } -export class Drizzle extends Initializer { +export class DB extends Initializer { constructor() { super(namespace); this.startPriority = 100; @@ -27,7 +28,10 @@ export class Drizzle extends Initializer { async initialize() { const dbContainer = {} as { db: ReturnType }; return Object.assign( - { generateMigrations: this.generateMigrations }, + { + generateMigrations: this.generateMigrations, + clearDatabase: this.clearDatabase, + }, dbContainer, ); } @@ -37,10 +41,10 @@ export class Drizzle extends Initializer { connectionString: config.database.connectionString, }); - api.drizzle.db = drizzle(pool); + api.db.db = drizzle(pool); if (config.database.autoMigrate) { - await migrate(api.drizzle.db, { migrationsFolder: "./drizzle" }); + await migrate(api.db.db, { migrationsFolder: "./drizzle" }); logger.info("database migrated successfully"); } @@ -81,4 +85,29 @@ export class Drizzle extends Initializer { if (await filePointer.exists()) await unlink(tmpfilePath); } } + + /** + * Erase all the tables in the active database. Will fail on production environments. + */ + async clearDatabase(restartIdentity = true, cascade = true) { + if (Bun.env.NODE_ENV === "production") { + throw new TypedError( + "clearDatabase cannot be called in production", + ErrorType.SERVER_INITIALIZATION, + ); + } + + const { rows } = await api.db.db.execute( + sql`SELECT tablename FROM pg_tables WHERE schemaname = CURRENT_SCHEMA`, + ); + + for (const row of rows) { + logger.debug(`truncating table ${row.tablename}`); + await api.db.db.execute( + sql.raw( + `TRUNCATE TABLE "${row.tablename}" ${restartIdentity ? "RESTART IDENTITY" : ""} ${cascade ? "CASCADE" : ""} `, + ), + ); + } + } } diff --git a/migrations.ts b/migrations.ts index f7c95fc..6a72b82 100644 --- a/migrations.ts +++ b/migrations.ts @@ -1,5 +1,5 @@ import { api } from "./api"; await api.initialize(); -await api.drizzle.generateMigrations(); +await api.db.generateMigrations(); await api.stop(); diff --git a/servers/web.ts b/servers/web.ts index 7cd3dac..c4dc4f6 100644 --- a/servers/web.ts +++ b/servers/web.ts @@ -90,13 +90,16 @@ export class WebServer extends Server> { params = new FormData(); } + // yes, body payload content clobbers form content, if the same keys exist const body = await request.text(); - try { - const bodyContent = JSON.parse(body) as Record; - for (const [key, value] of Object.entries(bodyContent)) { - params.set(key, value); - } - } catch {} + if (body && body.length > 2) { + try { + const bodyContent = JSON.parse(body) as Record; + for (const [key, value] of Object.entries(bodyContent)) { + params.set(key, value); + } + } catch {} + } // TODO: fork for files vs actions vs pages const { response, error } = await connection.act( From dbbe59c97d42f9a7162427347a1be7d8e95ef6f8 Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Sun, 24 Mar 2024 23:05:25 -0700 Subject: [PATCH 18/18] check body JSON on with appropriate mime type --- servers/web.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/servers/web.ts b/servers/web.ts index c4dc4f6..39e1a3d 100644 --- a/servers/web.ts +++ b/servers/web.ts @@ -90,9 +90,8 @@ export class WebServer extends Server> { params = new FormData(); } - // yes, body payload content clobbers form content, if the same keys exist - const body = await request.text(); - if (body && body.length > 2) { + if (request.headers.get("content-type") === "application/json") { + const body = await request.text(); try { const bodyContent = JSON.parse(body) as Record; for (const [key, value] of Object.entries(bodyContent)) {