Skip to content
Merged
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
9 changes: 9 additions & 0 deletions backend/migrations/003_plans_cache_columns.sql
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,12 @@ ALTER TABLE plans ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAU
ALTER TABLE plans ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();

CREATE INDEX IF NOT EXISTS idx_plans_is_active ON plans (is_active) WHERE is_active = true;

-- @down
DROP INDEX IF EXISTS idx_plans_is_active;
ALTER TABLE plans DROP COLUMN IF EXISTS updated_at;
ALTER TABLE plans DROP COLUMN IF EXISTS created_at;
ALTER TABLE plans DROP COLUMN IF EXISTS is_active;
ALTER TABLE plans DROP COLUMN IF EXISTS metadata;
ALTER TABLE plans DROP COLUMN IF EXISTS limits;
ALTER TABLE plans DROP COLUMN IF EXISTS features;
6 changes: 6 additions & 0 deletions backend/migrations/004_theme_storage.sql
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,9 @@ CREATE TRIGGER trg_single_active_theme
FOR EACH ROW
WHEN (NEW.is_active = true)
EXECUTE FUNCTION ensure_single_active_theme();

-- @down
DROP TRIGGER IF EXISTS trg_single_active_theme ON merchant_themes;
DROP FUNCTION IF EXISTS ensure_single_active_theme();
DROP TABLE IF EXISTS theme_variant_pairs;
DROP TABLE IF EXISTS merchant_themes;
104 changes: 104 additions & 0 deletions db/MIGRATION_ROLLBACK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Migration Rollback Support

## Overview

All SubTrackr migrations support forward (`up`) and reverse (`down`) operations.
The migration runner (`scripts/db-migrate-run.js`) maintains a `schema_migrations`
ledger table so that:

- `up` is idempotent — re-running applies only pending migrations.
- `down` rolls back the most recent migration(s) or all reversible migrations.
- Each migration runs in its own transaction, so a failure leaves the ledger
consistent with the database.
- A migration is only marked applied when its down section exists, enforcing
rollback parity.

## Rollback path format

Every migration must include a rollback path, provided in one of two ways:

### 1. Inline `-- @down` section

```sql
CREATE TABLE widgets (id SERIAL PRIMARY KEY);

-- @down
DROP TABLE widgets;
```

Everything after the `-- @down` (or `-- down`) marker is the rollback section.

### 2. Paired `.down.sql` file

```
005_widgets.sql # forward migration
005_widgets.down.sql # rollback migration
```

## Commands

```bash
# Apply all pending migrations
node scripts/db-migrate-run.js up

# Dry-run (show what would be applied without touching the database)
node scripts/db-migrate-run.js up --dry-run

# Roll back the most recent migration
node scripts/db-migrate-run.js down

# Roll back the last 3 migrations
node scripts/db-migrate-run.js down --steps 3

# Roll back all reversible migrations
node scripts/db-migrate-run.js down --all

# Show migration status (applied vs pending, rollback availability)
node scripts/db-migrate-run.js status
```

## Dry-run analysis (no database required)

```bash
# Analyse migrations for safety issues without a database connection
node scripts/db-migrate-dryrun.js

# JSON output for CI integration
node scripts/db-migrate-dryrun.js --json
```

The dry-run tool reports:
- Destructive changes (DROP / TRUNCATE)
- Statements requiring ACCESS EXCLUSIVE locks
- NOT NULL columns added without DEFAULT
- ALTER statements without lock_timeout guards
- Migrations with no rollback path

## Safety guarantees

1. **Transaction per migration**: Each migration (up or down) runs in a
`BEGIN/COMMIT` block. A failure rolls back the entire migration.

2. **CONCURRENTLY note**: `CREATE INDEX CONCURRENTLY` and `DROP INDEX
CONCURRENTLY` cannot run inside a transaction. The runner wraps each
migration in a transaction, so migrations using `CONCURRENTLY` should
structure their down section to match. The ledger insert is in the same
transaction as the migration body.

3. **Ledger consistency**: The `schema_migrations` table records the version
and checksum of each applied migration. `down` deletes the ledger row in
the same transaction as the rollback SQL.

4. **Irreversible migrations are flagged**: If a migration has no down path,
`up` warns and `down` skips it with a message listing the irreversible
versions.

## Migration directories

| Directory | Contents |
|---|---|
| `db/migrations/` | Core schema migrations (indexes, views, tables) |
| `backend/migrations/` | Backend-specific migrations (cache columns, themes) |

Default migration directory for the runner is `backend/migrations/`. Use
`--migrations-dir db/migrations` to target the core schema directory.
8 changes: 8 additions & 0 deletions db/migrations/001_base_indexes.sql
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,11 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_transactions_status
-- ── Analyse updated tables ───────────────────────────────────────────────────
ANALYZE subscriptions;
ANALYZE transactions;

