diff --git a/backend/src/routes/v1/admin.routes.ts b/backend/src/routes/v1/admin.routes.ts index 8799eea3..ebc6c6c5 100644 --- a/backend/src/routes/v1/admin.routes.ts +++ b/backend/src/routes/v1/admin.routes.ts @@ -6,6 +6,8 @@ import { getIndexerStatus, resetIndexer, replayFromLedger, + previewReset, + previewReplay, } from '../../services/indexerService.js'; import { prisma, pool } from '../../lib/prisma.js'; @@ -340,8 +342,15 @@ router.get('/indexer/status', async (req: Request, res: Response) => { * /v1/admin/indexer/reset: * post: * tags: [Admin] - * summary: Reset indexer lastProcessedLedger + * summary: Reset indexer lastProcessedLedger (supports dry-run preview) * security: [{ adminAuth: [] }] + * parameters: + * - in: query + * name: dryRun + * schema: + * type: boolean + * default: false + * description: If true, return the projected reset scope without mutating state. * requestBody: * required: true * content: @@ -393,7 +402,15 @@ router.post('/indexer/reset', async (req: Request, res: Response) => { res.status(400).json({ error: 'ledger must be a non-negative integer' }); return; } + + const dryRun = req.query.dryRun === 'true'; + try { + if (dryRun) { + const preview = await previewReset(ledger); + res.json({ dryRun: true, preview }); + return; + } await resetIndexer(ledger); res.json({ ok: true, lastLedger: ledger }); } catch (err) { @@ -406,7 +423,7 @@ router.post('/indexer/reset', async (req: Request, res: Response) => { * /v1/admin/indexer/replay: * post: * tags: [Admin] - * summary: Replay events from a given ledger (StreamEvent rows deduplicated; stream mutations not idempotent — see indexerService.ts JSDoc) + * summary: Replay events from a given ledger (supports dry-run preview) * security: [{ adminAuth: [] }] * parameters: * - in: query @@ -414,7 +431,15 @@ router.post('/indexer/reset', async (req: Request, res: Response) => { * required: true * schema: * type: integer + * - in: query + * name: dryRun + * schema: + * type: boolean + * default: false + * description: If true, return the projected replay scope without mutating state. * responses: + * 200: + * description: Dry-run preview of the replay scope * 202: * description: Replay started * content: @@ -456,7 +481,15 @@ router.post('/indexer/replay', async (req: Request, res: Response) => { res.status(400).json({ error: 'from_ledger must be a non-negative integer' }); return; } + + const dryRun = req.query.dryRun === 'true'; + try { + if (dryRun) { + const preview = await previewReplay(fromLedger); + res.json({ dryRun: true, preview }); + return; + } const requestId = await replayFromLedger(fromLedger); res.status(202).json({ ok: true, replayingFrom: fromLedger, requestId }); } catch (err) { diff --git a/backend/src/services/indexer.service.ts b/backend/src/services/indexer.service.ts index 5592b1e4..f63c363c 100644 --- a/backend/src/services/indexer.service.ts +++ b/backend/src/services/indexer.service.ts @@ -31,6 +31,74 @@ export async function resetIndexer(toLedger: number): Promise { logger.info(`[IndexerService] Reset lastProcessedLedger to ${toLedger}`); } +/** + * Preview what a reset would do without mutating state. + * Returns the current cursor and the target ledger so operators can + * verify the intended scope before committing. + */ +export interface ResetPreview { + currentLastLedger: number; + currentLastCursor: string | null; + targetLastLedger: number; +} + +export async function previewReset(targetLedger: number): Promise { + const state = await prisma.indexerState.findUnique({ + where: { id: INDEXER_STATE_ID }, + }); + return { + currentLastLedger: state?.lastLedger ?? 0, + currentLastCursor: state?.lastCursor ?? null, + targetLastLedger: targetLedger, + }; +} + +/** + * Preview what a replay from a given ledger would do without mutating state. + * Returns the event count, ledger range, and current cursor so operators can + * sanity-check before committing a destructive replay. + */ +export interface ReplayPreview { + fromLedger: number; + currentLastLedger: number; + currentLastCursor: string | null; + eventCount: number; + minLedgerInReplayRange: number | null; + maxLedgerInReplayRange: number | null; +} + +export async function previewReplay( + fromLedger: number, +): Promise { + const state = await prisma.indexerState.findUnique({ + where: { id: INDEXER_STATE_ID }, + }); + const currentLastLedger = state?.lastLedger ?? 0; + + const rangeFilter: import('../generated/prisma/index.js').Prisma.StreamEventWhereInput = + currentLastLedger > 0 + ? { ledgerSequence: { gte: fromLedger, lte: currentLastLedger } } + : { ledgerSequence: { gte: fromLedger } }; + + const [eventCount, aggregate] = await Promise.all([ + prisma.streamEvent.count({ where: rangeFilter }), + prisma.streamEvent.aggregate({ + where: rangeFilter, + _min: { ledgerSequence: true }, + _max: { ledgerSequence: true }, + }), + ]); + + return { + fromLedger, + currentLastLedger, + currentLastCursor: state?.lastCursor ?? null, + eventCount, + minLedgerInReplayRange: aggregate._min.ledgerSequence, + maxLedgerInReplayRange: aggregate._max.ledgerSequence, + }; +} + export async function replayFromLedger(fromLedger: number, customRequestId?: string): Promise { const requestId = customRequestId || requestContext.getStore()?.requestId || randomUUID(); return requestContext.run({ requestId }, async () => { diff --git a/backend/tests/integration/admin-metrics.test.ts b/backend/tests/integration/admin-metrics.test.ts index 5db22988..038b30c7 100644 --- a/backend/tests/integration/admin-metrics.test.ts +++ b/backend/tests/integration/admin-metrics.test.ts @@ -90,6 +90,8 @@ vi.mock('../../src/services/indexerService.js', () => ({ getIndexerStatus: vi.fn().mockResolvedValue({}), resetIndexer: vi.fn().mockResolvedValue(undefined), replayFromLedger: vi.fn().mockResolvedValue(undefined), + previewReset: vi.fn().mockResolvedValue({}), + previewReplay: vi.fn().mockResolvedValue({}), })); vi.mock('../../src/workers/soroban-event-worker.js', () => ({ @@ -113,6 +115,8 @@ import { getIndexerStatus, resetIndexer, replayFromLedger, + previewReset, + previewReplay, } from '../../src/services/indexerService.js'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -520,6 +524,50 @@ describe('POST /v1/admin/indexer/reset', () => { expect(res.status).toBe(500); expect(res.body).toEqual({ error: 'Reset failed' }); }); + + it('returns a dry-run preview without calling resetIndexer when dryRun=true', async () => { + const preview = { + currentLastLedger: 1000, + currentLastCursor: 'cursor_xyz', + targetLastLedger: 500, + }; + vi.mocked(previewReset).mockResolvedValueOnce(preview); + + const res = await request(app) + .post('/v1/admin/indexer/reset?dryRun=true') + .set('Authorization', `Bearer ${createToken()}`) + .send({ ledger: 500 }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ dryRun: true, preview }); + expect(previewReset).toHaveBeenCalledWith(500); + expect(resetIndexer).not.toHaveBeenCalled(); + }); + + it('treats non-true dryRun values as a real reset', async () => { + const res = await request(app) + .post('/v1/admin/indexer/reset?dryRun=false') + .set('Authorization', `Bearer ${createToken()}`) + .send({ ledger: 300 }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true, lastLedger: 300 }); + expect(resetIndexer).toHaveBeenCalledWith(300); + expect(previewReset).not.toHaveBeenCalled(); + }); + + it('returns 500 when previewReset throws for a dry-run request', async () => { + vi.mocked(previewReset).mockRejectedValueOnce(new Error('Preview failed')); + + const res = await request(app) + .post('/v1/admin/indexer/reset?dryRun=true') + .set('Authorization', `Bearer ${createToken()}`) + .send({ ledger: 500 }); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'Reset failed' }); + expect(resetIndexer).not.toHaveBeenCalled(); + }); }); describe('POST /v1/admin/indexer/replay', () => { @@ -587,5 +635,49 @@ describe('POST /v1/admin/indexer/replay', () => { expect(res.status).toBe(500); expect(res.body).toEqual({ error: 'Replay failed' }); }); + + it('returns a dry-run preview without triggering a replay when dryRun=true', async () => { + const preview = { + fromLedger: 200, + currentLastLedger: 1000, + currentLastCursor: 'cursor_xyz', + eventCount: 42, + minLedgerInReplayRange: 210, + maxLedgerInReplayRange: 999, + }; + vi.mocked(previewReplay).mockResolvedValueOnce(preview); + + const res = await request(app) + .post('/v1/admin/indexer/replay?from_ledger=200&dryRun=true') + .set('Authorization', `Bearer ${createToken()}`); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ dryRun: true, preview }); + expect(previewReplay).toHaveBeenCalledWith(200); + expect(replayFromLedger).not.toHaveBeenCalled(); + }); + + it('treats non-true dryRun values as a real replay', async () => { + const res = await request(app) + .post('/v1/admin/indexer/replay?from_ledger=200&dryRun=false') + .set('Authorization', `Bearer ${createToken()}`); + + expect(res.status).toBe(202); + expect(res.body).toMatchObject({ ok: true, replayingFrom: 200 }); + expect(replayFromLedger).toHaveBeenCalledWith(200); + expect(previewReplay).not.toHaveBeenCalled(); + }); + + it('returns 500 when previewReplay throws for a dry-run request', async () => { + vi.mocked(previewReplay).mockRejectedValueOnce(new Error('Preview failed')); + + const res = await request(app) + .post('/v1/admin/indexer/replay?from_ledger=200&dryRun=true') + .set('Authorization', `Bearer ${createToken()}`); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'Replay failed' }); + expect(replayFromLedger).not.toHaveBeenCalled(); + }); });