feat: support concurrent Reddit accounts

This commit is contained in:
jjbrunton committed 2026-08-08 19:00:38 +01:00
1 parent d819a0f1e6
commit 16a31335d0
51 files changed
+3241 -250

No files matched your search

+70 -2
View File
@@ -2,6 +2,7 @@ import fs from 'fs/promises';
import path from 'path';
import type { Logger } from '../../utils/logger.js';
import type { ActiveChat, ChatCacheFile, InvitationCooldown } from './types.js';
import type { AccountContentRepository } from '../../database/account-repositories.js';
export interface SyncMarkers {
nextBatch: string | null;
@@ -24,8 +25,13 @@ export class ChatCache {
private lastDeltaSyncAt: number | null = null;
private cooldown: InvitationCooldown | null = null;
constructor(private logger: Logger, filePath?: string) {
if (!filePath && process.env.NODE_ENV === 'test') {
constructor(
private logger: Logger,
filePath?: string,
private readonly databaseStore?: AccountContentRepository,
private accountId: string | null = null,
) {
if (!filePath && !databaseStore && process.env.NODE_ENV === 'test') {
// Without this guard a test that constructs a ChatCache (or a
// ChatService that builds one) would silently write to the prod
// ./data/chat-cache.json — concurrent test runs have already torn
@@ -37,6 +43,18 @@ export class ChatCache {
this.filePath = filePath ?? path.join(process.cwd(), 'data', 'chat-cache.json');
}
async switchAccount(accountId: string): Promise<void> {
if (this.accountId === accountId) return;
this.accountId = accountId;
this.chats = [];
this.lastFetchTime = 0;
this.nextBatch = null;
this.lastFullSyncAt = null;
this.lastDeltaSyncAt = null;
this.cooldown = null;
await this.load();
}
get(): ActiveChat[] {
return this.chats;
}
@@ -80,6 +98,20 @@ export class ChatCache {
}
async load(): Promise<void> {
if (this.databaseStore && this.accountId) {
const rows = this.databaseStore.listConversations(this.accountId);
this.chats = rows.flatMap((row) => {
try { return [JSON.parse(row.payloadJson) as ActiveChat]; } catch { return []; }
});
const state = this.databaseStore.getSyncState(this.accountId);
this.nextBatch = state?.nextBatch ?? null;
this.lastFullSyncAt = state?.lastFullSyncAt ?? null;
this.lastDeltaSyncAt = state?.lastDeltaSyncAt ?? null;
try { this.cooldown = state?.cooldownJson ? JSON.parse(state.cooldownJson) as InvitationCooldown : null; } catch { this.cooldown = null; }
this.lastFetchTime = Date.now();
this.logger.info(`Loaded ${this.chats.length} account-owned chats from the database`);
return;
}
try {
const raw = await fs.readFile(this.filePath, 'utf-8');
const parsed = JSON.parse(raw) as ChatCacheFile;
@@ -115,6 +147,10 @@ export class ChatCache {
* sync with `chats: []` and null markers.
*/
async persistCooldown(): Promise<void> {
if (this.databaseStore && this.accountId) {
this.persistDatabaseState();
return;
}
let existing: ChatCacheFile | null = null;
try {
const raw = await fs.readFile(this.filePath, 'utf-8');
@@ -143,6 +179,27 @@ export class ChatCache {
}
async save(chats: ActiveChat[] = this.chats): Promise<void> {
if (this.databaseStore && this.accountId) {
const retained = new Set<string>();
for (const chat of chats) {
const conversationId = chat.roomId ?? chat.username;
retained.add(conversationId);
this.databaseStore.upsertConversation({
accountId: this.accountId,
conversationId,
username: chat.username,
roomName: chat.roomName ?? null,
payloadJson: JSON.stringify(chat),
});
}
for (const existing of this.databaseStore.listConversations(this.accountId)) {
if (!retained.has(existing.conversationId)) {
this.databaseStore.deleteConversation(this.accountId, existing.conversationId);
}
}
this.persistDatabaseState();
return;
}
const payload: ChatCacheFile = {
version: 1,
savedAt: Date.now(),
@@ -159,6 +216,17 @@ export class ChatCache {
}
}
private persistDatabaseState(): void {
if (!this.databaseStore || !this.accountId) return;
this.databaseStore.saveSyncState({
accountId: this.accountId,
nextBatch: this.nextBatch,
lastFullSyncAt: this.lastFullSyncAt,
lastDeltaSyncAt: this.lastDeltaSyncAt,
cooldownJson: this.cooldown ? JSON.stringify(this.cooldown) : null,
});
}
/**
* Write the cache file via a temp+rename so a crashed/interrupted/concurrent
* writer can never leave the on-disk file half-written. rename(2) is atomic
+9
View File
@@ -256,6 +256,15 @@ export class ChatService {
}
}
async switchAccount(accountId: string): Promise<void> {
await this.enqueue(async () => {
await this.cache.switchAccount(accountId);
this.t2ToUsername.clear();
this.unresolvableRooms.clear();
this.invitationCooldown = this.cache.getCooldown();
});
}
async saveCacheExternal(): Promise<void> {
await this.cache.save();
}