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:
@@ -3,6 +3,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, makeFakeInference } from '@insignia/iios-testkit';
|
||||
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
|
||||
import { ActorResolver } from '../identity/actor.resolver';
|
||||
@@ -105,7 +106,7 @@ describe('AI × routing (P7, KG-05: AI proposes, never approves)', () => {
|
||||
|
||||
it('AiProjector auto-classifies a channel-bearing interaction, idempotently', async () => {
|
||||
const { interactionId, event } = await ingestInbound('There is a party at 9 PM tonight!');
|
||||
const p = new AiProjector(asService, new OutboxBus(), new DlqService(asService), ai());
|
||||
const p = new AiProjector(asService, new OutboxBus(), new DlqService(asService), ai(), new ProjectionCursorService(asService));
|
||||
await p.onNormalized(event);
|
||||
await p.onNormalized(event); // replay
|
||||
expect(await prisma.iiosAiJob.count({ where: { interactionId, jobType: 'CLASSIFY' } })).toBe(1);
|
||||
|
||||
@@ -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 { AiJobService } from './ai.service';
|
||||
|
||||
/**
|
||||
@@ -21,6 +22,7 @@ export class AiProjector implements OnModuleInit {
|
||||
private readonly bus: OutboxBus,
|
||||
private readonly dlq: DlqService,
|
||||
private readonly ai: AiJobService,
|
||||
private readonly cursor: ProjectionCursorService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
@@ -29,7 +31,12 @@ export class AiProjector 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 },
|
||||
|
||||
Reference in New Issue
Block a user