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
50 changes: 50 additions & 0 deletions db/QUERY_OPTIMIZATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,53 @@ If `idx_scan` stays at 0 after a representative window, or write latency
regresses on `usage_metrics`, drop the offending index (`DROP INDEX
CONCURRENTLY …`) — it is safe to remove because no application code depends on an
index existing.

## 7. Index audit tool (issue #1798)

A static analysis tool scans backend source files for SQL query patterns and
checks them against the indexes declared in migration files.

### Running the audit

```bash
node scripts/db-index-audit.js # human-readable report
node scripts/db-index-audit.js --json # JSON output for CI
```

The tool:
1. Parses `CREATE INDEX` statements from `db/migrations/*.sql` and
`backend/migrations/*.sql` (excluding `-- @down` sections).
2. Scans `backend/services/`, `backend/graphql/`, and `backend/notification/`
for `FROM <table> WHERE <col>`, `WHERE <alias>.<col>`, `ORDER BY <col>`,
and composite `WHERE col1 = $N AND col2 = $N` patterns.
3. Resolves single-letter table aliases (`s` → `subscriptions`, `t` →
`transactions`, etc.).
4. Treats `id` column lookups as always covered (primary key).
5. Reports covered patterns and gaps (query patterns with no matching index).

Exit code: 0 = no gaps, 1 = gaps found, 2 = usage error.

### Audit results (migration 009)

The initial audit found 10 query patterns with no matching index. Migration
`009_index_audit_hot_queries.sql` adds indexes for all of them:

| Table | Column(s) | Query source | Index |
|---|---|---|---|
| users | address | postgres.ts:382 | idx_users_address |
| users | email | postgres.ts:391 | idx_users_email |
| merchants | merchant_address | postgres.ts:470 | idx_merchants_merchant_address |
| merchants | status | postgres.ts:481 | idx_merchants_status |
| loyalty | subscriber_id | postgres.ts:570 | idx_loyalty_subscriber_id |
| loyalty | points DESC | postgres.ts:582 | idx_loyalty_points_desc |
| subscriptions | created_at DESC | resolvers.ts:71 | idx_subscriptions_created_at |
| user_devices | user_id | NotificationServiceImpl.ts | idx_user_devices_user_id |
| usage_alerts | created_at DESC | usageAlertsController.ts:144 | idx_usage_alerts_created_at |
| usage_alert_configs | subscription_id | usageAlertsController.ts | idx_usage_alert_configs_subscription |

After applying migration 009, re-run the audit to confirm zero gaps:

