import { DatabaseSync } from "node:sqlite"; import { clampNoteBody, createSeedState, normalizeStateForWrite } from "./state-utils.mjs"; export function createSqliteRepository({ dbPath }) { const db = new DatabaseSync(dbPath); return { runtime: "node", async init() { db.exec(schemaSql); seedDatabase(db); }, async readState() { return readState(db); }, async writeState(state) { writeState(db, state); return readState(db); }, async createNote(note) { return createNote(db, note); }, async updateNote(id, patch) { return updateNote(db, id, patch); }, async deleteNote(id) { db.prepare("DELETE FROM notes WHERE id = ?").run(id); }, async createFolder(folder) { return createFolder(db, folder); }, async updateFolder(id, patch) { return updateFolder(db, id, patch); }, async deleteFolder(id) { return deleteFolder(db, id); }, async getUser() { return db.prepare("SELECT id, password_hash AS passwordHash, created_at AS createdAt FROM users ORDER BY created_at ASC LIMIT 1").get() || null; }, async updateUserPassword({ passwordHash }) { const user = await this.getUser(); if (!user) return null; db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(passwordHash, user.id); return { ...user, passwordHash }; }, async createUser({ passwordHash }) { const now = Date.now(); const id = crypto.randomUUID(); db.prepare("INSERT INTO users (id, password_hash, created_at) VALUES (?, ?, ?)").run(id, passwordHash, now); return { id, passwordHash, createdAt: now }; }, async createSession({ tokenHash, expiresAt }) { const id = crypto.randomUUID(); db.prepare("INSERT INTO sessions (id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?)").run(id, tokenHash, expiresAt, Date.now()); return { id, tokenHash, expiresAt }; }, async getSession(tokenHash) { db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(Date.now()); return db.prepare("SELECT id, token_hash AS tokenHash, expires_at AS expiresAt FROM sessions WHERE token_hash = ? LIMIT 1").get(tokenHash) || null; }, async deleteSession(tokenHash) { db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash); }, async deleteAllSessions() { db.prepare("DELETE FROM sessions").run(); } }; } const schemaSql = ` PRAGMA journal_mode = WAL; CREATE TABLE IF NOT EXISTS folders ( id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, version INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS notes ( id TEXT PRIMARY KEY, folder TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, version INTEGER NOT NULL DEFAULT 1, FOREIGN KEY(folder) REFERENCES folders(id) ); CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL ); `; function seedDatabase(db) { migrateDatabase(db); const existing = db.prepare("SELECT COUNT(*) AS count FROM folders").get(); if (existing.count > 0) return; writeState(db, createSeedState()); } function migrateDatabase(db) { addColumnIfMissing(db, "folders", "version", "INTEGER NOT NULL DEFAULT 1"); addColumnIfMissing(db, "notes", "version", "INTEGER NOT NULL DEFAULT 1"); } function addColumnIfMissing(db, table, column, definition) { const columns = db.prepare(`PRAGMA table_info(${table})`).all(); if (columns.some((entry) => entry.name === column)) return; db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); } function readState(db) { const folders = db.prepare(` SELECT id, name, created_at AS createdAt, updated_at AS updatedAt, version FROM folders ORDER BY created_at ASC `).all(); const notes = db.prepare(` SELECT id, folder, body, created_at AS createdAt, updated_at AS updatedAt, version FROM notes ORDER BY updated_at DESC `).all(); return { folders, notes }; } function writeState(db, state) { const { folders, notes } = normalizeStateForWrite(state); const insertFolder = db.prepare(` INSERT INTO folders (id, name, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?) `); const insertNote = db.prepare(` INSERT INTO notes (id, folder, body, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?) `); db.exec("BEGIN"); try { db.exec("DELETE FROM notes"); db.exec("DELETE FROM folders"); folders.forEach((folder) => { insertFolder.run(folder.id, folder.name, folder.createdAt, folder.updatedAt, folder.version); }); notes.forEach((note) => { insertNote.run(note.id, note.folder, note.body, note.createdAt, note.updatedAt, note.version); }); db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); throw error; } } function createNote(db, note) { const now = Date.now(); const folder = db.prepare("SELECT id FROM folders WHERE id = ? LIMIT 1").get(note.folder); const folderId = folder?.id || "notes"; const createdAt = Number(note.createdAt || now); const updatedAt = Number(note.updatedAt || now); const id = String(note.id || crypto.randomUUID()); db.prepare(` INSERT INTO notes (id, folder, body, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, 1) `).run(id, folderId, clampNoteBody(note.body), createdAt, updatedAt); return getNote(db, id); } function updateNote(db, id, patch) { const existing = getNote(db, id); if (!existing) return { status: "missing" }; const expectedVersion = Number(patch.version); if (expectedVersion && expectedVersion !== existing.version) { return { status: "conflict", note: existing }; } const folder = patch.folder && db.prepare("SELECT id FROM folders WHERE id = ? LIMIT 1").get(patch.folder); const nextFolder = folder?.id || existing.folder; const nextBody = patch.body === undefined ? existing.body : clampNoteBody(patch.body); const nextUpdatedAt = Number(patch.updatedAt || Date.now()); const nextVersion = existing.version + 1; db.prepare(` UPDATE notes SET folder = ?, body = ?, updated_at = ?, version = ? WHERE id = ? `).run(nextFolder, nextBody, nextUpdatedAt, nextVersion, id); return { status: "ok", note: getNote(db, id) }; } function getNote(db, id) { return db.prepare(` SELECT id, folder, body, created_at AS createdAt, updated_at AS updatedAt, version FROM notes WHERE id = ? LIMIT 1 `).get(id) || null; } function createFolder(db, folder) { const now = Date.now(); const id = String(folder.id || crypto.randomUUID()); const name = String(folder.name || "未命名文件夹").trim() || "未命名文件夹"; const createdAt = Number(folder.createdAt || now); const updatedAt = Number(folder.updatedAt || now); db.prepare(` INSERT INTO folders (id, name, created_at, updated_at, version) VALUES (?, ?, ?, ?, 1) `).run(id, name, createdAt, updatedAt); return getFolder(db, id); } function updateFolder(db, id, patch) { const existing = getFolder(db, id); if (!existing || existing.id === "notes") return { status: "missing" }; const expectedVersion = Number(patch.version); if (expectedVersion && expectedVersion !== existing.version) { return { status: "conflict", folder: existing }; } const nextName = String(patch.name ?? existing.name).trim() || existing.name; const nextUpdatedAt = Number(patch.updatedAt || Date.now()); const nextVersion = existing.version + 1; db.prepare(` UPDATE folders SET name = ?, updated_at = ?, version = ? WHERE id = ? `).run(nextName, nextUpdatedAt, nextVersion, id); return { status: "ok", folder: getFolder(db, id) }; } function deleteFolder(db, id) { const folder = getFolder(db, id); if (!folder || folder.id === "notes") return { status: "missing" }; const fallback = getFolder(db, "notes") || db.prepare("SELECT id FROM folders ORDER BY created_at ASC LIMIT 1").get(); if (!fallback) return { status: "missing" }; const now = Date.now(); db.exec("BEGIN"); try { db.prepare("UPDATE notes SET folder = ?, updated_at = ?, version = version + 1 WHERE folder = ?").run(fallback.id, now, folder.id); db.prepare("DELETE FROM folders WHERE id = ?").run(folder.id); db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); throw error; } return { status: "ok", fallbackFolderId: fallback.id }; } function getFolder(db, id) { return db.prepare(` SELECT id, name, created_at AS createdAt, updated_at AS updatedAt, version FROM folders WHERE id = ? LIMIT 1 `).get(id) || null; }