mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 13:02:58 +00:00
Persist displayed branches through conflict operations
This commit is contained in:
@@ -153,6 +153,10 @@ Markdown conflict auto-merge should behave like a conservative three-way merge.
|
||||
|
||||
When in doubt, prefer the safer outcome: preserve data, keep the conflict visible, and ask the user rather than silently discarding content or choosing one side.
|
||||
|
||||
The detailed contract is documented in [Conflict resolution and revision provenance](docs/specs_conflict_resolution.md). Determine the merge base by intersecting the exact `available` revision IDs from both leaf histories and selecting the nearest shared revision. Do not infer ancestry from revision generation numbers. When a remote resolution reaches a Vault which still contains the exact content of a deleted losing branch, treat that content as known synchronised history so the resolution can be reflected without recreating the conflict.
|
||||
|
||||
File operations made while a conflict is active must use the device-local file-reflection provenance injected into `ServiceFileHandlerBase`. Treat its exact revision as authoritative; use byte equality only to reconstruct a missing record when exactly one available revision matches. If branch identity remains unknown, preserve data and leave the conflict visible. Do not hide key-value database readiness behind an implicit wait: maintained hosts open it through the sequential settings lifecycle before file events or replication begin.
|
||||
|
||||
- If one side deletes a line and the other side leaves that same line unchanged, treat it as a safe deletion. The deleted line must not be reintroduced into the merged result.
|
||||
- If one side inserts new content in a different region while the other side deletes an unchanged old region, preserve the insertion and the deletion.
|
||||
- If one side deletes a line and the other side modifies that same line, keep the conflict for user resolution.
|
||||
|
||||
@@ -143,7 +143,7 @@ Current verification:
|
||||
- `npm run test:e2e:obsidian:couchdb-upload` configures a unique CouchDB database, creates a note through Obsidian, commits it into the local database, runs one-shot synchronisation, and verifies that CouchDB contains the metadata document and all referenced chunk documents.
|
||||
- `npm run test:e2e:obsidian:minio-upload` configures a unique Object Storage bucket prefix, creates a note through Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written to the S3-compatible bucket.
|
||||
- `npm run test:e2e:obsidian:startup-scan` verifies that a file written while Obsidian is stopped is picked up during the next real Obsidian boot and uploaded to CouchDB after one-shot synchronisation.
|
||||
- `npm run test:e2e:obsidian:two-vault-sync` verifies two-vault note synchronisation: creation, update, rename, deletion, per-device target-filter differences, and a separate encrypted round-trip with Path Obfuscation enabled. The experimental Markdown conflict automatic merge check is available with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true` but is not part of the default local suite.
|
||||
- `npm run test:e2e:obsidian:two-vault-sync` verifies two-vault note synchronisation: creation, update, rename, deletion, per-device target-filter differences, and a separate encrypted round-trip with Path Obfuscation enabled. The optional Markdown conflict check creates divergent branches in two real Vaults, conservatively merges them, edits the merged result again, and verifies that the Vault holding the deleted losing revision accepts the propagated result without recreating a conflict. Enable it with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`. A separate `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` scope keeps four conflicts active while a Vault edits, deletes, case-renames, and cross-path-renames files, then verifies exact parent revisions and replicated trees. Neither scope is part of the default local suite.
|
||||
- `npm run test:e2e:obsidian:hidden-file-snippet-sync` verifies hidden file synchronisation as a two-vault round-trip: creation, deletion, automatic JSON conflict merging with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target-pattern differences.
|
||||
- `npm run test:e2e:obsidian:customisation-sync` verifies a two-vault Customisation Sync workflow: scan a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronise them through CouchDB, apply them on the second vault, assert the resulting `.obsidian` files, propagate a snippet update, and verify deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy.
|
||||
- `npm run test:e2e:obsidian:setting-markdown-export` verifies that setting Markdown export creates a vault file and omits credentials when credential export is disabled.
|
||||
@@ -186,7 +186,7 @@ Current implementation status:
|
||||
|
||||
Current implementation status:
|
||||
|
||||
- `test:e2e:obsidian:two-vault-sync` covers creation, update, rename, deletion, and per-device target-filter behaviour for a non-encrypted CouchDB configuration. Markdown conflict automatic merging remains an optional check because it needs a dedicated, less timing-sensitive fixture.
|
||||
- `test:e2e:obsidian:two-vault-sync` covers creation, update, rename, deletion, and per-device target-filter behaviour for a non-encrypted CouchDB configuration. Its optional conflict fixtures cover real two-Vault divergence, conservative merge, post-resolution editing, propagation to a Vault whose file still contains the deleted losing revision, and edit, logical deletion, case-only rename, and cross-path rename while conflicts remain active.
|
||||
- The same script creates a separate temporary CouchDB database and temporary vault pair for an encrypted two-vault round-trip with Path Obfuscation enabled.
|
||||
|
||||
### Phase 4: Harness Retirement
|
||||
|
||||
+3
-1
@@ -555,10 +555,12 @@ Should we keep folders that do not have any files inside?
|
||||
|
||||
### 5. Conflict resolution (Advanced)
|
||||
|
||||
Conflict resolution preserves unknown local content and automatically merges only when the available revision history supplies a safe shared base. See [Conflict resolution and revision provenance](specs_conflict_resolution.md) for the revision-tree rules, stale and concurrent resolutions, binary-file limitation, and the device-local provenance used for operations while a conflict is live.
|
||||
|
||||
#### (BETA) Always overwrite with a newer file
|
||||
|
||||
Setting key: resolveConflictsByNewerFile
|
||||
Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.
|
||||
Testing only. Resolve file conflicts by selecting the copy with the newer modification time. This can overwrite modified files and cannot establish which revision reflects the user's intent.
|
||||
|
||||
#### Delay conflict resolution of inactive files
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# Conflict resolution and revision provenance
|
||||
|
||||
This document describes the conflict-resolution and file-reflection guarantees used by Self-hosted LiveSync 1.0, together with the cases which still require user judgement. The underlying revision-tree operations and injectable provenance contract are owned by `@vrtmrz/livesync-commonlib`; LiveSync owns persistent device-local composition, Vault reflection, settings, and dialogue policy.
|
||||
|
||||
## Revision-tree model
|
||||
|
||||
PouchDB stores a document as a revision tree. It selects one live leaf as the deterministic winner and reports the other live leaves as conflicts. That winner is not proof that its content is newer, safer, or the version currently shown in the Vault.
|
||||
|
||||
For example:
|
||||
|
||||
```text
|
||||
A1
|
||||
├── B1 ── C1 ── D1
|
||||
└── B2 ── C2
|
||||
```
|
||||
|
||||
The two live leaves are `D1` and `C2`. Their nearest shared ancestor is `A1`; neither `B1` nor `B2` is shared. A conservative three-way merge therefore compares the changes from `A1` to each leaf. Matching generation numbers, or selecting the first older revision from one branch, does not prove shared ancestry.
|
||||
|
||||
Resolving a conflict writes the selected or merged result on one observed branch and deletes the other observed live leaf. A stale device may still have the deleted leaf's content in its Vault when it receives the resolution.
|
||||
|
||||
## Implemented 1.0 guarantees
|
||||
|
||||
- Automatic text and structured-data merge uses the nearest `available` revision ID which is present in both leaf histories.
|
||||
- Missing or compacted history stops conservative automatic merge instead of guessing a base.
|
||||
- A receiving Vault file which exactly matches any available revision in the document tree is treated as previously synchronised content. This includes an ancestor below a deleted losing leaf.
|
||||
- A receiving Vault file whose bytes do not match any available revision is preserved as an unsynchronised local change.
|
||||
- File bytes, rather than path, size, modification time, or revision generation, determine whether content is known.
|
||||
- Each device records the exact revision most recently reflected in each Vault file. An edit, deletion, or case-only rename made while a conflict is active extends that displayed branch rather than the deterministic database winner.
|
||||
- A cross-path rename stores the target before logically deleting only the displayed source branch.
|
||||
|
||||
The all-branch history check prevents a resolved conflict from being recreated merely because the receiving Vault still contains the known losing version. If the user has edited that version again, its bytes differ and the overwrite guard preserves it.
|
||||
|
||||
## Resolution patterns
|
||||
|
||||
| State | Safe action |
|
||||
| -------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Both leaves contain identical bytes | Collapse the duplicate leaf. |
|
||||
| Text or structured data has an available shared base and non-overlapping changes | Perform a conservative three-way merge. |
|
||||
| One side deletes content which the other leaves unchanged | Preserve the deletion. |
|
||||
| One side deletes content which the other modifies | Ask the user. |
|
||||
| A receiving file matches a revision available anywhere in the tree | Apply the propagated database result. |
|
||||
| A receiving file matches no available revision | Preserve it and ask the user. |
|
||||
| A required body or shared ancestor is missing or compacted | Ask the user. |
|
||||
| Binary contents differ | Prefer an explicit user selection; semantic merge is unavailable. |
|
||||
|
||||
The compatibility implementation currently selects the newer modification time for differing binary conflicts even when the general **Always overwrite with a newer file** option is disabled. This is existing behaviour, not a new 1.0 guarantee. Changing it to explicit selection only is a separate compatibility decision.
|
||||
|
||||
## Stale and concurrent resolutions
|
||||
|
||||
A device can resolve only the leaves which it has observed. If another device has already extended a branch, later replication can reveal another live leaf and require another resolution. Two devices can also produce different resolutions concurrently, leaving multiple live leaves after their trees meet.
|
||||
|
||||
A higher revision generation or modification time does not make either result authoritative. The resolver must examine every current live leaf again until one result remains or user action is required. This is continued conflict processing, not a reset of the synchronisation checkpoint.
|
||||
|
||||
## Device-local file provenance
|
||||
|
||||
LiveSync composes Commonlib's injected `FileReflectionProvenance` with its local key-value database. Each device stores:
|
||||
|
||||
```text
|
||||
path -> { revision, observedStorageMtime? }
|
||||
```
|
||||
|
||||
`revision` identifies the exact database revision which most recently produced the displayed Vault file. `observedStorageMtime` is the raw local modification time observed after reflection. It is not rounded, combined with another device's value, or used as proof of branch identity. No content hash is persisted.
|
||||
|
||||
The record changes only after a successful database-to-Vault reflection or Vault-to-database write. Reading a file does not change it. The recorded revision remains authoritative even if the user edits the file to bytes which equal another branch; otherwise content equality could silently move the edit to a branch which was not displayed.
|
||||
|
||||
LiveSync creates the namespaced store handle during service composition, before the key-value database is open. The sequential `onSettingLoaded` lifecycle opens that database before Vault scanning, watching, or replication starts. Store operations do not wait for implicit readiness: a lifecycle violation fails promptly, avoiding an indefinite or self-referential initialisation wait. Local database reset is a transient unavailable boundary, after which scanning reconstructs derived state.
|
||||
|
||||
When no record exists, LiveSync may reconstruct the displayed revision only if the current Vault bytes match exactly one available revision body. No match, or identical content in multiple revisions, cannot prove branch identity.
|
||||
|
||||
## Operations while a conflict exists
|
||||
|
||||
- Editing a file writes a child of its recorded or uniquely reconstructed displayed revision.
|
||||
- Deleting a file writes a logical-deletion child of that revision. It uses LiveSync's `deleted` marker, rather than a PouchDB `_deleted` tombstone, so the deletion remains a live branch which can replicate and be resolved against the other branch.
|
||||
- A case-only rename writes the new path as a child in the same document tree.
|
||||
- A cross-path rename stores the target document first, then writes a logical-deletion child on the displayed source branch.
|
||||
|
||||
If an edit's base cannot be proved, LiveSync keeps the bytes as another manual-resolution branch instead of attaching them silently to the database winner. If a deletion's displayed branch cannot be proved after the file body has gone, LiveSync preserves every branch and requests conflict review. For an unproven cross-path rename, the new target remains stored and every source branch is preserved for review. These fallbacks can leave a temporary duplicate or unresolved source, but they do not discard an unproven branch.
|
||||
|
||||
## Example device scenarios
|
||||
|
||||
### A user edits the branch shown on one device
|
||||
|
||||
Mac and Android have produced two branches of `shared.md`. Mac's local database selects revision `C1` as its deterministic winner, but the file currently shown in the Android Vault came from revision `C2`:
|
||||
|
||||
```text
|
||||
A1
|
||||
├── B1 ── C1 database winner
|
||||
└── B2 ── C2 displayed on Android
|
||||
```
|
||||
|
||||
Android recorded `C2` when it wrote that revision into the Vault. If the user edits the file on Android, the new revision extends `C2`:
|
||||
|
||||
```text
|
||||
A1
|
||||
├── B1 ── C1
|
||||
└── B2 ── C2 ── D2 Android edit
|
||||
```
|
||||
|
||||
After synchronisation, both devices receive `C1` and `D2` as the live branches. The edit is not moved silently onto `C1`, and ordinary conflict resolution can compare the real descendants.
|
||||
|
||||
### A user deletes the branch shown on one device
|
||||
|
||||
If Android deletes the file while it still displays `C2`, LiveSync writes a logical-deletion revision below `C2`:
|
||||
|
||||
```text
|
||||
A1
|
||||
├── B1 ── C1
|
||||
└── B2 ── C2 ── D2 (deleted: true)
|
||||
```
|
||||
|
||||
The deletion remains one side of the live conflict. The user can still choose between the content at `C1` and deleting the file. LiveSync does not delete `C1` merely because PouchDB selected it as the winner.
|
||||
|
||||
### A user renames a conflicted file
|
||||
|
||||
If the user changes only the spelling case, such as `Note.md` to `note.md`, LiveSync keeps the rename in the same revision tree and extends the revision displayed on that device.
|
||||
|
||||
If the user renames `draft.md` to `published.md`, LiveSync stores `published.md` before it marks the displayed `draft.md` branch as logically deleted. If an interruption occurs between those operations, the recoverable result is a duplicate which can be reviewed, rather than loss of the only copy. Any other live branch of `draft.md` remains available for conflict resolution.
|
||||
|
||||
### A remote resolution reaches a device which still shows the losing content
|
||||
|
||||
Android may resolve a conflict and continue editing while Mac still shows the losing revision. When Mac receives the resolved tree, LiveSync searches every available branch and recognises Mac's unchanged bytes as content which was already synchronised below the deleted losing leaf. It can apply Android's resolution without asking Mac to resolve the same unchanged conflict again.
|
||||
|
||||
If the user edited the file on Mac before the resolution arrived, the bytes no longer match that historical revision. LiveSync preserves the Mac edit as an unsynchronised conflict instead of overwriting it.
|
||||
|
||||
### The device-local record is missing
|
||||
|
||||
A local-database reset removes revision provenance. On the next scan, if the Vault file matches exactly one available revision, LiveSync can reconstruct which branch was displayed and continue from it. If the bytes match multiple revisions, or no available revision, the branch remains unproved.
|
||||
|
||||
In that unproved state, an edit is retained as another manual-resolution branch. A deletion leaves all existing branches intact. A cross-path rename stores the target but leaves every source branch for review. The result can require an extra decision, but it does not discard data by guessing the winner.
|
||||
|
||||
### Start-up or reset overlaps a provenance operation
|
||||
|
||||
LiveSync creates the provenance handle during composition, then opens its backing store during the sequential settings lifecycle before starting scans, watchers, or replication. If the store cannot open, start-up stops rather than leaving file processing waiting indefinitely.
|
||||
|
||||
During reset, the store can be temporarily unavailable. A racing provenance lookup fails promptly and follows the same conservative missing-record behaviour. After reopen, scanning can reconstruct a record when one exact revision body matches the Vault file.
|
||||
|
||||
## Unsafe shortcuts
|
||||
|
||||
Do not:
|
||||
|
||||
- infer a common ancestor from generation numbers alone;
|
||||
- assume that the PouchDB winner is the version currently displayed in the Vault;
|
||||
- replace recorded displayed provenance merely because current bytes match another branch;
|
||||
- discard local content when revision-history lookup fails;
|
||||
- infer revision identity from path, size, modification time, or content hash without a revision ID;
|
||||
- select the newest modification time unless the user has explicitly chosen that destructive policy; or
|
||||
- merge overlapping text edits or unrelated binary contents automatically.
|
||||
|
||||
## Verification
|
||||
|
||||
Commonlib's real-PouchDB and injected-boundary unit tests cover unequal branch lengths, exact shared ancestry, content below a deleted losing leaf, recorded and reconstructed branch identity, ambiguous matches, conflict-time editing, logical deletion, case-only rename, cross-path rename, and safe unproven fallbacks.
|
||||
|
||||
LiveSync's optional real-Obsidian two-Vault checks have two scopes. `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true` resolves and edits a Markdown conflict, propagates it to a Vault which still displays the deleted losing content, and requires one live result to remain. `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` edits, deletes, case-renames, and cross-path-renames files while conflicts remain active; it verifies the parent revision of each resulting branch, replicates those exact trees, and confirms that the other live branches remain intact.
|
||||
@@ -10,6 +10,7 @@ import { ServiceDatabaseFileAccessCLI } from "./DatabaseFileAccess";
|
||||
import { StorageEventManagerCLI } from "@/apps/cli/managers/StorageEventManagerCLI";
|
||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import type { IgnoreRules } from "./IgnoreRules";
|
||||
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
|
||||
|
||||
/**
|
||||
* Initialize service modules for CLI version
|
||||
@@ -93,6 +94,7 @@ export function initialiseServiceModulesCLI(
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess: storageAccess,
|
||||
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
|
||||
});
|
||||
|
||||
// Rebuilder (platform-independent)
|
||||
|
||||
@@ -259,6 +259,14 @@ export class NodeKeyValueDBService<T extends ServiceContext = ServiceContext>
|
||||
}
|
||||
|
||||
openSimpleStore<T>(kind: string): SimpleStore<T> {
|
||||
// Service modules are composed before onSettingLoaded opens the file-
|
||||
// backed database, so handle creation must not touch it. Actual store
|
||||
// operations are deliberately fail-fast: the sequential lifecycle opens
|
||||
// the database before scans, watchers, or replication start. Waiting here
|
||||
// could hang forever after failed initialisation, or deadlock if a future
|
||||
// initialisation handler tried to use the store it was waiting to open.
|
||||
// Reset is likewise a transient unavailable boundary, not a wait state;
|
||||
// callers must avoid store work there because an operation may fail.
|
||||
const getDB = () => {
|
||||
if (!this._kvDB) {
|
||||
throw new Error("KeyValueDB is not initialized yet");
|
||||
@@ -293,7 +301,9 @@ export class NodeKeyValueDBService<T extends ServiceContext = ServiceContext>
|
||||
.filter((key) => key >= lower && key <= upper)
|
||||
.map((key) => key.substring(prefix.length));
|
||||
},
|
||||
db: Promise.resolve(getDB()),
|
||||
get db() {
|
||||
return Promise.resolve(getDB());
|
||||
},
|
||||
} satisfies SimpleStore<T>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
|
||||
import type { NodeKeyValueDBDependencies } from "./NodeKeyValueDBService";
|
||||
import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
|
||||
|
||||
describe("NodeKeyValueDBService.openSimpleStore", () => {
|
||||
it("creates a namespaced store handle before the backing database is initialised", () => {
|
||||
const dependencies = {
|
||||
appLifecycle: { onSettingLoaded: { addHandler: vi.fn() } },
|
||||
databaseEvents: {
|
||||
onResetDatabase: { addHandler: vi.fn() },
|
||||
onDatabaseInitialisation: { addHandler: vi.fn() },
|
||||
onUnloadDatabase: { addHandler: vi.fn() },
|
||||
onCloseDatabase: { addHandler: vi.fn() },
|
||||
},
|
||||
vault: {},
|
||||
} as unknown as NodeKeyValueDBDependencies;
|
||||
const service = new NodeKeyValueDBService(
|
||||
createServiceContext(),
|
||||
dependencies,
|
||||
"/tmp/obsidian-livesync-node-kv-handle-test.json"
|
||||
);
|
||||
|
||||
expect(() => service.openSimpleStore("early-composition")).not.toThrow();
|
||||
});
|
||||
|
||||
it("fails store operations promptly instead of waiting for lifecycle initialisation", async () => {
|
||||
const dependencies = {
|
||||
appLifecycle: { onSettingLoaded: { addHandler: vi.fn() } },
|
||||
databaseEvents: {
|
||||
onResetDatabase: { addHandler: vi.fn() },
|
||||
onDatabaseInitialisation: { addHandler: vi.fn() },
|
||||
onUnloadDatabase: { addHandler: vi.fn() },
|
||||
onCloseDatabase: { addHandler: vi.fn() },
|
||||
},
|
||||
vault: {},
|
||||
} as unknown as NodeKeyValueDBDependencies;
|
||||
const service = new NodeKeyValueDBService(
|
||||
createServiceContext(),
|
||||
dependencies,
|
||||
"/tmp/obsidian-livesync-node-kv-uninitialised-test.json"
|
||||
);
|
||||
const store = service.openSimpleStore("early-composition");
|
||||
|
||||
await expect(store.get("key")).rejects.toThrow("KeyValueDB is not initialized yet");
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { ServiceDatabaseFileAccessFSAPI } from "./DatabaseFileAccess";
|
||||
import { StorageEventManagerFSAPI } from "@/apps/webapp/managers/StorageEventManagerFSAPI";
|
||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
|
||||
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
|
||||
|
||||
export interface FSAPIServiceModules extends ServiceModules {
|
||||
vaultAccess: FileAccessFSAPI;
|
||||
@@ -84,6 +85,7 @@ export function initialiseServiceModulesFSAPI(
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess: storageAccess,
|
||||
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
|
||||
});
|
||||
|
||||
// Rebuilder (platform-independent)
|
||||
|
||||
@@ -46,6 +46,7 @@ import { useReviewHarness } from "./serviceFeatures/useReviewHarness.ts";
|
||||
import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync/P2PReplicator/P2PReplicationUI.ts";
|
||||
import { useCompatibilityReview } from "./serviceFeatures/compatibilityReview.ts";
|
||||
import { createObsidianCompatibilityReviewUi } from "./serviceFeatures/compatibilityReviewObsidian.ts";
|
||||
import { createFileReflectionProvenance } from "./serviceModules/FileReflectionProvenance.ts";
|
||||
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
|
||||
export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
core: LiveSyncCore;
|
||||
@@ -104,6 +105,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess: storageAccess,
|
||||
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
|
||||
});
|
||||
const rebuilder = new ServiceRebuilder({
|
||||
events: services.context.events,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
StoredFileReflectionProvenance,
|
||||
type FileReflectionProvenanceRecord,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/interfaces/FileReflectionProvenance";
|
||||
import type { SimpleStore } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
|
||||
export const FILE_REFLECTION_PROVENANCE_STORE = "file-reflection-provenance-v1";
|
||||
|
||||
export type FileReflectionProvenanceStoreFactory = {
|
||||
openSimpleStore<T>(kind: string): SimpleStore<T>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the device-local record which links a Vault file to the exact
|
||||
* database revision most recently reflected in that Vault.
|
||||
*
|
||||
* This runs during service composition, before KeyValueDB is opened. The
|
||||
* returned namespaced handle is inert until its first operation; normal hosts
|
||||
* complete the sequential onSettingLoaded lifecycle before Vault scanning,
|
||||
* watching, or replication can invoke it. Operations are never held waiting for
|
||||
* readiness; they fail on a lifecycle violation and may fail during reset.
|
||||
*/
|
||||
export function createFileReflectionProvenance(keyValueDB: FileReflectionProvenanceStoreFactory) {
|
||||
return new StoredFileReflectionProvenance(
|
||||
keyValueDB.openSimpleStore<FileReflectionProvenanceRecord>(FILE_REFLECTION_PROVENANCE_STORE)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SimpleStore } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type { FileReflectionProvenanceRecord } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileReflectionProvenance";
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
createFileReflectionProvenance,
|
||||
FILE_REFLECTION_PROVENANCE_STORE,
|
||||
} from "./FileReflectionProvenance";
|
||||
|
||||
describe("createFileReflectionProvenance", () => {
|
||||
it("uses one reset-scoped host store for exact reflected revisions", async () => {
|
||||
const values = new Map<string, FileReflectionProvenanceRecord>();
|
||||
const store = {
|
||||
get: vi.fn(async (key: string) => values.get(key)),
|
||||
set: vi.fn(async (key: string, value: FileReflectionProvenanceRecord) => {
|
||||
values.set(key, value);
|
||||
}),
|
||||
delete: vi.fn(async (key: string) => {
|
||||
values.delete(key);
|
||||
}),
|
||||
keys: vi.fn(async () => [...values.keys()]),
|
||||
db: undefined,
|
||||
} as unknown as SimpleStore<FileReflectionProvenanceRecord>;
|
||||
const openSimpleStore = vi.fn().mockReturnValue(store);
|
||||
const path = "note.md" as FilePathWithPrefix;
|
||||
|
||||
const provenance = createFileReflectionProvenance({ openSimpleStore });
|
||||
expect(openSimpleStore).toHaveBeenCalledWith(FILE_REFLECTION_PROVENANCE_STORE);
|
||||
await provenance.set(path, { revision: "3-displayed", observedStorageMtime: 123.456 });
|
||||
|
||||
expect(openSimpleStore).toHaveBeenCalledTimes(1);
|
||||
await expect(provenance.get(path)).resolves.toEqual({
|
||||
revision: "3-displayed",
|
||||
observedStorageMtime: 123.456,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -130,7 +130,7 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
|
||||
|
||||
`test:e2e:obsidian:setup-uri-workflow` runs the repository's public Commonlib-backed CouchDB provisioning and Setup URI tools against the local CouchDB fixture. It configures the first data-less real Obsidian Vault through the visible onboarding wizard and uses Rebuild. After that device is working, it generates a new Setup URI through the registered command; the second real Obsidian Vault uses that URI for Fetch instead of reusing the provisioning-time bootstrap URI. The workflow verifies ordinary notes from the first device to the second and back again, independently enables Hidden File Sync on each device, and verifies a snippet. The retained Setup URI screenshots show only encrypted URIs and visually masked Setup URI passphrases; plaintext credentials are not captured. Files prefixed with `guide-` capture the relevant dialogue, settings panel, or workspace leaf without transient Notices. Public documentation copies selected images only after visual inspection; the E2E run does not overwrite repository documentation assets.
|
||||
|
||||
`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, ordinary rename, a case-only file name change within the same directory, deletion, and a separate encrypted round-trip with Path Obfuscation enabled. Its target-filter scenario confirms that one Vault receives and checkpoints a remote document without reflecting it, restarts with the same profile and filter, and then reflects the stored document after the filter is broadened through the settings service. Directory case changes deliberately remain outside this scenario because they require directory-aware rename handling. The optional Markdown conflict automatic merge check can be enabled with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`, but it is not part of the default local suite.
|
||||
`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, ordinary rename, a case-only file name change within the same directory, deletion, and a separate encrypted round-trip with Path Obfuscation enabled. Its target-filter scenario confirms that one Vault receives and checkpoints a remote document without reflecting it, restarts with the same profile and filter, and then reflects the stored document after the filter is broadened through the settings service. Directory case changes deliberately remain outside this scenario because they require directory-aware rename handling. The optional Markdown conflict check can be enabled with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`. It creates divergent revisions in two separate Vaults, performs a conservative merge on one Vault, edits that result again, and requires the other Vault to replace its known deleted losing revision without recreating the conflict. The separate `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` check keeps four conflicts active while one Vault edits, deletes, performs a case-only rename, and performs a cross-path rename. It asserts that each operation extends the revision displayed on that device, replicates the exact resulting revision tree, and preserves the other live branch. During focused development, `E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS=true` runs that self-contained scope without the ordinary, target-filter, or encrypted scenarios. Both conflict checks remain outside the default local suite.
|
||||
|
||||
`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one mobile-safe Notice with actionable touch targets; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
|
||||
|
||||
|
||||
@@ -39,6 +39,12 @@ const renameToPath = "E2E/two-vault/renamed/rename-target.md";
|
||||
const caseRenameFromPath = "E2E/two-vault/Case-Rename.md";
|
||||
const caseRenameToPath = "E2E/two-vault/case-rename.md";
|
||||
const conflictPath = "E2E/two-vault/conflict.md";
|
||||
const conflictEditPath = "E2E/two-vault/conflict-operations/edit.md";
|
||||
const conflictDeletePath = "E2E/two-vault/conflict-operations/delete.md";
|
||||
const conflictCaseFromPath = "E2E/two-vault/conflict-operations/Case-Rename.md";
|
||||
const conflictCaseToPath = "E2E/two-vault/conflict-operations/case-rename.md";
|
||||
const conflictRenameFromPath = "E2E/two-vault/conflict-operations/rename-source.md";
|
||||
const conflictRenameToPath = "E2E/two-vault/conflict-operations/renamed/rename-target.md";
|
||||
const targetMismatchPath = "E2E/two-vault/target-mismatch.md";
|
||||
const encryptedPath = "E2E/two-vault/encrypted.md";
|
||||
|
||||
@@ -51,6 +57,17 @@ type RunnerContext = {
|
||||
activeSessions: Set<ObsidianLiveSyncSession>;
|
||||
};
|
||||
|
||||
type FileConflictState = {
|
||||
currentRev: string;
|
||||
branches: {
|
||||
rev: string;
|
||||
parentRev?: string;
|
||||
content: string;
|
||||
deleted: boolean;
|
||||
path: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
|
||||
const fullPath = join(vaultPath, path);
|
||||
await mkdir(dirname(fullPath), { recursive: true });
|
||||
@@ -279,25 +296,116 @@ async function storeFileRevision(
|
||||
return result.rev;
|
||||
}
|
||||
|
||||
async function createMarkdownConflict(
|
||||
context: RunnerContext,
|
||||
session: ObsidianLiveSyncSession,
|
||||
vault: TemporaryVault,
|
||||
path: string,
|
||||
base: string,
|
||||
left: string,
|
||||
right: string
|
||||
): Promise<void> {
|
||||
const baseRev = await storeFileRevision(context.cliBinary, session.cliEnv, path, base);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path);
|
||||
await storeFileRevision(context.cliBinary, session.cliEnv, path, left, baseRev);
|
||||
await storeFileRevision(context.cliBinary, session.cliEnv, path, right, baseRev);
|
||||
await writeVaultFile(vault.path, path, right);
|
||||
async function readFileConflictState(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
path: string
|
||||
): Promise<FileConflictState> {
|
||||
return await evalObsidianJson<FileConflictState>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true},true);",
|
||||
"if(!meta) throw new Error(`Could not find conflict metadata: ${path}`);",
|
||||
"const revisions=[meta._rev,...(meta._conflicts??[])];",
|
||||
"const branches=[];",
|
||||
"for(const rev of revisions){",
|
||||
" const branchMeta=await core.localDatabase.getDBEntryMeta(path,{rev,revs:true},true);",
|
||||
" const entry=await core.localDatabase.getDBEntry(path,{rev},false,true,true);",
|
||||
" if(!branchMeta||!entry) throw new Error(`Could not read conflict revision: ${path} ${rev}`);",
|
||||
" const content=Array.isArray(entry.data)?entry.data.join(''):entry.data;",
|
||||
" if(typeof content!=='string') throw new Error(`Conflict revision was not text: ${path} ${rev}`);",
|
||||
" const ids=branchMeta._revisions?.ids??[];",
|
||||
" const parentRev=ids[1]?`${branchMeta._revisions.start-1}-${ids[1]}`:undefined;",
|
||||
" branches.push({rev,parentRev,content,deleted:Boolean(branchMeta.deleted||branchMeta._deleted),path:branchMeta.path});",
|
||||
"}",
|
||||
"return JSON.stringify({currentRev:meta._rev,branches});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function autoMergeMarkdownConflict(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
async function waitForFileConflict(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
path: string
|
||||
): Promise<FileConflictState> {
|
||||
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000);
|
||||
let state = await readFileConflictState(cliBinary, env, path);
|
||||
while (state.branches.length < 2 && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
state = await readFileConflictState(cliBinary, env, path);
|
||||
}
|
||||
if (state.branches.length < 2) {
|
||||
throw new Error(`Timed out waiting for a file conflict: ${path}`);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async function waitForConflictBranch(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
path: string,
|
||||
predicate: (branch: FileConflictState["branches"][number]) => boolean
|
||||
): Promise<FileConflictState["branches"][number]> {
|
||||
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000);
|
||||
let state = await readFileConflictState(cliBinary, env, path);
|
||||
while (Date.now() < deadline) {
|
||||
const branch = state.branches.find(predicate);
|
||||
if (branch) return branch;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
state = await readFileConflictState(cliBinary, env, path);
|
||||
}
|
||||
throw new Error(`Timed out waiting for the expected conflict branch: ${path}; ${JSON.stringify(state)}`);
|
||||
}
|
||||
|
||||
async function readFileReflectionProvenance(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
path: string
|
||||
): Promise<{ revision: string; observedStorageMtime?: number } | null> {
|
||||
return await evalObsidianJson<{ revision: string; observedStorageMtime?: number } | null>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const store=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');",
|
||||
"return JSON.stringify((await store.get(path))??null);",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function readPathIdentity(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
paths: readonly string[]
|
||||
): Promise<{ caseSensitive: boolean; ids: Record<string, string> }> {
|
||||
return await evalObsidianJson<{ caseSensitive: boolean; ids: Record<string, string> }>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const paths=${JSON.stringify(paths)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const ids={};",
|
||||
"for(const path of paths) ids[path]=await core.services.path.path2id(path);",
|
||||
"return JSON.stringify({",
|
||||
" caseSensitive:Boolean(core.services.setting.currentSettings().handleFilenameCaseSensitive),",
|
||||
" ids,",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function calculateMarkdownAutoMerge(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<string> {
|
||||
const result = await evalObsidianJson<{ content: string }>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
@@ -307,11 +415,29 @@ async function autoMergeMarkdownConflict(cliBinary: string, env: NodeJS.ProcessE
|
||||
"if(!('result' in result)){",
|
||||
" throw new Error(`Markdown conflict was not auto-mergeable: ${path}; ${JSON.stringify(result)}`);",
|
||||
"}",
|
||||
"if(!(await core.databaseFileAccess.storeContent(path,result.result))){",
|
||||
" throw new Error(`Could not store merged Markdown content: ${path}`);",
|
||||
"}",
|
||||
"if(!(await core.fileHandler.deleteRevisionFromDB(path,result.conflictedRev))){",
|
||||
" throw new Error(`Could not delete conflicted revision: ${path}`);",
|
||||
"return JSON.stringify({content:result.result});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
return result.content;
|
||||
}
|
||||
|
||||
async function deleteRevisionAndReflect(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
path: string,
|
||||
revision: string
|
||||
): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const revision=${JSON.stringify(revision)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"if(!(await core.fileHandler.deleteRevisionFromDB(path,revision))){",
|
||||
" throw new Error(`Could not delete conflicted revision: ${path} ${revision}`);",
|
||||
"}",
|
||||
"if(!(await core.fileHandler.dbToStorage(path,path,true))){",
|
||||
" throw new Error(`Could not reflect merged Markdown content: ${path}`);",
|
||||
@@ -468,31 +594,290 @@ async function runMarkdownAutoMerge(
|
||||
const base = "# Conflict\n\nTop anchor\n\nMiddle anchor\n\nBottom anchor\n";
|
||||
const left = "# Conflict\n\nTop anchor\n\nLeft line\n\nMiddle anchor\n\nBottom anchor\n";
|
||||
const right = "# Conflict\n\nTop anchor\n\nMiddle anchor\n\nRight tail\n\nBottom anchor\n";
|
||||
const conflictOverrides = {
|
||||
disableMarkdownAutoMerge: true,
|
||||
checkConflictOnlyOnOpen: true,
|
||||
showMergeDialogOnlyOnActive: true,
|
||||
};
|
||||
|
||||
let session = await startConfiguredSession(context, vaultB);
|
||||
await createMarkdownConflict(context, session, vaultB, conflictPath, base, left, right);
|
||||
await autoMergeMarkdownConflict(context.cliBinary, session.cliEnv, conflictPath);
|
||||
let session = await startConfiguredSession(context, vaultA, conflictOverrides);
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, conflictPath, base);
|
||||
await uploadNote(context, session, conflictPath);
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultB, conflictOverrides);
|
||||
await syncAndApply(context, session);
|
||||
const baseOnB = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictPath);
|
||||
await waitForPathContent(vaultB.path, conflictPath, (content) => content === base);
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultA, conflictOverrides);
|
||||
const baseOnA = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictPath);
|
||||
await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, left, baseOnA.rev);
|
||||
await writeVaultFile(vaultA.path, conflictPath, left);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultB, conflictOverrides);
|
||||
await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, right, baseOnB.rev);
|
||||
await writeVaultFile(vaultB.path, conflictPath, right);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
const conflict = await waitForFileConflict(context.cliBinary, session.cliEnv, conflictPath);
|
||||
const leftBranch = conflict.branches.find((branch) => branch.content === left);
|
||||
const rightBranch = conflict.branches.find((branch) => branch.content === right);
|
||||
if (!leftBranch || !rightBranch) {
|
||||
throw new Error(`The two Vault edits did not form the expected conflict: ${JSON.stringify(conflict)}`);
|
||||
}
|
||||
|
||||
const merged = await calculateMarkdownAutoMerge(context.cliBinary, session.cliEnv, conflictPath);
|
||||
if (!merged.includes("Left line") || !merged.includes("Right tail")) {
|
||||
throw new Error(`Markdown auto-merge discarded a non-overlapping edit: ${JSON.stringify({ merged })}`);
|
||||
}
|
||||
const mergedRev = await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, merged, rightBranch.rev);
|
||||
await deleteRevisionAndReflect(context.cliBinary, session.cliEnv, conflictPath, leftBranch.rev);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
const mergedOnB = await waitForPathContent(
|
||||
vaultB.path,
|
||||
conflictPath,
|
||||
(content) => content.includes("Left line") && content.includes("Right tail"),
|
||||
(content) => content === merged,
|
||||
Number(process.env.E2E_OBSIDIAN_MERGE_FILE_TIMEOUT_MS ?? 30000)
|
||||
);
|
||||
|
||||
const afterResolution = `${merged.trimEnd()}\n\nPost-resolution edit on B.\n`;
|
||||
await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, afterResolution, mergedRev);
|
||||
await writeVaultFile(vaultB.path, conflictPath, afterResolution);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultA);
|
||||
session = await startConfiguredSession(context, vaultA, conflictOverrides);
|
||||
await syncAndApply(context, session);
|
||||
const mergedOnA = await waitForPathContent(
|
||||
const resolvedOnA = await waitForPathContent(
|
||||
vaultA.path,
|
||||
conflictPath,
|
||||
(content) => content.includes("Left line") && content.includes("Right tail"),
|
||||
(content) => content === afterResolution,
|
||||
Number(process.env.E2E_OBSIDIAN_MERGE_FILE_TIMEOUT_MS ?? 30000)
|
||||
);
|
||||
const resolvedState = await readFileConflictState(context.cliBinary, session.cliEnv, conflictPath);
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
assertEqual(mergedOnB, merged, "The resolving Vault did not reflect the merged Markdown content.");
|
||||
assertEqual(
|
||||
resolvedOnA,
|
||||
afterResolution,
|
||||
"The resolved Markdown content did not replace the known losing revision."
|
||||
);
|
||||
assertEqual(
|
||||
resolvedState.branches.length,
|
||||
1,
|
||||
"The receiving Vault recreated a conflict from the known losing revision."
|
||||
);
|
||||
console.log(
|
||||
"A two-Vault Markdown conflict was merged, edited again, and propagated to the Vault holding the resolved losing revision."
|
||||
);
|
||||
}
|
||||
|
||||
async function runConflictTimeStorageOperations(
|
||||
context: RunnerContext,
|
||||
vaultA: TemporaryVault,
|
||||
vaultB: TemporaryVault
|
||||
): Promise<void> {
|
||||
const paths = [conflictEditPath, conflictDeletePath, conflictCaseFromPath, conflictRenameFromPath] as const;
|
||||
const conflictOverrides = {
|
||||
disableMarkdownAutoMerge: true,
|
||||
checkConflictOnlyOnOpen: true,
|
||||
showMergeDialogOnlyOnActive: true,
|
||||
handleFilenameCaseSensitive: false,
|
||||
};
|
||||
const baseContent = Object.fromEntries(paths.map((path) => [path, `# Conflict operation\n\nBase for ${path}.\n`])) as Record<
|
||||
(typeof paths)[number],
|
||||
string
|
||||
>;
|
||||
const leftContent = Object.fromEntries(
|
||||
paths.map((path) => [path, `${baseContent[path]}\nEdit made on Vault A.\n`])
|
||||
) as Record<(typeof paths)[number], string>;
|
||||
const rightContent = Object.fromEntries(
|
||||
paths.map((path) => [path, `${baseContent[path]}\nDisplayed edit made on Vault B.\n`])
|
||||
) as Record<(typeof paths)[number], string>;
|
||||
|
||||
let session = await startConfiguredSession(context, vaultA, conflictOverrides);
|
||||
for (const path of paths) {
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, path, baseContent[path]);
|
||||
await uploadNote(context, session, path);
|
||||
}
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultB, conflictOverrides);
|
||||
await syncAndApply(context, session);
|
||||
for (const path of paths) {
|
||||
await waitForPathContent(vaultB.path, path, (content) => content === baseContent[path]);
|
||||
}
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultA, conflictOverrides);
|
||||
for (const path of paths) {
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, path, leftContent[path]);
|
||||
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path);
|
||||
}
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultB, conflictOverrides);
|
||||
for (const path of paths) {
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, path, rightContent[path]);
|
||||
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path);
|
||||
}
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
|
||||
const displayedRevisions = new Map<string, string>();
|
||||
const initialBranchRevisions = new Map<string, Set<string>>();
|
||||
for (const path of paths) {
|
||||
const state = await waitForFileConflict(context.cliBinary, session.cliEnv, path);
|
||||
const displayedBranch = state.branches.find((branch) => branch.content === rightContent[path] && !branch.deleted);
|
||||
if (!displayedBranch) {
|
||||
throw new Error(`Could not identify the branch displayed by Vault B: ${path}; ${JSON.stringify(state)}`);
|
||||
}
|
||||
const provenance = await readFileReflectionProvenance(context.cliBinary, session.cliEnv, path);
|
||||
assertEqual(
|
||||
provenance?.revision,
|
||||
displayedBranch.rev,
|
||||
`Vault B did not retain the exact displayed revision for ${path}.`
|
||||
);
|
||||
displayedRevisions.set(path, displayedBranch.rev);
|
||||
initialBranchRevisions.set(path, new Set(state.branches.map((branch) => branch.rev)));
|
||||
}
|
||||
|
||||
const editedAgain = `${rightContent[conflictEditPath]}\nSecond edit while the conflict is active.\n`;
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, conflictEditPath, editedAgain);
|
||||
const editedBranch = await waitForConflictBranch(
|
||||
context.cliBinary,
|
||||
session.cliEnv,
|
||||
conflictEditPath,
|
||||
(branch) => branch.content === editedAgain
|
||||
);
|
||||
assertEqual(
|
||||
editedBranch.parentRev,
|
||||
displayedRevisions.get(conflictEditPath),
|
||||
"A conflict-time edit did not extend the displayed revision."
|
||||
);
|
||||
|
||||
await deleteNoteViaObsidian(context.cliBinary, session.cliEnv, conflictDeletePath);
|
||||
const deletedBranch = await waitForConflictBranch(
|
||||
context.cliBinary,
|
||||
session.cliEnv,
|
||||
conflictDeletePath,
|
||||
(branch) => branch.deleted
|
||||
);
|
||||
assertEqual(
|
||||
deletedBranch.parentRev,
|
||||
displayedRevisions.get(conflictDeletePath),
|
||||
"A conflict-time deletion did not extend the displayed revision."
|
||||
);
|
||||
|
||||
await renameNoteViaObsidian(
|
||||
context.cliBinary,
|
||||
session.cliEnv,
|
||||
conflictCaseFromPath,
|
||||
conflictCaseToPath
|
||||
);
|
||||
const caseRenamedBranch = await waitForConflictBranch(
|
||||
context.cliBinary,
|
||||
session.cliEnv,
|
||||
conflictCaseToPath,
|
||||
(branch) =>
|
||||
!initialBranchRevisions.get(conflictCaseFromPath)?.has(branch.rev) &&
|
||||
branch.path === conflictCaseToPath &&
|
||||
branch.content === rightContent[conflictCaseFromPath] &&
|
||||
!branch.deleted
|
||||
);
|
||||
const expectedCaseParent = displayedRevisions.get(conflictCaseFromPath);
|
||||
if (caseRenamedBranch.parentRev !== expectedCaseParent) {
|
||||
const [state, oldProvenance, newProvenance, identity] = await Promise.all([
|
||||
readFileConflictState(context.cliBinary, session.cliEnv, conflictCaseToPath),
|
||||
readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseFromPath),
|
||||
readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseToPath),
|
||||
readPathIdentity(context.cliBinary, session.cliEnv, [conflictCaseFromPath, conflictCaseToPath]),
|
||||
]);
|
||||
throw new Error(
|
||||
`A conflict-time case-only rename did not extend the displayed revision: ${JSON.stringify({
|
||||
expectedCaseParent,
|
||||
caseRenamedBranch,
|
||||
state,
|
||||
oldProvenance,
|
||||
newProvenance,
|
||||
identity,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
const [oldCaseProvenance, newCaseProvenance] = await Promise.all([
|
||||
readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseFromPath),
|
||||
readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseToPath),
|
||||
]);
|
||||
assertEqual(oldCaseProvenance, null, "A conflict-time case-only rename retained the old provenance path.");
|
||||
assertEqual(
|
||||
newCaseProvenance?.revision,
|
||||
caseRenamedBranch.rev,
|
||||
"A conflict-time case-only rename did not record the new displayed revision."
|
||||
);
|
||||
|
||||
await renameNoteViaObsidian(
|
||||
context.cliBinary,
|
||||
session.cliEnv,
|
||||
conflictRenameFromPath,
|
||||
conflictRenameToPath
|
||||
);
|
||||
const renamedTarget = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictRenameToPath);
|
||||
const renamedSourceDeletion = await waitForConflictBranch(
|
||||
context.cliBinary,
|
||||
session.cliEnv,
|
||||
conflictRenameFromPath,
|
||||
(branch) => branch.deleted
|
||||
);
|
||||
assertEqual(
|
||||
renamedSourceDeletion.parentRev,
|
||||
displayedRevisions.get(conflictRenameFromPath),
|
||||
"A conflict-time cross-path rename did not soft-delete the displayed source revision."
|
||||
);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultA, conflictOverrides);
|
||||
await syncAndApply(context, session);
|
||||
const replicatedBranches = [
|
||||
[conflictEditPath, editedBranch],
|
||||
[conflictDeletePath, deletedBranch],
|
||||
[conflictCaseToPath, caseRenamedBranch],
|
||||
[conflictRenameFromPath, renamedSourceDeletion],
|
||||
] as const;
|
||||
for (const [path, expectedBranch] of replicatedBranches) {
|
||||
const replicated = await waitForConflictBranch(
|
||||
context.cliBinary,
|
||||
session.cliEnv,
|
||||
path,
|
||||
(branch) => branch.rev === expectedBranch.rev
|
||||
);
|
||||
assertEqual(
|
||||
replicated.parentRev,
|
||||
expectedBranch.parentRev,
|
||||
`The exact conflict-operation revision tree did not replicate for ${path}.`
|
||||
);
|
||||
}
|
||||
await waitForPathContent(
|
||||
vaultA.path,
|
||||
conflictRenameToPath,
|
||||
(content) => content === rightContent[conflictRenameFromPath]
|
||||
);
|
||||
const targetOnA = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictRenameToPath);
|
||||
assertEqual(targetOnA.id, renamedTarget.id, "The cross-path rename target did not replicate as the same document.");
|
||||
assertEqual(
|
||||
await readVaultFile(vaultA.path, conflictDeletePath),
|
||||
leftContent[conflictDeletePath],
|
||||
"A logical deletion from one conflict branch removed the other Vault's live branch."
|
||||
);
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
assertEqual(mergedOnA, mergedOnB, "Merged Markdown content was not consistent across both vaults.");
|
||||
console.log("Markdown conflict was automatically merged and propagated by the next synchronisation.");
|
||||
console.log(
|
||||
"Conflict-time edit, logical deletion, case-only rename, and cross-path rename extended the displayed branches and replicated their revision trees."
|
||||
);
|
||||
}
|
||||
|
||||
async function runTargetMismatch(
|
||||
@@ -617,14 +1002,22 @@ async function main(): Promise<void> {
|
||||
console.log(`Temporary CouchDB database: ${dbName}`);
|
||||
console.log(`Temporary encrypted CouchDB database: ${encryptedDbName}`);
|
||||
|
||||
await runCreateUpdateDelete(context, vaultA, vaultB);
|
||||
await runRename(context, vaultA, vaultB);
|
||||
await runCaseOnlyRename(context, vaultA, vaultB);
|
||||
if (process.env.E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT === "true") {
|
||||
await runMarkdownAutoMerge(context, vaultA, vaultB);
|
||||
const onlyConflictOperations = process.env.E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS === "true";
|
||||
if (!onlyConflictOperations) {
|
||||
await runCreateUpdateDelete(context, vaultA, vaultB);
|
||||
await runRename(context, vaultA, vaultB);
|
||||
await runCaseOnlyRename(context, vaultA, vaultB);
|
||||
if (process.env.E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT === "true") {
|
||||
await runMarkdownAutoMerge(context, vaultA, vaultB);
|
||||
}
|
||||
}
|
||||
if (onlyConflictOperations || process.env.E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS === "true") {
|
||||
await runConflictTimeStorageOperations(context, vaultA, vaultB);
|
||||
}
|
||||
if (!onlyConflictOperations) {
|
||||
await runTargetMismatch(context, vaultA, vaultB);
|
||||
await runEncryptedRoundTrip(encryptedContext, encryptedVaultA, encryptedVaultB);
|
||||
}
|
||||
await runTargetMismatch(context, vaultA, vaultB);
|
||||
await runEncryptedRoundTrip(encryptedContext, encryptedVaultA, encryptedVaultB);
|
||||
} finally {
|
||||
await stopTrackedSessions(context);
|
||||
await stopTrackedSessions(encryptedContext);
|
||||
|
||||
@@ -31,6 +31,8 @@ Earlier releases remain available in the [0.25 release history](https://github.c
|
||||
|
||||
### Fixed
|
||||
|
||||
- Conflict resolutions made on another device no longer recreate the same conflict when the receiving Vault still contains the exact content of the deleted losing revision. Automatic text and structured-data merge now uses the nearest revision actually shared by both branches instead of inferring ancestry from revision generation numbers.
|
||||
- Edits, deletions, and renames made while a file is conflicted now extend the exact revision displayed on that device. If LiveSync cannot prove the displayed branch, it preserves the affected branches for review instead of silently applying the operation to the database winner.
|
||||
- The optional Custom HTTP Handler used by Object Storage now sends the correct byte range from binary request bodies and reports unsupported body types instead of silently sending an empty request.
|
||||
- When selectors, ignore files, size limits, modification-time limits, or file-name case settings are broadened, LiveSync now rechecks previously received files without requiring another remote update.
|
||||
- P2P setup on the first device no longer displays reset or upload steps for a central database, and Config Doctor now offers its chunk size recommendation for CouchDB only when a CouchDB remote profile is selected.
|
||||
|
||||
Reference in New Issue
Block a user