```bash
node scripts/db-index-audit.js
# Expected: Gaps (no index): 0
```
147 changes: 147 additions & 0 deletions db/migrations/009_index_audit_hot_queries.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
-- ── Migration 009: Index audit — hot query coverage gaps (issue #1798) ────────
--
-- Adds indexes identified by `node scripts/db-index-audit.js` for query patterns
-- in backend/services, backend/graphql, and backend/notification that had no
-- matching index.
--
-- All indexes are created CONCURRENTLY and IF NOT EXISTS so this migration is
-- safe to run online against a populated database without blocking writes.
--
-- Run with: psql $DATABASE_URL -f 009_index_audit_hot_queries.sql
--
-- NOTE: CREATE INDEX CONCURRENTLY cannot run inside a transaction block. Do not
-- wrap this file in BEGIN/COMMIT.

-- ── users ────────────────────────────────────────────────────────────────────
--
-- Query (backend/services/repositories/postgres.ts:382):
-- SELECT ... FROM users WHERE address = $1 LIMIT 1
--
-- Wallet-address lookup for login / identity resolution. No existing index.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_address
ON users (address);

-- Query (backend/services/repositories/postgres.ts:391):
-- SELECT ... FROM users WHERE email = $1 LIMIT 1
--
-- Email lookup for passwordless auth / notification routing. No existing index.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email
ON users (email);

-- ── merchants ────────────────────────────────────────────────────────────────
--
-- Query (backend/services/repositories/postgres.ts:470):
-- SELECT ... FROM merchants WHERE merchant_address = $1 LIMIT 1
--
-- Merchant on-chain address lookup. No existing index.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_merchants_merchant_address
ON merchants (merchant_address);

-- Query (backend/services/repositories/postgres.ts:481):
-- SELECT ... FROM merchants WHERE status = $1
--
-- Status filter for admin / verification dashboards. No existing index.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_merchants_status
ON merchants (status);

-- ── loyalty ──────────────────────────────────────────────────────────────────
--
-- Query (backend/services/repositories/postgres.ts:570):
-- SELECT ... FROM loyalty WHERE subscriber_id = $1 LIMIT 1
--
-- Per-subscriber loyalty lookup. No existing index.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_loyalty_subscriber_id
ON loyalty (subscriber_id);

-- Query (backend/services/repositories/postgres.ts:582):
-- SELECT ... FROM loyalty ORDER BY points DESC LIMIT $1
--
-- Leaderboard top-N query. A DESC index on points avoids a sort.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_loyalty_points_desc
ON loyalty (points DESC);

-- ── subscriptions ──────────────────────────────────────────────────────────────
--
-- Query (backend/graphql/resolvers.ts:71):
-- SELECT ... FROM subscriptions s
-- WHERE s.user_id = $1 [AND created_at < :sortValue ...]
-- ORDER BY created_at DESC, id LIMIT $n
--
-- Keyset pagination on created_at. The existing idx_subscriptions_status has
-- created_at as the trailing column (behind status), so it cannot serve an
-- ORDER BY created_at without a status filter.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_subscriptions_created_at
ON subscriptions (created_at DESC);

-- ── user_devices ──────────────────────────────────────────────────────────────
--
-- Query (backend/notification/NotificationServiceImpl.ts):
-- SELECT ... FROM user_devices WHERE user_id = $1
--
-- Per-user device lookup for push notification targeting. No existing index.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_devices_user_id
ON user_devices (user_id);

-- ── usage_alerts ───────────────────────────────────────────────────────────────
--
-- Query (backend/notification/controller/usageAlertsController.ts:144):
-- SELECT ... FROM usage_alerts
-- WHERE subscription_id = $1 AND created_at > $2
-- ORDER BY created_at DESC LIMIT 100
--
-- The existing idx_alerts_created is a partial index (WHERE cooldown_until > …)
-- that does not cover all created_at range scans. A non-partial index on
-- created_at DESC serves the general range + ORDER BY.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_alerts_created_at
ON usage_alerts (created_at DESC);

-- ── usage_alert_configs ────────────────────────────────────────────────────────
--
-- Query (backend/notification/controller/usageAlertsController.ts):
-- SELECT ... FROM usage_alert_configs WHERE subscription_id = $1
--
-- The table has a UNIQUE(subscription_id) constraint which creates an implicit
-- index, but we add an explicit one for clarity and to support future
-- multi-column extensions.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_alert_configs_subscription
ON usage_alert_configs (subscription_id);

-- ── Analyse updated tables ───────────────────────────────────────────────────
ANALYZE users;
ANALYZE merchants;
ANALYZE loyalty;
ANALYZE subscriptions;
ANALYZE user_devices;
ANALYZE usage_alerts;
ANALYZE usage_alert_configs;

-- ── Write-overhead / maintenance notes ──────────────────────────────────────
--
-- * idx_users_address / idx_users_email — users is low-write (registrations
-- and profile updates). Negligible overhead.
-- * idx_merchants_merchant_address / status — merchants is low-write. Negligible.
-- * idx_loyalty_subscriber_id / points_desc — loyalty is updated on each
-- points accrual. Two narrow indexes; monitor write latency.
-- * idx_subscriptions_created_at — subscriptions is moderate-write
-- (status changes, billing updates). One extra index write per update.
-- * idx_user_devices_user_id — user_devices is low-write (device
-- registration). Negligible.
-- * idx_usage_alerts_created_at — usage_alerts is append-mostly.
-- One extra index write per insert.
-- * idx_usage_alert_configs_subscription — Low-write config table.
-- Negligible. (Redundant with UNIQUE constraint index but explicit for
-- future composite extensions; safe to drop if storage is a concern.)
--
-- Run `node scripts/db-index-audit.js` after applying to verify zero gaps.

-- @down
DROP INDEX CONCURRENTLY IF EXISTS idx_usage_alert_configs_subscription;
DROP INDEX CONCURRENTLY IF EXISTS idx_usage_alerts_created_at;
DROP INDEX CONCURRENTLY IF EXISTS idx_user_devices_user_id;
DROP INDEX CONCURRENTLY IF EXISTS idx_subscriptions_created_at;
DROP INDEX CONCURRENTLY IF EXISTS idx_loyalty_points_desc;
DROP INDEX CONCURRENTLY IF EXISTS idx_loyalty_subscriber_id;
DROP INDEX CONCURRENTLY IF EXISTS idx_merchants_status;
DROP INDEX CONCURRENTLY IF EXISTS idx_merchants_merchant_address;
DROP INDEX CONCURRENTLY IF EXISTS idx_users_email;
DROP INDEX CONCURRENTLY IF EXISTS idx_users_address;
197 changes: 197 additions & 0 deletions scripts/__tests__/db-index-audit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* Tests for scripts/db-index-audit.js (issue #1798).
*
* Tests the pure analysis layer: index parsing, query pattern extraction,
* coverage detection, and gap reporting.
*/

'use strict';

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

const {
extractQueryPatterns,
isCoveredByIndex,
loadIndexes,
parseCreateIndex,
run,
deduplicatePatterns,
} = require('../db-index-audit');

let tmpDir;
let migrationsDir;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'index-audit-'));
migrationsDir = path.join(tmpDir, 'migrations');
fs.mkdirSync(migrationsDir, { recursive: true });
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

