feat(iios): idempotency-command ledger + IdempotencyService (P9)

Adds the doc-mandated IiosIdempotencyCommand table + a reusable
IdempotencyService.run() that writes IN_FLIGHT at command start, replays the
cached response on a same-key/same-body retry, rejects a same-key/different-body
reuse (409), and retries a FAILED command. Tenant-scoped via
@@unique([scopeId, commandName, idempotencyKey]) (KG-02/KG-06).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 02:19:03 +05:30
parent dbf8f814c0
commit b8b7e0d3e8
7 changed files with 231 additions and 1 deletions
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "IiosIdempotencyCommand" (
"id" TEXT NOT NULL,
"scopeId" TEXT,
"commandName" TEXT NOT NULL,
"idempotencyKey" TEXT NOT NULL,
"schemaVersion" TEXT NOT NULL DEFAULT 'v1',
"actorRefId" TEXT,
"requestHash" TEXT NOT NULL,
"responseHash" TEXT,
"response" JSONB,
"status" TEXT NOT NULL DEFAULT 'IN_FLIGHT',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMP(3),
CONSTRAINT "IiosIdempotencyCommand_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "IiosIdempotencyCommand_scopeId_status_idx" ON "IiosIdempotencyCommand"("scopeId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "IiosIdempotencyCommand_scopeId_commandName_idempotencyKey_key" ON "IiosIdempotencyCommand"("scopeId", "commandName", "idempotencyKey");
@@ -492,6 +492,26 @@ model IiosProcessedEvent {
@@id([consumerName, eventId]) @@id([consumerName, eventId])
} }
/// Idempotent-command ledger (P9). Suppresses duplicate externally-visible mutations:
/// same (scope, command, key) replays the cached response; a different request → conflict.
model IiosIdempotencyCommand {
id String @id @default(cuid())
scopeId String?
commandName String
idempotencyKey String
schemaVersion String @default("v1")
actorRefId String?
requestHash String
responseHash String?
response Json?
status String @default("IN_FLIGHT") // IN_FLIGHT | COMPLETED | FAILED
createdAt DateTime @default(now())
completedAt DateTime?
@@unique([scopeId, commandName, idempotencyKey])
@@index([scopeId, status])
}
// ─── P2: messaging — receipts & unread ──────────────────────────── // ─── P2: messaging — receipts & unread ────────────────────────────
/// Delivery/read receipt for a message interaction, per actor. /// Delivery/read receipt for a message interaction, per actor.
+2
View File
@@ -3,6 +3,7 @@ import { PrismaModule } from './prisma/prisma.module';
import { PlatformModule } from './platform/platform.module'; import { PlatformModule } from './platform/platform.module';
import { IdentityModule } from './identity/identity.module'; import { IdentityModule } from './identity/identity.module';
import { InteractionsModule } from './interactions/interactions.module'; import { InteractionsModule } from './interactions/interactions.module';
import { IdempotencyModule } from './idempotency/idempotency.module';
import { OutboxModule } from './outbox/outbox.module'; import { OutboxModule } from './outbox/outbox.module';
import { ThreadsModule } from './threads/threads.module'; import { ThreadsModule } from './threads/threads.module';
import { MessageModule } from './messaging/message.module'; import { MessageModule } from './messaging/message.module';
@@ -23,6 +24,7 @@ import { DevController } from './dev/dev.controller';
PlatformModule, PlatformModule,
IdentityModule, IdentityModule,
InteractionsModule, InteractionsModule,
IdempotencyModule,
OutboxModule, OutboxModule,
ThreadsModule, ThreadsModule,
MessageModule, MessageModule,
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { IdempotencyService } from './idempotency.service';
/** Command-idempotency ledger, global so any feature module can wrap its commands. */
@Global()
@Module({
providers: [IdempotencyService],
exports: [IdempotencyService],
})
export class IdempotencyModule {}
@@ -0,0 +1,89 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { ConflictException } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { resetDb } from '../test-utils/reset-db';
import { IdempotencyService } from './idempotency.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;
const svc = () => new IdempotencyService(asService);
const CMD = 'test.command';
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });
describe('IdempotencyService (P9 — command ledger)', () => {
it('runs fn once, returns its result, and records a COMPLETED row with the response', async () => {
let calls = 0;
const result = await svc().run({ scopeId: 's1', commandName: CMD, key: 'k1', request: { a: 1 } }, async () => {
calls++;
return { id: 'r1', ok: true };
});
expect(result).toEqual({ id: 'r1', ok: true });
expect(calls).toBe(1);
const row = await prisma.iiosIdempotencyCommand.findFirstOrThrow({ where: { scopeId: 's1', commandName: CMD, idempotencyKey: 'k1' } });
expect(row.status).toBe('COMPLETED');
expect(row.response).toEqual({ id: 'r1', ok: true });
expect(row.completedAt).toBeTruthy();
});
it('replays the cached response on a duplicate (same request) without re-running fn', async () => {
let calls = 0;
const params = { scopeId: 's1', commandName: CMD, key: 'k1', request: { a: 1 } };
const first = await svc().run(params, async () => { calls++; return { id: 'r1' }; });
const second = await svc().run(params, async () => { calls++; return { id: 'r2-should-not-run' }; });
expect(second).toEqual(first);
expect(calls).toBe(1); // fn never ran the second time
expect(await prisma.iiosIdempotencyCommand.count()).toBe(1);
});
it('rejects the same key reused with a different request (fn not called)', async () => {
let calls = 0;
await svc().run({ scopeId: 's1', commandName: CMD, key: 'k1', request: { a: 1 } }, async () => { calls++; return { id: 'r1' }; });
await expect(
svc().run({ scopeId: 's1', commandName: CMD, key: 'k1', request: { a: 2 } }, async () => { calls++; return { id: 'r2' }; }),
).rejects.toBeInstanceOf(ConflictException);
expect(calls).toBe(1);
});
it('marks FAILED on throw + propagates, and a later run with the same key retries to COMPLETED', async () => {
let calls = 0;
const params = { scopeId: 's1', commandName: CMD, key: 'k1', request: { a: 1 } };
await expect(
svc().run(params, async () => { calls++; throw new Error('boom'); }),
).rejects.toThrow('boom');
let row = await prisma.iiosIdempotencyCommand.findFirstOrThrow({ where: { scopeId: 's1', commandName: CMD, idempotencyKey: 'k1' } });
expect(row.status).toBe('FAILED');
const retry = await svc().run(params, async () => { calls++; return { id: 'r-ok' }; });
expect(retry).toEqual({ id: 'r-ok' });
expect(calls).toBe(2); // failed attempt + successful retry
row = await prisma.iiosIdempotencyCommand.findFirstOrThrow({ where: { scopeId: 's1', commandName: CMD, idempotencyKey: 'k1' } });
expect(row.status).toBe('COMPLETED');
});
it('a concurrent IN_FLIGHT row → conflict (command in progress)', async () => {
await prisma.iiosIdempotencyCommand.create({
data: { scopeId: 's1', commandName: CMD, idempotencyKey: 'k1', requestHash: 'irrelevant', status: 'IN_FLIGHT' },
});
// Same requestHash so we reach the IN_FLIGHT branch, not the mismatch branch.
const { createHash } = await import('node:crypto');
const rh = createHash('sha256').update('{"a":1}').digest('hex');
await prisma.iiosIdempotencyCommand.updateMany({ where: { idempotencyKey: 'k1' }, data: { requestHash: rh } });
await expect(
svc().run({ scopeId: 's1', commandName: CMD, key: 'k1', request: { a: 1 } }, async () => ({ id: 'r' })),
).rejects.toThrow(/in progress/);
});
it('the same command+key under two different scopes are independent (tenant isolation)', async () => {
const a = await svc().run({ scopeId: 'scope-A', commandName: CMD, key: 'k1', request: { a: 1 } }, async () => ({ id: 'A' }));
const b = await svc().run({ scopeId: 'scope-B', commandName: CMD, key: 'k1', request: { a: 1 } }, async () => ({ id: 'B' }));
expect(a).toEqual({ id: 'A' });
expect(b).toEqual({ id: 'B' }); // B did not replay A's response
expect(await prisma.iiosIdempotencyCommand.count()).toBe(2);
});
});
@@ -0,0 +1,86 @@
import { ConflictException, Injectable } from '@nestjs/common';
import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
const hash = (v: unknown) => createHash('sha256').update(stable(v)).digest('hex');
// Stable stringify: sort object keys so {a,b} and {b,a} hash equal.
const stable = (v: unknown): string =>
v === null || typeof v !== 'object'
? JSON.stringify(v) ?? 'null'
: Array.isArray(v)
? `[${v.map(stable).join(',')}]`
: `{${Object.keys(v as object)
.sort()
.map((k) => `${JSON.stringify(k)}:${stable((v as Record<string, unknown>)[k])}`)
.join(',')}}`;
export interface IdempotencyParams {
scopeId?: string;
commandName: string;
key: string;
request: unknown;
actorRefId?: string;
}
/**
* Idempotent-command ledger (P9, KG-06). Wraps an externally-visible mutation so a
* retry with the same (scope, command, key) replays the cached response instead of
* re-executing; the same key with a different request body is rejected as a conflict.
* Write IN_FLIGHT at command start → COMPLETED (+ cached response) or FAILED on throw.
*/
@Injectable()
export class IdempotencyService {
constructor(private readonly prisma: PrismaService) {}
async run<T>(p: IdempotencyParams, fn: () => Promise<T>): Promise<T> {
const requestHash = hash(p.request);
const scopeId = p.scopeId ?? null;
const where = { scopeId, commandName: p.commandName, idempotencyKey: p.key };
const existing = await this.prisma.iiosIdempotencyCommand.findFirst({ where });
if (existing) return this.resolveExisting<T>(where, existing, requestHash, fn);
try {
await this.prisma.iiosIdempotencyCommand.create({
data: { scopeId, commandName: p.commandName, idempotencyKey: p.key, requestHash, actorRefId: p.actorRefId },
});
} catch {
// Lost the create race: another caller inserted the same (scope, command, key).
const row = await this.prisma.iiosIdempotencyCommand.findFirst({ where });
if (row) return this.resolveExisting<T>(where, row, requestHash, fn);
throw new ConflictException('idempotent command conflict');
}
return this.executeAndRecord<T>(where, fn);
}
private async resolveExisting<T>(
where: { scopeId: string | null; commandName: string; idempotencyKey: string },
row: { status: string; requestHash: string; response: unknown },
requestHash: string,
fn: () => Promise<T>,
): Promise<T> {
if (row.requestHash !== requestHash) throw new ConflictException('idempotency key reused with a different request');
if (row.status === 'COMPLETED') return row.response as T; // replay — no re-execution
if (row.status === 'IN_FLIGHT') throw new ConflictException('command in progress');
return this.executeAndRecord<T>(where, fn); // FAILED → retry + re-record the outcome
}
/** Run the command, then record COMPLETED (+ cached response) or FAILED against its ledger row. */
private async executeAndRecord<T>(
where: { scopeId: string | null; commandName: string; idempotencyKey: string },
fn: () => Promise<T>,
): Promise<T> {
try {
const result = await fn();
await this.prisma.iiosIdempotencyCommand.updateMany({
where,
data: { status: 'COMPLETED', responseHash: hash(result), response: result as object, completedAt: new Date() },
});
return result;
} catch (err) {
await this.prisma.iiosIdempotencyCommand.updateMany({ where, data: { status: 'FAILED', completedAt: new Date() } });
throw err;
}
}
}
@@ -8,7 +8,7 @@ import type { PrismaClient } from '@prisma/client';
export async function resetDb(prisma: PrismaClient): Promise<void> { export async function resetDb(prisma: PrismaClient): Promise<void> {
await prisma.$executeRawUnsafe( await prisma.$executeRawUnsafe(
`TRUNCATE TABLE `TRUNCATE TABLE
"IiosDlqItem","IiosAuditLink", "IiosIdempotencyCommand","IiosDlqItem","IiosAuditLink",
"IiosMeetingActionItem","IiosActionItem","IiosTranscriptSegment","IiosMeetingTranscript","IiosMeetingSummary","IiosMeetingParticipant","IiosMeeting","IiosMeetingRequest", "IiosMeetingActionItem","IiosActionItem","IiosTranscriptSegment","IiosMeetingTranscript","IiosMeetingSummary","IiosMeetingParticipant","IiosMeeting","IiosMeetingRequest",
"IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow", "IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow",
"IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef", "IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef",