Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion src/core/VaultMutationService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,21 @@
import type { App, TFile } from "obsidian";
import type { MdbaseMutationBackend } from "./mdbase/MdbaseMutationBackend";

let mdbaseBackend: MdbaseMutationBackend | null = null;

/**
* Install (or remove, with `null`) the mdbase write path. While installed, writes
* to files in the mdbase collection go through the runtime as intents; everything
* else keeps using Obsidian's APIs.
*/
export function setMdbaseMutationBackend(backend: MdbaseMutationBackend | null): void {
mdbaseBackend = backend;
}

/** The installed mdbase write path, if any. */
export function getMdbaseMutationBackend(): MdbaseMutationBackend | null {
return mdbaseBackend;
}

type FrontmatterMutationApp = {
fileManager: {
Expand Down Expand Up @@ -54,6 +71,10 @@ export async function processVaultFrontMatterWithinMutation(
file: TFile,
update: (frontmatter: Record<string, unknown>) => void
): Promise<void> {
if (mdbaseBackend?.claims(file.path)) {
const r = await mdbaseBackend.processFrontMatter(file.path, update);
if (r.handled) return;
}
await app.fileManager.processFrontMatter(file, update);
}

Expand All @@ -70,10 +91,22 @@ export async function processVaultFileWithinMutation(
file: TFile,
update: (content: string) => string
): Promise<string> {
if (mdbaseBackend?.claims(file.path)) {
const r = await mdbaseBackend.process(file.path, update);
if (r.handled) return r.value;
}
return app.vault.process(file, update);
}

export async function createVaultFile(app: App, path: string, content: string): Promise<TFile> {
if (mdbaseBackend?.claims(path)) {
const r = await mdbaseBackend.create(path, content);
if (r.handled) {
const file = app.vault.getFileByPath(path);
if (file) return file;
throw new Error(`mdbase created ${path}, but Obsidian has not indexed it yet`);
}
}
return app.vault.create(path, content);
}

Expand All @@ -82,7 +115,13 @@ export async function createVaultFolder(app: App, path: string): Promise<void> {
}

export async function modifyVaultFile(app: App, file: TFile, content: string): Promise<void> {
await withVaultFileMutation(file, () => app.vault.modify(file, content));
await withVaultFileMutation(file, async () => {
if (mdbaseBackend?.claims(file.path)) {
const r = await mdbaseBackend.modify(file.path, content);
if (r.handled) return;
}
await app.vault.modify(file, content);
});
}

export async function renameVaultFile(app: App, file: TFile, newPath: string): Promise<void> {
Expand Down
170 changes: 170 additions & 0 deletions src/core/mdbase/MdbaseMutationBackend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* Route TaskNotes' vault writes through the mdbase runtime when it manages the
* vault's collection.
*
* TaskNotes funnels every write through `VaultMutationService`
* (`processFrontMatter`, `process`, `create`, `modify`). When a backend is installed
* and claims a file, each write becomes a replica intent, in every collection state
* (local only, synced, synced end to end), over one client API:
* - a frontmatter callback becomes a **field-level update**: the keys the callback
* set, and the keys it removed, with the values it read as the merge base. Two
* devices editing different fields of one task both win, instead of the last
* whole-file write;
* - a whole-content callback, or `modify`, becomes a document replace with the read
* document as its base. The replica merges, or holds the file;
* - `create` becomes a record create at the requested path.
*
* The replica publishes into the vault itself, using the editor fence when the note is
* open. Each call resolves once the write has settled (see
* {@link MdbaseWriteClient.settle}), so callers that re-read the file afterwards see
* the new content, as with Obsidian's own APIs.
*
* Files outside the collection, or written while no runtime is attached, keep using
* Obsidian's APIs unchanged.
*/

/** A record as TaskNotes needs it, with plain JSON-like frontmatter. */
export interface MdbaseRecord {
readonly id: string;
readonly path: string;
readonly frontmatter: Record<string, unknown>;
/** The whole source, when read with `document`. */
readonly document?: string;
}

/** An accepted write (pending or confirmed). */
export interface MdbaseWrite {
readonly mutation: string;
}

/**
* What the backend needs from the mdbase client. The adapter over
* `@mdbase-dev/sdk`'s `MdbaseClient` converts values to and from plain JSON.
*/
export interface MdbaseWriteClient {
/** The collection-relative path for a vault path, or `null` if outside the collection. */
collectionPath(vaultPath: string): string | null;
/** Read a record by path, with frontmatter (and the document when asked). */
find(path: string, opts: { document: boolean }): Promise<MdbaseRecord | null>;
/** Field-level update, with `base` taken from `seen`. */
update(
seen: MdbaseRecord,
changes: { patch: Record<string, unknown>; unset: string[] }
): Promise<MdbaseWrite>;
/** Replace the whole document, with `seen.document` as the merge base. */
replaceDocument(seen: MdbaseRecord, document: string): Promise<MdbaseWrite>;
/** Create a record from a complete document at `path`. */
create(path: string, document: string): Promise<MdbaseWrite>;
/**
* Resolve once the write is settled for TaskNotes' purposes: rejected (throws), or
* published into the vault file (the replica's publish, or the editor fence plus
* Obsidian's save) and indexed by Obsidian's vault (so `getFileByPath` finds a
* created file). It does not wait for log confirmation, so TaskNotes works the
* same offline.
*/
settle(write: MdbaseWrite, vaultPath: string): Promise<void>;
}

/** Result of a backend attempt: `false` means "not mine, use Obsidian's API". */
export type Handled<T> = { handled: true; value: T } | { handled: false };

const NOT_HANDLED = { handled: false } as const;

function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Date);
}

/** Structural equality for YAML-shaped values (objects compare as key sets). */
export function yamlEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((x, i) => yamlEqual(x, b[i]));
}
if (isPlainObject(a) && isPlainObject(b)) {
const ka = Object.keys(a);
const kb = Object.keys(b);
return ka.length === kb.length && ka.every((k) => Object.prototype.hasOwnProperty.call(b, k) && yamlEqual(a[k], b[k]));
}
return false;
}

