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
@@ -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 { RouteService } from './route.service';
/**
@@ -21,6 +22,7 @@ export class RouteProjector implements OnModuleInit {
private readonly bus: OutboxBus,
private readonly dlq: DlqService,
private readonly routes: RouteService,
private readonly cursor: ProjectionCursorService,
) {}
onModuleInit(): void {
@@ -29,7 +31,12 @@ export class RouteProjector implements OnModuleInit {
}
async onNormalized(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.applyNormalized(event);
await this.cursor.advance(this.consumer, event);
}
private async applyNormalized(event: CloudEvent): Promise<void> {
const { interactionId } = event.data as { interactionId: string };
const interaction = await this.prisma.iiosInteraction.findUnique({
where: { id: interactionId },
@@ -3,6 +3,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient, IiosRouteMode } 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 { MessageService, type MessagePrincipal } from '../messaging/message.service';
import { ActorResolver } from '../identity/actor.resolver';
@@ -160,7 +161,7 @@ describe('RouteService approve/deny → execute (P6)', () => {
describe('RouteProjector — AUTOMATIC forwarding (P6)', () => {
const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry())), actors);
const projector = () => new RouteProjector(asService, new OutboxBus(), new DlqService(asService), routeSvc());
const projector = () => new RouteProjector(asService, new OutboxBus(), new DlqService(asService), routeSvc(), new ProjectionCursorService(asService));
// Ingest an inbound (channel-bearing) interaction — routable, unlike native DMs.
async function ingestInbound(text: string): Promise<{ interactionId: string; scopeId: string; event: CloudEvent }> {
@@ -223,4 +224,30 @@ describe('RouteProjector — AUTOMATIC forwarding (P6)', () => {
await p.onNormalized(event);
expect(await prisma.iiosOutboundCommand.count()).toBe(1);
});
it('checkpoints the projection cursor once per event, advancing the offset (P9 slice 7)', async () => {
const { scopeId, event } = await ingestInbound('good morning');
await automaticBinding(scopeId);
const p = projector();
await p.onNormalized(event);
let cursor = await prisma.iiosProjectionCursor.findFirstOrThrow({ where: { projectionName: 'route-projector' } });
expect(cursor.sourceTopic).toBe(event.type);
expect(cursor.lastOffset).toBe(1);
expect(cursor.lastEventId).toBe(event.id);
// Replaying the same event does NOT double-advance (claim gate).
await p.onNormalized(event);
cursor = await prisma.iiosProjectionCursor.findFirstOrThrow({ where: { projectionName: 'route-projector' } });
expect(cursor.lastOffset).toBe(1);
// A second, distinct normalized event advances the high-water-mark to 2.
const second = await ingestInbound('afternoon all');
await automaticBinding(second.scopeId);
await p.onNormalized(second.event);
cursor = await prisma.iiosProjectionCursor.findFirstOrThrow({
where: { projectionName: 'route-projector', partitionKey: second.scopeId },
});
expect(cursor.lastOffset).toBe(2);
});
});