Menu
popagent
publicLatest change 87f51a535b92b11ccaf217f96b05a14c907d8eac - Store project documentation in PostgreSQL by Ólafur Búi Ólafsson
import type { DocumentationFolder, DocumentationPage, DocumentationPageSummary } from "./api-types";
import { storage } from "./storage";
import { retryableInit } from "./retryable-init";
export const MAX_DOCUMENTATION_CHARACTERS = 1_000_000;
export class DocumentationConflictError extends Error {
constructor(readonly current: DocumentationPage) {
super("Documentation revision conflict");
}
}
function documentationPath(input: string, folder = false): string {
const path = input.trim().replaceAll("\\", "/").replace(/^\/+|\/+$/g, "");
const parts = path.split("/");
if (!path || parts.some((part) => !part || part === "." || part === ".." || part.startsWith("."))) {
throw new RangeError(`Invalid documentation ${folder ? "folder" : "path"}`);
}
if (!folder && !path.endsWith(".md")) throw new RangeError("Invalid documentation path");
return path;
}
function digest(content: string): string {
return new Bun.CryptoHasher("sha256").update(content).digest("hex");
}
function pageTitle(path: string, content: string): string {
const heading = content.match(/^#\s+(.+)$/m)?.[1]?.trim();
if (heading) return heading.replace(/\s+#+$/, "");
return path.split("/").at(-1)!.replace(/\.md$/i, "").replaceAll(/[-_]+/g, " ");
}
export class DocumentationFiles {
private readonly initializeOnce = retryableInit(async () => {
await storage.init();
await storage.db.none(await Bun.file(new URL("./documentation.sql", import.meta.url)).text());
});
private init(): Promise<void> {
return this.initializeOnce();
}
async list(workspaceId: string): Promise<{ folders: DocumentationFolder[]; pages: DocumentationPageSummary[] }> {
await this.init();
const [folders, pages] = await Promise.all([
storage.db.any<DocumentationFolder>(`
SELECT path, name, updated_at::text AS "updatedAt"
FROM popagent_document_folders WHERE workspace_id = $1 ORDER BY path
`, [workspaceId]),
storage.db.any<DocumentationPageSummary>(`
SELECT path, title, revision, length(content)::int AS size, updated_at::text AS "updatedAt"
FROM popagent_document_pages WHERE workspace_id = $1 ORDER BY path
`, [workspaceId]),
]);
return { folders, pages };
}
async read(workspaceId: string, inputPath: string): Promise<DocumentationPage> {
await this.init();
const path = documentationPath(inputPath);
const page = await storage.db.oneOrNone<DocumentationPage>(`
SELECT path, title, revision, length(content)::int AS size,
updated_at::text AS "updatedAt", content
FROM popagent_document_pages WHERE workspace_id = $1 AND path = $2
`, [workspaceId, path]);
if (!page) throw new Error("Documentation page not found");
return page;
}
async createFolder(workspaceId: string, inputPath: string): Promise<DocumentationFolder> {
await this.init();
const path = documentationPath(inputPath, true);
const segments = path.split("/");
let current = "";
let folder!: DocumentationFolder;
for (const name of segments) {
current = current ? `${current}/${name}` : name;
folder = await storage.db.one<DocumentationFolder>(`
INSERT INTO popagent_document_folders (workspace_id, path, name)
VALUES ($1, $2, $3)
ON CONFLICT (workspace_id, path) DO UPDATE SET name = EXCLUDED.name
RETURNING path, name, updated_at::text AS "updatedAt"
`, [workspaceId, current, name]);
}
return folder;
}
async save(workspaceId: string, input: { path: string; content: string; revision?: string }): Promise<DocumentationPage> {
const path = documentationPath(input.path);
if (input.content.length > MAX_DOCUMENTATION_CHARACTERS) throw new RangeError("Documentation page is too large");
await this.init();
const current = await this.read(workspaceId, path).catch((error) => {
if (error instanceof Error && error.message === "Documentation page not found") return undefined;
throw error;
});
if (current && input.revision !== current.revision) throw new DocumentationConflictError(current);
if (!current && input.revision) throw new Error("Documentation page not found");
const parent = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
if (parent) await this.createFolder(workspaceId, parent);
const revision = digest(input.content);
const title = pageTitle(path, input.content);
return storage.db.one<DocumentationPage>(`
INSERT INTO popagent_document_pages (workspace_id, path, title, content, revision)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (workspace_id, path) DO UPDATE SET
title = EXCLUDED.title, content = EXCLUDED.content,
revision = EXCLUDED.revision, updated_at = NOW()
RETURNING path, title, revision, length(content)::int AS size,
updated_at::text AS "updatedAt", content
`, [workspaceId, path, title, input.content, revision]);
}
async move(workspaceId: string, inputPath: string, nextInputPath: string, revision: string): Promise<DocumentationPage> {
const current = await this.read(workspaceId, inputPath);
if (revision !== current.revision) throw new DocumentationConflictError(current);
const nextPath = documentationPath(nextInputPath);
if (nextPath === current.path) return current;
const parent = nextPath.includes("/") ? nextPath.slice(0, nextPath.lastIndexOf("/")) : "";
if (parent) await this.createFolder(workspaceId, parent);
const page = await storage.db.oneOrNone<DocumentationPage>(`
UPDATE popagent_document_pages SET path = $3, updated_at = NOW()
WHERE workspace_id = $1 AND path = $2
AND NOT EXISTS (
SELECT 1 FROM popagent_document_pages WHERE workspace_id = $1 AND path = $3
)
RETURNING path, title, revision, length(content)::int AS size,
updated_at::text AS "updatedAt", content
`, [workspaceId, current.path, nextPath]);
if (!page) throw new Error("Documentation page already exists");
return page;
}
async remove(workspaceId: string, inputPath: string, revision: string): Promise<void> {
const current = await this.read(workspaceId, inputPath);
if (revision !== current.revision) throw new DocumentationConflictError(current);
await storage.db.none(
"DELETE FROM popagent_document_pages WHERE workspace_id = $1 AND path = $2",
[workspaceId, current.path],
);
}
async removeFolder(workspaceId: string, inputPath: string): Promise<string[]> {
await this.init();
const path = documentationPath(inputPath, true);
return storage.db.tx(async (transaction) => {
const pages = await transaction.any<{ path: string }>(`
DELETE FROM popagent_document_pages
WHERE workspace_id = $1 AND (path = $2 OR path LIKE $2 || '/%')
RETURNING path
`, [workspaceId, path]);
const deleted = await transaction.oneOrNone<{ path: string }>(`
DELETE FROM popagent_document_folders
WHERE workspace_id = $1 AND path = $2
RETURNING path
`, [workspaceId, path]);
if (!deleted) throw new Error("Documentation folder not found");
await transaction.none(`
DELETE FROM popagent_document_folders
WHERE workspace_id = $1 AND path LIKE $2 || '/%'
`, [workspaceId, path]);
return pages.map((page) => page.path);
});
}
}