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:
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ThreadsService } from './threads.service';
|
||||
|
||||
@Controller('v1/threads')
|
||||
export class ThreadsController {
|
||||
constructor(private readonly threads: ThreadsService) {}
|
||||
|
||||
@Get(':id/messages')
|
||||
async messages(@Param('id') id: string) {
|
||||
return this.threads.getMessages(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ThreadsController } from './threads.controller';
|
||||
import { ThreadsService } from './threads.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ThreadsController],
|
||||
providers: [ThreadsService],
|
||||
exports: [ThreadsService],
|
||||
})
|
||||
export class ThreadsModule {}
|
||||
@@ -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 })),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { makeFakePorts, portalMessageBasic } from '@insignia/iios-testkit';
|
||||
import { IngestService } from '../interactions/ingest.service';
|
||||
import { ThreadsService } from './threads.service';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
||||
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();
|
||||
}
|
||||
|
||||
beforeAll(async () => { await prisma.$connect(); });
|
||||
afterAll(async () => { await prisma.$disconnect(); });
|
||||
beforeEach(async () => { await clean(); });
|
||||
|
||||
describe('GET thread messages', () => {
|
||||
it('returns the stored message part for the thread', async () => {
|
||||
const ports = makeFakePorts();
|
||||
const ingest = new IngestService(asService, ports);
|
||||
const res = await ingest.ingest(portalMessageBasic, 'idem-thread-1');
|
||||
|
||||
const threads = new ThreadsService(asService, ports);
|
||||
const out = await threads.getMessages(res.threadId);
|
||||
|
||||
expect(out.messages).toHaveLength(1);
|
||||
expect(out.messages[0]?.parts[0]?.bodyText).toBe('my payment failed');
|
||||
expect(out.messages[0]?.kind).toBe('MESSAGE');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user