dev/2026-04-08 #1
181
plugin/core/store.ts
Normal file
181
plugin/core/store.ts
Normal file
@@ -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<string, ClientRecord>;
|
||||
}
|
||||
|
||||
export interface YonexusServerStore {
|
||||
readonly filePath: string;
|
||||
load(): Promise<ServerStoreLoadResult>;
|
||||
save(clients: Iterable<ClientRecord>): Promise<void>;
|
||||
}
|
||||
|
||||
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<ServerStoreLoadResult> {
|
||||
try {
|
||||
const raw = await readFile(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as ServerPersistenceData;
|
||||
assertPersistenceDataShape(parsed, filePath);
|
||||
|
||||
const clients = new Map<string, ClientRecord>();
|
||||
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<ClientRecord>
|
||||
): Promise<void> {
|
||||
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<ServerPersistenceData>;
|
||||
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<SerializedClientRecord>;
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user