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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/mdbase-next-mutation-seam.md
Original file line number Diff line number Diff line change
@@ -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.
54 changes: 51 additions & 3 deletions main.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<string, MdbaseIssue[]>();
private readonly validation = new ValidationState();
Expand Down Expand Up @@ -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,
Expand All @@ -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<string, unknown>): Promise<TFile> {
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<void> {
if (this.mutationBackend) {
await this.mutationBackend.transform(file.path, transform);
} else {
await this.app.vault.process(file, transform);
}
}

async onload(): Promise<void> {
await this.loadSettings();
await this.connectSync.initialize();
Expand Down Expand Up @@ -539,6 +572,7 @@ export default class MdbasePlugin extends Plugin {
cancelValidation(): void { this.validationAbort?.abort(); }

async openContractCatalog(): Promise<void> {
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.");
Expand All @@ -562,6 +596,7 @@ export default class MdbasePlugin extends Plugin {
}

async initializeCollection(): Promise<void> {
this.assertLegacyOperation("Initializing collections");
this.connectSync.assertLocalAuthorityWritable();
const { created } = await ensureCollectionInitialized(this.app.vault, { seedNoteType: false });
this.invalidateSchemaCache();
Expand All @@ -581,6 +616,7 @@ export default class MdbasePlugin extends Plugin {
}

async applyMigration(plan: V02MigrationPlan, allowLossy: boolean): Promise<void> {
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.");
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 2 additions & 6 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Notice, TFile } from "obsidian";
import { CreateTypedNoteModal } from "./createTypedNoteModal";
import type MdbasePlugin from "../main";
import {
ensureCollectionInitialized,
getTypesForFile,
parseFrontmatter,
type MdbaseTypeDef,
Expand Down Expand Up @@ -163,9 +162,7 @@ export async function syncNow(plugin: MdbasePlugin): Promise<void> {

async function initializeCollection(plugin: MdbasePlugin): Promise<void> {
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. */
Expand Down Expand Up @@ -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();
Expand Down
11 changes: 11 additions & 0 deletions src/connectSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<T>(signal: AbortSignal | undefined, operation: (signal: AbortSignal) => Promise<T>): Promise<T> {
this.assertActive();
this.options.assertLegacyRuntime?.();
this.activeOperations += 1;
const controller = new AbortController();
const abort = () => controller.abort();
this.lifetime.signal.addEventListener("abort", abort, { once: true });
Expand All @@ -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);
}
Expand Down
68 changes: 68 additions & 0 deletions src/mdbaseMutationBackend.ts
Original file line number Diff line number Diff line change
@@ -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<MdbaseRecord | null>;
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<void>;
}

export interface MdbaseRecord {
readonly id: string;
readonly path: string;
readonly frontmatter: Record<string, unknown>;
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<void> {
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<TFile> {
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<TFile> {
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;
}
}
Loading
Loading