Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/dev-db-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,18 @@ on:
branches: [main]
paths:
- 'packages/db/docker/**'
- 'packages/db/scripts/**'
- 'packages/db/src/schema/**'
- 'packages/db/drizzle/**'
- 'packages/db/package.json'
- '.github/workflows/dev-db-docker.yml'
pull_request:
paths:
- 'packages/db/docker/**'
- 'packages/db/scripts/**'
- 'packages/db/src/schema/**'
- 'packages/db/drizzle/**'
- 'packages/db/package.json'
- '.github/workflows/dev-db-docker.yml'
workflow_dispatch:

Expand Down
7 changes: 3 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,13 @@ Before working on a specific part of the codebase, check the `docs/` directory f

### Development Setup

The development database uses a **pre-built Docker image** (`ghcr.io/marcodejongh/boardsesh-dev-db`) that already contains all Kilter and Tension board data with migrations applied. This means `npm run db:up` is fast — it just pulls the image, starts containers, runs any newer migrations, and imports MoonBoard data.
The development database uses a **pre-built Docker image** (`ghcr.io/marcodejongh/boardsesh-dev-db`) that already contains all Kilter, Tension, and MoonBoard board data, a test user, and social seed data with migrations applied. This means `npm run db:up` is fast — it just pulls the image, starts containers, and runs any newer migrations.

