diff --git a/src/core/VaultMutationService.ts b/src/core/VaultMutationService.ts index 647ce0275..adbed30a0 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,26 @@ 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 { +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) { + 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 +119,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..c6680295e --- /dev/null +++ b/src/core/mdbase/MdbaseMutationBackend.ts @@ -0,0 +1,252 @@ +/** + * 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 + * (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 + * 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, non-record files (backups, exports), and writes while + * no runtime is attached keep using Obsidian's APIs unchanged. + * + * **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. */ +export interface MdbaseRecord { + readonly id: string; + readonly path: string; + readonly frontmatter: Record; + /** The whole source, when read with `document`. */ + 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; +} + +/** + * 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; + /** + * 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; + /** 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`. */ + 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 }; +} + +/** 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) {} + + /** 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; + 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) { + 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); + 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; + 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) { + 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); + 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; + } + + /** 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); + 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/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/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/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/core/mdbase/MdbaseMutationBackend.test.ts b/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts new file mode 100644 index 000000000..80f66bd72 --- /dev/null +++ b/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts @@ -0,0 +1,207 @@ +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[] = []; + isRecordPath(path: string) { + return path.endsWith(".md") && !path.startsWith(".tasknotes-backups/"); + } + 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" }; + } + isResourcePath(_path: string) { + return false; + } + async resources() { + return { mutation: "x" }; + } + 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 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/.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"); + 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(); + await processVaultFrontMatter(a as never, { path: "Tasks/t.md" } as TFile, (fm) => (fm.a = 1)); + expect(a.fileManager.processFrontMatter).toHaveBeenCalledTimes(1); + }); +}); 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([]); + }); +}); 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({ 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'