import { join } from "node:path"; import { config } from "../config.js"; import { atomicWriteJson, ensureDirectory, listJsonFiles, readJsonFile, removeFileIfExists, slugify, toStableId, } from "../utils/fileStore.js"; export type SessionStatus = "active" | "archived"; export type SessionRecord = { sessionId: string; actorKey: string; ownerUserId?: string; projectId?: string; projectKey: string; parentSessionId?: string; createdAt: string; updatedAt: string; status: SessionStatus; title?: string; }; type SessionMetadataContext = { actorKey: string; userId?: string; projectId?: string; projectKey: string; }; type EnsureSessionMetadataInput = SessionMetadataContext & { sessionId: string; parentSessionId?: string; }; export class SessionMetadataOwnershipError extends Error { constructor(sessionId: string) { super(`session metadata ownership mismatch: ${sessionId}`); this.name = "SessionMetadataOwnershipError"; } } export class SessionMetadataStore { constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR) {} async initialize() { await ensureDirectory(this.baseDir); } async ensure(input: EnsureSessionMetadataInput) { const sessionId = normalizeSessionId(input.sessionId); if (!sessionId) { throw new Error("sessionId is required"); } const existing = await this.readRecord(sessionId); if (existing) { if (!matchesContext(existing, input)) { throw new SessionMetadataOwnershipError(sessionId); } await atomicWriteJson(this.filePath(sessionId), existing); await removeFileIfExists(this.legacyFilePath(sessionId)); return { created: false, record: existing }; } const now = new Date().toISOString(); const record: SessionRecord = { sessionId, actorKey: input.actorKey, ownerUserId: input.userId?.trim(), projectId: input.projectId, projectKey: input.projectKey, parentSessionId: normalizeSessionId(input.parentSessionId), createdAt: now, updatedAt: now, status: "active", }; await atomicWriteJson( this.filePath(record.sessionId), record, ); return { created: true, record }; } async get(context: SessionMetadataContext, sessionId: string) { const normalizedSessionId = normalizeSessionId(sessionId); if (!normalizedSessionId) { return null; } const record = await this.readRecord(normalizedSessionId); return record && matchesContext(record, context) ? record : null; } async touch( record: SessionRecord, updates: Partial> = {}, ) { const next: SessionRecord = { ...record, ...normalizeSessionUpdates(updates), updatedAt: new Date().toISOString(), }; await atomicWriteJson( this.filePath(record.sessionId), next, ); await removeFileIfExists(this.legacyFilePath(record.sessionId)); return next; } async list(context: SessionMetadataContext) { const files = await listJsonFiles(this.baseDir); const records = await Promise.all( files.map((file) => readJsonFile(file)), ); const uniqueRecords = new Map(); for (const record of records) { if (record && matchesContext(record, context)) { const previous = uniqueRecords.get(record.sessionId); if (!previous || record.updatedAt > previous.updatedAt) { uniqueRecords.set(record.sessionId, record); } } } return [...uniqueRecords.values()] .filter((record): record is SessionRecord => Boolean(record)) .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); } async remove(record: SessionRecord) { await Promise.all( this.filePaths(record.sessionId).map((path) => removeFileIfExists(path)), ); } private filePath(sessionId: string) { return join( this.baseDir, `${slugify(sessionId)}-${toStableId(sessionId)}.json`, ); } private legacyFilePath(sessionId: string) { return join(this.baseDir, `${slugify(sessionId)}.json`); } private filePaths(sessionId: string) { return [this.filePath(sessionId), this.legacyFilePath(sessionId)]; } private async readRecord(sessionId: string) { for (const path of this.filePaths(sessionId)) { const record = await readJsonFile(path); if (record?.sessionId === sessionId) { return record; } } return null; } } const matchesContext = ( record: SessionRecord, context: SessionMetadataContext, ) => record.actorKey === context.actorKey && record.projectKey === context.projectKey && (!record.ownerUserId || record.ownerUserId === context.userId?.trim()) && (!record.projectId || record.projectId === context.projectId); const normalizeSessionId = (value?: string) => { const normalized = value?.trim(); return normalized ? normalized.slice(0, 128) : undefined; }; const normalizeSessionUpdates = ( updates: Partial>, ) => { const normalized: Partial> = {}; if (updates.status === "active" || updates.status === "archived") { normalized.status = updates.status; } if (typeof updates.title === "string") { const trimmed = updates.title.trim(); if (trimmed) { normalized.title = trimmed.slice(0, 120); } } return normalized; };