feat: complete search REST route, restore disk sync, and extraFields support across API/MCP
This commit is contained in:
@@ -3,6 +3,8 @@ import { documents, documentRevisions, documentChunks } from "@mcpedia/db/schema
|
||||
import { reindexChunks } from "./index.service";
|
||||
import { eq, desc, and, sql } from "drizzle-orm";
|
||||
import { toMeta } from "./row-map";
|
||||
import { CONTENT_ROOT } from "@mcpedia/config";
|
||||
import { join } from "node:path";
|
||||
import type { DocumentMeta } from "@mcpedia/types";
|
||||
|
||||
export interface RevisionSummary {
|
||||
@@ -77,7 +79,8 @@ export async function getRevision(
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a revision: write its body+metadata back into the live `documents` row.
|
||||
* Restore a revision: write its body+metadata back into the live `documents` row
|
||||
* and update the on-disk markdown file.
|
||||
*
|
||||
* @param id revision UUID
|
||||
* @param opts optional seam for testing — override the chunk-rebuild step so
|
||||
@@ -110,6 +113,11 @@ export async function restoreRevision(
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
const [docRow] = await db
|
||||
.select({ path: documents.path })
|
||||
.from(documents)
|
||||
.where(eq(documents.id, rev.documentId));
|
||||
|
||||
await db
|
||||
.update(documents)
|
||||
.set({
|
||||
@@ -124,6 +132,34 @@ export async function restoreRevision(
|
||||
})
|
||||
.where(eq(documents.id, rev.documentId));
|
||||
|
||||
// Sync back to disk (source of truth for file-based reads)
|
||||
if (docRow?.path) {
|
||||
const absPath = join(CONTENT_ROOT, docRow.path);
|
||||
try {
|
||||
const { stringifyFile } = await import("@mcpedia/parser");
|
||||
stringifyFile(
|
||||
absPath,
|
||||
docRow.path,
|
||||
{
|
||||
id: rev.slug,
|
||||
slug: rev.slug,
|
||||
title: rev.title,
|
||||
type: (m.type as any) ?? "documentation",
|
||||
section: (m.section as any) ?? "docs",
|
||||
status: (m.status as any) ?? "published",
|
||||
author: m.author ?? "",
|
||||
tags: m.tags ?? [],
|
||||
path: docRow.path,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
rev.body,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`restoreRevision: failed to write file to disk for ${rev.slug}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild semantic chunks + embeddings from the restored body so semantic
|
||||
// and hybrid search stay consistent (otherwise document_chunks would hold
|
||||
// the NEW body's chunks while documents.body holds the OLD/restore body).
|
||||
|
||||
@@ -60,11 +60,10 @@ export function parseFile(absPath: string, relPath: string): ParsedFile {
|
||||
|
||||
// Extract any non-standard frontmatter keys as dynamic extra fields.
|
||||
// These are stored in DB as JSONB + rendered as dynamic badges in the UI.
|
||||
const extraFields: Record<string, string> = {};
|
||||
const extraFields: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (!STANDARD_FRONTMATTER_KEYS.has(k) && v !== undefined && v !== null) {
|
||||
// Serialize non-string values (numbers, booleans) to string
|
||||
extraFields[k] = typeof v === "string" ? v : JSON.stringify(v);
|
||||
extraFields[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,3 +79,56 @@ test("parseFile: body excludes frontmatter delimiter", () => {
|
||||
expect(body).not.toContain("---");
|
||||
expect(body).toContain("# Real body");
|
||||
});
|
||||
|
||||
test("parseFile: extracts dynamic extra fields", () => {
|
||||
const { meta } = writeDoc(
|
||||
"writeups/ctf/chal.md",
|
||||
[
|
||||
"---",
|
||||
'title: CTF Challenge',
|
||||
'event: DEF CON 2024',
|
||||
'points: 100',
|
||||
'solved: true',
|
||||
"---",
|
||||
"# Solved",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(meta.extraFields?.event).toBe("DEF CON 2024");
|
||||
expect(meta.extraFields?.points).toBe(100);
|
||||
expect(meta.extraFields?.solved).toBe(true);
|
||||
});
|
||||
|
||||
test("stringifyFile: writes file that round-trips via parseFile", async () => {
|
||||
const { stringifyFile } = await import("../src/index");
|
||||
const p = join(tmp, "docs/roundtrip.md");
|
||||
const meta = {
|
||||
id: "docs/roundtrip",
|
||||
slug: "docs/roundtrip",
|
||||
title: "Roundtrip Test",
|
||||
type: "documentation" as const,
|
||||
section: "docs" as const,
|
||||
status: "published" as const,
|
||||
author: "tester",
|
||||
tags: ["a", "b"],
|
||||
path: "docs/roundtrip.md",
|
||||
createdAt: "2026-08-21T00:00:00.000Z",
|
||||
updatedAt: "2026-08-21T00:00:00.000Z",
|
||||
extraFields: {
|
||||
event: "DEF CON",
|
||||
difficulty: "medium",
|
||||
points: 500,
|
||||
},
|
||||
};
|
||||
const body = "# Content\n\nParagraph content.";
|
||||
stringifyFile(p, "docs/roundtrip.md", meta, body);
|
||||
|
||||
const parsed = parseFile(p, "docs/roundtrip.md");
|
||||
expect(parsed.meta.title).toBe("Roundtrip Test");
|
||||
expect(parsed.meta.author).toBe("tester");
|
||||
expect(parsed.meta.tags).toEqual(["a", "b"]);
|
||||
expect(parsed.meta.extraFields?.event).toBe("DEF CON");
|
||||
expect(parsed.meta.extraFields?.difficulty).toBe("medium");
|
||||
expect(parsed.meta.extraFields?.points).toBe(500);
|
||||
expect(parsed.body.trim()).toBe(body);
|
||||
});
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ const VALID_TYPES: DocType[] = ["documentation", "writeup", "research", "note"];
|
||||
|
||||
/** Map a Drizzle row (text columns, Date timestamps) into the strict types. */
|
||||
function toMeta(row: DocumentRow): DocumentMeta {
|
||||
const extra = row.extraFields as Record<string, unknown> | null;
|
||||
const extra = (row.extraFields ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
@@ -47,8 +47,9 @@ function toMeta(row: DocumentRow): DocumentMeta {
|
||||
path: row.path,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
extraFields: extra,
|
||||
// Spread dynamic extra fields (CTF: event, challenge, category, difficulty, points, etc.)
|
||||
...(extra ?? {}),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user