Skip to content

Commit cef153f

Browse files
authored
Merge pull request #203 from wheval/fix/issues-188
fix(admin): add dry-run mode for indexer replay
2 parents 26eabdf + 1d4e6c5 commit cef153f

3 files changed

Lines changed: 129 additions & 8 deletions

File tree

docs/read-model-rebuild.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,22 @@ Source of truth (events / primary tables)
7878

7979
Duration depends on DB instance size, network, and whether indexes are rebuilt inline or deferred.
8080

81+
## Indexer replay dry-run
82+
83+
Use the admin replay endpoint in dry-run mode to validate replay inputs without producing audit-write side effects.
84+
85+
```bash
86+
curl -X POST "$API_BASE_URL/admin/indexer/replay" \
87+
-H "Content-Type: application/json" \
88+
-H "x-admin-id: <admin-id>" \
89+
-d '{"startLedger": 123456, "dryRun": true}'
90+
```
91+
92+
Notes:
93+
- `dryRun` defaults to `false`; omit it to run a normal replay initiation.
94+
- In dry-run mode, the response includes `dryRun: true` and no audit event is written.
95+
- `startLedger` must be a positive integer in both dry-run and normal mode.
96+
8197
## Rollback guidance
8298

8399
If the rebuild produces incorrect data:
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { httpReplayIndexerEvents } from './admin.controllers';
2+
import { emitAuditEvent } from '../../utils/audit.utils';
3+
import { AdminRequest } from '../../middlewares/admin-guard.middleware';
4+
import { Response } from 'express';
5+
6+
jest.mock('../../utils/prisma.utils', () => ({
7+
prisma: {
8+
creatorProfile: {
9+
findUnique: jest.fn(),
10+
update: jest.fn(),
11+
},
12+
},
13+
}));
14+
15+
jest.mock('../../utils/audit.utils', () => ({
16+
emitAuditEvent: jest.fn(),
17+
}));
18+
19+
describe('httpReplayIndexerEvents', () => {
20+
const next = jest.fn();
21+
22+
const createRes = (): Response =>
23+
({
24+
status: jest.fn().mockReturnThis(),
25+
json: jest.fn(),
26+
}) as unknown as Response;
27+
28+
beforeEach(() => {
29+
jest.clearAllMocks();
30+
});
31+
32+
it('returns validation error when dryRun is not a boolean', async () => {
33+
const req = {
34+
body: { startLedger: 10, dryRun: 'true' },
35+
adminId: 'admin-1',
36+
} as unknown as AdminRequest;
37+
const res = createRes();
38+
39+
await httpReplayIndexerEvents(req, res, next);
40+
41+
expect(res.status).toHaveBeenCalledWith(400);
42+
expect(res.json).toHaveBeenCalledWith(
43+
expect.objectContaining({
44+
success: false,
45+
error: expect.objectContaining({
46+
message: 'Invalid request body',
47+
details: expect.arrayContaining([expect.objectContaining({ field: 'dryRun' })]),
48+
}),
49+
})
50+
);
51+
expect(emitAuditEvent).not.toHaveBeenCalled();
52+
});
53+
54+
it('does not emit audit event when dryRun=true', async () => {
55+
const req = {
56+
body: { startLedger: 20, dryRun: true },
57+
adminId: 'admin-2',
58+
} as unknown as AdminRequest;
59+
const res = createRes();
60+
61+
await httpReplayIndexerEvents(req, res, next);
62+
63+
expect(emitAuditEvent).not.toHaveBeenCalled();
64+
expect(res.status).toHaveBeenCalledWith(200);
65+
expect(res.json).toHaveBeenCalledWith(
66+
expect.objectContaining({
67+
success: true,
68+
data: expect.objectContaining({
69+
type: 'INDEXER_REPLAY_INITIATED',
70+
startLedger: 20,
71+
dryRun: true,
72+
initiatedBy: 'admin-2',
73+
}),
74+
})
75+
);
76+
});
77+
78+
it('emits audit event when dryRun=false', async () => {
79+
const req = {
80+
body: { startLedger: 30, dryRun: false },
81+
adminId: 'admin-3',
82+
} as unknown as AdminRequest;
83+
const res = createRes();
84+
85+
await httpReplayIndexerEvents(req, res, next);
86+
87+
expect(emitAuditEvent).toHaveBeenCalledWith(
88+
expect.objectContaining({
89+
actor: 'admin-3',
90+
action: 'replay_indexer_events',
91+
targetId: '30',
92+
metadata: expect.objectContaining({ startLedger: 30, dryRun: false }),
93+
})
94+
);
95+
expect(res.status).toHaveBeenCalledWith(200);
96+
});
97+
});

src/modules/admin/admin.controllers.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -98,29 +98,37 @@ export const httpUpdateCreatorMetadata: AsyncController = async (
9898

9999
export const httpReplayIndexerEvents: AsyncController = async (req: AdminRequest, res: Response, next) => {
100100
try {
101-
const { startLedger } = req.body as { startLedger?: number };
101+
const { startLedger, dryRun = false } = req.body as { startLedger?: number; dryRun?: boolean };
102102
const adminId = req.adminId;
103103

104104
if (typeof startLedger !== 'number' || startLedger < 1) {
105105
return sendValidationError(res, 'Invalid request body', [
106106
{ field: 'startLedger', message: 'startLedger must be a positive integer' },
107107
]);
108108
}
109+
if (typeof dryRun !== 'boolean') {
110+
return sendValidationError(res, 'Invalid request body', [
111+
{ field: 'dryRun', message: 'dryRun must be a boolean' },
112+
]);
113+
}
109114

110115
const replayInitiated = {
111116
type: 'INDEXER_REPLAY_INITIATED',
112117
startLedger,
118+
dryRun,
113119
initiatedBy: adminId,
114120
timestamp: new Date().toISOString(),
115121
};
116122

117-
await emitAuditEvent({
118-
actor: adminId || 'unknown',
119-
action: 'replay_indexer_events',
120-
target: 'IndexerQueue',
121-
targetId: String(startLedger),
122-
metadata: { startLedger },
123-
});
123+
if (!dryRun) {
124+
await emitAuditEvent({
125+
actor: adminId || 'unknown',
126+
action: 'replay_indexer_events',
127+
target: 'IndexerQueue',
128+
targetId: String(startLedger),
129+
metadata: { startLedger, dryRun },
130+
});
131+
}
124132

125133
sendSuccess(res, replayInitiated);
126134
} catch (error) {

0 commit comments

Comments
 (0)