68 lines
2.8 KiB
TypeScript
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(); }
|
|
}
|