From 412cbed5a5393f6643e2083c77c9aa1602ef80a6 Mon Sep 17 00:00:00 2001 From: kaseywright Date: Tue, 9 Jun 2026 12:45:56 -0400 Subject: [PATCH 1/9] feat(db): add idempotent API bootstrap script --- db/init/01-api.sql | 14 ++++++++ src/db/scripts/bootstrap.ts | 70 +++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 db/init/01-api.sql create mode 100644 src/db/scripts/bootstrap.ts diff --git a/db/init/01-api.sql b/db/init/01-api.sql new file mode 100644 index 00000000..bab55fb9 --- /dev/null +++ b/db/init/01-api.sql @@ -0,0 +1,14 @@ +-- Fluent API — standalone database bootstrap. +-- Run automatically on first postgres container start via docker-entrypoint-initdb.d. +-- In ecosystem mode, the API self-provisions via migrations; this script is not used. +-- ================================================================ + +CREATE USER IF NOT EXISTS web_user WITH PASSWORD 'password'; +CREATE SCHEMA IF NOT EXISTS pgboss; + +-- public already exists; ensure web_user owns it for Drizzle migrations +ALTER SCHEMA public OWNER TO web_user; +ALTER SCHEMA pgboss OWNER TO web_user; + +GRANT USAGE, CREATE ON SCHEMA public TO web_user; +GRANT USAGE, CREATE ON SCHEMA pgboss TO web_user; diff --git a/src/db/scripts/bootstrap.ts b/src/db/scripts/bootstrap.ts new file mode 100644 index 00000000..0a6c8618 --- /dev/null +++ b/src/db/scripts/bootstrap.ts @@ -0,0 +1,70 @@ +// src/db/scripts/bootstrap.ts +// Idempotent DB provisioning for the API's own concern. +// Connects as the postgres superuser (BOOTSTRAP_DATABASE_URL) and creates the +// API's migration + runtime roles, its schemas, ownership, and default grants. +// Runs identically against a standalone or platform-shared Postgres. +import postgres from 'postgres'; + +function parse(url: string): { user: string; password: string } { + const u = new URL(url); + return { user: decodeURIComponent(u.username), password: decodeURIComponent(u.password) }; +} + +const bootstrapUrl = process.env.BOOTSTRAP_DATABASE_URL; +const runtimeUrl = process.env.DATABASE_URL; +const migrationsUrl = process.env.MIGRATIONS_DATABASE_URL; + +if (!bootstrapUrl || !runtimeUrl || !migrationsUrl) { + throw new Error('bootstrap requires BOOTSTRAP_DATABASE_URL, DATABASE_URL, MIGRATIONS_DATABASE_URL'); +} + +const runtime = parse(runtimeUrl); +const migrator = parse(migrationsUrl); + +async function main() { + const sql = postgres(bootstrapUrl, { max: 1 }); + try { + // Roles (CREATE ROLE has no IF NOT EXISTS — guard with DO blocks). + for (const role of [migrator, runtime]) { + await sql.unsafe(` + DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${role.user}') THEN + CREATE ROLE ${role.user} LOGIN PASSWORD '${role.password}'; + ELSE + ALTER ROLE ${role.user} LOGIN PASSWORD '${role.password}'; + END IF; + END $$; + `); + } + + // Migrator owns DDL surfaces; allow it to create the drizzle tracking schema. + await sql.unsafe(`ALTER SCHEMA public OWNER TO ${migrator.user};`); + await sql.unsafe(`GRANT CREATE ON DATABASE ${new URL(bootstrapUrl).pathname.slice(1)} TO ${migrator.user};`); + + // pgboss is API-internal; runtime role owns it so pg-boss can manage it. + await sql.unsafe(`CREATE SCHEMA IF NOT EXISTS pgboss AUTHORIZATION ${runtime.user};`); + + // Runtime role can use public and gets DML on everything the migrator creates there. + await sql.unsafe(`GRANT USAGE ON SCHEMA public TO ${runtime.user};`); + await sql.unsafe(` + ALTER DEFAULT PRIVILEGES FOR ROLE ${migrator.user} IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${runtime.user}; + `); + await sql.unsafe(` + ALTER DEFAULT PRIVILEGES FOR ROLE ${migrator.user} IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO ${runtime.user}; + `); + // Cover any tables already present from a prior migrate in this volume. + await sql.unsafe(`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${runtime.user};`); + await sql.unsafe(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${runtime.user};`); + + console.log('API bootstrap complete: roles, schemas, grants ensured.'); + } finally { + await sql.end(); + } +} + +main().catch((err) => { + console.error('API bootstrap failed:', err); + process.exit(1); +}); From fce20e277f6de1aedddc205c765f2c39f6f5a2f4 Mon Sep 17 00:00:00 2001 From: kaseywright Date: Tue, 9 Jun 2026 12:46:38 -0400 Subject: [PATCH 2/9] chore(db): run drizzle migrations as the migration role --- drizzle.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drizzle.config.ts b/drizzle.config.ts index 22cdb39a..0d7f3780 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ out: './src/db/migrations', dialect: 'postgresql', dbCredentials: { - url: process.env.DATABASE_URL!, + url: process.env.MIGRATIONS_DATABASE_URL ?? process.env.DATABASE_URL!, }, migrations: { prefix: 'index', From 7077225f8cbb63d383b773ef0881636509d365c5 Mon Sep 17 00:00:00 2001 From: kaseywright Date: Tue, 9 Jun 2026 12:46:47 -0400 Subject: [PATCH 3/9] feat(db): bootstrap roles/schemas before API migrations --- docker-entrypoint.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 24425d73..259d41e6 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,6 +1,9 @@ #!/bin/sh set -e +echo "Bootstrapping database roles/schemas..." +npx tsx src/db/scripts/bootstrap.ts || { echo "ERROR: db bootstrap failed"; exit 1; } + echo "Running database migrations..." npx drizzle-kit migrate || { echo "ERROR: database migrations failed"; exit 1; } From 00a770b11797dd86069e1f0d61ef0c0080d382c2 Mon Sep 17 00:00:00 2001 From: kaseywright Date: Tue, 9 Jun 2026 12:47:47 -0400 Subject: [PATCH 4/9] refactor(db): API provisions via bootstrap+migrations, drop init SQL --- .env.example | 7 +++- compose.yaml | 9 ++-- db/init/01-api.sql | 14 ------- db/init/01-init-db.sql | 93 ------------------------------------------ 4 files changed, 12 insertions(+), 111 deletions(-) delete mode 100644 db/init/01-api.sql delete mode 100644 db/init/01-init-db.sql diff --git a/.env.example b/.env.example index 32f78218..947d8453 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,12 @@ NODE_ENV=development PORT=9999 LOG_LEVEL=debug -DATABASE_URL=postgres://postgres:123456@localhost:5432/fluent +# Runtime (least-privilege) role used by the app: +DATABASE_URL=postgres://api_user:password@localhost:5432/fluent +# Migration role (DDL) used by drizzle-kit: +MIGRATIONS_DATABASE_URL=postgres://api_migrator:password@localhost:5432/fluent +# Superuser used only by the bootstrap script: +BOOTSTRAP_DATABASE_URL=postgres://postgres:postgres@localhost:5432/fluent BETTER_AUTH_SECRET=generate-a-32-character-secret-key BETTER_AUTH_URL=http://localhost:9999/api/auth BETTER_AUTH_COOKIE_DOMAIN=.fluent.bible diff --git a/compose.yaml b/compose.yaml index fc0d00c9..ea373170 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,7 +9,6 @@ services: - '${DB_PORT:-5432}:5432' volumes: - fluent-pgdata:/var/lib/postgresql/data - - ./db/init:/docker-entrypoint-initdb.d:ro healthcheck: test: [CMD-SHELL, pg_isready -U postgres -d fluent] interval: 5s @@ -30,7 +29,9 @@ services: - fluent-api-node-modules:/app/node_modules env_file: .env environment: - DATABASE_URL: postgres://postgres:postgres@db:5432/fluent + BOOTSTRAP_DATABASE_URL: postgres://postgres:postgres@db:5432/fluent + MIGRATIONS_DATABASE_URL: postgres://api_migrator:password@db:5432/fluent + DATABASE_URL: postgres://api_user:password@db:5432/fluent EXPORTS_DIR: /app/exports user: '1001:1001' read_only: true @@ -62,7 +63,9 @@ services: - fluent-worker-node-modules:/app/node_modules env_file: .env environment: - DATABASE_URL: postgres://postgres:postgres@db:5432/fluent + BOOTSTRAP_DATABASE_URL: postgres://postgres:postgres@db:5432/fluent + MIGRATIONS_DATABASE_URL: postgres://api_migrator:password@db:5432/fluent + DATABASE_URL: postgres://api_user:password@db:5432/fluent EXPORTS_DIR: /app/exports command: [dumb-init, --, npx, tsx, watch, src/workers/standalone-worker.ts] user: '1001:1001' diff --git a/db/init/01-api.sql b/db/init/01-api.sql deleted file mode 100644 index bab55fb9..00000000 --- a/db/init/01-api.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Fluent API — standalone database bootstrap. --- Run automatically on first postgres container start via docker-entrypoint-initdb.d. --- In ecosystem mode, the API self-provisions via migrations; this script is not used. --- ================================================================ - -CREATE USER IF NOT EXISTS web_user WITH PASSWORD 'password'; -CREATE SCHEMA IF NOT EXISTS pgboss; - --- public already exists; ensure web_user owns it for Drizzle migrations -ALTER SCHEMA public OWNER TO web_user; -ALTER SCHEMA pgboss OWNER TO web_user; - -GRANT USAGE, CREATE ON SCHEMA public TO web_user; -GRANT USAGE, CREATE ON SCHEMA pgboss TO web_user; diff --git a/db/init/01-init-db.sql b/db/init/01-init-db.sql deleted file mode 100644 index c5e25f36..00000000 --- a/db/init/01-init-db.sql +++ /dev/null @@ -1,93 +0,0 @@ --- PostgreSQL roles and privileges for local development. --- Canonical source — fluent-ai copies from this file. --- Executed automatically on first DB volume initialization via --- docker-entrypoint-initdb.d. Safe to re-examine in psql; not re-run. - --- ================================================================ --- SECTION 1: LOGIN USERS --- ================================================================ - -CREATE USER db_admin WITH PASSWORD 'password' CREATEROLE; -CREATE USER migrations WITH PASSWORD 'password'; -CREATE USER web_user WITH PASSWORD 'password'; -CREATE USER ai_user WITH PASSWORD 'password'; - --- ================================================================ --- SECTION 2: GROUP ROLES (no login) --- ================================================================ - -CREATE ROLE role_web_data; -- full DML on public schema -CREATE ROLE role_pgboss_user; -- full DML on pgboss schema -CREATE ROLE role_ai_data; -- full DML on ai schema -CREATE ROLE role_ai_reader; -- SELECT on public schema (cross-schema reads for fluent-ai) -CREATE ROLE role_migrations; -- DDL + DML across all schemas - --- ================================================================ --- SECTION 3: ASSIGN ROLES TO LOGIN USERS --- ================================================================ - -GRANT role_web_data, role_pgboss_user TO web_user; -GRANT role_ai_data, role_ai_reader, role_pgboss_user TO ai_user; -GRANT role_migrations TO migrations; - --- ================================================================ --- SECTION 4: CREATE SCHEMAS --- ================================================================ - -CREATE SCHEMA IF NOT EXISTS drizzle; -CREATE SCHEMA IF NOT EXISTS pgboss; -CREATE SCHEMA IF NOT EXISTS ai; - --- public already exists; postgres retains ownership (superuser default). -ALTER SCHEMA drizzle OWNER TO migrations; -ALTER SCHEMA pgboss OWNER TO web_user; -ALTER SCHEMA ai OWNER TO ai_user; - --- ================================================================ --- SECTION 5: SCHEMA USAGE GRANTS --- ================================================================ - -GRANT USAGE ON SCHEMA public TO role_web_data, role_ai_reader, role_migrations; -GRANT CREATE ON SCHEMA public TO role_migrations; -GRANT USAGE ON SCHEMA drizzle TO role_migrations; -GRANT CREATE ON SCHEMA drizzle TO role_migrations; -GRANT USAGE ON SCHEMA pgboss TO role_pgboss_user; -GRANT CREATE ON SCHEMA pgboss TO role_pgboss_user; -GRANT USAGE ON SCHEMA ai TO role_ai_data; - --- db_admin: convenience DBA account for local inspection; not a runtime user -GRANT USAGE ON SCHEMA public, pgboss, ai, drizzle TO db_admin; - --- ================================================================ --- SECTION 6: DEFAULT PRIVILEGES --- Set for BOTH postgres (current Drizzle user) and migrations (future --- least-privilege target) so grants apply regardless of who runs migrations. --- ================================================================ - --- Tables created by postgres -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public - GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO role_web_data; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public - GRANT SELECT ON TABLES TO role_ai_reader; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public - GRANT USAGE, SELECT ON SEQUENCES TO role_web_data, role_ai_reader; - --- Tables created by migrations -ALTER DEFAULT PRIVILEGES FOR ROLE migrations IN SCHEMA public - GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO role_web_data; -ALTER DEFAULT PRIVILEGES FOR ROLE migrations IN SCHEMA public - GRANT SELECT ON TABLES TO role_ai_reader; -ALTER DEFAULT PRIVILEGES FOR ROLE migrations IN SCHEMA public - GRANT USAGE, SELECT ON SEQUENCES TO role_web_data, role_ai_reader; - --- Tables created by web_user in pgboss schema -ALTER DEFAULT PRIVILEGES FOR ROLE web_user IN SCHEMA pgboss - GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO role_pgboss_user; -ALTER DEFAULT PRIVILEGES FOR ROLE web_user IN SCHEMA pgboss - GRANT USAGE, SELECT ON SEQUENCES TO role_pgboss_user; - --- Tables created by ai_user in ai schema -ALTER DEFAULT PRIVILEGES FOR ROLE ai_user IN SCHEMA ai - GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO role_ai_data; -ALTER DEFAULT PRIVILEGES FOR ROLE ai_user IN SCHEMA ai - GRANT USAGE, SELECT ON SEQUENCES TO role_ai_data; From 442712533fc8a3e9a5845dac787dc550a63720d4 Mon Sep 17 00:00:00 2001 From: kaseywright Date: Tue, 9 Jun 2026 12:54:41 -0400 Subject: [PATCH 5/9] fix(queue): skip CREATE SCHEMA so api_user needs no database-level CREATE --- src/lib/queue.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/queue.ts b/src/lib/queue.ts index acd4bf0e..ac677bf9 100644 --- a/src/lib/queue.ts +++ b/src/lib/queue.ts @@ -28,6 +28,9 @@ export async function initializeQueue(): Promise { boss = new PgBoss({ connectionString, schema: 'pgboss', + // Bootstrap creates the pgboss schema; skip the CREATE SCHEMA DDL so + // api_user (runtime role) doesn't need CREATE privilege on the database. + createSchema: false, max: 10, application_name: 'fluent-server-queue', superviseIntervalSeconds: 60, From 53bdf5cf7897ed90f3fd9eda34f6246dbd2392c1 Mon Sep 17 00:00:00 2001 From: kaseywright Date: Tue, 9 Jun 2026 13:03:23 -0400 Subject: [PATCH 6/9] fix(db): quote bootstrap identifiers/passwords server-side; assert single db Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 1 + src/db/scripts/bootstrap.ts | 89 +++++++++++++++++++++++++------------ 2 files changed, 62 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index 947d8453..a51ddf59 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ NODE_ENV=development PORT=9999 LOG_LEVEL=debug +# Role passwords below are dev-only defaults — override all three in any shared or deployed environment. # Runtime (least-privilege) role used by the app: DATABASE_URL=postgres://api_user:password@localhost:5432/fluent # Migration role (DDL) used by drizzle-kit: diff --git a/src/db/scripts/bootstrap.ts b/src/db/scripts/bootstrap.ts index 0a6c8618..47398ffa 100644 --- a/src/db/scripts/bootstrap.ts +++ b/src/db/scripts/bootstrap.ts @@ -3,11 +3,25 @@ // Connects as the postgres superuser (BOOTSTRAP_DATABASE_URL) and creates the // API's migration + runtime roles, its schemas, ownership, and default grants. // Runs identically against a standalone or platform-shared Postgres. +// +// Identifiers and the password literal are quoted server-side via +// quote_ident() / quote_literal(), so role names, the database name, and +// passwords containing special characters cannot break or inject the DDL. import postgres from 'postgres'; -function parse(url: string): { user: string; password: string } { +interface Conn { + user: string; + password: string; + database: string; +} + +function parseConn(url: string): Conn { const u = new URL(url); - return { user: decodeURIComponent(u.username), password: decodeURIComponent(u.password) }; + return { + user: decodeURIComponent(u.username), + password: decodeURIComponent(u.password), + database: decodeURIComponent(u.pathname.slice(1)), + }; } const bootstrapUrl = process.env.BOOTSTRAP_DATABASE_URL; @@ -18,45 +32,64 @@ if (!bootstrapUrl || !runtimeUrl || !migrationsUrl) { throw new Error('bootstrap requires BOOTSTRAP_DATABASE_URL, DATABASE_URL, MIGRATIONS_DATABASE_URL'); } -const runtime = parse(runtimeUrl); -const migrator = parse(migrationsUrl); +const runtime = parseConn(runtimeUrl); +const migrator = parseConn(migrationsUrl); +const database = parseConn(bootstrapUrl).database; + +if (runtime.database !== database || migrator.database !== database) { + throw new Error( + `all three DATABASE URLs must reference the same database ` + + `(bootstrap=${database}, runtime=${runtime.database}, migrations=${migrator.database})` + ); +} async function main() { const sql = postgres(bootstrapUrl, { max: 1 }); try { - // Roles (CREATE ROLE has no IF NOT EXISTS — guard with DO blocks). + // Ask Postgres to produce safe identifier / literal text so special + // characters in role names, the db name, or passwords cannot break the DDL. + const ident = async (name: string): Promise => { + const [row] = await sql`SELECT quote_ident(${name}) AS q`; + return row.q as string; + }; + const literal = async (value: string): Promise => { + const [row] = await sql`SELECT quote_literal(${value}) AS q`; + return row.q as string; + }; + + // Roles (CREATE ROLE has no IF NOT EXISTS — check, then create or alter). for (const role of [migrator, runtime]) { - await sql.unsafe(` - DO $$ BEGIN - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${role.user}') THEN - CREATE ROLE ${role.user} LOGIN PASSWORD '${role.password}'; - ELSE - ALTER ROLE ${role.user} LOGIN PASSWORD '${role.password}'; - END IF; - END $$; - `); + const roleIdent = await ident(role.user); + const pwLiteral = await literal(role.password); + const [row] = await sql`SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = ${role.user}) AS exists`; + const verb = row.exists ? 'ALTER' : 'CREATE'; + await sql.unsafe(`${verb} ROLE ${roleIdent} LOGIN PASSWORD ${pwLiteral}`); } + const migratorIdent = await ident(migrator.user); + const runtimeIdent = await ident(runtime.user); + const dbIdent = await ident(database); + // Migrator owns DDL surfaces; allow it to create the drizzle tracking schema. - await sql.unsafe(`ALTER SCHEMA public OWNER TO ${migrator.user};`); - await sql.unsafe(`GRANT CREATE ON DATABASE ${new URL(bootstrapUrl).pathname.slice(1)} TO ${migrator.user};`); + await sql.unsafe(`ALTER SCHEMA public OWNER TO ${migratorIdent}`); + await sql.unsafe(`GRANT CREATE ON DATABASE ${dbIdent} TO ${migratorIdent}`); // pgboss is API-internal; runtime role owns it so pg-boss can manage it. - await sql.unsafe(`CREATE SCHEMA IF NOT EXISTS pgboss AUTHORIZATION ${runtime.user};`); + await sql.unsafe(`CREATE SCHEMA IF NOT EXISTS pgboss AUTHORIZATION ${runtimeIdent}`); // Runtime role can use public and gets DML on everything the migrator creates there. - await sql.unsafe(`GRANT USAGE ON SCHEMA public TO ${runtime.user};`); - await sql.unsafe(` - ALTER DEFAULT PRIVILEGES FOR ROLE ${migrator.user} IN SCHEMA public - GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${runtime.user}; - `); - await sql.unsafe(` - ALTER DEFAULT PRIVILEGES FOR ROLE ${migrator.user} IN SCHEMA public - GRANT USAGE, SELECT ON SEQUENCES TO ${runtime.user}; - `); + await sql.unsafe(`GRANT USAGE ON SCHEMA public TO ${runtimeIdent}`); + await sql.unsafe( + `ALTER DEFAULT PRIVILEGES FOR ROLE ${migratorIdent} IN SCHEMA public ` + + `GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${runtimeIdent}` + ); + await sql.unsafe( + `ALTER DEFAULT PRIVILEGES FOR ROLE ${migratorIdent} IN SCHEMA public ` + + `GRANT USAGE, SELECT ON SEQUENCES TO ${runtimeIdent}` + ); // Cover any tables already present from a prior migrate in this volume. - await sql.unsafe(`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${runtime.user};`); - await sql.unsafe(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${runtime.user};`); + await sql.unsafe(`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${runtimeIdent}`); + await sql.unsafe(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${runtimeIdent}`); console.log('API bootstrap complete: roles, schemas, grants ensured.'); } finally { From f70db7453d44e951c9ef5dd1f455b0c6265f049e Mon Sep 17 00:00:00 2001 From: kaseywright Date: Tue, 9 Jun 2026 14:21:03 -0400 Subject: [PATCH 7/9] chore(scripts): fix Podman DB env to per-service roles; drop db/init Co-Authored-By: Claude Opus 4.8 --- fapi.ps1 | 43 +++++++++++-------------------------------- fapi.sh | 45 ++++++++++----------------------------------- 2 files changed, 21 insertions(+), 67 deletions(-) diff --git a/fapi.ps1 b/fapi.ps1 index 5f28931e..ed9b4c22 100644 --- a/fapi.ps1 +++ b/fapi.ps1 @@ -117,7 +117,6 @@ function Start-DbContainer { --name fluent-api-db --pod $PodName ` -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=fluent ` -v fluent-api-pgdata:/var/lib/postgresql/data ` - -v "${ScriptDir}/db/init:/docker-entrypoint-initdb.d:ro" ` --health-cmd "pg_isready -U postgres -d fluent" ` --health-interval 5s --health-timeout 5s --health-retries 5 ` docker.io/postgres:16-alpine @@ -128,7 +127,9 @@ function Start-DbContainer { function Get-EnvFlags { $flags = @( "-e", "NODE_ENV=development", - "-e", "DATABASE_URL=postgres://postgres:postgres@localhost:5432/fluent", + "-e", "BOOTSTRAP_DATABASE_URL=postgres://postgres:postgres@localhost:5432/fluent", + "-e", "MIGRATIONS_DATABASE_URL=postgres://api_migrator:password@localhost:5432/fluent", + "-e", "DATABASE_URL=postgres://api_user:password@localhost:5432/fluent", "-e", "EXPORTS_DIR=/app/exports" ) if (Test-Path "$ScriptDir/.env") { $flags += @("--env-file", "$ScriptDir/.env") } @@ -340,34 +341,6 @@ switch ($Command) { else { Invoke-Compose @("exec", "db", "psql", "-U", "postgres", "-d", "fluent") } } - "db:dump-schema" { - $output = $svc - if (-not $output) { - $aiInitDir = Join-Path $ScriptDir "../fluent-ai/db/init" - if (Test-Path $aiInitDir) { $output = Join-Path $aiInitDir "02-fluent-api-schema.sql" } - else { $output = Join-Path $ScriptDir "fluent-api-schema-dump.sql" } - } - Write-Running "Dumping fluent-api public schema to $output..." - $header = @" --- Schema-only dump of fluent-api's public tables. --- Used for standalone fluent-ai development so cross-schema reads work. --- --- This file is auto-generated. DO NOT EDIT MANUALLY. --- Regenerate with: ./fapi.ps1 db:dump-schema [output-path] --- Then commit to fluent-ai/db/init/ to update the standalone DB snapshot. -"@ - if ($RuntimeMode -eq "podman-pod") { - $dump = & podman exec fluent-api-db pg_dump -U postgres --schema-only --schema=public fluent - if ($LASTEXITCODE -ne 0) { Write-Err "pg_dump failed"; exit 1 } - } else { - $dump = Invoke-Compose @("exec", "-T", "db", "pg_dump", "-U", "postgres", "--schema-only", "--schema=public", "fluent") - if ($LASTEXITCODE -ne 0) { Write-Err "pg_dump failed"; exit 1 } - } - "$header`n$dump" | Set-Content $output -Encoding UTF8 - Write-Success "Schema dumped to $output" - Write-Host "Next: commit this file to fluent-ai/db/init/ and run './fai.sh clean && ./fai.sh up' to sync." - } - "clean" { Write-Running "This will stop and remove containers and volumes." $confirm = Read-Host "Continue? [y/N]" @@ -430,7 +403,7 @@ switch ($Command) { Write-Host "Created empty .env file" } } else { Write-Host ".env already exists, skipping." } - Write-Host "Remember to fill in DATABASE_URL and BETTER_AUTH_SECRET in .env before running db:init." + Write-Host "Remember to fill in the DB URLs (BOOTSTRAP/MIGRATIONS/DATABASE) and BETTER_AUTH_SECRET in .env before running db:init." } default { @@ -468,13 +441,19 @@ Database: db:generate Generate a new Drizzle migration db:studio Launch Drizzle Studio on the host db:psql Open psql session - db:dump-schema [path] Dump public schema for fluent-ai standalone sync Lifecycle: clean [service] Remove containers and volumes (default: all) fresh Nuke everything: containers, volumes, and images build [service] Rebuild images without cache setup Create .env from .env.example if missing + +Environment variables: + DB_PORT Standalone DB host port (default: 5432) + API_PORT API service host port (default: 9999) + BOOTSTRAP_DATABASE_URL Superuser URL the container uses to self-provision (bootstrap) + MIGRATIONS_DATABASE_URL api_migrator URL for Drizzle migrations (DDL) + DATABASE_URL api_user runtime URL (least-privilege; set in .env to use platform DB) "@ } } diff --git a/fapi.sh b/fapi.sh index 96a9452c..ab3abf47 100755 --- a/fapi.sh +++ b/fapi.sh @@ -135,7 +135,6 @@ start_db_container() { -e POSTGRES_PASSWORD=postgres \ -e POSTGRES_DB=fluent \ -v $PGDATA_VOLUME:/var/lib/postgresql/data \ - -v "$SCRIPT_DIR/db/init:/docker-entrypoint-initdb.d:ro" \ --health-cmd "pg_isready -U postgres -d fluent" \ --health-interval 5s \ --health-timeout 5s \ @@ -155,7 +154,9 @@ start_api_container() { local -a env_flags=( -e "NODE_ENV=development" - -e "DATABASE_URL=postgres://postgres:postgres@localhost:5432/fluent" + -e "BOOTSTRAP_DATABASE_URL=postgres://postgres:postgres@localhost:5432/fluent" + -e "MIGRATIONS_DATABASE_URL=postgres://api_migrator:password@localhost:5432/fluent" + -e "DATABASE_URL=postgres://api_user:password@localhost:5432/fluent" -e "EXPORTS_DIR=/app/exports" ) if [[ -f "$SCRIPT_DIR/.env" ]]; then @@ -190,7 +191,9 @@ start_worker_container() { local -a env_flags=( -e "NODE_ENV=development" - -e "DATABASE_URL=postgres://postgres:postgres@localhost:5432/fluent" + -e "BOOTSTRAP_DATABASE_URL=postgres://postgres:postgres@localhost:5432/fluent" + -e "MIGRATIONS_DATABASE_URL=postgres://api_migrator:password@localhost:5432/fluent" + -e "DATABASE_URL=postgres://api_user:password@localhost:5432/fluent" -e "EXPORTS_DIR=/app/exports" ) if [[ -f "$SCRIPT_DIR/.env" ]]; then @@ -590,35 +593,6 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then fi ;; - db:dump-schema) - output="${1:-}" - if [ -z "$output" ]; then - if [ -d "$SCRIPT_DIR/../fluent-ai/db/init" ]; then - output="$SCRIPT_DIR/../fluent-ai/db/init/02-fluent-api-schema.sql" - else - output="$SCRIPT_DIR/fluent-api-schema-dump.sql" - fi - fi - echo_running "Dumping fluent-api public schema to $output..." - { - cat <<'HEADER' --- Schema-only dump of fluent-api's public tables. --- Used for standalone fluent-ai development so cross-schema reads work. --- --- This file is auto-generated. DO NOT EDIT MANUALLY. --- Regenerate with: ./fapi.sh db:dump-schema [output-path] --- Then commit to fluent-ai/db/init/ to update the standalone DB snapshot. -HEADER - if [ "$RUNTIME_MODE" = "podman-pod" ]; then - $RUNTIME exec $DB_CONTAINER pg_dump -U postgres --schema-only --schema=public fluent - else - $COMPOSE_CMD exec -T db pg_dump -U postgres --schema-only --schema=public fluent - fi - } > "$output" - echo_success "Schema dumped to $output" - echo "Next: commit this file to fluent-ai/db/init/ and run './fai.sh clean && ./fai.sh up' to sync." - ;; - # ── Lifecycle commands ───────────────────────────────────────────────────── clean) @@ -671,7 +645,7 @@ HEADER else echo ".env already exists, skipping." fi - echo "Remember to fill in DATABASE_URL and BETTER_AUTH_SECRET in .env before running db:init." + echo "Remember to fill in the DB URLs (BOOTSTRAP/MIGRATIONS/DATABASE) and BETTER_AUTH_SECRET in .env before running db:init." ;; help|*) @@ -709,7 +683,6 @@ Database: db:generate Generate a new Drizzle migration db:studio Launch Drizzle Studio on the host db:psql Open psql session - db:dump-schema [path] pg_dump public schema for fluent-ai standalone sync Lifecycle: clean [service] Remove containers and volumes (default: all) @@ -720,7 +693,9 @@ Lifecycle: Environment variables: DB_PORT Standalone DB host port (default: 5432) API_PORT API service host port (default: 9999) - DATABASE_URL Override DB connection (set in .env to use platform DB) + BOOTSTRAP_DATABASE_URL Superuser URL the container uses to self-provision (bootstrap) + MIGRATIONS_DATABASE_URL api_migrator URL for Drizzle migrations (DDL) + DATABASE_URL api_user runtime URL (least-privilege; set in .env to use platform DB) USAGE ;; esac From 0198141e5f418bdbbb11d891740f211b8ec95634 Mon Sep 17 00:00:00 2001 From: kaseywright Date: Tue, 9 Jun 2026 20:45:05 -0400 Subject: [PATCH 8/9] Add db:init command to run all seeder and migrations --- fapi.ps1 | 2 +- fapi.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fapi.ps1 b/fapi.ps1 index ed9b4c22..11d99ff9 100644 --- a/fapi.ps1 +++ b/fapi.ps1 @@ -437,7 +437,7 @@ Development (runs in API container): Database: db:migrate Run Drizzle migrations db:seed Seed all data (org, roles, RBAC, dev users) - db:init Run migrations + all seeds (delegates to npm run db:setup) + db:init Run migrations + all seeds (interactive confirmation) db:generate Generate a new Drizzle migration db:studio Launch Drizzle Studio on the host db:psql Open psql session diff --git a/fapi.sh b/fapi.sh index ab3abf47..9a49b502 100755 --- a/fapi.sh +++ b/fapi.sh @@ -677,9 +677,9 @@ Development (runs in API container): run