From c5287fa474644cec72d1e863e36a34b50977d929 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 20:33:25 +0000 Subject: [PATCH] feat(server): add registry persistence store --- plugin/core/store.ts | 181 +++++++++++++++++++++++++++++++++++++++++++ plugin/index.ts | 10 +++ 2 files changed, 191 insertions(+) create mode 100644 plugin/core/store.ts diff --git a/plugin/core/store.ts b/plugin/core/store.ts new file mode 100644 index 0000000..18e1337 --- /dev/null +++ b/plugin/core/store.ts @@ -0,0 +1,181 @@ +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" + ); +} diff --git a/plugin/index.ts b/plugin/index.ts index a02ec78..36e1261 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -19,6 +19,16 @@ export { type SerializedClientRecord, type ServerPersistenceData } from "./core/persistence.js"; +export { + SERVER_PERSISTENCE_VERSION, + YonexusServerStoreError, + YonexusServerStoreCorruptionError, + createYonexusServerStore, + loadServerStore, + saveServerStore, + type ServerStoreLoadResult, + type YonexusServerStore +} from "./core/store.js"; export interface YonexusServerPluginManifest { readonly name: "Yonexus.Server";