Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
de48e82
Allow storage to reopen after a transient IndexedDB failure
callumalpass Oct 1, 2026
bbad48f
Refuse queued text writes through a renamed or replaced TFile
callumalpass Oct 1, 2026
e3cc29c
Keep approval reachable and cancellable when restoring sync
callumalpass Oct 1, 2026
339e72a
Merge remote-tracking branch 'origin/main' into polish/ux
callumalpass Oct 1, 2026
5f4c7be
Merge remote-tracking branch 'origin/main' into polish/reliability
callumalpass Oct 1, 2026
f480efb
Port SDK conflict-resolution fencing to prevent snapshot-wait edit loss
callumalpass Oct 1, 2026
5896557
Build history ledgers only when their disclosures are open
callumalpass Oct 1, 2026
52d589f
Report settings success only after the sync scope is saved
callumalpass Oct 1, 2026
1bac62b
Serialize history snapshots and repair torn tails before append
callumalpass Oct 1, 2026
fe0cd1d
Yield cached record scans so large collections do not block typing
callumalpass Oct 1, 2026
5e6e7fe
Keep chasing pending work after the bounded sync round limit
callumalpass Oct 1, 2026
c73d885
Make workspace tabs keyboard navigable without losing focus
callumalpass Oct 1, 2026
ac13f63
Serialize desktop HTTP headers in their owning process so multipart u…
callumalpass Oct 1, 2026
146e082
Prove rejected, binary, rename and occupied-path conflicts settle los…
callumalpass Oct 1, 2026
5b6f34a
Ignore inspections and status responses from retired mirror enrollments
callumalpass Oct 1, 2026
28011e5
Preserve sync control focus even for quoted file paths
callumalpass Oct 1, 2026
f1c3c62
Serialize complete conflict settlements with sync and inspection
callumalpass Oct 1, 2026
5d9a27f
Keep failed or unresolved conflict settlements out of up-to-date results
callumalpass Oct 1, 2026
a6240f5
Start the first download after connecting without a redundant review
callumalpass Oct 1, 2026
d3f12ca
Name the destination of deletions and renames in sync reviews
callumalpass Oct 1, 2026
bcd7576
Keep full record and type-impact scans out of Sync and Issues
callumalpass Oct 1, 2026
a7c9bdf
Retain the conflict when both merge and recovery-copy writes fail
callumalpass Oct 1, 2026
c1f5f55
Exercise offline edit convergence and checkpoint quota-failure recovery
callumalpass Oct 1, 2026
5978bf3
Keep conflict-copy evidence visible and history disclosures stable
callumalpass Oct 1, 2026
743dbe5
Consume each write echo once so immediate user edits still trigger sync
callumalpass Oct 1, 2026
2d93f10
Only reserve a text-write echo after the conditional check succeeds
callumalpass Oct 1, 2026
0b61ed0
Persist merged text before clearing its conflict so restarts cannot l…
callumalpass Oct 1, 2026
8e78ae5
Contain rejected host operations inside the scheduler retry boundary
callumalpass Oct 1, 2026
763199f
Keep both when the same new field was placed at incompatible positions
callumalpass Oct 1, 2026
8041801
Keep long sync paths and recovery actions usable in narrow panes
callumalpass Oct 1, 2026
fa01f5b
Use conditional Vault.process for invalid UTF-8 receive-only repairs
callumalpass Oct 1, 2026
a557132
Trim identical bulk so small edits in large notes merge without copies
callumalpass Oct 1, 2026
e7e6c5d
Keep consent reasons ahead of roomy actions and an honest review heading
callumalpass Oct 1, 2026
1e8fdf0
Inspect local projection changes immediately when sync settings change
callumalpass Oct 1, 2026
4d26ae8
Exercise seeded insert/delete mutations and exact kept-both YAML roun…
callumalpass Oct 1, 2026
4fc52a9
Vary offline convergence seeds and inject quota errors during settlement
callumalpass Oct 1, 2026
faae8ef
Prove merged-resolution retries survive checkpoint failure without co…
callumalpass Oct 1, 2026
0f692e4
Dismiss pinned sync notices without erasing their history
callumalpass Oct 1, 2026
6f70f49
Normalize malformed stored sync policies without discarding enrollmen…
callumalpass Oct 1, 2026
2d70000
Merge branch 'polish/merge' into integration/polish-2
callumalpass Oct 1, 2026
f7ec9c0
Merge branch 'polish/perf' into integration/polish-2
callumalpass Oct 1, 2026
cca5960
Merge branch 'polish/ux' into integration/polish-2
callumalpass Oct 1, 2026
f3bf85b
Merge branch 'polish/lab-e2e' into integration/polish-2
callumalpass Oct 1, 2026
ffd53ea
Rebuild main.js for integration round 2
callumalpass Oct 1, 2026
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
28 changes: 28 additions & 0 deletions docs/sync-ux-acceptance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Sync UI acceptance in isolated Obsidian

