mancala/apps/backend/src/live/LiveGameStore.ts
Halit Aksoy f6136be007 feat(backend): keep live games and caches in Valkey
- Valkey client that fails commands at once while Valkey is down instead
  of queueing them, and logs connection errors once
- ValkeyLiveGameStore: live game snapshots and finished games waiting to
  be saved, loaded with SCAN on startup
- ValkeyCache: JSON values with a TTL; errors count as cache misses
- Integration tests against a real Valkey, including an unreachable one
2026-10-16 00:14:07 +03:00

62 lines
2.2 KiB
TypeScript

import { GameRecord } from "@mancala/core";
import { Valkey } from "iovalkey";
import { scanKeys } from "./valkey";
const LIVE_PREFIX = "mancala:live:";
const PENDING_SAVE_PREFIX = "mancala:pending-save:";
// Durable copy of state that otherwise only lives in memory: live games, so they survive a restart, and finished
// games that could not be saved to the database yet. Methods reject when Valkey is unreachable.
export interface LiveGameStore {
saveGame(gameId: string, snapshot: unknown): Promise<void>;
removeGame(gameId: string): Promise<void>;
loadGames(): Promise<unknown[]>;
savePendingRecord(record: GameRecord): Promise<void>;
removePendingRecord(gameId: string): Promise<void>;
loadPendingRecords(): Promise<GameRecord[]>;
}
export class ValkeyLiveGameStore implements LiveGameStore {
constructor(private readonly client: Valkey) { }
async saveGame(gameId: string, snapshot: unknown): Promise<void> {
await this.client.set(LIVE_PREFIX + gameId, JSON.stringify(snapshot));
}
async removeGame(gameId: string): Promise<void> {
await this.client.del(LIVE_PREFIX + gameId);
}
loadGames(): Promise<unknown[]> {
return this.loadAll(LIVE_PREFIX);
}
async savePendingRecord(record: GameRecord): Promise<void> {
await this.client.set(PENDING_SAVE_PREFIX + record.id, JSON.stringify(record));
}
async removePendingRecord(gameId: string): Promise<void> {
await this.client.del(PENDING_SAVE_PREFIX + gameId);
}
loadPendingRecords(): Promise<GameRecord[]> {
return this.loadAll(PENDING_SAVE_PREFIX) as Promise<GameRecord[]>;
}
private async loadAll(prefix: string): Promise<unknown[]> {
const keys = await scanKeys(this.client, prefix + "*");
if (keys.length === 0) return [];
const values = await this.client.mget(keys);
const parsed: unknown[] = [];
values.forEach((value, i) => {
if (value === null) return;
try {
parsed.push(JSON.parse(value));
} catch {
console.error(`(Valkey) Ignoring unreadable value in ${keys[i]}`);
}
});
return parsed;
}
}