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,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 } from '@insignia/iios-testkit';
import { MessageService, type MessagePrincipal } from './message.service';
import { ActorResolver } from '../identity/actor.resolver';
@@ -14,18 +15,7 @@ const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'po
const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' };
async function clean(): Promise<void> {
await prisma.iiosUnreadCounter.deleteMany();
await prisma.iiosMessageReceipt.deleteMany();
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);
}
async function actorIdFor(userId: string): Promise<string> {