- Wire identity, metrics and update services into the assembled API. - Add connection authorization modes and scoped console authorization. - Preserve notification group IDs for filtered delivery. - Extend maintenance, backup, resource and module flows with runtime metadata and upstream safety.
388 lines
14 KiB
TypeScript
388 lines
14 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { chmod, mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
|
|
import { basename, join } from 'node:path';
|
|
|
|
import type { SqliteDatabase } from '../../infrastructure/database/database.js';
|
|
|
|
export type DataComponentKey =
|
|
| 'instances'
|
|
| 'statusSnapshots'
|
|
| 'jobs'
|
|
| 'auditEvents'
|
|
| 'scheduledRuns'
|
|
| 'notificationQueue'
|
|
| 'smsMessages'
|
|
| 'eventJournal'
|
|
| 'connectionLogs';
|
|
|
|
export interface RetentionPolicy {
|
|
readonly enabled: boolean;
|
|
readonly days: number;
|
|
readonly maximumCount: number;
|
|
}
|
|
|
|
export interface MaintenanceOverview {
|
|
readonly runtime: {
|
|
readonly version: string;
|
|
readonly platform: NodeJS.Platform;
|
|
readonly arch: string;
|
|
readonly uptimeSeconds: number;
|
|
};
|
|
readonly storage: {
|
|
readonly databaseBytes: number;
|
|
/** SQLite file path; empty for in-memory databases. */
|
|
readonly databasePath: string;
|
|
/** Write-ahead log file size; 0 when the database is not in WAL mode. */
|
|
readonly walBytes: number;
|
|
/** Total size of the retained backup files. */
|
|
readonly backupBytes: number;
|
|
/** Free pages that "整理空间" can hand back to the filesystem. */
|
|
readonly reclaimableBytes: number;
|
|
readonly components: readonly {
|
|
readonly key: DataComponentKey;
|
|
readonly count: number;
|
|
readonly bytes: number;
|
|
}[];
|
|
};
|
|
readonly retention: Readonly<Record<DataComponentKey, RetentionPolicy>>;
|
|
}
|
|
|
|
export interface MaintenanceBackup {
|
|
readonly filename: string;
|
|
readonly path: string;
|
|
readonly sizeBytes: number;
|
|
readonly sha256: string;
|
|
readonly createdAt: string;
|
|
}
|
|
|
|
/** Download metadata without the full-file hash so large backups stay cheap to serve. */
|
|
export interface MaintenanceBackupFile {
|
|
readonly filename: string;
|
|
readonly path: string;
|
|
readonly sizeBytes: number;
|
|
readonly createdAt: string;
|
|
}
|
|
|
|
interface ServiceOptions {
|
|
readonly version: string;
|
|
readonly now?: () => Date;
|
|
readonly backupDirectory?: string;
|
|
readonly maximumBackups?: number;
|
|
}
|
|
|
|
const COMPONENT_TABLES: Readonly<Record<DataComponentKey, string>> = Object.freeze({
|
|
instances: 'instances',
|
|
statusSnapshots: 'status_snapshots',
|
|
jobs: 'jobs',
|
|
auditEvents: 'audit_events',
|
|
scheduledRuns: 'scheduled_runs',
|
|
notificationQueue: 'notification_queue',
|
|
smsMessages: 'sms_messages',
|
|
eventJournal: 'event_journal',
|
|
connectionLogs: 'connection_logs',
|
|
});
|
|
|
|
const RETENTION_SETTING_KEY = 'system.retention';
|
|
const DEFAULT_RETENTION: Readonly<Record<DataComponentKey, RetentionPolicy>> = Object.freeze({
|
|
instances: Object.freeze({ enabled: false, days: 365, maximumCount: 0 }),
|
|
statusSnapshots: Object.freeze({ enabled: false, days: 30, maximumCount: 0 }),
|
|
jobs: Object.freeze({ enabled: true, days: 90, maximumCount: 5_000 }),
|
|
auditEvents: Object.freeze({ enabled: true, days: 180, maximumCount: 50_000 }),
|
|
scheduledRuns: Object.freeze({ enabled: true, days: 180, maximumCount: 5_000 }),
|
|
notificationQueue: Object.freeze({ enabled: true, days: 180, maximumCount: 10_000 }),
|
|
smsMessages: Object.freeze({ enabled: false, days: 365, maximumCount: 0 }),
|
|
eventJournal: Object.freeze({ enabled: true, days: 30, maximumCount: 10_000 }),
|
|
// The probe journal already self-caps, so retention here is opt-in by day window.
|
|
connectionLogs: Object.freeze({ enabled: true, days: 30, maximumCount: 20_000 }),
|
|
});
|
|
|
|
function cloneRetention(
|
|
value: Readonly<Record<DataComponentKey, RetentionPolicy>>,
|
|
): Record<DataComponentKey, RetentionPolicy> {
|
|
return Object.fromEntries(
|
|
Object.entries(value).map(([key, policy]) => [key, { ...policy }]),
|
|
) as Record<DataComponentKey, RetentionPolicy>;
|
|
}
|
|
|
|
function validateRetention(value: unknown): Record<DataComponentKey, RetentionPolicy> {
|
|
const source =
|
|
value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {};
|
|
const merged = cloneRetention(DEFAULT_RETENTION);
|
|
for (const key of Object.keys(DEFAULT_RETENTION) as DataComponentKey[]) {
|
|
const current = source[key];
|
|
if (current === undefined) continue;
|
|
const policy = current as Partial<RetentionPolicy>;
|
|
if (
|
|
typeof policy.enabled !== 'boolean' ||
|
|
!Number.isSafeInteger(policy.days) ||
|
|
(policy.days ?? 0) < 1 ||
|
|
(policy.days ?? 0) > 3_650 ||
|
|
!Number.isSafeInteger(policy.maximumCount) ||
|
|
(policy.maximumCount ?? 0) < 0
|
|
)
|
|
throw new RangeError('Retention days must be between 1 and 3650 and limits must be valid');
|
|
merged[key] = {
|
|
enabled: policy.enabled,
|
|
days: policy.days!,
|
|
maximumCount: policy.maximumCount!,
|
|
};
|
|
}
|
|
if (Object.keys(source).some((key) => !(key in DEFAULT_RETENTION)))
|
|
throw new TypeError('Unknown data component');
|
|
return merged;
|
|
}
|
|
|
|
function tableExists(db: SqliteDatabase, table: string): boolean {
|
|
return Boolean(
|
|
db.prepare('SELECT 1 FROM sqlite_master WHERE type = ? AND name = ?').get('table', table),
|
|
);
|
|
}
|
|
|
|
function countTable(db: SqliteDatabase, table: string): number {
|
|
if (!tableExists(db, table)) return 0;
|
|
return Number(
|
|
(db.prepare(`SELECT COUNT(*) AS count FROM "${table}"`).get() as { count?: number } | undefined)
|
|
?.count ?? 0,
|
|
);
|
|
}
|
|
|
|
/** Per-table page usage from the dbstat virtual table; 0 when SQLite was built without it. */
|
|
function tableBytes(db: SqliteDatabase, table: string): number {
|
|
if (!tableExists(db, table)) return 0;
|
|
try {
|
|
const row = db
|
|
.prepare('SELECT COALESCE(SUM(pgsize), 0) AS bytes FROM dbstat WHERE name = ?')
|
|
.get(table) as { bytes?: number } | undefined;
|
|
return Number(row?.bytes ?? 0);
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
export class SystemMaintenanceService {
|
|
readonly #db: SqliteDatabase;
|
|
readonly #version: string;
|
|
readonly #now: () => Date;
|
|
readonly #backupDirectory: string;
|
|
readonly #maximumBackups: number;
|
|
|
|
constructor(db: SqliteDatabase, options: ServiceOptions) {
|
|
this.#db = db;
|
|
this.#version = options.version;
|
|
this.#now = options.now ?? (() => new Date());
|
|
this.#backupDirectory = options.backupDirectory ?? './data/backups';
|
|
this.#maximumBackups = Math.max(1, options.maximumBackups ?? 10);
|
|
}
|
|
|
|
async overview(): Promise<MaintenanceOverview> {
|
|
const pageCount = Number(this.#db.pragma('page_count', { simple: true }));
|
|
const pageSize = Number(this.#db.pragma('page_size', { simple: true }));
|
|
const databaseFile = String(
|
|
(this.#db.prepare('PRAGMA database_list').get() as { file?: string } | undefined)?.file ?? '',
|
|
);
|
|
const walBytes = databaseFile
|
|
? await stat(`${databaseFile}-wal`)
|
|
.then((details) => (details.isFile() ? details.size : 0))
|
|
.catch(() => 0)
|
|
: 0;
|
|
const freelist = Number(this.#db.pragma('freelist_count', { simple: true }));
|
|
return {
|
|
runtime: {
|
|
version: this.#version,
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
uptimeSeconds: Math.floor(process.uptime()),
|
|
},
|
|
storage: {
|
|
databaseBytes: pageCount * pageSize,
|
|
databasePath: databaseFile,
|
|
walBytes,
|
|
backupBytes: await this.#backupBytes(),
|
|
reclaimableBytes: freelist * pageSize,
|
|
components: (Object.keys(COMPONENT_TABLES) as DataComponentKey[]).map((key) => ({
|
|
key,
|
|
count: countTable(this.#db, COMPONENT_TABLES[key]),
|
|
bytes: tableBytes(this.#db, COMPONENT_TABLES[key]),
|
|
})),
|
|
},
|
|
retention: await this.getRetention(),
|
|
};
|
|
}
|
|
|
|
async #backupBytes(): Promise<number> {
|
|
const names = await readdir(this.#backupDirectory).catch(() => [] as string[]);
|
|
let total = 0;
|
|
for (const name of names.filter((entry) => entry.startsWith('multi-simadmin-'))) {
|
|
const details = await stat(join(this.#backupDirectory, name)).catch(() => undefined);
|
|
if (details?.isFile()) total += details.size;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
defaultRetention(): Readonly<Record<DataComponentKey, RetentionPolicy>> {
|
|
return cloneRetention(DEFAULT_RETENTION);
|
|
}
|
|
|
|
async getRetention(): Promise<Readonly<Record<DataComponentKey, RetentionPolicy>>> {
|
|
const row = this.#db
|
|
.prepare('SELECT value_json FROM app_settings WHERE key = ?')
|
|
.get(RETENTION_SETTING_KEY) as { value_json?: string } | undefined;
|
|
if (!row?.value_json) return this.defaultRetention();
|
|
return validateRetention(JSON.parse(row.value_json));
|
|
}
|
|
|
|
async updateRetention(
|
|
value: unknown,
|
|
): Promise<Readonly<Record<DataComponentKey, RetentionPolicy>>> {
|
|
const policy = validateRetention(value);
|
|
const now = this.#now().toISOString();
|
|
this.#db
|
|
.prepare(
|
|
`INSERT INTO app_settings (key,value_json,created_at,updated_at)
|
|
VALUES (?,?,?,?)
|
|
ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json,
|
|
updated_at = excluded.updated_at`,
|
|
)
|
|
.run(RETENTION_SETTING_KEY, JSON.stringify(policy), now, now);
|
|
return policy;
|
|
}
|
|
|
|
async cleanup(
|
|
components: readonly DataComponentKey[],
|
|
): Promise<Partial<Record<DataComponentKey, number>>> {
|
|
const known = new Set(Object.keys(COMPONENT_TABLES));
|
|
if (components.some((component) => !known.has(component)))
|
|
throw new Error('Unknown data component');
|
|
const retention = await this.getRetention();
|
|
const result: Partial<Record<DataComponentKey, number>> = {};
|
|
|
|
for (const component of components) {
|
|
const policy = retention[component];
|
|
const table = COMPONENT_TABLES[component];
|
|
if (!policy.enabled || !tableExists(this.#db, table)) continue;
|
|
const cutoff = new Date(this.#now().getTime() - policy.days * 86_400_000).toISOString();
|
|
if (component === 'auditEvents')
|
|
result[component] = this.#delete(`DELETE FROM ${table} WHERE created_at < ?`, cutoff);
|
|
else if (component === 'jobs')
|
|
result[component] = this.#delete(
|
|
`DELETE FROM ${table} WHERE created_at < ? AND status NOT IN ('running','pending')`,
|
|
cutoff,
|
|
);
|
|
else if (component === 'scheduledRuns')
|
|
result[component] = this.#delete(`DELETE FROM ${table} WHERE due_at < ?`, cutoff);
|
|
else if (component === 'eventJournal')
|
|
result[component] = this.#delete(`DELETE FROM ${table}`);
|
|
else if (component === 'connectionLogs')
|
|
result[component] = this.#delete(`DELETE FROM ${table} WHERE observed_at < ?`, cutoff);
|
|
else if (policy.maximumCount > 0)
|
|
result[component] = this.#delete(
|
|
`DELETE FROM ${table} WHERE rowid IN (
|
|
SELECT rowid FROM ${table} LIMIT ?
|
|
)`,
|
|
Math.max(0, countTable(this.#db, table) - policy.maximumCount),
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async optimize(): Promise<{ checkpointed: boolean; vacuumed: boolean; analyzed: boolean }> {
|
|
this.#db.pragma('wal_checkpoint(TRUNCATE)');
|
|
this.#db.exec('VACUUM');
|
|
this.#db.exec('ANALYZE');
|
|
return { checkpointed: true, vacuumed: true, analyzed: true };
|
|
}
|
|
|
|
async createBackup(): Promise<MaintenanceBackup> {
|
|
await mkdir(this.#backupDirectory, { recursive: true });
|
|
const filename = `multi-simadmin-${this.#now().toISOString().replaceAll(/[:.]/gu, '-')}.db`;
|
|
const path = join(this.#backupDirectory, filename);
|
|
await this.#db.backup(path);
|
|
await chmod(path, 0o600);
|
|
const details = await stat(path);
|
|
await this.#pruneBackups();
|
|
return {
|
|
filename,
|
|
path,
|
|
sizeBytes: details.size,
|
|
sha256: await readFile(path).then((content) =>
|
|
createHash('sha256').update(content).digest('hex'),
|
|
),
|
|
createdAt: this.#now().toISOString(),
|
|
};
|
|
}
|
|
|
|
async listBackups(): Promise<readonly MaintenanceBackup[]> {
|
|
await mkdir(this.#backupDirectory, { recursive: true });
|
|
const names = (await readdir(this.#backupDirectory))
|
|
.filter((name) => name.startsWith('multi-simadmin-') && name.endsWith('.db'))
|
|
.sort()
|
|
.reverse();
|
|
const backups: MaintenanceBackup[] = [];
|
|
for (const filename of names.slice(0, this.#maximumBackups)) {
|
|
const path = join(this.#backupDirectory, filename);
|
|
const details = await stat(path);
|
|
if (!details.isFile()) continue;
|
|
backups.push({
|
|
filename,
|
|
path,
|
|
sizeBytes: details.size,
|
|
sha256: createHash('sha256')
|
|
.update(await readFile(path))
|
|
.digest('hex'),
|
|
createdAt: details.mtime.toISOString(),
|
|
});
|
|
}
|
|
return backups;
|
|
}
|
|
|
|
/** Resolves a listed backup name to a path that stays inside the backup directory. */
|
|
#backupPath(filename: string): string {
|
|
if (
|
|
!/^multi-simadmin-[A-Za-z0-9._-]{1,120}\.db$/u.test(filename) ||
|
|
basename(filename) !== filename
|
|
)
|
|
throw new TypeError('Backup filename is invalid');
|
|
return join(this.#backupDirectory, filename);
|
|
}
|
|
|
|
async backupFile(filename: string): Promise<MaintenanceBackupFile | undefined> {
|
|
const path = this.#backupPath(filename);
|
|
const details = await stat(path).catch((error: unknown) => {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
throw error;
|
|
});
|
|
if (!details?.isFile()) return undefined;
|
|
return {
|
|
filename,
|
|
path,
|
|
sizeBytes: details.size,
|
|
createdAt: details.mtime.toISOString(),
|
|
};
|
|
}
|
|
|
|
async deleteBackup(filename: string): Promise<{ filename: string }> {
|
|
await rm(this.#backupPath(filename), { force: true });
|
|
return { filename };
|
|
}
|
|
|
|
/** Names sort newest-first because they embed an ISO timestamp. */
|
|
async #pruneBackups(): Promise<number> {
|
|
const names = (await readdir(this.#backupDirectory))
|
|
.filter((name) => name.startsWith('multi-simadmin-') && name.endsWith('.db'))
|
|
.sort()
|
|
.reverse();
|
|
let removed = 0;
|
|
for (const filename of names.slice(this.#maximumBackups)) {
|
|
await rm(join(this.#backupDirectory, filename), { force: true });
|
|
removed += 1;
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
#delete(statement: string, ...parameters: readonly unknown[]): number {
|
|
return Number(this.#db.prepare(statement).run(...parameters).changes);
|
|
}
|
|
}
|