Files
idle-xboct/packages/server/src/persist/db.ts
T

137 lines
4.7 KiB
TypeScript

import { mkdirSync } from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
/**
* Запись зрителя: инвентарь и XP навыков. Хранится вне ECS (мета-слой).
*/
export interface ViewerRecord {
userId: string;
name: string;
color: string;
/** skill id -> суммарный xp */
skills: Record<string, number>;
/** item id -> количество */
items: Record<string, number>;
}
const SCHEMA_VERSION = 2;
/**
* Тонкий слой persist на встроенном node:sqlite — без нативных сборок и
* install-скриптов. При необходимости заменяется (better-sqlite3/postgres)
* без изменений в остальном коде.
*/
export class Db {
private readonly db: DatabaseSync;
constructor(dbPath: string) {
if (dbPath !== ':memory:') {
mkdirSync(path.dirname(dbPath), { recursive: true });
}
this.db = new DatabaseSync(dbPath);
this.db.exec('PRAGMA journal_mode = WAL');
const row = this.db.prepare('PRAGMA user_version').get() as { user_version?: number };
const version = row?.user_version ?? 0;
if (version < SCHEMA_VERSION) {
// дев-стадия: старую схему не мигрируем, а пересоздаём
this.db.exec(`
DROP TABLE IF EXISTS viewer_skills;
DROP TABLE IF EXISTS viewer_items;
DROP TABLE IF EXISTS viewers;
CREATE TABLE viewers (
channel_id TEXT NOT NULL,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (channel_id, user_id)
);
CREATE TABLE viewer_skills (
channel_id TEXT NOT NULL,
user_id TEXT NOT NULL,
skill TEXT NOT NULL,
xp INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (channel_id, user_id, skill)
);
CREATE TABLE viewer_items (
channel_id TEXT NOT NULL,
user_id TEXT NOT NULL,
item TEXT NOT NULL,
qty INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (channel_id, user_id, item)
);
PRAGMA user_version = ${SCHEMA_VERSION};
`);
}
}
loadViewers(channelId: string): ViewerRecord[] {
const viewers = this.db
.prepare('SELECT user_id, name, color FROM viewers WHERE channel_id = ?')
.all(channelId) as Array<{ user_id: string; name: string; color: string }>;
const skills = this.db
.prepare('SELECT user_id, skill, xp FROM viewer_skills WHERE channel_id = ?')
.all(channelId) as Array<{ user_id: string; skill: string; xp: number }>;
const items = this.db
.prepare('SELECT user_id, item, qty FROM viewer_items WHERE channel_id = ?')
.all(channelId) as Array<{ user_id: string; item: string; qty: number }>;
const byId = new Map<string, ViewerRecord>();
for (const v of viewers) {
byId.set(v.user_id, { userId: v.user_id, name: v.name, color: v.color, skills: {}, items: {} });
}
for (const s of skills) {
const rec = byId.get(s.user_id);
if (rec) rec.skills[s.skill] = s.xp;
}
for (const it of items) {
const rec = byId.get(it.user_id);
if (rec) rec.items[it.item] = it.qty;
}
return [...byId.values()];
}
saveViewer(channelId: string, v: ViewerRecord): void {
this.db.exec('BEGIN');
try {
this.db
.prepare(
`INSERT INTO viewers (channel_id, user_id, name, color, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(channel_id, user_id) DO UPDATE SET
name = excluded.name, color = excluded.color, updated_at = excluded.updated_at`,
)
.run(channelId, v.userId, v.name, v.color, Date.now());
this.db
.prepare('DELETE FROM viewer_skills WHERE channel_id = ? AND user_id = ?')
.run(channelId, v.userId);
this.db
.prepare('DELETE FROM viewer_items WHERE channel_id = ? AND user_id = ?')
.run(channelId, v.userId);
const skillStmt = this.db.prepare(
'INSERT INTO viewer_skills (channel_id, user_id, skill, xp) VALUES (?, ?, ?, ?)',
);
for (const [skill, xp] of Object.entries(v.skills)) {
skillStmt.run(channelId, v.userId, skill, xp);
}
const itemStmt = this.db.prepare(
'INSERT INTO viewer_items (channel_id, user_id, item, qty) VALUES (?, ?, ?, ?)',
);
for (const [item, qty] of Object.entries(v.items)) {
if (qty > 0) itemStmt.run(channelId, v.userId, item, qty);
}
this.db.exec('COMMIT');
} catch (e) {
this.db.exec('ROLLBACK');
throw e;
}
}
close(): void {
this.db.close();
}
}