/** * InMemoryTopicStore — reference in-memory implementation of * {@link TopicStore}. * * Mirrors the write-time CAS contract of the disk store: every * `updateTopic` compares the supplied `ownerVersion` against the persisted * copy and rejects with `StaleTopicError` on mismatch. Convention #17: * cross-tenant access throws `TenantIsolationError` with no fallback. * * NZ-TOPIC-01 renamed this from `InMemoryThreadStore` (moved from * `store/thread/memory.ts`) and re-exported the old name as an identity * binding. NZ-TOPIC-05 removed that re-export, once 28.0.0 had carried the * deprecation to the registry — the class is reachable only as * `InMemoryTopicStore` now. */ import { StaleTopicError, TenantIsolationError } from '../../session/errors.js' import type { TenantId } from '../../types/ids/index.js' import type { ProjectId, TopicId } from '../../types/session/ids.js' import type { Topic } from '../../types/topic/entity.js' import type { CreateTopicParams, TopicStore } from '../../types/topic/store.js' import { asProjectId, asTenantId, asTopicId, generateTopicId } from '../../utils/id.js' interface TopicRecord { tenantId: TenantId topic: Topic } export class InMemoryTopicStore implements TopicStore { private readonly topics = new Map() /** Hydrate existing Topic snapshots without creating new topic identities. */ constructor(topics: readonly Topic[] = []) { for (const input of topics) { const topic = structuredClone(input) asTopicId(topic.id) asProjectId(topic.projectId) asTenantId(topic.tenantId) if (this.topics.has(topic.id)) throw new Error(`Duplicate topic ${topic.id}`) this.topics.set(topic.id, { tenantId: topic.tenantId, topic }) } } async createTopic(params: CreateTopicParams, tenantId: TenantId): Promise { const now = new Date() const topic: Topic = { id: generateTopicId(), projectId: params.projectId, tenantId, title: params.title, status: 'open', ownerVersion: 0, createdAt: now, updatedAt: now, } this.topics.set(topic.id, { tenantId, topic }) return topic } async getTopic(topicId: TopicId, tenantId: TenantId): Promise { const record = this.topics.get(topicId) if (!record) return null this.assertTenant(record.tenantId, tenantId, `topic(${topicId})`) return record.topic } async updateTopic(topic: Topic, tenantId: TenantId): Promise { if (topic.tenantId !== tenantId) { throw new TenantIsolationError({ requested: tenantId, resource: `topic(${topic.id}) payload`, }) } const existing = this.topics.get(topic.id) if (!existing) { throw new Error(`Topic ${topic.id} not found`) } this.assertTenant(existing.tenantId, tenantId, `topic(${topic.id})`) // CAS on ownerVersion — supplied version must match persisted exactly. // Any drift means another writer already advanced the record; the caller // must re-read + re-apply + retry. if (topic.ownerVersion !== existing.topic.ownerVersion) { throw new StaleTopicError({ topicId: topic.id, expectedVersion: topic.ownerVersion, actualVersion: existing.topic.ownerVersion, }) } const updated: Topic = { ...topic, ownerVersion: existing.topic.ownerVersion + 1, updatedAt: new Date(), } this.topics.set(topic.id, { tenantId, topic: updated }) } async deleteTopic(topicId: TopicId, tenantId: TenantId): Promise { const record = this.topics.get(topicId) if (!record) return // Idempotent: missing = no-op. this.assertTenant(record.tenantId, tenantId, `topic(${topicId})`) this.topics.delete(topicId) } async listTopics(projectId: ProjectId, tenantId: TenantId): Promise { const matches: Topic[] = [] for (const { tenantId: ownerTenant, topic } of this.topics.values()) { if (ownerTenant !== tenantId) continue if (topic.projectId !== projectId) continue matches.push(topic) } matches.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) return matches } private assertTenant(owning: TenantId, requested: TenantId, resource: string): void { if (owning !== requested) { throw new TenantIsolationError({ requested, resource }) } } }