From c112ede0eeabf766c113023d1a8709854de2ef0e Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 4 Oct 2026 21:18:03 +1100 Subject: [PATCH 1/6] feat: route vault writes through an mdbase-next mutation backend when installed VaultMutationService is TaskNotes' single write funnel. When an MdbaseMutationBackend is installed and claims a file (it is in the mdbase collection and is a known record), writes become replica intents: frontmatter callbacks -> field-level updates (set/unset keys with the read values as merge base), content callbacks and modify -> document replace with the read document as base, create -> record create. Each call resolves once the write is published into the vault, so callers see the same behaviour as with Obsidian's APIs. Without a backend, or for files outside the collection, nothing changes. Not installed by default yet: the shared runtime wiring lands once runtime.wasm hosts the file store. --- src/core/VaultMutationService.ts | 41 ++++- src/core/mdbase/MdbaseMutationBackend.ts | 170 ++++++++++++++++++ .../core/mdbase/MdbaseMutationBackend.test.ts | 139 ++++++++++++++ 3 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 src/core/mdbase/MdbaseMutationBackend.ts create mode 100644 tests/unit/core/mdbase/MdbaseMutationBackend.test.ts diff --git a/src/core/VaultMutationService.ts b/src/core/VaultMutationService.ts index 647ce0275..9a47c22cf 100644 --- a/src/core/VaultMutationService.ts +++ b/src/core/VaultMutationService.ts @@ -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: { @@ -54,6 +71,10 @@ export async function processVaultFrontMatterWithinMutation( file: TFile, update: (frontmatter: Record) => void ): Promise { + if (mdbaseBackend?.claims(file.path)) { + const r = await mdbaseBackend.processFrontMatter(file.path, update); + if (r.handled) return; + } await app.fileManager.processFrontMatter(file, update); } @@ -70,10 +91,22 @@ export async function processVaultFileWithinMutation( file: TFile, update: (content: string) => string ): Promise { + 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 { + 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); } @@ -82,7 +115,13 @@ export async function createVaultFolder(app: App, path: string): Promise { } export async function modifyVaultFile(app: App, file: TFile, content: string): Promise { - 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 { diff --git a/src/core/mdbase/MdbaseMutationBackend.ts b/src/core/mdbase/MdbaseMutationBackend.ts new file mode 100644 index 000000000..d4ad0829e --- /dev/null +++ b/src/core/mdbase/MdbaseMutationBackend.ts @@ -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; + /** 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; + /** Field-level update, with `base` taken from `seen`. */ + update( + seen: MdbaseRecord, + changes: { patch: Record; unset: string[] } + ): Promise; + /** Replace the whole document, with `seen.document` as the merge base. */ + replaceDocument(seen: MdbaseRecord, document: string): Promise; + /** Create a record from a complete document at `path`. */ + create(path: string, document: string): Promise; + /** + * 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; +} + +/** Result of a backend attempt: `false` means "not mine, use Obsidian's API". */ +export type Handled = { handled: true; value: T } | { handled: false }; + +const NOT_HANDLED = { handled: false } as const; + +function isPlainObject(v: unknown): v is Record { + 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(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, + after: Record +): { patch: Record; unset: string[] } { + const patch: Record = {}; + 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) => void + ): Promise> { + 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> { + 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> { + 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> { + 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 }; + } +} diff --git a/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts b/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts new file mode 100644 index 000000000..afe08973a --- /dev/null +++ b/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts @@ -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(); + 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; 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); + }); +}); From 03358d18fa70f9b263fe9a30bcb4df1b29932b26 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 4 Oct 2026 23:43:29 +1100 Subject: [PATCH 2/6] mdbase-next backend: create only record paths through the runtime; document relation to v5's SafeMetadata path --- src/core/mdbase/MdbaseMutationBackend.ts | 20 ++++++++++++++++--- .../core/mdbase/MdbaseMutationBackend.test.ts | 11 ++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/core/mdbase/MdbaseMutationBackend.ts b/src/core/mdbase/MdbaseMutationBackend.ts index d4ad0829e..86760cf35 100644 --- a/src/core/mdbase/MdbaseMutationBackend.ts +++ b/src/core/mdbase/MdbaseMutationBackend.ts @@ -19,8 +19,16 @@ * {@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. + * Files outside the collection, non-record files (backups, exports), and writes while + * no runtime is attached keep using Obsidian's APIs unchanged. + * + * **Relation to v5's existing mdbase path.** v5 manages the collection's own metadata + * (`mdbase.yaml`, `_types/`) through `SafeMetadata` and `MetadataTransaction`, with a + * swap journal and direct adapter writes. This backend covers the record funnel only. + * When the runtime hosts the collection, metadata writes must also become replica + * resource writes, because type changes are ordered in the log. That is the next step + * of the port: a `SafeMetadataAdapter` backed by this client. Until then, the runtime + * ingests those writes as outside edits, as it does any other tool's. */ /** A record as TaskNotes needs it, with plain JSON-like frontmatter. */ @@ -44,6 +52,12 @@ export interface MdbaseWrite { export interface MdbaseWriteClient { /** The collection-relative path for a vault path, or `null` if outside the collection. */ collectionPath(vaultPath: string): string | null; + /** + * Whether a new file at this collection path becomes a record (the collection's + * record globs and file inclusion, from `describe`). Other files are created through + * Obsidian: TaskNotes' backups, exports and attachments. + */ + isRecordPath(path: string): boolean; /** Read a record by path, with frontmatter (and the document when asked). */ find(path: string, opts: { document: boolean }): Promise; /** Field-level update, with `base` taken from `seen`. */ @@ -162,7 +176,7 @@ export class MdbaseMutationBackend { /** `vault.create`. The caller looks up the `TFile` once this resolves. */ async create(vaultPath: string, content: string): Promise> { const path = this.client.collectionPath(vaultPath); - if (path === null) return NOT_HANDLED; + if (path === null || !this.client.isRecordPath(path)) return NOT_HANDLED; const write = await this.client.create(path, content); await this.client.settle(write, vaultPath); return { handled: true, value: undefined }; diff --git a/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts b/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts index afe08973a..f1735b185 100644 --- a/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts +++ b/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts @@ -17,6 +17,9 @@ class FakeClient implements MdbaseWriteClient { records = new Map(); calls: unknown[] = []; settled: string[] = []; + isRecordPath(path: string) { + return path.endsWith(".md") && !path.startsWith(".tasknotes-backups/"); + } collectionPath(vaultPath: string) { return vaultPath.startsWith("Tasks/") ? vaultPath.slice("Tasks/".length) : null; } @@ -130,6 +133,14 @@ describe("VaultMutationService with the mdbase backend", () => { expect(client.calls).toEqual([]); }); + it("creates non-record files (backups) through Obsidian", async () => { + const a = app(); + await createVaultFile(a as never, "Tasks/.tasknotes-backups/x.md", "b"); + await createVaultFile(a as never, "Tasks/export.ics", "c"); + expect(a.vault.create).toHaveBeenCalledTimes(2); + expect(client.calls).toEqual([]); + }); + it("without a backend, behaves exactly as before", async () => { setMdbaseMutationBackend(null); const a = app(); From 40c31ac84bbb6c9845252dc320fd2c0b309f6ea6 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 4 Oct 2026 23:47:46 +1100 Subject: [PATCH 3/6] mdbase-next backend: collection metadata as resource writes When the backend is installed, SafeMetadata writes to mdbase.yaml, type and contract files become resource writes through the runtime, based on the content they read (base_revision), with null base = must_not_exist for pack-created types (contracts #201). A MetadataTransaction's whole set is one mutation, applied atomically by the log with no local swap journal; backups under .tasknotes/ are still written. Setting a type aside copies it out, then deletes the resource with its base. --- src/core/mdbase/MdbaseMutationBackend.ts | 59 ++++++++- src/services/mdbase/MetadataTransaction.ts | 14 ++ src/services/mdbase/SafeMetadata.ts | 25 ++++ .../core/mdbase/MdbaseMutationBackend.test.ts | 6 + .../core/mdbase/MdbaseResourceWrites.test.ts | 124 ++++++++++++++++++ 5 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 tests/unit/core/mdbase/MdbaseResourceWrites.test.ts diff --git a/src/core/mdbase/MdbaseMutationBackend.ts b/src/core/mdbase/MdbaseMutationBackend.ts index 86760cf35..d3646a33d 100644 --- a/src/core/mdbase/MdbaseMutationBackend.ts +++ b/src/core/mdbase/MdbaseMutationBackend.ts @@ -22,13 +22,13 @@ * Files outside the collection, non-record files (backups, exports), and writes while * no runtime is attached keep using Obsidian's APIs unchanged. * - * **Relation to v5's existing mdbase path.** v5 manages the collection's own metadata - * (`mdbase.yaml`, `_types/`) through `SafeMetadata` and `MetadataTransaction`, with a - * swap journal and direct adapter writes. This backend covers the record funnel only. - * When the runtime hosts the collection, metadata writes must also become replica - * resource writes, because type changes are ordered in the log. That is the next step - * of the port: a `SafeMetadataAdapter` backed by this client. Until then, the runtime - * ingests those writes as outside edits, as it does any other tool's. + * **Collection metadata.** v5 writes `mdbase.yaml`, type files and contract files + * through `SafeMetadata` and `MetadataTransaction`. When this backend is installed, + * those writes become **resource writes** ({@link writeResources}): each carries the + * content it read as its base revision, and a new file from a pack is sent with + * `must_not_exist`. A transaction's whole set is one mutation, so a pack lands + * atomically in the log, with no local swap journal. Backups under `.tasknotes/` are + * still written to the vault, because they are not resources. */ /** A record as TaskNotes needs it, with plain JSON-like frontmatter. */ @@ -40,6 +40,16 @@ export interface MdbaseRecord { readonly document?: string; } +/** + * A resource write (`intent.md` ยง3.6): `mdbase.yaml`, type and contract files. + * `base` is the exact content the writer read. The client sends its revision as + * `base_revision`, or `must_not_exist` when `base` is `null` (a new type from a pack; + * contracts #201). + */ +export type ResourceOp = + | { readonly kind: "put"; readonly path: string; readonly doc: string; readonly base: string | null } + | { readonly kind: "delete"; readonly path: string; readonly base: string }; + /** An accepted write (pending or confirmed). */ export interface MdbaseWrite { readonly mutation: string; @@ -58,6 +68,13 @@ export interface MdbaseWriteClient { * Obsidian: TaskNotes' backups, exports and attachments. */ isRecordPath(path: string): boolean; + /** Whether a collection path is a resource under the catalog in force. */ + isResourcePath(path: string): boolean; + /** + * Submit resource writes as **one** mutation. It is ordered in the log and applied + * atomically, so a pack's types and the config change together, or not at all. + */ + resources(ops: ResourceOp[]): Promise; /** Read a record by path, with frontmatter (and the document when asked). */ find(path: string, opts: { document: boolean }): Promise; /** Field-level update, with `base` taken from `seen`. */ @@ -125,6 +142,11 @@ export function frontmatterDiff( return { patch, unset }; } +/** A resource write in vault paths. */ +export type ResourceInput = + | { readonly kind: "put"; readonly vaultPath: string; readonly doc: string; readonly base: string | null } + | { readonly kind: "delete"; readonly vaultPath: string; readonly base: string }; + /** The mdbase write path for TaskNotes. */ export class MdbaseMutationBackend { constructor(private readonly client: MdbaseWriteClient) {} @@ -173,6 +195,29 @@ export class MdbaseMutationBackend { return r.handled ? { handled: true, value: undefined } : NOT_HANDLED; } + /** Whether `vaultPath` is a collection resource this backend writes. */ + claimsResource(vaultPath: string): boolean { + const path = this.client.collectionPath(vaultPath); + return path !== null && this.client.isResourcePath(path); + } + + /** + * Write resources as one mutation, with each one's read content as its base. + * Every op must be a claimed resource. A mismatch is rejected by the replica + * (`conflict`), never overwritten. + */ + async writeResources(ops: readonly ResourceInput[]): Promise { + if (ops.length === 0) return; + const mapped: ResourceOp[] = []; + for (const op of ops) { + const path = this.client.collectionPath(op.vaultPath); + if (path === null || !this.client.isResourcePath(path)) throw new Error(`Not an mdbase resource: ${op.vaultPath}`); + mapped.push(op.kind === "put" ? { kind: "put", path, doc: op.doc, base: op.base } : { kind: "delete", path, base: op.base }); + } + const write = await this.client.resources(mapped); + for (const op of ops) if (op.kind === "put") await this.client.settle(write, op.vaultPath); + } + /** `vault.create`. The caller looks up the `TFile` once this resolves. */ async create(vaultPath: string, content: string): Promise> { const path = this.client.collectionPath(vaultPath); diff --git a/src/services/mdbase/MetadataTransaction.ts b/src/services/mdbase/MetadataTransaction.ts index fdceb250f..2326beace 100644 --- a/src/services/mdbase/MetadataTransaction.ts +++ b/src/services/mdbase/MetadataTransaction.ts @@ -1,5 +1,6 @@ import YAML from "yaml"; import { SafeMetadata, type MetadataSnapshot } from "./SafeMetadata"; +import { getMdbaseMutationBackend } from "../../core/VaultMutationService"; export const METADATA_PENDING = ".tasknotes/migrations/mdbase-v0.3-pending.json"; type Journal = { backupFolder: string; snapshots: MetadataSnapshot[]; intendedWrites: MetadataSnapshot[] }; @@ -62,6 +63,19 @@ export class MetadataTransaction { await this.io.replace({ path, content: null }, snapshot.content); } await this.io.replace({ path: `${backupFolder}/manifest.json`, content: null }, JSON.stringify(journal, null, 2)); + const backend = getMdbaseMutationBackend(); + if (backend && changed.every((write) => backend.claimsResource(write.path))) { + // The runtime hosts the collection: the whole upgrade is one mutation of + // resource writes, each based on its compared snapshot. The log applies it + // atomically (no local swap journal), and a concurrent change rejects it. + await this.assertUnchanged(snapshots); + await backend.writeResources(changed.map((write) => { + const snapshot = snapshots.find((item) => item.path === write.path); + if (!snapshot || write.content === null) throw new Error(`Invalid metadata write: ${write.path}`); + return { kind: "put" as const, vaultPath: write.path, doc: write.content, base: snapshot.content }; + })); + return; + } try { await this.io.replace({ path: METADATA_PENDING, content: null }, JSON.stringify(journal, null, 2)); // Type before contract activation, collection configuration last. diff --git a/src/services/mdbase/SafeMetadata.ts b/src/services/mdbase/SafeMetadata.ts index d67bc93a3..495123c56 100644 --- a/src/services/mdbase/SafeMetadata.ts +++ b/src/services/mdbase/SafeMetadata.ts @@ -1,4 +1,5 @@ import { METADATA_SWAPS, recoverMetadataSwap, type SwapJournal } from "./MetadataSwap"; +import { getMdbaseMutationBackend } from "../../core/VaultMutationService"; class MetadataPublicationConflict extends Error {} @@ -183,10 +184,26 @@ export class SafeMetadata { } } + /** + * The mdbase runtime owns this path as a resource, so it is written through the + * replica (ordered in the log, with the read content as its base) instead of the + * vault adapter. + */ + private runtimeOwner(path: string) { + const backend = getMdbaseMutationBackend(); + return backend?.claimsResource(path) ? backend : null; + } + async replace(snapshot: MetadataSnapshot, content: string): Promise { await this.assertPhysical(snapshot.path); if (await this.read(snapshot.path) !== snapshot.content) throw new Error(`Concurrent metadata change: ${snapshot.path}`); if (snapshot.content === content) return; + const owner = this.runtimeOwner(snapshot.path); + if (owner) { + // base = what was read; null base = must_not_exist (a new type from a pack). + await owner.writeResources([{ kind: "put", vaultPath: snapshot.path, doc: content, base: snapshot.content }]); + return; + } if (!this.adapter.rename) throw new Error(`Safe metadata move unavailable: ${snapshot.path}`); if (snapshot.content === null) return this.create(snapshot.path, content); const native = await this.native(); @@ -232,6 +249,14 @@ export class SafeMetadata { /** Move, never copy/delete. Even an edit during rename survives in recovery storage. */ async move(snapshot: MetadataSnapshot, destination: string): Promise { if (await this.read(snapshot.path) !== snapshot.content) throw new Error(`Concurrent metadata change: ${snapshot.path}`); + const owner = this.runtimeOwner(snapshot.path); + if (snapshot.content !== null && owner && !this.runtimeOwner(destination)) { + // Set aside a resource: keep a copy outside the collection's resources, then + // delete the resource through the replica with the read content as base. + await this.create(destination, snapshot.content); + await owner.writeResources([{ kind: "delete", vaultPath: snapshot.path, base: snapshot.content }]); + return; + } await this.moveEmpty(snapshot.path, destination); if (await this.read(destination) !== snapshot.content) throw new Error(`Concurrent metadata change: ${snapshot.path}; retained at ${destination}`); } diff --git a/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts b/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts index f1735b185..29df3781c 100644 --- a/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts +++ b/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts @@ -40,6 +40,12 @@ class FakeClient implements MdbaseWriteClient { this.calls.push(["create", path, document]); return { mutation: "m3" }; } + isResourcePath() { + return false; + } + async resources() { + return { mutation: "x" }; + } async settle(_w: unknown, vaultPath: string) { this.settled.push(vaultPath); } diff --git a/tests/unit/core/mdbase/MdbaseResourceWrites.test.ts b/tests/unit/core/mdbase/MdbaseResourceWrites.test.ts new file mode 100644 index 000000000..82636c8f3 --- /dev/null +++ b/tests/unit/core/mdbase/MdbaseResourceWrites.test.ts @@ -0,0 +1,124 @@ +import { MdbaseMutationBackend, type MdbaseRecord, type MdbaseWriteClient, type ResourceOp } from "../../../../src/core/mdbase/MdbaseMutationBackend"; +import { setMdbaseMutationBackend } from "../../../../src/core/VaultMutationService"; +import { SafeMetadata } from "../../../../src/services/mdbase/SafeMetadata"; +import { MetadataTransaction, METADATA_PENDING } from "../../../../src/services/mdbase/MetadataTransaction"; + +class ResourceClient implements MdbaseWriteClient { + batches: ResourceOp[][] = []; + collectionPath(p: string) { + return p; + } + isRecordPath(p: string) { + return p.endsWith(".md") && !p.startsWith("_types/") && !p.startsWith(".tasknotes/"); + } + isResourcePath(p: string) { + return p === "mdbase.yaml" || p.startsWith("_types/") || p.startsWith("_contracts/") || p.startsWith("_schemas/"); + } + async find(): Promise { + return null; + } + async update() { + return { mutation: "u" }; + } + async replaceDocument() { + return { mutation: "r" }; + } + async create() { + return { mutation: "c" }; + } + async resources(ops: ResourceOp[]) { + this.batches.push(ops); + return { mutation: `m${this.batches.length}` }; + } + async settle() {} +} + +function adapter(entries: Record) { + const files = new Map(Object.entries(entries)); + const folders = new Set(); + return { + files, + exists: async (p: string) => files.has(p) || folders.has(p), + read: async (p: string) => files.get(p)!, + write: async (p: string, c: string) => void files.set(p, c), + rename: async (a: string, b: string) => { + files.set(b, files.get(a)!); + files.delete(a); + }, + remove: async (p: string) => void files.delete(p), + mkdir: async (p: string) => void folders.add(p), + list: async () => ({ files: [], folders: [] }), + }; +} + +describe("collection metadata through the mdbase runtime (resource writes)", () => { + let client: ResourceClient; + beforeEach(() => { + client = new ResourceClient(); + setMdbaseMutationBackend(new MdbaseMutationBackend(client)); + }); + afterEach(() => setMdbaseMutationBackend(null)); + + it("SafeMetadata.replace of a resource is a resource_put based on the read content", async () => { + const a = adapter({ "mdbase.yaml": "spec_version: 0.3.0\n" }); + const io = new SafeMetadata(a); + await io.replace({ path: "mdbase.yaml", content: "spec_version: 0.3.0\n" }, "spec_version: 0.3.0\nsettings: {}\n"); + expect(client.batches).toEqual([[{ kind: "put", path: "mdbase.yaml", doc: "spec_version: 0.3.0\nsettings: {}\n", base: "spec_version: 0.3.0\n" }]]); + expect(a.files.get("mdbase.yaml")).toBe("spec_version: 0.3.0\n"); // the replica publishes, not TaskNotes + expect([...a.files.keys()].some((p) => p.includes("tasknotes-stage"))).toBe(false); + }); + + it("a new type from a pack is sent with base null (must_not_exist)", async () => { + const io = new SafeMetadata(adapter({})); + await io.replace({ path: "_types/tasknotes.task.md", content: null }, "---\nname: tasknotes.task\n---\n"); + expect(client.batches[0]).toEqual([{ kind: "put", path: "_types/tasknotes.task.md", doc: "---\nname: tasknotes.task\n---\n", base: null }]); + }); + + it("non-resource files (backups) are still written to the vault", async () => { + const a = adapter({}); + const io = new SafeMetadata(a); + await io.replace({ path: ".tasknotes/migrations/x/mdbase.yaml.bak", content: null }, "old"); + expect(client.batches).toEqual([]); + expect(a.files.get(".tasknotes/migrations/x/mdbase.yaml.bak")).toBe("old"); + }); + + it("setting a type aside keeps a copy and deletes the resource with its base", async () => { + const a = adapter({ "_types/task.md": "dup" }); + const io = new SafeMetadata(a); + await io.move({ path: "_types/task.md", content: "dup" }, ".tasknotes/migrations/superseded/_types/task.md"); + expect(a.files.get(".tasknotes/migrations/superseded/_types/task.md")).toBe("dup"); + expect(client.batches).toEqual([[{ kind: "delete", path: "_types/task.md", base: "dup" }]]); + }); + + it("a metadata transaction is one mutation, with backups but no local swap journal", async () => { + const a = adapter({ "mdbase.yaml": "v: 1\n", "_types/task.md": "old type" }); + const io = new SafeMetadata(a); + const tx = new MetadataTransaction(io, async (p) => void (await a.mkdir(p)), async (p) => void a.files.delete(p)); + await tx.run(async () => { + tx.stage({ path: "_types/task.md", content: "old type" }, "new type"); + tx.stage({ path: "_types/tasknotes.task.md", content: null }, "pack type"); + tx.stage({ path: "mdbase.yaml", content: "v: 1\n" }, "v: 2\n"); + }); + expect(client.batches).toHaveLength(1); + expect(client.batches[0]).toEqual([ + { kind: "put", path: "_types/task.md", doc: "new type", base: "old type" }, + { kind: "put", path: "_types/tasknotes.task.md", doc: "pack type", base: null }, + { kind: "put", path: "mdbase.yaml", doc: "v: 2\n", base: "v: 1\n" }, + ]); + expect(a.files.has(METADATA_PENDING)).toBe(false); + expect([...a.files.keys()].some((p) => p.endsWith("manifest.json"))).toBe(true); // backups kept + }); + + it("a concurrent change before commit still aborts", async () => { + const a = adapter({ "mdbase.yaml": "v: 1\n" }); + const io = new SafeMetadata(a); + const tx = new MetadataTransaction(io, async () => {}, async () => {}); + await expect( + tx.run(async () => { + tx.stage({ path: "mdbase.yaml", content: "v: 1\n" }, "v: 2\n"); + a.files.set("mdbase.yaml", "v: user\n"); + }), + ).rejects.toThrow(/Concurrent metadata change/); + expect(client.batches).toEqual([]); + }); +}); From 24cef50257d76dc69f40a0edd65b1684eadfee33 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 14:25:54 +1100 Subject: [PATCH 4/6] mdbase backend: keep managed writes on the runtime path --- src/core/mdbase/MdbaseMutationBackend.ts | 31 ++++++++-- .../core/mdbase/MdbaseMutationBackend.test.ts | 57 ++++++++++++++++++- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/core/mdbase/MdbaseMutationBackend.ts b/src/core/mdbase/MdbaseMutationBackend.ts index d3646a33d..c6680295e 100644 --- a/src/core/mdbase/MdbaseMutationBackend.ts +++ b/src/core/mdbase/MdbaseMutationBackend.ts @@ -5,7 +5,7 @@ * 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: + * (Cloud copy, Private, or the advanced/migration Sync: off state), 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 @@ -163,8 +163,16 @@ export class MdbaseMutationBackend { ): Promise> { const path = this.client.collectionPath(vaultPath); if (path === null) return NOT_HANDLED; + if (this.client.isResourcePath(path)) { + throw new Error("Managed resources must use the resource write API"); + } const seen = await this.client.find(path, { document: false }); - if (!seen) return NOT_HANDLED; // not a record (yet): let Obsidian write it + if (!seen) { + if (this.client.isRecordPath(path) || this.client.isResourcePath(path)) { + throw new Error("Managed file is unavailable; retry after the backend is ready"); + } + return NOT_HANDLED; + } const working = clone(seen.frontmatter); update(working); const changes = frontmatterDiff(seen.frontmatter, working); @@ -180,8 +188,19 @@ export class MdbaseMutationBackend { async process(vaultPath: string, update: (content: string) => string): Promise> { const path = this.client.collectionPath(vaultPath); if (path === null) return NOT_HANDLED; + if (this.client.isResourcePath(path)) { + throw new Error("Managed resources must use the resource write API"); + } const seen = await this.client.find(path, { document: true }); - if (!seen || seen.document === undefined) return NOT_HANDLED; + if (!seen) { + if (this.client.isRecordPath(path) || this.client.isResourcePath(path)) { + throw new Error("Managed file is unavailable; retry after the backend is ready"); + } + return NOT_HANDLED; + } + if (seen.document === undefined) { + throw new Error("Managed file document is unavailable; retry after the backend is ready"); + } const next = update(seen.document); if (next === seen.document) return { handled: true, value: next }; const write = await this.client.replaceDocument(seen, next); @@ -221,7 +240,11 @@ export class MdbaseMutationBackend { /** `vault.create`. The caller looks up the `TFile` once this resolves. */ async create(vaultPath: string, content: string): Promise> { const path = this.client.collectionPath(vaultPath); - if (path === null || !this.client.isRecordPath(path)) return NOT_HANDLED; + if (path === null) return NOT_HANDLED; + if (this.client.isResourcePath(path)) { + throw new Error("Managed resources must use the resource write API"); + } + if (!this.client.isRecordPath(path)) return NOT_HANDLED; const write = await this.client.create(path, content); await this.client.settle(write, vaultPath); return { handled: true, value: undefined }; diff --git a/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts b/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts index 29df3781c..80f66bd72 100644 --- a/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts +++ b/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts @@ -40,7 +40,7 @@ class FakeClient implements MdbaseWriteClient { this.calls.push(["create", path, document]); return { mutation: "m3" }; } - isResourcePath() { + isResourcePath(_path: string) { return false; } async resources() { @@ -129,16 +129,67 @@ describe("VaultMutationService with the mdbase backend", () => { expect(a.vault.create).not.toHaveBeenCalled(); }); - it("leaves files outside the collection, and unknown records, to Obsidian", async () => { + it("leaves files outside the collection, and non-record backups, 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 processVaultFrontMatter(a as never, { path: "Tasks/.tasknotes-backups/x.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.each(["frontmatter", "process", "modify"])("blocks unavailable managed records for %s without a legacy write", async (operation) => { + const a = app(); + const file = { path: "Tasks/unknown.md" } as TFile; + const callback = jest.fn(() => "next"); + const write = operation === "frontmatter" + ? processVaultFrontMatter(a as never, file, callback) + : operation === "process" + ? processVaultFile(a as never, file, callback) + : modifyVaultFile(a as never, file, "next"); + await expect(write).rejects.toThrow("Managed file is unavailable"); + expect(callback).not.toHaveBeenCalled(); + expect(a.fileManager.processFrontMatter).not.toHaveBeenCalled(); + expect(a.vault.process).not.toHaveBeenCalled(); + expect(a.vault.modify).not.toHaveBeenCalled(); + expect(client.calls).toEqual([]); + }); + + it("blocks a managed record without its document before calling the updater", async () => { + client.records.set("t.md", { ...task, document: undefined }); + const a = app(); + const callback = jest.fn(() => "next"); + await expect(processVaultFile(a as never, { path: "Tasks/t.md" } as TFile, callback)).rejects.toThrow("Managed file document is unavailable"); + expect(callback).not.toHaveBeenCalled(); + expect(a.vault.process).not.toHaveBeenCalled(); + expect(client.calls).toEqual([]); + }); + + it("requires managed resource creates to use the resource API", async () => { + client.isResourcePath = (path: string) => path === "_types/task.md"; + const a = app(); + await expect(createVaultFile(a as never, "Tasks/_types/task.md", "type")).rejects.toThrow("Managed resources must use the resource write API"); + expect(a.vault.create).not.toHaveBeenCalled(); + expect(client.calls).toEqual([]); + }); + + it("blocks unavailable resources on content and frontmatter paths", async () => { + client.isResourcePath = (path: string) => path === "mdbase.yaml"; + const a = app(); + await expect(processVaultFrontMatter(a as never, { path: "Tasks/mdbase.yaml" } as TFile, () => {})).rejects.toThrow("Managed resources must use the resource write API"); + await expect(processVaultFile(a as never, { path: "Tasks/mdbase.yaml" } as TFile, () => "next")).rejects.toThrow("Managed resources must use the resource write API"); + expect(a.fileManager.processFrontMatter).not.toHaveBeenCalled(); + expect(a.vault.process).not.toHaveBeenCalled(); + }); + + it("propagates lookup errors without a legacy write", async () => { + client.find = jest.fn(async () => { throw new Error("lookup unavailable"); }); + const a = app(); + await expect(processVaultFrontMatter(a as never, { path: "Tasks/t.md" } as TFile, () => {})).rejects.toThrow("lookup unavailable"); + expect(a.fileManager.processFrontMatter).not.toHaveBeenCalled(); + }); + it("creates non-record files (backups) through Obsidian", async () => { const a = app(); await createVaultFile(a as never, "Tasks/.tasknotes-backups/x.md", "b"); From 6634483b612bb7cde09aa5a5df079c2fe8a33422 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 16:26:56 +1100 Subject: [PATCH 5/6] mdbase: place fresh native schema sidecars beneath configured types --- src/services/MdbaseSpecService.ts | 35 +++++- tests/unit/services/MdbaseSpecService.test.ts | 111 ++++++++++++++++++ 2 files changed, 142 insertions(+), 4 deletions(-) diff --git a/src/services/MdbaseSpecService.ts b/src/services/MdbaseSpecService.ts index 7af3ba739..68dd4718b 100644 --- a/src/services/MdbaseSpecService.ts +++ b/src/services/MdbaseSpecService.ts @@ -18,6 +18,7 @@ import { import { TASKNOTES_SPEC_VERSION as TASKNOTES_CONTRACT_VERSION } from "@tasknotes/model"; import TaskNotesPlugin from "../main"; +import { getMdbaseMutationBackend } from "../core/VaultMutationService"; import { FieldMapping } from "../types"; import { UserMappedField } from "../types/settings"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; @@ -188,6 +189,19 @@ export class MdbaseSpecService { ); } + private usesNativeSchemaProfile(): boolean { + return getMdbaseMutationBackend()?.claimsResource("mdbase.yaml") === true; + } + + private async assertNativeSchemaProfileMigration(): Promise { + if (!this.usesNativeSchemaProfile()) return; + for (const name of ["tasknotes-task.schema.json", "tasknotes-task-binding.schema.json"]) { + if (await this.plugin.app.vault.adapter.exists(`_schemas/tasknotes/${name}`)) { + throw new Error("Existing TaskNotes schema sidecars require explicit migration before using the native resource profile."); + } + } + } + /** * Load an enabled v0.3 TaskNotes type before runtime services capture their * settings, upgrading clearly TaskNotes-owned v0.2 metadata first. @@ -198,6 +212,7 @@ export class MdbaseSpecService { } try { + await this.assertNativeSchemaProfileMigration(); await this.metadataIO.recoverSwaps(); await this.metadataTransaction.recover(); await this.recoverInterruptedV02Migration(); @@ -542,7 +557,7 @@ export class MdbaseSpecService { let recoveryFolder = "see pending record"; try { const parsed = JSON.parse(await adapter.read(MDBASE_MIGRATION_PENDING_PATH)) as unknown; - const journal = parseMigrationJournal(parsed); + const journal = parseMigrationJournal(parsed, this.usesNativeSchemaProfile()); recoveryFolder = journal.backupFolder; const configSnapshot = journal.snapshots.find(({ path }) => path === "mdbase.yaml"); const typeSnapshot = journal.snapshots.find(({ path }) => path.endsWith("/task.md")); @@ -1453,6 +1468,14 @@ export class MdbaseSpecService { private async writeCanonicalSupportResources( resources: TaskNotesMdbaseResources ): Promise { + await this.assertNativeSchemaProfileMigration(); + const backend = getMdbaseMutationBackend(); + if (backend?.claimsResource("mdbase.yaml")) { + const { config, type, contract, taskSchema, bindingSchema } = resources.paths; + if (![config, type, contract, taskSchema, bindingSchema].every((path) => backend.claimsResource(path))) { + throw new Error("Native canonical resources must be owned by the resource backend."); + } + } const entries = [ [resources.paths.contract, resources.contractDocument], [resources.paths.taskSchema, resources.taskSchemaDocument], @@ -1775,6 +1798,9 @@ export class MdbaseSpecService { typeName, typesFolder, contractsFolder, + // Native Core resources include every file beneath types/contracts. Keep + // both required JSON sidecars without inventing a reserved schema folder. + ...(this.usesNativeSchemaProfile() ? { schemasFolder: `${typesFolder}/tasknotes` } : {}), tasksFolder: settings.tasksFolder || "", legacyCompatibility, modelConfig: buildTaskNotesModelConfig(settings), @@ -2056,7 +2082,7 @@ function uniqueStrings(values: string[]): string[] { return [...new Set(values.filter((value) => value.length > 0))]; } -function parseMigrationJournal(value: unknown): MigrationJournal { +function parseMigrationJournal(value: unknown, nativeSchemaProfile: boolean): MigrationJournal { if (!isRecord(value) || typeof value.backupFolder !== "string") { throw new Error("The pending migration record is malformed."); } @@ -2104,12 +2130,13 @@ function parseMigrationJournal(value: unknown): MigrationJournal { DEFAULT_TYPES_FOLDER; const contractsFolder = normalizeJournalFolder(migratedConfig.settings?.contracts_folder) ?? DEFAULT_CONTRACTS_FOLDER; + const schemasFolder = nativeSchemaProfile ? `${typesFolder}/tasknotes` : "_schemas/tasknotes"; const expectedPaths = new Set([ "mdbase.yaml", `${typesFolder}/task.md`, `${contractsFolder}/tasknotes.task.md`, - "_schemas/tasknotes/tasknotes-task.schema.json", - "_schemas/tasknotes/tasknotes-task-binding.schema.json", + `${schemasFolder}/tasknotes-task.schema.json`, + `${schemasFolder}/tasknotes-task-binding.schema.json`, ]); if ( snapshotPaths.length !== expectedPaths.size || diff --git a/tests/unit/services/MdbaseSpecService.test.ts b/tests/unit/services/MdbaseSpecService.test.ts index c90902b86..cbd1aaf5b 100644 --- a/tests/unit/services/MdbaseSpecService.test.ts +++ b/tests/unit/services/MdbaseSpecService.test.ts @@ -14,6 +14,8 @@ import YAML from "yaml"; import { FieldMapper } from "../../../src/services/FieldMapper"; import { MdbaseSpecService } from "../../../src/services/MdbaseSpecService"; +import { setMdbaseMutationBackend } from "../../../src/core/VaultMutationService"; +import type { MdbaseMutationBackend, ResourceInput } from "../../../src/core/mdbase/MdbaseMutationBackend"; import { DEFAULT_FIELD_MAPPING, DEFAULT_NLP_TRIGGERS, @@ -1990,6 +1992,115 @@ describe("MdbaseSpecService", () => { }); }); + describe("native canonical schema resource profile", () => { + const nativePaths = ["mdbase.yaml", "_contracts/tasknotes.task.md", "_types/tasknotes/tasknotes-task.schema.json", "_types/tasknotes/tasknotes-task-binding.schema.json", "_types/task.md"]; + function installBackend(files?: Map) { + // Unit-only disposition/memory fixture, not a native publication oracle. + const backend = { + claimsResource: jest.fn((path: string) => path === "mdbase.yaml" || /^(?:_types|_contracts)\//.test(path)), + writeResources: jest.fn(async (ops: readonly ResourceInput[]) => { + for (const op of ops) { + if (op.kind === "put") files?.set(op.vaultPath, op.doc); + else files?.delete(op.vaultPath); + } + }), + }; + setMdbaseMutationBackend(backend as unknown as MdbaseMutationBackend); + return backend; + } + afterEach(() => setMdbaseMutationBackend(null)); + + it("keeps all five resources and preserves inline definitions and schema bytes", () => { + const service = new MdbaseSpecService(createMockPlugin()); + const legacy = (service as any).buildCanonicalMdbaseResources("_types", false); + installBackend(); + const native = (service as any).buildCanonicalMdbaseResources("_types", false); + expect([native.paths.config, native.paths.contract, native.paths.taskSchema, native.paths.bindingSchema, native.paths.type]).toEqual(nativePaths); + for (const key of ["typeDocument", "contractDocument", "taskSchemaDocument", "bindingSchemaDocument"]) expect(native[key]).toBe(legacy[key]); + }); + + it("uses the configured types folder, independently of the contracts folder", () => { + installBackend(); + const service = new MdbaseSpecService(createMockPlugin()); + const resource = (service as any).buildCanonicalMdbaseResources("System/Types", false, "task", "System/Contracts"); + expect(resource.paths.taskSchema).toBe("System/Types/tasknotes/tasknotes-task.schema.json"); + expect(resource.paths.bindingSchema).toBe("System/Types/tasknotes/tasknotes-task-binding.schema.json"); + expect(resource.paths.contract).toBe("System/Contracts/tasknotes.task.md"); + }); + + it("submits exactly five resources in one backend call and reads both JSON sidecars", async () => { + const plugin = createMockPlugin(); + const files = installMemoryVault(plugin, { "mdbase.yaml": YAML.stringify({ spec_version: "0.3.0", settings: { types_folder: "_types", contracts_folder: "_contracts" } }) }); + const backend = installBackend(files); + const service = new MdbaseSpecService(plugin); + await service.initialize(); + const resources = (service as any).buildCanonicalMdbaseResources("_types", false); + await (service as any).verifyMigratedV03Collection(resources.paths.type, resources); + expect(backend.writeResources).toHaveBeenCalledTimes(1); + expect(backend.writeResources.mock.calls[0][0].map(op => op.vaultPath).sort()).toEqual([...nativePaths].sort()); + for (const path of nativePaths.filter(path => path.endsWith(".json"))) { + expect(JSON.parse(files.get(path)!)["$schema"]).toBe("https://json-schema.org/draft/2020-12/schema"); + expect(plugin.app.vault.adapter.read).toHaveBeenCalledWith(path); + } + expect(files.has("_schemas/tasknotes/tasknotes-task.schema.json")).toBe(false); + expect(plugin.app.vault.adapter.write.mock.calls.some(([path]: [string]) => nativePaths.includes(path))).toBe(false); + }); + + it.each(["tasknotes-task.schema.json", "tasknotes-task-binding.schema.json"])("requires explicit migration for existing %s before any metadata writes", async name => { + const plugin = createMockPlugin(); + const entries = { "mdbase.yaml": "spec_version: 0.3.0\n", [`_schemas/tasknotes/${name}`]: "{\"custom\":true}" }; + const files = installMemoryVault(plugin, entries); + const backend = installBackend(files); + await new MdbaseSpecService(plugin).initialize(); + expect(Object.fromEntries(files)).toEqual(entries); + expect(backend.writeResources).not.toHaveBeenCalled(); + expect(plugin.app.vault.adapter.write).not.toHaveBeenCalled(); + expect(plugin.app.vault.adapter.remove).not.toHaveBeenCalled(); + }); + + it("refuses a native profile whose backend does not own every generated resource", async () => { + const plugin = createMockPlugin(); + const entries = { "mdbase.yaml": "spec_version: 0.3.0\n" }; + const files = installMemoryVault(plugin, entries); + const backend = installBackend(files); + backend.claimsResource.mockImplementation(path => path === "mdbase.yaml"); + await new MdbaseSpecService(plugin).initialize(); + expect(Object.fromEntries(files)).toEqual(entries); + expect(backend.writeResources).not.toHaveBeenCalled(); + }); + + it("accepts the exact native-profile pending file set before checking recovery copies", async () => { + const plugin = createMockPlugin(); + const config = v02Config(); + const snapshots = nativePaths.map(path => ({ path, content: path === "mdbase.yaml" ? config : path === "_types/task.md" ? "synthetic-type-snapshot" : null })); + const pending = ".tasknotes/migrations/mdbase-v0.2-pending.json"; + const journal = { backupFolder: ".tasknotes/migrations/test-profile", snapshots, intendedWrites: snapshots.map(s => ({ ...s, content: s.path === "mdbase.yaml" ? "spec_version: 0.3.0\n" : "synthetic-intended" })) }; + const entries = { "mdbase.yaml": config, [pending]: JSON.stringify(journal) }; + const files = installMemoryVault(plugin, entries); + const backend = installBackend(files); + // Deliberately absent recovery copy: acceptance must reach its read, + // not reject the schema namespace or perform any restoration. + await expect((new MdbaseSpecService(plugin) as any).recoverInterruptedV02Migration()).rejects.toThrow("Missing file: .tasknotes/migrations/test-profile/mdbase.yaml.bak"); + expect(Object.fromEntries(files)).toEqual(entries); + expect(backend.writeResources).not.toHaveBeenCalled(); + }); + + it("does not reinterpret an old-profile pending migration as a native-profile journal", async () => { + const plugin = createMockPlugin(); + const oldPaths = nativePaths.map(path => path.replace("_types/tasknotes/", "_schemas/tasknotes/")); + const config = v02Config(); + const snapshots = oldPaths.map(path => ({ path, content: path === "mdbase.yaml" ? config : null })); + const pending = ".tasknotes/migrations/mdbase-v0.2-pending.json"; + const journal = { backupFolder: ".tasknotes/migrations/test-profile", snapshots, intendedWrites: snapshots.map(s => ({ ...s, content: s.path === "mdbase.yaml" ? "spec_version: 0.3.0\n" : "synthetic-intended" })) }; + const entries = { "mdbase.yaml": config, [pending]: JSON.stringify(journal) }; + const files = installMemoryVault(plugin, entries); + const backend = installBackend(files); + await expect((new MdbaseSpecService(plugin) as any).recoverInterruptedV02Migration()).rejects.toThrow("outside TaskNotes metadata"); + expect(Object.fromEntries(files)).toEqual(entries); + expect(backend.writeResources).not.toHaveBeenCalled(); + }); + }); + describe("automatic TaskNotes v0.2 metadata migration", () => { it("replaces generated v0.2 metadata with a legacy-compatible canonical type without touching tasks", async () => { const plugin = createMockPlugin({ From 01cd5c66a46d47bfc87dcc393067742dbb858199 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 17:09:11 +1100 Subject: [PATCH 6/6] mdbase: route full TaskCreationService through shared write facade --- src/core/VaultMutationService.ts | 6 +- .../task-service/TaskCreationService.ts | 3 +- tests/unit/services/TaskService.test.ts | 58 +++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/core/VaultMutationService.ts b/src/core/VaultMutationService.ts index 9a47c22cf..adbed30a0 100644 --- a/src/core/VaultMutationService.ts +++ b/src/core/VaultMutationService.ts @@ -98,7 +98,11 @@ export async function processVaultFileWithinMutation( return app.vault.process(file, update); } -export async function createVaultFile(app: App, path: string, content: string): Promise { +export async function createVaultFile( + app: { vault: Pick }, + path: string, + content: string +): Promise { if (mdbaseBackend?.claims(path)) { const r = await mdbaseBackend.create(path, content); if (r.handled) { diff --git a/src/services/task-service/TaskCreationService.ts b/src/services/task-service/TaskCreationService.ts index 544d6c379..349799ba9 100644 --- a/src/services/task-service/TaskCreationService.ts +++ b/src/services/task-service/TaskCreationService.ts @@ -27,6 +27,7 @@ import { import type { UserMappedField } from "../../types/settings"; import { createTaskNotesLogger } from "../../utils/tasknotesLogger"; import { generateUuidV4 } from "../../utils/uuid"; +import { createVaultFile } from "../VaultMutationService"; const tasknotesLogger = createTaskNotesLogger({ tag: "Services/TaskService/TaskCreationService" }); @@ -348,7 +349,7 @@ export class TaskCreationService { content += `${normalizedBody}\n`; } - const file = await runtime.app.vault.create(fullPath, content); + const file = await createVaultFile(runtime.app, fullPath, content); const taskInfo: TaskInfo = { ...completeTaskData, diff --git a/tests/unit/services/TaskService.test.ts b/tests/unit/services/TaskService.test.ts index b988d9df1..dbd66d8a4 100644 --- a/tests/unit/services/TaskService.test.ts +++ b/tests/unit/services/TaskService.test.ts @@ -15,6 +15,8 @@ import { FileSystemFactory, PluginFactory, TaskFactory } from '../../helpers/moc import { MockObsidian, TFile } from '../../helpers/obsidian-runtime'; import { TaskCreationData, TaskService } from '../../../src/services/TaskService'; import { TaskInfo, TimeEntry } from '../../../src/types'; +import { setMdbaseMutationBackend } from '../../../src/core/VaultMutationService'; +import { MdbaseMutationBackend, type MdbaseWriteClient } from '../../../src/core/mdbase/MdbaseMutationBackend'; // Mock external dependencies jest.mock('../../../src/utils/dateUtils', () => { @@ -94,6 +96,22 @@ describe('TaskService', () => { taskService = new TaskService(mockPlugin); }); + afterEach(() => setMdbaseMutationBackend(null)); + + function managedCreateClient(): MdbaseWriteClient { + return { + collectionPath: path => path.startsWith('Tasks/') ? path : null, + isRecordPath: path => path.endsWith('.md'), + isResourcePath: () => false, + find: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ mutation: 'test-create' }), + update: jest.fn().mockResolvedValue({ mutation: 'test-update' }), + replaceDocument: jest.fn().mockResolvedValue({ mutation: 'test-replace' }), + resources: jest.fn().mockResolvedValue({ mutation: 'test-resource' }), + settle: jest.fn().mockResolvedValue(undefined), + }; + } + function getLastCreatedFrontmatter(): any { const createCalls = mockPlugin.app.vault.create.mock.calls; const content = createCalls[createCalls.length - 1]?.[1] as string; @@ -132,6 +150,46 @@ describe('TaskService', () => { } describe('createTask', () => { + it('routes the real TaskCreationService through the shared backend and waits for settle', async () => { + const client = managedCreateClient(); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + client.settle = jest.fn(() => gate); + setMdbaseMutationBackend(new MdbaseMutationBackend(client)); + const indexed = new TFile('Tasks/native-task.md'); + mockPlugin.app.vault.getFileByPath = jest.fn(() => indexed); + let complete = false; + const pending = taskService.createTask({ title: 'Native Task' }).then(result => { complete = true; return result; }); + for (let i = 0; i < 50 && !(client.settle as jest.Mock).mock.calls.length; i++) await Promise.resolve(); + expect(client.create).toHaveBeenCalledWith('Tasks/native-task.md', expect.stringContaining('---')); + expect(client.settle).toHaveBeenCalledWith({ mutation: 'test-create' }, 'Tasks/native-task.md'); + expect(complete).toBe(false); + expect(mockPlugin.app.vault.create).not.toHaveBeenCalled(); + expect(mockPlugin.emitter.trigger).not.toHaveBeenCalled(); + release(); + expect((await pending).file).toBe(indexed); + expect(mockPlugin.app.vault.create).not.toHaveBeenCalled(); + }); + + it('propagates managed create rejection without a vault fallback or success event', async () => { + const client = managedCreateClient(); + (client.create as jest.Mock).mockRejectedValue(new Error('typed managed rejection')); + setMdbaseMutationBackend(new MdbaseMutationBackend(client)); + await expect(taskService.createTask({ title: 'Native Task' })).rejects.toThrow('typed managed rejection'); + expect(mockPlugin.app.vault.create).not.toHaveBeenCalled(); + expect(mockPlugin.emitter.trigger).not.toHaveBeenCalled(); + expect(client.settle).not.toHaveBeenCalled(); + }); + + it('does not acknowledge an unsettled managed create when indexing is missing', async () => { + const client = managedCreateClient(); + setMdbaseMutationBackend(new MdbaseMutationBackend(client)); + mockPlugin.app.vault.getFileByPath = jest.fn(() => null); + await expect(taskService.createTask({ title: 'Native Task' })).rejects.toThrow('has not indexed it yet'); + expect(mockPlugin.app.vault.create).not.toHaveBeenCalled(); + expect(mockPlugin.emitter.trigger).not.toHaveBeenCalled(); + }); + it('should create a basic task with minimal data', async () => { const taskData: TaskCreationData = { title: 'Test Task'