From 81632cff768cdd42e9c4b83ea9e738ce09873d6a Mon Sep 17 00:00:00 2001 From: oshowunm Date: Sat, 29 Aug 2026 23:14:52 +0100 Subject: [PATCH 1/3] feat(backend): add dryRun mode to admin indexer replay/reset endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators executing POST /v1/admin/indexer/replay or /reset currently get no preview of the scope before the mutation fires. A single misfired call can trigger wide, hard-to-undo state changes (non-idempotent withdrawnAmount increments, cursor resets across the full event range). Add ?dryRun=true query parameter support to both endpoints: - replay preview returns event count, ledger range, and current cursor so operators can sanity-check the projected scope before committing - reset preview returns the current cursor and target ledger for verification Neither endpoint mutates state when dryRun=true; both return 200 with a structured preview object. Closes #1238 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- backend/src/routes/v1/admin.routes.ts | 39 ++++++++++++-- backend/src/services/indexerService.ts | 70 ++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/backend/src/routes/v1/admin.routes.ts b/backend/src/routes/v1/admin.routes.ts index 798b00cb..4642c3bc 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'; @@ -215,8 +217,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: @@ -229,7 +238,7 @@ router.get('/indexer/status', async (req: Request, res: Response) => { * type: integer * responses: * 200: - * description: Reset successful + * description: Reset successful (or dry-run preview) */ router.post('/indexer/reset', async (req: Request, res: Response) => { const ledger = Number(req.body?.ledger); @@ -237,7 +246,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) { @@ -250,7 +267,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 @@ -258,7 +275,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 */ @@ -268,7 +293,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/indexerService.ts b/backend/src/services/indexerService.ts index 1759d75f..85147d18 100644 --- a/backend/src/services/indexerService.ts +++ b/backend/src/services/indexerService.ts @@ -55,6 +55,76 @@ 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; + + // Count events in the ledger range that would be re-fetched. + // Soroban RPC replays all events from fromLedger up to the current cursor. + 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, + }; +} + /** * Replay events from a given ledger by resetting state and triggering a poll. * The @@unique([transactionHash, eventType]) constraint on StreamEvent From 625155e81ab685058aa98e391e26ab7bacb6cc77 Mon Sep 17 00:00:00 2001 From: oshowunm Date: Mon, 31 Aug 2026 06:59:22 +0100 Subject: [PATCH 2/3] fix(backend): restore previewReset/previewReplay lost in main merge The merge with main renamed indexerService.ts to indexer.service.ts and reduced indexerService.ts to a re-export, but dropped the dryRun preview functions (previewReset, previewReplay and their interfaces) that admin.routes.ts imports. Re-add them to indexer.service.ts so both the npm run build and docker build CI steps compile. --- backend/src/services/indexer.service.ts | 68 +++++++++++++++++++++++++ 1 file changed, 68 insertions(+) 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 () => { From 14ed8d9d3afbb92cfcbbdd5d552f10a9059ee85b Mon Sep 17 00:00:00 2001 From: oshowunm Date: Mon, 31 Aug 2026 08:09:14 +0100 Subject: [PATCH 3/3] test(backend): cover dry-run preview for admin indexer reset/replay The dryRun=true preview paths in admin.routes.ts were implemented without tests. Add six cases: preview responses for reset and replay, non-true dryRun values falling through to the real mutation, and preview failures returning 500 without mutating state. --- .../tests/integration/admin-metrics.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) 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(); + }); });