134 lines
4.1 KiB
TypeScript
134 lines
4.1 KiB
TypeScript
import { uuidv7 } from "@earendil-works/pi-ai";
|
|
import {
|
|
type LeafEntry,
|
|
SessionError,
|
|
type SessionMetadata,
|
|
type SessionStorage,
|
|
type SessionTreeEntry,
|
|
} from "../types.ts";
|
|
|
|
function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void {
|
|
if (entry.type !== "label") return;
|
|
const label = entry.label?.trim();
|
|
if (label) {
|
|
labelsById.set(entry.targetId, label);
|
|
} else {
|
|
labelsById.delete(entry.targetId);
|
|
}
|
|
}
|
|
|
|
function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
|
|
const labelsById = new Map<string, string>();
|
|
for (const entry of entries) {
|
|
updateLabelCache(labelsById, entry);
|
|
}
|
|
return labelsById;
|
|
}
|
|
|
|
function generateEntryId(byId: { has(id: string): boolean }): string {
|
|
for (let i = 0; i < 100; i++) {
|
|
// The uuidv7 prefix is timestamp-derived and nearly constant between calls,
|
|
// so short ids must come from the random tail.
|
|
const id = uuidv7().slice(-8);
|
|
if (!byId.has(id)) return id;
|
|
}
|
|
return uuidv7();
|
|
}
|
|
|
|
function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
|
|
return entry.type === "leaf" ? entry.targetId : entry.id;
|
|
}
|
|
|
|
export class InMemorySessionStorage<TMetadata extends SessionMetadata = SessionMetadata>
|
|
implements SessionStorage<TMetadata>
|
|
{
|
|
private readonly metadata: TMetadata;
|
|
private entries: SessionTreeEntry[];
|
|
private byId: Map<string, SessionTreeEntry>;
|
|
private labelsById: Map<string, string>;
|
|
private leafId: string | null;
|
|
|
|
constructor(options?: { entries?: SessionTreeEntry[]; metadata?: TMetadata }) {
|
|
this.entries = options?.entries ? [...options.entries] : [];
|
|
this.byId = new Map(this.entries.map((entry) => [entry.id, entry]));
|
|
this.labelsById = buildLabelsById(this.entries);
|
|
this.leafId = null;
|
|
for (const entry of this.entries) this.leafId = leafIdAfterEntry(entry);
|
|
if (this.leafId !== null && !this.byId.has(this.leafId)) {
|
|
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
|
|
}
|
|
this.metadata = options?.metadata ?? ({ id: uuidv7(), createdAt: new Date().toISOString() } as TMetadata);
|
|
}
|
|
|
|
async getMetadata(): Promise<TMetadata> {
|
|
return this.metadata;
|
|
}
|
|
|
|
async getLeafId(): Promise<string | null> {
|
|
if (this.leafId !== null && !this.byId.has(this.leafId)) {
|
|
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
|
|
}
|
|
return this.leafId;
|
|
}
|
|
|
|
async setLeafId(leafId: string | null): Promise<void> {
|
|
if (leafId !== null && !this.byId.has(leafId)) {
|
|
throw new SessionError("not_found", `Entry ${leafId} not found`);
|
|
}
|
|
const entry: LeafEntry = {
|
|
type: "leaf",
|
|
id: generateEntryId(this.byId),
|
|
parentId: this.leafId,
|
|
timestamp: new Date().toISOString(),
|
|
targetId: leafId,
|
|
};
|
|
this.entries.push(entry);
|
|
this.byId.set(entry.id, entry);
|
|
this.leafId = leafId;
|
|
}
|
|
|
|
async createEntryId(): Promise<string> {
|
|
return generateEntryId(this.byId);
|
|
}
|
|
|
|
async appendEntry(entry: SessionTreeEntry): Promise<void> {
|
|
this.entries.push(entry);
|
|
this.byId.set(entry.id, entry);
|
|
updateLabelCache(this.labelsById, entry);
|
|
this.leafId = leafIdAfterEntry(entry);
|
|
}
|
|
|
|
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
|
|
return this.byId.get(id);
|
|
}
|
|
|
|
async findEntries<TType extends SessionTreeEntry["type"]>(
|
|
type: TType,
|
|
): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>> {
|
|
return this.entries.filter((entry): entry is Extract<SessionTreeEntry, { type: TType }> => entry.type === type);
|
|
}
|
|
|
|
async getLabel(id: string): Promise<string | undefined> {
|
|
return this.labelsById.get(id);
|
|
}
|
|
|
|
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
|
|
if (leafId === null) return [];
|
|
const path: SessionTreeEntry[] = [];
|
|
let current = this.byId.get(leafId);
|
|
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
|
|
while (current) {
|
|
path.unshift(current);
|
|
if (!current.parentId) break;
|
|
const parent = this.byId.get(current.parentId);
|
|
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
|
|
current = parent;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
async getEntries(): Promise<SessionTreeEntry[]> {
|
|
return [...this.entries];
|
|
}
|
|
}
|