diff --git a/.env.example b/.env.example index 9623647..3f2af2a 100644 --- a/.env.example +++ b/.env.example @@ -15,5 +15,10 @@ 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://postgres:postgres@localhost:5432/bun" +db.connectionString.test="postgres://postgres:postgres@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 86b48e7..16d5a16 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -34,6 +34,23 @@ jobs: test: runs-on: ubuntu-latest + + services: + redis: + image: redis + postgres: + image: postgres + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: bun-test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v1 diff --git a/README.md b/README.md index 1158977..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** @@ -90,3 +94,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/__tests__/actions/user.test.ts b/__tests__/actions/user.test.ts new file mode 100644 index 0000000..092930a --- /dev/null +++ b/__tests__/actions/user.test.ts @@ -0,0 +1,47 @@ +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(); + await api.db.clearDatabase(); +}); + +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"); +}); + +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/hello.ts b/actions/hello.ts index e94b2e5..05967a9 100644 --- a/actions/hello.ts +++ b/actions/hello.ts @@ -1,20 +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, - 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 new file mode 100644 index 0000000..552fd5b --- /dev/null +++ b/actions/user.ts @@ -0,0 +1,45 @@ +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 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) { + const user = ( + await api.db.db + .insert(users) + .values({ + name: params.name, + email: params.email, + password_hash: await hashPassword(params.password), + }) + .returning() + )[0]; + + return serializeUser(user); + } +} diff --git a/bun.lockb b/bun.lockb index 1a86ca0..a17ac04 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/classes/API.ts b/classes/API.ts index 483848f..2152d57 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 { ErrorType, TypedError } from "./TypedError"; export class API { rootDir: string; @@ -36,11 +37,14 @@ 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}`); + const response = await initializer.initialize?.(); + if (response) this[initializer.name] = response; + this.logger.debug(`Initialized initializer ${initializer.name}`); + } catch (e) { + throw new TypedError(`${e}`, ErrorType.SERVER_INITIALIZATION); + } } this.initialized = true; @@ -57,9 +61,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}`, ErrorType.SERVER_START); + } } this.started = true; @@ -74,9 +82,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}`, ErrorType.SERVER_STOP); + } } this.stopped = true; diff --git a/classes/Action.ts b/classes/Action.ts index b4b8a78..77fa91c 100644 --- a/classes/Action.ts +++ b/classes/Action.ts @@ -2,15 +2,14 @@ import type { Inputs } from "./Inputs"; import type { Connection } from "./Connection"; import type { Input } from "./Input"; -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; @@ -24,7 +23,7 @@ export type ActionConstructorInputs = { export abstract class Action { name: string; - description: string; + description?: string; inputs: Inputs; web: { route: RegExp | string; @@ -37,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, }; } @@ -51,11 +50,6 @@ export abstract class Action { params: ActionParams, connection: Connection, // ): ActionResponse; ): Promise; - - async validate() { - if (!this.name) throw new Error("Action name is required"); - if (!this.description) throw new Error("Action description is required"); - } } export type ActionParams = { diff --git a/classes/Connection.ts b/classes/Connection.ts index 0e31689..ba35075 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 { ErrorType, 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}` : ""}`, + ErrorType.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}`, ErrorType.CONNECTION_ACTION_RUN); } // Note: we want the params object to remain on the same line as the message, so we stringify @@ -76,25 +81,50 @@ 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}`, + ErrorType.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}`, + ErrorType.CONNECTION_ACTION_PARAM_REQUIRED, + key, + ); } if (paramDefinition.formatter) { - value = paramDefinition.formatter(value); + try { + value = paramDefinition.formatter(value); + } catch (e) { + throw new TypedError( + `${e}`, + ErrorType.CONNECTION_ACTION_PARAM_FORMATTING, + key, + value, + ); + } } 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 TypedError( + validationResponse, + ErrorType.CONNECTION_ACTION_PARAM_VALIDATION, + key, + value, + ); } } diff --git a/classes/Initializer.ts b/classes/Initializer.ts index 2bdd290..b1f6a54 100644 --- a/classes/Initializer.ts +++ b/classes/Initializer.ts @@ -1,11 +1,3 @@ -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. */ @@ -40,26 +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 Error("name is required for this initializer"); - } - - for (const priority of InitializerPriorities) { - const p = this[priority]; - - if (!p) { - throw new Error( - `${priority} is a required property for the initializer \`${this.name}\``, - ); - } else if (typeof p !== "number" || p < 0) { - throw new Error( - `${priority} is not a positive integer for the initializer \`${this.name}\``, - ); - } - } - } } export type InitializerSortKeys = diff --git a/classes/Input.ts b/classes/Input.ts index 63b8b9a..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) => Boolean; +export type InputValidator = (p: any) => string | undefined; // returning anything truthy means there is an error 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 new file mode 100644 index 0000000..64de56a --- /dev/null +++ b/classes/TypedError.ts @@ -0,0 +1,35 @@ +export enum ErrorType { + // 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", +} + +export class TypedError extends Error { + type: ErrorType; + key?: string; + value?: any; + + constructor(message: string, type: ErrorType, key?: string, value?: any) { + super(message); + this.type = type; + this.key = key; + this.value = value; + } +} diff --git a/config/database.ts b/config/database.ts new file mode 100644 index 0000000..77cc824 --- /dev/null +++ b/config/database.ts @@ -0,0 +1,6 @@ +import { loadFromEnvIfSet } from "../util/config"; + +export const configDatabase = { + connectionString: await loadFromEnvIfSet("db.connectionString", "x"), + autoMigrate: await loadFromEnvIfSet("db.autoMigrate", true), +}; diff --git a/config/index.ts b/config/index.ts index 39c9042..49cb2ab 100644 --- a/config/index.ts +++ b/config/index.ts @@ -1,9 +1,13 @@ 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/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/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"), +}; diff --git a/drizzle/0000_eminent_tyger_tiger.sql b/drizzle/0000_eminent_tyger_tiger.sql new file mode 100644 index 0000000..a395c24 --- /dev/null +++ b/drizzle/0000_eminent_tyger_tiger.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS "messages" ( + "id" serial PRIMARY KEY NOT NULL, + "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) 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 +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/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 new file mode 100644 index 0000000..3cd8349 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,126 @@ +{ + "id": "9fab8cf1-d31c-4bb9-afa8-25f57ab50d2e", + "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": 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": {} + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..5064b2c --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,132 @@ +{ + "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": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..184d4a3 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "5", + "dialect": "pg", + "entries": [ + { + "idx": 0, + "version": "5", + "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/actionts.ts b/initializers/actionts.ts index 759f97d..e990d6c 100644 --- a/initializers/actionts.ts +++ b/initializers/actionts.ts @@ -19,6 +19,11 @@ export class Actions extends Initializer { async initialize() { const actions = await globLoader("actions"); + + for (const a of actions) { + if (!a.description) a.description = `An Action: ${a.name}`; + } + logger.info(`loaded ${Object.keys(actions).length} actions`); return { actions }; } diff --git a/initializers/db.ts b/initializers/db.ts new file mode 100644 index 0000000..93f0538 --- /dev/null +++ b/initializers/db.ts @@ -0,0 +1,113 @@ +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 { sql } from "drizzle-orm"; +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"; +import { ErrorType, TypedError } from "../classes/TypedError"; + +const namespace = "db"; + +declare module "../classes/API" { + export interface API { + [namespace]: Awaited>; + } +} + +export class DB extends Initializer { + constructor() { + super(namespace); + this.startPriority = 100; + } + + async initialize() { + const dbContainer = {} as { db: ReturnType }; + return Object.assign( + { + generateMigrations: this.generateMigrations, + clearDatabase: this.clearDatabase, + }, + dbContainer, + ); + } + + async start() { + const pool = new Pool({ + connectionString: config.database.connectionString, + }); + + api.db.db = drizzle(pool); + + if (config.database.autoMigrate) { + await migrate(api.db.db, { migrationsFolder: "./drizzle" }); + logger.info("database migrated successfully"); + } + + 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 { + await Bun.write(tmpfilePath, fileContent); + const { exitCode, stdout, stderr } = + await $`bun drizzle-kit generate:pg --config ${tmpfilePath}`.quiet(); + logger.trace(stdout.toString()); + if (exitCode !== 0) { + { + throw new TypedError( + `Failed to generate migrations: ${stderr.toString()}`, + ErrorType.SERVER_INITIALIZATION, + ); + } + } + } finally { + const filePointer = Bun.file(tmpfilePath); + 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 new file mode 100644 index 0000000..6a72b82 --- /dev/null +++ b/migrations.ts @@ -0,0 +1,5 @@ +import { api } from "./api"; + +await api.initialize(); +await api.db.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 640747f..62fa4b4 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,14 +16,15 @@ "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" }, "scripts": { + "migrations": "bun run migrations.ts", "lint": "prettier --check .", "pretty": "prettier --write .", "ci": "bun run type_doc && bun run lint && bun test", diff --git a/schema/messages.ts b/schema/messages.ts new file mode 100644 index 0000000..44c090c --- /dev/null +++ b/schema/messages.ts @@ -0,0 +1,15 @@ +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").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 new file mode 100644 index 0000000..380239c --- /dev/null +++ b/schema/users.ts @@ -0,0 +1,29 @@ +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 }).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 { + nameIndex: uniqueIndex("name_idx").on(users.name), + emailIndex: uniqueIndex("email_idx").on(users.email), + }; + }, +); + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; diff --git a/servers/web.ts b/servers/web.ts index 04ee50f..39e1a3d 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 { ErrorType, 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}`, ErrorType.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", ErrorType.SERVER_START); let errorStatusCode = 500; const ipAddress = this.server.requestIP(request)?.address || "unknown"; @@ -86,6 +90,16 @@ export class WebServer extends Server> { params = new FormData(); } + 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)) { + params.set(key, value); + } + } catch {} + } + // TODO: fork for files vs actions vs pages const { response, error } = await connection.act( actionName, @@ -103,7 +117,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", ErrorType.CONNECTION_SERVER_ERROR), + 404, + ); } async handlePage(request: Request, url: URLParsed) { @@ -113,7 +131,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", ErrorType.CONNECTION_SERVER_ERROR), + 404, + ); } } @@ -177,7 +198,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 +216,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..d364beb 100644 --- a/util/config.ts +++ b/util/config.ts @@ -1,5 +1,6 @@ import { $, sleep } from "bun"; import { EOL } from "os"; +import { ErrorType, 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.", + ErrorType.CONFIG_ERROR, ); } diff --git a/util/glob.ts b/util/glob.ts index 98984a3..f58e993 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 { ErrorType, 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}`, + ErrorType.SERVER_INITIALIZATION, + ); } } }