UNPKG

@braindb/core

Version:

markdown-graph-content-layer-database

207 lines (206 loc) 7.34 kB
import mitt from "mitt"; // @ts-ignore import chokidar from "chokidar"; import { getDb } from "./db.js"; import { getConnectedDocuments, resolveLinks } from "./resolveLinks.js"; import { addDocument } from "./addDocument.js"; import { symmetricDifference } from "./utils.js"; import { deleteDocument, deleteOldRevision } from "./deleteDocument.js"; import { Document } from "./Document.js"; import { document, link, task } from "./schema.js"; import { eq, sql } from "drizzle-orm"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; import { Link } from "./Link.js"; import { documentsSync } from "./query.js"; import { Task } from "./Task.js"; export { Document, Task, Link }; export class BrainDB { cfg; emitter; db; watcher; initializing = true; initQueue = []; constructor(cfg) { this.cfg = cfg; this.cfg.root = this.cfg.root.replace(/\/$/, ""); if (this.cfg.source === undefined) this.cfg.source = ""; this.cfg.source = this.cfg.source.replace(/\/$/, ""); if (this.cfg.source && !this.cfg.source.startsWith("/")) this.cfg.source = "/" + this.cfg.source; if (this.cfg.cache === undefined) this.cfg.cache = Boolean(this.cfg.dbPath); // @ts-expect-error https://nodejs.org/api/events.html#eventtarget-and-event-api this.emitter = mitt(); if (this.cfg.dbPath) { let dbPath = join(this.cfg.dbPath, ".braindb"); mkdirSync(dbPath, { recursive: true }); this.db = getDb(join(dbPath, "db.sqlite")); } else { this.db = getDb(":memory:"); } } start(silent) { if (this.watcher) if (silent) return this; else throw new Error("Already started"); const revision = new Date().getTime(); this.initializing = true; // this will acumulate all files, which can be problematic // what if instead of array - fetch files from DB in the end this.initQueue = []; const fileToPathId = (file) => (file.startsWith("/") ? file : "/" + file).replace(this.cfg.root, ""); const files = `${this.cfg.root}${this.cfg.source}/`; const dotfilesRegex = /(^|[\/\\])\../; this.watcher = chokidar .watch(files, { ignored: (path, stats) => { return (stats?.isFile() && !(path.endsWith(".md") || path.endsWith(".mdx")) && !dotfilesRegex.test(path)); }, persistent: true, }) .on("error", (error) => console.log(`Watcher error: ${error}`)) .on("ready", async () => { const res = await Promise.all(this.initQueue); this.initQueue = []; deleteOldRevision(this.db, revision); resolveLinks(this.db); this.initializing = false; res.forEach((path) => this.emitter.emit("create", { document: new Document(this.db, path) })); this.emitter.emit("ready"); }) .on("add", async (file) => { const idPath = fileToPathId(file); if (this.initializing) { const p = addDocument(this.db, idPath, this.cfg, revision).then(() => idPath); this.initQueue.push(p); await p; return; } const linksBefore = getConnectedDocuments({ db: this.db, idPath, }); await addDocument(this.db, idPath, this.cfg, revision); resolveLinks(this.db); this.emitter.emit("create", { document: new Document(this.db, idPath), }); const linksAfter = getConnectedDocuments({ db: this.db, idPath, }); symmetricDifference(linksBefore, linksAfter).forEach((path) => this.emitter.emit("update", { path })); }) .on("unlink", (file) => { const idPath = fileToPathId(file); const linksBefore = getConnectedDocuments({ db: this.db, idPath, }); deleteDocument(this.db, idPath); this.emitter.emit("delete", { document: new Document(this.db, idPath), }); symmetricDifference(linksBefore, []).forEach((path) => this.emitter.emit("update", { document: new Document(this.db, path) })); }) .on("change", async (file) => { const idPath = fileToPathId(file); const linksBefore = getConnectedDocuments({ db: this.db, idPath, }); await addDocument(this.db, idPath, this.cfg, revision); resolveLinks(this.db); this.emitter.emit("update", { document: new Document(this.db, idPath), }); const linksAfter = getConnectedDocuments({ db: this.db, idPath, }); symmetricDifference(linksBefore, linksAfter).forEach((path) => this.emitter.emit("update", { document: new Document(this.db, path) })); }); return this; } async stop() { if (this.watcher) await this.watcher.close(); this.watcher = undefined; this.initQueue = []; return this; } on(type, handler) { this.emitter.on(type, handler); return this; } off(type, handler) { this.emitter.off(type, handler); return this; } ready() { if (!this.watcher) return Promise.reject(new Error("BraindDB not started")); return this.initializing ? new Promise((resolve) => { // @ts-expect-error TS is wrong this.on("ready", () => resolve()); }) : Promise.resolve(); } documentsSync(options) { return documentsSync(this.db, options); } async documents(options) { await this.ready(); return this.documentsSync(options); } findDocumentSync(path) { return this.db .select({ path: document.path }) .from(document) .where(eq(document.path, path)) .all() .map(({ path }) => new Document(this.db, path))[0]; } async findDocument(path) { await this.ready(); return this.findDocumentSync(path); } linksSync() { return this.db .select({ from: link.from, start: link.start }) .from(link) .all() .map(({ from, start }) => new Link(this.db, from, start)); } async links() { await this.ready(); return this.linksSync(); } /** * TODO: filter by checked true/false */ tasksSync() { return this.db .select({ from: task.from, start: task.start }) .from(task) .all() .map(({ from, start }) => new Task(this.db, from, start)); } async tasks() { await this.ready(); return this.tasksSync(); } // this is experimental - do not use it __rawQuery(query) { return this.db.all(sql.raw(query)); } }