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;
}