Add chapter editing API with internal summaries and revision control
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { randomUUID, createHash } from "node:crypto";
|
||||
import { openSiteDatabase } from "./site-database";
|
||||
import { DocumentValidationError, DocumentConflictError } from "./document-errors";
|
||||
import { sectionSchema, loadSections, reconcileSections, persistSections, splitSections, outline,
|
||||
applySectionOperations, parseSectionEdit, type DocumentSection } from "./document-sections";
|
||||
export { DocumentValidationError, DocumentConflictError } from "./document-errors";
|
||||
|
||||
export type DocLink = {
|
||||
slug: string; sourceSlug: string; category: string; title: string; searchText?: string;
|
||||
@@ -9,8 +13,6 @@ export type DocLink = {
|
||||
export type Doc = DocLink & { content: string };
|
||||
export type DocInput = Omit<Doc, "revision" | "searchText">;
|
||||
export type DocumentExport = { format: "tabularium-documents-v1"; documents: Doc[] };
|
||||
export class DocumentValidationError extends Error {}
|
||||
export class DocumentConflictError extends Error {}
|
||||
export type DocumentReceipt = Pick<Doc, "slug" | "sourceSlug" | "revision">;
|
||||
export const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
|
||||
|
||||
@@ -36,6 +38,7 @@ function database() {
|
||||
digest TEXT PRIMARY KEY, document_count INTEGER NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
sectionSchema(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
@@ -57,12 +60,13 @@ export function validateDocument(input: DocInput): void {
|
||||
throw new DocumentValidationError("Use compressed WebP images instead of original rasters");
|
||||
}
|
||||
|
||||
function write(db: ReturnType<typeof database>, input: DocInput, expectedRevision: number): Doc {
|
||||
function write(db: ReturnType<typeof database>, input: DocInput, expectedRevision: number, editedSections?: DocumentSection[]): Doc {
|
||||
validateDocument(input);
|
||||
const existing = db.prepare("SELECT id, revision FROM documents WHERE source_slug=?")
|
||||
.get(input.sourceSlug) as { id: string; revision: number } | undefined;
|
||||
const existing = db.prepare("SELECT id, revision, payload FROM documents WHERE source_slug=?")
|
||||
.get(input.sourceSlug) as { id: string; revision: number; payload: string } | undefined;
|
||||
if ((existing?.revision ?? 0) !== expectedRevision) throw new DocumentConflictError("Document revision conflict");
|
||||
const id = existing?.id ?? randomUUID();
|
||||
const previous = existing ? loadSections(db, id, (JSON.parse(existing.payload) as Doc).content) : [];
|
||||
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 };
|
||||
@@ -80,9 +84,75 @@ function write(db: ReturnType<typeof database>, input: DocInput, expectedRevisio
|
||||
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());
|
||||
persistSections(db, id, doc.revision, editedSections ?? reconcileSections(doc.content, previous));
|
||||
return doc;
|
||||
}
|
||||
|
||||
// Internal summaries never enter Doc, catalog, search or browser page props.
|
||||
export function readDocumentSections(slug: string, revision?: number) {
|
||||
if (!validSlug.test(slug)) throw new DocumentValidationError("Invalid document slug");
|
||||
if (revision !== undefined && (!Number.isSafeInteger(revision) || revision < 1)) throw new DocumentValidationError("Invalid revision");
|
||||
const db = database();
|
||||
try {
|
||||
db.exec("BEGIN");
|
||||
const row = db.prepare(`SELECT d.id,d.payload FROM documents d JOIN document_aliases a
|
||||
ON a.document_id=d.id WHERE a.alias=?`).get(slug) as { id: string; payload: string } | undefined;
|
||||
if (!row) { db.exec("COMMIT"); return null; }
|
||||
let doc = JSON.parse(row.payload) as Doc;
|
||||
let sections: DocumentSection[];
|
||||
if (revision === undefined || revision === doc.revision) sections = loadSections(db, row.id, doc.content);
|
||||
else {
|
||||
const prior = db.prepare("SELECT payload FROM document_revisions WHERE document_id=? AND revision=?")
|
||||
.get(row.id, revision) as { payload: string } | undefined;
|
||||
if (!prior) { db.exec("COMMIT"); return null; }
|
||||
doc = JSON.parse(prior.payload) as Doc;
|
||||
const snapshot = db.prepare("SELECT payload FROM document_section_revisions WHERE document_id=? AND revision=?")
|
||||
.get(row.id, revision) as { payload: string } | undefined;
|
||||
sections = snapshot ? JSON.parse(snapshot.payload) as DocumentSection[] : splitSections(doc.content);
|
||||
}
|
||||
db.exec("COMMIT");
|
||||
return { slug: doc.slug, sourceSlug: doc.sourceSlug, title: doc.title, revision: doc.revision, sections };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
export function editDocumentSections(slug: string, value: unknown, request?: { namespace: string; id: string }) {
|
||||
if (!validSlug.test(slug)) throw new DocumentValidationError("Invalid document slug");
|
||||
const edit = parseSectionEdit(value);
|
||||
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({ slug, edit })).digest("hex");
|
||||
const namespace = request ? request.namespace + ":sections" : undefined;
|
||||
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(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 { ...JSON.parse(prior.response) as { slug: string; sourceSlug: string; revision: number; sections: ReturnType<typeof outline> }, replayed: true };
|
||||
}
|
||||
}
|
||||
const row = db.prepare(`SELECT d.id,d.payload FROM documents d JOIN document_aliases a
|
||||
ON a.document_id=d.id WHERE a.alias=?`).get(slug) as { id: string; payload: string } | undefined;
|
||||
if (!row) { db.exec("COMMIT"); return null; }
|
||||
const doc = JSON.parse(row.payload) as Doc;
|
||||
if (doc.revision !== edit.revision) throw new DocumentConflictError("Document revision conflict");
|
||||
const previous = loadSections(db, row.id, doc.content);
|
||||
const sections = applySectionOperations(previous, edit.operations);
|
||||
// Preserve the initial outline when upgrading an existing document for the first time.
|
||||
db.prepare("INSERT OR IGNORE INTO document_section_revisions VALUES (?,?,?)").run(row.id, doc.revision, JSON.stringify(previous));
|
||||
const saved = write(db, { ...doc, content: sections.map((s) => s.content).join("") }, doc.revision, sections);
|
||||
const receipt = { slug: saved.slug, sourceSlug: saved.sourceSlug, revision: saved.revision, sections: outline(sections) };
|
||||
if (request) db.prepare("INSERT INTO document_write_requests VALUES (?,?,?,?)")
|
||||
.run(namespace!, request.id, digest, JSON.stringify(receipt));
|
||||
db.exec("COMMIT");
|
||||
return { ...receipt, replayed: false };
|
||||
} catch (error) { db.exec("ROLLBACK"); throw error; }
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
export function saveDocument(input: DocInput, expectedRevision: number): Doc {
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw new DocumentValidationError("Provide expected revision");
|
||||
const db = database();
|
||||
|
||||
Reference in New Issue
Block a user