sessionStore.ts
3,943 bytes
| 1 | import { randomUUID } from 'node:crypto' |
|---|---|
| 2 | import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises' |
| 3 | import path from 'node:path' |
| 4 | import { |
| 5 | SessionSchema, |
| 6 | initialCoverage, |
| 7 | type Coverage, |
| 8 | type Segment, |
| 9 | type Session, |
| 10 | type SessionStatus, |
| 11 | type SessionSummary, |
| 12 | type Speaker, |
| 13 | } from '../../shared/types' |
| 14 | |
| 15 | export class SessionStore { |
| 16 | constructor(private readonly baseDir: string) {} |
| 17 | |
| 18 | private sessionDir(id: string): string { |
| 19 | return path.join(this.baseDir, id) |
| 20 | } |
| 21 | |
| 22 | private sessionFile(id: string): string { |
| 23 | return path.join(this.sessionDir(id), 'session.json') |
| 24 | } |
| 25 | |
| 26 | private async writeSession(session: Session): Promise<void> { |
| 27 | const dir = this.sessionDir(session.id) |
| 28 | await mkdir(dir, { recursive: true }) |
| 29 | const file = this.sessionFile(session.id) |
| 30 | const tmp = path.join(dir, `.session-${randomUUID()}.tmp`) |
| 31 | await writeFile(tmp, JSON.stringify(session, null, 2), 'utf8') |
| 32 | await rename(tmp, file) |
| 33 | } |
| 34 | |
| 35 | async createSession(name: string, targetDir: string): Promise<Session> { |
| 36 | const session: Session = { |
| 37 | id: randomUUID(), |
| 38 | name, |
| 39 | targetDir, |
| 40 | createdAt: new Date().toISOString(), |
| 41 | segments: [], |
| 42 | coverage: initialCoverage(), |
| 43 | summary: '', |
| 44 | status: 'interviewing', |
| 45 | openBlockers: [], |
| 46 | } |
| 47 | await this.writeSession(session) |
| 48 | return session |
| 49 | } |
| 50 | |
| 51 | async getSession(id: string): Promise<Session | null> { |
| 52 | try { |
| 53 | const raw = await readFile(this.sessionFile(id), 'utf8') |
| 54 | return SessionSchema.parse(JSON.parse(raw)) |
| 55 | } catch (err) { |
| 56 | if (isNotFound(err)) return null |
| 57 | throw err |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | async listSessions(): Promise<SessionSummary[]> { |
| 62 | let entries: string[] |
| 63 | try { |
| 64 | entries = await readdir(this.baseDir) |
| 65 | } catch (err) { |
| 66 | if (isNotFound(err)) return [] |
| 67 | throw err |
| 68 | } |
| 69 | const summaries: SessionSummary[] = [] |
| 70 | for (const id of entries) { |
| 71 | const session = await this.getSession(id) |
| 72 | if (session) { |
| 73 | summaries.push({ |
| 74 | id: session.id, |
| 75 | name: session.name, |
| 76 | status: session.status, |
| 77 | createdAt: session.createdAt, |
| 78 | }) |
| 79 | } |
| 80 | } |
| 81 | summaries.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) |
| 82 | return summaries |
| 83 | } |
| 84 | |
| 85 | async deleteSession(id: string): Promise<boolean> { |
| 86 | const session = await this.getSession(id) |
| 87 | if (!session) return false |
| 88 | await rm(this.sessionDir(id), { recursive: true, force: true }) |
| 89 | return true |
| 90 | } |
| 91 | |
| 92 | async appendSegment( |
| 93 | id: string, |
| 94 | speaker: Speaker, |
| 95 | text: string, |
| 96 | ): Promise<{ session: Session; segment: Segment }> { |
| 97 | const session = await this.getSession(id) |
| 98 | if (!session) throw new Error(`session not found: ${id}`) |
| 99 | const segment: Segment = { |
| 100 | id: `S${session.segments.length + 1}`, |
| 101 | ts: new Date().toISOString(), |
| 102 | speaker, |
| 103 | text, |
| 104 | } |
| 105 | session.segments.push(segment) |
| 106 | await this.writeSession(session) |
| 107 | return { session, segment } |
| 108 | } |
| 109 | |
| 110 | async updateTurn( |
| 111 | id: string, |
| 112 | update: { |
| 113 | coverage?: Coverage |
| 114 | summary?: string |
| 115 | status?: SessionStatus |
| 116 | openBlockers?: string[] |
| 117 | }, |
| 118 | ): Promise<Session> { |
| 119 | const session = await this.getSession(id) |
| 120 | if (!session) throw new Error(`session not found: ${id}`) |
| 121 | if (update.coverage) session.coverage = update.coverage |
| 122 | if (update.summary !== undefined) session.summary = update.summary |
| 123 | if (update.status) session.status = update.status |
| 124 | if (update.openBlockers) session.openBlockers = update.openBlockers |
| 125 | await this.writeSession(session) |
| 126 | return session |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | function isNotFound(err: unknown): boolean { |
| 131 | return typeof err === 'object' && err !== null && 'code' in err && (err as { code?: string }).code === 'ENOENT' |
| 132 | } |
| 133 | |
| 134 | export function createDefaultSessionStore(): SessionStore { |
| 135 | const baseDir = process.env.DATA_DIR ?? path.join(process.cwd(), 'data', 'sessions') |
| 136 | return new SessionStore(baseDir) |
| 137 | } |
| 138 | |