-- @down
DROP INDEX CONCURRENTLY IF EXISTS idx_transactions_status;
DROP INDEX CONCURRENTLY IF EXISTS idx_transactions_user_ts;
DROP INDEX CONCURRENTLY IF EXISTS idx_transactions_subscription_ts;
DROP INDEX CONCURRENTLY IF EXISTS idx_subscriptions_status;
DROP INDEX CONCURRENTLY IF EXISTS idx_subscriptions_next_billing;
DROP INDEX CONCURRENTLY IF EXISTS idx_subscriptions_user_status;
6 changes: 6 additions & 0 deletions db/migrations/002_materialized_views.sql
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,9 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_csm_cohort_month

-- No DDL needed — refreshed_at is already a computed column in each view.
-- The backend monitoring service reads it via the view_freshness_metric.

-- @down
DROP MATERIALIZED VIEW IF EXISTS churn_summary_mv;
DROP MATERIALIZED VIEW IF EXISTS monthly_revenue_mv;
DROP MATERIALIZED VIEW IF EXISTS subscriber_balance_mv;
DROP MATERIALIZED VIEW IF EXISTS active_subscriptions_summary;
5 changes: 5 additions & 0 deletions db/migrations/003_cqrs_materialized_views.sql
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,8 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_ltv_mv_month
COMMENT ON MATERIALIZED VIEW mrr_mv IS 'MRR view - refresh every 5 minutes';
COMMENT ON MATERIALIZED VIEW cohort_retention_mv IS 'Cohort retention view - refresh every 1 hour';
COMMENT ON MATERIALIZED VIEW ltv_mv IS 'LTV view - refresh every 24 hours';

