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
244 changes: 120 additions & 124 deletions main.js

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1095,12 +1095,18 @@ export default class MdbasePlugin extends Plugin {
return;
}
try {
const status = await this.connectSync.sync();
const reviewed = await this.connectSync.preview();
const outcome = await this.connectSync.sync(reviewed);
const status = await this.connectSync.status();
this.invalidateSchemaCache();
this.refreshWorkspaceViews(true);
const attentionCount = status.conflicts.length + status.local_issues.length;
const attentionCount = (status?.conflicts.length ?? 0) + (status?.local_issues.length ?? 0);
new Notice(
attentionCount
outcome.status === "stale"
? "The sync plan changed before it could apply. Review the new plan in the mdbase workspace."
: outcome.status === "failed" || outcome.status === "blocked"
? `mdbase sync stopped safely: ${outcome.failure?.message ?? outcome.status}.`
: attentionCount
? `Sync completed with ${attentionCount} item${attentionCount === 1 ? "" : "s"} needing attention.`
: "mdbase sync completed.",
);
Expand Down
24 changes: 14 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
},
"dependencies": {
"@callumalpass/mdbase-interop": "0.1.0-rc.2",
"@mdbase-dev/connect-protocol": "0.1.0-beta.23",
"@mdbase-dev/connect-sync": "0.1.0-beta.23",
"@mdbase-dev/connect-protocol": "0.1.0-beta.40",
"@mdbase-dev/connect-sync": "0.1.0-beta.40",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"picomatch": "^4.0.5"
Expand Down
5 changes: 4 additions & 1 deletion scripts/check-mobile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { readFile } from "node:fs/promises";
const bundle = await readFile(new URL("../main.js", import.meta.url));
const source = bundle.toString("utf8");
const gzipBytes = gzipSync(bundle).byteLength;
const rawBudget = 575 * 1024;
// Canonical plan rendering and structured apply/cancellation outcomes replace
// the old initialization-only preview. Keep gzip fixed while allowing the
// bounded engine-plan UI and vacancy-checked move adapter 17 KiB of raw room.
const rawBudget = 592 * 1024;
const gzipBudget = 170 * 1024;
const forbidden = [
/require\((["'])node:(?:fs|path|crypto|os|worker_threads|child_process)\1\)/,
Expand Down
49 changes: 42 additions & 7 deletions src/connectSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ import {
} from "@mdbase-dev/connect-sync/adoption";
import {
DirectoryMirror,
type MirrorApplyResult,
type DirectoryMirrorOptions,
type MirrorFileSystem,
type MirrorInitializationPreview,
type MirrorLease,
type MirrorProgress,
type MirrorState,
Expand All @@ -60,6 +60,7 @@ import {
loadMdbaseConfig,
normalizeSafeRelativePath,
} from "./mdbaseCore";
import { type MdbaseSyncPreview, previewFromPlan } from "./syncPreview";

export interface MirrorProfile {
version: 1;
Expand Down Expand Up @@ -107,7 +108,7 @@ export interface ConnectSyncSettingsHost {
const ROLE_MARKER_PATH = ".mdbase/connect-role.json";
const ADOPTION_MARKER_PATH = ".mdbase/authority-adoption.json";
const ADOPTION_SNAPSHOT_PATH = ".mdbase/authority-adoption-snapshot.json";
const STATE_DATABASE = "mdbase-obsidian-connect";
const STATE_DATABASE = "mdbase-obsidian-connect-exact-v1";
const STATE_STORE = "mirrors";
const ACCESS_SECRET_PREFIX = "mdbase-connect-access-";
const REFRESH_SECRET_PREFIX = "mdbase-connect-refresh-";
Expand Down Expand Up @@ -387,6 +388,12 @@ async function ensureFolder(vault: Vault, path: string): Promise<void> {
export class ObsidianMirrorFileSystem implements MirrorFileSystem {
constructor(private readonly vault: Vault) {}

async exists(input: string): Promise<boolean> {
const path = safeMirrorPath(input);
return this.vault.getAbstractFileByPath(path) !== null
|| await this.vault.adapter.exists(path);
}

async read(input: string): Promise<string | null> {
const path = safeMirrorPath(input);
const file = this.vault.getAbstractFileByPath(path);
Expand All @@ -412,6 +419,21 @@ export class ObsidianMirrorFileSystem implements MirrorFileSystem {
}
}

async move(sourceInput: string, targetInput: string): Promise<void> {
const source = safeMirrorPath(sourceInput);
const target = safeMirrorPath(targetInput);
const slash = target.lastIndexOf("/");
if (slash >= 0) await ensureFolder(this.vault, target.slice(0, slash));
const sourceFile = this.vault.getAbstractFileByPath(source);
if (!(sourceFile instanceof TFile)) {
throw new SyncError("mirror_path_collision", `Expected a file at ${source}.`);
}
if (this.vault.getAbstractFileByPath(target) !== null || await this.vault.adapter.exists(target)) {
throw new SyncError("mirror_path_collision", `A file or folder blocks ${target}.`);
}
await this.vault.rename(sourceFile, target);
}

async remove(input: string): Promise<void> {
const path = safeMirrorPath(input);
const existing = this.vault.getAbstractFileByPath(path);
Expand Down Expand Up @@ -574,6 +596,7 @@ export interface ConnectSyncControllerOptions {

export class ConnectSyncController {
private progress: MirrorProgress | null = null;
private syncAbort: AbortController | null = null;
private readonly fileSystem: MirrorFileSystem;
private readonly enrollmentClient: MirrorEnrollmentClient;
private readonly adoptionClient: AuthorityAdoptionClient;
Expand Down Expand Up @@ -607,6 +630,10 @@ export class ConnectSyncController {
return this.progress ? { ...this.progress } : null;
}

cancelSync(): void {
this.syncAbort?.abort();
}

getAdoptionMarker(): Readonly<AdoptionMarker> | null {
return this.adoptionMarker ? JSON.parse(JSON.stringify(this.adoptionMarker)) as AdoptionMarker : null;
}
Expand Down Expand Up @@ -727,9 +754,9 @@ export class ConnectSyncController {
return this.requireProfile();
}

async preview(): Promise<MirrorInitializationPreview> {
async preview(): Promise<MdbaseSyncPreview> {
const mirror = await this.createMirror();
return mirror.previewInitialization();
return previewFromPlan(await mirror.inspect());
}

async status(): Promise<MirrorStatus | null> {
Expand All @@ -740,16 +767,24 @@ export class ConnectSyncController {
return mirror.status();
}

async sync(onProgress?: (progress: MirrorProgress) => void): Promise<MirrorStatus> {
async sync(
reviewed: MdbaseSyncPreview,
onProgress?: (progress: MirrorProgress) => void,
): Promise<MirrorApplyResult> {
if (this.syncAbort) {
throw new SyncError("mirror_busy", "Synchronization is already running for this vault.");
}
const abort = new AbortController();
this.syncAbort = abort;
const mirror = await this.createMirror((next) => {
this.progress = next;
onProgress?.({ ...next });
});
try {
await mirror.sync();
return mirror.status();
return await mirror.apply(reviewed.plan, { signal: abort.signal });
} finally {
this.progress = null;
this.syncAbort = null;
}
}

Expand Down
119 changes: 119 additions & 0 deletions src/syncPreview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type {
MirrorInitializationPreview,
MirrorPlanAction,
MirrorSyncPlan,
} from "@mdbase-dev/connect-sync/mirror";

export type SyncPreviewDirection = "download" | "upload" | "attention";
export type SyncPreviewAction = "create" | "update" | "rename" | "delete" | "replace" | "fix";

export interface SyncPreviewEntry {
kind: "document" | "file";
path: string;
direction: SyncPreviewDirection;
action: SyncPreviewAction;
detail: string;
recordId?: string;
fileId?: string;
}

/** UI projection of the engine-owned plan. It contains no independent diff logic. */
export interface MdbaseSyncPreview extends MirrorInitializationPreview {
plan: MirrorSyncPlan;
phase: MirrorSyncPlan["kind"];
entries: SyncPreviewEntry[];
cursor: number | null;
remoteHead: number;
}

export function previewFromPlan(plan: MirrorSyncPlan): MdbaseSyncPreview {
const entries = [
...plan.actions.flatMap((action) => action.command === "advance_checkpoint" ? [] : [actionEntry(action)]),
...plan.issues.map((issue): SyncPreviewEntry => ({
kind: "document",
path: issue.path ?? "Sync engine",
direction: "attention",
action: "fix",
detail: issue.message,
})),
];
return {
plan,
phase: plan.kind,
entries,
cursor: plan.base_cursor,
remoteHead: plan.authority_cursor,
already_initialized: plan.kind === "incremental",
download_documents: plan.actions.filter((action) =>
["write_local", "move_local", "delete_local"].includes(action.command)
&& ("target" in action ? action.target.entity !== "file" : "source" in action && action.source.entity !== "file")).length,
upload_documents: plan.actions.filter((action) =>
["put_remote", "move_remote", "delete_remote"].includes(action.command)
&& ("target" in action ? action.target.entity === "record" : "source" in action && action.source.entity === "record")).length,
unchanged_documents: 0,
download_files: plan.actions.filter((action) =>
["write_local", "move_local", "delete_local"].includes(action.command)
&& ("target" in action ? action.target.entity === "file" : "source" in action && action.source.entity === "file")).length,
upload_files: plan.actions.filter((action) =>
["put_remote", "move_remote", "delete_remote"].includes(action.command)
&& ("target" in action ? action.target.entity === "file" : "source" in action && action.source.entity === "file")).length,
unchanged_files: 0,
collisions: plan.issues
.filter((issue) => issue.blocking && issue.code === "local_collision" && issue.path)
.map((issue) => issue.path!),
local_issues: plan.issues
.filter((issue): issue is typeof issue & { path: string } =>
issue.code === "invalid_frontmatter" && issue.path !== undefined)
.map((issue) => ({
code: "invalid_frontmatter" as const,
message: issue.message,
path: issue.path,
})),
};
}

function actionEntry(action: MirrorPlanAction): SyncPreviewEntry {
if (action.command === "advance_checkpoint") {
throw new Error("Checkpoint actions are not preview entries.");
}
if (action.command === "record_conflict") {
const object = action.local.state === "exact"
? action.local.object
: action.remote.state === "exact"
? action.remote.object
: undefined;
return {
kind: action.entity === "file" ? "file" : "document",
path: object?.path ?? action.identity,
direction: "attention",
action: "fix",
detail: `Local and hosted ${action.entity} changes conflict (${action.conflict_kind.replace(/_/g, " ")}).`,
...(action.entity === "record" ? { recordId: action.identity } : { fileId: action.identity }),
};
}
const localCommand = action.command.endsWith("_local");
const object = "target" in action ? action.target : action.source;
const path = action.command === "move_local" || action.command === "move_remote"
? action.target_path
: object.path;
const creates = (action.command === "write_local" && action.expected_local.state === "absent")
|| (action.command === "put_remote" && action.expected_remote.state === "absent");
const verb = action.command.startsWith("move_")
? "rename"
: action.command.startsWith("delete_")
? "delete"
: creates
? "create"
: "update";
const operation = action.command.split("_")[0];
const movement = action.command.startsWith("move_") ? ` from ${object.path}` : "";
return {
kind: object.entity === "file" ? "file" : "document",
path,
direction: localCommand ? "download" : "upload",
action: verb,
detail: `${localCommand ? "Hosted" : "Local"} ${object.entity} will ${operation}${movement}.`,
...(object.entity === "record" ? { recordId: object.identity } : {}),
...(object.entity === "file" ? { fileId: object.identity } : {}),
};
}
Loading
Loading