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