import { randomUUID, createHash } from "node:crypto"; import { openSiteDatabase } from "./site-database"; 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; export type DocumentExport = { format: "tabularium-documents-v1"; documents: Doc[] }; export class DocumentValidationError extends Error {} export class DocumentConflictError extends Error {} export type DocumentReceipt = Pick; 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_write_requests ( namespace TEXT NOT NULL, request_id TEXT NOT NULL, digest TEXT NOT NULL, response TEXT NOT NULL, PRIMARY KEY(namespace, request_id) ); 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 new DocumentValidationError("Invalid document"); for (const key of ["slug", "sourceSlug"] as const) if (typeof input[key] !== "string" || !validSlug.test(input[key])) throw new DocumentValidationError(`Invalid ${key}`); if (input.slug.split("/").some((part) => /^\d+-/.test(part))) throw new DocumentValidationError("Use a semantic public slug"); for (const key of ["title", "category", "collection", "group"] as const) if (typeof input[key] !== "string" || !input[key].trim()) throw new DocumentValidationError(`Missing ${key}`); if (typeof input.content !== "string" || typeof input.description !== "string" || typeof input.draft !== "boolean" || typeof input.featured !== "boolean") throw new DocumentValidationError("Invalid document fields"); if (input.content.length > 2_000_000) throw new DocumentValidationError("Document exceeds content limit"); const images = [ ...input.content.matchAll(/!\[[^\]]*\]\(]+)/g), ...input.content.matchAll(/]*\bsrc=["']([^"']+)/gi), ]; if (images.some(([, source]) => /\.(?:png|jpe?g|tiff?|bmp)(?:[?#]|$)/i.test(source))) throw new DocumentValidationError("Use compressed WebP images instead of original rasters"); } function write(db: ReturnType, 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 new DocumentConflictError("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); // Reserve both canonical and historical names before updating any rows. 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 new DocumentConflictError(`Document alias collision: ${alias}`); } 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])) 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 new DocumentValidationError("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 { const doc = readDocument(slug); if (!doc) throw Error("Document not found"); return doc; } export async function listDocs(): Promise { 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(); } } // API batches commit entirely or not at all; receipts make lost-response retries safe. export function saveDocumentBatch(documents: Doc[], request?: { namespace: string; id: string }) { if (!Array.isArray(documents) || !documents.length || documents.length > 100) throw new DocumentValidationError("Provide 1 to 100 documents"); const seen = new Set(); for (const doc of documents) { validateDocument(doc); if (!Number.isSafeInteger(doc.revision) || doc.revision < 0) throw new DocumentValidationError("Provide expected revision"); if (seen.has(doc.sourceSlug)) throw new DocumentValidationError("Duplicate sourceSlug in batch"); seen.add(doc.sourceSlug); } if (request && (!request.namespace || !/^[A-Za-z0-9._:-]{1,128}$/.test(request.id))) throw new DocumentValidationError("Invalid Idempotency-Key"); const digest = createHash("sha256").update(JSON.stringify(documents)).digest("hex"); const db = database(); try { db.exec("BEGIN IMMEDIATE"); try { if (request) { const prior = db.prepare("SELECT digest,response FROM document_write_requests WHERE namespace=? AND request_id=?") .get(request.namespace, request.id) as { digest: string; response: string } | undefined; if (prior) { if (prior.digest !== digest) throw new DocumentConflictError("Idempotency-Key was used with different content"); db.exec("COMMIT"); return { documents: JSON.parse(prior.response) as DocumentReceipt[], replayed: true }; } } const receipts = documents.map((input) => { const { slug, sourceSlug, revision } = write(db, input, input.revision); return { slug, sourceSlug, revision }; }); if (request) db.prepare("INSERT INTO document_write_requests VALUES (?,?,?,?)") .run(request.namespace, request.id, digest, JSON.stringify(receipts)); db.exec("COMMIT"); return { documents: receipts, replayed: false }; } catch (error) { db.exec("ROLLBACK"); throw error; } } finally { db.close(); } } export function documentPage(limit = 50, offset = 0) { if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0) throw new DocumentValidationError("Invalid pagination"); const db = database(); try { db.exec("BEGIN"); const total = (db.prepare("SELECT COUNT(*) AS n FROM documents").get() as { n: number }).n; const documents = (db.prepare("SELECT json_remove(payload,'$.content') AS payload FROM documents ORDER BY source_slug LIMIT ? OFFSET ?") .all(limit, offset) as { payload: string }[]).map((row) => JSON.parse(row.payload) as DocLink); db.exec("COMMIT"); return { documents, total, limit, offset, nextOffset: offset + documents.length < total ? offset + documents.length : null }; } finally { db.close(); } } export function documentHistoryPage(sourceSlug: string, limit = 50, offset = 0) { if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0) throw new DocumentValidationError("Invalid pagination"); const db = database(); try { db.exec("BEGIN"); const total = (db.prepare(`SELECT COUNT(*) AS n FROM document_revisions r JOIN documents d ON d.id=r.document_id WHERE d.source_slug=?`).get(sourceSlug) as { n: number }).n; const revisions = (db.prepare(`SELECT json_remove(r.payload,'$.content') AS payload, r.created_at AS createdAt FROM document_revisions r JOIN documents d ON d.id=r.document_id WHERE d.source_slug=? ORDER BY r.revision DESC LIMIT ? OFFSET ?`) .all(sourceSlug, limit, offset) as { payload: string; createdAt: string }[]) .map((row) => ({ ...JSON.parse(row.payload) as DocLink, createdAt: row.createdAt })); db.exec("COMMIT"); return { revisions, total, limit, offset, nextOffset: offset + revisions.length < total ? offset + revisions.length : null }; } finally { db.close(); } } export function documentRevision(sourceSlug: string, revision: number): Doc | null { if (!Number.isSafeInteger(revision) || revision < 1) throw new DocumentValidationError("Invalid revision"); const db = database(); try { const row = db.prepare(`SELECT r.payload FROM document_revisions r JOIN documents d ON d.id=r.document_id WHERE d.source_slug=? AND r.revision=?`).get(sourceSlug, revision) as { payload: string } | undefined; return row ? JSON.parse(row.payload) as Doc : null; } finally { db.close(); } }