notes / src /data /sqlite-repository.mjs
ghuser1's picture
Update src/data/sqlite-repository.mjs
ab1d25f verified
Raw
History Blame Contribute Delete
8.97 kB
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;
}