diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0235f1..30864b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,19 +28,19 @@ jobs: ports: - 5432:5432 options: >- - --health-cmd "pg_isready -U ${{ secrets.POSTGRES_USER }} -d ${{ secrets.POSTGRES_DB }}" + --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10 env: NODE_ENV: test - # Points to the service container above โ€” never touches the VPS. - DATABASE_URL: ${{ secrets.DATABASE_URL }} - # JWT_SECRET must be โ‰ฅ32 chars; using a fixed CI-only value is safe here. + # Built from individual secrets so the port is always 5432 (service container). + DATABASE_URL: postgresql://${{ secrets.POSTGRES_USER }}:${{ secrets.POSTGRES_PASSWORD }}@localhost:5432/${{ secrets.POSTGRES_DB }} JWT_SECRET: ${{ secrets.JWT_SECRET }} LOG_LEVEL: error PORT: 3000 + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: ๐Ÿšš Checkout Repository @@ -49,7 +49,7 @@ jobs: - name: โšก๏ธ Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.11 - name: ๐Ÿ“ฆ Install Dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/stage.yml b/.github/workflows/stage.yml index 5aa4cb8..3a172b1 100644 --- a/.github/workflows/stage.yml +++ b/.github/workflows/stage.yml @@ -1,153 +1,153 @@ -name: Staging Deploy - -# Triggers on pushes to the `develop` branch only. -# This is the full staging pipeline: test with an isolated service container DB, -# then deploy the whole stack to the staging VPS if tests pass. -# The staging VPS uses its own .env.staging file โ€” completely isolated from -# the CI DB (ephemeral) and production DB. -on: - push: - branches: [develop] - -concurrency: - # Cancel any in-progress staging deploy if a new commit is pushed. - group: staging-deploy-${{ github.ref }} - cancel-in-progress: true - -jobs: - # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - # JOB 1: Test - # GitHub Actions service container provides an isolated, ephemeral Postgres. - # No staging or production DB credentials are used here. - # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - test: - name: ๐Ÿงช Test (Service Container DB) - runs-on: ubuntu-latest - - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_USER: ci_user - POSTGRES_PASSWORD: ci_password - POSTGRES_DB: ci_test_db - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U ci_user -d ci_test_db" - --health-interval 5s - --health-timeout 5s - --health-retries 10 - - env: - NODE_ENV: test - CI_TEST_DB_URL: postgresql://ci_user:ci_password@localhost:5432/ci_test_db - JWT_SECRET: ${{ secrets.CI_JWT_SECRET }} - LOG_LEVEL: error - PORT: 3000 - - steps: - - name: ๐Ÿšš Checkout - uses: actions/checkout@v4 - - - name: โšก๏ธ Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: ๐Ÿ“ฆ Install Dependencies - run: bun install --frozen-lockfile - - - name: ๐Ÿงน Lint (Biome) - run: bun run lint - - - name: ๐Ÿ›ก๏ธ Type Check - run: bun run typecheck - - - name: ๐Ÿ—„๏ธ Push Schema โ†’ Service Container DB - run: bunx drizzle-kit push --config=src/config/drizzle.config.ts --force - - - name: ๐Ÿงช Unit Tests - run: bun run test:unit - - - name: ๐Ÿงช Integration Tests - run: bun run test:integration - - - name: ๐Ÿ—๏ธ Build - run: bun run build - - # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - # JOB 2: Deploy Full Stack to Staging VPS - # Runs ONLY when the test job above passes. - # SSHes into the staging VPS and brings the entire docker compose stack up, - # including the backend, database, Loki, and Grafana services. - # The staging VPS reads .env.staging which holds STAGING_DATABASE_URL, - # Loki/Grafana credentials, etc. โ€” none of these ever appear in this file. - # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - deploy-staging: - name: ๐Ÿš€ Deploy to Staging VPS - needs: test - runs-on: ubuntu-latest - - environment: - name: staging - url: ${{ secrets.STAGING_APP_URL }} - - steps: - - name: ๐Ÿ”‘ Deploy via SSH - uses: appleboy/ssh-action@v1.0.3 - with: - host: ${{ secrets.STAGING_VPS_HOST }} - username: ${{ secrets.STAGING_VPS_USER }} - key: ${{ secrets.STAGING_VPS_SSH_PRIVATE_KEY }} - port: ${{ secrets.STAGING_VPS_SSH_PORT }} - script: | - set -e - - echo "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" - echo " Deploying commit ${{ github.sha }} to staging" - echo "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" - - cd ${{ secrets.STAGING_APP_DIR }} - - # Pull latest code from develop branch - git fetch origin develop - git checkout develop - git pull origin develop - - # Install / update dependencies - bun install --frozen-lockfile - - # Apply any schema changes to the staging DB. - # NODE_ENV=production so drizzle.config.ts picks up PROD_DATABASE_URL, - # which in .env.staging points to the staging Postgres โ€” not production. - NODE_ENV=production bunx drizzle-kit push \ - --config=src/config/drizzle.config.ts \ - --force - - # Bring up the full stack (backend + db + loki + grafana) - # --remove-orphans cleans up containers from old compose definitions - docker compose \ - -f docker-compose.staging.yml \ - --env-file .env.staging \ - up -d \ - --build \ - --remove-orphans - - echo "โœ… Staging deploy complete" - - - name: ๐Ÿฉบ Health Check - # Wait up to 60 s for the app to respond before marking the deploy successful. - run: | - echo "Waiting for staging app to become healthy..." - for i in $(seq 1 12); do - STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${{ secrets.STAGING_APP_URL }}/health" || true) - if [ "$STATUS" = "200" ]; then - echo "โœ… Health check passed (HTTP 200)" - exit 0 - fi - echo " Attempt $i/12 โ€” got HTTP $STATUS, retrying in 5s..." - sleep 5 - done - echo "โŒ Health check failed after 60s" - exit 1 +# name: Staging Deploy + +# # Triggers on pushes to the `develop` branch only. +# # This is the full staging pipeline: test with an isolated service container DB, +# # then deploy the whole stack to the staging VPS if tests pass. +# # The staging VPS uses its own .env.staging file โ€” completely isolated from +# # the CI DB (ephemeral) and production DB. +# on: +# push: +# branches: [develop] + +# concurrency: +# # Cancel any in-progress staging deploy if a new commit is pushed. +# group: staging-deploy-${{ github.ref }} +# cancel-in-progress: true + +# jobs: +# # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# # JOB 1: Test +# # GitHub Actions service container provides an isolated, ephemeral Postgres. +# # No staging or production DB credentials are used here. +# # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# test: +# name: ๐Ÿงช Test (Service Container DB) +# runs-on: ubuntu-latest + +# services: +# postgres: +# image: postgres:16-alpine +# env: +# POSTGRES_USER: ci_user +# POSTGRES_PASSWORD: ci_password +# POSTGRES_DB: ci_test_db +# ports: +# - 5432:5432 +# options: >- +# --health-cmd "pg_isready -U ci_user -d ci_test_db" +# --health-interval 5s +# --health-timeout 5s +# --health-retries 10 + +# env: +# NODE_ENV: test +# CI_TEST_DB_URL: postgresql://ci_user:ci_password@localhost:5432/ci_test_db +# JWT_SECRET: ${{ secrets.CI_JWT_SECRET }} +# LOG_LEVEL: error +# PORT: 3000 + +# steps: +# - name: ๐Ÿšš Checkout +# uses: actions/checkout@v4 + +# - name: โšก๏ธ Setup Bun +# uses: oven-sh/setup-bun@v2 +# with: +# bun-version: latest + +# - name: ๐Ÿ“ฆ Install Dependencies +# run: bun install --frozen-lockfile + +# - name: ๐Ÿงน Lint (Biome) +# run: bun run lint + +# - name: ๐Ÿ›ก๏ธ Type Check +# run: bun run typecheck + +# - name: ๐Ÿ—„๏ธ Push Schema โ†’ Service Container DB +# run: bunx drizzle-kit push --config=src/config/drizzle.config.ts --force + +# - name: ๐Ÿงช Unit Tests +# run: bun run test:unit + +# - name: ๐Ÿงช Integration Tests +# run: bun run test:integration + +# - name: ๐Ÿ—๏ธ Build +# run: bun run build + +# # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# # JOB 2: Deploy Full Stack to Staging VPS +# # Runs ONLY when the test job above passes. +# # SSHes into the staging VPS and brings the entire docker compose stack up, +# # including the backend, database, Loki, and Grafana services. +# # The staging VPS reads .env.staging which holds STAGING_DATABASE_URL, +# # Loki/Grafana credentials, etc. โ€” none of these ever appear in this file. +# # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# deploy-staging: +# name: ๐Ÿš€ Deploy to Staging VPS +# needs: test +# runs-on: ubuntu-latest + +# environment: +# name: staging +# url: ${{ secrets.STAGING_APP_URL }} + +# steps: +# - name: ๐Ÿ”‘ Deploy via SSH +# uses: appleboy/ssh-action@v1.0.3 +# with: +# host: ${{ secrets.STAGING_VPS_HOST }} +# username: ${{ secrets.STAGING_VPS_USER }} +# key: ${{ secrets.STAGING_VPS_SSH_PRIVATE_KEY }} +# port: ${{ secrets.STAGING_VPS_SSH_PORT }} +# script: | +# set -e + +# echo "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" +# echo " Deploying commit ${{ github.sha }} to staging" +# echo "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" + +# cd ${{ secrets.STAGING_APP_DIR }} + +# # Pull latest code from develop branch +# git fetch origin develop +# git checkout develop +# git pull origin develop + +# # Install / update dependencies +# bun install --frozen-lockfile + +# # Apply any schema changes to the staging DB. +# # NODE_ENV=production so drizzle.config.ts picks up PROD_DATABASE_URL, +# # which in .env.staging points to the staging Postgres โ€” not production. +# NODE_ENV=production bunx drizzle-kit push \ +# --config=src/config/drizzle.config.ts \ +# --force + +# # Bring up the full stack (backend + db + loki + grafana) +# # --remove-orphans cleans up containers from old compose definitions +# docker compose \ +# -f docker-compose.staging.yml \ +# --env-file .env.staging \ +# up -d \ +# --build \ +# --remove-orphans + +# echo "โœ… Staging deploy complete" + +# - name: ๐Ÿฉบ Health Check +# # Wait up to 60 s for the app to respond before marking the deploy successful. +# run: | +# echo "Waiting for staging app to become healthy..." +# for i in $(seq 1 12); do +# STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${{ secrets.STAGING_APP_URL }}/health" || true) +# if [ "$STATUS" = "200" ]; then +# echo "โœ… Health check passed (HTTP 200)" +# exit 0 +# fi +# echo " Attempt $i/12 โ€” got HTTP $STATUS, retrying in 5s..." +# sleep 5 +# done +# echo "โŒ Health check failed after 60s" +# exit 1 diff --git a/src/database/index.ts b/src/database/index.ts index 6cddb97..8dfc411 100644 --- a/src/database/index.ts +++ b/src/database/index.ts @@ -18,11 +18,8 @@ export function getDb(): PostgresJsDatabase { return database; } -export const db = new Proxy({} as PostgresJsDatabase, { - get(_target, prop, receiver) { - return Reflect.get(getDb(), prop, receiver); - }, -}); +export let db: PostgresJsDatabase = + null as unknown as PostgresJsDatabase; export const startDatabase = async (): Promise => { queryClient = isProd @@ -40,6 +37,7 @@ export const startDatabase = async (): Promise => { }); database = drizzle(queryClient, { schema }); + db = database; await queryClient`SELECT 1`; logger.info(`Database connected [${env.NODE_ENV.toUpperCase()}]`); @@ -54,6 +52,7 @@ export const stopDatabase = async (): Promise => { await queryClient.end(); queryClient = null; database = null; + db = null as unknown as PostgresJsDatabase; logger.info("Database connection closed"); } catch (error) { logger.error({ err: error }, "Error closing database connection"); diff --git a/tests/integration/helpers/index.ts b/tests/integration/helpers/index.ts index 73c8d86..9f59c42 100644 --- a/tests/integration/helpers/index.ts +++ b/tests/integration/helpers/index.ts @@ -1,11 +1,11 @@ -import { db } from "@/database"; +import { getDb } from "@/database"; import { UserHelpers, todos, users } from "@/database/schema"; import { signJwt } from "@/utils/auth"; export async function setupTestContext(username: string) { - await db.delete(users); + await getDb().delete(users); - const user = await UserHelpers.create(db, { + const user = await UserHelpers.create(getDb(), { username, passwordRaw: "test-password", }); @@ -57,7 +57,7 @@ export async function createTestTodo( userId: number, overrides: Partial<{ title: string; completed: boolean }> = {}, ) { - const [todo] = await db + const [todo] = await getDb() .insert(todos) .values({ title: "Test todo", diff --git a/tests/integration/todo.test.ts b/tests/integration/todo.test.ts index 6400163..d983737 100644 --- a/tests/integration/todo.test.ts +++ b/tests/integration/todo.test.ts @@ -13,7 +13,7 @@ mock.module("@/utils/logger", () => { // โ”€โ”€ Imports โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ import { createApp } from "@/app"; -import { db, startDatabase, stopDatabase } from "@/database"; +import { getDb, startDatabase, stopDatabase } from "@/database"; import { todos, users } from "@/database/schema"; import { Errors } from "@/utils/errors"; import { createTestTodo, json, jsonReq, setupTestContext } from "./helpers"; @@ -30,7 +30,7 @@ let authed: Awaited>["authed"]; beforeAll(async () => { await startDatabase(); - await db.delete(todos); + await getDb().delete(todos); const ctx = await setupTestContext("alice"); userId = ctx.user.id; authHeader = ctx.authHeader; @@ -38,8 +38,8 @@ beforeAll(async () => { }); afterAll(async () => { - await db.delete(todos); - await db.delete(users); + await getDb().delete(todos); + await getDb().delete(users); await stopDatabase(); }); @@ -69,7 +69,7 @@ describe("GET /api/todos", () => { }); it("returns 200 with all todos belonging to the authenticated user", async () => { - await db.insert(todos).values([ + await getDb().insert(todos).values([ { title: "Buy milk", completed: false, user_id: userId }, { title: "Walk dog", completed: true, user_id: userId }, ]); @@ -83,7 +83,7 @@ describe("GET /api/todos", () => { expect(body.data[0].title).toBe("Buy milk"); expect(body.data[1].title).toBe("Walk dog"); - await db.delete(todos); + await getDb().delete(todos); }); }); @@ -149,7 +149,7 @@ describe("GET /api/todos/:id", () => { }); afterAll(async () => { - await db.delete(todos); + await getDb().delete(todos); }); it("returns 401 when not authenticated", async () => { @@ -196,7 +196,7 @@ describe("PUT /api/todos/:id", () => { }); afterAll(async () => { - await db.delete(todos); + await getDb().delete(todos); }); it("returns 401 when not authenticated", async () => { @@ -266,7 +266,7 @@ describe("DELETE /api/todos/:id", () => { }); afterAll(async () => { - await db.delete(todos); + await getDb().delete(todos); }); it("returns 401 when not authenticated", async () => { diff --git a/tests/integration/auth.test.ts b/tests/unit/auth.test.ts similarity index 100% rename from tests/integration/auth.test.ts rename to tests/unit/auth.test.ts