merge: integrate database documents, preserve all proposals and keep CI main-only

This commit is contained in:
2026-10-30 16:40:53 +08:00
79 changed files with 556 additions and 5537 deletions
+2 -1
View File
@@ -11,7 +11,8 @@
"lint": "eslint . --max-warnings=0",
"typecheck": "NEXT_TELEMETRY_DISABLED=1 next typegen && tsc --noEmit",
"test:auth": "node scripts/test-auth.mjs",
"test:research": "node scripts/test-research.mjs"
"test:research": "node scripts/test-research.mjs",
"test:docs": "node scripts/test-docs.mjs"
},
"dependencies": {
"next": "16.4.3",
+29 -6
View File
@@ -18,6 +18,7 @@ import {
totp,
isLoginRedirect,
} from "../src/lib/auth";
import { readDocument, saveDocument } from "../src/lib/docs";
import { consumeCode, initializeStore } from "../src/lib/auth-store";
async function main() {
@@ -132,11 +133,6 @@ async function main() {
join(__dirname, ".next/BUILD_ID"),
"utf8",
).trim();
const routes = Object.keys(
JSON.parse(
readFileSync(join(__dirname, ".next/prerender-manifest.json"), "utf8"),
).routes,
) as string[];
for (const path of [
"/",
"/docs/00-endor-concepts-draft",
@@ -160,7 +156,7 @@ async function main() {
path,
);
}
for (const path of ["/api/private", "/api/unknown", "/api/search", "/api/research/ai-companies"])
for (const path of ["/api/private", "/api/unknown", "/api/search", "/api/documents/source?slug=endor/endor-purpose-draft", "/api/research/ai-companies"])
assert.equal((await request(path)).status, 401, path);
assert.equal(
(
@@ -209,6 +205,33 @@ async function main() {
assert.equal(destination.searchParams.get("from"), "legacy");
assert.ok(legacy.headers.get("cache-control")?.includes("no-store"));
}
const routes = ["/", ...searchIndex.map((doc: { slug: string }) => `/docs/${doc.slug}`)];
const raw = await request("/api/documents/source?slug=endor/endor-purpose-draft", { headers });
assert.equal(raw.status, 200);
assert.ok((await raw.text()).startsWith("# Endor"));
if (process.env.DOCUMENT_SMOKE_TEST) {
const sourceSlug = "ci-live-document";
const previous = readDocument(sourceSlug);
if (command === "exercise") {
const first = saveDocument({ slug: "endor/ci-live-document", sourceSlug,
title: "Runtime document", category: "Endor", collection: "Endor 文档", group: "CI",
description: "Isolated smoke test", draft: true, featured: false,
content: "# Runtime document\n\nInitial body" }, previous?.revision ?? 0);
assert.equal((await request(`/docs/${first.slug}`, { headers })).status, 200);
saveDocument({ ...first, slug: "endor/ci-live-document-renamed", content: "# Runtime document\n\nUpdated without rebuilding" }, first.revision);
}
const stored = readDocument(sourceSlug);
assert.ok(stored, "Smoke document must exist after creation or restart");
assert.ok(stored.content.includes("Updated without rebuilding"), "DB content survives restart");
const live = await request(`/docs/${stored.slug}`, { headers });
assert.equal(live.status, 200);
assert.ok((await live.text()).includes("Updated without rebuilding"));
const alias = await request("/docs/endor/ci-live-document?live=1", { headers });
assert.equal(alias.status, 308);
assert.ok(alias.headers.get("location")?.includes("ci-live-document-renamed?live=1"));
const updatedIndex = await (await request("/api/search", { headers })).json();
assert.ok(updatedIndex.some((doc: { searchText: string }) => doc.searchText.includes("Updated without rebuilding")));
}
let home = "";
let purpose = "";
for (const route of routes) {
+5
View File
@@ -7,3 +7,8 @@ await build({
format: "cjs",
outfile: ".next/standalone/apps/docs/auth-cli.cjs",
});
await build({
entryPoints: ["scripts/docs-cli.ts"], bundle: true, platform: "node", target: "node22",
format: "cjs", outfile: ".next/standalone/apps/docs/docs-cli.cjs",
});
-13
View File
@@ -23,19 +23,6 @@ for (const name of await files(path.join(root, "apps/docs/public"))) {
throw new Error(`Original raster exposed in public: ${path.relative(root, name)}. Move it to tools/figures/assets and run images.`);
}
}
for (const name of await files(path.join(root, "content/docs"))) {
if (!name.endsWith(".md")) continue;
const content = await readFile(name, "utf8");
const images = [
...content.matchAll(/!\[[^\]]*\]\(<?([^\s)>]+)/g),
...content.matchAll(/<img\b[^>]*\bsrc=["']([^"']+)/gi),
];
for (const [, source] of images) {
if (/\.(?:png|jpe?g|tiff?|bmp)(?:[?#]|$)/i.test(source)) {
throw new Error(`${path.relative(root, name)} embeds an original raster: ${source}. Use the compressed WebP.`);
}
}
}
await mkdir(output, { recursive: true });
for (const source of await files(sources)) {
+44
View File
@@ -0,0 +1,44 @@
import { readFileSync, writeFileSync } from "node:fs";
import { openSiteDatabase } from "../src/lib/site-database";
import { allDocuments, readDocument, saveDocument, importDocuments, documentHistory } from "../src/lib/docs";
process.umask(0o077);
const [command, argument] = process.argv.slice(2);
try {
switch (command) {
case "backup": {
if (!argument) throw Error("Provide a private backup filename");
const db = openSiteDatabase();
try { db.prepare("VACUUM INTO ?").run(argument); } finally { db.close(); }
console.log("Full site database backup created; existing files are never overwritten"); break;
}
case "list":
console.log(JSON.stringify(allDocuments().map(({ content: _, ...doc }) => { void _; return doc; }), null, 2)); break;
case "get": {
const doc = readDocument(argument ?? "");
if (!doc) throw Error("Document not found");
console.log(JSON.stringify(doc, null, 2)); break;
}
case "save": {
const { revision, ...input } = JSON.parse(readFileSync(0, "utf8"));
const doc = saveDocument(input, revision);
console.log(JSON.stringify({ slug: doc.slug, revision: doc.revision })); break;
}
case "history":
console.log(JSON.stringify(documentHistory(argument ?? ""), null, 2)); break;
case "import": {
const bundle = JSON.parse(readFileSync(0, "utf8"));
console.log(JSON.stringify({ imported: importDocuments(bundle, argument === "--if-empty") })); break;
}
case "export": {
if (!argument) throw Error("Provide a private export filename");
writeFileSync(argument, JSON.stringify({ format: "tabularium-documents-v1", documents: allDocuments() }),
{ flag: "wx", mode: 0o600 });
console.log("Document export created; existing files are never overwritten"); break;
}
default: throw Error("Usage: docs backup <private-file> | list | get <slug> | save < input.json | history <sourceSlug> | import [--if-empty] < export.json | export <private-file>");
}
} catch (error) {
console.error(error instanceof Error ? error.message : "Document operation failed");
process.exitCode = 1;
}
+14
View File
@@ -0,0 +1,14 @@
import { build } from "esbuild";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
const dir = await mkdtemp(join(tmpdir(), "tabularium-docs-tests-"));
try {
const file = join(dir, "docs.test.cjs");
await build({ entryPoints: ["tests/docs.test.ts"], bundle: true,
platform: "node", target: "node22", format: "cjs", outfile: file });
const result = spawnSync(process.execPath, ["--test", file], { stdio: "inherit" });
process.exitCode = result.status ?? 1;
} finally { await rm(dir, { recursive: true, force: true }); }
+135 -38
View File
@@ -1,50 +1,147 @@
import fs from "node:fs/promises";
import path from "node:path";
import catalog from "../../../../content/catalog.json";
import { randomUUID, createHash } from "node:crypto";
import { openSiteDatabase } from "./site-database";
const directory = path.resolve(process.cwd(), "../../content/docs");
const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
type Metadata = { slug?: string; category?: string; collection?: string; group?: string; description?: string; draft?: boolean; featured?: boolean };
export type DocLink = {
slug: string; sourceSlug: string; category: string; title: string; searchText?: string;
group: string; collection: string; description: string; draft: boolean; featured: boolean;
revision: number;
};
export type Doc = DocLink & { content: string };
export type DocInput = Omit<Doc, "revision" | "searchText">;
export type DocumentExport = { format: "tabularium-documents-v1"; documents: Doc[] };
export const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
function database() {
const db = openSiteDatabase();
db.exec(`
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, source_slug TEXT NOT NULL UNIQUE,
revision INTEGER NOT NULL, payload TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS document_aliases (
alias TEXT PRIMARY KEY, document_id TEXT NOT NULL REFERENCES documents(id)
);
CREATE TABLE IF NOT EXISTS document_revisions (
document_id TEXT NOT NULL REFERENCES documents(id), revision INTEGER NOT NULL,
payload TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(document_id, revision)
);
CREATE TABLE IF NOT EXISTS document_imports (
digest TEXT PRIMARY KEY, document_count INTEGER NOT NULL, created_at TEXT NOT NULL
);
`);
return db;
}
export function validateDocument(input: DocInput): void {
if (!input || typeof input !== "object") throw Error("Invalid document");
for (const key of ["slug", "sourceSlug"] as const)
if (typeof input[key] !== "string" || !validSlug.test(input[key])) throw Error(`Invalid ${key}`);
if (input.slug.split("/").some((part) => /^\d+-/.test(part))) throw Error("Use a semantic public slug");
for (const key of ["title", "category", "collection", "group"] as const)
if (typeof input[key] !== "string" || !input[key].trim()) throw Error(`Missing ${key}`);
if (typeof input.content !== "string" || typeof input.description !== "string" ||
typeof input.draft !== "boolean" || typeof input.featured !== "boolean") throw Error("Invalid document fields");
if (input.content.length > 2_000_000) throw Error("Document exceeds content limit");
const images = [
...input.content.matchAll(/!\[[^\]]*\]\(<?([^\s)>]+)/g),
...input.content.matchAll(/<img\b[^>]*\bsrc=["']([^"']+)/gi),
];
if (images.some(([, source]) => /\.(?:png|jpe?g|tiff?|bmp)(?:[?#]|$)/i.test(source)))
throw Error("Use compressed WebP images instead of original rasters");
}
function write(db: ReturnType<typeof database>, input: DocInput, expectedRevision: number): Doc {
validateDocument(input);
const existing = db.prepare("SELECT id, revision FROM documents WHERE source_slug=?")
.get(input.sourceSlug) as { id: string; revision: number } | undefined;
if ((existing?.revision ?? 0) !== expectedRevision) throw Error("Document revision conflict");
const id = existing?.id ?? randomUUID();
const doc: Doc = { slug: input.slug, sourceSlug: input.sourceSlug, category: input.category,
title: input.title, group: input.group, collection: input.collection, description: input.description,
draft: input.draft, featured: input.featured, content: input.content, revision: expectedRevision + 1 };
const payload = JSON.stringify(doc);
// Aliases remain reserved after renaming; collision rolls back the whole write.
db.prepare(`INSERT INTO documents VALUES (?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET
slug=excluded.slug, revision=excluded.revision, payload=excluded.payload`)
.run(id, doc.slug, doc.sourceSlug, doc.revision, payload);
for (const alias of new Set([doc.slug, doc.sourceSlug])) {
const prior = db.prepare("SELECT document_id FROM document_aliases WHERE alias=?")
.get(alias) as { document_id: string } | undefined;
if (prior && prior.document_id !== id) throw Error(`Document alias collision: ${alias}`);
db.prepare("INSERT OR IGNORE INTO document_aliases VALUES (?,?)").run(alias, id);
}
db.prepare("INSERT INTO document_revisions VALUES (?,?,?,?)")
.run(id, doc.revision, payload, new Date().toISOString());
return doc;
}
export function saveDocument(input: DocInput, expectedRevision: number): Doc {
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw Error("Provide expected revision");
const db = database();
try {
db.exec("BEGIN IMMEDIATE");
try { const doc = write(db, input, expectedRevision); db.exec("COMMIT"); return doc; }
catch (error) { db.exec("ROLLBACK"); throw error; }
} finally { db.close(); }
}
export function readDocument(slug: string): Doc | null {
if (!validSlug.test(slug)) return null;
const db = database();
try {
const row = db.prepare(`SELECT d.payload FROM documents d JOIN document_aliases a
ON a.document_id=d.id WHERE a.alias=?`).get(slug) as { payload: string } | undefined;
return row ? JSON.parse(row.payload) as Doc : null;
} finally { db.close(); }
}
export function allDocuments(): Doc[] {
const db = database();
try {
return (db.prepare("SELECT payload FROM documents ORDER BY source_slug").all() as { payload: string }[])
.map((row) => JSON.parse(row.payload) as Doc);
} finally { db.close(); }
}
export async function readDoc(slug: string): Promise<Doc> {
if (!validSlug.test(slug)) throw new Error("Invalid document slug");
const sourceSlug = Object.entries(catalog.documents).find(([, metadata]) => metadata.slug === slug)?.[0] ?? slug;
const content = await fs.readFile(path.join(directory, `${sourceSlug}.md`), "utf8");
const title = content.match(/^# (.+)$/m)?.[1]?.trim() ?? slug;
const collection = catalog.collections.find((c) => !c.path || sourceSlug.startsWith(`${c.path}/`))!;
const metadata: Metadata = (catalog.documents as Record<string, Metadata>)[sourceSlug] ?? {};
if (!metadata.slug || !metadata.category || !validSlug.test(metadata.slug) || metadata.slug.split("/").some((part) => /^\d+-/.test(part))) {
throw new Error(`Register a semantic slug and category in content/catalog.json: ${sourceSlug}`);
}
return { slug: metadata.slug, sourceSlug, category: metadata.category, title, content, searchText: content,
group: metadata.group ?? collection.title, collection: metadata.collection ?? collection.title,
description: metadata.description ?? collection.description,
draft: metadata.draft ?? false, featured: metadata.featured ?? false,
};
const doc = readDocument(slug);
if (!doc) throw Error("Document not found");
return doc;
}
export async function listDocs(): Promise<DocLink[]> {
async function walk(folder: string): Promise<string[]> {
const entries = await fs.readdir(path.join(directory, folder), { withFileTypes: true });
const files = await Promise.all(entries.map(async (entry) => {
const relative = folder ? `${folder}/${entry.name}` : entry.name;
if (entry.isDirectory()) return walk(relative);
if (entry.isFile() && relative.endsWith(".md") && validSlug.test(relative.slice(0, -3)))
return [relative.slice(0, -3)];
return [];
}));
return files.flat();
}
const docs = await Promise.all((await walk("")).sort().map(async (slug) => {
const { content: _, searchText: __, ...doc } = await readDoc(slug);
void _; void __;
return doc;
}));
if (new Set(docs.map((doc) => doc.slug)).size !== docs.length) throw new Error("Duplicate public document slug");
return docs;
return allDocuments().map(({ content: _, ...doc }) => { void _; return doc; });
}
// Explicit, atomic bootstrap only. Runtime reads never import repository data.
export function importDocuments(bundle: DocumentExport, onlyIfEmpty = false): number {
if (bundle?.format !== "tabularium-documents-v1" || !Array.isArray(bundle.documents) || !bundle.documents.length)
throw Error("Invalid or empty document export");
bundle.documents.forEach(validateDocument);
const digest = createHash("sha256").update(JSON.stringify(bundle)).digest("hex");
const db = database();
try {
db.exec("BEGIN IMMEDIATE");
try {
const imported = db.prepare("SELECT digest FROM document_imports WHERE digest=?").get(digest);
if (imported) { db.exec("COMMIT"); return 0; }
const count = (db.prepare("SELECT COUNT(*) AS n FROM documents").get() as { n: number }).n;
if (count) {
if (!onlyIfEmpty) throw Error("Bootstrap requires an empty document database");
db.exec("COMMIT"); return 0;
}
for (const doc of bundle.documents) write(db, doc, 0);
db.prepare("INSERT INTO document_imports VALUES (?,?,?)").run(digest, bundle.documents.length, new Date().toISOString());
db.exec("COMMIT"); return bundle.documents.length;
} catch (error) { db.exec("ROLLBACK"); throw error; }
} finally { db.close(); }
}
export function documentHistory(sourceSlug: string): Doc[] {
const db = database();
try {
return (db.prepare(`SELECT r.payload FROM document_revisions r JOIN documents d
ON r.document_id=d.id WHERE d.source_slug=? ORDER BY r.revision`).all(sourceSlug) as { payload: string }[])
.map((row) => JSON.parse(row.payload) as Doc);
} finally { db.close(); }
}
@@ -0,0 +1,22 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { readDocument } from "@/lib/docs";
// The proxy protects Markdown just like the rendered document and search index.
export default function handler(request: NextApiRequest, response: NextApiResponse) {
response.setHeader("Cache-Control", "private, no-store");
if (request.method !== "GET") {
response.setHeader("Allow", "GET");
return response.status(405).end();
}
const slug = request.query.slug;
if (typeof slug !== "string") return response.status(400).json({ error: "缺少文档地址" });
try {
const doc = readDocument(slug);
if (!doc) return response.status(404).end();
response.setHeader("Content-Type", "text/plain; charset=utf-8");
response.setHeader("X-Content-Type-Options", "nosniff");
return response.status(200).send(doc.content);
} catch {
return response.status(503).json({ error: "文档数据库暂时不可用" });
}
}
+7 -6
View File
@@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { listDocs, readDoc } from "@/lib/docs";
import { allDocuments } from "@/lib/docs";
// The proxy authenticates this endpoint, including index content.
export default async function handler(request: NextApiRequest, response: NextApiResponse) {
@@ -8,9 +8,10 @@ export default async function handler(request: NextApiRequest, response: NextApi
response.setHeader("Allow", "GET");
return response.status(405).end();
}
const docs = await listDocs();
const index = await Promise.all(docs.map(async (doc) => ({
...doc, searchText: (await readDoc(doc.slug)).content,
})));
response.status(200).json(index);
try {
const index = allDocuments().map(({ content, ...doc }) => ({ ...doc, searchText: content }));
return response.status(200).json(index);
} catch {
return response.status(503).json({ error: "文档数据库暂时不可用" });
}
}
+15 -15
View File
@@ -1,7 +1,6 @@
import type {
GetStaticPaths,
GetStaticProps,
InferGetStaticPropsType,
GetServerSideProps,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import dynamic from "next/dynamic";
@@ -15,7 +14,7 @@ import {
} from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { listDocs, readDoc, type Doc, type DocLink } from "@/lib/docs";
import { listDocs, readDocument, type Doc, type DocLink } from "@/lib/docs";
import { Shell, Navigation } from "@/components/Shell";
import { ArchitectureFigure } from "@/components/ArchitectureFigure";
import { FigureGallery } from "@/components/FigureGallery";
@@ -26,16 +25,17 @@ import { headingsFor } from "@/lib/navigation";
type Props = { doc: Doc; docs: DocLink[] };
const AICompanyResearch = dynamic(() => import("@/components/AICompanyResearch"));
export const getStaticPaths: GetStaticPaths = async () => ({
paths: (await listDocs()).map(({ slug }) => ({ params: { slug: slug.split("/") } })),
fallback: false,
});
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
export const getServerSideProps: GetServerSideProps<Props> = async ({ params, res, resolvedUrl }) => {
res.setHeader("Cache-Control", "private, no-store");
const slug = Array.isArray(params?.slug) ? params.slug.join("/") : params?.slug;
if (typeof slug !== "string") return { notFound: true };
const docs = await listDocs();
if (!docs.some((d) => d.slug === slug)) return { notFound: true };
return { props: { doc: await readDoc(slug), docs } };
const doc = readDocument(slug);
if (!doc) return { notFound: true };
if (slug !== doc.slug) {
const query = resolvedUrl.includes("?") ? resolvedUrl.slice(resolvedUrl.indexOf("?")) : "";
return { redirect: { destination: `/docs/${doc.slug}${query}`, permanent: true } };
}
return { props: { doc, docs: await listDocs() } };
};
function textOf(value: ReactNode): string {
return Children.toArray(value)
@@ -49,7 +49,7 @@ function textOf(value: ReactNode): string {
export default function DocPage({
doc,
docs,
}: InferGetStaticPropsType<typeof getStaticProps>) {
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
const headings = headingsFor(doc.content);
const [active, setActive] = useState("");
@@ -231,9 +231,9 @@ export default function DocPage({
<div className="article-end">
<a
href={`https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs/src/branch/main/content/docs/${doc.sourceSlug}.md`}
href={`/api/documents/source?slug=${encodeURIComponent(doc.slug)}`}
>
查看文档源码 ↗
查看 Markdown 原文 ↗
</a>
</div>
<nav className="pagination" aria-label="相邻文档">
+6 -5
View File
@@ -1,4 +1,4 @@
import type { GetStaticProps, InferGetStaticPropsType } from "next";
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";
import Head from "next/head";
import Link from "next/link";
import { useState } from "react";
@@ -7,11 +7,12 @@ import { Shell, Navigation } from "@/components/Shell";
import { Icon } from "@/components/Icon";
import { collectionEntries, collectionIcon, collectionLabel, collectionTone } from "@/lib/collections";
export const getStaticProps: GetStaticProps<{ docs: DocLink[] }> = async () => ({
props: { docs: await listDocs() },
});
export const getServerSideProps: GetServerSideProps<{ docs: DocLink[] }> = async ({ res }) => {
res.setHeader("Cache-Control", "private, no-store");
return { props: { docs: await listDocs() } };
};
const PAGE_SIZE = 8;
export default function Home({ docs }: InferGetStaticPropsType<typeof getStaticProps>) {
export default function Home({ docs }: InferGetServerSidePropsType<typeof getServerSideProps>) {
const [collection, setCollection] = useState("");
const [query, setQuery] = useState("");
const [page, setPage] = useState(0);
-13
View File
@@ -1,11 +1,5 @@
import { NextResponse, type NextRequest } from "next/server";
import { authConfig, SESSION_COOKIE, validSession } from "./lib/auth";
import catalog from "../../../content/catalog.json";
const legacyRoutes = new Map(Object.entries(catalog.documents).map(([source, metadata]) => [
`/docs/${source}`, `/docs/${metadata.slug}`,
]));
export function proxy(request: NextRequest) {
const path = request.nextUrl.pathname;
const headers = {
@@ -54,13 +48,6 @@ export function proxy(request: NextRequest) {
);
}
}
// Keep legacy URLs behind authentication and out of static generation.
const destination = legacyRoutes.get(path);
if (destination && ["GET", "HEAD"].includes(request.method)) {
const target = request.nextUrl.clone();
target.pathname = destination;
return NextResponse.redirect(target, { status: 308, headers });
}
return NextResponse.next({ headers });
}
if (path.startsWith("/api/") || !["GET", "HEAD"].includes(request.method)) {
+99
View File
@@ -0,0 +1,99 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { allDocuments, documentHistory, importDocuments, readDocument, saveDocument,
type DocInput, type DocumentExport } from "../src/lib/docs";
const input: DocInput = { slug: "endor/example", sourceSlug: "00-example", title: "Example",
category: "Endor", collection: "Endor 文档", group: "讨论", description: "Example description",
draft: true, featured: false, content: "# Example\n\nOriginal body" };
function isolated(run: (directory: string) => void) {
const directory = mkdtempSync(join(tmpdir(), "tabularium-docs-"));
const previous = process.env.AUTH_CONFIG_FILE;
process.env.AUTH_CONFIG_FILE = join(directory, "config.json");
try { run(directory); } finally {
if (previous === undefined) delete process.env.AUTH_CONFIG_FILE;
else process.env.AUTH_CONFIG_FILE = previous;
rmSync(directory, { recursive: true, force: true });
}
}
function bundle(documents: DocInput[]): DocumentExport {
return { format: "tabularium-documents-v1", documents: documents.map((doc) => ({ ...doc, revision: 1 })) };
}
test("writes and metadata persist across independent connections; no repository seed", () => isolated(() => {
assert.deepEqual(allDocuments(), []);
const saved = saveDocument(input, 0);
assert.deepEqual(readDocument(input.slug), saved);
assert.deepEqual(readDocument(input.sourceSlug), saved);
const changed = saveDocument({ ...input, content: "# Revised", collection: "新合集", group: "新目录" }, 1);
assert.equal(changed.revision, 2);
assert.equal(allDocuments()[0].collection, "新合集");
assert.deepEqual(documentHistory(input.sourceSlug).map((doc) => doc.content), [input.content, "# Revised"]);
}));
test("optimistic concurrency prevents lost updates and requires explicit revision", () => isolated(() => {
saveDocument(input, 0);
assert.throws(() => saveDocument({ ...input, content: "Lost" }, 0), /conflict/);
assert.throws(() => saveDocument(input, NaN), /expected revision/);
assert.equal(readDocument(input.slug)?.content, input.content);
assert.equal(documentHistory(input.sourceSlug).length, 1);
}));
test("renames preserve aliases and alias collisions roll back document and revision", () => isolated(() => {
saveDocument(input, 0);
saveDocument({ ...input, slug: "endor/renamed" }, 1);
assert.equal(readDocument(input.slug)?.slug, "endor/renamed");
assert.throws(() => saveDocument({ ...input, sourceSlug: "another", slug: input.slug }, 0), /collision/);
assert.equal(allDocuments().length, 1);
assert.equal(readDocument("another"), null);
assert.equal(documentHistory(input.sourceSlug).length, 2);
}));
test("bootstrap is atomic and repeat imports never replace DB edits", () => isolated(() => {
const snapshot = bundle([input]);
assert.equal(importDocuments(snapshot), 1);
saveDocument({ ...input, content: "Written directly to DB" }, 1);
assert.equal(importDocuments(snapshot), 0);
assert.equal(importDocuments(bundle([{ ...input, content: "Stale release" }]), true), 0);
assert.throws(() => importDocuments(bundle([{ ...input, content: "Other" }])), /empty/);
assert.equal(readDocument(input.slug)?.content, "Written directly to DB");
}));
test("colliding bootstrap leaves no partial rows and can be retried", () => isolated(() => {
assert.throws(() => importDocuments(bundle([input, { ...input, sourceSlug: "other" }])));
assert.deepEqual(allDocuments(), []);
assert.equal(importDocuments(bundle([input])), 1);
}));
test("migration preserves unrelated authentication and research tables", () => isolated((directory) => {
const db = new DatabaseSync(join(directory, "login-state.sqlite"));
db.exec("CREATE TABLE login_state(value TEXT); INSERT INTO login_state VALUES ('preserved'); CREATE TABLE research_datasets(value TEXT); INSERT INTO research_datasets VALUES ('research');");
db.close();
importDocuments(bundle([input]));
const reopened = new DatabaseSync(join(directory, "login-state.sqlite"));
assert.equal(reopened.prepare("SELECT value FROM login_state").get()?.value, "preserved");
assert.equal(reopened.prepare("SELECT value FROM research_datasets").get()?.value, "research");
assert.deepEqual(reopened.prepare("PRAGMA foreign_key_check").all(), []);
reopened.close();
}));
test("invalid content cannot enter the database", () => isolated(() => {
for (const invalid of [ { ...input, slug: "../private" }, { ...input, slug: "endor/01-example" },
{ ...input, content: "![original](/figures/raw.png)" }, { ...input, category: "" } ])
assert.throws(() => saveDocument(invalid, 0));
assert.deepEqual(allDocuments(), []);
assert.equal(readDocument("../private"), null);
}));
test("missing database configuration fails closed", () => {
const previous = process.env.AUTH_CONFIG_FILE;
delete process.env.AUTH_CONFIG_FILE;
try { assert.throws(() => allDocuments(), /not configured/); }
finally { if (previous !== undefined) process.env.AUTH_CONFIG_FILE = previous; }
});