Skip to content

Commit ef906aa

Browse files
authored
Merge pull request #19 from AdaInTheLab/bugfix/sync-overwrite
FIX [CONTENT] Prevent FS sync from overwriting admin-edited content 🧿
2 parents ac84382 + c5f2ed8 commit ef906aa

3 files changed

Lines changed: 161 additions & 15 deletions

File tree

src/routes/adminRoutes.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,14 @@ import type Database from "better-sqlite3";
44
import { randomUUID } from "node:crypto";
55
import { marked } from "marked";
66
import passport, { requireAdmin, isGithubOAuthEnabled } from "../auth.js";
7-
import { syncLabNotesFromFs } from "../services/syncLabNotesFromFs.js";
7+
import { syncLabNotesFromFs, SyncCounts } from "../services/syncLabNotesFromFs.js";
88
import { normalizeLocale, sha256Hex } from "../lib/helpers.js";
99

1010
marked.setOptions({
1111
gfm: true,
1212
breaks: false, // ✅ strict
1313
});
1414

15-
1615
export function registerAdminRoutes(app: any, db: Database.Database) {
1716
// Must match your UI origin exactly (no trailing slash)
1817
const UI_BASE_URL = process.env.UI_BASE_URL ?? "http://localhost:8001";
@@ -434,15 +433,21 @@ export function registerAdminRoutes(app: any, db: Database.Database) {
434433
// ---------------------------------------------------------------------------
435434
// Admin: Syncs MD Files to DB (protected)
436435
// ---------------------------------------------------------------------------
437-
app.post("/admin/notes/sync", requireAdmin, (req: any, res: { json: (arg0: { rootDir: string; locales: string[]; scanned: number; upserted: number; skipped: number; errors: Array<{ file: string; error: string; }>; ok: boolean; }) => void; status: (arg0: number) => { (): any; new(): any; json: { (arg0: { ok: boolean; error: any; }): void; new(): any; }; }; }) => {
436+
app.post("/admin/notes/sync", requireAdmin, (req: Request, res: Response) => {
438437
try {
439-
const result = syncLabNotesFromFs(db);
438+
const force =
439+
String(req.query.force ?? "").trim() === "1" ||
440+
req.body?.force === true ||
441+
String(req.body?.force ?? "").trim() === "1";
442+
443+
const result: SyncCounts = syncLabNotesFromFs(db, { force });
440444
res.json({ ok: true, ...result });
441445
} catch (e: any) {
442446
res.status(500).json({ ok: false, error: e?.message ?? String(e) });
443447
}
444448
});
445449

450+
446451
// ---------------------------------------------------------------------------
447452
// Auth helpers (always available)
448453
// ---------------------------------------------------------------------------

src/services/syncLabNotesFromFs.ts

Lines changed: 71 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@ import crypto from "node:crypto";
55
import matter from "gray-matter";
66
import type Database from "better-sqlite3";
77

8-
type SyncCounts = {
8+
export type SyncCounts = {
99
rootDir: string;
1010
locales: string[];
1111
scanned: number;
1212
upserted: number;
1313
skipped: number;
1414
revisionsInserted: number;
1515
pointersUpdated: number;
16+
pointersSkippedProtected: number;
17+
pointersForced: number;
1618
emptyBodySkipped: number;
1719
errors: Array<{ file: string; error: string }>;
20+
protected: Array<{ slug: string; locale: string; reason: string }>;
1821
};
1922

2023
/* ===========================================================
@@ -83,7 +86,11 @@ function jsonString(v: any, fallback: any): string {
8386
}
8487
}
8588

86-
export function syncLabNotesFromFs(db: Database.Database): SyncCounts {
89+
export function syncLabNotesFromFs(
90+
db: Database.Database,
91+
opts?: { force?: boolean }
92+
): SyncCounts {
93+
const force = Boolean(opts?.force);
8794
const rootDir = String(process.env.LABNOTES_DIR || "").trim();
8895
if (!rootDir) throw new Error("LABNOTES_DIR is not set");
8996
if (!fs.existsSync(rootDir)) throw new Error(`LABNOTES_DIR not found: ${rootDir}`);
@@ -103,8 +110,11 @@ export function syncLabNotesFromFs(db: Database.Database): SyncCounts {
103110
skipped: 0,
104111
revisionsInserted: 0,
105112
pointersUpdated: 0,
113+
pointersSkippedProtected: 0,
114+
pointersForced: 0,
106115
emptyBodySkipped: 0,
107116
errors: [],
117+
protected: [],
108118
};
109119

110120
// -----------------------------
@@ -167,10 +177,18 @@ export function syncLabNotesFromFs(db: Database.Database): SyncCounts {
167177
`);
168178

169179
const selectLatestRevision = db.prepare(`
170-
SELECT id, revision_num, content_hash, length(content_markdown) AS md_len
171-
FROM lab_note_revisions
172-
WHERE note_id = ?
173-
ORDER BY revision_num DESC
180+
SELECT id, revision_num, content_hash, length(content_markdown) AS md_len, source
181+
FROM lab_note_revisions
182+
WHERE note_id = ?
183+
ORDER BY revision_num DESC
184+
LIMIT 1
185+
`);
186+
187+
const selectCurrentRevisionSource = db.prepare(`
188+
SELECT r.source AS source
189+
FROM lab_notes n
190+
LEFT JOIN lab_note_revisions r ON r.id = n.current_revision_id
191+
WHERE n.id = ?
174192
LIMIT 1
175193
`);
176194

@@ -287,12 +305,20 @@ export function syncLabNotesFromFs(db: Database.Database): SyncCounts {
287305
const hash = sha256Hex(markdown);
288306

289307
const latest = selectLatestRevision.get(noteRow.id) as
290-
| { id: string; revision_num: number; content_hash: string; md_len: number }
308+
| { id: string; revision_num: number; content_hash: string; md_len: number; source: string | null }
291309
| undefined;
292310

293311
// 3) Idempotency: if the latest revision already matches this body, do nothing
312+
// 3) Idempotency: if the latest revision already matches this body, usually do nothing
294313
if (latest && latest.content_hash === hash && (latest.md_len ?? 0) > 0) {
295-
counts.skipped += 1;
314+
// But if we're forcing, we may still need to advance pointers to the latest import revision
315+
if (force && latest.source === "import") {
316+
updatePointers.run(latest.id, latest.id, noteRow.id);
317+
counts.pointersUpdated += 1;
318+
counts.pointersForced += 1;
319+
} else {
320+
counts.skipped += 1;
321+
}
296322
return;
297323
}
298324

@@ -350,9 +376,43 @@ export function syncLabNotesFromFs(db: Database.Database): SyncCounts {
350376
});
351377
counts.revisionsInserted += 1;
352378

353-
// 5) Advance pointers ONLY to a non-empty revision (this one is guaranteed non-empty)
354-
updatePointers.run(newRevId, newRevId, noteRow.id);
355-
counts.pointersUpdated += 1;
379+
// 5) Advance pointers ONLY if safe (and non-empty revision is already guaranteed)
380+
const curSourceRow = selectCurrentRevisionSource.get(noteRow.id) as
381+
| { source?: string | null }
382+
| undefined;
383+
384+
const curSource = curSourceRow?.source ?? null;
385+
386+
// Safe-to-advance rules:
387+
// - force=true: always advance (explicit override)
388+
// - no current revision: new note, safe
389+
// - current source is 'import': FS already owns the current draft
390+
const canAdvance =
391+
force ||
392+
!noteRow.current_revision_id ||
393+
curSource === "import";
394+
395+
if (canAdvance) {
396+
updatePointers.run(newRevId, newRevId, noteRow.id);
397+
counts.pointersUpdated += 1;
398+
399+
if (force && noteRow.current_revision_id && curSource !== "import") {
400+
counts.pointersForced += 1;
401+
}
402+
} else {
403+
// Protected admin draft: we still inserted the import revision, but we don't switch pointers
404+
counts.pointersSkippedProtected += 1;
405+
406+
// Optional: record protected items for UI/debug (cap it)
407+
if (counts.protected.length < 50) {
408+
counts.protected.push({
409+
slug,
410+
locale,
411+
reason: `protected admin draft (current source=${curSource})`,
412+
});
413+
}
414+
}
415+
356416
} catch (e: any) {
357417
counts.errors.push({ file: filePath, error: e?.message ?? String(e) });
358418
}

tests/admin.routes.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import request from "supertest";
22
import { createTestApp, api } from "./helpers/createTestApp.js";
3+
import fs from "fs";
4+
import os from "os";
5+
import path from "path";
36

47
describe("Admin routes", () => {
58
const OLD_ENV = { ...process.env };
@@ -133,5 +136,83 @@ describe("Admin routes", () => {
133136
expect(matches[0].title).toBe("Version 2");
134137
expect(matches[0].summary).toBe("two");
135138
});
139+
140+
test("POST /admin/notes/sync protects admin (web) current revision unless forced", async () => {
141+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "labnotes-"));
142+
process.env.LABNOTES_DIR = tmp;
143+
144+
const localeDir = path.join(tmp, "en");
145+
fs.mkdirSync(localeDir, { recursive: true });
146+
147+
const { app, db } = createTestApp();
148+
149+
// 1) Create admin draft (current revision should be source='web')
150+
const slug = "sync-protect-me";
151+
const locale = "en";
152+
153+
const create = await request(app).post(api("/admin/notes")).send({
154+
title: "Admin Draft",
155+
slug,
156+
locale,
157+
status: "draft",
158+
contentMarkdown: "ADMIN VERSION", // use whatever field your admin route accepts
159+
});
160+
161+
expect(create.status).toBe(201);
162+
163+
// 2) Write conflicting FS markdown for same slug/locale
164+
const mdPath = path.join(localeDir, `${slug}.md`);
165+
fs.writeFileSync(
166+
mdPath,
167+
[
168+
"---",
169+
`slug: ${slug}`,
170+
"title: FS Version",
171+
"type: labnote",
172+
"---",
173+
"",
174+
"FS VERSION",
175+
"",
176+
].join("\n"),
177+
"utf8"
178+
);
179+
180+
// Helper: read current revision source for the note
181+
const getCurrentSource = () => {
182+
const row = db
183+
.prepare(
184+
`
185+
SELECT r.source AS source
186+
FROM lab_notes n
187+
JOIN lab_note_revisions r ON r.id = n.current_revision_id
188+
WHERE n.slug = ? AND n.locale = ?
189+
LIMIT 1
190+
`
191+
)
192+
.get(slug, locale) as { source: string } | undefined;
193+
194+
return row?.source ?? null;
195+
};
196+
197+
expect(getCurrentSource()).toBe("web");
198+
199+
// 3) Sync WITHOUT force -> should NOT clobber web pointer
200+
const s1 = await request(app).post(api("/admin/notes/sync"));
201+
expect(s1.status).toBe(200);
202+
expect(s1.body?.ok).toBe(true);
203+
204+
// the key behavior change
205+
expect(s1.body?.pointersSkippedProtected).toBeGreaterThanOrEqual(1);
206+
expect(getCurrentSource()).toBe("web");
207+
208+
// 4) Sync WITH force -> should advance pointer to import
209+
const s2 = await request(app).post(api("/admin/notes/sync?force=1"));
210+
expect(s2.status).toBe(200);
211+
expect(s2.body?.ok).toBe(true);
212+
213+
expect(getCurrentSource()).toBe("import");
214+
expect(s2.body?.pointersForced).toBeGreaterThanOrEqual(1);
215+
});
216+
136217
});
137218
});

0 commit comments

Comments
 (0)