@braindb/core
Version:
markdown-graph-content-layer-database
207 lines (206 loc) • 7.34 kB
JavaScript
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));
}
}