dev/2026-04-08 #1

Merged
hzhang merged 29 commits from dev/2026-04-08 into main 2026-04-13 09:34:22 +00:00
2 changed files with 191 additions and 0 deletions
Showing only changes of commit c5287fa474 - Show all commits

181
plugin/core/store.ts Normal file
View 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"
);
}

View File

@@ -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";