150 lines
3.8 KiB
TypeScript
150 lines
3.8 KiB
TypeScript
import { join } from "node:path";
|
|
|
|
import { config } from "../config.js";
|
|
import {
|
|
atomicWriteJson,
|
|
ensureDirectory,
|
|
listJsonFiles,
|
|
readJsonFile,
|
|
removeFileIfExists,
|
|
slugify,
|
|
} 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 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 readJsonFile<SessionRecord>(
|
|
this.filePath(sessionId),
|
|
);
|
|
if (existing) {
|
|
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;
|
|
}
|
|
return await readJsonFile<SessionRecord>(
|
|
this.filePath(normalizedSessionId),
|
|
);
|
|
}
|
|
|
|
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,
|
|
);
|
|
return next;
|
|
}
|
|
|
|
async list(context: SessionMetadataContext) {
|
|
const files = await listJsonFiles(this.baseDir);
|
|
const records = await Promise.all(
|
|
files.map((file) => readJsonFile<SessionRecord>(file)),
|
|
);
|
|
return records
|
|
.filter((record): record is SessionRecord => Boolean(record))
|
|
.filter(
|
|
(record) =>
|
|
record.actorKey === context.actorKey &&
|
|
record.projectKey === context.projectKey,
|
|
)
|
|
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
}
|
|
|
|
async remove(record: SessionRecord) {
|
|
await removeFileIfExists(
|
|
this.filePath(record.sessionId),
|
|
);
|
|
}
|
|
|
|
private filePath(sessionId: string) {
|
|
return join(this.baseDir, `${slugify(sessionId)}.json`);
|
|
}
|
|
}
|
|
|
|
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;
|
|
};
|