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, 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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user