195 lines
5.4 KiB
TypeScript
195 lines
5.4 KiB
TypeScript
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<Pick<SessionRecord, "title" | "status">> = {},
|
|
) {
|
|
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<SessionRecord>(file)),
|
|
);
|
|
const uniqueRecords = new Map<string, SessionRecord>();
|
|
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<SessionRecord>(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<Pick<SessionRecord, "title" | "status">>,
|
|
) => {
|
|
const normalized: Partial<Pick<SessionRecord, "title" | "status">> = {};
|
|
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;
|
|
};
|