feat(iios): projectors checkpoint the projection cursor (P9)

All 5 projectors (route/ai/inbox/calendar/raw-event) now advance their
IiosProjectionCursor after a freshly-claimed event is applied — split into
claim → apply → advance so a duplicate never double-counts and a throw leaves
the cursor untouched (event replays via the DLQ). Inbox's two topics get two
cursor rows. Proves KG-06 replay reproduction at the projection level.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 02:44:23 +05:30
parent 1997fe48bb
commit 2424739e3a
11 changed files with 84 additions and 12 deletions
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { DlqService } from '../outbox/dlq.service';
import { resetDb } from '../test-utils/reset-db';
import { ProjectionCursorService } from '../projection/projection-cursor.service';
import { makeFakePorts } from '@insignia/iios-testkit';
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
import { signedFixture, emailFixture } from '@insignia/iios-adapter-sdk';
@@ -24,7 +25,7 @@ const SECRET = 'dev-adapter-secret'; // AdapterRegistry default
const registry = () => new AdapterRegistry();
const inbound = () => new InboundService(asService, registry());
const projector = () =>
new RawEventProjector(asService, new OutboxBus(), new DlqService(asService), registry(), new IngestService(asService, makeFakePorts(), new ActorResolver(asService)));
new RawEventProjector(asService, new OutboxBus(), new DlqService(asService), registry(), new IngestService(asService, makeFakePorts(), new ActorResolver(asService)), new ProjectionCursorService(asService));
async function rawReceivedEvents(): Promise<CloudEvent[]> {
const rows = await prisma.iiosOutboxEvent.findMany({ where: { eventType: IIOS_EVENTS.rawReceived }, orderBy: { createdAt: 'asc' } });
@@ -3,6 +3,7 @@ import { CloudEvent, IIOS_EVENTS } from '@insignia/iios-contracts';
import { PrismaService } from '../prisma/prisma.service';
import { OutboxBus } from '../outbox/outbox.bus';
import { DlqService } from '../outbox/dlq.service';
import { ProjectionCursorService } from '../projection/projection-cursor.service';
import { AdapterRegistry } from './adapter.registry';
import { IngestService } from '../interactions/ingest.service';
@@ -21,6 +22,7 @@ export class RawEventProjector implements OnModuleInit {
private readonly dlq: DlqService,
private readonly registry: AdapterRegistry,
private readonly ingest: IngestService,
private readonly cursor: ProjectionCursorService,
) {}
onModuleInit(): void {
@@ -29,7 +31,12 @@ export class RawEventProjector implements OnModuleInit {
}
async onRawReceived(event: CloudEvent): Promise<void> {
if (!(await this.claim(event.id))) return;
if (!(await this.claim(event.id))) return; // duplicate → no apply, no cursor advance
await this.applyRawReceived(event);
await this.cursor.advance(this.consumer, event);
}
private async applyRawReceived(event: CloudEvent): Promise<void> {
const { rawEventId } = event.data as { rawEventId: string };
const raw = await this.prisma.iiosInboundRawEvent.findUnique({ where: { id: rawEventId } });
if (!raw || raw.status !== 'RECEIVED') return;