feat(service): P3.2 InboxProjector + message.read event + scheduled relay

Event-driven projector creates/collapses NEEDS_REPLY items from message.sent
(traceId propagated; inbox.item.created emitted) and resolves them to DONE on
message.read; idempotent via processed-event ledger. markRead now emits a
message.read outbox event (atomic). OutboxRelay runs on a 500ms timer in the
app (off in tests). Shared resetDb (TRUNCATE CASCADE) fixes cross-spec FK
isolation. 32 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 02:48:44 +05:30
parent 7b174e24fa
commit a2b95afaf0
13 changed files with 340 additions and 56 deletions
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { OutboxBus } from './outbox.bus';
@@ -11,14 +11,31 @@ import { OutboxBus } from './outbox.bus';
* A scheduler (P9) calls this on an interval; tests call it directly.
*/
@Injectable()
export class OutboxRelay {
export class OutboxRelay implements OnModuleInit, OnModuleDestroy {
private readonly consumer = 'outbox-relay';
private readonly logger = new Logger(OutboxRelay.name);
private timer?: ReturnType<typeof setInterval>;
constructor(
private readonly prisma: PrismaService,
private readonly bus: OutboxBus,
) {}
// Tests construct via `new OutboxRelay(...)`, so onModuleInit never runs there
// (no timer). The running app ticks the relay so events flow to consumers.
onModuleInit(): void {
const ms = Number(process.env.IIOS_RELAY_INTERVAL_MS ?? 500);
if (ms > 0) {
this.timer = setInterval(() => {
void this.relayOnce().catch((err) => this.logger.warn(`relay tick failed: ${(err as Error).message}`));
}, ms);
}
}
onModuleDestroy(): void {
if (this.timer) clearInterval(this.timer);
}
async relayOnce(batchSize = 100): Promise<number> {
const eventIds = await this.prisma.$transaction(async (tx) => {
const picked = await tx.$queryRaw<Array<{ eventId: string }>>`
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { resetDb } from '../test-utils/reset-db';
import { makeFakePorts, portalMessageBasic, replayTwice, isIdempotent } from '@insignia/iios-testkit';
import { IngestService } from '../interactions/ingest.service';
import { OutboxRelay } from './outbox.relay';
@@ -11,16 +12,7 @@ const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService;
async function clean(): Promise<void> {
await prisma.iiosOutboxEvent.deleteMany();
await prisma.iiosProcessedEvent.deleteMany();
await prisma.iiosMessagePart.deleteMany();
await prisma.iiosInteraction.deleteMany();
await prisma.iiosThreadParticipant.deleteMany();
await prisma.iiosThread.deleteMany();
await prisma.iiosActorRef.deleteMany();
await prisma.iiosSourceHandle.deleteMany();
await prisma.iiosChannel.deleteMany();
await prisma.iiosScope.deleteMany();
await resetDb(prisma);
}
beforeAll(async () => { await prisma.$connect(); });