fad9760265
- 新增 Node 服务端(Hono + Hocuspocus + node:sqlite),数据保存在服务器 - 基于 Yjs 的多端实时同步,本机 IndexedDB 离线缓存,撤销只针对本机操作 - 空间(标签)有独立地址,增删改通过接口同步到所有设备 - 口令登录(签名 Cookie 会话、登录限流、同源校验) - 图片上传到服务器,过大照片上传前自动压缩 - 历史版本(自动/手动保存、一键恢复)、跨空间搜索、在线设备 - 随机油画加载动画(Painting Loaders,本地收录 p5 / p5.brush) - 选中样式与拖拽性能优化:拖动结束后才同步位置 - Docker / docker-compose 部署,README 与 CLAUDE.md 更新
150 lines
4.3 KiB
TypeScript
150 lines
4.3 KiB
TypeScript
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()
|
||
}
|
||
}
|