106 lines
3.5 KiB
JavaScript
106 lines
3.5 KiB
JavaScript
// 同步服务存储层:SQLite(node:sqlite 内置,零依赖)
|
||
import { DatabaseSync } from 'node:sqlite';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
|
||
export const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data');
|
||
export const BLOB_DIR = path.join(DATA_DIR, 'blobs');
|
||
const DB_PATH = path.join(DATA_DIR, 'sync.db');
|
||
|
||
let db = null;
|
||
|
||
export function getDb() {
|
||
if (db) return db;
|
||
fs.mkdirSync(BLOB_DIR, { recursive: true });
|
||
db = new DatabaseSync(DB_PATH);
|
||
db.exec('PRAGMA journal_mode = WAL;');
|
||
db.exec('PRAGMA foreign_keys = ON;');
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id TEXT PRIMARY KEY,
|
||
username TEXT NOT NULL UNIQUE,
|
||
password_hash TEXT NOT NULL,
|
||
salt TEXT NOT NULL,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS devices (
|
||
id TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL REFERENCES users(id),
|
||
name TEXT NOT NULL,
|
||
last_seen_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||
UNIQUE(user_id, name)
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS tokens (
|
||
token TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL REFERENCES users(id),
|
||
device_id TEXT NOT NULL REFERENCES devices(id),
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||
expires_at TEXT NOT NULL
|
||
);
|
||
|
||
-- 每行最新状态(按 user + table + row_uuid 唯一,LWW 胜者)
|
||
CREATE TABLE IF NOT EXISTS current (
|
||
user_id TEXT NOT NULL,
|
||
table_name TEXT NOT NULL,
|
||
row_uuid TEXT NOT NULL,
|
||
op TEXT NOT NULL,
|
||
hlc TEXT NOT NULL,
|
||
ms INTEGER NOT NULL,
|
||
cnt INTEGER NOT NULL,
|
||
dev TEXT NOT NULL,
|
||
data TEXT,
|
||
updated_seq INTEGER,
|
||
PRIMARY KEY (user_id, table_name, row_uuid)
|
||
);
|
||
|
||
-- 全量变更历史(seq 单调递增,作为拉取游标基准)
|
||
CREATE TABLE IF NOT EXISTS changes (
|
||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id TEXT NOT NULL,
|
||
table_name TEXT NOT NULL,
|
||
row_uuid TEXT NOT NULL,
|
||
op TEXT NOT NULL,
|
||
hlc TEXT NOT NULL,
|
||
data TEXT,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_current_seq ON current(user_id, updated_seq);
|
||
CREATE INDEX IF NOT EXISTS idx_changes_user ON changes(user_id, seq);
|
||
|
||
-- 附件:内容寻址去重
|
||
CREATE TABLE IF NOT EXISTS attachments (
|
||
user_id TEXT NOT NULL,
|
||
sha256 TEXT NOT NULL,
|
||
size INTEGER NOT NULL,
|
||
mime TEXT,
|
||
file_name TEXT,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||
PRIMARY KEY (user_id, sha256)
|
||
);
|
||
`);
|
||
// 管理员与邀请码(动态颁发制;首个注册用户自动成为管理员)
|
||
const hasAdmin = db.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('users') WHERE name='is_admin'").get().c;
|
||
if (Number(hasAdmin) === 0) db.exec("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0;");
|
||
db.exec(`CREATE TABLE IF NOT EXISTS invite_codes (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
code TEXT NOT NULL UNIQUE,
|
||
created_by TEXT,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||
used_by TEXT,
|
||
used_at TEXT,
|
||
valid INTEGER NOT NULL DEFAULT 1
|
||
);`);
|
||
const hasRemark = db.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('invite_codes') WHERE name='remark'").get().c;
|
||
if (Number(hasRemark) === 0) db.exec("ALTER TABLE invite_codes ADD COLUMN remark TEXT;");
|
||
return db;
|
||
}
|
||
|
||
// 允许同步的表(与客户端 schema 对应;footprint_dimension 以 uuid 对作普通行同步)
|
||
export const SYNC_TABLES = new Set([
|
||
'footprint', 'footprint_type', 'taxonomy', 'dimension', 'attachment',
|
||
'semester', 'course_grade', 'metric_def', 'saved_view', 'footprint_dimension'
|
||
]);
|