From 066c3310ff05227c9c55568eceeb47f1ea3a080a Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 27 Apr 2026 19:41:10 -0400 Subject: [PATCH] Add auto-migration on server startup Run Drizzle migrations before seeding admin user so tables exist on fresh database. Migration files generated from current schema. Co-Authored-By: Claude Opus 4.6 --- .../drizzle/0000_tearful_hedge_knight.sql | 63 +++ apps/server/drizzle/meta/0000_snapshot.json | 470 ++++++++++++++++++ apps/server/drizzle/meta/_journal.json | 13 + apps/server/src/db/index.ts | 13 + apps/server/src/index.ts | 2 + 5 files changed, 561 insertions(+) create mode 100644 apps/server/drizzle/0000_tearful_hedge_knight.sql create mode 100644 apps/server/drizzle/meta/0000_snapshot.json create mode 100644 apps/server/drizzle/meta/_journal.json diff --git a/apps/server/drizzle/0000_tearful_hedge_knight.sql b/apps/server/drizzle/0000_tearful_hedge_knight.sql new file mode 100644 index 0000000..d64cd40 --- /dev/null +++ b/apps/server/drizzle/0000_tearful_hedge_knight.sql @@ -0,0 +1,63 @@ +CREATE TABLE "achievements" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "achievement_id" text NOT NULL, + "unlocked_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "invitations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "code" text NOT NULL, + "created_by" uuid NOT NULL, + "used_by" uuid, + "created_at" timestamp DEFAULT now() NOT NULL, + "expires_at" timestamp, + CONSTRAINT "invitations_code_unique" UNIQUE("code") +); +--> statement-breakpoint +CREATE TABLE "leaderboard" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "company_name" text NOT NULL, + "category" text NOT NULL, + "score" integer NOT NULL, + "era" text NOT NULL, + "tick_count" integer NOT NULL, + "submitted_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "saves" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "company_name" text NOT NULL, + "save_version" integer NOT NULL, + "game_data" jsonb NOT NULL, + "tick_count" integer DEFAULT 0 NOT NULL, + "era" text DEFAULT 'startup' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "anon_token" uuid DEFAULT gen_random_uuid() NOT NULL, + "username" text, + "email" text, + "password_hash" text, + "role" text DEFAULT 'user' NOT NULL, + "must_reset_password" boolean DEFAULT false NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "last_seen_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "users_anon_token_unique" UNIQUE("anon_token"), + CONSTRAINT "users_username_unique" UNIQUE("username"), + CONSTRAINT "users_email_unique" UNIQUE("email") +); +--> statement-breakpoint +ALTER TABLE "achievements" ADD CONSTRAINT "achievements_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_used_by_users_id_fk" FOREIGN KEY ("used_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "leaderboard" ADD CONSTRAINT "leaderboard_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "saves" ADD CONSTRAINT "saves_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "achievements_user_id_idx" ON "achievements" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "leaderboard_category_score_idx" ON "leaderboard" USING btree ("category","score");--> statement-breakpoint +CREATE INDEX "saves_user_id_idx" ON "saves" USING btree ("user_id"); \ No newline at end of file diff --git a/apps/server/drizzle/meta/0000_snapshot.json b/apps/server/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..f67827f --- /dev/null +++ b/apps/server/drizzle/meta/0000_snapshot.json @@ -0,0 +1,470 @@ +{ + "id": "8cfe4136-b228-464d-bf2c-e4f2e8c73ce1", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.achievements": { + "name": "achievements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "achievement_id": { + "name": "achievement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlocked_at": { + "name": "unlocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "achievements_user_id_idx": { + "name": "achievements_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "achievements_user_id_users_id_fk": { + "name": "achievements_user_id_users_id_fk", + "tableFrom": "achievements", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "used_by": { + "name": "used_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "invitations_created_by_users_id_fk": { + "name": "invitations_created_by_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invitations_used_by_users_id_fk": { + "name": "invitations_used_by_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "used_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitations_code_unique": { + "name": "invitations_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leaderboard": { + "name": "leaderboard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "era": { + "name": "era", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tick_count": { + "name": "tick_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leaderboard_category_score_idx": { + "name": "leaderboard_category_score_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leaderboard_user_id_users_id_fk": { + "name": "leaderboard_user_id_users_id_fk", + "tableFrom": "leaderboard", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saves": { + "name": "saves", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "save_version": { + "name": "save_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "game_data": { + "name": "game_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tick_count": { + "name": "tick_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "era": { + "name": "era", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'startup'" + }, + "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": { + "saves_user_id_idx": { + "name": "saves_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saves_user_id_users_id_fk": { + "name": "saves_user_id_users_id_fk", + "tableFrom": "saves", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "anon_token": { + "name": "anon_token", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "must_reset_password": { + "name": "must_reset_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_anon_token_unique": { + "name": "users_anon_token_unique", + "nullsNotDistinct": false, + "columns": [ + "anon_token" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json new file mode 100644 index 0000000..cfec2a1 --- /dev/null +++ b/apps/server/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1777333216602, + "tag": "0000_tearful_hedge_knight", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/apps/server/src/db/index.ts b/apps/server/src/db/index.ts index e8bb209..041ad0c 100644 --- a/apps/server/src/db/index.ts +++ b/apps/server/src/db/index.ts @@ -1,4 +1,7 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { drizzle } from 'drizzle-orm/postgres-js'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; import postgres from 'postgres'; import * as schema from './schema'; @@ -8,3 +11,13 @@ const client = postgres(connectionString); export const db = drizzle(client, { schema }); export type Database = typeof db; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export async function runMigrations() { + const migrationClient = postgres(connectionString, { max: 1 }); + const migrationDb = drizzle(migrationClient); + await migrate(migrationDb, { migrationsFolder: path.resolve(__dirname, '../../drizzle') }); + await migrationClient.end(); + console.log('Database migrations complete'); +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 7bdfb60..3779056 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -6,6 +6,7 @@ import { auth } from './routes/auth'; import { savesRouter } from './routes/saves'; import { leaderboardRouter } from './routes/leaderboard'; import { invitesRouter } from './routes/invites'; +import { runMigrations } from './db'; import { seedAdmin } from './db/seed'; if (!process.env.JWT_SECRET) { @@ -40,6 +41,7 @@ const port = Number(process.env.PORT) || 3001; console.log(`AI Tycoon API server starting on port ${port}...`); +await runMigrations(); await seedAdmin(); serve({ fetch: app.fetch, port });