function writeMigration(name, sql) {
fs.writeFileSync(path.join(migrationsDir, name), sql, 'utf8');
}

describe('db-index-audit :: parseCreateIndex', () => {
it('parses a basic CREATE INDEX', () => {
const sql = 'CREATE INDEX idx_foo ON users (email);';
const idx = parseCreateIndex(sql);
expect(idx).toEqual({
name: 'idx_foo',
table: 'users',
columns: ['email'],
partial: null,
});
});

it('parses CREATE INDEX CONCURRENTLY IF NOT EXISTS with composite columns', () => {
const sql = "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_bar ON subscriptions (user_id, status) WHERE status = 'active';";
const idx = parseCreateIndex(sql);
expect(idx.name).toBe('idx_bar');
expect(idx.table).toBe('subscriptions');
expect(idx.columns).toEqual(['user_id', 'status']);
expect(idx.partial).toContain("status = 'active'");
});

it('parses a UNIQUE index', () => {
const sql = 'CREATE UNIQUE INDEX idx_uq ON users (email);';
const idx = parseCreateIndex(sql);
expect(idx.name).toBe('idx_uq');
expect(idx.table).toBe('users');
});

it('returns null for non-index statements', () => {
expect(parseCreateIndex('SELECT * FROM users;')).toBeNull();
expect(parseCreateIndex('CREATE TABLE foo (id INT);')).toBeNull();
});
});

describe('db-index-audit :: loadIndexes', () => {
it('loads indexes from migration files', () => {
writeMigration('001_init.sql', `
CREATE INDEX IF NOT EXISTS idx_a ON users (email);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_b ON subscriptions (user_id, status);
`);
const indexes = loadIndexes([migrationsDir]);
expect(indexes).toHaveLength(2);
expect(indexes[0].name).toBe('idx_a');
expect(indexes[1].name).toBe('idx_b');
});

it('ignores indexes in -- @down sections', () => {
writeMigration('001_init.sql', `
CREATE INDEX IF NOT EXISTS idx_up ON users (email);

-- @down
DROP INDEX IF EXISTS idx_up;
`);
const indexes = loadIndexes([migrationsDir]);
expect(indexes).toHaveLength(1);
expect(indexes[0].name).toBe('idx_up');
});

it('returns empty for non-existent directory', () => {
expect(loadIndexes(['/nonexistent'])).toEqual([]);
});
});

