Skip to content

Commit 17aa6f4

Browse files
authored
Merge pull request #61 from robertocarlous/robbert
feat #53 Build portfolio migration tool for schema/version upgrades
2 parents 01821f6 + 44086d5 commit 17aa6f4

File tree

9 files changed

+426
-3
lines changed

9 files changed

+426
-3
lines changed

.github/workflows/backend-tests.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ on:
44
pull_request:
55
paths:
66
- 'backend/**'
7+
- 'docs/MIGRATION.md'
78
- '.github/workflows/backend-tests.yml'
89
push:
910
branches:
1011
- main
1112
- develop
1213
paths:
1314
- 'backend/**'
15+
- 'docs/MIGRATION.md'
1416

1517
jobs:
1618
test:
@@ -35,6 +37,10 @@ jobs:
3537
working-directory: backend
3638
run: npm ci
3739

40+
- name: Verify migrations (dry-run)
41+
working-directory: backend
42+
run: npm run db:migrate:dry-run
43+
3844
- name: Run tests
3945
working-directory: backend
4046
run: npm run test

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ cp backend/.env.example backend/.env
6767
cp frontend/.env.example frontend/.env
6868
# Edit with contract addresses
6969

70+
**Database migrations** (when using PostgreSQL with `DATABASE_URL`): see [docs/MIGRATION.md](docs/MIGRATION.md). Apply with `cd backend && npm run db:migrate`; use `--dry-run` to preview.
71+
7072
Configure SMTP for Email Notifications (Optional)
7173

7274
To enable email notifications for rebalancing events:

backend/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
"build": "tsc",
88
"start": "node dist/index.js",
99
"db:migrate": "tsx src/db/migrate.ts",
10+
"db:migrate:dry-run": "tsx src/db/migrate.ts -- --dry-run",
11+
"db:migrate:rollback": "tsx src/db/migrate.ts -- --rollback",
12+
"db:migrate:status": "tsx src/db/migrate.ts -- --status",
1013
"db:setup": "tsx src/db/migrate.ts",
1114
"test": "vitest run",
1215
"test:watch": "vitest"

backend/src/db/migrate.ts

Lines changed: 184 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,200 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Versioned migration runner for PostgreSQL.
4+
* Usage:
5+
* npm run db:migrate - Apply all pending migrations
6+
* npm run db:migrate -- --dry-run - Show what would run without applying
7+
* npm run db:migrate -- --rollback [n] - Roll back last n migrations (default 1)
8+
* npm run db:migrate -- --status - List applied and pending migrations
9+
*/
110
import 'dotenv/config'
2-
import { readFileSync } from 'fs'
11+
import { readFileSync, readdirSync } from 'fs'
312
import { fileURLToPath } from 'url'
413
import { dirname, join } from 'path'
514
import { getPool, closePool, isDbConfigured } from './client.js'
615

716
const __dirname = dirname(fileURLToPath(import.meta.url))
17+
const MIGRATIONS_DIR = join(__dirname, 'migrations')
18+
19+
const MIGRATION_TABLE = `CREATE TABLE IF NOT EXISTS schema_migrations (
20+
version VARCHAR(64) PRIMARY KEY,
21+
name VARCHAR(256) NOT NULL,
22+
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
23+
);`
24+
25+
interface MigrationFile {
26+
version: string
27+
name: string
28+
upPath: string
29+
downPath: string
30+
}
31+
32+
function parseArgs(): { dryRun: boolean; rollback: number | null; status: boolean } {
33+
const args = process.argv.slice(2)
34+
let dryRun = false
35+
let rollback: number | null = null
36+
let status = false
37+
for (let i = 0; i < args.length; i++) {
38+
if (args[i] === '--dry-run') dryRun = true
39+
else if (args[i] === '--status') status = true
40+
else if (args[i] === '--rollback') {
41+
const next = args[i + 1]
42+
rollback = next !== undefined && /^\d+$/.test(next) ? parseInt(next, 10) : 1
43+
if (next !== undefined && /^\d+$/.test(next)) i++
44+
}
45+
}
46+
return { dryRun, rollback, status }
47+
}
48+
49+
function discoverMigrations(): MigrationFile[] {
50+
const files = readdirSync(MIGRATIONS_DIR)
51+
const upFiles = files.filter((f) => f.endsWith('.up.sql'))
52+
const migrations: MigrationFile[] = []
53+
for (const up of upFiles) {
54+
const match = up.match(/^(\d+)_(.+)\.up\.sql$/)
55+
if (!match) continue
56+
const down = `${match[1]}_${match[2]}.down.sql`
57+
if (!files.includes(down)) {
58+
console.warn(`Warning: missing down migration ${down}`)
59+
}
60+
migrations.push({
61+
version: match[1],
62+
name: match[2],
63+
upPath: join(MIGRATIONS_DIR, up),
64+
downPath: join(MIGRATIONS_DIR, down)
65+
})
66+
}
67+
migrations.sort((a, b) => a.version.localeCompare(b.version, 'en'))
68+
return migrations
69+
}
70+
71+
async function ensureMigrationsTable(): Promise<void> {
72+
await getPool().query(MIGRATION_TABLE)
73+
}
74+
75+
async function getAppliedVersions(): Promise<string[]> {
76+
const result = await getPool().query<{ version: string }>(
77+
'SELECT version FROM schema_migrations ORDER BY version ASC'
78+
)
79+
return result.rows.map((r) => r.version)
80+
}
881

