import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { deserializeClientRecord, serializeClientRecord, type ClientRecord, type SerializedClientRecord, type ServerPersistenceData } from "./persistence.js"; export const SERVER_PERSISTENCE_VERSION = 1; export class YonexusServerStoreError extends Error { override readonly cause?: unknown; constructor(message: string, cause?: unknown) { super(message); this.name = "YonexusServerStoreError"; this.cause = cause; } } export class YonexusServerStoreCorruptionError extends YonexusServerStoreError { constructor(message: string, cause?: unknown) { super(message, cause); this.name = "YonexusServerStoreCorruptionError"; } } export interface ServerStoreLoadResult { readonly version: number; readonly persistedAt?: number; readonly clients: Map; } export interface YonexusServerStore { readonly filePath: string; load(): Promise; save(clients: Iterable): Promise; } export function createYonexusServerStore(filePath: string): YonexusServerStore { return { filePath, load: async () => loadServerStore(filePath), save: async (clients) => saveServerStore(filePath, clients) }; } export async function loadServerStore(filePath: string): Promise { try { const raw = await readFile(filePath, "utf8"); const parsed = JSON.parse(raw) as ServerPersistenceData; assertPersistenceDataShape(parsed, filePath); const clients = new Map(); for (const serialized of parsed.clients) { assertSerializedClientRecordShape(serialized, filePath); clients.set(serialized.identifier, deserializeClientRecord(serialized)); } return { version: parsed.version, persistedAt: parsed.persistedAt, clients }; } catch (error) { if (isFileNotFoundError(error)) { return { version: SERVER_PERSISTENCE_VERSION, clients: new Map() }; } if (error instanceof YonexusServerStoreError) { throw error; } throw new YonexusServerStoreCorruptionError( `Failed to load Yonexus.Server persistence file: ${filePath}`, error ); } } export async function saveServerStore( filePath: string, clients: Iterable ): Promise { const payload: ServerPersistenceData = { version: SERVER_PERSISTENCE_VERSION, persistedAt: Math.floor(Date.now() / 1000), clients: Array.from(clients, (record) => serializeClientRecord(record)) }; const tempPath = `${filePath}.tmp`; try { await mkdir(dirname(filePath), { recursive: true }); await writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); await rename(tempPath, filePath); } catch (error) { throw new YonexusServerStoreError( `Failed to save Yonexus.Server persistence file: ${filePath}`, error ); } } function assertPersistenceDataShape( value: unknown, filePath: string ): asserts value is ServerPersistenceData { if (!value || typeof value !== "object") { throw new YonexusServerStoreCorruptionError( `Persistence file is not a JSON object: ${filePath}` ); } const candidate = value as Partial; if (candidate.version !== SERVER_PERSISTENCE_VERSION) { throw new YonexusServerStoreCorruptionError( `Unsupported persistence version in ${filePath}: ${String(candidate.version)}` ); } if (!Array.isArray(candidate.clients)) { throw new YonexusServerStoreCorruptionError( `Persistence file has invalid clients array: ${filePath}` ); } if ( candidate.persistedAt !== undefined && (!Number.isInteger(candidate.persistedAt) || candidate.persistedAt < 0) ) { throw new YonexusServerStoreCorruptionError( `Persistence file has invalid persistedAt value: ${filePath}` ); } } function assertSerializedClientRecordShape( value: unknown, filePath: string ): asserts value is SerializedClientRecord { if (!value || typeof value !== "object") { throw new YonexusServerStoreCorruptionError( `Persistence file contains a non-object client record: ${filePath}` ); } const candidate = value as Partial; if (typeof candidate.identifier !== "string" || candidate.identifier.trim().length === 0) { throw new YonexusServerStoreCorruptionError( `Persistence file contains a client record with invalid identifier: ${filePath}` ); } if (typeof candidate.pairingStatus !== "string" || typeof candidate.status !== "string") { throw new YonexusServerStoreCorruptionError( `Persistence file contains a client record with invalid state fields: ${filePath}` ); } if (!Number.isInteger(candidate.createdAt) || !Number.isInteger(candidate.updatedAt)) { throw new YonexusServerStoreCorruptionError( `Persistence file contains a client record with invalid timestamps: ${filePath}` ); } } function isFileNotFoundError(error: unknown): error is NodeJS.ErrnoException { return ( typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "ENOENT" ); }