From a5cc74273c7e7da91492e49a8d40f4583e495356 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Mon, 5 Oct 2026 00:23:24 +1100 Subject: [PATCH] Add inactive shared-runtime mutation seam for plugin-owned writes --- docs/mdbase-next-mutation-seam.md | 35 +++++++ main.ts | 54 ++++++++++- src/commands.ts | 8 +- src/connectSync.ts | 11 +++ src/mdbaseMutationBackend.ts | 68 ++++++++++++++ test/mdbaseMutationBackend.test.ts | 141 +++++++++++++++++++++++++++++ 6 files changed, 308 insertions(+), 9 deletions(-) create mode 100644 docs/mdbase-next-mutation-seam.md create mode 100644 src/mdbaseMutationBackend.ts create mode 100644 test/mdbaseMutationBackend.test.ts diff --git a/docs/mdbase-next-mutation-seam.md b/docs/mdbase-next-mutation-seam.md new file mode 100644 index 0000000..9aadc1b --- /dev/null +++ b/docs/mdbase-next-mutation-seam.md @@ -0,0 +1,35 @@ +# Shared-runtime mutation seam (inactive) + +The plugin still uses today's Connect implementation. No setting, command, saved +profile or startup path installs the new backend in this change. + +`MdbaseMutationBackend` accepts the structural interface of the shared runtime's +`SdkWriteClient`. Its successful calls must mean **durably published to the vault**, +not simply submitted or accepted by the log. Rejected or held writes propagate +without direct-vault fallback. Creates additionally wait for Obsidian's index. + +The internal `setMdbaseMutationBackend` hook routes these plugin-owned writes: + +- typed-note creates; +- single/bulk quick fixes, transformed against the replica's document view; +- type creates (`must_not_exist`) and same-path edits (exact-content base revision). + +Existing mirror/adoption profiles and in-flight Connect operations prevent +attachment. Attached backends block subsequent old Connect operations. Pack +installation, collection initialization, v0.2 migration and type rename are not +ported yet and explicitly refuse to write while attached. Type rename must use +the runtime's rename planner rather than file-manager rename and rollback. + +## Activation remains a separate change + +Do not expose the attachment hook as an end-user feature yet. The full port must: + +1. attach to the process-wide shared registry and handle rehome/lost events; +2. wire the real WASM Worker, sqlite index, dual journal and editor fence; +3. use the SDK transport's `wait: published` and resource `mustNotExist` support; +4. replace the old sync/adoption/recovery UI and finish packs/initialization/rename; +5. migrate existing profiles safely and validate collection-path classification; +6. test both plugins loaded together and runtime shutdown/detachment during writes. + +The existing controller is not replaced here. This is a tested write-path seam, +not a working new sync engine, release or migration. diff --git a/main.ts b/main.ts index 1b8658b..f121206 100644 --- a/main.ts +++ b/main.ts @@ -1,5 +1,6 @@ import { ContractCatalogModal, recoverPackInstall } from "./src/contractCatalog"; import { ValidationState } from "./src/validationState"; +import { MdbaseMutationBackend } from "./src/mdbaseMutationBackend"; import { createNoteFromTypeCommand } from "./src/commands"; import { applyQuickFixToDocument, quickFixLabel } from "./src/quickFix"; import { @@ -116,6 +117,7 @@ export interface MdbaseObsidianApiV1 { export default class MdbasePlugin extends Plugin { readonly api: MdbaseObsidianApiV1; readonly connectSync: ConnectSyncController; + private mutationBackend: MdbaseMutationBackend | null = null; settings: MdbasePluginSettings; private issueMap = new Map(); private readonly validation = new ValidationState(); @@ -170,7 +172,7 @@ export default class MdbasePlugin extends Plugin { else this.requestSync(); }, deviceId: () => this.deviceId(), - }); + }, { assertLegacyRuntime: () => this.assertLegacyOperation("Old Connect operations") }); this.interopBridge = new ObsidianInteropBridge(app, () => this.settings?.interopEnabled === true); this.api = { apiVersion: 1, @@ -182,6 +184,37 @@ export default class MdbasePlugin extends Plugin { }; } + /** Internal seam only; runtime attachment/activation is a separate follow-up. */ + setMdbaseMutationBackend(backend: MdbaseMutationBackend | null): void { + this.connectSync.assertIdle(); + if (backend && (this.getMirrorProfile() || this.connectSync.getAdoptionMarker())) { + throw new Error("Disconnect or complete migration from the old Connect runtime before attaching mdbase next."); + } + this.mutationBackend = backend; + } + + private assertLegacyOperation(operation: string): void { + if (this.mutationBackend) throw new Error(`${operation} is not yet available through the shared runtime.`); + } + + async createTypedNote(path: string, frontmatter: Record): Promise { + if (this.mutationBackend) { + return this.mutationBackend.create(this.app.vault, path, `${formatMarkdown(frontmatter, "")}\n`); + } + this.connectSync.assertLocalAuthorityWritable(); + if (this.getMirrorProfile()?.mode === "read_only") throw new Error("This mirror has read-only access."); + const { createNoteFromType } = await import("./src/mdbaseCore"); + return createNoteFromType(this.app.vault, path, frontmatter); + } + + private async transformRecord(file: TFile, transform: (raw: string) => string): Promise { + if (this.mutationBackend) { + await this.mutationBackend.transform(file.path, transform); + } else { + await this.app.vault.process(file, transform); + } + } + async onload(): Promise { await this.loadSettings(); await this.connectSync.initialize(); @@ -539,6 +572,7 @@ export default class MdbasePlugin extends Plugin { cancelValidation(): void { this.validationAbort?.abort(); } async openContractCatalog(): Promise { + this.assertLegacyOperation("Installing packs"); if (this.getMirrorProfile()) throw new Error("Install packs at the hosted collection authority using mdbase editor."); const loaded = await this.getConfigAndTypes(); if (!loaded || !loaded.config.spec_version.startsWith("0.3.")) throw new Error("Initialize or migrate this collection first."); @@ -562,6 +596,7 @@ export default class MdbasePlugin extends Plugin { } async initializeCollection(): Promise { + this.assertLegacyOperation("Initializing collections"); this.connectSync.assertLocalAuthorityWritable(); const { created } = await ensureCollectionInitialized(this.app.vault, { seedNoteType: false }); this.invalidateSchemaCache(); @@ -581,6 +616,7 @@ export default class MdbasePlugin extends Plugin { } async applyMigration(plan: V02MigrationPlan, allowLossy: boolean): Promise { + this.assertLegacyOperation("Migrating v0.2 collections"); this.connectSync.assertLocalAuthorityWritable(); if (this.getMirrorProfile()) { throw new Error("Collection authority resources must be migrated at the collection authority."); @@ -616,7 +652,7 @@ export default class MdbasePlugin extends Plugin { if (this.getMirrorProfile()?.mode === "read_only") throw new Error("This mirror has read-only access."); let changed = false; - await this.app.vault.process(file, (raw) => { + await this.transformRecord(file, (raw) => { this.connectSync.assertLocalAuthorityWritable(); const result = applyQuickFixToDocument(raw, issue); changed = result.changed; @@ -639,7 +675,7 @@ export default class MdbasePlugin extends Plugin { continue; } let applied = false; - await this.app.vault.process(file, (raw) => { + await this.transformRecord(file, (raw) => { this.connectSync.assertLocalAuthorityWritable(); const result = applyQuickFixToDocument(raw, issue); applied = result.changed; @@ -1217,6 +1253,11 @@ export default class MdbasePlugin extends Plugin { const defaultTargetPath = normalizePath(`${typesFolder}/${typeName}.md`); if (!existingFile) { + if (this.mutationBackend) { + const created = await this.mutationBackend.putResource(this.app.vault, defaultTargetPath, content, null); + this.invalidateSchemaCache(); + return created; + } await this.ensureFolderExists(typesFolder); if (await this.app.vault.adapter.exists(defaultTargetPath)) { @@ -1244,6 +1285,13 @@ export default class MdbasePlugin extends Plugin { throw new Error(`Cannot rename type file to ${targetPath}; file already exists.`); } + if (this.mutationBackend) { + if (targetPath !== originalPath) throw new Error("Type renames need the shared runtime's rename planner; no files were changed."); + const updated = await this.mutationBackend.putResource(this.app.vault, originalPath, content, originalContent); + this.invalidateSchemaCache(); + return updated; + } + let renamed = false; try { if (targetPath !== existingFile.path) { diff --git a/src/commands.ts b/src/commands.ts index 14ed220..19525fe 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -2,7 +2,6 @@ import { Notice, TFile } from "obsidian"; import { CreateTypedNoteModal } from "./createTypedNoteModal"; import type MdbasePlugin from "../main"; import { - ensureCollectionInitialized, getTypesForFile, parseFrontmatter, type MdbaseTypeDef, @@ -163,9 +162,7 @@ export async function syncNow(plugin: MdbasePlugin): Promise { async function initializeCollection(plugin: MdbasePlugin): Promise { plugin.connectSync.assertLocalAuthorityWritable(); - const { created } = await ensureCollectionInitialized(plugin.app.vault, { seedNoteType: false }); - plugin.invalidateSchemaCache(); - new Notice(created.length ? `Initialized mdbase collection: ${created.join(", ")}` : "mdbase collection already initialized."); + await plugin.initializeCollection(); } /** Edits the type of the open note (or the open type file); otherwise asks which type. */ @@ -225,8 +222,7 @@ export async function createNoteFromTypeCommand(plugin: MdbasePlugin, typeName?: new CreateTypedNoteModal(plugin.app, chosenType, loaded.config, loaded.types, async (path, frontmatter) => { plugin.connectSync.assertLocalAuthorityWritable(); if (plugin.getMirrorProfile()?.mode === "read_only") throw new Error("This mirror has read-only access."); - const { createNoteFromType } = await import("./mdbaseCore"); - const file = await createNoteFromType(plugin.app.vault, path, frontmatter); + const file = await plugin.createTypedNote(path, frontmatter); await plugin.app.workspace.getLeaf(true).openFile(file); await plugin.validateFileAndStore(file, "manual"); }).open(); diff --git a/src/connectSync.ts b/src/connectSync.ts index 0faf994..7479abc 100644 --- a/src/connectSync.ts +++ b/src/connectSync.ts @@ -1664,6 +1664,8 @@ export class DeviceMirrorLease implements MirrorLease { } export interface ConnectSyncControllerOptions { + /** Fail closed before any old-runtime network/adoption operation. */ + assertLegacyRuntime?: () => void; stateStoreFactory?: (profile: MirrorProfile) => MirrorStateStore; blobStoreFactory?: (profile: MirrorProfile) => MirrorBlobStore; adoptionBlobStoreFactory?: (collectionId: string) => MirrorBlobStore; @@ -1693,8 +1695,16 @@ export class ConnectSyncController { this.blobStores.clear(); } + private activeOperations = 0; + + assertIdle(): void { + if (this.activeOperations !== 0) throw new Error("Wait for the old Connect operation to finish before attaching mdbase next."); + } + private async withLifetime(signal: AbortSignal | undefined, operation: (signal: AbortSignal) => Promise): Promise { this.assertActive(); + this.options.assertLegacyRuntime?.(); + this.activeOperations += 1; const controller = new AbortController(); const abort = () => controller.abort(); this.lifetime.signal.addEventListener("abort", abort, { once: true }); @@ -1704,6 +1714,7 @@ export class ConnectSyncController { abortIfNeeded(controller.signal); return await operation(controller.signal); } finally { + this.activeOperations -= 1; this.lifetime.signal.removeEventListener("abort", abort); signal?.removeEventListener("abort", abort); } diff --git a/src/mdbaseMutationBackend.ts b/src/mdbaseMutationBackend.ts new file mode 100644 index 0000000..b2bb426 --- /dev/null +++ b/src/mdbaseMutationBackend.ts @@ -0,0 +1,68 @@ +import { TFile, type Vault } from "obsidian"; +import { normalizeSafeRelativePath } from "./mdbaseCore"; + +/** Structural subset of the shared runtime's SdkWriteClient; no unpublished dependency. */ +export interface MdbaseWriteClient { + collectionPath(vaultPath: string): string | null; + isRecordPath(path: string): boolean; + isResourcePath(path: string): boolean; + find(path: string, options: { "document": boolean }): Promise; + replaceDocument(record: MdbaseRecord, document: string): Promise<{ mutation: string }>; + create(path: string, document: string): Promise<{ mutation: string }>; + resources(ops: Array<{ kind: "put"; path: string; doc: string; base: string | null }>): Promise<{ mutation: string }>; + settle(write: { mutation: string }, vaultPath: string): Promise; +} + +export interface MdbaseRecord { + readonly id: string; + readonly path: string; + readonly frontmatter: Record; + readonly "document"?: string; +} + +/** + * Opt-in mutation seam for the next runtime. Successful writes mean published to + * the vault (not merely accepted by a log). Rejection/holds propagate; NEVER + * fall back to direct vault writes when attached. No plugin setting enables it. + */ +export class MdbaseMutationBackend { + constructor(private readonly client: MdbaseWriteClient) {} + + private path(vaultPath: string, resource: boolean): string { + const path = this.client.collectionPath(normalizeSafeRelativePath(vaultPath)); + if (path === null || !(resource ? this.client.isResourcePath(path) : this.client.isRecordPath(path))) { + throw new Error(`Path is not a collection ${resource ? "resource" : "record"}: ${vaultPath}`); + } + return path; + } + + async transform(vaultPath: string, transform: (document: string) => string): Promise { + const record = await this.client.find(this.path(vaultPath, false), { document: true }); + if (!record || record.document === undefined) throw new Error(`Record document unavailable: ${vaultPath}`); + const next = transform(record.document); + if (next === record.document) return; + const write = await this.client.replaceDocument(record, next); + await this.client.settle(write, vaultPath); + } + + async create(vault: Vault, vaultPath: string, document: string): Promise { + const path = this.path(vaultPath, false); + const write = await this.client.create(path, document); + await this.client.settle(write, vaultPath); + return this.file(vault, vaultPath); + } + + async putResource(vault: Vault, vaultPath: string, document: string, base: string | null): Promise { + const path = this.path(vaultPath, true); + // null means must_not_exist; an edit carries the revision of these exact bytes. + const write = await this.client.resources([{ kind: "put", path, doc: document, base }]); + await this.client.settle(write, vaultPath); + return this.file(vault, vaultPath); + } + + private file(vault: Vault, path: string): TFile { + const file = vault.getAbstractFileByPath(path); + if (!(file instanceof TFile)) throw new Error(`Published file not indexed: ${path}`); + return file; + } +} diff --git a/test/mdbaseMutationBackend.test.ts b/test/mdbaseMutationBackend.test.ts new file mode 100644 index 0000000..aa9bc0d --- /dev/null +++ b/test/mdbaseMutationBackend.test.ts @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Vault } from "obsidian"; +import { MdbaseMutationBackend, type MdbaseWriteClient } from "../src/mdbaseMutationBackend"; +import MdbasePlugin from "../main"; +import { parseFrontmatter } from "../src/mdbaseCore"; +import { MemoryVault } from "./memoryVault"; + +function fixture() { + const vault = new MemoryVault(); + const calls: unknown[][] = []; + const record = { id: "record", path: "note.md", frontmatter: {}, document: "runtime bytes" }; + let fail: Error | null = null; + const write = { mutation: "m" }; + const client: MdbaseWriteClient = { + collectionPath: path => path.startsWith("Notes/") ? path.slice(6) : null, + isRecordPath: path => path === "note.md", + isResourcePath: path => path === "_types/note.md", + find: async (path, opts) => { calls.push(["find", path, opts]); return record; }, + replaceDocument: async (seen, doc) => { + calls.push(["replace", seen, doc]); + if (fail) throw fail; + return write; + }, + create: async (path, doc) => { calls.push(["create", path, doc]); if (fail) throw fail; return write; }, + resources: async ops => { calls.push(["resources", ops]); if (fail) throw fail; return write; }, + settle: async (receipt, path) => { calls.push(["settle", receipt, path]); }, + }; + const backend = new MdbaseMutationBackend(client); + return { vault, calls, record, client, backend, fail: (error: Error) => { fail = error; } }; +} + +test("document transforms read the replica view, preserving the client's base token", async () => { + const f = fixture(); + await f.vault.create("Notes/note.md", "stale vault bytes"); + await f.backend.transform("Notes/note.md", raw => `${raw}!`); + assert.equal(f.calls[1]?.[1], f.record); + assert.equal(f.calls[1]?.[2], "runtime bytes!"); + assert.equal(f.calls[2]?.[0], "settle"); + assert.equal(f.vault.read("Notes/note.md"), "stale vault bytes", "backend never writes directly"); +}); + +test("a no-op does not submit; a missing document fails rather than reading stale vault bytes", async () => { + const f = fixture(); + await f.backend.transform("Notes/note.md", raw => raw); + assert.equal(f.calls.length, 1); + f.client.find = async () => null; + await assert.rejects(f.backend.transform("Notes/note.md", () => "new"), /unavailable/); + f.client.find = async () => ({ id: "r", path: "note.md", frontmatter: {} }); + await assert.rejects(f.backend.transform("Notes/note.md", () => "new"), /unavailable/); +}); + +test("create waits for publication/indexing and never calls vault.create as fallback", async () => { + const f = fixture(); + const file = await f.vault.create("Notes/note.md", "published by host"); + assert.equal(await f.backend.create(f.vault as unknown as Vault, file.path, "new"), file); + assert.deepEqual(f.calls.map(call => call[0]), ["create", "settle"]); + f.fail(new Error("held")); + await assert.rejects(f.backend.create(f.vault as unknown as Vault, file.path, "new"), /held/); + assert.equal(f.vault.read(file.path), "published by host"); +}); + +test("resource creation carries null (must-not-exist); edits carry exact base bytes", async () => { + const f = fixture(); + const path = "Notes/_types/note.md"; + await f.vault.create(path, "host-published type"); + for (const base of [null, "original type bytes"]) { + await f.backend.putResource(f.vault as unknown as Vault, path, "new type", base); + assert.deepEqual(f.calls[f.calls.length - 2], ["resources", [{ kind: "put", path: "_types/note.md", doc: "new type", base }]]); + } + f.fail(new Error("conflict")); + await assert.rejects(f.backend.putResource(f.vault as unknown as Vault, path, "new type", "old"), /conflict/); + assert.equal(f.vault.read(path), "host-published type"); +}); + +test("outside collection and wrong path class never reach a mutation", async () => { + const f = fixture(); + await assert.rejects(f.backend.transform("Elsewhere/note.md", raw => raw), /not a collection/); + await assert.rejects(f.backend.transform("Notes/_types/note.md", raw => raw), /not a collection/); + await assert.rejects(f.backend.create(f.vault as unknown as Vault, "Notes/image.png", "bytes"), /not a collection/); + await assert.rejects(f.backend.putResource(f.vault as unknown as Vault, "Notes/note.md", "new", null), /not a collection/); + assert.deepEqual(f.calls, []); +}); + +test("a published create without an indexed TFile fails without attempting another create", async () => { + const f = fixture(); + await assert.rejects(f.backend.create(f.vault as unknown as Vault, "Notes/note.md", "new"), /not indexed/); + assert.equal(f.vault.files.size, 0); +}); + +function pluginFixture() { + const f = fixture(); + const plugin = new MdbasePlugin({ vault: f.vault, workspace: { getLeavesOfType: () => [] } } as never, { id: "mdbase", version: "test" } as never); + plugin.settings = { mirrorProfile: null, autoSync: false, typeDrafts: {}, archivedTypeDrafts: [], validateOnSave: false, validateOnOpen: false, showNoticeOnSave: false, interopEnabled: false }; + return { ...f, plugin }; +} + +test("plugin defaults to legacy creates; explicit backend attachment routes typed-note creation", async () => { + const f = pluginFixture(); + await f.plugin.createTypedNote("legacy.md", { type: "note" }); + assert.equal(parseFrontmatter(f.vault.read("legacy.md") ?? "").frontmatter.type, "note"); + assert.deepEqual(f.calls, []); + await f.vault.create("Notes/note.md", "host bytes"); + f.plugin.setMdbaseMutationBackend(f.backend); + await f.plugin.createTypedNote("Notes/note.md", { type: "note" }); + assert.equal(f.calls[0]?.[0], "create"); + assert.equal(f.vault.read("Notes/note.md"), "host bytes"); + f.plugin.connectSync.dispose(); +}); + +test("attached runtime blocks unported disk migrations/packs and legacy enrolment", async () => { + const f = pluginFixture(); + f.plugin.setMdbaseMutationBackend(f.backend); + await assert.rejects(f.plugin.initializeCollection(), /not yet available/); + await assert.rejects(f.plugin.openContractCatalog(), /not yet available/); + await assert.rejects(f.plugin.applyMigration({} as never, false), /not yet available/); + await assert.rejects(f.plugin.connectSync.enroll({} as never, {} as never), /not yet available/); + await assert.rejects(f.plugin.connectSync.adoptLocalCollection({} as never, {} as never), /not yet available/); + assert.equal(f.vault.files.size, 0); + f.plugin.connectSync.dispose(); +}); + +test("in-flight legacy work prevents attachment until its finally block runs", async () => { + const f = pluginFixture(); + let finish: () => void = () => {}; + const pending = new Promise(resolve => { finish = resolve; }); + const internal = f.plugin.connectSync as unknown as { withLifetime(signal: undefined, operation: () => Promise): Promise }; + const operation = internal.withLifetime(undefined, () => pending); + assert.throws(() => f.plugin.setMdbaseMutationBackend(f.backend), /Wait for the old Connect operation/); + finish(); + await operation; + f.plugin.setMdbaseMutationBackend(f.backend); + f.plugin.connectSync.dispose(); +}); + +test("a configured old mirror prevents backend attachment", () => { + const f = pluginFixture(); + f.plugin.settings.mirrorProfile = { version: 1 } as never; + assert.throws(() => f.plugin.setMdbaseMutationBackend(f.backend), /Disconnect/); + f.plugin.connectSync.dispose(); +});