import { MongoClient, type Collection } from 'mongodb'; export type EndReason = 'disconnect' | 'shutdown' | 'interrupted'; export interface VisitRecord { _id: string; visitorId: string; nickname: string; locationId: string; roomId: string; joinedAt: Date; lastSeenAt: Date; leftAt: Date | null; endReason: EndReason | null; serverInstanceId: string; } /** Only visit lifecycle metadata is persisted; visitorId is anonymous, not authentication. */ export interface VisitStore { insert(record: VisitRecord): Promise; heartbeat(sessionIds: string[], at: Date): Promise; finish(sessionId: string, at: Date, reason: EndReason): Promise; close(): Promise; } /** Explicit test/development fixture. Never selected as a fallback. */ export class MemoryVisitStore implements VisitStore { readonly records = new Map(); async insert(record: VisitRecord) { if (this.records.has(record._id)) throw new Error('Duplicate visit'); this.records.set(record._id, structuredClone(record)); } async heartbeat(ids: string[], at: Date) { for (const id of ids) { const r = this.records.get(id); if (r && r.endReason === null) r.lastSeenAt = at; } } async finish(id: string, at: Date, reason: EndReason) { const r = this.records.get(id); if (r && r.endReason === null) { r.lastSeenAt = at; r.leftAt = at; r.endReason = reason; } } async close() {} } export class MongoVisitStore implements VisitStore { private client: MongoClient; private records: Collection; private constructor(client: MongoClient, database: string) { this.client = client; this.records = client.db(database).collection('visitSessions'); } static async connect(uri: string, database: string): Promise { const client = new MongoClient(uri, { serverSelectionTimeoutMS: 5000, connectTimeoutMS: 5000, socketTimeoutMS: 10000, writeConcern: { w: 'majority', wtimeoutMS: 5000 } }); try { await client.connect(); const store = new MongoVisitStore(client, database); await store.records.createIndex({ endReason: 1, lastSeenAt: 1 }); // Never rewrite historical visits on connection. A crashed session retains // its lastSeenAt and unknown leftAt; this process owns only its new sessions. return store; } catch (error) { await client.close(); throw error; } } async insert(record: VisitRecord) { await this.records.insertOne(record); } async heartbeat(ids: string[], at: Date) { if (ids.length) await this.records.updateMany({ _id: { $in: ids }, endReason: null }, { $max: { lastSeenAt: at } }); } async finish(id: string, at: Date, reason: EndReason) { await this.records.updateOne({ _id: id, endReason: null }, { $set: { leftAt: at, endReason: reason }, $max: { lastSeenAt: at } }); } async close() { await this.client.close(); } }