feat: support concurrent Reddit accounts
This commit is contained in:
1 parent
d819a0f1e6
commit
16a31335d0
51 files changed
+3241
-250
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user