Replace unused vector clocks with a conflict check that works, drop the rest
The vector clocks in the server were never reached: no client sent one, so the branch was dead and every write was a blind overwrite. Not "the newer edit wins" — "the request that arrives last wins", which is worse: a phone that was offline all day buries the laptop's newer work simply by reconnecting late. Each write now carries baseUpdatedAt, the revision it was written on top of. If the note has moved on since, the incoming edit still wins — a rejection would throw away what was just typed — but the state it replaces is kept as a version marked "conflict", so both exist and the history is where you choose. Clocks could have told a real conflict from a device catching up, but that precision only pays off with a merge interface, and this reuses the version history already built. adoptRevision() stores the revision the server reports back, or the second save in a row would be flagged as conflicting with the first. Also removed, as agreed: - POST /api/notes/pdf — accepted PDFs that nothing could ever read back, with no retrieval route and no viewer. The readme advertised "PDF Import & Annotation"; that claim is gone too. - POST /api/sync — a batch endpoint the client never called. - the tags column — notebooks, starring and search already cover it. Columns on existing databases are left in place. Dropping them buys nothing and is not worth touching a live database for. Tables now have a right-click menu (editor/tableMenu.js) for rows, columns, header and merging. It needs stopPropagation: without it the same event reaches window, where the menu that is mounting reads it as a click elsewhere and closes instantly — the reason the other context menus in App.jsx already do this. 92 browser checks across seven suites, 125 unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8c36c6121b
commit
df279a5d36
10 files changed
+367
-152
No files matched your search
+53
-130
@@ -12,7 +12,7 @@
|
||||
*
|
||||
* Install:
|
||||
* npm init -y
|
||||
* npm install express better-sqlite3 bcryptjs jsonwebtoken ws uuid cors helmet dotenv multer
|
||||
* npm install express better-sqlite3 bcryptjs jsonwebtoken ws uuid cors helmet dotenv
|
||||
*
|
||||
* Run:
|
||||
* node server.js
|
||||
@@ -28,7 +28,6 @@ const { WebSocketServer } = require("ws");
|
||||
const Database = require("better-sqlite3");
|
||||
const { v4: uuid } = require("uuid");
|
||||
const crypto = require("crypto");
|
||||
const multer = require("multer");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
@@ -148,14 +147,12 @@ db.exec(`
|
||||
pdf_path TEXT,
|
||||
annotations_path TEXT,
|
||||
starred INTEGER DEFAULT 0,
|
||||
tags TEXT DEFAULT '[]',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
version INTEGER DEFAULT 1,
|
||||
device_id TEXT,
|
||||
vector_clock TEXT DEFAULT '{}'
|
||||
device_id TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_log (
|
||||
@@ -373,25 +370,27 @@ function detectImageMime(buffer, filePath = "") {
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
/* ─────────── Vector clock conflict resolution ─────────── */
|
||||
function mergeVectorClocks(clockA, clockB) {
|
||||
const merged = { ...clockA };
|
||||
for (const [device, time] of Object.entries(clockB)) {
|
||||
merged[device] = Math.max(merged[device] || 0, time);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function isClockNewer(clockA, clockB) {
|
||||
// Returns true if clockA is strictly newer than clockB
|
||||
let aHasNewer = false;
|
||||
for (const [device, time] of Object.entries(clockA)) {
|
||||
if (time > (clockB[device] || 0)) aHasNewer = true;
|
||||
}
|
||||
for (const [device, time] of Object.entries(clockB)) {
|
||||
if (time > (clockA[device] || 0)) return false; // conflict or older
|
||||
}
|
||||
return aHasNewer;
|
||||
/* ─────────── Conflict detection ─────────── */
|
||||
/**
|
||||
* Whether this write is based on a stale copy of the note.
|
||||
*
|
||||
* A client sends the `updatedAt` it last saw. If the note has moved on since —
|
||||
* another device saved in between — the two edits are independent and one is
|
||||
* about to bury the other.
|
||||
*
|
||||
* Nothing is rejected: refusing the write would lose whatever was just typed,
|
||||
* and on a phone that was offline for a day that is the worse outcome. The
|
||||
* incoming edit wins, but the version it replaces is kept with reason
|
||||
* "conflict", so both exist and the newer one can be picked in the history.
|
||||
*
|
||||
* This deliberately replaces the vector clocks that used to sit here. They
|
||||
* could tell a genuine conflict from a simple catch-up, but no client ever sent
|
||||
* one, and the extra precision only pays off with a merge interface to spend it
|
||||
* on. Comparing revisions needs one field and reuses the history already built.
|
||||
*/
|
||||
function isStaleWrite(serverUpdatedAt, clientBaseUpdatedAt) {
|
||||
if (!clientBaseUpdatedAt || !serverUpdatedAt) return false;
|
||||
return String(serverUpdatedAt) > String(clientBaseUpdatedAt);
|
||||
}
|
||||
|
||||
/* ─────────── Auth middleware ─────────── */
|
||||
@@ -460,14 +459,6 @@ setInterval(() => {
|
||||
// response time does not reveal which usernames exist.
|
||||
const DUMMY_HASH = bcrypt.hashSync("not-a-real-password", 12);
|
||||
|
||||
const upload = multer({
|
||||
dest: UPLOAD_DIR,
|
||||
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype === "application/pdf") cb(null, true);
|
||||
else cb(new Error("Only PDF files allowed"));
|
||||
},
|
||||
});
|
||||
|
||||
/* ─── Auth routes ─── */
|
||||
/* There is no registration route on purpose: this is a private instance, and
|
||||
@@ -719,12 +710,12 @@ function isBigShrink(previous, next) {
|
||||
|
||||
/* ─── Note routes ─── */
|
||||
app.get("/api/notes", authMiddleware, (req, res) => {
|
||||
const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, tags, sort_order, created_at, updated_at, version, vector_clock FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId);
|
||||
const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, sort_order, created_at, updated_at, version FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId);
|
||||
res.json(notes);
|
||||
});
|
||||
|
||||
app.get("/api/notes/trash", authMiddleware, (req, res) => {
|
||||
const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, tags, sort_order, created_at, updated_at, deleted_at, version FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC").all(req.userId);
|
||||
const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, sort_order, created_at, updated_at, deleted_at, version FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC").all(req.userId);
|
||||
res.json(notes);
|
||||
});
|
||||
|
||||
@@ -775,14 +766,13 @@ app.get("/api/notes/:id", authMiddleware, (req, res) => {
|
||||
});
|
||||
|
||||
app.post("/api/notes", authMiddleware, (req, res) => {
|
||||
const { type, title, content, notebookId, starred, tags } = req.body;
|
||||
const { type, title, content, notebookId, starred } = req.body;
|
||||
const id = uuid();
|
||||
const encContent = content ? encrypt(content) : null;
|
||||
const clock = JSON.stringify({ [req.deviceId]: 1 });
|
||||
|
||||
db.prepare(`INSERT INTO notes (id, user_id, notebook_id, type, title, content_encrypted, starred, tags, device_id, vector_clock)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(id, req.userId, notebookId, type || "text", title || "Untitled", encContent, starred ? 1 : 0, JSON.stringify(tags || []), req.deviceId, clock);
|
||||
db.prepare(`INSERT INTO notes (id, user_id, notebook_id, type, title, content_encrypted, starred, device_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(id, req.userId, notebookId, type || "text", title || "Untitled", encContent, starred ? 1 : 0, req.deviceId);
|
||||
|
||||
db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, device_id) VALUES (?, 'note', ?, 'create', ?)")
|
||||
.run(req.userId, id, req.deviceId);
|
||||
@@ -808,34 +798,25 @@ const replaceNoteLinks = db.transaction((userId, sourceId, links) => {
|
||||
});
|
||||
|
||||
app.put("/api/notes/:id", authMiddleware, (req, res) => {
|
||||
const { title, content, notebookId, starred, tags, vectorClock, links } = req.body;
|
||||
const { title, content, notebookId, starred, baseUpdatedAt, links } = req.body;
|
||||
const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
|
||||
if (!note) return res.status(404).json({ error: "Not found" });
|
||||
|
||||
// Conflict detection
|
||||
const serverClock = JSON.parse(note.vector_clock || "{}");
|
||||
const clientClock = vectorClock ? JSON.parse(vectorClock) : {};
|
||||
|
||||
if (Object.keys(clientClock).length > 0 && !isClockNewer(clientClock, serverClock)) {
|
||||
// Conflict! Return both versions
|
||||
return res.status(409).json({
|
||||
error: "Conflict detected",
|
||||
serverVersion: { ...note, vectorClock: serverClock },
|
||||
clientVersion: req.body,
|
||||
mergedClock: mergeVectorClocks(serverClock, clientClock),
|
||||
});
|
||||
}
|
||||
|
||||
const conflict = isStaleWrite(note.updated_at, baseUpdatedAt);
|
||||
const encContent = content ? encrypt(content) : note.content_encrypted;
|
||||
const newClock = { ...mergeVectorClocks(serverClock, clientClock), [req.deviceId]: (serverClock[req.deviceId] || 0) + 1 };
|
||||
|
||||
// Keep the state we are about to replace.
|
||||
if (content !== undefined) {
|
||||
// Keep the state we are about to replace. A conflicting write always keeps
|
||||
// one, whatever the coalescing window would otherwise say — that snapshot is
|
||||
// the only copy of the other device's work.
|
||||
if (content !== undefined || conflict) {
|
||||
const shrank = isBigShrink(note.content_encrypted, encContent);
|
||||
snapshotNote(req.userId, req.params.id, req.deviceId, {
|
||||
force: shrank,
|
||||
reason: shrank ? "large-deletion" : "edit",
|
||||
force: shrank || conflict,
|
||||
reason: conflict ? "conflict" : shrank ? "large-deletion" : "edit",
|
||||
});
|
||||
if (conflict) {
|
||||
console.log(` ⚠️ conflicting write to note ${req.params.id} from ${req.deviceId} — previous state kept`);
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE notes SET
|
||||
@@ -843,18 +824,18 @@ app.put("/api/notes/:id", authMiddleware, (req, res) => {
|
||||
content_encrypted = COALESCE(?, content_encrypted),
|
||||
notebook_id = COALESCE(?, notebook_id),
|
||||
starred = COALESCE(?, starred),
|
||||
tags = COALESCE(?, tags),
|
||||
updated_at = datetime('now'),
|
||||
version = version + 1,
|
||||
vector_clock = ?,
|
||||
device_id = ?
|
||||
WHERE id = ?`)
|
||||
.run(title, encContent, notebookId, starred !== undefined ? (starred ? 1 : 0) : null, tags ? JSON.stringify(tags) : null, JSON.stringify(newClock), req.deviceId, req.params.id);
|
||||
.run(title, encContent, notebookId, starred !== undefined ? (starred ? 1 : 0) : null, req.deviceId, req.params.id);
|
||||
|
||||
if (Array.isArray(links)) replaceNoteLinks(req.userId, req.params.id, links);
|
||||
|
||||
const fresh = db.prepare("SELECT updated_at FROM notes WHERE id = ?").get(req.params.id);
|
||||
broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, title } }, req.deviceId);
|
||||
res.json({ success: true, vectorClock: newClock });
|
||||
// `conflict` lets the client say so; `updatedAt` is what the next write bases itself on.
|
||||
res.json({ success: true, updatedAt: fresh.updated_at, conflict });
|
||||
});
|
||||
|
||||
/* ─── Version history ─── */
|
||||
@@ -1096,73 +1077,15 @@ app.get("/api/attachments/:id", authMiddleware, (req, res) => {
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
/* ─── PDF upload route ─── */
|
||||
app.post("/api/notes/pdf", authMiddleware, upload.single("pdf"), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: "No file uploaded" });
|
||||
|
||||
const id = uuid();
|
||||
const clock = JSON.stringify({ [req.deviceId]: 1 });
|
||||
|
||||
db.prepare(`INSERT INTO notes (id, user_id, type, title, pdf_path, device_id, vector_clock)
|
||||
VALUES (?, ?, 'pdf', ?, ?, ?, ?)`)
|
||||
.run(id, req.userId, req.file.originalname, req.file.path, req.deviceId, clock);
|
||||
|
||||
res.json({ id, title: req.file.originalname });
|
||||
});
|
||||
|
||||
/* ─── Sync endpoint ─── */
|
||||
app.post("/api/sync", authMiddleware, (req, res) => {
|
||||
const { lastSyncTimestamp, deviceId, changes } = req.body;
|
||||
const results = { pushed: [], pulled: [], conflicts: [] };
|
||||
|
||||
// Apply client changes
|
||||
if (changes && changes.length > 0) {
|
||||
for (const change of changes) {
|
||||
try {
|
||||
if (change.entityType === "note") {
|
||||
if (change.action === "create") {
|
||||
const encContent = change.data.content ? encrypt(change.data.content) : null;
|
||||
db.prepare(`INSERT OR REPLACE INTO notes (id, user_id, notebook_id, type, title, content_encrypted, starred, device_id, vector_clock, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`)
|
||||
.run(change.data.id, req.userId, change.data.notebookId, change.data.type, change.data.title, encContent, change.data.starred ? 1 : 0, deviceId, JSON.stringify(change.data.vectorClock || {}));
|
||||
results.pushed.push({ id: change.data.id, status: "ok" });
|
||||
} else if (change.action === "update") {
|
||||
const existing = db.prepare("SELECT vector_clock FROM notes WHERE id = ? AND user_id = ?").get(change.data.id, req.userId);
|
||||
if (existing) {
|
||||
const serverClock = JSON.parse(existing.vector_clock || "{}");
|
||||
const clientClock = change.data.vectorClock || {};
|
||||
if (!isClockNewer(clientClock, serverClock)) {
|
||||
results.conflicts.push({ id: change.data.id, serverClock, clientClock });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
results.pushed.push({ id: change.data.id, status: "ok" });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
results.pushed.push({ id: change.data?.id, status: "error", error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get server changes since last sync
|
||||
const serverChanges = db.prepare(`
|
||||
SELECT s.*, n.title, n.type, n.updated_at
|
||||
FROM sync_log s
|
||||
LEFT JOIN notes n ON s.entity_id = n.id
|
||||
WHERE s.user_id = ? AND s.timestamp > COALESCE(?, '1970-01-01') AND s.device_id != ?
|
||||
ORDER BY s.timestamp
|
||||
`).all(req.userId, lastSyncTimestamp, deviceId);
|
||||
|
||||
results.pulled = serverChanges;
|
||||
results.syncTimestamp = new Date().toISOString();
|
||||
|
||||
// Update device last sync
|
||||
db.prepare("INSERT OR REPLACE INTO devices (id, user_id, last_sync) VALUES (?, ?, datetime('now'))").run(deviceId, req.userId);
|
||||
|
||||
res.json(results);
|
||||
});
|
||||
/* Two routes used to sit here and were removed on 2026-08-08:
|
||||
*
|
||||
* POST /api/notes/pdf accepted a PDF and stored it, but nothing could ever
|
||||
* read it back — there was no retrieval route and no
|
||||
* viewer, so uploads simply accumulated unreachable.
|
||||
* POST /api/sync a batch sync endpoint the client never called; it
|
||||
* syncs through the individual routes instead.
|
||||
*
|
||||
* Both are in the git history if a PDF viewer is ever built for real. */
|
||||
|
||||
/* ─── WebSocket for real-time sync ─── */
|
||||
const server = http.createServer(app);
|
||||
|
||||
Reference in new issue
Block a user