feat: add scoped document API and resumable batch uploader
This commit is contained in:
+105
-15
@@ -9,6 +9,9 @@ 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]+)*)*$/;
|
||||
|
||||
function database() {
|
||||
@@ -25,6 +28,10 @@ function database() {
|
||||
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
|
||||
);
|
||||
@@ -33,50 +40,51 @@ function database() {
|
||||
}
|
||||
|
||||
export function validateDocument(input: DocInput): void {
|
||||
if (!input || typeof input !== "object") throw Error("Invalid document");
|
||||
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 Error(`Invalid ${key}`);
|
||||
if (input.slug.split("/").some((part) => /^\d+-/.test(part))) throw Error("Use a semantic public slug");
|
||||
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 Error(`Missing ${key}`);
|
||||
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 Error("Invalid document fields");
|
||||
if (input.content.length > 2_000_000) throw Error("Document exceeds content limit");
|
||||
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(/!\[[^\]]*\]\(<?([^\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");
|
||||
throw new DocumentValidationError("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");
|
||||
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);
|
||||
// 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);
|
||||
// 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 Error(`Document alias collision: ${alias}`);
|
||||
db.prepare("INSERT OR IGNORE INTO document_aliases VALUES (?,?)").run(alias, id);
|
||||
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 Error("Provide expected revision");
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw new DocumentValidationError("Provide expected revision");
|
||||
const db = database();
|
||||
try {
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
@@ -145,3 +153,85 @@ export function documentHistory(sourceSlug: string): Doc[] {
|
||||
.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<string>();
|
||||
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(); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user