982
async function run() {
83+
const { dryRun, rollback, status } = parseArgs()
84+
const migrations = discoverMigrations()
85+
if (migrations.length === 0) {
86+
console.log('No migration files found in', MIGRATIONS_DIR)
87+
process.exit(0)
88+
}
89+
1090
if (!isDbConfigured()) {
91+
if (dryRun && rollback === null && !status) {
92+
console.log('[DRY RUN] DATABASE_URL not set. Showing migration files that would be applied:')
93+
for (const m of migrations) {
94+
console.log(' ', m.version, m.name)
95+
}
96+
console.log('Total:', migrations.length, 'migration(s).')
97+
process.exit(0)
98+
}
1199
console.error('DATABASE_URL is not set. Set it to run migrations.')
12100
process.exit(1)
13101
}
102+
14103
try {
15-
const schema = readFileSync(join(__dirname, 'schema.sql'), 'utf8')
16-
await getPool().query(schema)
104+
await ensureMigrationsTable()
105+
const applied = await getAppliedVersions()
106+
107+
if (status) {
108+
console.log('Applied migrations:')
109+
for (const v of applied) {
110+
const m = migrations.find((x) => x.version === v)
111+
console.log(' ', v, m ? m.name : '(unknown)')
112+
}
113+
console.log('\nPending migrations:')
114+
const pending = migrations.filter((m) => !applied.includes(m.version))
115+
for (const m of pending) {
116+
console.log(' ', m.version, m.name)
117+
}
118+
await closePool()
119+
process.exit(0)
120+
return
121+
}
122+
123+
if (rollback !== null) {
124+
const toRollback = applied.slice(-rollback).reverse()
125+
if (toRollback.length === 0) {
126+
console.log('No migrations to roll back.')
127+
await closePool()
128+
process.exit(0)
129+
return
130+
}
131+
if (dryRun) {
132+
console.log('[DRY RUN] Would roll back:', toRollback.join(', '))
133+
for (const v of toRollback) {
134+
const m = migrations.find((x) => x.version === v)
135+
if (m) {
136+
try {
137+
const sql = readFileSync(m.downPath, 'utf8')
138+
console.log('---', m.version, m.name, '(down) ---\n', sql)
139+
} catch {
140+
console.log('---', m.version, m.name, '(down file missing) ---')
141+
}
142+
}
143+
}
144+
await closePool()
145+
process.exit(0)
146+
}
147+
for (const v of toRollback) {
148+
const m = migrations.find((x) => x.version === v)
149+
if (!m) {
150+
console.error('Migration version not found:', v)
151+
process.exit(1)
152+
}
153+
let downSql: string
154+
try {
155+
downSql = readFileSync(m.downPath, 'utf8')
156+
} catch (err) {
157+
console.error('Cannot read down migration', m.downPath, err)
158+
process.exit(1)
159+
}
160+
console.log('Rolling back', m.version, m.name, '...')
161+
await getPool().query(downSql)
162+
await getPool().query('DELETE FROM schema_migrations WHERE version = $1', [m.version])
163+
console.log('Rolled back', m.version, m.name)
164+
}
165+
console.log('Rollback completed.')
166+
await closePool()
167+
process.exit(0)
168+
}
169+
170+
const pending = migrations.filter((m) => !applied.includes(m.version))
171+
if (pending.length === 0) {
172+
console.log('No pending migrations.')
173+
await closePool()
174+
process.exit(0)
175+
}
176+
177+
if (dryRun) {
178+
console.log('[DRY RUN] Pending migrations (not applied):')
179+
for (const m of pending) {
180+
const sql = readFileSync(m.upPath, 'utf8')
181+
console.log('---', m.version, m.name, '---\n', sql.substring(0, 500) + (sql.length > 500 ? '...' : ''), '\n')
182+
}
183+
console.log('Total:', pending.length, 'migration(s). Run without --dry-run to apply.')
184+
await closePool()
185+
process.exit(0)
186+
}
187+
188+
for (const m of pending) {
189+
const sql = readFileSync(m.upPath, 'utf8')
190+
console.log('Applying', m.version, m.name, '...')
191+
await getPool().query(sql)
192+
await getPool().query(
193+
'INSERT INTO schema_migrations (version, name) VALUES ($1, $2)',
194+
[m.version, m.name]
195+
)
196+
console.log('Applied', m.version, m.name)
197+
}
17198
console.log('Migrations completed successfully.')
18199
} catch (err) {
19200
console.error('Migration failed:', err)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- Migration: 001_initial_schema (down)
2+
-- Rollback: Drop tables in reverse dependency order to respect foreign keys.
3+
4+
DROP INDEX IF EXISTS idx_notification_preferences_user;
5+
DROP TABLE IF EXISTS notification_preferences;
6+
7+
DROP INDEX IF EXISTS idx_analytics_portfolio_time;
8+
DROP TABLE IF EXISTS analytics_snapshots;
9+
10+
DROP INDEX IF EXISTS idx_rebalance_events_timestamp;
11+
DROP INDEX IF EXISTS idx_rebalance_events_portfolio;
12+
DROP TABLE IF EXISTS rebalance_events;
13+
14+
DROP INDEX IF EXISTS idx_portfolios_user;
15+
DROP TABLE IF EXISTS portfolios;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
-- Migration: 001_initial_schema (up)
2+
-- Description: Initial schema for portfolios, rebalance_events, analytics_snapshots, notification_preferences.
3+
-- Rollback: See 001_initial_schema.down.sql
4+
5+
CREATE TABLE IF NOT EXISTS portfolios (
6+
id VARCHAR(64) PRIMARY KEY,
7+
user_address VARCHAR(256) NOT NULL,
8+
allocations JSONB NOT NULL DEFAULT '{}',
9+
threshold INTEGER NOT NULL,
10+
balances JSONB NOT NULL DEFAULT '{}',
11+
total_value NUMERIC NOT NULL DEFAULT 0,
12+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
13+
last_rebalance TIMESTAMPTZ NOT NULL DEFAULT NOW()
14+
);
15+
16+
CREATE INDEX IF NOT EXISTS idx_portfolios_user ON portfolios(user_address);
17+
18+
CREATE TABLE IF NOT EXISTS rebalance_events (
19+
id VARCHAR(64) PRIMARY KEY,
20+
portfolio_id VARCHAR(64) NOT NULL REFERENCES portfolios(id) ON DELETE CASCADE,
21+
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
22+
trigger VARCHAR(512) NOT NULL,
23+
trades INTEGER NOT NULL DEFAULT 0,
24+
gas_used VARCHAR(64) NOT NULL DEFAULT '',
25+
status VARCHAR(32) NOT NULL CHECK (status IN ('completed', 'failed', 'pending')),
26+
is_automatic BOOLEAN NOT NULL DEFAULT FALSE,
27+
risk_alerts JSONB,
28+
error TEXT,
29+
details JSONB
30+
);
31+
32+
CREATE INDEX IF NOT EXISTS idx_rebalance_events_portfolio ON rebalance_events(portfolio_id);
33+
CREATE INDEX IF NOT EXISTS idx_rebalance_events_timestamp ON rebalance_events(timestamp DESC);
34+
35+
CREATE TABLE IF NOT EXISTS analytics_snapshots (
36+
id SERIAL PRIMARY KEY,
37+
portfolio_id VARCHAR(64) NOT NULL,
38+
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
39+
total_value NUMERIC NOT NULL,
40+
allocations JSONB NOT NULL DEFAULT '{}',
41+
balances JSONB NOT NULL DEFAULT '{}'
42+
);
43+
44+
CREATE INDEX IF NOT EXISTS idx_analytics_portfolio_time ON analytics_snapshots(portfolio_id, timestamp DESC);
45+
46+
CREATE TABLE IF NOT EXISTS notification_preferences (
47+
user_id VARCHAR(256) PRIMARY KEY,
48+
email_enabled BOOLEAN NOT NULL DEFAULT FALSE,
49+
email_address VARCHAR(512),
50+
webhook_enabled BOOLEAN NOT NULL DEFAULT FALSE,
51+
webhook_url VARCHAR(1024),
52+
event_rebalance BOOLEAN NOT NULL DEFAULT TRUE,
53+
event_circuit_breaker BOOLEAN NOT NULL DEFAULT TRUE,
54+
event_price_movement BOOLEAN NOT NULL DEFAULT TRUE,
55+
event_risk_change BOOLEAN NOT NULL DEFAULT TRUE,
56+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
57+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
58+
);
59+
60+
CREATE INDEX IF NOT EXISTS idx_notification_preferences_user ON notification_preferences(user_id);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- Migration: 002_seed_demo_data (down)
2+
-- Rollback: Remove demo portfolio and its rebalance events.
3+
4+
DELETE FROM rebalance_events WHERE portfolio_id = 'demo-portfolio-1';
5+
DELETE FROM portfolios WHERE id = 'demo-portfolio-1';
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
-- Migration: 002_seed_demo_data (up)
2+
-- Description: Optional demo/seed data for development and staging. Safe to skip in production.
3+
-- Rollback: See 002_seed_demo_data.down.sql
4+
-- Idempotent: Uses ON CONFLICT DO NOTHING so re-run is safe.
5+
6+
INSERT INTO portfolios (id, user_address, allocations, threshold, balances, total_value, created_at, last_rebalance)
7+
VALUES (
8+
'demo-portfolio-1',
9+
'DEMO-USER',
10+
'{"XLM": 40, "BTC": 30, "ETH": 20, "USDC": 10}',
11+
5,
12+
'{"XLM": 11173.18, "BTC": 0.02697, "ETH": 0.68257, "USDC": 1000}',
13+
10000,
14+
NOW(),
15+
NOW()
16+
)
17+
ON CONFLICT (id) DO NOTHING;
18+
19+
INSERT INTO rebalance_events (id, portfolio_id, timestamp, trigger, trades, gas_used, status, is_automatic, risk_alerts, error, details)
20+
VALUES
21+
(
22+
'demo-evt-1',
23+
'demo-portfolio-1',
24+
NOW() - INTERVAL '2 hours',
25+
'Threshold exceeded (8.2%)',
26+
3,
27+
'0.0234 XLM',
28+
'completed',
29+
FALSE,
30+
NULL,
31+
NULL,
32+
'{"fromAsset": "XLM", "toAsset": "ETH", "amount": 1200, "reason": "Portfolio allocation drift exceeded rebalancing threshold", "riskLevel": "medium", "priceDirection": "down", "performanceImpact": "neutral"}'::jsonb
33+
),
34+
(
35+
'demo-evt-2',
36+
'demo-portfolio-1',
37+
NOW() - INTERVAL '12 hours',
38+
'Automatic Rebalancing',
39+
2,
40+
'0.0156 XLM',
41+
'completed',
42+
TRUE,
43+
NULL,
44+
NULL,
45+
'{"reason": "Automated scheduled rebalancing executed", "riskLevel": "low", "priceDirection": "up", "performanceImpact": "positive"}'::jsonb
46+
),
47+
(
48+
'demo-evt-3',
49+
'demo-portfolio-1',
50+
NOW() - INTERVAL '3 days',
51+
'Volatility circuit breaker',
52+
1,
53+
'0.0089 XLM',
54+
'completed',
55+
TRUE,
56+
NULL,
57+
NULL,
58+
'{"reason": "High market volatility detected, protective rebalance executed", "volatilityDetected": true, "riskLevel": "high", "priceDirection": "down", "performanceImpact": "negative"}'::jsonb
59+
)
60+
ON CONFLICT (id) DO NOTHING;

0 commit comments

Comments
 (0)