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

150 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}
}