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
@@ -4,6 +4,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';
interface MessageSentData {
interactionId: string;
@@ -30,6 +31,7 @@ export class InboxProjector implements OnModuleInit {
private readonly prisma: PrismaService,
private readonly bus: OutboxBus,
private readonly dlq: DlqService,
private readonly cursor: ProjectionCursorService,
) {}
onModuleInit(): void {
@@ -39,7 +41,12 @@ export class InboxProjector implements OnModuleInit {
}
async onMessageSent(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.applyMessageSent(event);
await this.cursor.advance(this.consumer, event);
}
private async applyMessageSent(event: CloudEvent): Promise<void> {
const data = event.data as MessageSentData;
const traceId = event.insignia?.correlationId;
const thread = await this.prisma.iiosThread.findUnique({ where: { id: data.threadId } });
@@ -53,7 +60,12 @@ export class InboxProjector implements OnModuleInit {
}
async onMessageRead(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.applyMessageRead(event);
await this.cursor.advance(this.consumer, event);
}
private async applyMessageRead(event: CloudEvent): Promise<void> {
const data = event.data as MessageReadData;
const item = await this.prisma.iiosInboxItem.findFirst({
where: {