-- @down
DROP MATERIALIZED VIEW IF EXISTS ltv_mv;
DROP MATERIALIZED VIEW IF EXISTS cohort_retention_mv;
DROP MATERIALIZED VIEW IF EXISTS mrr_mv;
22 changes: 22 additions & 0 deletions db/migrations/003_encrypted_columns.sql
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,25 @@ CREATE INDEX IF NOT EXISTS idx_encryption_audit_key_id ON encryption_audit_log(k
CREATE INDEX IF NOT EXISTS idx_encryption_audit_performed_at ON encryption_audit_log(performed_at);

COMMIT;

-- @down
BEGIN;
DROP INDEX IF EXISTS idx_encryption_audit_performed_at;
DROP INDEX IF EXISTS idx_encryption_audit_key_id;
DROP TABLE IF EXISTS encryption_audit_log;
DROP TABLE IF EXISTS encryption_keys;
ALTER TABLE IF EXISTS subscriptions
DROP COLUMN IF EXISTS subscriber_phone_encrypted,
DROP COLUMN IF EXISTS subscriber_name_encrypted,
DROP COLUMN IF EXISTS subscriber_email_encrypted;
ALTER TABLE IF EXISTS merchant_records
DROP COLUMN IF EXISTS contact_phone_encrypted,
DROP COLUMN IF EXISTS contact_email_encrypted,
DROP COLUMN IF EXISTS business_address_encrypted,
DROP COLUMN IF EXISTS business_name_encrypted;
ALTER TABLE IF EXISTS users
DROP COLUMN IF EXISTS address_encrypted,
DROP COLUMN IF EXISTS phone_encrypted,
DROP COLUMN IF EXISTS name_encrypted,
DROP COLUMN IF EXISTS email_encrypted;
COMMIT;
15 changes: 15 additions & 0 deletions db/migrations/004_api_key_rotation.sql
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,18 @@ CREATE TABLE IF NOT EXISTS api_key_rotation_policies (
);

COMMIT;

-- @down
BEGIN;
DROP TABLE IF EXISTS api_key_rotation_policies;
DROP TABLE IF EXISTS api_key_rotation_history;
ALTER TABLE IF EXISTS api_keys
DROP COLUMN IF EXISTS status,
DROP COLUMN IF EXISTS last_rotated_by,
DROP COLUMN IF EXISTS rotation_count,
DROP COLUMN IF EXISTS grace_period_end,
DROP COLUMN IF EXISTS expires_at,
DROP COLUMN IF EXISTS rotated_at,
DROP COLUMN IF EXISTS key_hash,
DROP COLUMN IF EXISTS key_prefix;
COMMIT;
5 changes: 5 additions & 0 deletions db/migrations/004_audit_trail.sql
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,8 @@ CREATE TABLE IF NOT EXISTS audit_quarantine (
reason TEXT NOT NULL,
details JSONB DEFAULT '{}'::jsonb
);

-- @down
DROP TABLE IF EXISTS audit_quarantine;
DROP TABLE IF EXISTS audit_anchors;
DROP TABLE IF EXISTS audit_events;
6 changes: 6 additions & 0 deletions db/migrations/005_merchant_gateway_config.sql
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,9 @@ CREATE INDEX IF NOT EXISTS idx_gateway_failed_status ON gateway_failed_attempts(
CREATE INDEX IF NOT EXISTS idx_gateway_failed_next_retry ON gateway_failed_attempts(next_retry_at);

COMMIT;

-- @down
BEGIN;
DROP TABLE IF EXISTS gateway_failed_attempts;
DROP TABLE IF EXISTS merchant_gateway_configs;
COMMIT;
6 changes: 6 additions & 0 deletions db/migrations/006_usage_alerts.sql
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,9 @@ ANALYZE usage_metrics;
ANALYZE usage_alert_configs;
ANALYZE usage_alerts;
ANALYZE overage_approvals;

-- @down
DROP TABLE IF EXISTS overage_approvals;
DROP TABLE IF EXISTS usage_alerts;
DROP TABLE IF EXISTS usage_alert_configs;
DROP TABLE IF EXISTS usage_metrics;
5 changes: 5 additions & 0 deletions db/migrations/007_composite_query_indexes.sql
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,8 @@ ANALYZE usage_metrics;
--
-- None of these indexes duplicate an existing one; verify with the audit query
-- in db/QUERY_OPTIMIZATION.md before and after applying.

-- @down
DROP INDEX CONCURRENTLY IF EXISTS idx_usage_metrics_subscription_meter;
DROP INDEX CONCURRENTLY IF EXISTS idx_payment_methods_user_id;
DROP INDEX CONCURRENTLY IF EXISTS idx_alerts_subscription_created;
10 changes: 10 additions & 0 deletions db/migrations/008_communication_preferences.sql
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,13 @@ ANALYZE communication_preferences;
ANALYZE communication_preference_events;
ANALYZE voice_call_reminders;
ANALYZE sms_inbound_messages;

-- @down
DROP INDEX CONCURRENTLY IF EXISTS idx_sms_inbound_user;
DROP INDEX CONCURRENTLY IF EXISTS idx_voice_reminders_subscription;
DROP INDEX CONCURRENTLY IF EXISTS idx_voice_reminders_user;
DROP INDEX CONCURRENTLY IF EXISTS idx_comm_pref_events_user;
DROP TABLE IF EXISTS sms_inbound_messages;
DROP TABLE IF EXISTS voice_call_reminders;
DROP TABLE IF EXISTS communication_preference_events;
DROP TABLE IF EXISTS communication_preferences;
74 changes: 74 additions & 0 deletions scripts/__tests__/migration-rollback-parity.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Tests for migration rollback parity (issue #1796).
*
* Verifies that every SQL migration in db/migrations/ and backend/migrations/
* has a rollback path (inline -- @down section or paired .down.sql file) so
* that `node scripts/db-migrate-run.js down` can revert any applied migration.
*/

'use strict';

const fs = require('fs');
const path = require('path');

const { loadMigrations, splitSections } = require('../db-migrate-run');
const { hasDownMarker } = require('../db-migrate-dryrun');

const MIGRATION_DIRS = [
path.join(__dirname, '../../db/migrations'),
path.join(__dirname, '../../backend/migrations'),
];

describe('migration rollback parity (issue #1796)', () => {
for (const dir of MIGRATION_DIRS) {
const label = path.relative(path.join(__dirname, '../..'), dir);

describe(`directory: ${label}`, () => {
const migrations = fs.existsSync(dir)
? fs
.readdirSync(dir)
.filter((f) => f.endsWith('.sql') && !f.endsWith('.down.sql'))
.sort()
: [];

it('has at least one migration', () => {
expect(migrations.length).toBeGreaterThan(0);
});

for (const file of migrations) {
it(`${file} has a rollback path`, () => {
const sql = fs.readFileSync(path.join(dir, file), 'utf8');
const inlineDown = splitSections(sql).down.trim();
const downFile = path.join(dir, file.replace(/\.sql$/, '.down.sql'));
const pairedDown = fs.existsSync(downFile)
? fs.readFileSync(downFile, 'utf8').trim()
: '';

expect(inlineDown.length > 0 || pairedDown.length > 0).toBe(true);
});

it(`${file} down section is non-trivial (>= 10 chars)`, () => {
const sql = fs.readFileSync(path.join(dir, file), 'utf8');
const inlineDown = splitSections(sql).down.trim();
const downFile = path.join(dir, file.replace(/\.sql$/, '.down.sql'));
const pairedDown = fs.existsSync(downFile)
? fs.readFileSync(downFile, 'utf8').trim()
: '';

const down = inlineDown || pairedDown;
expect(down.length).toBeGreaterThanOrEqual(10);
});
}
});
}

it('db-migrate-run loadMigrations finds down sections for all migrations', () => {
for (const dir of MIGRATION_DIRS) {
if (!fs.existsSync(dir)) continue;
const migrations = loadMigrations(dir);
for (const migration of migrations) {
expect(migration.down.length).toBeGreaterThan(0);
}
}
});
});
Loading