describe('db-index-audit :: isCoveredByIndex', () => {
const indexes = [
{ name: 'idx_a', table: 'users', columns: ['email'], partial: null },
{ name: 'idx_b', table: 'subscriptions', columns: ['user_id', 'status'], partial: null },
];

it('covers a single-column query when index first column matches', () => {
expect(isCoveredByIndex({ table: 'users', columns: ['email'], type: 'WHERE' }, indexes)).toBe(true);
});

it('covers a composite query when index is a prefix match', () => {
expect(isCoveredByIndex({ table: 'subscriptions', columns: ['user_id', 'status'], type: 'WHERE' }, indexes)).toBe(true);
});

it('does not cover when no index exists for the table', () => {
expect(isCoveredByIndex({ table: 'orders', columns: ['user_id'], type: 'WHERE' }, indexes)).toBe(false);
});

it('always covers id lookups (primary key)', () => {
expect(isCoveredByIndex({ table: 'users', columns: ['id'], type: 'WHERE' }, [])).toBe(true);
});

it('does not cover when the column is not the leading column', () => {
expect(isCoveredByIndex({ table: 'subscriptions', columns: ['status'], type: 'WHERE' }, indexes)).toBe(false);
});
});

describe('db-index-audit :: extractQueryPatterns', () => {
it('extracts WHERE column = $N patterns', () => {
const content = "`SELECT * FROM users WHERE email = $1`";
const patterns = extractQueryPatterns(content, 'test.ts');
expect(patterns).toContainEqual({
table: 'users',
columns: ['email'],
type: 'WHERE',
file: 'test.ts',
});
});

it('extracts FROM table WHERE column patterns', () => {
const content = "`SELECT * FROM subscriptions WHERE user_id = $1 ORDER BY created_at DESC`";
const patterns = extractQueryPatterns(content, 'test.ts');
const wherePattern = patterns.find((p) => p.type === 'WHERE');
expect(wherePattern.table).toBe('subscriptions');
expect(wherePattern.columns).toEqual(['user_id']);
});
});

describe('db-index-audit :: deduplicatePatterns', () => {
it('removes duplicate patterns', () => {
const patterns = [
{ table: 'users', columns: ['email'], type: 'WHERE', file: 'a.ts' },
{ table: 'users', columns: ['email'], type: 'WHERE', file: 'b.ts' },
{ table: 'users', columns: ['id'], type: 'WHERE', file: 'c.ts' },
];
const deduped = deduplicatePatterns(patterns);
expect(deduped).toHaveLength(2);
});
});

describe('db-index-audit :: run', () => {
it('exits 0 when no gaps (all covered)', () => {
writeMigration('001.sql', 'CREATE INDEX IF NOT EXISTS idx_email ON users (email);');
const srcDir = path.join(tmpDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, 'repo.ts'), "`SELECT * FROM users WHERE email = $1`", 'utf8');

const code = run(['--migrations-dir', migrationsDir, '--src-dir', srcDir]);
expect(code).toBe(0);
});

it('exits 1 when gaps are found', () => {
const srcDir = path.join(tmpDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, 'repo.ts'), "`SELECT * FROM users WHERE email = $1`", 'utf8');

const code = run(['--migrations-dir', migrationsDir, '--src-dir', srcDir]);
expect(code).toBe(1);
});

it('outputs JSON when --json is passed', () => {
writeMigration('001.sql', 'CREATE INDEX IF NOT EXISTS idx_email ON users (email);');
const srcDir = path.join(tmpDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, 'repo.ts'), "`SELECT * FROM users WHERE email = $1`", 'utf8');

const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
run(['--json', '--migrations-dir', migrationsDir, '--src-dir', srcDir]);
const jsonOutput = logSpy.mock.calls.map((c) => c[0]).join('\n');
const parsed = JSON.parse(jsonOutput);
expect(parsed.summary).toBeDefined();
expect(parsed.summary.indexesFound).toBe(1);
logSpy.mockRestore();
});
});
Loading
Loading