feat(service): outbox relay + thread read endpoint + replay (P1.5, P1 complete)

OutboxRelay: FOR UPDATE SKIP LOCKED claim, in-process bus publish, PENDING->
PUBLISHED with 30s backoff + idempotent-consumer ledger. GET /v1/threads/:id/
messages (policy-scoped). Replay + read specs green; vitest fileParallelism off
(shared DB). End-to-end HTTP smoke verified: idempotent ingest, read-back, 400
on missing key. 19 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 21:29:00 +05:30
parent 4b7dc98c6b
commit 072c541e09
10 changed files with 279 additions and 1 deletions
@@ -0,0 +1,46 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import type { IiosPlatformPorts } from '@insignia/iios-contracts';
import { PrismaService } from '../prisma/prisma.service';
import { PLATFORM_PORTS } from '../platform/platform-ports';
import { decideOrThrow } from '../platform/fail-closed';
export interface ThreadMessage {
interactionId: string;
actorId: string | null;
kind: string;
occurredAt: Date;
parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null }>;
}
@Injectable()
export class ThreadsService {
constructor(
private readonly prisma: PrismaService,
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
) {}
async getMessages(threadId: string): Promise<{ threadId: string; messages: ThreadMessage[] }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
// Reads are policy-scoped too (fail-closed).
await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId });
const interactions = await this.prisma.iiosInteraction.findMany({
where: { threadId },
orderBy: { occurredAt: 'asc' },
include: { parts: { orderBy: { partIndex: 'asc' } } },
});
return {
threadId,
messages: interactions.map((i) => ({
interactionId: i.id,
actorId: i.actorId,
kind: i.kind,
occurredAt: i.occurredAt,
parts: i.parts.map((p) => ({ kind: p.kind, bodyText: p.bodyText, contentRef: p.contentRef })),
})),
};
}
}