function clone<T>(v: T): T {
return typeof structuredClone === "function" ? structuredClone(v) : (JSON.parse(JSON.stringify(v)) as T);
}

/** The top-level keys a frontmatter callback set or removed. */
export function frontmatterDiff(
before: Record<string, unknown>,
after: Record<string, unknown>
): { patch: Record<string, unknown>; unset: string[] } {
const patch: Record<string, unknown> = {};
const unset: string[] = [];
for (const [k, v] of Object.entries(after)) {
if (v === undefined) {
if (k in before) unset.push(k);
} else if (!(k in before) || !yamlEqual(before[k], v)) {
patch[k] = v;
}
}
for (const k of Object.keys(before)) if (!(k in after)) unset.push(k);
return { patch, unset };
}

/** The mdbase write path for TaskNotes. */
export class MdbaseMutationBackend {
constructor(private readonly client: MdbaseWriteClient) {}

/** Whether this backend handles writes to `vaultPath`. */
claims(vaultPath: string): boolean {
return this.client.collectionPath(vaultPath) !== null;
}

/** `fileManager.processFrontMatter`, as a field-level update. */
async processFrontMatter(
vaultPath: string,
update: (frontmatter: Record<string, unknown>) => void
): Promise<Handled<void>> {
const path = this.client.collectionPath(vaultPath);
if (path === null) return NOT_HANDLED;
const seen = await this.client.find(path, { document: false });
if (!seen) return NOT_HANDLED; // not a record (yet): let Obsidian write it
const working = clone(seen.frontmatter);
update(working);
const changes = frontmatterDiff(seen.frontmatter, working);
if (Object.keys(changes.patch).length === 0 && changes.unset.length === 0) {
return { handled: true, value: undefined };
}
const write = await this.client.update(seen, changes);
await this.client.settle(write, vaultPath);
return { handled: true, value: undefined };
}

/** `vault.process`, as a document replace with the read document as base. */
async process(vaultPath: string, update: (content: string) => string): Promise<Handled<string>> {
const path = this.client.collectionPath(vaultPath);
if (path === null) return NOT_HANDLED;
const seen = await this.client.find(path, { document: true });
if (!seen || seen.document === undefined) return NOT_HANDLED;
const next = update(seen.document);
if (next === seen.document) return { handled: true, value: next };
const write = await this.client.replaceDocument(seen, next);
await this.client.settle(write, vaultPath);
return { handled: true, value: next };
}

/** `vault.modify`: a document replace from whatever is current. */
async modify(vaultPath: string, content: string): Promise<Handled<void>> {
const r = await this.process(vaultPath, () => content);
return r.handled ? { handled: true, value: undefined } : NOT_HANDLED;
}

/** `vault.create`. The caller looks up the `TFile` once this resolves. */
async create(vaultPath: string, content: string): Promise<Handled<void>> {
const path = this.client.collectionPath(vaultPath);
if (path === null) return NOT_HANDLED;
const write = await this.client.create(path, content);
await this.client.settle(write, vaultPath);
return { handled: true, value: undefined };
}
}
139 changes: 139 additions & 0 deletions tests/unit/core/mdbase/MdbaseMutationBackend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import type { TFile } from "obsidian";
import {
frontmatterDiff,
MdbaseMutationBackend,
type MdbaseRecord,
type MdbaseWriteClient,
} from "../../../../src/core/mdbase/MdbaseMutationBackend";
import {
createVaultFile,
modifyVaultFile,
processVaultFile,
processVaultFrontMatter,
setMdbaseMutationBackend,
} from "../../../../src/core/VaultMutationService";

