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
16 changes: 16 additions & 0 deletions docs/read-model-rebuild.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,22 @@ Source of truth (events / primary tables)

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

## Indexer replay dry-run

Use the admin replay endpoint in dry-run mode to validate replay inputs without producing audit-write side effects.

```bash
curl -X POST "$API_BASE_URL/admin/indexer/replay" \
-H "Content-Type: application/json" \
-H "x-admin-id: <admin-id>" \
-d '{"startLedger": 123456, "dryRun": true}'
```

Notes:
- `dryRun` defaults to `false`; omit it to run a normal replay initiation.
- In dry-run mode, the response includes `dryRun: true` and no audit event is written.
- `startLedger` must be a positive integer in both dry-run and normal mode.

## Rollback guidance

If the rebuild produces incorrect data:
Expand Down
97 changes: 97 additions & 0 deletions src/modules/admin/admin.controllers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { httpReplayIndexerEvents } from './admin.controllers';
import { emitAuditEvent } from '../../utils/audit.utils';
import { AdminRequest } from '../../middlewares/admin-guard.middleware';
import { Response } from 'express';

jest.mock('../../utils/prisma.utils', () => ({
prisma: {
creatorProfile: {
findUnique: jest.fn(),
update: jest.fn(),
},
},
}));

jest.mock('../../utils/audit.utils', () => ({
emitAuditEvent: jest.fn(),
}));

describe('httpReplayIndexerEvents', () => {
const next = jest.fn();

const createRes = (): Response =>
({
status: jest.fn().mockReturnThis(),
json: jest.fn(),
}) as unknown as Response;

beforeEach(() => {
jest.clearAllMocks();
});

it('returns validation error when dryRun is not a boolean', async () => {
const req = {
body: { startLedger: 10, dryRun: 'true' },
adminId: 'admin-1',
} as unknown as AdminRequest;
const res = createRes();

await httpReplayIndexerEvents(req, res, next);

expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: expect.objectContaining({
message: 'Invalid request body',
details: expect.arrayContaining([expect.objectContaining({ field: 'dryRun' })]),
}),
})
);
expect(emitAuditEvent).not.toHaveBeenCalled();
});

it('does not emit audit event when dryRun=true', async () => {
const req = {
body: { startLedger: 20, dryRun: true },
adminId: 'admin-2',
} as unknown as AdminRequest;
const res = createRes();

await httpReplayIndexerEvents(req, res, next);

expect(emitAuditEvent).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
data: expect.objectContaining({
type: 'INDEXER_REPLAY_INITIATED',
startLedger: 20,
dryRun: true,
initiatedBy: 'admin-2',
}),
})
);
});

it('emits audit event when dryRun=false', async () => {
const req = {
body: { startLedger: 30, dryRun: false },
adminId: 'admin-3',
} as unknown as AdminRequest;
const res = createRes();

await httpReplayIndexerEvents(req, res, next);

expect(emitAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
actor: 'admin-3',
action: 'replay_indexer_events',
targetId: '30',
metadata: expect.objectContaining({ startLedger: 30, dryRun: false }),
})
);
expect(res.status).toHaveBeenCalledWith(200);
});
});
24 changes: 16 additions & 8 deletions src/modules/admin/admin.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,29 +84,37 @@ export const httpUpdateCreatorMetadata: AsyncController = async (req, res, next)

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

if (typeof startLedger !== 'number' || startLedger < 1) {
return sendValidationError(res, 'Invalid request body', [
{ field: 'startLedger', message: 'startLedger must be a positive integer' },
]);
}
if (typeof dryRun !== 'boolean') {
return sendValidationError(res, 'Invalid request body', [
{ field: 'dryRun', message: 'dryRun must be a boolean' },
]);
}

const replayInitiated = {
type: 'INDEXER_REPLAY_INITIATED',
startLedger,
dryRun,
initiatedBy: adminId,
timestamp: new Date().toISOString(),
};

await emitAuditEvent({
actor: adminId || 'unknown',
action: 'replay_indexer_events',
target: 'IndexerQueue',
targetId: String(startLedger),
metadata: { startLedger },
});
if (!dryRun) {
await emitAuditEvent({
actor: adminId || 'unknown',
action: 'replay_indexer_events',
target: 'IndexerQueue',
targetId: String(startLedger),
metadata: { startLedger, dryRun },
});
}

sendSuccess(res, replayInitiated);
} catch (error) {
Expand Down
Loading