fix(iios): thread subject on create, graceful open_thread error, isolated test DB
- openThread/createThread accept a generic `subject` (group name) — stored on the
thread, echoed via listThreads; kernel never interprets it.
- Gateway open_thread now returns an ACK'd { error } on failure instead of throwing
(which never acked → clients hung on "loading"). Non-member/missing-thread opens
fail cleanly.
- Tests run against an isolated `iios_test` database (vitest globalSetup creates +
migrates it; test.env overrides DATABASE_URL) so `pnpm test` can never TRUNCATE the
dev database again. Verified: full suite green, dev DB row counts unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -66,11 +66,20 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('open_thread')
|
@SubscribeMessage('open_thread')
|
||||||
async openThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId?: string; membership?: string; creatorRole?: string }) {
|
async openThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId?: string; membership?: string; creatorRole?: string; subject?: string }) {
|
||||||
const { principal } = client.data as SocketState;
|
const { principal } = client.data as SocketState;
|
||||||
const result = await this.messages.openThread(body?.threadId ?? null, principal, { membership: body?.membership, creatorRole: body?.creatorRole });
|
try {
|
||||||
await client.join(result.threadId);
|
const result = await this.messages.openThread(body?.threadId ?? null, principal, {
|
||||||
return result;
|
membership: body?.membership,
|
||||||
|
creatorRole: body?.creatorRole,
|
||||||
|
subject: body?.subject,
|
||||||
|
});
|
||||||
|
await client.join(result.threadId);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
// Fail to an ACK'd error instead of throwing (which never acks → client hangs on "loading").
|
||||||
|
return { error: (err as Error).message ?? 'could not open the conversation' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('add_participant')
|
@SubscribeMessage('add_participant')
|
||||||
|
|||||||
@@ -59,17 +59,18 @@ export class MessageService {
|
|||||||
* for a group. Opening an existing thread is a GOVERNED join: only an existing member may
|
* for a group. Opening an existing thread is a GOVERNED join: only an existing member may
|
||||||
* re-open it (policy `iios.thread.join`) — new members enter via addParticipant.
|
* re-open it (policy `iios.thread.join`) — new members enter via addParticipant.
|
||||||
*/
|
*/
|
||||||
async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string }): Promise<OpenThreadResult> {
|
async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string }): Promise<OpenThreadResult> {
|
||||||
if (!threadId) {
|
if (!threadId) {
|
||||||
await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal });
|
await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal });
|
||||||
const scope = await this.actors.resolveScope(principal);
|
const scope = await this.actors.resolveScope(principal);
|
||||||
const actor = await this.actors.resolveActor(scope.id, principal);
|
const actor = await this.actors.resolveActor(scope.id, principal);
|
||||||
// `membership` + `creatorRole` are generic, app-supplied thread attributes — the kernel
|
// `membership`/`creatorRole`/`subject` are generic, app-supplied thread attributes — the
|
||||||
// stores/echoes them but never branches on their chat meaning (that lives in policy + app).
|
// kernel stores/echoes them but never branches on their chat meaning (that lives in policy + app).
|
||||||
const thread = await this.prisma.iiosThread.create({
|
const thread = await this.prisma.iiosThread.create({
|
||||||
data: {
|
data: {
|
||||||
scopeId: scope.id,
|
scopeId: scope.id,
|
||||||
createdByActorId: actor.id,
|
createdByActorId: actor.id,
|
||||||
|
subject: opts?.subject?.trim() || undefined,
|
||||||
metadata: opts?.membership ? ({ membership: opts.membership } as Prisma.InputJsonValue) : undefined,
|
metadata: opts?.membership ? ({ membership: opts.membership } as Prisma.InputJsonValue) : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ export class ThreadsController {
|
|||||||
return this.messages.listThreads(this.principal(auth));
|
return this.messages.listThreads(this.principal(auth));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a thread; `membership`/`creatorRole` are opaque, app-supplied thread attributes the kernel stores but never interprets. */
|
/** Create a thread; `membership`/`creatorRole`/`subject` are opaque, app-supplied attributes the kernel stores but never interprets. */
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(201)
|
@HttpCode(201)
|
||||||
async createThread(@Body() body: { membership?: string; creatorRole?: string }, @Headers('authorization') auth?: string) {
|
async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string }, @Headers('authorization') auth?: string) {
|
||||||
return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole });
|
return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */
|
/** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
// Tests TRUNCATE between cases, so they MUST NOT touch the dev database. Run them
|
||||||
|
// against an isolated `iios_test` DB (created + migrated here, once, before the suite).
|
||||||
|
const TEST_URL = 'postgresql://iios:iios@localhost:5434/iios_test?schema=public';
|
||||||
|
|
||||||
|
export default function setup() {
|
||||||
|
try {
|
||||||
|
execSync(`docker exec iios-db psql -U iios -d postgres -c "CREATE DATABASE iios_test"`, { stdio: 'pipe' });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = String(e.stderr ?? e.stdout ?? e);
|
||||||
|
if (!/already exists/i.test(msg)) {
|
||||||
|
console.warn(`[vitest] could not create iios_test (is the iios-db container up?): ${msg.slice(0, 160)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
execSync('pnpm --filter @insignia/iios-service exec prisma migrate deploy', {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: { ...process.env, DATABASE_URL: TEST_URL },
|
||||||
|
});
|
||||||
|
}
|
||||||
+7
-3
@@ -15,9 +15,13 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
include: ['packages/**/src/**/*.{test,spec}.ts', 'test/**/*.{test,spec}.ts'],
|
include: ['packages/**/src/**/*.{test,spec}.ts', 'test/**/*.{test,spec}.ts'],
|
||||||
// DB-backed specs share one Postgres and TRUNCATE between tests. Run every
|
// DB-backed specs TRUNCATE between tests, so they run against an ISOLATED
|
||||||
// file in a single worker process, sequentially, so there is no cross-file
|
// `iios_test` database (created + migrated by the global setup) — never the dev
|
||||||
// race on the shared database.
|
// DB. This env overrides any DATABASE_URL from the shell.
|
||||||
|
env: { DATABASE_URL: 'postgresql://iios:iios@localhost:5434/iios_test?schema=public' },
|
||||||
|
globalSetup: ['./scripts/vitest-global-setup.mjs'],
|
||||||
|
// Run every file in a single worker process, sequentially, so there is no
|
||||||
|
// cross-file race on the shared database.
|
||||||
fileParallelism: false,
|
fileParallelism: false,
|
||||||
sequence: { concurrent: false },
|
sequence: { concurrent: false },
|
||||||
pool: 'forks',
|
pool: 'forks',
|
||||||
|
|||||||
Reference in New Issue
Block a user