class FakeClient implements MdbaseWriteClient {
records = new Map<string, MdbaseRecord>();
calls: unknown[] = [];
settled: string[] = [];
collectionPath(vaultPath: string) {
return vaultPath.startsWith("Tasks/") ? vaultPath.slice("Tasks/".length) : null;
}
async find(path: string, opts: { document: boolean }) {
const r = this.records.get(path);
if (!r) return null;
return opts.document ? r : { ...r, document: undefined };
}
async update(seen: MdbaseRecord, changes: { patch: Record<string, unknown>; unset: string[] }) {
this.calls.push(["update", seen.id, changes]);
return { mutation: "m1" };
}
async replaceDocument(seen: MdbaseRecord, document: string) {
this.calls.push(["replace", seen.id, seen.document, document]);
return { mutation: "m2" };
}
async create(path: string, document: string) {
this.calls.push(["create", path, document]);
return { mutation: "m3" };
}
async settle(_w: unknown, vaultPath: string) {
this.settled.push(vaultPath);
}
}

const task: MdbaseRecord = {
id: "r1",
path: "t.md",
frontmatter: { status: "open", tags: ["a"], scheduled: "2026-10-04", meta: { x: 1 } },
document: "---\nstatus: open\n---\nbody\n",
};

function app() {
return {
fileManager: { processFrontMatter: jest.fn(async () => {}) },
vault: {
process: jest.fn(async (_f: TFile, fn: (c: string) => string) => fn("obsidian")),
modify: jest.fn(async () => {}),
create: jest.fn(async (p: string) => ({ path: p }) as TFile),
getFileByPath: jest.fn((p: string) => ({ path: p }) as TFile),
},
};
}

describe("frontmatterDiff", () => {
it("reports only keys the callback set or removed", () => {
expect(frontmatterDiff({ a: 1, b: [1, 2], c: { d: 1 }, e: "x" }, { a: 1, b: [1, 2, 3], c: { d: 1 }, f: true })).toEqual({
patch: { b: [1, 2, 3], f: true },
unset: ["e"],
});
expect(frontmatterDiff({ a: 1 }, { a: undefined })).toEqual({ patch: {}, unset: ["a"] });
});
});

describe("VaultMutationService with the mdbase backend", () => {
let client: FakeClient;
beforeEach(() => {
client = new FakeClient();
client.records.set("t.md", task);
setMdbaseMutationBackend(new MdbaseMutationBackend(client));
});
afterEach(() => setMdbaseMutationBackend(null));

it("turns a frontmatter callback into a field-level update", async () => {
const a = app();
await processVaultFrontMatter(a as never, { path: "Tasks/t.md" } as TFile, (fm) => {
fm.status = "done";
fm.completedDate = "2026-10-04";
delete fm.scheduled;
(fm.tags as string[]).push("b");
});
expect(client.calls).toEqual([
["update", "r1", { patch: { status: "done", tags: ["a", "b"], completedDate: "2026-10-04" }, unset: ["scheduled"] }],
]);
expect(client.settled).toEqual(["Tasks/t.md"]);
expect(a.fileManager.processFrontMatter).not.toHaveBeenCalled();
expect(task.frontmatter.tags).toEqual(["a"]); // the read view is not mutated
});

it("sends nothing when the callback changes nothing", async () => {
await processVaultFrontMatter(app() as never, { path: "Tasks/t.md" } as TFile, () => {});
expect(client.calls).toEqual([]);
});

it("turns a content callback into a document replace with the read document as base", async () => {
const out = await processVaultFile(app() as never, { path: "Tasks/t.md" } as TFile, (c) => c.replace("body", "BODY"));
expect(out).toBe("---\nstatus: open\n---\nBODY\n");
expect(client.calls).toEqual([["replace", "r1", task.document, out]]);
});

it("routes modify and create", async () => {
const a = app();
await modifyVaultFile(a as never, { path: "Tasks/t.md" } as TFile, "new");
const f = await createVaultFile(a as never, "Tasks/n.md", "---\ntitle: n\n---\n");
expect(f.path).toBe("Tasks/n.md");
expect(client.calls).toEqual([
["replace", "r1", task.document, "new"],
["create", "n.md", "---\ntitle: n\n---\n"],
]);
expect(a.vault.modify).not.toHaveBeenCalled();
expect(a.vault.create).not.toHaveBeenCalled();
});

it("leaves files outside the collection, and unknown records, to Obsidian", async () => {
const a = app();
await processVaultFrontMatter(a as never, { path: "Other/x.md" } as TFile, (fm) => (fm.a = 1));
await processVaultFrontMatter(a as never, { path: "Tasks/unknown.md" } as TFile, (fm) => (fm.a = 1));
await createVaultFile(a as never, "Other/y.md", "y");
expect(a.fileManager.processFrontMatter).toHaveBeenCalledTimes(2);
expect(a.vault.create).toHaveBeenCalledTimes(1);
expect(client.calls).toEqual([]);
});

it("without a backend, behaves exactly as before", async () => {
setMdbaseMutationBackend(null);
const a = app();
await processVaultFrontMatter(a as never, { path: "Tasks/t.md" } as TFile, (fm) => (fm.a = 1));
expect(a.fileManager.processFrontMatter).toHaveBeenCalledTimes(1);
});
});
Loading