`test/workspaceView.test.ts` and `test/settingsTab.test.ts` exercise the real renderers and handlers in jsdom. Run `npm test` and `npm run lint`.

For visual checks, `scripts/test-sync-layout.cjs` drives an **isolated, disposable** Obsidian instance over Playwright CDP. Never attach it to a personal vault. It stops background sync and injects presentation state; it does not enroll or call a live Connect service.

## Prepare

1. Build the plugin and copy `main.js`, `manifest.json`, and `styles.css` into a disposable vault's `.obsidian/plugins/mdbase-obsidian/`. Enable the plugin in that vault.
2. Use a separate Obsidian app copy and profile. The profile's `obsidian.json` should list only the disposable vault, with `open: true`. Launch with `--no-sandbox --ozone-platform=wayland --user-data-dir=<profile> --password-store=basic --remote-debugging-port=<port>`.
3. Create the opt-in marker `.obsidian/mdbase-ux-disposable` in the disposable vault. The script refuses unmarked vaults and checks the attached vault's exact path before injecting state.
4. Make that isolated window visible so Wayland can paint screenshots. Do not drive another agent's window.

## Run

```sh
MDBASE_UX_CDP_URL=http://127.0.0.1:42001 \
MDBASE_UX_VAULT=/absolute/path/to/disposable-vault \
MDBASE_UX_SHOTS=/absolute/path/to/screenshots \
MDBASE_UX_PLAYWRIGHT=/absolute/path/to/node_modules/@playwright/test \
node scripts/test-sync-layout.cjs after-layout
```

`MDBASE_UX_PLAYWRIGHT` can be omitted if Playwright is already resolvable. It is not a plugin runtime dependency.

The audit captures 14 states at 1100px and 390px, in dark and light themes: healthy, offline, sign-in, copied device, paused, unexpected failure, progress, first-sync review, deletion-burst review, rebuild, unresolved conflict, pinned conflict history, enrollment, and local upload. Desktop sidebars are collapsed for realistic narrow panes. Assertions reject horizontal overflow and primary/dismiss targets below 44px in narrow panes. Long, unbroken paths are intentional. `*-checks.json` records the measured bounds; the command exits nonzero on a regression.

Review the PNGs as well as the assertions. Diff panels may scroll horizontally within their own surface; the workspace itself must not. Stop the isolated instance after testing and discard its app/profile/vault. Injected profile state is ephemeral and must never be saved as real credentials.
234 changes: 117 additions & 117 deletions main.js

Large diffs are not rendered by default.

