Files

68 lines
2.8 KiB
TypeScript

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<void>;
heartbeat(sessionIds: string[], at: Date): Promise<void>;
finish(sessionId: string, at: Date, reason: EndReason): Promise<void>;
close(): Promise<void>;
}
/** Explicit test/development fixture. Never selected as a fallback. */
export class MemoryVisitStore implements VisitStore {
readonly records = new Map<string, VisitRecord>();
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<VisitRecord>;
private constructor(client: MongoClient, database: string) {
this.client = client;
this.records = client.db(database).collection<VisitRecord>('visitSessions');
}
static async connect(uri: string, database: string): Promise<MongoVisitStore> {
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(); }
}