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
25 changes: 24 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,30 @@ jobs:
run:
working-directory: backend

services:
postgres:
image: postgres:16
env:
POSTGRES_DB: quittance_test
POSTGRES_USER: quittance
POSTGRES_PASSWORD: password
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5

env:
# The integration test starts its own Horizon stub on a loopback port and
# overrides this at runtime. It is set here so the suite never reaches the
# public testnet even if a test forgets to override it.
STELLAR_NETWORK: TESTNET
STELLAR_HORIZON_URL: http://127.0.0.1:1
NODE_ENV: test
DATABASE_URL: postgresql://quittance:password@localhost:5432/quittance_test
INVOICE_STORAGE: postgres

steps:
- uses: actions/checkout@v4
Expand All @@ -58,7 +75,13 @@ jobs:
- name: Typecheck
run: npm run typecheck

- name: Test
- name: Migrate database (run 1)
run: npm run db:migrate

- name: Migrate database (run 2 — idempotency check)
run: npm run db:migrate

- name: Test (including Postgres integration suite)
run: npm test

frontend:
Expand Down
12 changes: 12 additions & 0 deletions backend/scripts/cutover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
importSnapshotToPostgres,
CutoverSnapshot,
} from '../src/services/cutover.service';
import { checkMigrationStatus, runMigrations } from '../src/db/migration-runner';