33 changes: 3 additions & 30 deletions main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
import type { StoredTypeDraft, TypeEditorModel } from "./src/typeEditorTypes";
import {
ConnectSyncController,
normalizeSelectiveSync,
normalizeMirrorProfile,
type MirrorProfile,
} from "./src/connectSync";
import {
Expand Down Expand Up @@ -93,24 +93,6 @@ const DEFAULT_SETTINGS: MdbasePluginSettings = {
/** Vault-scoped device storage key; Obsidian keeps it out of the vault and out of plugin data. */
const DEVICE_ID_KEY = "mdbase-sync-device-id";

function isMirrorProfile(value: unknown): value is MirrorProfile {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const profile = value as Partial<MirrorProfile>;
return profile.version === 1
&& typeof profile.syncUrl === "string"
&& typeof profile.controlUrl === "string"
&& typeof profile.collectionId === "string"
&& typeof profile.replicaId === "string"
&& (profile.mode === "read_only" || profile.mode === "read_write")
&& typeof profile.name === "string"
&& typeof profile.enrollmentId === "string"
&& typeof profile.accessTokenExpiresAt === "string"
&& (profile.selectiveSync === undefined || (
Array.isArray(profile.selectiveSync.file_classes)
&& Array.isArray(profile.selectiveSync.excluded_folders)
));
}

function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
Expand Down Expand Up @@ -356,16 +338,7 @@ export default class MdbasePlugin extends Plugin {
this.settings.autoSync = true;
this.settings.syncSettingsVersion = 2;
}
if (!isMirrorProfile(this.settings.mirrorProfile)) {
this.settings.mirrorProfile = null;
} else {
try {
this.settings.mirrorProfile.selectiveSync = normalizeSelectiveSync(this.settings.mirrorProfile.selectiveSync);
} catch {
// Fail closed for corrupted legacy data without discarding enrollment.
this.settings.mirrorProfile.selectiveSync = normalizeSelectiveSync();
}
}
this.settings.mirrorProfile = normalizeMirrorProfile(this.settings.mirrorProfile);
if (!this.settings.typeDrafts || typeof this.settings.typeDrafts !== "object" || Array.isArray(this.settings.typeDrafts)) {
this.settings.typeDrafts = {};
}
Expand Down Expand Up @@ -1071,7 +1044,7 @@ export default class MdbasePlugin extends Plugin {
* echoes. The scheduler syncs once the burst of edits settles.
*/
private observeLocalMirrorChange(path: string): void {
if (!this.getMirrorProfile() || this.connectSync.isEngineWrite(path)) return;
if (!this.getMirrorProfile() || this.connectSync.consumeEngineWrite(path)) return;
const normalized = normalizePath(path);
const reservedFolders = [this.app.vault.configDir, ".mdbase", ".trash", ".git"];
if (reservedFolders.some((folder) => normalized === folder || normalized.startsWith(`${folder}/`))) return;
Expand Down
114 changes: 107 additions & 7 deletions patches/@mdbase-dev+connect-sync+0.1.0-beta.120.patch
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
diff --git a/node_modules/@mdbase-dev/connect-sync/dist/directory-mirror.d.ts b/node_modules/@mdbase-dev/connect-sync/dist/directory-mirror.d.ts
index dd5830c..e774a50 100644
index dd5830c..868ba93 100644
--- a/node_modules/@mdbase-dev/connect-sync/dist/directory-mirror.d.ts
+++ b/node_modules/@mdbase-dev/connect-sync/dist/directory-mirror.d.ts
@@ -34,6 +34,11 @@ export declare class DirectoryMirror<Frontmatter extends JsonObject = JsonObject
@@ -34,12 +34,18 @@ export declare class DirectoryMirror<Frontmatter extends JsonObject = JsonObject
* into the durable base before the checkpoint action runs.
*/
private initialUnchangedState;
Expand All @@ -14,11 +14,27 @@ index dd5830c..e774a50 100644
status(): Promise<MirrorStatus>;
checkpointStatus(): Promise<MirrorStatus>;
private checkpointStatusUnlocked;
authorityPromotionManifest(): Promise<AuthorityPromotionManifest>;
previewInitialization(): Promise<MirrorInitializationPreview>;
- resolveConflict(identity: string, decisionId: string, resolution: "local" | "remote"): Promise<void>;
+ /** An optional merged record is written conditionally before the durable conflict is cleared. */
+ resolveConflict(identity: string, decisionId: string, resolution: "local" | "remote", mergedDocument?: string): Promise<void>;
private resolveConflictUnlocked;
private installRemoteRecord;
private installRemoteFile;
diff --git a/node_modules/@mdbase-dev/connect-sync/dist/directory-mirror.js b/node_modules/@mdbase-dev/connect-sync/dist/directory-mirror.js
index a9018ff..80cc6e3 100644
index a9018ff..ba68459 100644
--- a/node_modules/@mdbase-dev/connect-sync/dist/directory-mirror.js
+++ b/node_modules/@mdbase-dev/connect-sync/dist/directory-mirror.js
@@ -68,7 +68,7 @@ export class DirectoryMirror {
@@ -8,6 +8,7 @@ import { checkpointMirrorStatus, mirrorStatusFromPlan } from "./mirror-status.js
import { PlanOnlyMirrorInspector } from "./sync-inspector.js";
import { PlanOnlySyncExecutor } from "./sync-executor.js";
import { PlanRevalidator } from "./sync-revalidator.js";
+import { runtimeDocumentRevision } from "./mirror-format.js";
import { abandonStaleBatch, prepareSyncBatch } from "./sync-journal.js";
import { advanceEmptySyncCheckpoint, advanceSyncCheckpoint } from "./sync-checkpoint.js";
import { loadMirrorSnapshot } from "./sync-snapshot-loader.js";
@@ -68,7 +69,7 @@ export class DirectoryMirror {
}
if (state?.last_completed_plan === plan.fingerprint) {
const checkpoint = checkpointMirrorStatus(state, this.mode);
Expand All @@ -27,7 +43,7 @@ index a9018ff..80cc6e3 100644
}
const inspection = await this.inspectDetailed(state);
if (inspection.plan.fingerprint !== plan.fingerprint) {
@@ -126,6 +126,9 @@ export class DirectoryMirror {
@@ -126,6 +127,9 @@ export class DirectoryMirror {
})
}).execute(state, signal);
if (result.status !== "effects_complete") {
Expand All @@ -37,7 +53,7 @@ index a9018ff..80cc6e3 100644
const checkpoint = checkpointMirrorStatus(state, this.mode);
return mirrorApplyResult(result.status === "blocked" ? "failed" : result.status, batch.plan, checkpoint, result.completed, result.failure);
}
@@ -135,7 +138,8 @@ export class DirectoryMirror {
@@ -135,7 +139,8 @@ export class DirectoryMirror {
const checkpoint = checkpointMirrorStatus(state, this.mode);
const attention = checkpoint.conflicts.length > 0
|| checkpoint.local_issues.length > 0
Expand All @@ -47,7 +63,7 @@ index a9018ff..80cc6e3 100644
return mirrorApplyResult(attention ? "attention" : "applied", plan, checkpoint, result.completed);
}
inspectDetailed(state) {
@@ -196,13 +200,21 @@ export class DirectoryMirror {
@@ -196,13 +201,21 @@ export class DirectoryMirror {
planned_conflicts: {}
};
}
Expand Down Expand Up @@ -75,6 +91,90 @@ index a9018ff..80cc6e3 100644
}
async checkpointStatus() {
return this.lease.runExclusive(() => this.checkpointStatusUnlocked());
@@ -263,10 +276,10 @@ export class DirectoryMirror {
.map((issue) => ({ code: "invalid_frontmatter", message: issue.message, path: issue.path }))
};
}
- async resolveConflict(identity, decisionId, resolution) {
- await this.lease.runExclusive(() => this.resolveConflictUnlocked(identity, decisionId, resolution));
+ async resolveConflict(identity, decisionId, resolution, mergedDocument) {
+ await this.lease.runExclusive(() => this.resolveConflictUnlocked(identity, decisionId, resolution, mergedDocument));
}
- async resolveConflictUnlocked(identity, decisionId, resolution) {
+ async resolveConflictUnlocked(identity, decisionId, resolution, mergedDocument) {
if (this.mode !== "read_write") {
throw new SyncError("mirror_read_only", "Receive-only mirrors have no writable conflicts.");
}
@@ -280,12 +293,9 @@ export class DirectoryMirror {
}
if ((planned.decision_id ?? "") !== decisionId)
throw staleConflict();
- const revalidator = new PlanRevalidator(this.fileSystem, this.runtime);
- if (planned.local.state === "exact") {
- await revalidator.validateExpected(planned.local);
- }
- else if (planned.remote.state === "exact") {
- await revalidator.validateExpectedAt(planned.remote.object.path, planned.local);
+ if (mergedDocument !== undefined && (resolution !== "local"
+ || planned.entity !== "record" || planned.local.state !== "exact")) {
+ throw new SyncError("invalid_conflict_resolution", "A merged document requires an exact local record conflict.");
}
const snapshot = await loadMirrorSnapshot(this.replicaId, this.transport, this.mode, this.selectiveSync, this.runtime);
const currentRecord = planned.entity === "record"
@@ -299,6 +309,15 @@ export class DirectoryMirror {
: fileStateMatches(planned.remote, currentFile);
if (!remoteMatches)
throw staleConflict();
+ // Snapshot loading can take arbitrarily long. Fence the decision's local
+ // bytes after that network wait, not before it.
+ const revalidator = new PlanRevalidator(this.fileSystem, this.runtime);
+ if (planned.local.state === "exact") {
+ await revalidator.validateExpected(planned.local);
+ }
+ else if (planned.remote.state === "exact") {
+ await revalidator.validateExpectedAt(planned.remote.object.path, planned.local);
+ }
if (resolution === "remote") {
if (planned.entity === "record") {
await this.installRemoteRecord(state, identity, currentRecord);
@@ -307,6 +326,17 @@ export class DirectoryMirror {
await this.installRemoteFile(state, identity, currentFile);
}
}
+ if (mergedDocument !== undefined) {
+ const path = planned.local.object.path;
+ const expected = await this.fileSystem.read(path);
+ if (expected === null || runtimeDocumentRevision(expected, this.runtime) !== planned.local.object.payload_revision) {
+ throw staleConflict();
+ }
+ // Persist both halves in the merged file before clearing the durable
+ // conflict. A crash or failed write must never authorize an upload
+ // of the original, unmerged local document over the hosted edit.
+ await this.fileSystem.write(path, mergedDocument, expected);
+ }
delete state.planned_conflicts?.[identity];
if (resolution === "remote")
delete state.local_bindings?.[identity];
@@ -314,14 +344,12 @@ export class DirectoryMirror {
}
async installRemoteRecord(state, identity, current) {
if (current) {
- const conflict = state.planned_conflicts?.[identity];
- const acceptedDocument = await this.fileSystem.read(current.path);
- const pathBelongsToIdentity = state.records[identity]?.path === current.path
- || (conflict?.local.state === "exact" && conflict.local.object.path === current.path);
+ // Accept only the local version named by the conflict decision. Never
+ // authorize whatever bytes happen to be present after an async reread.
+ const local = state.planned_conflicts?.[identity]?.local;
await this.materializer.put(state, current, {
- inspectionPreflighted: false,
- ...(pathBelongsToIdentity && acceptedDocument !== null
- ? { acceptedHash: this.runtime.digest(acceptedDocument) }
+ ...(local?.state === "exact" && local.object.path === current.path
+ ? { acceptedHash: local.object.payload_revision.replace(/^sha256:/u, "") }
: {})
});
return;
diff --git a/node_modules/@mdbase-dev/connect-sync/dist/mirror-materializer.js b/node_modules/@mdbase-dev/connect-sync/dist/mirror-materializer.js
index 6936f79..64b0670 100644
--- a/node_modules/@mdbase-dev/connect-sync/dist/mirror-materializer.js
Expand Down
16 changes: 16 additions & 0 deletions patches/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,22 @@ addition is the optional `ancestor_document` below):
- `sync-executor.js`: the special receive-only malformed-frontmatter repair
allowance applies only to that explicitly identified target, never every
download in a partial plan. Conditional writes remain enforced.
- `directory-mirror.js`: a hosted conflict resolution accepts only the inspected
local document revision, not whatever bytes happen to exist after loading the
authority snapshot. A user edit or recreated file during that await leaves
the decision stale instead of silently overwriting bytes absent from the copy.
Ported exactly from mdbase-connect `56dabd74` (local revalidation after the
snapshot, decision-bound `acceptedHash`). Integration regression:
`test/connectSync.settlement.test.ts`.
- `directory-mirror.js` / `.d.ts`: `resolveConflict(identity, decisionId, "local",
mergedDocument?)` optionally persists a merged record with the adapter's
conditional write before clearing its durable conflict. Without this ordering,
a hard restart between clearing and the plugin's write could upload unmerged
local text over the hosted edit. Only an exact local record accepts a merged
document; a failed write leaves the conflict intact. No protocol or persisted
state change. This additional SDK API needs an upstream source port before
upgrading/removing the patch. Regressions simulate restart at the write
boundary, disk-full failure, and a user edit during the atomic write.
- `directory-mirror.js`: partial completion reports attention, not failure or
falsely complete synchronization, while preserving its unresolved diagnostics.
- `directory-mirror.js`: release stale batches at the SDK's existing safe journal
Expand Down
Loading
Loading