From 685035d975b79919adc159aef3cb22c903cc0756 Mon Sep 17 00:00:00 2001 From: Ada Date: Fri, 9 Jan 2026 13:28:52 -0500 Subject: [PATCH] =?UTF-8?q?FIX=20[SCMS]=20Stabilize=20admin=20API=20base?= =?UTF-8?q?=20URL=20resolution=20=F0=9F=A7=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use same-origin base by default (prevents localhost leakage in prod) - Allow VITE_API_BASE_URL override for local dev co-authored-by: Lyric co-authored-by: Carmel --- .env.example | 2 +- src/app.ts | 2 + src/db.ts | 1 - src/lib/markdownBlocks.ts | 19 +++ src/mappers/labNotesMapper.ts | 5 + src/routes/adminRoutes.ts | 116 ++++++++++------ src/routes/labNotesRoutes.ts | 91 +++++++------ src/services/syncLabNotesFromFs.ts | 207 ++++++++++++++++++++--------- 8 files changed, 293 insertions(+), 150 deletions(-) create mode 100644 src/lib/markdownBlocks.ts diff --git a/.env.example b/.env.example index aaffe93..f04f23e 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,6 @@ SESSION_SECRET=your_session_secret_here ALLOWED_GITHUB_USERNAME=your_username_here ADMIN_DEV_BYPASS=true -LABNOTES_DIR=/home/humanpatternlab/lab-api/content/labnotes +LABNOTES_DIR=content/labnotes diff --git a/src/app.ts b/src/app.ts index c801975..17f5597 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,6 +9,7 @@ import { registerLabNotesRoutes } from "./routes/labNotesRoutes.js"; import { registerAdminRoutes } from "./routes/adminRoutes.js"; import OpenApiValidator from "express-openapi-validator"; import { registerOpenApiRoutes } from "./routes/openapiRoutes.js"; +import { registerAdminTokensRoutes } from "./routes/adminTokensRoutes.js"; import fs from "node:fs"; import path from "node:path"; import { env } from "./env.js"; @@ -228,6 +229,7 @@ export function createApp() { registerHealthRoutes(api, dbPath); registerAdminRoutes(api, db); registerLabNotesRoutes(api, db); + registerAdminTokensRoutes(app, db); // MOUNT THE ROUTER (this is what makes routes actually exist) app.use("/", api); // ✅ canonical diff --git a/src/db.ts b/src/db.ts index 4d42680..1e62451 100644 --- a/src/db.ts +++ b/src/db.ts @@ -3,7 +3,6 @@ import Database from "better-sqlite3"; import path from "path"; import { fileURLToPath } from "url"; import crypto from "crypto"; -import { marked } from "marked"; import { env } from "./env.js"; import { nowIso, sha256Hex } from './lib/helpers.js'; import { migrateLabNotesSchema, LAB_NOTES_SCHEMA_VERSION } from "./db/migrateLabNotes.js"; diff --git a/src/lib/markdownBlocks.ts b/src/lib/markdownBlocks.ts new file mode 100644 index 0000000..b02291c --- /dev/null +++ b/src/lib/markdownBlocks.ts @@ -0,0 +1,19 @@ +export function expandMascotBlocks(md: string): string { + // :::carmel ... ::: + return md.replace( + /(^|\n):::carmel\s*\n([\s\S]*?)\n:::(?=\n|$)/g, + (_m, lead, body) => { + const text = body.trim(); + // Escape HTML so users can't inject tags inside the block + const safe = text + .replace(/&/g, "&") + .replace(//g, ">"); + + return `${lead}
+
😼 Carmel calls this:
+
“${safe}”
+
`; + } + ); +} diff --git a/src/mappers/labNotesMapper.ts b/src/mappers/labNotesMapper.ts index 0ae0f75..12ab173 100644 --- a/src/mappers/labNotesMapper.ts +++ b/src/mappers/labNotesMapper.ts @@ -27,6 +27,11 @@ const ALLOWED_NOTE_TYPES: ReadonlySet = new Set([ "weather", ]); +marked.setOptions({ + gfm: true, + breaks: false, // ✅ strict +}); + /** * deriveStatus * If status is missing/invalid, infer from publish timestamp. diff --git a/src/routes/adminRoutes.ts b/src/routes/adminRoutes.ts index c1e2da5..9e25738 100644 --- a/src/routes/adminRoutes.ts +++ b/src/routes/adminRoutes.ts @@ -2,10 +2,17 @@ import type { Request, Response } from "express"; import type Database from "better-sqlite3"; import { randomUUID } from "node:crypto"; +import { marked } from "marked"; import passport, { requireAdmin, isGithubOAuthEnabled } from "../auth.js"; import { syncLabNotesFromFs } from "../services/syncLabNotesFromFs.js"; import { normalizeLocale, sha256Hex } from "../lib/helpers.js"; +marked.setOptions({ + gfm: true, + breaks: false, // ✅ strict +}); + + export function registerAdminRoutes(app: any, db: Database.Database) { // Must match your UI origin exactly (no trailing slash) const UI_BASE_URL = process.env.UI_BASE_URL ?? "http://localhost:8001"; @@ -121,13 +128,32 @@ export function registerAdminRoutes(app: any, db: Database.Database) { // ✅ Resolve canonical noteId by (slug, locale) to make upserts stable const existing = db - .prepare("SELECT id FROM lab_notes WHERE slug = ? AND locale = ?") - .get(slug, noteLocale) as { id: string } | undefined; + .prepare("SELECT id, department_id, dept, type FROM lab_notes WHERE slug = ? AND locale = ?") + .get(slug, noteLocale) as + | { id: string; department_id: string | null; dept: string | null; type: string | null } + | undefined; // If the row already exists, prefer its id over any incoming id. // This prevents “identity drift” where (slug, locale) updates a different id. const noteId = existing?.id ?? id ?? randomUUID(); + const incomingDepartment = + typeof department_id === "string" && department_id.trim() ? department_id.trim() : null; + + const incomingDept = + typeof dept === "string" && dept.trim() ? dept.trim() : null; + + // Preserve existing if not provided, else default for brand-new notes + const resolvedDepartment = + incomingDepartment ?? existing?.department_id ?? "SCMS"; + + const resolvedDept = + incomingDept ?? existing?.dept ?? null; + + // Type is identity-ish too; preserve if missing + const resolvedType = + (typeof type === "string" && type.trim() ? type.trim() : null) ?? existing?.type ?? "labnote"; + const tx = db.transaction(() => { // 1) Upsert metadata row (NO content_html writes, NO content_markdown column) db.prepare(` @@ -151,11 +177,11 @@ export function registerAdminRoutes(app: any, db: Database.Database) { title=excluded.title, type=excluded.type, status=excluded.status, - dept=excluded.dept, + dept = COALESCE(excluded.dept, lab_notes.dept), category=excluded.category, excerpt=excluded.excerpt, summary=excluded.summary, - department_id=excluded.department_id, + department_id = COALESCE(excluded.department_id, lab_notes.department_id), shadow_density=excluded.shadow_density, coherence_score=excluded.coherence_score, safer_landing=excluded.safer_landing, @@ -176,7 +202,7 @@ export function registerAdminRoutes(app: any, db: Database.Database) { excerpt || "", summary || "", - department_id || "SCMS", + incomingDepartment, shadow_density ?? 0, coherence_score ?? 1.0, safer_landing ? 1 : 0, @@ -200,7 +226,7 @@ export function registerAdminRoutes(app: any, db: Database.Database) { const nextRev = (revRow?.maxRev ?? 0) + 1; // 4) Create revision row (ledger truth) - const revisionId = crypto.randomUUID(); + const revisionId = randomUUID(); const prevPointer = db .prepare(` @@ -219,7 +245,7 @@ export function registerAdminRoutes(app: any, db: Database.Database) { status: noteStatus, published: normalizedPublishedAt ?? undefined, dept: dept ?? null, - department_id: department_id || "SCMS", + department_id: resolvedDepartment, shadow_density: shadow_density ?? 0, coherence_score: coherence_score ?? 1.0, safer_landing: Boolean(safer_landing), @@ -342,57 +368,63 @@ export function registerAdminRoutes(app: any, db: Database.Database) { // --------------------------------------------------------------------------- // Admin: Publish Lab Note (protected) // --------------------------------------------------------------------------- - app.post("/admin/notes/:id/publish", requireAdmin, (req: Request, res: Response) => { + // Admin: Publish by slug + locale + app.post("/admin/notes/:slug/publish", requireAdmin, (req: Request, res: Response) => { try { - const id = String(req.params.id ?? "").trim(); - if (!id) return res.status(400).json({ error: "id is required" }); + const slug = String(req.params.slug ?? "").trim(); + const locale = normalizeLocale(String(req.query.locale ?? "en")); + if (!slug) return res.status(400).json({ error: "slug is required" }); const nowDate = new Date().toISOString().slice(0, 10); - const result = db - .prepare( - ` - UPDATE lab_notes - SET - status = 'published', - published_at = COALESCE(published_at, ?), - updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') - WHERE id = ? - ` - ) - .run(nowDate, id); + const row = db + .prepare(`SELECT id FROM lab_notes WHERE slug = ? AND locale = ? LIMIT 1`) + .get(slug, locale) as { id: string } | undefined; - if (result.changes === 0) return res.status(404).json({ error: "Not found" }); - return res.json({ ok: true, id, status: "published" }); + if (!row) return res.status(404).json({ error: "Not found" }); + + db.prepare(` + UPDATE lab_notes + SET + status = 'published', + published_revision_id = current_revision_id, + published_at = COALESCE(published_at, ?), + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE id = ? + `).run(nowDate, row.id); + + return res.json({ ok: true, slug, locale, id: row.id, status: "published" }); } catch (e: any) { return res.status(500).json({ error: "Database error", details: String(e?.message ?? e) }); } }); - // --------------------------------------------------------------------------- // Admin: Un-publish Lab Note (protected) // --------------------------------------------------------------------------- - app.post("/admin/notes/:id/unpublish", requireAdmin, (req: Request, res: Response) => { + app.post("/admin/notes/:slug/unpublish", requireAdmin, (req: Request, res: Response) => { try { - const id = String(req.params.id ?? "").trim(); - if (!id) return res.status(400).json({ error: "id is required" }); + const slug = String(req.params.slug ?? "").trim(); + const locale = normalizeLocale(String(req.query.locale ?? "en")); + if (!slug) return res.status(400).json({ error: "slug is required" }); - const result = db - .prepare( - ` - UPDATE lab_notes - SET - status = 'draft', - published_at = NULL, - updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') - WHERE id = ? - ` - ) - .run(id); + const row = db + .prepare(`SELECT id FROM lab_notes WHERE slug = ? AND locale = ? LIMIT 1`) + .get(slug, locale) as { id: string } | undefined; + + if (!row) return res.status(404).json({ error: "Not found" }); - if (result.changes === 0) return res.status(404).json({ error: "Not found" }); - return res.json({ ok: true, id, status: "draft" }); + db.prepare(` + UPDATE lab_notes + SET + status = 'draft', + published_revision_id = NULL, + published_at = NULL, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE id = ? + `).run(row.id); + + return res.json({ ok: true, slug, locale, id: row.id, status: "draft" }); } catch (e: any) { return res.status(500).json({ error: "Database error", details: String(e?.message ?? e) }); } diff --git a/src/routes/labNotesRoutes.ts b/src/routes/labNotesRoutes.ts index 47190a1..b8bc637 100644 --- a/src/routes/labNotesRoutes.ts +++ b/src/routes/labNotesRoutes.ts @@ -4,9 +4,13 @@ import type Database from "better-sqlite3"; import type { LabNoteRecord, TagResult } from "../types/labNotes.js"; import { mapToLabNotePreview, mapToLabNoteView } from "../mappers/labNotesMapper.js"; import { normalizeLocale, inferLocale } from "../lib/helpers.js"; +import { expandMascotBlocks } from "../lib/markdownBlocks.js"; +import { marked } from "marked"; -// Markdown -> HTML for public rendering -import { marked } from "marked"; // npm i marked +marked.setOptions({ + gfm: true, + breaks: false, // ✅ strict +}); export function registerLabNotesRoutes(app: any, db: Database.Database) { // --------------------------------------------------------------------------- @@ -23,32 +27,31 @@ export function registerLabNotesRoutes(app: any, db: Database.Database) { `; const sqlAll = ` - SELECT - id, slug, locale, type, status, - title, subtitle, summary, excerpt, - department_id, dept, shadow_density, safer_landing, read_time_minutes, - published_at, created_at, updated_at - FROM v_lab_notes - WHERE status = 'published' - ${orderBy} - `; + SELECT + id, slug, locale, type, status, + title, subtitle, summary, excerpt, + department_id, dept, shadow_density, safer_landing, read_time_minutes, + published_at, created_at, updated_at + FROM v_lab_notes + WHERE status = 'published' + ${orderBy} + `; const sqlByLocale = ` - SELECT - id, slug, locale, type, status, - title, subtitle, summary, excerpt, - department_id, dept, shadow_density, safer_landing, read_time_minutes, - published_at, created_at, updated_at - FROM v_lab_notes - WHERE locale = ? - AND status = 'published' - ${orderBy} - `; + SELECT + id, slug, locale, type, status, + title, subtitle, summary, excerpt, + department_id, dept, shadow_density, safer_landing, read_time_minutes, + published_at, created_at, updated_at + FROM v_lab_notes + WHERE locale = ? + AND status = 'published' + ${orderBy} + `; const notes = (locale === "all" - ? db.prepare(sqlAll).all() - : db.prepare(sqlByLocale).all(locale) - ) as LabNoteRecord[]; + ? db.prepare(sqlAll).all() + : db.prepare(sqlByLocale).all(locale)) as LabNoteRecord[]; const mapped = notes.map((note) => { const tagRows = db @@ -78,19 +81,19 @@ export function registerLabNotesRoutes(app: any, db: Database.Database) { if (!slug) return res.status(400).json({ error: "slug is required" }); const sql = ` - SELECT - id, slug, locale, type, status, - title, subtitle, summary, excerpt, category, - department_id, dept, shadow_density, coherence_score, - safer_landing, read_time_minutes, - published_at, created_at, updated_at, - content_markdown - FROM v_lab_notes - WHERE slug = ? - AND locale = ? - AND status = 'published' - LIMIT 1 - `; + SELECT + id, slug, locale, type, status, + title, subtitle, summary, excerpt, category, + department_id, dept, shadow_density, coherence_score, + safer_landing, read_time_minutes, + published_at, created_at, updated_at, + content_markdown + FROM v_lab_notes + WHERE slug = ? + AND locale = ? + AND status = 'published' + LIMIT 1 + `; // 1) canonical lookup: slug + locale column let row = db.prepare(sql).get(slug, locale) as @@ -114,7 +117,6 @@ export function registerLabNotesRoutes(app: any, db: Database.Database) { | (LabNoteRecord & { content_markdown?: string }) | undefined; - // if that still fails, try the literal legacy format too if (!row) { row = db.prepare(sql).get(`${baseSlug}:${effectiveLocale}`, effectiveLocale) as | (LabNoteRecord & { content_markdown?: string }) @@ -128,14 +130,22 @@ export function registerLabNotesRoutes(app: any, db: Database.Database) { .prepare("SELECT tag FROM lab_note_tags WHERE note_id = ?") .all(row.id) as TagResult[]; - const html = marked.parse(String(row.content_markdown ?? "")) as string; + const markdown = String(row.content_markdown ?? ""); + const expanded = expandMascotBlocks(markdown); + const html = marked.parse(expanded) as string; const noteForMapper = { ...(row as any), content_html: html, } as LabNoteRecord; - return res.json(mapToLabNoteView(noteForMapper, tagRows.map((t) => t.tag))); + const mapped = mapToLabNoteView(noteForMapper, tagRows.map((t) => t.tag)); + + return res.json({ + ...mapped, + // ✅ canonical truth for “correct” clients + content_markdown: markdown, + }); } catch (e: any) { console.error("GET /lab-notes/:slug failed:", e?.message); if (res.headersSent) return; @@ -143,7 +153,6 @@ export function registerLabNotesRoutes(app: any, db: Database.Database) { } }); - // --------------------------------------------------------------------------- // Public Upsert — DISABLED // This endpoint wrote v1 wide-row content_html directly, which desyncs the ledger. diff --git a/src/services/syncLabNotesFromFs.ts b/src/services/syncLabNotesFromFs.ts index 2dfb5ec..846285c 100644 --- a/src/services/syncLabNotesFromFs.ts +++ b/src/services/syncLabNotesFromFs.ts @@ -32,6 +32,23 @@ function sha256Hex(input: string): string { return crypto.createHash("sha256").update(input, "utf8").digest("hex"); } +/** + * Convert arbitrary JS values to types that better-sqlite3 can bind: + * numbers | strings | bigints | buffers | null + * - booleans become 0/1 + * - objects/arrays become JSON strings + */ +function bindable(v: any) { + if (v === undefined || v === null) return null; + if (typeof v === "boolean") return v ? 1 : 0; + if (typeof v === "number") return Number.isFinite(v) ? v : null; + if (typeof v === "bigint") return v; + if (typeof v === "string") return v; + if (Buffer.isBuffer(v)) return v; + if (v instanceof Date) return v.toISOString(); + return JSON.stringify(v); +} + function listMarkdownFilesRecursive(dir: string): string[] { if (!fs.existsSync(dir)) return []; const out: string[] = []; @@ -51,10 +68,22 @@ function nowIso(): string { return new Date().toISOString(); } -export function syncLabNotesFromFs(db: Database.Database): SyncCounts { - //TODO: TEMP - console.log("[SYNC] LABNOTES_DIR =", process.env.LABNOTES_DIR); +function safeString(v: any): string | null { + if (v === undefined || v === null) return null; + return String(v); +} +function jsonString(v: any, fallback: any): string { + // Always return a JSON string for json columns + const val = v === undefined ? fallback : v; + try { + return JSON.stringify(val); + } catch { + return JSON.stringify(fallback); + } +} + +export function syncLabNotesFromFs(db: Database.Database): SyncCounts { const rootDir = String(process.env.LABNOTES_DIR || "").trim(); if (!rootDir) throw new Error("LABNOTES_DIR is not set"); if (!fs.existsSync(rootDir)) throw new Error(`LABNOTES_DIR not found: ${rootDir}`); @@ -79,7 +108,7 @@ export function syncLabNotesFromFs(db: Database.Database): SyncCounts { }; // ----------------------------- - // SQL: identity + metadata upsert + // SQL: identity + metadata upsert (NAMED PARAMS) // - Does NOT clear published_at unless excluded.published_at is provided // - Does NOT touch status (human-controlled) // ----------------------------- @@ -93,19 +122,24 @@ export function syncLabNotesFromFs(db: Database.Database): SyncCounts { created_at, updated_at ) VALUES ( - COALESCE(?, lower(hex(randomblob(16)))), - COALESCE(NULLIF(?, ''), 'core'), - ?, LOWER(COALESCE(NULLIF(?, ''), 'en')), - COALESCE(NULLIF(?, ''), 'labnote'), - COALESCE(NULLIF(?, ''), ''), - ?, ?, ?, - ?, ?, ?, ?, - ?, - COALESCE(NULLIF(?, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')), + COALESCE(@id, lower(hex(randomblob(16)))), + COALESCE(NULLIF(@group_id, ''), 'core'), + @slug, + LOWER(COALESCE(NULLIF(@locale, ''), 'en')), + COALESCE(NULLIF(@type, ''), 'labnote'), + COALESCE(NULLIF(@title, ''), ''), + @category, + @excerpt, + @department_id, + @shadow_density, + @coherence_score, + @safer_landing, + @read_time_minutes, + @published_at, + COALESCE(NULLIF(@created_at, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')), strftime('%Y-%m-%dT%H:%M:%fZ','now') ) ON CONFLICT(slug, locale) DO UPDATE SET - -- metadata is safe to update type=excluded.type, title=excluded.title, category=excluded.category, @@ -140,6 +174,10 @@ export function syncLabNotesFromFs(db: Database.Database): SyncCounts { LIMIT 1 `); + // ----------------------------- + // SQL: revisions insert (NAMED PARAMS) + // We accept JSON fields as strings (already serialized) + // ----------------------------- const insertRevision = db.prepare(` INSERT INTO lab_note_revisions ( id, note_id, @@ -153,15 +191,15 @@ export function syncLabNotesFromFs(db: Database.Database): SyncCounts { created_at ) VALUES ( - ?, ?, - ?, ?, - ?, ?, ?, - ?, 'import', - ?, '1', - '[]', '[]', 1, - 'human_session', '[]', - NULL, - ? + @id, @note_id, + @revision_num, @supersedes_revision_id, + @frontmatter_json, @content_markdown, @content_hash, + @schema_version, @source, + @intent, @intent_version, + @scope_json, @side_effects_json, @reversible, + @auth_type, @scopes_json, + @reasoning_json, + @created_at ) `); @@ -186,17 +224,18 @@ export function syncLabNotesFromFs(db: Database.Database): SyncCounts { const slug = String(parsed.data.slug || slugFromFilename(filePath)).trim(); const title = String(parsed.data.title || slug).trim(); - - const excerpt = parsed.data.excerpt ? String(parsed.data.excerpt).trim() : null; - const category = parsed.data.category ? String(parsed.data.category) : null; - const departmentId = parsed.data.department_id ? String(parsed.data.department_id) : null; - const type = parsed.data.type ? String(parsed.data.type) : "labnote"; - const shadowDensity = parsed.data.shadow_density ?? null; - const coherenceScore = parsed.data.coherence_score ?? null; - const saferLanding = parsed.data.safer_landing ?? null; - const readTimeMinutes = parsed.data.read_time_minutes ?? null; + // Frontmatter fields that land in lab_notes + const shadowDensity = bindable(parsed.data.shadow_density); + const coherenceScore = bindable(parsed.data.coherence_score); + const saferLanding = bindable(parsed.data.safer_landing); + const readTimeMinutes = bindable(parsed.data.read_time_minutes); + + // These might be strings (preferred), but bindable makes them safe anyway + const excerpt = bindable(parsed.data.excerpt); + const category = bindable(parsed.data.category); + const departmentId = bindable(parsed.data.department_id); // ✅ Only set published_at if MD explicitly includes it const publishedAt = parsed.data.published_at ? String(parsed.data.published_at) : null; @@ -205,23 +244,23 @@ export function syncLabNotesFromFs(db: Database.Database): SyncCounts { const markdown = String(parsed.content || "").trim(); // 1) Ensure note registry row exists / metadata updated - upsertNote.run( - null, // id optional - parsed.data.group_id ? String(parsed.data.group_id) : "core", + upsertNote.run({ + id: null, + group_id: parsed.data.group_id ? String(parsed.data.group_id) : "core", slug, locale, type, title, category, excerpt, - departmentId, - shadowDensity, - coherenceScore, - saferLanding, - readTimeMinutes, - publishedAt, - nowIso() - ); + department_id: departmentId, + shadow_density: shadowDensity, + coherence_score: coherenceScore, + safer_landing: saferLanding, + read_time_minutes: readTimeMinutes, + published_at: publishedAt, + created_at: nowIso(), + }); counts.upserted += 1; const noteRow = selectNote.get(slug, locale) as @@ -242,7 +281,6 @@ export function syncLabNotesFromFs(db: Database.Database): SyncCounts { // 2) GUARD: Never create / advance to an empty-body revision if (!markdown) { counts.emptyBodySkipped += 1; - // No revision insert. No pointer changes. Metadata-only sync is allowed. return; } @@ -263,18 +301,53 @@ export function syncLabNotesFromFs(db: Database.Database): SyncCounts { const nextNum = latest ? Number(latest.revision_num) + 1 : 1; const supersedes = latest ? latest.id : null; - insertRevision.run( - newRevId, - noteRow.id, - nextNum, - supersedes, - JSON.stringify(parsed.data ?? {}), - markdown, - hash, - "v1", - `sync:md:${slug}:${locale}`, - nowIso() - ); + // We store the full frontmatter as JSON for traceability + const frontmatterJson = jsonString(parsed.data ?? {}, {}); + + // intent: allow string, otherwise store json-stringified + const intentVal = + typeof parsed.data?.intent === "string" + ? parsed.data.intent + : parsed.data?.intent != null + ? JSON.stringify(parsed.data.intent) + : `sync:md:${slug}:${locale}`; // ✅ default, never null + + // Use common-ish frontmatter keys if present, default to [] + const scopeJson = jsonString(parsed.data?.scope, []); + const sideEffectsJson = jsonString(parsed.data?.side_effects, []); + const scopesJson = jsonString(parsed.data?.scopes, []); + + // reversible: boolean-ish -> 0/1 + const reversible = bindable(parsed.data?.reversible) ?? 1; + + // reasoning: allow string, otherwise JSON + const reasoningJson = + parsed.data?.reasoning == null + ? null + : typeof parsed.data.reasoning === "string" + ? JSON.stringify({ text: parsed.data.reasoning }) + : JSON.stringify(parsed.data.reasoning); + + insertRevision.run({ + id: newRevId, + note_id: noteRow.id, + revision_num: nextNum, + supersedes_revision_id: supersedes, + frontmatter_json: frontmatterJson, + content_markdown: markdown, + content_hash: hash, + schema_version: "v1", + source: "import", + intent: intentVal, + intent_version: "1", + scope_json: scopeJson, + side_effects_json: sideEffectsJson, + reversible: reversible, + auth_type: "human_session", + scopes_json: scopesJson, + reasoning_json: reasoningJson, + created_at: nowIso(), + }); counts.revisionsInserted += 1; // 5) Advance pointers ONLY to a non-empty revision (this one is guaranteed non-empty) @@ -285,16 +358,20 @@ export function syncLabNotesFromFs(db: Database.Database): SyncCounts { } }; - // Walk locales - if (localeDirs.length) { - for (const loc of localeDirs) { - const files = listMarkdownFilesRecursive(path.join(rootDir, loc)); - for (const f of files) processFile(f, loc); + // Wrap in a transaction so you don't half-write if something explodes mid-sync + const syncTx = db.transaction(() => { + if (localeDirs.length) { + for (const loc of localeDirs) { + const files = listMarkdownFilesRecursive(path.join(rootDir, loc)); + for (const f of files) processFile(f, loc); + } + } else { + const files = listMarkdownFilesRecursive(rootDir); + for (const f of files) processFile(f, "en"); } - } else { - const files = listMarkdownFilesRecursive(rootDir); - for (const f of files) processFile(f, "en"); - } + }); + + syncTx(); return counts; }