async function main() {
const options = {
Expand Down Expand Up @@ -78,6 +79,17 @@ Environment Variables:
console.log(`[Cutover] Snapshot validation PASSED (${snapshot.count} invoices).`);

const isDryRun = Boolean(values['dry-run']);
console.log(`[Cutover] Checking database migration status...`);
const status = await checkMigrationStatus(pool);
if (!status.ready) {
if (isDryRun) {
console.log(`[Cutover] Pending migrations detected: ${status.pendingVersions.join(', ')}`);
} else {
console.log(`[Cutover] Applying pending migrations before import...`);
await runMigrations(pool);
}
}

console.log(`[Cutover] Executing transactional import (dry-run: ${isDryRun})...`);

const result = await importSnapshotToPostgres(pool, snapshot, { dryRun: isDryRun });
Expand Down
69 changes: 67 additions & 2 deletions backend/src/config/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,12 @@ export function simulationAllowed(env: RuntimeEnvironment = process.env): boolea
return env.NODE_ENV !== 'production' && env.ALLOW_SIMULATE === 'true';
}

import { pool } from './database';
import { checkMigrationStatus } from '../db/migration-runner';

export function deploymentReadiness(
env: RuntimeEnvironment = process.env,
options: { storage?: string } = {}
options: { storage?: string; storageReady?: boolean } = {}
): ReadinessCheck {
const network = (env.STELLAR_NETWORK || 'TESTNET').toUpperCase();
const horizonUrl = env.STELLAR_HORIZON_URL ||
Expand All @@ -59,12 +62,24 @@ export function deploymentReadiness(
: 'https://horizon.stellar.org');
const isHttps = /^https:\/\//i.test(horizonUrl);
const isDevOrTestLocal = env.NODE_ENV !== 'production' && /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i.test(horizonUrl);

const isPostgresSync = Boolean(
(options.storage && /postgres/i.test(options.storage)) ||
(!options.storage && env.INVOICE_STORAGE === 'postgres')
);

const storageReadySync = options.storageReady !== undefined
? options.storageReady
: isPostgresSync
? Boolean(env.DATABASE_URL)
: true;

const checks: ReadinessCheck['checks'] = {
frontendOrigins: configuredFrontendOrigins(env).length > 0,
simulationDisabled: env.ALLOW_SIMULATE !== 'true',
stellarNetwork: network === 'TESTNET' || network === 'PUBLIC',
horizonUrl: isHttps || isDevOrTestLocal,
storageReady: true, // In-memory MVP is immediately ready without Postgres
storageReady: storageReadySync,
};
const reasons: string[] = [];
const missing: string[] = [];
Expand All @@ -85,6 +100,10 @@ export function deploymentReadiness(
reasons.push('STELLAR_HORIZON_URL must use HTTPS');
missing.push('STELLAR_HORIZON_URL');
}
if (!checks.storageReady && isPostgresSync && !env.DATABASE_URL) {
reasons.push('DATABASE_URL is required for PostgreSQL storage');
missing.push('DATABASE_URL');
}

return { ready: Object.values(checks).every(Boolean), checks, reasons, missing };
}
Expand Down Expand Up @@ -145,6 +164,8 @@ export interface DeploymentReadinessOptions {
storage?: string;
pingHorizon?: boolean;
timeoutMs?: number;
pool?: any;
migrationsDir?: string;
}

export async function deploymentReadinessAsync(
Expand Down Expand Up @@ -173,6 +194,50 @@ export async function deploymentReadinessAsync(
}
}

const isExplicitMemory =
options.storage === 'memory' ||
options.storage === 'in-memory' ||
env.INVOICE_STORAGE === 'memory';

const isPostgres = Boolean(
(options.storage && /postgres/i.test(options.storage)) ||
(!options.storage && env.INVOICE_STORAGE === 'postgres') ||
(!options.storage && !env.INVOICE_STORAGE && env.DATABASE_URL)
);

if (isPostgres && !isExplicitMemory) {
if (!env.DATABASE_URL && !options.pool) {
checks.storageReady = false;
if (!missing.includes('DATABASE_URL')) {
missing.push('DATABASE_URL');
reasons.push('DATABASE_URL is required for PostgreSQL storage');
}
} else {
try {
const dbPool = options.pool ?? pool;
const migrationStatus = await checkMigrationStatus(dbPool, {
migrationsDir: options.migrationsDir,
});

if (!migrationStatus.ready) {
checks.storageReady = false;
if (migrationStatus.pendingVersions.length > 0) {
reasons.push(
`Database schema is not up to date (pending migrations: ${migrationStatus.pendingVersions.join(', ')})`
);
} else {
reasons.push('Database schema is not ready');
}
} else {
checks.storageReady = true;
}
} catch (err: any) {
checks.storageReady = false;
reasons.push(`PostgreSQL connection failed: ${err.message || 'connection error'}`);
}
}
}

const allChecksPassed = Object.values(checks).every(Boolean);
return {
ready: allChecksPassed,
Expand Down
67 changes: 31 additions & 36 deletions backend/src/db/migrate.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,30 @@
import * as fs from 'fs';
import * as path from 'path';
import { pool } from '../config/database';
import { runMigrations, checkMigrationStatus } from './migration-runner';

// SQL lives at the repo root (db/), the runner lives with the backend code so
// `npm run db:migrate` (tsx src/db/migrate.ts) resolves both the script and its
// dependencies.
//
// schema.sql applied here defines the full parity column set: seller name and
// email, asset_code + asset_issuer pair, customer name + email, all payer
// fields (public_key / name / email / tx_hash / paid_at), cancellation and
// settlement context fields, expires_at, and JSONB metadata. Any column added
// to StoredInvoice in invoice-storage.ts requires a matching ALTER or a re-run
// of this migrate against production; the shared interface + TypeScript will
// surface the mismatch at compile time if the two drift.
const SQL_DIR = path.join(__dirname, '../../../db');

async function migrate() {
export async function migrate() {
console.log('🚀 Starting database migration...\n');

try {
const schemaPath = path.join(SQL_DIR, 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
const result = await runMigrations(pool);

await pool.query(schema);
if (result.adopted.length > 0) {
console.log('📦 Adopted baseline migrations for existing database:');
for (const m of result.adopted) {
console.log(` - ${m.version}_${m.name} (adopted)`);
}
}

console.log('✅ Database migration completed successfully!\n');
console.log('📋 Created tables:');
console.log(' - invoices (keyed by seller_public_key)');
console.log(' - transactions');
console.log(' - payment_events');
console.log('\n📊 Created views:');
console.log(' - invoice_stats\n');
if (result.applied.length > 0) {
console.log('✅ Applied migrations:');
for (const m of result.applied) {
console.log(` - ${m.version}_${m.name}`);
}
} else if (result.adopted.length === 0) {
console.log('✨ No pending migrations. Database schema is already up to date.');
}

console.log(`\n📌 Current schema version: ${result.latestVersion || 'none'}\n`);
return result;
} catch (error) {
console.error('❌ Migration failed:', error);
throw error;
Expand All @@ -40,13 +33,15 @@ async function migrate() {
}
}

// Run migration
migrate()
.then(() => {
console.log('✨ All done!');
process.exit(0);
})
.catch((error) => {
console.error('Migration error:', error);
process.exit(1);
});
// Run CLI when invoked directly
if (require.main === module || process.argv[1]?.endsWith('migrate.ts') || process.argv[1]?.endsWith('migrate.js')) {
migrate()
.then(() => {
console.log('✨ All done!');
process.exit(0);
})
.catch((error) => {
console.error('Migration error:', error);
process.exit(1);
});
}
Loading