diff --git a/.env.example b/.env.example index 4dddc44..bae96a5 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,8 @@ VITE_SUPABASE_ANON_KEY=your-supabase-anon-key GOOGLE_SHEETS_API_KEY=your-google-sheets-api-key GOOGLE_SHEETS_CLIENT_EMAIL=your-service-account@project.iam.gserviceaccount.com GOOGLE_SHEETS_PRIVATE_KEY=your-google-sheets-private-key +# Or use service account JSON (base64 encoded or file path): +GOOGLE_SA_JSON={"type":"service_account","project_id":"your-project",...} # ===== Analytics Configuration ===== ANALYTICS_ENABLED=true diff --git a/.github/workflows/db-bootstrap.yml b/.github/workflows/db-bootstrap.yml new file mode 100644 index 0000000..a77da72 --- /dev/null +++ b/.github/workflows/db-bootstrap.yml @@ -0,0 +1,87 @@ +name: Database Bootstrap + +# This workflow runs database migrations manually with approval +# to prevent accidental schema changes in production + +on: + workflow_dispatch: + inputs: + environment: + description: 'Target environment' + required: true + default: 'production' + type: choice + options: + - production + - staging + +jobs: + migrate: + name: Run Database Migrations + runs-on: ubuntu-latest + # Require manual approval for production deployments + environment: ${{ github.event.inputs.environment }} + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install PostgreSQL client + run: | + sudo apt-get update + sudo apt-get install -y postgresql-client + + - name: Run migrations + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + PGSSLMODE: require + run: | + echo "Running database migrations..." + pnpm run migrate:up + + - name: Apply seed data + if: github.event.inputs.environment == 'staging' + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + PGSSLMODE: require + run: | + echo "Applying seed data (staging only)..." + if [ -f "database/seeds/0001_seeds.sql" ]; then + psql "$DATABASE_URL" < database/seeds/0001_seeds.sql || echo "Seed data might already exist" + fi + + - name: Migration Summary + run: | + echo "✅ Database migrations completed successfully" + echo "Environment: ${{ github.event.inputs.environment }}" + echo "Commit: ${{ github.sha }}" + echo "Branch: ${{ github.ref_name }}" diff --git a/database/migration-config.js b/database/migration-config.js new file mode 100644 index 0000000..215d768 --- /dev/null +++ b/database/migration-config.js @@ -0,0 +1,23 @@ +/** + * node-pg-migrate configuration + * Reads DATABASE_URL and PGSSLMODE from environment variables + */ + +const databaseUrl = process.env.DATABASE_URL; +const pgsslmode = process.env.PGSSLMODE || 'require'; + +if (!databaseUrl) { + throw new Error('DATABASE_URL environment variable is required'); +} + +module.exports = { + databaseUrl, + dir: 'database/migrations', + migrationsTable: 'pgmigrations', + direction: 'up', + ssl: pgsslmode === 'require' || pgsslmode === 'verify-full', + decamelize: true, + createSchema: true, + createMigrationsSchema: false, + verbose: true, +}; diff --git a/database/migrations/0001_initial.js b/database/migrations/0001_initial.js new file mode 100644 index 0000000..e1b16cb --- /dev/null +++ b/database/migrations/0001_initial.js @@ -0,0 +1,652 @@ +/** + * Initial database schema migration + * Creates all tables, indexes, triggers, and inserts default roles + * Uses pgcrypto extension for UUID generation (gen_random_uuid) + * Compatible with managed PostgreSQL providers like Supabase + */ + +exports.up = (pgm) => { + // Enable pgcrypto extension (instead of uuid-ossp for better compatibility) + pgm.createExtension('pgcrypto', { + ifNotExists: true, + }); + + // Users table + pgm.createTable( + 'users', + { + id: { + type: 'uuid', + primaryKey: true, + default: pgm.func('gen_random_uuid()'), + }, + email: { + type: 'varchar(255)', + notNull: true, + unique: true, + }, + username: { + type: 'varchar(100)', + notNull: true, + unique: true, + }, + created_at: { + type: 'timestamp with time zone', + notNull: true, + default: pgm.func('NOW()'), + }, + updated_at: { + type: 'timestamp with time zone', + notNull: true, + default: pgm.func('NOW()'), + }, + }, + { + ifNotExists: true, + }, + ); + + // Roles table + pgm.createTable( + 'roles', + { + id: { + type: 'uuid', + primaryKey: true, + default: pgm.func('gen_random_uuid()'), + }, + name: { + type: 'varchar(50)', + notNull: true, + unique: true, + }, + description: { + type: 'text', + }, + }, + { + ifNotExists: true, + }, + ); + + // User roles junction table + pgm.createTable( + 'user_roles', + { + user_id: { + type: 'uuid', + notNull: true, + references: 'users(id)', + onDelete: 'CASCADE', + }, + role_id: { + type: 'uuid', + notNull: true, + references: 'roles(id)', + onDelete: 'CASCADE', + }, + guild_id: { + type: 'uuid', + }, + }, + { + ifNotExists: true, + }, + ); + + // Add composite primary key for user_roles + pgm.addConstraint( + 'user_roles', + 'user_roles_pkey', + { + primaryKey: [ + 'user_id', + 'role_id', + { expression: "COALESCE(guild_id, '00000000-0000-0000-0000-000000000000'::UUID)" }, + ], + }, + { + ifNotExists: true, + }, + ); + + // Guilds table (multi-tenant) + pgm.createTable( + 'guilds', + { + id: { + type: 'uuid', + primaryKey: true, + default: pgm.func('gen_random_uuid()'), + }, + discord_guild_id: { + type: 'varchar(100)', + notNull: true, + unique: true, + }, + name: { + type: 'varchar(255)', + notNull: true, + }, + settings: { + type: 'jsonb', + default: "'{}'", + }, + feature_flags: { + type: 'jsonb', + default: "'{}'", + }, + created_at: { + type: 'timestamp with time zone', + notNull: true, + default: pgm.func('NOW()'), + }, + updated_at: { + type: 'timestamp with time zone', + notNull: true, + default: pgm.func('NOW()'), + }, + }, + { + ifNotExists: true, + }, + ); + + // Guild members table + pgm.createTable( + 'guild_members', + { + guild_id: { + type: 'uuid', + notNull: true, + references: 'guilds(id)', + onDelete: 'CASCADE', + }, + user_id: { + type: 'uuid', + notNull: true, + references: 'users(id)', + onDelete: 'CASCADE', + }, + discord_user_id: { + type: 'varchar(100)', + notNull: true, + }, + joined_at: { + type: 'timestamp with time zone', + notNull: true, + default: pgm.func('NOW()'), + }, + }, + { + ifNotExists: true, + }, + ); + + pgm.addConstraint( + 'guild_members', + 'guild_members_pkey', + { + primaryKey: ['guild_id', 'user_id'], + }, + { + ifNotExists: true, + }, + ); + + // Feature flags table + pgm.createTable( + 'feature_flags', + { + id: { + type: 'uuid', + primaryKey: true, + default: pgm.func('gen_random_uuid()'), + }, + name: { + type: 'varchar(100)', + notNull: true, + unique: true, + }, + enabled: { + type: 'boolean', + default: false, + }, + rollout_percentage: { + type: 'integer', + default: 0, + }, + guild_ids: { + type: 'jsonb', + default: "'[]'", + }, + created_at: { + type: 'timestamp with time zone', + notNull: true, + default: pgm.func('NOW()'), + }, + updated_at: { + type: 'timestamp with time zone', + notNull: true, + default: pgm.func('NOW()'), + }, + }, + { + ifNotExists: true, + }, + ); + + // Add check constraint for rollout_percentage + pgm.addConstraint( + 'feature_flags', + 'feature_flags_rollout_percentage_check', + { + check: 'rollout_percentage >= 0 AND rollout_percentage <= 100', + }, + { + ifNotExists: true, + }, + ); + + // Audit logs table + pgm.createTable( + 'audit_logs', + { + id: { + type: 'uuid', + primaryKey: true, + default: pgm.func('gen_random_uuid()'), + }, + action: { + type: 'varchar(255)', + notNull: true, + }, + user_id: { + type: 'uuid', + references: 'users(id)', + onDelete: 'SET NULL', + }, + guild_id: { + type: 'uuid', + references: 'guilds(id)', + onDelete: 'SET NULL', + }, + metadata: { + type: 'jsonb', + default: "'{}'", + }, + ip_address: { + type: 'inet', + }, + user_agent: { + type: 'text', + }, + created_at: { + type: 'timestamp with time zone', + notNull: true, + default: pgm.func('NOW()'), + }, + }, + { + ifNotExists: true, + }, + ); + + // Analytics events table + pgm.createTable( + 'analytics_events', + { + id: { + type: 'uuid', + primaryKey: true, + default: pgm.func('gen_random_uuid()'), + }, + event_type: { + type: 'varchar(100)', + notNull: true, + }, + user_id: { + type: 'uuid', + references: 'users(id)', + onDelete: 'SET NULL', + }, + guild_id: { + type: 'uuid', + references: 'guilds(id)', + onDelete: 'SET NULL', + }, + metadata: { + type: 'jsonb', + default: "'{}'", + }, + created_at: { + type: 'timestamp with time zone', + notNull: true, + default: pgm.func('NOW()'), + }, + }, + { + ifNotExists: true, + }, + ); + + // Scheduled jobs table + pgm.createTable( + 'scheduled_jobs', + { + id: { + type: 'uuid', + primaryKey: true, + default: pgm.func('gen_random_uuid()'), + }, + name: { + type: 'varchar(255)', + notNull: true, + }, + schedule: { + type: 'varchar(100)', + notNull: true, + }, + payload: { + type: 'jsonb', + default: "'{}'", + }, + last_run: { + type: 'timestamp with time zone', + }, + next_run: { + type: 'timestamp with time zone', + notNull: true, + }, + enabled: { + type: 'boolean', + default: true, + }, + created_at: { + type: 'timestamp with time zone', + notNull: true, + default: pgm.func('NOW()'), + }, + updated_at: { + type: 'timestamp with time zone', + notNull: true, + default: pgm.func('NOW()'), + }, + }, + { + ifNotExists: true, + }, + ); + + // Queue jobs table + pgm.createTable( + 'queue_jobs', + { + id: { + type: 'uuid', + primaryKey: true, + default: pgm.func('gen_random_uuid()'), + }, + type: { + type: 'varchar(100)', + notNull: true, + }, + payload: { + type: 'jsonb', + default: "'{}'", + }, + priority: { + type: 'integer', + default: 0, + }, + attempts: { + type: 'integer', + default: 0, + }, + max_attempts: { + type: 'integer', + default: 3, + }, + status: { + type: 'varchar(20)', + default: "'pending'", + }, + created_at: { + type: 'timestamp with time zone', + notNull: true, + default: pgm.func('NOW()'), + }, + processed_at: { + type: 'timestamp with time zone', + }, + }, + { + ifNotExists: true, + }, + ); + + // Add check constraint for queue_jobs status + pgm.addConstraint( + 'queue_jobs', + 'queue_jobs_status_check', + { + check: "status IN ('pending', 'processing', 'completed', 'failed')", + }, + { + ifNotExists: true, + }, + ); + + // Troops table (Gems of War data) + pgm.createTable( + 'troops', + { + id: { + type: 'uuid', + primaryKey: true, + default: pgm.func('gen_random_uuid()'), + }, + name: { + type: 'varchar(255)', + notNull: true, + }, + rarity: { + type: 'varchar(50)', + notNull: true, + }, + mana_colors: { + type: 'jsonb', + default: "'[]'", + }, + attack: { + type: 'integer', + default: 0, + }, + armor: { + type: 'integer', + default: 0, + }, + life: { + type: 'integer', + default: 0, + }, + magic: { + type: 'integer', + default: 0, + }, + traits: { + type: 'jsonb', + default: "'[]'", + }, + spell_description: { + type: 'text', + }, + }, + { + ifNotExists: true, + }, + ); + + // Battle simulations table + pgm.createTable( + 'battle_simulations', + { + id: { + type: 'uuid', + primaryKey: true, + default: pgm.func('gen_random_uuid()'), + }, + user_id: { + type: 'uuid', + references: 'users(id)', + onDelete: 'SET NULL', + }, + team1: { + type: 'jsonb', + notNull: true, + }, + team2: { + type: 'jsonb', + notNull: true, + }, + result: { + type: 'jsonb', + notNull: true, + }, + created_at: { + type: 'timestamp with time zone', + notNull: true, + default: pgm.func('NOW()'), + }, + }, + { + ifNotExists: true, + }, + ); + + // Create indexes + pgm.createIndex('users', 'email', { ifNotExists: true }); + pgm.createIndex('guilds', 'discord_guild_id', { ifNotExists: true }); + pgm.createIndex('audit_logs', 'user_id', { ifNotExists: true }); + pgm.createIndex('audit_logs', 'guild_id', { ifNotExists: true }); + pgm.createIndex('audit_logs', 'created_at', { ifNotExists: true }); + pgm.createIndex('analytics_events', 'guild_id', { ifNotExists: true }); + pgm.createIndex('analytics_events', 'created_at', { ifNotExists: true }); + pgm.createIndex('queue_jobs', 'status', { ifNotExists: true }); + pgm.createIndex('scheduled_jobs', 'next_run', { + ifNotExists: true, + where: 'enabled = true', + }); + + // Function to update updated_at timestamp + pgm.createFunction( + 'update_updated_at_column', + [], + { + returns: 'trigger', + language: 'plpgsql', + replace: true, + }, + ` +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +`, + ); + + // Triggers for updated_at + pgm.createTrigger( + 'users', + 'update_users_updated_at', + { + when: 'BEFORE', + operation: 'UPDATE', + function: 'update_updated_at_column', + level: 'ROW', + }, + { + ifNotExists: true, + }, + ); + + pgm.createTrigger( + 'guilds', + 'update_guilds_updated_at', + { + when: 'BEFORE', + operation: 'UPDATE', + function: 'update_updated_at_column', + level: 'ROW', + }, + { + ifNotExists: true, + }, + ); + + pgm.createTrigger( + 'feature_flags', + 'update_feature_flags_updated_at', + { + when: 'BEFORE', + operation: 'UPDATE', + function: 'update_updated_at_column', + level: 'ROW', + }, + { + ifNotExists: true, + }, + ); + + pgm.createTrigger( + 'scheduled_jobs', + 'update_scheduled_jobs_updated_at', + { + when: 'BEFORE', + operation: 'UPDATE', + function: 'update_updated_at_column', + level: 'ROW', + }, + { + ifNotExists: true, + }, + ); + + // Insert default roles (idempotent - will be handled by ON CONFLICT in seeds) + pgm.sql(` + INSERT INTO roles (name, description) VALUES + ('admin', 'Full system access'), + ('guild_master', 'Guild management access'), + ('moderator', 'Moderation tools access'), + ('member', 'Basic member access') + ON CONFLICT (name) DO NOTHING; + `); +}; + +exports.down = (pgm) => { + // Drop triggers + pgm.dropTrigger('scheduled_jobs', 'update_scheduled_jobs_updated_at', { ifExists: true }); + pgm.dropTrigger('feature_flags', 'update_feature_flags_updated_at', { ifExists: true }); + pgm.dropTrigger('guilds', 'update_guilds_updated_at', { ifExists: true }); + pgm.dropTrigger('users', 'update_users_updated_at', { ifExists: true }); + + // Drop function + pgm.dropFunction('update_updated_at_column', [], { ifExists: true }); + + // Drop tables (in reverse order of creation to handle dependencies) + pgm.dropTable('battle_simulations', { ifExists: true }); + pgm.dropTable('troops', { ifExists: true }); + pgm.dropTable('queue_jobs', { ifExists: true }); + pgm.dropTable('scheduled_jobs', { ifExists: true }); + pgm.dropTable('analytics_events', { ifExists: true }); + pgm.dropTable('audit_logs', { ifExists: true }); + pgm.dropTable('feature_flags', { ifExists: true }); + pgm.dropTable('guild_members', { ifExists: true }); + pgm.dropTable('guilds', { ifExists: true }); + pgm.dropTable('user_roles', { ifExists: true }); + pgm.dropTable('roles', { ifExists: true }); + pgm.dropTable('users', { ifExists: true }); + + // Drop extension + pgm.dropExtension('pgcrypto', { ifExists: true }); +}; diff --git a/database/schema_pgcrypto.sql b/database/schema_pgcrypto.sql new file mode 100644 index 0000000..3cab133 --- /dev/null +++ b/database/schema_pgcrypto.sql @@ -0,0 +1,178 @@ +-- StarForge Database Schema for Supabase/Postgres +-- Version using pgcrypto extension instead of uuid-ossp +-- This version is more compatible with managed PostgreSQL providers + +-- Enable pgcrypto extension (better compatibility than uuid-ossp) +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + username VARCHAR(100) UNIQUE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Roles table +CREATE TABLE IF NOT EXISTS roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(50) UNIQUE NOT NULL, + description TEXT +); + +-- User roles junction table +CREATE TABLE IF NOT EXISTS user_roles ( + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + role_id UUID REFERENCES roles(id) ON DELETE CASCADE, + guild_id UUID, + PRIMARY KEY (user_id, role_id, COALESCE(guild_id, '00000000-0000-0000-0000-000000000000'::UUID)) +); + +-- Guilds table (multi-tenant) +CREATE TABLE IF NOT EXISTS guilds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + discord_guild_id VARCHAR(100) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + settings JSONB DEFAULT '{}', + feature_flags JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Guild members table +CREATE TABLE IF NOT EXISTS guild_members ( + guild_id UUID REFERENCES guilds(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + discord_user_id VARCHAR(100) NOT NULL, + joined_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + PRIMARY KEY (guild_id, user_id) +); + +-- Feature flags table +CREATE TABLE IF NOT EXISTS feature_flags ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) UNIQUE NOT NULL, + enabled BOOLEAN DEFAULT false, + rollout_percentage INTEGER DEFAULT 0 CHECK (rollout_percentage >= 0 AND rollout_percentage <= 100), + guild_ids JSONB DEFAULT '[]', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Audit logs table +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + action VARCHAR(255) NOT NULL, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + guild_id UUID REFERENCES guilds(id) ON DELETE SET NULL, + metadata JSONB DEFAULT '{}', + ip_address INET, + user_agent TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Analytics events table +CREATE TABLE IF NOT EXISTS analytics_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_type VARCHAR(100) NOT NULL, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + guild_id UUID REFERENCES guilds(id) ON DELETE SET NULL, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Scheduled jobs table +CREATE TABLE IF NOT EXISTS scheduled_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + schedule VARCHAR(100) NOT NULL, + payload JSONB DEFAULT '{}', + last_run TIMESTAMP WITH TIME ZONE, + next_run TIMESTAMP WITH TIME ZONE NOT NULL, + enabled BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Queue jobs table +CREATE TABLE IF NOT EXISTS queue_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + type VARCHAR(100) NOT NULL, + payload JSONB DEFAULT '{}', + priority INTEGER DEFAULT 0, + attempts INTEGER DEFAULT 0, + max_attempts INTEGER DEFAULT 3, + status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed', 'failed')), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + processed_at TIMESTAMP WITH TIME ZONE +); + +-- Troops table (Gems of War data) +CREATE TABLE IF NOT EXISTS troops ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + rarity VARCHAR(50) NOT NULL, + mana_colors JSONB DEFAULT '[]', + attack INTEGER DEFAULT 0, + armor INTEGER DEFAULT 0, + life INTEGER DEFAULT 0, + magic INTEGER DEFAULT 0, + traits JSONB DEFAULT '[]', + spell_description TEXT +); + +-- Battle simulations table +CREATE TABLE IF NOT EXISTS battle_simulations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + team1 JSONB NOT NULL, + team2 JSONB NOT NULL, + result JSONB NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_guilds_discord_id ON guilds(discord_guild_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_guild_id ON audit_logs(guild_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_analytics_events_guild_id ON analytics_events(guild_id); +CREATE INDEX IF NOT EXISTS idx_analytics_events_created_at ON analytics_events(created_at); +CREATE INDEX IF NOT EXISTS idx_queue_jobs_status ON queue_jobs(status); +CREATE INDEX IF NOT EXISTS idx_scheduled_jobs_next_run ON scheduled_jobs(next_run) WHERE enabled = true; + +-- Insert default roles +INSERT INTO roles (name, description) VALUES + ('admin', 'Full system access'), + ('guild_master', 'Guild management access'), + ('moderator', 'Moderation tools access'), + ('member', 'Basic member access') +ON CONFLICT (name) DO NOTHING; + +-- Function to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Triggers for updated_at +DROP TRIGGER IF EXISTS update_users_updated_at ON users; +CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_guilds_updated_at ON guilds; +CREATE TRIGGER update_guilds_updated_at BEFORE UPDATE ON guilds + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_feature_flags_updated_at ON feature_flags; +CREATE TRIGGER update_feature_flags_updated_at BEFORE UPDATE ON feature_flags + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_scheduled_jobs_updated_at ON scheduled_jobs; +CREATE TRIGGER update_scheduled_jobs_updated_at BEFORE UPDATE ON scheduled_jobs + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/database/seeds/0001_seeds.sql b/database/seeds/0001_seeds.sql new file mode 100644 index 0000000..57dac39 --- /dev/null +++ b/database/seeds/0001_seeds.sql @@ -0,0 +1,25 @@ +-- Seed data for development and testing +-- All inserts are idempotent using ON CONFLICT DO NOTHING + +-- Insert default roles (if not already inserted by migration) +INSERT INTO roles (name, description) VALUES + ('admin', 'Full system access'), + ('guild_master', 'Guild management access'), + ('moderator', 'Moderation tools access'), + ('member', 'Basic member access') +ON CONFLICT (name) DO NOTHING; + +-- Insert sample feature flags +INSERT INTO feature_flags (name, enabled, rollout_percentage) VALUES + ('battle_simulator', true, 100), + ('recommendations', true, 50), + ('google_sheets_sync', false, 0), + ('advanced_analytics', true, 100) +ON CONFLICT (name) DO NOTHING; + +-- Insert sample guild +INSERT INTO guilds (discord_guild_id, name, settings, feature_flags) VALUES + ('123456789012345678', 'Test Guild', + '{"timezone": "UTC", "weeklyResetDay": 1, "notificationsEnabled": true}'::jsonb, + '{"battle_simulator": true, "recommendations": true}'::jsonb) +ON CONFLICT (discord_guild_id) DO NOTHING; diff --git a/docs/DB_MIGRATIONS.md b/docs/DB_MIGRATIONS.md new file mode 100644 index 0000000..ef581d3 --- /dev/null +++ b/docs/DB_MIGRATIONS.md @@ -0,0 +1,262 @@ +# Database Migrations + +This document explains how to manage database migrations for the StarForge project. + +## Overview + +StarForge uses [node-pg-migrate](https://salsita.github.io/node-pg-migrate/) for database schema migrations. This provides: + +- **Version control** for database schema changes +- **Up and down migrations** for easy rollback +- **Idempotent operations** that are safe to run multiple times +- **Transaction support** for atomic changes + +## Key Design Decisions + +### pgcrypto vs uuid-ossp + +We use the `pgcrypto` extension with `gen_random_uuid()` instead of `uuid-ossp` with `uuid_generate_v4()`. This choice was made for better compatibility with managed PostgreSQL providers like Supabase, which may have restrictions on certain extensions. + +### JavaScript Migrations + +Migrations are written in JavaScript (not SQL) to take advantage of: + +- Built-in up/down migration support +- Better transaction control +- Programmatic schema generation +- Type safety and validation + +### Idempotent Seeds + +Seed data uses `INSERT ... ON CONFLICT DO NOTHING` to ensure it can be safely run multiple times without duplicating data. + +## Local Development + +### Prerequisites + +- PostgreSQL client tools (`psql`) installed +- Node.js and pnpm installed +- `DATABASE_URL` environment variable configured + +### Setup + +1. **Configure your database connection:** + +```bash +export DATABASE_URL="postgresql://user:password@localhost:5432/starforge" +export PGSSLMODE=require # Optional, defaults to 'require' +``` + +2. **Run the bootstrap script:** + +```bash +./scripts/bootstrap-db.sh +``` + +This will: + +- Install dependencies +- Run all pending migrations +- Apply seed data +- Verify the setup + +### Manual Migration Commands + +**Run all pending migrations:** + +```bash +pnpm run migrate:up +``` + +**Rollback the last migration:** + +```bash +pnpm run migrate:down +``` + +**Create a new migration:** + +```bash +pnpm run migrate create my-migration-name +``` + +**Check migration status:** + +```bash +pnpm run migrate status --config database/migration-config.js +``` + +## CI/CD and Production + +### GitHub Actions Workflow + +We have a manual workflow (`db-bootstrap.yml`) for running migrations in production: + +1. Go to **Actions** → **Database Bootstrap** in GitHub +2. Click **Run workflow** +3. Select the target environment (production/staging) +4. Click **Run workflow** button +5. **Approve the deployment** if using the production environment + +### Environment Protection + +The workflow requires manual approval for production deployments through GitHub Environments. This prevents accidental schema changes. + +### Setting Up Environments + +1. In your GitHub repository, go to **Settings** → **Environments** +2. Create a `production` environment +3. Enable **Required reviewers** +4. Add team members who should approve production migrations + +### Required Secrets + +Add these secrets in GitHub repository settings: + +- `DATABASE_URL`: PostgreSQL connection string for your database + +Example: + +``` +postgresql://username:password@host.supabase.co:5432/postgres +``` + +## Creating New Migrations + +### Best Practices + +1. **Keep migrations small and focused** - One logical change per migration +2. **Always include down migrations** - For easy rollback if needed +3. **Use transactions** - node-pg-migrate wraps migrations in transactions by default +4. **Test locally first** - Always test migrations on a local database before production +5. **Make migrations idempotent** - Use `IF NOT EXISTS` and similar clauses + +### Example Migration + +```javascript +// database/migrations/1234567890_add_user_preferences.js + +exports.up = (pgm) => { + pgm.createTable( + 'user_preferences', + { + id: { + type: 'uuid', + primaryKey: true, + default: pgm.func('gen_random_uuid()'), + }, + user_id: { + type: 'uuid', + notNull: true, + references: 'users(id)', + onDelete: 'CASCADE', + }, + theme: { + type: 'varchar(50)', + default: "'light'", + }, + notifications_enabled: { + type: 'boolean', + default: true, + }, + }, + { + ifNotExists: true, + }, + ); + + pgm.createIndex('user_preferences', 'user_id', { ifNotExists: true }); +}; + +exports.down = (pgm) => { + pgm.dropTable('user_preferences', { ifExists: true }); +}; +``` + +## Troubleshooting + +### Extension Permissions + +If you encounter errors about creating extensions: + +1. **Enable the extension manually** in your database: + + ```sql + CREATE EXTENSION IF NOT EXISTS pgcrypto; + ``` + +2. **Check extension availability:** + + ```sql + SELECT * FROM pg_available_extensions WHERE name = 'pgcrypto'; + ``` + +3. **Contact your database administrator** if you don't have permissions + +### Connection Issues + +If migrations fail to connect: + +1. Verify your `DATABASE_URL` is correct +2. Check that `PGSSLMODE` is set appropriately (`require` for most cloud providers) +3. Verify your IP is whitelisted if using managed PostgreSQL +4. Test the connection manually: + ```bash + psql "$DATABASE_URL" -c "SELECT 1" + ``` + +### Migration Conflicts + +If migrations get out of sync: + +1. **Check current migration status:** + + ```bash + pnpm run migrate status --config database/migration-config.js + ``` + +2. **Inspect the pgmigrations table:** + + ```sql + SELECT * FROM pgmigrations ORDER BY run_on DESC; + ``` + +3. **Never modify completed migrations** - Create a new migration to fix issues + +## Security Best Practices + +### Secrets Management + +- **Never commit secrets** to version control +- Use GitHub Secrets for CI/CD +- Rotate credentials regularly +- Use different credentials for each environment + +### Access Control + +- Limit who can run migrations in production +- Use GitHub Environment protection rules +- Enable audit logging for database changes +- Review migration PRs carefully + +### Database Backups + +- Always backup before running migrations in production +- Test restoration procedures regularly +- Keep backups for regulatory compliance periods +- Consider point-in-time recovery for critical databases + +## Additional Resources + +- [node-pg-migrate Documentation](https://salsita.github.io/node-pg-migrate/) +- [PostgreSQL Documentation](https://www.postgresql.org/docs/) +- [Supabase Database Documentation](https://supabase.com/docs/guides/database) + +## Support + +If you encounter issues: + +1. Check the troubleshooting section above +2. Review recent migration files for common patterns +3. Consult the team's database administrator +4. Open an issue in the repository with detailed error messages diff --git a/package.json b/package.json index 7f77330..daf323e 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,17 @@ "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,scss,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,css,scss,md}\"", "clean": "turbo run clean && rm -rf node_modules", - "type-check": "turbo run type-check" + "type-check": "turbo run type-check", + "migrate": "node-pg-migrate", + "migrate:up": "node-pg-migrate up --config database/migration-config.js", + "migrate:down": "node-pg-migrate down --config database/migration-config.js", + "db:bootstrap": "./scripts/bootstrap-db.sh" }, "devDependencies": { "@types/node": "^20.10.0", "eslint": "^8.55.0", + "node-pg-migrate": "^7.8.2", + "pg": "^8.11.3", "prettier": "^3.1.0", "turbo": "^1.11.0", "typescript": "^5.3.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d33f9d..3f3e0da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: eslint: specifier: ^8.55.0 version: 8.57.1 + node-pg-migrate: + specifier: ^7.8.2 + version: 7.9.1(pg@8.16.3) + pg: + specifier: ^8.11.3 + version: 8.16.3 prettier: specifier: ^3.1.0 version: 3.6.2 @@ -892,6 +898,30 @@ packages: deprecated: Use @eslint/object-schema instead dev: true + /@isaacs/balanced-match@4.0.1: + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + dev: true + + /@isaacs/brace-expansion@5.0.0: + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + dependencies: + '@isaacs/balanced-match': 4.0.1 + dev: true + + /@isaacs/cliui@8.0.2: + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + dependencies: + string-width: 5.1.2 + string-width-cjs: /string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: /strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: /wrap-ansi@7.0.0 + dev: true + /@jest/schemas@29.6.3: resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1613,6 +1643,11 @@ packages: engines: {node: '>=8'} dev: true + /ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + dev: true + /ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -1625,6 +1660,11 @@ packages: engines: {node: '>=10'} dev: true + /ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + dev: true + /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} dev: true @@ -1739,6 +1779,15 @@ packages: get-func-name: 2.0.2 dev: true + /cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + dev: true + /color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1885,12 +1934,24 @@ packages: engines: {node: '>=12'} dev: false + /eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + dev: true + /ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} dependencies: safe-buffer: 5.2.1 dev: false + /emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + dev: true + + /emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + dev: true + /end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} dependencies: @@ -1974,6 +2035,11 @@ packages: '@esbuild/win32-x64': 0.25.12 dev: true + /escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + dev: true + /escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2300,6 +2366,14 @@ packages: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} dev: true + /foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + dev: true + /forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -2317,6 +2391,11 @@ packages: dev: true optional: true + /get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + dev: true + /get-func-name@2.0.2: resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} dev: true @@ -2346,6 +2425,19 @@ packages: is-glob: 4.0.3 dev: true + /glob@11.0.3: + resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} + engines: {node: 20 || >=22} + hasBin: true + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.1.1 + minimatch: 10.1.1 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.1 + dev: true + /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -2443,6 +2535,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + dev: true + /is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -2469,6 +2566,13 @@ packages: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} dev: true + /jackspeak@4.1.1: + resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} + engines: {node: 20 || >=22} + dependencies: + '@isaacs/cliui': 8.0.2 + dev: true + /joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -2561,6 +2665,11 @@ packages: get-func-name: 2.0.2 dev: true + /lru-cache@11.2.2: + resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} + engines: {node: 20 || >=22} + dev: true + /magic-bytes.js@1.12.1: resolution: {integrity: sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==} dev: false @@ -2596,6 +2705,13 @@ packages: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} dev: false + /minimatch@10.1.1: + resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} + engines: {node: 20 || >=22} + dependencies: + '@isaacs/brace-expansion': 5.0.0 + dev: true + /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: @@ -2620,6 +2736,11 @@ packages: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} dev: false + /minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + dev: true + /mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} dependencies: @@ -2658,6 +2779,22 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true + /node-pg-migrate@7.9.1(pg@8.16.3): + resolution: {integrity: sha512-6z4OSN27ye8aYdX9ZU7NN2PTI5pOp34hTr+22Ej12djIYECq++gT7LPLZVOQXEeVCBOZQLqf87kC3Y36G434OQ==} + engines: {node: '>=18.19.0'} + hasBin: true + peerDependencies: + '@types/pg': '>=6.0.0 <9.0.0' + pg: '>=4.3.0 <9.0.0' + peerDependenciesMeta: + '@types/pg': + optional: true + dependencies: + glob: 11.0.3 + pg: 8.16.3 + yargs: 17.7.2 + dev: true + /npm-run-path@5.3.0: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2725,6 +2862,10 @@ packages: p-limit: 3.1.0 dev: true + /package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + dev: true + /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2756,6 +2897,14 @@ packages: engines: {node: '>=12'} dev: true + /path-scurry@2.0.1: + resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} + engines: {node: 20 || >=22} + dependencies: + lru-cache: 11.2.2 + minipass: 7.1.2 + dev: true + /path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -2773,6 +2922,68 @@ packages: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} dev: true + /pg-cloudflare@1.2.7: + resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} + requiresBuild: true + dev: true + optional: true + + /pg-connection-string@2.9.1: + resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==} + dev: true + + /pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + dev: true + + /pg-pool@3.10.1(pg@8.16.3): + resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==} + peerDependencies: + pg: '>=8.0' + dependencies: + pg: 8.16.3 + dev: true + + /pg-protocol@1.10.3: + resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} + dev: true + + /pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + dev: true + + /pg@8.16.3: + resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + dependencies: + pg-connection-string: 2.9.1 + pg-pool: 3.10.1(pg@8.16.3) + pg-protocol: 1.10.3 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.2.7 + dev: true + + /pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + dependencies: + split2: 4.2.0 + dev: true + /picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2897,6 +3108,28 @@ packages: picocolors: 1.1.1 source-map-js: 1.2.1 + /postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + dev: true + + /postgres-bytea@1.0.0: + resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + engines: {node: '>=0.10.0'} + dev: true + + /postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + dev: true + + /postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + dependencies: + xtend: 4.0.2 + dev: true + /prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2978,6 +3211,11 @@ packages: engines: {node: '>= 12.13.0'} dev: false + /require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + dev: true + /require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -3128,7 +3366,6 @@ packages: /split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - dev: false /stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -3148,6 +3385,24 @@ packages: reusify: 1.1.0 dev: false + /string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + dev: true + + /string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + dev: true + /string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} dependencies: @@ -3161,6 +3416,13 @@ packages: ansi-regex: 5.0.1 dev: true + /strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + dependencies: + ansi-regex: 6.2.2 + dev: true + /strip-final-newline@3.0.0: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} @@ -3573,6 +3835,24 @@ packages: engines: {node: '>=0.10.0'} dev: true + /wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + dev: true + + /wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + dev: true + /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3597,7 +3877,29 @@ packages: /xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} - dev: false + + /y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + dev: true + + /yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + dev: true + + /yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + dev: true /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} diff --git a/scripts/bootstrap-db.sh b/scripts/bootstrap-db.sh new file mode 100755 index 0000000..060f166 --- /dev/null +++ b/scripts/bootstrap-db.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash + +# +# StarForge Database Bootstrap Script +# +# This script bootstraps the database with migrations and seeds. +# It is idempotent and safe to run multiple times. +# +# Requirements: +# - psql command-line tool (PostgreSQL client) +# - node and pnpm installed +# - DATABASE_URL environment variable set +# +# Usage: +# export DATABASE_URL="postgresql://user:pass@host:port/dbname" +# ./scripts/bootstrap-db.sh +# + +set -e # Exit on error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo "==========================================" +echo "StarForge Database Bootstrap" +echo "==========================================" +echo "" + +# Check DATABASE_URL is set +if [ -z "$DATABASE_URL" ]; then + echo -e "${RED}ERROR: DATABASE_URL environment variable is not set${NC}" + echo "Please set DATABASE_URL to your PostgreSQL connection string:" + echo " export DATABASE_URL='postgresql://user:password@host:port/database'" + exit 1 +fi + +echo -e "${GREEN}✓${NC} DATABASE_URL is set" + +# Set PGSSLMODE if not already set +if [ -z "$PGSSLMODE" ]; then + export PGSSLMODE=require + echo -e "${YELLOW}ℹ${NC} PGSSLMODE not set, defaulting to 'require'" +else + echo -e "${GREEN}✓${NC} PGSSLMODE is set to '$PGSSLMODE'" +fi + +# Check if psql is installed +if ! command -v psql &> /dev/null; then + echo -e "${RED}ERROR: psql command not found${NC}" + echo "Please install PostgreSQL client tools (psql)" + exit 1 +fi + +echo -e "${GREEN}✓${NC} psql is installed" + +# Check if node is installed +if ! command -v node &> /dev/null; then + echo -e "${RED}ERROR: node command not found${NC}" + echo "Please install Node.js" + exit 1 +fi + +echo -e "${GREEN}✓${NC} node is installed" + +# Check if pnpm is installed +if ! command -v pnpm &> /dev/null; then + echo -e "${RED}ERROR: pnpm command not found${NC}" + echo "Please install pnpm: npm install -g pnpm" + exit 1 +fi + +echo -e "${GREEN}✓${NC} pnpm is installed" + +echo "" +echo "Installing dependencies..." +pnpm install --frozen-lockfile + +echo "" +echo "Running database migrations..." + +# Run migrations +# Check if migration fails due to extension permission issues +if pnpm run migrate:up; then + echo -e "${GREEN}✓${NC} Migrations completed successfully" +else + EXIT_CODE=$? + echo -e "${YELLOW}⚠${NC} Migration failed (exit code: $EXIT_CODE)" + + # Check if it's a permission error related to extensions + echo "" + echo -e "${YELLOW}This might be due to extension permission issues.${NC}" + echo "Some managed PostgreSQL providers restrict extension creation." + echo "The migration uses pgcrypto which is typically available." + echo "" + echo "If the error is about extensions, please:" + echo " 1. Enable the pgcrypto extension in your database manually" + echo " 2. Re-run this script" + echo "" + exit $EXIT_CODE +fi + +echo "" +echo "Applying seed data..." + +# Apply seeds if the file exists +SEEDS_FILE="database/seeds/0001_seeds.sql" +if [ -f "$SEEDS_FILE" ]; then + if psql "$DATABASE_URL" < "$SEEDS_FILE" > /dev/null 2>&1; then + echo -e "${GREEN}✓${NC} Seed data applied successfully" + else + echo -e "${YELLOW}⚠${NC} Seed data application had warnings (might be already present)" + fi +else + echo -e "${YELLOW}⚠${NC} No seed file found at $SEEDS_FILE" +fi + +echo "" +echo "==========================================" +echo -e "${GREEN}Database bootstrap completed!${NC}" +echo "==========================================" +echo "" +echo "Next steps:" +echo " - Start your application: pnpm run dev" +echo " - Check database: psql \$DATABASE_URL" +echo ""