import { DatabaseSync } from 'node:sqlite' export interface SnapshotRow { id: number spaceId: string createdAt: number cardCount: number preview: string reason: SnapshotReason } /** auto:编辑时定时保存;manual:手动保存;restore:恢复前自动备份当前状态 */ export type SnapshotReason = 'auto' | 'manual' | 'restore' /** 每个空间最多保留的历史版本数 */ const MAX_SNAPSHOTS_PER_SPACE = 120 /** * SQLite 存储,使用 Node 内置的 node:sqlite,不依赖原生编译模块,部署更省心。 * - documents:Yjs 文档的二进制状态 * - snapshots:空间的历史版本 */ export class Storage { private readonly db: DatabaseSync constructor(file: string) { this.db = new DatabaseSync(file) this.db.exec(` PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; CREATE TABLE IF NOT EXISTS documents ( name TEXT PRIMARY KEY, data BLOB NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS snapshots ( id INTEGER PRIMARY KEY AUTOINCREMENT, space_id TEXT NOT NULL, created_at INTEGER NOT NULL, data BLOB NOT NULL, state_vector BLOB NOT NULL, card_count INTEGER NOT NULL, preview TEXT NOT NULL, reason TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS snapshots_space ON snapshots (space_id, created_at); `) } loadDocument(name: string): Uint8Array | null { const row = this.db.prepare('SELECT data FROM documents WHERE name = ?').get(name) as | { data: Uint8Array } | undefined return row ? new Uint8Array(row.data) : null } saveDocument(name: string, data: Uint8Array) { this.db .prepare( `INSERT INTO documents (name, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`, ) .run(name, data, Date.now()) } deleteDocument(name: string) { this.db.prepare('DELETE FROM documents WHERE name = ?').run(name) } latestSnapshot(spaceId: string): { createdAt: number; stateVector: Uint8Array } | null { const row = this.db .prepare( 'SELECT created_at, state_vector FROM snapshots WHERE space_id = ? ORDER BY id DESC LIMIT 1', ) .get(spaceId) as { created_at: number; state_vector: Uint8Array } | undefined return row ? { createdAt: row.created_at, stateVector: new Uint8Array(row.state_vector) } : null } addSnapshot(input: { spaceId: string data: Uint8Array stateVector: Uint8Array cardCount: number preview: string reason: SnapshotReason }) { const result = this.db .prepare( `INSERT INTO snapshots (space_id, created_at, data, state_vector, card_count, preview, reason) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run( input.spaceId, Date.now(), input.data, input.stateVector, input.cardCount, input.preview, input.reason, ) this.db .prepare( `DELETE FROM snapshots WHERE space_id = ? AND id NOT IN ( SELECT id FROM snapshots WHERE space_id = ? ORDER BY id DESC LIMIT ? )`, ) .run(input.spaceId, input.spaceId, MAX_SNAPSHOTS_PER_SPACE) return Number(result.lastInsertRowid) } listSnapshots(spaceId: string): SnapshotRow[] { const rows = this.db .prepare( `SELECT id, space_id, created_at, card_count, preview, reason FROM snapshots WHERE space_id = ? ORDER BY id DESC`, ) .all(spaceId) as Array<{ id: number space_id: string created_at: number card_count: number preview: string reason: SnapshotReason }> return rows.map((row) => ({ id: Number(row.id), spaceId: row.space_id, createdAt: row.created_at, cardCount: row.card_count, preview: row.preview, reason: row.reason, })) } loadSnapshot(spaceId: string, id: number): Uint8Array | null { const row = this.db .prepare('SELECT data FROM snapshots WHERE space_id = ? AND id = ?') .get(spaceId, id) as { data: Uint8Array } | undefined return row ? new Uint8Array(row.data) : null } deleteSnapshots(spaceId: string) { this.db.prepare('DELETE FROM snapshots WHERE space_id = ?').run(spaceId) } close() { this.db.close() } }