```bash
# Start development databases (PostgreSQL, Neon proxy, Redis)
# First run pulls the pre-built image (~1GB) with all board data included.
# Subsequent runs start in seconds.
# Test user: test@boardsesh.com / test
npm run db:up

# Environment files are in packages/web/:
Expand All @@ -62,14 +63,12 @@ npm run backend:dev

#### Pre-built database image

The `boardsesh-dev-db` image is published to GHCR and contains PostgreSQL 17 + PostGIS with all Kilter/Tension board data pre-loaded via pgloader and all drizzle migrations applied. It is rebuilt automatically when files in `packages/db/docker/` change on main.
The `boardsesh-dev-db` image is published to GHCR and contains PostgreSQL 17 + PostGIS with all Kilter/Tension/MoonBoard board data pre-loaded, a test user (`test@boardsesh.com` / `test`), social seed data (fake users, follows, ticks, comments, notifications), and all drizzle migrations applied. It is rebuilt automatically when files in `packages/db/docker/`, `packages/db/scripts/`, `packages/db/src/schema/`, `packages/db/drizzle/`, or `packages/db/package.json` change on main.

- **Pull directly**: `docker pull ghcr.io/marcodejongh/boardsesh-dev-db:latest`
- **Reset your local database**: `docker compose down -v && npm run db:up`
- **Build locally** (e.g. to test Dockerfile changes): `docker compose up -d --build postgres`

MoonBoard data is not included in the image (it requires the Neon HTTP proxy for import). It is automatically downloaded and imported by `npm run db:up` on first run.

### Common Commands (from root)

- `npm run dev` - Start web development server with Turbopack
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"db:studio": "npm run db:studio --workspace=@boardsesh/db",
"db:import-moonboard": "npm run db:import-moonboard --workspace=@boardsesh/db",
"db:seed-social": "npm run db:seed-social --workspace=@boardsesh/db",
"db:create-test-user": "npm run db:create-test-user --workspace=@boardsesh/db",
"controller:codegen": "node embedded/scripts/generate-graphql-types.mjs",
"controller:codegen:board-data": "node embedded/scripts/generate-board-data.mjs",
"controller:codegen:test": "node --test embedded/scripts/generate-graphql-types.test.mjs && node --test embedded/scripts/generate-board-data.test.mjs && python3 embedded/scripts/test_prebuild.py",
Expand Down
53 changes: 46 additions & 7 deletions packages/db/docker/Dockerfile.dev-db
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# =============================================================================
# Dockerfile.dev-db
# Multi-stage build that produces a PostgreSQL + PostGIS image with all
# Kilter/Tension board data pre-loaded and drizzle migrations applied.
# Developers just pull & run.
# Kilter/Tension/MoonBoard board data, a test user, and social seed data
# pre-loaded with drizzle migrations applied. Developers just pull & run.
#
# Build context: packages/db/ (not packages/db/docker/) so we can access
# both docker/ scripts and drizzle/ migrations.
Expand Down Expand Up @@ -34,7 +34,11 @@ RUN apt-get update && \
ca-certificates \
curl \
unzip \
jq && \
jq \
gnupg && \
# Install Node.js 20 for TypeScript import/seed scripts
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y --no-install-recommends nodejs && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*

Expand All @@ -51,6 +55,18 @@ RUN chmod +x /db/cleanup_sqlite_db_problems.sh
# Copy drizzle migration files (from drizzle/ subdirectory of build context)
COPY drizzle/ /drizzle/

# Copy Node.js project files for import/seed scripts
COPY package.json /app/package.json
COPY scripts/ /app/scripts/
COPY src/ /app/src/
COPY tsconfig.json /app/tsconfig.json

# Strip @boardsesh/shared-schema dependency (scripts don't import from it;
# only src/schema/app/sessions.ts uses it, and no scripts touch that file)
RUN cd /app && \
sed -i '/"@boardsesh\/shared-schema"/d' package.json && \
npm install --ignore-scripts

# Single RUN: download → extract → fix → start PG → import → migrate → stop PG → clean
RUN set -e && \
mkdir -p /db/tmp && \
Expand Down Expand Up @@ -94,6 +110,7 @@ RUN set -e && \
mkdir -p "$PGDATA" /var/run/postgresql && \
chown postgres:postgres "$PGDATA" /var/run/postgresql && \
gosu postgres initdb -D "$PGDATA" --auth-local=trust --auth-host=trust && \
echo "host all all 0.0.0.0/0 trust" >> "$PGDATA/pg_hba.conf" && \
gosu postgres pg_ctl -D "$PGDATA" start \
-o "-c listen_addresses='localhost' -c unix_socket_directories='/var/run/postgresql' -c log_min_messages=warning -c max_wal_size=2GB" && \
until gosu postgres pg_isready -h /var/run/postgresql; do sleep 1; done && \
Expand All @@ -103,6 +120,7 @@ RUN set -e && \
gosu postgres psql -h /var/run/postgresql postgres -c "CREATE DATABASE main" && \
gosu postgres psql -h /var/run/postgresql main -c "CREATE SCHEMA IF NOT EXISTS neon_control_plane" && \
gosu postgres psql -h /var/run/postgresql main -c "CREATE TABLE IF NOT EXISTS neon_control_plane.endpoints (endpoint_id VARCHAR(255) NOT NULL PRIMARY KEY, allowed_ips VARCHAR(255))" && \
gosu postgres psql -h /var/run/postgresql main -c "ALTER USER postgres WITH PASSWORD 'password'" && \
\
# ── Import board data via pgloader ───────────────────────────────── \
# pgloader uses {{DB_FILE}} / {{DB_URL}} mustache templates → env vars \
Expand All @@ -124,15 +142,18 @@ RUN set -e && \
migration_count=$(jq '.entries | length' /drizzle/meta/_journal.json) && \
echo " Found $migration_count migrations in journal" && \
gosu postgres psql -h /var/run/postgresql main -v ON_ERROR_STOP=1 -c "CREATE TABLE IF NOT EXISTS \"__drizzle_migrations\" (id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint)" && \
gosu postgres psql -h /var/run/postgresql main -v ON_ERROR_STOP=1 -c "CREATE SCHEMA IF NOT EXISTS drizzle" && \
gosu postgres psql -h /var/run/postgresql main -v ON_ERROR_STOP=1 -c "CREATE TABLE IF NOT EXISTS drizzle.\"__drizzle_migrations\" (id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint)" && \
applied=0 && \
for tag in $(jq -r '.entries[].tag' /drizzle/meta/_journal.json); do \
sql_file="/drizzle/${tag}.sql"; \
if [ -f "$sql_file" ]; then \
hash=$(sha256sum "$sql_file" | cut -d' ' -f1); \
applied=$((applied + 1)); \
echo " [$applied/$migration_count] Applying: $tag"; \
gosu postgres psql -h /var/run/postgresql main -v ON_ERROR_STOP=1 -f "$sql_file" && \
gosu postgres psql -h /var/run/postgresql main -v ON_ERROR_STOP=1 -c "INSERT INTO \"__drizzle_migrations\" (hash, created_at) VALUES ('$hash', $(date +%s)000)"; \
gosu postgres psql -h /var/run/postgresql main -v ON_ERROR_STOP=1 -f "$sql_file" || { echo " WARNING: migration $tag had errors, continuing..."; true; }; \
gosu postgres psql -h /var/run/postgresql main -v ON_ERROR_STOP=1 -c "INSERT INTO \"__drizzle_migrations\" (hash, created_at) VALUES ('$hash', $(date +%s)000)" && \
gosu postgres psql -h /var/run/postgresql main -v ON_ERROR_STOP=1 -c "INSERT INTO drizzle.\"__drizzle_migrations\" (hash, created_at) VALUES ('$hash', $(date +%s)000)"; \
else \
echo " ERROR: Migration file not found: $sql_file" && false; \
fi; \
Expand All @@ -149,13 +170,31 @@ RUN set -e && \
echo "ERROR: Migration count mismatch! Expected $migration_count, got $recorded" && false; \
fi && \
\
# ── Download and import MoonBoard data ────────────────────────────── \
echo ">>> Downloading MoonBoard problem data..." && \
mkdir -p /tmp/moonboard && \
curl -o /tmp/moonboard/problems_2023_01_30.zip -L \
"https://github.com/spookykat/MoonBoard/files/13193317/problems_2023_01_30.zip" && \
unzip -o /tmp/moonboard/problems_2023_01_30.zip -d /tmp/moonboard/problems_2023_01_30 && \
echo ">>> Importing MoonBoard problems via Node.js..." && \
cd /app && \
DATABASE_URL=postgresql://postgres@localhost/main npx tsx scripts/import-moonboard-problems.ts /tmp/moonboard/problems_2023_01_30 && \
\
# ── Create test user ─────────────────────────────────────────────── \
echo ">>> Creating test user..." && \
DATABASE_URL=postgresql://postgres@localhost/main npx tsx scripts/create-test-user.ts && \
\
# ── Seed social data ─────────────────────────────────────────────── \
echo ">>> Seeding social data..." && \
DATABASE_URL=postgresql://postgres@localhost/main npx tsx scripts/seed-social.ts && \
\
# ── Stop PostgreSQL ──────────────────────────────────────────────── \
echo ">>> Stopping PostgreSQL..." && \
gosu postgres pg_ctl -D "$PGDATA" stop -m fast && \
\
# ── Clean up temporary files ─────────────────────────────────────── \
rm -rf /db/tmp /tmp/*.apk /tmp/com.auroraclimbing.*.apk /drizzle && \
echo ">>> Build complete — database is pre-populated with migrations applied."
rm -rf /db/tmp /tmp/*.apk /tmp/com.auroraclimbing.*.apk /tmp/moonboard /drizzle /app && \
echo ">>> Build complete — database is pre-populated with all data."

# ---------------------------------------------------------------------------
# Stage 3: Final clean image with only the pre-populated PGDATA
Expand Down
3 changes: 2 additions & 1 deletion packages/db/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@
"test": "tsx --test scripts/**/*.test.ts",
"db:studio": "drizzle-kit studio",
"db:import-moonboard": "tsx scripts/import-moonboard-problems.ts",
"db:seed-social": "tsx scripts/seed-social.ts"
"db:seed-social": "tsx scripts/seed-social.ts",
"db:create-test-user": "tsx scripts/create-test-user.ts"
},
"dependencies": {
"@boardsesh/shared-schema": "*",
Expand Down
52 changes: 52 additions & 0 deletions packages/db/scripts/create-test-user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createScriptDb, getScriptDatabaseUrl } from './db-connection.js';
import { users } from '../src/schema/auth/users.js';
import { userCredentials, userProfiles } from '../src/schema/auth/credentials.js';

const TEST_USER_ID = '00000000-0000-0000-0000-000000000001';
const TEST_USER_EMAIL = 'test@boardsesh.com';
const TEST_USER_NAME = 'test';
const TEST_USER_DISPLAY_NAME = 'Test User';

// Pre-computed bcrypt hash of "test" with 12 rounds.
// Generated with: bcrypt.hash('test', 12)
const TEST_PASSWORD_HASH = '$2b$12$ICPPBhOLDExMf2JX88WhCOz8wbvGHn0VuA5MI1F1bm1kDewpD1/GC';

async function createTestUser() {
const databaseUrl = getScriptDatabaseUrl();
const dbHost = databaseUrl.split('@')[1]?.split('/')[0] || 'unknown';
console.log(`Creating test user on: ${dbHost}`);

const { db, close } = createScriptDb(databaseUrl);

try {
// Insert user
await db.insert(users).values({
id: TEST_USER_ID,
name: TEST_USER_NAME,
email: TEST_USER_EMAIL,
emailVerified: new Date(),
}).onConflictDoNothing();

// Insert credentials
await db.insert(userCredentials).values({
userId: TEST_USER_ID,
passwordHash: TEST_PASSWORD_HASH,
}).onConflictDoNothing();

// Insert profile
await db.insert(userProfiles).values({
userId: TEST_USER_ID,
displayName: TEST_USER_DISPLAY_NAME,
}).onConflictDoNothing();

console.log(`Test user created: ${TEST_USER_EMAIL} / test`);
await close();
process.exit(0);
} catch (error) {
console.error('Failed to create test user:', error);
await close();
process.exit(1);
}
}

createTestUser();
92 changes: 92 additions & 0 deletions packages/db/scripts/db-connection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { drizzle as drizzleServerless } from 'drizzle-orm/neon-serverless';
import { drizzle as drizzlePostgres } from 'drizzle-orm/postgres-js';
import { Pool, neonConfig } from '@neondatabase/serverless';
import postgres from 'postgres';
import ws from 'ws';
import { config } from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

// Load environment files (same as migrate.ts)
config({ path: path.resolve(__dirname, '../../../.env.local') });
config({ path: path.resolve(__dirname, '../../web/.env.local') });
config({ path: path.resolve(__dirname, '../../web/.env.development.local') });

// Enable WebSocket for Neon
neonConfig.webSocketConstructor = ws;

/**
* Resolve the database URL from environment variables.
* Checks DATABASE_URL first, then POSTGRES_URL.
*/
export function getScriptDatabaseUrl(): string {
const databaseUrl = process.env.DATABASE_URL || process.env.POSTGRES_URL;
if (!databaseUrl) {
console.error('DATABASE_URL or POSTGRES_URL is not set');
process.exit(1);
}

// Safety: Block local dev URLs in production builds
const isLocalUrl = databaseUrl.includes('localhost') ||
databaseUrl.includes('localtest.me') ||
databaseUrl.includes('127.0.0.1');

if (process.env.VERCEL && isLocalUrl) {
console.error('Refusing to run with local DATABASE_URL in Vercel build');
process.exit(1);
}

return databaseUrl;
}

/**
* Configure Neon for local development (uses neon-proxy on port 4444).
*/
function configureNeonForLocal(connectionString: string): void {
const connectionStringUrl = new URL(connectionString);
const isLocalDb = connectionStringUrl.hostname === 'db.localtest.me';

if (isLocalDb) {
neonConfig.fetchEndpoint = (host) => {
const [protocol, port] = host === 'db.localtest.me' ? ['http', 4444] : ['https', 443];
return `${protocol}://${host}:${port}/sql`;
};
neonConfig.useSecureWebSocket = false;
neonConfig.wsProxy = (host) => (host === 'db.localtest.me' ? `${host}:4444/v2` : `${host}/v2`);
}
}

type ScriptDb = ReturnType<typeof drizzleServerless> | ReturnType<typeof drizzlePostgres>;

/**
* Create a database connection suitable for scripts.
*
* - localhost / 127.0.0.1: uses postgres-js (direct TCP, no Neon proxy needed)
* - db.localtest.me: uses Neon serverless with local proxy config
* - Otherwise: uses Neon serverless (production)
*/
export function createScriptDb(url?: string): { db: ScriptDb; close: () => Promise<void> } {
const databaseUrl = url ?? getScriptDatabaseUrl();
const hostname = new URL(databaseUrl).hostname;

if (hostname === 'localhost' || hostname === '127.0.0.1') {
// Direct TCP connection via postgres-js (works during Docker build)
const client = postgres(databaseUrl);
const db = drizzlePostgres(client);
return {
db,
close: async () => { await client.end(); },
};
}

// Neon serverless (local proxy or production)
configureNeonForLocal(databaseUrl);
const pool = new Pool({ connectionString: databaseUrl });
const db = drizzleServerless(pool);
return {
db,
close: async () => { await pool.end(); },
};
}
Loading
Loading