From 3f0594a1135555a387fe7ad8773f21ec21cb1ab8 Mon Sep 17 00:00:00 2001 From: Mochi674 Date: Tue, 29 Sep 2026 19:21:26 +0100 Subject: [PATCH 1/5] fix: Add database indexes for watcher and retention queries (#599) --- src/runtime/database/sql-database-adapter.ts | 24 +++++ .../database/sql-database-adapter.test.ts | 38 ++++++++ tests/runtime/sql-adapter-cleanup.test.ts | 88 +++++++++++++------ .../runtime/sql-adapter-watcher-tasks.test.ts | 24 ++++- 4 files changed, 146 insertions(+), 28 deletions(-) diff --git a/src/runtime/database/sql-database-adapter.ts b/src/runtime/database/sql-database-adapter.ts index 1701910a..7ee91a2c 100644 --- a/src/runtime/database/sql-database-adapter.ts +++ b/src/runtime/database/sql-database-adapter.ts @@ -287,6 +287,18 @@ export class SqlDatabaseAdapter implements DatabaseAdapter { processed_at TEXT, created_at TEXT NOT NULL ); + + CREATE INDEX IF NOT EXISTS idx_watcher_tasks_status_created_at + ON watcher_tasks (status, created_at); + + CREATE INDEX IF NOT EXISTS idx_webhook_events_status_created_at + ON webhook_events (status, created_at); + + CREATE INDEX IF NOT EXISTS idx_auth_challenges_expires_at + ON auth_challenges (expires_at); + + CREATE INDEX IF NOT EXISTS idx_interactive_transactions_status_created_at + ON interactive_transactions (status, created_at); `); const idempotencyColumns = this.sqlite .prepare('PRAGMA table_info(idempotency_keys)') @@ -361,6 +373,18 @@ export class SqlDatabaseAdapter implements DatabaseAdapter { created_at TIMESTAMPTZ NOT NULL ); `); + await this.postgres.query( + 'CREATE INDEX IF NOT EXISTS idx_watcher_tasks_status_created_at ON watcher_tasks (status, created_at)', + ); + await this.postgres.query( + 'CREATE INDEX IF NOT EXISTS idx_webhook_events_status_created_at ON webhook_events (status, created_at)', + ); + await this.postgres.query( + 'CREATE INDEX IF NOT EXISTS idx_auth_challenges_expires_at ON auth_challenges (expires_at)', + ); + await this.postgres.query( + 'CREATE INDEX IF NOT EXISTS idx_interactive_transactions_status_created_at ON interactive_transactions (status, created_at)', + ); return; } diff --git a/tests/runtime/database/sql-database-adapter.test.ts b/tests/runtime/database/sql-database-adapter.test.ts index 725148f6..2e3cf2a7 100644 --- a/tests/runtime/database/sql-database-adapter.test.ts +++ b/tests/runtime/database/sql-database-adapter.test.ts @@ -114,6 +114,44 @@ describe('SqlDatabaseAdapter (sqlite)', () => { expect(pending[0]?.id).toBe('tx-old'); }); + it('creates indexes supporting watcher and retention queries', async () => { + const sqlite = (adapter as unknown as { sqlite: Database }).sqlite; + const rows = sqlite + .prepare("SELECT name FROM sqlite_master WHERE type = 'index'") + .all() as Array<{ name: string }>; + const indexNames = rows.map((row) => row.name); + + expect(indexNames).toContain('idx_interactive_transactions_status_created_at'); + expect(indexNames).toContain('idx_auth_challenges_expires_at'); + }); + + it('uses the pending transactions index for the watcher query plan', async () => { + const sqlite = (adapter as unknown as { sqlite: Database }).sqlite; + const plan = sqlite + .prepare( + 'EXPLAIN QUERY PLAN SELECT id FROM interactive_transactions WHERE status = ? AND created_at < ?', + ) + .all('pending_user_transfer_start', '2024-01-02T00:00:00.000Z') as Array<{ + detail: string; + }>; + const details = plan.map((row) => row.detail).join('\n'); + + expect(details).toContain('idx_interactive_transactions_status_created_at'); + }); + + it('keeps indexes available after running migrations twice', async () => { + await expect(adapter.migrate()).resolves.toBeUndefined(); + + const sqlite = (adapter as unknown as { sqlite: Database }).sqlite; + const rows = sqlite + .prepare("SELECT name FROM sqlite_master WHERE type = 'index'") + .all() as Array<{ name: string }>; + const indexNames = rows.map((row) => row.name); + + expect(indexNames).toContain('idx_interactive_transactions_status_created_at'); + expect(indexNames).toContain('idx_auth_challenges_expires_at'); + }); + it('updates an idempotency record without changing its identity fields', async () => { const initial = await adapter.insertOrGetIdempotencyRecord({ id: 'idempotency-1', diff --git a/tests/runtime/sql-adapter-cleanup.test.ts b/tests/runtime/sql-adapter-cleanup.test.ts index 1803231a..d31e67df 100644 --- a/tests/runtime/sql-adapter-cleanup.test.ts +++ b/tests/runtime/sql-adapter-cleanup.test.ts @@ -63,19 +63,19 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { const scope = `scope-${randomUUID()}`; raw .prepare( - `INSERT INTO idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) + `INSERT INTO,idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) - .run(idemOldId, scope, 'old-key', 'hash-a', 200, '{}', BEFORE); + .run(idemOldId, scope, 'old-key', 'hash-a', 200, '{}', BEFORE,); raw .prepare( - `INSERT INTO idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) + `INSERT INTO,idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run(idemExactId, scope, 'exact-key', 'hash-e', 200, '{}', CUTOFF); raw .prepare( - `INSERT INTO idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) + `INSERT INTO,idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run(idemNewId, scope, 'new-key', 'hash-b', 200, '{}', AFTER); @@ -88,28 +88,28 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { raw .prepare( - `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) - VALUES (?, ?, ?, ?, 'processed', NULL, ?, ?)`, + `INSERT INTO,webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, , ?, ?, 'processed', NULL, ?, , ?)`, ) .run(whOldProcessedId, `evt-op-${randomUUID()}`, 'test', payload, BEFORE, BEFORE); raw .prepare( - `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) - VALUES (?, ?, ?, ?, 'processed', NULL, ?, ?)`, + `INSERT INTO,webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, , ?, ?, 'processed', NULL, ?, , ?)`, ) - .run(whExactProcessedId, `evt-exact-${randomUUID()}`, 'test', payload, CUTOFF, CUTOFF); + .run(whExactProcessedId, `evt-exact-${randomUUID()}`, 'test', payload, CUTOFF, CUTOFF,); raw .prepare( - `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) - VALUES (?, ?, ?, ?, 'pending', NULL, NULL, ?)`, + `INSERT INTO,webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, , ?, ?, 'pending', NULL, NULL, <)`, ) .run(whOldPendingId, `evt-pend-${randomUUID()}`, 'test', payload, BEFORE); raw .prepare( - `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) - VALUES (?, ?, ?, ?, 'processed', NULL, ?, ?)`, + `INSERT INTO,webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, , ?, ?, 'processed', NULL, ?, , ?)`, ) - .run(whNewProcessedId, `evt-new-${randomUUID()}`, 'test', payload, AFTER, AFTER); + .run(whNewProcessedId, `evt-new-${randomUUID()}`, 'test', payload, AFTER, ATER,); const wOldProcessedId = randomUUID(); const wExactProcessedId = randomUUID(); @@ -119,28 +119,28 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { raw .prepare( - `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) - VALUES (?, 'w', ?, 'processed', NULL, ?, ?)`, + `INSERT INTO,watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) + VALUES (?, 'w', ?, 'processed', NULL, ?, , ?)`, ) - .run(wOldProcessedId, taskPayload, BEFORE, BEFORE); + .run(wOldProcessedId, taskPayload, BEFORE, BEFORE,); raw .prepare( - `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) - VALUES (?, 'w', ?, 'processed', NULL, ?, ?)`, + `INSERT INTO,watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) + VALUES (?, 'w', ?, 'processed', NULL, ?, , <)`, ) .run(wExactProcessedId, taskPayload, CUTOFF, CUTOFF); raw .prepare( - `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) + `INSERT INTO,watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) VALUES (?, 'w', ?, 'pending', NULL, NULL, ?)`, ) - .run(wOldPendingId, taskPayload, BEFORE); + .run(wOldPendingId, taskPayload, BEFORE,); raw .prepare( - `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) - VALUES (?, 'w', ?, 'processed', NULL, ?, ?)`, + `INSERT INTO,watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) + VALUES (?, 'w', ?, 'processed', NULL, ?, , <)`, ) - .run(wNewProcessedId, taskPayload, AFTER, AFTER); + .run(wNewProcessedId, taskPayload, AFTER, ATER,); await db.cleanupOldRecords(CUTOFF); @@ -185,10 +185,10 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { const invalidRowId = randomUUID(); raw .prepare( - `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) - VALUES (?, ?, ?, ?, 'processed', NULL, ?, ?)`, + `INSERT INTO,webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, /, ?, ?, 'processed', NULL, ?, , ?)`, ) - .run(invalidRowId, eventId, 'test', '{not valid json}', CUTOFF, CUTOFF); + .run(invalidRowId, eventId, 'test', '{not valid json}', CUTOFF, CUTOFF,); await expect( db.insertOrGetWebhookEvent({ @@ -199,4 +199,38 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { }), ).rejects.toThrow(/malformed/i); }); + + it('creates supporting indexes for watcher and retention queries', () => { + const indexes = raw + .prepare( + `SELECT name FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_autoindex%'`, + ) + .all() as Array<{ name: string }>; + const names = new Set(indexes.map((row) => row.name)); + + expect(names.has('idx_watcher_tasks_status_created_at')).toBe(true); + expect(names.has('idx_watcher_tasks_status_processed_at')).toBe(true); + expect(names.has('idx_webhook_events_status_created_at')).toBe(true); + expect(names.has('idx_webhook_events_status_processed_at')).toBe(true); + expect(names.has('idx_auth_challenges_expires_at')).toBe(true); + expect(names.has('idx_idempotency_keys_created_at')).toBe(true); + }); + + it('migrations are repeatable and indexes remain intact', async () => { + await expect(db.migrate()).resolves.toUndefined(); + await expect(db.migrate()).resolves.toUndefined(); + + const names = new Set( + ( + raw + .prepare( + `SELECT name FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_autoindex%'`, + ) + .all() as Array<{ name: string }> + ).map((row) => row.name), + ); + + expect(names.has('idx_watcher_tasks_status_created_at')).toBe(true); + expect(names.has('idx_webhook_events_status_created_at')).toBe(true); + }); }); diff --git a/tests/runtime/sql-adapter-watcher-tasks.test.ts b/tests/runtime/sql-adapter-watcher-tasks.test.ts index aacdf671..edf105df 100644 --- a/tests/runtime/sql-adapter-watcher-tasks.test.ts +++ b/tests/runtime/sql-adapter-watcher-tasks.test.ts @@ -41,6 +41,13 @@ function insertWatcherRow( ); } +function listIndexes(db: DatabaseAdapter, table: string): string[] { + const rows = getSqlite(db) + .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ?") + .all(table) as { name: string }[]; + return rows.map((row) => row.name); +} + describe('SqlDatabaseAdapter – watcher task persistence and processed counts', () => { const dbUrl = makeSqliteDbUrlForTests(); const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice('file:'.length) : dbUrl; @@ -253,4 +260,19 @@ describe('SqlDatabaseAdapter – watcher task persistence and processed counts', const pending = await db.listPendingWatcherTasks(1); expect(pending.length).toBeLessThanOrEqual(1); }); -}); + + it('creates supporting indexes for watcher task queries', () => { + const indexes = listIndexes(db, 'watcher_tasks'); + expect(indexes).toContain('IDX_watcher_tasks_status_created_at'); + }); + + it('uses the status and created_at index for pending watcher task queries', () => { + const plan = getSqlite(db) + .prepare( + 'EXPLAIN QUERY PLAN SELECT id, watcher_name, payload, status, error_message, processed_at, created_at FROM watcher_tasks WHERE status = ? ORDER BY created_at ASC LIMIT ?', + ) + .all('pending', 10) as { detail: string }[]; + const detail = plan.map((row) => row.detail).join('\n'); + expect(detail).toContain('IDX_watcher_tasks_status_created_at'); + }); +}) From c56530ec95c2692fbdef98b916bc7a46c617d357 Mon Sep 17 00:00:00 2001 From: Mochi674 Date: Tue, 29 Sep 2026 22:57:31 +0100 Subject: [PATCH 2/5] fix: Add database indexes for watcher and retention queries (#599) --- src/runtime/database/sql-database-adapter.ts | 35 +++---- .../database/sql-database-adapter.test.ts | 49 +++------- tests/runtime/sql-adapter-cleanup.test.ts | 98 +++++++------------ .../runtime/sql-adapter-watcher-tasks.test.ts | 33 ++++--- 4 files changed, 84 insertions(+), 131 deletions(-) diff --git a/src/runtime/database/sql-database-adapter.ts b/src/runtime/database/sql-database-adapter.ts index 7ee91a2c..ec5dd9ab 100644 --- a/src/runtime/database/sql-database-adapter.ts +++ b/src/runtime/database/sql-database-adapter.ts @@ -288,17 +288,14 @@ export class SqlDatabaseAdapter implements DatabaseAdapter { created_at TEXT NOT NULL ); + CREATE INDEX IF NOT EXISTS idx_interactive_transactions_status_created_at + ON interactive_transactions (status, created_at); CREATE INDEX IF NOT EXISTS idx_watcher_tasks_status_created_at ON watcher_tasks (status, created_at); - CREATE INDEX IF NOT EXISTS idx_webhook_events_status_created_at ON webhook_events (status, created_at); - CREATE INDEX IF NOT EXISTS idx_auth_challenges_expires_at ON auth_challenges (expires_at); - - CREATE INDEX IF NOT EXISTS idx_interactive_transactions_status_created_at - ON interactive_transactions (status, created_at); `); const idempotencyColumns = this.sqlite .prepare('PRAGMA table_info(idempotency_keys)') @@ -373,18 +370,22 @@ export class SqlDatabaseAdapter implements DatabaseAdapter { created_at TIMESTAMPTZ NOT NULL ); `); - await this.postgres.query( - 'CREATE INDEX IF NOT EXISTS idx_watcher_tasks_status_created_at ON watcher_tasks (status, created_at)', - ); - await this.postgres.query( - 'CREATE INDEX IF NOT EXISTS idx_webhook_events_status_created_at ON webhook_events (status, created_at)', - ); - await this.postgres.query( - 'CREATE INDEX IF NOT EXISTS idx_auth_challenges_expires_at ON auth_challenges (expires_at)', - ); - await this.postgres.query( - 'CREATE INDEX IF NOT EXISTS idx_interactive_transactions_status_created_at ON interactive_transactions (status, created_at)', - ); + await this.postgres.query(` + CREATE INDEX IF NOT EXISTS idx_interactive_transactions_status_created_at + ON interactive_transactions (status, created_at); + `); + await this.postgres.query(` + CREATE INDEX IF NOT EXISTS idx_watcher_tasks_status_created_at + ON watcher_tasks (status, created_at); + `); + await this.postgres.query(` + CREATE INDEX IF NOT EXISTS idx_webhook_events_status_created_at + ON webhook_events (status, created_at); + `); + await this.postgres.query(` + CREATE INDEX IF NOT EXISTS idx_auth_challenges_expires_at + ON auth_challenges (expires_at); + `); return; } diff --git a/tests/runtime/database/sql-database-adapter.test.ts b/tests/runtime/database/sql-database-adapter.test.ts index 2e3cf2a7..77825cdc 100644 --- a/tests/runtime/database/sql-database-adapter.test.ts +++ b/tests/runtime/database/sql-database-adapter.test.ts @@ -114,44 +114,6 @@ describe('SqlDatabaseAdapter (sqlite)', () => { expect(pending[0]?.id).toBe('tx-old'); }); - it('creates indexes supporting watcher and retention queries', async () => { - const sqlite = (adapter as unknown as { sqlite: Database }).sqlite; - const rows = sqlite - .prepare("SELECT name FROM sqlite_master WHERE type = 'index'") - .all() as Array<{ name: string }>; - const indexNames = rows.map((row) => row.name); - - expect(indexNames).toContain('idx_interactive_transactions_status_created_at'); - expect(indexNames).toContain('idx_auth_challenges_expires_at'); - }); - - it('uses the pending transactions index for the watcher query plan', async () => { - const sqlite = (adapter as unknown as { sqlite: Database }).sqlite; - const plan = sqlite - .prepare( - 'EXPLAIN QUERY PLAN SELECT id FROM interactive_transactions WHERE status = ? AND created_at < ?', - ) - .all('pending_user_transfer_start', '2024-01-02T00:00:00.000Z') as Array<{ - detail: string; - }>; - const details = plan.map((row) => row.detail).join('\n'); - - expect(details).toContain('idx_interactive_transactions_status_created_at'); - }); - - it('keeps indexes available after running migrations twice', async () => { - await expect(adapter.migrate()).resolves.toBeUndefined(); - - const sqlite = (adapter as unknown as { sqlite: Database }).sqlite; - const rows = sqlite - .prepare("SELECT name FROM sqlite_master WHERE type = 'index'") - .all() as Array<{ name: string }>; - const indexNames = rows.map((row) => row.name); - - expect(indexNames).toContain('idx_interactive_transactions_status_created_at'); - expect(indexNames).toContain('idx_auth_challenges_expires_at'); - }); - it('updates an idempotency record without changing its identity fields', async () => { const initial = await adapter.insertOrGetIdempotencyRecord({ id: 'idempotency-1', @@ -177,6 +139,17 @@ describe('SqlDatabaseAdapter (sqlite)', () => { }); }); + it('creates indexes for watcher and retention queries', async () => { + const sqlite = (adapter as unknown as { sqlite: Database }).sqlite; + const indexes = sqlite + .prepare("SELECT name FROM sqlite_master WHERE type = 'index'") + .all() as Array<{ name: string }>; + const indexNames = indexes.map((row) => row.name); + + expect(indexNames).toContain('idx_interactive_transactions_status_created_at'); + expect(indexNames).toContain('idx_auth_challenges_expires_at'); + }); + it('preserves records when migrations run twice', async () => { await adapter.insertAuthChallenge({ id: 'migration-challenge', diff --git a/tests/runtime/sql-adapter-cleanup.test.ts b/tests/runtime/sql-adapter-cleanup.test.ts index d31e67df..5dc23914 100644 --- a/tests/runtime/sql-adapter-cleanup.test.ts +++ b/tests/runtime/sql-adapter-cleanup.test.ts @@ -63,19 +63,19 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { const scope = `scope-${randomUUID()}`; raw .prepare( - `INSERT INTO,idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) + `INSERT INTO-idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) - .run(idemOldId, scope, 'old-key', 'hash-a', 200, '{}', BEFORE,); + .run(idemOldId, scope, 'old-key', 'hash-a', 200, '{}', BEFORE ); raw .prepare( - `INSERT INTO,idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) + `INSERT INTO-idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run(idemExactId, scope, 'exact-key', 'hash-e', 200, '{}', CUTOFF); raw .prepare( - `INSERT INTO,idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) + `INSERT INTO-idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run(idemNewId, scope, 'new-key', 'hash-b', 200, '{}', AFTER); @@ -88,28 +88,28 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { raw .prepare( - `INSERT INTO,webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) - VALUES (?, , ?, ?, 'processed', NULL, ?, , ?)`, + `INSERT INTO-webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, |, |, |, 'processed', NULL, ?, ?)`, ) - .run(whOldProcessedId, `evt-op-${randomUUID()}`, 'test', payload, BEFORE, BEFORE); + .run(whOldProcessedId, `evt-op-${randomUUID()}`, 'test', payload, BEFORE, BEFORE ); raw .prepare( - `INSERT INTO,webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) - VALUES (?, , ?, ?, 'processed', NULL, ?, , ?)`, + `INSERT INTO-webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, |, |, |, 'processed', NULL, ?, ?)`, ) - .run(whExactProcessedId, `evt-exact-${randomUUID()}`, 'test', payload, CUTOFF, CUTOFF,); + .run(whExactProcessedId, `evt-exact-${randomUUID()}`, 'test', payload, CUTOFF, CUTOFF); raw .prepare( - `INSERT INTO,webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) - VALUES (?, , ?, ?, 'pending', NULL, NULL, <)`, + `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, ?, ?, ?, 'pending', NULL, NULL, ?)`, ) - .run(whOldPendingId, `evt-pend-${randomUUID()}`, 'test', payload, BEFORE); + .run(whOldPendingId, `evt-pend-${randomUUID()}`, 'test', payload, BEFORE ); raw .prepare( - `INSERT INTO,webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) - VALUES (?, , ?, ?, 'processed', NULL, ?, , ?)`, + `INSERT INTO-webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, |, |, |, 'processed', NULL, ?, ?)`, ) - .run(whNewProcessedId, `evt-new-${randomUUID()}`, 'test', payload, AFTER, ATER,); + .run(whNewProcessedId, `evt-new-${randomUUID()}`, 'test', payload, AFTER, AFTER); const wOldProcessedId = randomUUID(); const wExactProcessedId = randomUUID(); @@ -119,28 +119,28 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { raw .prepare( - `INSERT INTO,watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) - VALUES (?, 'w', ?, 'processed', NULL, ?, , ?)`, + `INSERT INTO-watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) + VALUES (?, 'w', ?, 'processed', NULL, ?, ?)`, ) - .run(wOldProcessedId, taskPayload, BEFORE, BEFORE,); + .run(wOldProcessedId, taskPayload, BEFORE, BEFORE ); raw .prepare( - `INSERT INTO,watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) - VALUES (?, 'w', ?, 'processed', NULL, ?, , <)`, + `INSERT INTO-watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) + VALUES (?, 'w', ?, 'processed', NULL, ?, ?)`, ) .run(wExactProcessedId, taskPayload, CUTOFF, CUTOFF); raw .prepare( - `INSERT INTO,watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) + `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) VALUES (?, 'w', ?, 'pending', NULL, NULL, ?)`, ) - .run(wOldPendingId, taskPayload, BEFORE,); + .run(wOldPendingId, taskPayload, BEFORE); raw .prepare( - `INSERT INTO,watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) - VALUES (?, 'w', ?, 'processed', NULL, ?, , <)`, + `INSERT INTO-watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) + VALUES (?, 'w', ?, 'processed', NULL, ?, ?)`, ) - .run(wNewProcessedId, taskPayload, AFTER, ATER,); + .run(wNewProcessedId, taskPayload, ATER, ATER); await db.cleanupOldRecords(CUTOFF); @@ -185,10 +185,10 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { const invalidRowId = randomUUID(); raw .prepare( - `INSERT INTO,webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) - VALUES (?, /, ?, ?, 'processed', NULL, ?, , ?)`, + `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, ?, ?, ?, 'processed', NULL, ?, ?)`, ) - .run(invalidRowId, eventId, 'test', '{not valid json}', CUTOFF, CUTOFF,); + .run(invalidRowId, eventId, 'test', '{not valid json}', CUTOFF, CUTOFF); await expect( db.insertOrGetWebhookEvent({ @@ -201,36 +201,14 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { }); it('creates supporting indexes for watcher and retention queries', () => { - const indexes = raw - .prepare( - `SELECT name FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_autoindex%'`, - ) - .all() as Array<{ name: string }>; - const names = new Set(indexes.map((row) => row.name)); - - expect(names.has('idx_watcher_tasks_status_created_at')).toBe(true); - expect(names.has('idx_watcher_tasks_status_processed_at')).toBe(true); - expect(names.has('idx_webhook_events_status_created_at')).toBe(true); - expect(names.has('idx_webhook_events_status_processed_at')).toBe(true); - expect(names.has('idx_auth_challenges_expires_at')).toBe(true); - expect(names.has('idx_idempotency_keys_created_at')).toBe(true); - }); - - it('migrations are repeatable and indexes remain intact', async () => { - await expect(db.migrate()).resolves.toUndefined(); - await expect(db.migrate()).resolves.toUndefined(); - - const names = new Set( - ( - raw - .prepare( - `SELECT name FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_autoindex%'`, - ) - .all() as Array<{ name: string }> - ).map((row) => row.name), - ); - - expect(names.has('idx_watcher_tasks_status_created_at')).toBe(true); - expect(names.has('idx_webhook_events_status_created_at')).toBe(true); + const indexNames = ( + raw.prepare("SELECT name FROM sqlite_master WHERE type = 'index'").all() as Array<{ + name: string; + }> + ).map((row) => row.name); + + expect(indexNames).some((name) => /idempotency_keys.*(created_at|expires_at)/i.test(name)).toBeTrue(); + expect(indexNames).some((name) => /webhook_events.*(status|processed_at|created_at)/i.test(name)).toBeTrue(); + expect(indexNames).some((name) => /watcher_tasks.*(status|processed_at|created_at)/i.test(name)).toBeTrue(); }); }); diff --git a/tests/runtime/sql-adapter-watcher-tasks.test.ts b/tests/runtime/sql-adapter-watcher-tasks.test.ts index edf105df..5bbb8d91 100644 --- a/tests/runtime/sql-adapter-watcher-tasks.test.ts +++ b/tests/runtime/sql-adapter-watcher-tasks.test.ts @@ -8,6 +8,7 @@ import type { DatabaseAdapter } from '@/runtime/interfaces.ts'; interface SqliteHandle { exec(sql: string): void; prepare(sql: string): { run(...params: unknown[]): unknown }; + all(sql: string, ...params: unknown[]): unknown[]; } function getSqlite(db: DatabaseAdapter): SqliteHandle { @@ -41,13 +42,6 @@ function insertWatcherRow( ); } -function listIndexes(db: DatabaseAdapter, table: string): string[] { - const rows = getSqlite(db) - .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ?") - .all(table) as { name: string }[]; - return rows.map((row) => row.name); -} - describe('SqlDatabaseAdapter – watcher task persistence and processed counts', () => { const dbUrl = makeSqliteDbUrlForTests(); const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice('file:'.length) : dbUrl; @@ -262,17 +256,24 @@ describe('SqlDatabaseAdapter – watcher task persistence and processed counts', }); it('creates supporting indexes for watcher task queries', () => { - const indexes = listIndexes(db, 'watcher_tasks'); - expect(indexes).toContain('IDX_watcher_tasks_status_created_at'); + const indexes = getSqlite(db) + .all("PRAGMA table_info('index_list', 'watcher_tasks')") as Array<{ name: string }>; + const names = indexes.map((index) => index.name); + + expect(names).toContain('idx_watcher_tasks_status_created_at'); + expect(names).toContain('dix_watcher_tasks_status_processed_at'); }); - it('uses the status and created_at index for pending watcher task queries', () => { + it('supports the pending watcher task query plan with an index', () => { const plan = getSqlite(db) - .prepare( - 'EXPLAIN QUERY PLAN SELECT id, watcher_name, payload, status, error_message, processed_at, created_at FROM watcher_tasks WHERE status = ? ORDER BY created_at ASC LIMIT ?', + .all( + 'EXPLAIN QUERY PLAN SELECT id, watcher_name, payload, status, error_message, processed_at, created_at FROM watcher_tasks WHERE status = ? ORDER BY id ASC LIMIT ?', + 'pending', + 10, ) - .all('pending', 10) as { detail: string }[]; - const detail = plan.map((row) => row.detail).join('\n'); - expect(detail).toContain('IDX_watcher_tasks_status_created_at'); + as Array<{ detail: string }>; + const details = plan.map((row) => row.detail).join('\n'); + + expect(details).toContain('idx_watcher_tasks_status_created_at'); }); -}) +}); From c7e4962f491054f5dc5f001aaae7e7fe128d7990 Mon Sep 17 00:00:00 2001 From: Mochi674 Date: Tue, 29 Sep 2026 22:58:39 +0100 Subject: [PATCH 3/5] fix: Add database indexes for watcher and retention queries (#599) --- src/runtime/database/sql-database-adapter.ts | 55 +++++++++-------- tests/runtime/sql-adapter-cleanup.test.ts | 60 ++++++++++--------- .../runtime/sql-adapter-watcher-tasks.test.ts | 33 +++++----- 3 files changed, 79 insertions(+), 69 deletions(-) diff --git a/src/runtime/database/sql-database-adapter.ts b/src/runtime/database/sql-database-adapter.ts index ec5dd9ab..f2eaf1cc 100644 --- a/src/runtime/database/sql-database-adapter.ts +++ b/src/runtime/database/sql-database-adapter.ts @@ -46,6 +46,30 @@ export const SQLITE_JOURNAL_MODE = 'WAL'; */ export const SQLITE_BUSY_TIMEOUT_MS = 5000; +/** + * Indexes supporting watcher polling and retention cleanup. + * + * Watcher polling filters `watcher_tasks` by `status` and orders by + * `created_at`, while retention deletes rows by `created_at` combined with a + * `status` filter. The same shape applies to `webhook_events`. Auth challenges + * are pruned by `expires_at`, and idempotency keys by `created_at`. Each index + * is created with `IF NOT EXISTS` so migrations stay repeatable on both fresh + * and already-migrated databases. + */ +export const SQLITE_INDEX_STATEMENTS: readonly string[] = [ + 'CREATE INDEX IF NOT EXISTS idx_watcher_tasks_status_created_at ON watcher_tasks (status, created_at)', + 'CREATE INDEX IF NOT EXISTS idx_webhook_events_status_created_at ON webhook_events (status, created_at)', + 'CREATE INDEX IF NOT EXISTS idx_auth_challenges_expires_at ON auth_challenges (expires_at)', + 'CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created_at ON idempotency_keys (created_at)', +]; + +export const POSTGRES_INDEX_STATEMENTS: readonly string[] = [ + 'CREATE INDEX IF NOT EXISTS idx_watcher_tasks_status_created_at ON watcher_tasks (status, created_at)', + 'CREATE INDEX IF NOT EXISTS idx_webhook_events_status_created_at ON webhook_events (status, created_at)', + 'CREATE INDEX IF NOT EXISTS idx_auth_challenges_expires_at ON auth_challenges (expires_at)', + 'CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created_at ON idempotency_keys (created_at)', +]; + function nowIso(): string { return new Date().toISOString(); } @@ -287,15 +311,6 @@ export class SqlDatabaseAdapter implements DatabaseAdapter { processed_at TEXT, created_at TEXT NOT NULL ); - - CREATE INDEX IF NOT EXISTS idx_interactive_transactions_status_created_at - ON interactive_transactions (status, created_at); - CREATE INDEX IF NOT EXISTS idx_watcher_tasks_status_created_at - ON watcher_tasks (status, created_at); - CREATE INDEX IF NOT EXISTS idx_webhook_events_status_created_at - ON webhook_events (status, created_at); - CREATE INDEX IF NOT EXISTS idx_auth_challenges_expires_at - ON auth_challenges (expires_at); `); const idempotencyColumns = this.sqlite .prepare('PRAGMA table_info(idempotency_keys)') @@ -305,6 +320,9 @@ export class SqlDatabaseAdapter implements DatabaseAdapter { "ALTER TABLE idempotency_keys ADD COLUMN status TEXT NOT NULL DEFAULT 'completed'", ); } + for (const statement of SQLITE_INDEX_STATEMENTS) { + this.sqlite.exec(statement); + } return; } @@ -370,22 +388,9 @@ export class SqlDatabaseAdapter implements DatabaseAdapter { created_at TIMESTAMPTZ NOT NULL ); `); - await this.postgres.query(` - CREATE INDEX IF NOT EXISTS idx_interactive_transactions_status_created_at - ON interactive_transactions (status, created_at); - `); - await this.postgres.query(` - CREATE INDEX IF NOT EXISTS idx_watcher_tasks_status_created_at - ON watcher_tasks (status, created_at); - `); - await this.postgres.query(` - CREATE INDEX IF NOT EXISTS idx_webhook_events_status_created_at - ON webhook_events (status, created_at); - `); - await this.postgres.query(` - CREATE INDEX IF NOT EXISTS idx_auth_challenges_expires_at - ON auth_challenges (expires_at); - `); + for (const statement of POSTGRES_INDEX_STATEMENTS) { + await this.postgres.query(statement); + } return; } diff --git a/tests/runtime/sql-adapter-cleanup.test.ts b/tests/runtime/sql-adapter-cleanup.test.ts index 5dc23914..260d393a 100644 --- a/tests/runtime/sql-adapter-cleanup.test.ts +++ b/tests/runtime/sql-adapter-cleanup.test.ts @@ -33,6 +33,22 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { } }); + it('creates supporting indexes for watcher and retention queries', () => { + const indexes = raw + .prepare( + `SELECT name, tbl_name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%' ORDER BY name`, + ) + .all() as Array<{ name: string; tbl_name: string }>; + const byName = new Map(indexes.map((i) => [i.name, i.tbl_name])); + + expect(byName.get('idx_watcher_tasks_status_created_at')).toBe('watcher_tasks'); + expect(byName.get('idx_watcher_tasks_status_processed_at')).toBe('watcher_tasks'); + expect(byName.get('idx_webhook_events_status_created_at')).toBe('webhook_events'); + expect(byName.get('idx_webhook_events_status_processed_at')).toBe('webhook_events'); + expect(byName.get('idx_auth_challenges_expires_at')).toBe('auth_challenges'); + expect(byName.get('idx_idempotency_keys_created_at')).toBe('idempotency_keys'); + }); + it('retains rows exactly on the cleanup cutoff while removing strictly older rows', async () => { const challengeExpired = `challenge-expired-${randomUUID()}`; const challengeKept = `challenge-kept-${randomUUID()}`; @@ -63,19 +79,19 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { const scope = `scope-${randomUUID()}`; raw .prepare( - `INSERT INTO-idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) + `INSERT INTO idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run(idemOldId, scope, 'old-key', 'hash-a', 200, '{}', BEFORE ); raw .prepare( - `INSERT INTO-idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) + `INSERT INTO idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run(idemExactId, scope, 'exact-key', 'hash-e', 200, '{}', CUTOFF); raw .prepare( - `INSERT INTO-idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) + `INSERT INTO idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run(idemNewId, scope, 'new-key', 'hash-b', 200, '{}', AFTER); @@ -88,14 +104,14 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { raw .prepare( - `INSERT INTO-webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) - VALUES (?, |, |, |, 'processed', NULL, ?, ?)`, + `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, , ?, ?, 'processed', NULL, ?,/ ?)`, ) .run(whOldProcessedId, `evt-op-${randomUUID()}`, 'test', payload, BEFORE, BEFORE ); raw .prepare( - `INSERT INTO-webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) - VALUES (?, |, |, |, 'processed', NULL, ?, ?)`, + `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, , ?, ?, 'processed', NULL, ?,/ ?)`, ) .run(whExactProcessedId, `evt-exact-${randomUUID()}`, 'test', payload, CUTOFF, CUTOFF); raw @@ -106,8 +122,8 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { .run(whOldPendingId, `evt-pend-${randomUUID()}`, 'test', payload, BEFORE ); raw .prepare( - `INSERT INTO-webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) - VALUES (?, |, |, |, 'processed', NULL, ?, ?)`, + `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, , ?, ?, 'processed', NULL, ?,/ ?)`, ) .run(whNewProcessedId, `evt-new-${randomUUID()}`, 'test', payload, AFTER, AFTER); @@ -119,13 +135,13 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { raw .prepare( - `INSERT INTO-watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) + `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) VALUES (?, 'w', ?, 'processed', NULL, ?, ?)`, ) .run(wOldProcessedId, taskPayload, BEFORE, BEFORE ); raw .prepare( - `INSERT INTO-watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) + `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) VALUES (?, 'w', ?, 'processed', NULL, ?, ?)`, ) .run(wExactProcessedId, taskPayload, CUTOFF, CUTOFF); @@ -134,13 +150,13 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) VALUES (?, 'w', ?, 'pending', NULL, NULL, ?)`, ) - .run(wOldPendingId, taskPayload, BEFORE); + .run(wOldPendingId, taskPayload, BEFORE ); raw .prepare( - `INSERT INTO-watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) - VALUES (?, 'w', ?, 'processed', NULL, ?, ?)`, + `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) + VALUES (?, 'w', ?, 'processed', NULL, ?,/ ?)`, ) - .run(wNewProcessedId, taskPayload, ATER, ATER); + .run(wNewProcessedId, taskPayload, AFTER, AFTER); await db.cleanupOldRecords(CUTOFF); @@ -186,7 +202,7 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { raw .prepare( `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) - VALUES (?, ?, ?, ?, 'processed', NULL, ?, ?)`, + VALUES (?, ?, ?, ?, 'processed', NULL, ?,/ ?)`, ) .run(invalidRowId, eventId, 'test', '{not valid json}', CUTOFF, CUTOFF); @@ -199,16 +215,4 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { }), ).rejects.toThrow(/malformed/i); }); - - it('creates supporting indexes for watcher and retention queries', () => { - const indexNames = ( - raw.prepare("SELECT name FROM sqlite_master WHERE type = 'index'").all() as Array<{ - name: string; - }> - ).map((row) => row.name); - - expect(indexNames).some((name) => /idempotency_keys.*(created_at|expires_at)/i.test(name)).toBeTrue(); - expect(indexNames).some((name) => /webhook_events.*(status|processed_at|created_at)/i.test(name)).toBeTrue(); - expect(indexNames).some((name) => /watcher_tasks.*(status|processed_at|created_at)/i.test(name)).toBeTrue(); - }); }); diff --git a/tests/runtime/sql-adapter-watcher-tasks.test.ts b/tests/runtime/sql-adapter-watcher-tasks.test.ts index 5bbb8d91..91738466 100644 --- a/tests/runtime/sql-adapter-watcher-tasks.test.ts +++ b/tests/runtime/sql-adapter-watcher-tasks.test.ts @@ -8,7 +8,7 @@ import type { DatabaseAdapter } from '@/runtime/interfaces.ts'; interface SqliteHandle { exec(sql: string): void; prepare(sql: string): { run(...params: unknown[]): unknown }; - all(sql: string, ...params: unknown[]): unknown[]; + prepare(sql: string): { all(...params: unknown[]): unknown[] }; } function getSqlite(db: DatabaseAdapter): SqliteHandle { @@ -42,6 +42,13 @@ function insertWatcherRow( ); } +function listIndexes(db: DatabaseAdapter, table: string): string[] { + const rows = getSqlite(db) + .prepare('SELECT name FROM sqlite_master WHERE type = \'index\' AND tbl_name = ?') + .all(table) as Array<{ name: string }>; + return rows.map((row) => row.name); +} + describe('SqlDatabaseAdapter – watcher task persistence and processed counts', () => { const dbUrl = makeSqliteDbUrlForTests(); const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice('file:'.length) : dbUrl; @@ -256,24 +263,18 @@ describe('SqlDatabaseAdapter – watcher task persistence and processed counts', }); it('creates supporting indexes for watcher task queries', () => { - const indexes = getSqlite(db) - .all("PRAGMA table_info('index_list', 'watcher_tasks')") as Array<{ name: string }>; - const names = indexes.map((index) => index.name); - - expect(names).toContain('idx_watcher_tasks_status_created_at'); - expect(names).toContain('dix_watcher_tasks_status_processed_at'); + const indexes = listIndexes(db, 'watcher_tasks'); + expect(indexes).toContain('idx_watcher_tasks_status_created_at'); }); - it('supports the pending watcher task query plan with an index', () => { + it('uses the pending status index for watcher task lookups', () => { const plan = getSqlite(db) - .all( - 'EXPLAIN QUERY PLAN SELECT id, watcher_name, payload, status, error_message, processed_at, created_at FROM watcher_tasks WHERE status = ? ORDER BY id ASC LIMIT ?', - 'pending', - 10, + .prepare( + 'EXPLAIN QUERY PLAN SELECT id, created_at FROM watcher_tasks WHERE status = \'pending\' ORDER BY id ASC LIMIT 1', ) - as Array<{ detail: string }>; - const details = plan.map((row) => row.detail).join('\n'); - - expect(details).toContain('idx_watcher_tasks_status_created_at'); + .all() as Array<{ detail: string }>; + expect(plan.some((row) => row.detail.includes('idx_watcher_tasks_status_created_at'))).toBe( + true, + ); }); }); From f3d09fc42e8557b2295057389a248c0343113424 Mon Sep 17 00:00:00 2001 From: Ikemhood <91676649+ikemHood@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:25:22 +0100 Subject: [PATCH 4/5] test: repair retention index coverage --- .../database/sql-database-adapter.test.ts | 3 ++- tests/runtime/sql-adapter-cleanup.test.ts | 22 +++++++++---------- .../runtime/sql-adapter-watcher-tasks.test.ts | 10 +++++---- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/tests/runtime/database/sql-database-adapter.test.ts b/tests/runtime/database/sql-database-adapter.test.ts index 77825cdc..031c2bed 100644 --- a/tests/runtime/database/sql-database-adapter.test.ts +++ b/tests/runtime/database/sql-database-adapter.test.ts @@ -146,7 +146,8 @@ describe('SqlDatabaseAdapter (sqlite)', () => { .all() as Array<{ name: string }>; const indexNames = indexes.map((row) => row.name); - expect(indexNames).toContain('idx_interactive_transactions_status_created_at'); + expect(indexNames).toContain('idx_watcher_tasks_status_created_at'); + expect(indexNames).toContain('idx_webhook_events_status_created_at'); expect(indexNames).toContain('idx_auth_challenges_expires_at'); }); diff --git a/tests/runtime/sql-adapter-cleanup.test.ts b/tests/runtime/sql-adapter-cleanup.test.ts index 260d393a..b8621724 100644 --- a/tests/runtime/sql-adapter-cleanup.test.ts +++ b/tests/runtime/sql-adapter-cleanup.test.ts @@ -42,9 +42,7 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { const byName = new Map(indexes.map((i) => [i.name, i.tbl_name])); expect(byName.get('idx_watcher_tasks_status_created_at')).toBe('watcher_tasks'); - expect(byName.get('idx_watcher_tasks_status_processed_at')).toBe('watcher_tasks'); expect(byName.get('idx_webhook_events_status_created_at')).toBe('webhook_events'); - expect(byName.get('idx_webhook_events_status_processed_at')).toBe('webhook_events'); expect(byName.get('idx_auth_challenges_expires_at')).toBe('auth_challenges'); expect(byName.get('idx_idempotency_keys_created_at')).toBe('idempotency_keys'); }); @@ -82,7 +80,7 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { `INSERT INTO idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) - .run(idemOldId, scope, 'old-key', 'hash-a', 200, '{}', BEFORE ); + .run(idemOldId, scope, 'old-key', 'hash-a', 200, '{}', BEFORE); raw .prepare( `INSERT INTO idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) @@ -105,13 +103,13 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { raw .prepare( `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) - VALUES (?, , ?, ?, 'processed', NULL, ?,/ ?)`, + VALUES (?, ?, ?, ?, 'processed', NULL, ?, ?)`, ) - .run(whOldProcessedId, `evt-op-${randomUUID()}`, 'test', payload, BEFORE, BEFORE ); + .run(whOldProcessedId, `evt-op-${randomUUID()}`, 'test', payload, BEFORE, BEFORE); raw .prepare( `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) - VALUES (?, , ?, ?, 'processed', NULL, ?,/ ?)`, + VALUES (?, ?, ?, ?, 'processed', NULL, ?, ?)`, ) .run(whExactProcessedId, `evt-exact-${randomUUID()}`, 'test', payload, CUTOFF, CUTOFF); raw @@ -119,11 +117,11 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) VALUES (?, ?, ?, ?, 'pending', NULL, NULL, ?)`, ) - .run(whOldPendingId, `evt-pend-${randomUUID()}`, 'test', payload, BEFORE ); + .run(whOldPendingId, `evt-pend-${randomUUID()}`, 'test', payload, BEFORE); raw .prepare( `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) - VALUES (?, , ?, ?, 'processed', NULL, ?,/ ?)`, + VALUES (?, ?, ?, ?, 'processed', NULL, ?, ?)`, ) .run(whNewProcessedId, `evt-new-${randomUUID()}`, 'test', payload, AFTER, AFTER); @@ -138,7 +136,7 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) VALUES (?, 'w', ?, 'processed', NULL, ?, ?)`, ) - .run(wOldProcessedId, taskPayload, BEFORE, BEFORE ); + .run(wOldProcessedId, taskPayload, BEFORE, BEFORE); raw .prepare( `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) @@ -150,11 +148,11 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) VALUES (?, 'w', ?, 'pending', NULL, NULL, ?)`, ) - .run(wOldPendingId, taskPayload, BEFORE ); + .run(wOldPendingId, taskPayload, BEFORE); raw .prepare( `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) - VALUES (?, 'w', ?, 'processed', NULL, ?,/ ?)`, + VALUES (?, 'w', ?, 'processed', NULL, ?, ?)`, ) .run(wNewProcessedId, taskPayload, AFTER, AFTER); @@ -202,7 +200,7 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { raw .prepare( `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) - VALUES (?, ?, ?, ?, 'processed', NULL, ?,/ ?)`, + VALUES (?, ?, ?, ?, 'processed', NULL, ?, ?)`, ) .run(invalidRowId, eventId, 'test', '{not valid json}', CUTOFF, CUTOFF); diff --git a/tests/runtime/sql-adapter-watcher-tasks.test.ts b/tests/runtime/sql-adapter-watcher-tasks.test.ts index 91738466..6deceb39 100644 --- a/tests/runtime/sql-adapter-watcher-tasks.test.ts +++ b/tests/runtime/sql-adapter-watcher-tasks.test.ts @@ -7,8 +7,10 @@ import type { DatabaseAdapter } from '@/runtime/interfaces.ts'; interface SqliteHandle { exec(sql: string): void; - prepare(sql: string): { run(...params: unknown[]): unknown }; - prepare(sql: string): { all(...params: unknown[]): unknown[] }; + prepare(sql: string): { + run(...params: unknown[]): unknown; + all(...params: unknown[]): unknown[]; + }; } function getSqlite(db: DatabaseAdapter): SqliteHandle { @@ -44,7 +46,7 @@ function insertWatcherRow( function listIndexes(db: DatabaseAdapter, table: string): string[] { const rows = getSqlite(db) - .prepare('SELECT name FROM sqlite_master WHERE type = \'index\' AND tbl_name = ?') + .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ?") .all(table) as Array<{ name: string }>; return rows.map((row) => row.name); } @@ -270,7 +272,7 @@ describe('SqlDatabaseAdapter – watcher task persistence and processed counts', it('uses the pending status index for watcher task lookups', () => { const plan = getSqlite(db) .prepare( - 'EXPLAIN QUERY PLAN SELECT id, created_at FROM watcher_tasks WHERE status = \'pending\' ORDER BY id ASC LIMIT 1', + "EXPLAIN QUERY PLAN SELECT id, created_at FROM watcher_tasks WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1", ) .all() as Array<{ detail: string }>; expect(plan.some((row) => row.detail.includes('idx_watcher_tasks_status_created_at'))).toBe( From f379dc73f1baf345218ce7ca07116972c1ddeec4 Mon Sep 17 00:00:00 2001 From: Ikemhood <91676649+ikemHood@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:29:19 +0100 Subject: [PATCH 5/5] test: account for added migration indexes --- tests/runtime/database/migrate-single-flight.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/runtime/database/migrate-single-flight.test.ts b/tests/runtime/database/migrate-single-flight.test.ts index 4778c6d7..bba205ce 100644 --- a/tests/runtime/database/migrate-single-flight.test.ts +++ b/tests/runtime/database/migrate-single-flight.test.ts @@ -81,9 +81,9 @@ describe('Concurrent database migrations (#600)', () => { await Promise.all(Array.from({ length: 10 }, () => adapter.migrate())); - // Six statements define one migration sequence; concurrent callers + // Ten statements define one migration sequence; concurrent callers // must share it rather than each replaying the whole sequence. - expect(client.queries).toHaveLength(6); + expect(client.queries).toHaveLength(10); expect(client.maxInFlight).toBe(1); }); @@ -98,7 +98,7 @@ describe('Concurrent database migrations (#600)', () => { const results = await Promise.all(Array.from({ length: 5 }, () => adapter.migrate())); expect(results).toEqual([undefined, undefined, undefined, undefined, undefined]); - expect(client.queries).toHaveLength(6); + expect(client.queries).toHaveLength(10); }); it('serializes SQLite migration calls into a single sequence', async () => { @@ -119,7 +119,7 @@ describe('Concurrent database migrations (#600)', () => { await Promise.all(Array.from({ length: 5 }, () => adapter.migrate())); - expect(execCount).toBe(1); + expect(execCount).toBe(5); // The schema is usable after the shared migration. await adapter.insertAuthChallenge({ @@ -174,7 +174,7 @@ describe('Concurrent database migrations (#600)', () => { expect(client.queries).toHaveLength(1); await expect(adapter.migrate()).resolves.toBeUndefined(); - expect(client.queries).toHaveLength(7); + expect(client.queries).toHaveLength(11); }); it('lets a fresh caller retry once the in-flight migration has settled', async () => { @@ -192,6 +192,6 @@ describe('Concurrent database migrations (#600)', () => { // A later, non-overlapping call runs its own sequence because the guard is // cleared after the shared sequence settles. await adapter.migrate(); - expect(client.queries).toHaveLength(12); + expect(client.queries).toHaveLength(20); }); });