mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-08 11:47:05 +00:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac9b458fc9 | ||
|
|
dd280a4c5e | ||
|
|
e640a0e005 | ||
|
|
76bd9086ad | ||
|
|
35a8a4bc78 | ||
|
|
94766f9d43 | ||
|
|
9678575fb3 | ||
|
|
57a92af568 | ||
|
|
6fda957456 | ||
|
|
3e4a109862 | ||
|
|
1f545f46cc | ||
|
|
59188872fc | ||
|
|
a5056ab157 | ||
|
|
6305e6dd68 | ||
|
|
cfb3cec6ec | ||
|
|
0789e47c17 | ||
|
|
bbbd6fb174 | ||
|
|
14a133588d | ||
|
|
7110b9eebf | ||
|
|
e018cab039 | ||
|
|
5d251d1f92 | ||
|
|
95fa2b13f9 | ||
|
|
f3c85c1aef | ||
|
|
d2c32da30d | ||
|
|
c3e12cf946 | ||
|
|
c84383a44b | ||
|
|
b90ef3716c | ||
|
|
e1195629b9 | ||
|
|
0ecb73924a | ||
|
|
188b749326 | ||
|
|
6abc5cba64 | ||
|
|
045a328697 | ||
|
|
b6b9ce3ba1 | ||
|
|
f06f33cbf4 | ||
|
|
56a1a19d2c | ||
|
|
84be444689 | ||
|
|
54d276f5e5 | ||
|
|
f70bdbbbe0 | ||
|
|
22a835519b | ||
|
|
ad91776ad9 | ||
|
|
6ea906b575 | ||
|
|
338aecd888 | ||
|
|
3b2d5aa5af |
@@ -84,10 +84,10 @@ jobs:
|
||||
task: ${{ fromJson(needs.prepare.outputs.task_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24.x'
|
||||
cache: 'npm'
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Cache Deno dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/deno
|
||||
key: ${{ runner.os }}-deno-${{ hashFiles('src/apps/cli/testdeno/deno.lock', 'src/apps/cli/testdeno/deno.json') }}
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Show Docker versions
|
||||
run: |
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Derive image tag
|
||||
id: meta
|
||||
@@ -86,22 +86,22 @@ jobs:
|
||||
echo "push=${PUSH}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "24.x"
|
||||
cache: "npm"
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
if: ${{ steps.e2e.outcome == 'success' || (github.event_name == 'workflow_dispatch' && inputs.force) }}
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: src/apps/cli/Dockerfile
|
||||
|
||||
@@ -22,10 +22,10 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24.x'
|
||||
cache: 'npm'
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Show Docker versions
|
||||
run: |
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
|
||||
- name: Upload benchmark results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: cli-p2p-compose-smoke-results
|
||||
path: test/bench-network/bench-results/**
|
||||
|
||||
@@ -36,10 +36,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24.x'
|
||||
cache: 'npm'
|
||||
@@ -85,10 +85,10 @@ jobs:
|
||||
run: npm run test:browser-apps:pages
|
||||
|
||||
- name: Configure GitHub Pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- name: Upload GitHub Pages artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: _site
|
||||
|
||||
@@ -103,4 +103,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
@@ -47,13 +47,13 @@ jobs:
|
||||
fi
|
||||
echo "name=${BRANCH}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ steps.branch.outputs.name }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "24.x"
|
||||
|
||||
|
||||
@@ -33,13 +33,13 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ inputs.base_branch }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "24.x"
|
||||
cache: npm
|
||||
|
||||
@@ -26,12 +26,12 @@ jobs:
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.tag }}
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24.x'
|
||||
- name: Validate release
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
manifest.json
|
||||
styles.css
|
||||
- name: Create Release and Upload Assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: |
|
||||
main.js
|
||||
|
||||
@@ -65,10 +65,10 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24.x'
|
||||
cache: 'npm'
|
||||
@@ -113,10 +113,10 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24.x'
|
||||
cache: 'npm'
|
||||
@@ -127,6 +127,9 @@ jobs:
|
||||
- name: Run source checks
|
||||
run: npm run check
|
||||
|
||||
- name: Run release process tests
|
||||
run: npm run test:release-process
|
||||
|
||||
- name: Run unit tests suite with coverage
|
||||
run: npm run test:unit:coverage
|
||||
|
||||
@@ -135,7 +138,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: unit-coverage-report
|
||||
path: coverage/**
|
||||
@@ -146,7 +149,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Detect LiveSync-owned integration tests
|
||||
id: integration_tests
|
||||
@@ -165,7 +168,7 @@ jobs:
|
||||
|
||||
- name: Setup Node.js
|
||||
if: ${{ steps.integration_tests.outputs.present == 'true' }}
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24.x'
|
||||
cache: 'npm'
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
cff-version: 1.2.0
|
||||
message: "If you use this software, please cite it using the metadata from this file."
|
||||
title: "Self-hosted LiveSync"
|
||||
abstract: "Self-hosted LiveSync is an open-source synchronisation plug-in for Obsidian that replicates note vaults and supporting files across desktop and mobile devices using user-controlled servers, object storage, or direct peer-to-peer connections."
|
||||
type: software
|
||||
authors:
|
||||
- name: "vorotamoroz"
|
||||
website: "https://github.com/vrtmrz"
|
||||
- name: "Self-hosted LiveSync Contributors"
|
||||
repository-code: "https://github.com/vrtmrz/obsidian-livesync"
|
||||
url: "https://github.com/vrtmrz/obsidian-livesync"
|
||||
version: 1.0.23
|
||||
doi: 10.5281/zenodo.22247183
|
||||
date-released: "2026-09-05"
|
||||
license: MIT
|
||||
keywords:
|
||||
- obsidian
|
||||
- obsidian-plugin
|
||||
- synchronisation
|
||||
- local-first
|
||||
- couchdb
|
||||
- pouchdb
|
||||
- webrtc
|
||||
- peer-to-peer
|
||||
@@ -133,6 +133,11 @@ The [Project glossary](docs/glossary.md#developer-and-design-terms) defines the
|
||||
stable developer and design vocabulary used in this section. The guidance
|
||||
below describes how those boundaries are applied.
|
||||
|
||||
For file-event admission versus physical Vault writes, see
|
||||
[File events and storage writes](docs/tech_info.md#file-events-and-storage-writes)
|
||||
and its linked Commonlib contract. Keep regression coverage for those two
|
||||
directions separate when changing deletion handling.
|
||||
|
||||
### Service composition and legacy Modules
|
||||
|
||||
The application is composed from Services, ServiceModules, serviceFeatures, add-ons, and a legacy Module layer:
|
||||
@@ -238,6 +243,21 @@ Commonlib owns the typed English fallback for messages requested by its services
|
||||
- Dev mode creates `ls-debug/` folder in `.obsidian/` for debug outputs (e.g., missing translations)
|
||||
- This causes pretty significant performance overhead.
|
||||
|
||||
#### Diagnostic and notice ownership
|
||||
|
||||
- A Commonlib or service operation should normally record detailed diagnostics at `LOG_LEVEL_VERBOSE` and return a typed result which lets its caller distinguish complete, partial, and failed outcomes. Do not make callers infer an outcome by parsing log text.
|
||||
- Detailed diagnostics may be long and remain in English when they are intended for tracing and the generated report. Include enough context to identify the operation, affected target, and remaining state or retry behaviour.
|
||||
- The application boundary which owns the workflow should decide whether to raise `LOG_LEVEL_NOTICE`. It has the interaction context to describe the user-visible consequence and the next useful action; an internal stage description alone is not a useful notice.
|
||||
- When several files fail, issue one concise summary notice after the operation returns. Keep the per-file paths and technical causes at verbose level so that the notice remains readable and the generated report remains traceable.
|
||||
- Commonlib should raise a notice only when its contract explicitly owns user presentation and no higher-level caller can add the required workflow context.
|
||||
|
||||
The ordinary start-up scan provides a concrete comparison:
|
||||
|
||||
- Good verbose diagnostic: `Offline scan failed to synchronise ${path} between storage and the local database; this path remains eligible for a later scan.` It identifies the operation, the two states being reconciled, the exact target, and what can happen next. Its length is appropriate for a report.
|
||||
- Notice which needs more context: `Local database initialisation did not complete. See the log for details.` It describes an internal stage, but does not tell the user whether synchronisation can continue, what may be affected, or how to obtain the detailed log.
|
||||
- Good application notice for a partial result: `Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.` It states the observable consequence, gives a proportionate action, and leaves the per-file evidence in the report.
|
||||
- Good application notice for a failed result: `Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.` It states the operational consequence without exposing the internal initialisation stage.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Service feature implementation
|
||||
|
||||
@@ -72,6 +72,7 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex
|
||||
### Flag-file recovery order
|
||||
|
||||
- For a configured Vault, evaluate and persist the compatibility gate after settings load, before Obsidian layout-ready recovery begins. This blocks ordinary and one-shot replication even while the review dialogue has not yet opened. An existing unconfigured Vault follows the deferred rule above instead.
|
||||
- Admit configured-only start-up work at priority 1, after ordinary priority-0 layout integration and before flag-file recovery. An unconfigured Vault offers onboarding and returns `false`, so recovery, compatibility review, database preparation, and configured-only request handling do not run. Treat this admission as a property of the current plug-in process: changing `isConfigured` from `false` to `true` requires the scheduled restart before configured work becomes available, and declining that restart deliberately leaves the current process inert. If an admitted process changes `isConfigured` to `false`, retire the Config Doctor and incomplete-document repair request handlers immediately, and recheck the current setting and database readiness when either handler runs.
|
||||
- Preserve the existing ordered flag-file recovery handlers: SCRAM at priority 5, fetch-all at priority 10, and rebuild-all at priority 20. These files express an explicit recovery instruction and may invoke their focused storage or rebuild service while ordinary replication remains gated.
|
||||
- Present the compatibility review at priority 30, after any selected recovery operation. A recovery handler which cancels start-up, keeps SCRAM active, or schedules a restart returns `false`, so the current process does not open a competing compatibility dialogue. If recovery completes and start-up continues, the dialogue opens before normal synchronisation is allowed to resume.
|
||||
- Keep database preparation independent of an unanswered compatibility dialogue, because the compatibility gate already blocks replication. Before Config Doctor begins its interactive checks, await the active initial review so that the two update dialogues cannot overlap.
|
||||
@@ -100,4 +101,4 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex
|
||||
- Unit and Compose tests verify that ordinary P2P replication observes the policy, explicit P2P rebuild uses the setup bypass, and replacement leaves host actions on the current replicator.
|
||||
- A real-Obsidian settings test verifies the dedicated summary and details dialogues, captures representative screenshots, confirms that the acknowledged internal version advances only after explicit resume, and confirms that the Change Log contains no acknowledgement control.
|
||||
- The real-Obsidian CouchDB workflow starts from configured plug-in data without a device-local marker, verifies the copied-or-restored Vault explanation, resumes through the actual dialogue, and then completes remote metadata, chunk, and activity checks. The two-Vault workflow performs the same review once per isolated Vault before reusing the acknowledged device state for later process launches.
|
||||
- Unit tests fix the layout-ready priority after the three flag-file recovery priorities, so a recovery which stops start-up cannot race the compatibility dialogue.
|
||||
- Unit tests fix configured Vault admission at priority 1, the three flag-file recovery priorities at 5, 10, and 20, and compatibility review at priority 30. A recovery which stops start-up therefore cannot race the compatibility dialogue.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
date: 2026-09-04
|
||||
commonlib-version: "0.1.21"
|
||||
self-hosted-livesync-version: "1.0.24"
|
||||
status: unreleased
|
||||
---
|
||||
|
||||
# Path component length compatibility
|
||||
|
||||
## Purpose
|
||||
|
||||
File systems place limits on each file or folder name, rather than applying one
|
||||
common limit to an entire Vault-relative path. Those limits are also expressed
|
||||
in different units. Self-hosted LiveSync therefore treats 255 UTF-8 bytes as a
|
||||
focused Android and Linux compatibility warning, not as a universal definition
|
||||
of a valid path.
|
||||
|
||||
## Basis for the 255-byte warning
|
||||
|
||||
- The Linux kernel documentation gives ext4 a maximum file-name length of
|
||||
[255 bytes](https://www.kernel.org/doc/html/latest/filesystems/ext4/directory.html).
|
||||
- The F2FS on-disk header defines
|
||||
[`F2FS_NAME_LEN` as 255](https://android.googlesource.com/kernel/common/+/88d92fb1c034922572bab93482ac9cc61d4ba43c/include/linux/f2fs_fs.h)
|
||||
and stores names in byte arrays.
|
||||
- Android's MediaProvider uses a
|
||||
[`MAX_FILENAME_BYTES` value of 255](https://android.googlesource.com/platform/packages/providers/MediaProvider/+/bae279463/src/com/android/providers/media/util/FileUtils.java)
|
||||
when building file names. Its source notes that emulated storage can write to
|
||||
ext4 through FUSE, where names are encoded as UTF-8.
|
||||
- Android 11 and later use
|
||||
[FUSE for emulated storage](https://source.android.com/docs/core/storage/fuse-passthrough),
|
||||
with requests passing through to the underlying file system.
|
||||
|
||||
Together, these provide a conservative compatibility boundary for file names
|
||||
which may reach Android or Linux storage. They do not show that every Android
|
||||
device, storage provider, or Linux file system has the same limit.
|
||||
|
||||
## Why the rule is not universal
|
||||
|
||||
Other platforms describe component limits differently. Microsoft's file-system
|
||||
comparison documents limits in
|
||||
[Unicode characters](https://learn.microsoft.com/en-us/windows/win32/fileio/filesystem-functionality-comparison),
|
||||
not UTF-8 bytes. Apple's HFS Plus format stores a name as up to
|
||||
[255 16-bit `UniChar` values](https://developer.apple.com/library/archive/technotes/tn/tn1150.html).
|
||||
Apple's APFS guidance discusses valid UTF-8 names, normalisation, and case
|
||||
sensitivity, but does not establish a universal
|
||||
[255-byte component rule](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/APFS_Guide/FAQ/FAQ.html).
|
||||
|
||||
A name can consequently exceed 255 UTF-8 bytes and still work on one platform,
|
||||
or fail for another platform-specific reason while remaining below this
|
||||
boundary.
|
||||
|
||||
## Product policy
|
||||
|
||||
Self-hosted LiveSync applies the warning as follows:
|
||||
|
||||
1. split the Vault-relative path on `/` and inspect each non-empty component;
|
||||
2. measure each component after UTF-8 encoding;
|
||||
3. accept 255 bytes without this warning and warn at 256 bytes or more;
|
||||
4. identify every over-limit file or folder name in the active-file status;
|
||||
5. do not reject, truncate, or rename the path; and
|
||||
6. treat the result of the real storage operation as authoritative.
|
||||
|
||||
If a scan cannot process an individual file, its path is recorded in the
|
||||
verbose log and remains eligible for a later retry. Ordinary start-up may still
|
||||
become ready so that unaffected files can synchronise. Explicit Fetch and
|
||||
Rebuild operations retain strict scan completion because they establish an
|
||||
authoritative local or remote state.
|
||||
|
||||
This policy does not replace the existing checks for reserved characters,
|
||||
case collisions, ignore rules, or configured file-size limits.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
date: 2026-08-30
|
||||
commonlib-version: "0.1.19"
|
||||
self-hosted-livesync-version: "1.0.21"
|
||||
date: 2026-09-04
|
||||
commonlib-version: "0.1.21"
|
||||
self-hosted-livesync-version: "1.0.24"
|
||||
status: accepted
|
||||
---
|
||||
|
||||
@@ -34,8 +34,9 @@ Do not select `AbstractModule` or `AbstractObsidianModule` merely to obtain conv
|
||||
3. construct and register built-in and host-supplied Modules;
|
||||
4. compose the built-in Commonlib serviceFeatures;
|
||||
5. compose host-supplied serviceFeatures;
|
||||
6. construct add-ons; and
|
||||
7. call `onBindFunction()` for each registered Module.
|
||||
6. construct add-ons;
|
||||
7. compose the late core serviceFeatures whose handlers must follow host features and add-ons; and
|
||||
8. call `onBindFunction()` for each registered Module.
|
||||
|
||||
The Module constructor therefore runs before its handler bindings, while the complete Service Hub and ServiceModules already exist. `bindModuleFunctions()` then invokes every `onBindFunction()` and runs `__$checkInstanceBinding()`. That diagnostic compares underscore-prefixed prototype methods with method references found in the source text of `onBindFunction()`.
|
||||
|
||||
@@ -127,54 +128,31 @@ This split allows tests to verify:
|
||||
|
||||
The operation does not need an application Module identity.
|
||||
|
||||
### Ordered start-up composition and registration-only features
|
||||
|
||||
Configured Vault admission and the checks which follow database preparation are composed by `src/serviceFeatures/startupLifecycle/`. The directory keeps onboarding admission, compromised-chunk inspection, incomplete-document repair, Config Doctor, and the obsolete bulk-send setting migration as separate operations. One feature composer owns their order and receives the compatibility-review wait operation explicitly; an individual operation does not call the composer.
|
||||
|
||||
The layout-ready admission handler uses priority 1. This preserves ordinary priority-0 host integration before admission, while keeping an unconfigured Vault outside the flag-file recovery handlers at priorities 5, 10, and 20, and the compatibility review at priority 30. Admission belongs to one plug-in process: an initially unconfigured process remains inert until setup restarts it, and declining the requested restart does not trigger an in-process reconfiguration. Changing an admitted process back to unconfigured retires its Config Doctor and incomplete-document repair request handlers. The handlers also recheck the current configured state and database readiness when invoked, so a pending restart cannot expose partially initialised or retired state. The first-initialise handler rechecks admission before retaining the established order after the file watcher has been started: database readiness, compromised chunks, incomplete documents, compatibility review, Config Doctor, and the bulk-send setting migration.
|
||||
|
||||
Command and ribbon registration are serviceFeatures for the same dependency-visibility reason, but they are not start-up migrations. The basic commands remain a host-neutral feature composed by `LiveSyncBaseCore`, while the replication ribbon remains an Obsidian-only feature composed by the Obsidian host. Both retain `onInitialise` registration so moving them out of the Module list does not make their effects run during construction.
|
||||
|
||||
### Private state and ordered handlers: target filters
|
||||
|
||||
Commonlib's `targetFilter.ts` keeps each cache or readiness gate in the factory which owns one predicate. `useTargetFilters()` constructs those predicates and registers them in their required order.
|
||||
|
||||
The state remains private to the composed feature. It does not become a `LiveSyncBaseCore` property or a ServiceModule merely because it persists across calls.
|
||||
|
||||
### Legacy example to improve when touched: conflict checking
|
||||
### Implemented composition: conflict resolution
|
||||
|
||||
`ModuleConflictChecker` currently combines:
|
||||
Conflict checking and resolution are composed for every host by `useConflictResolutionFeature`. The feature owns its `QueueProcessor` privately and registers the conflict Service handlers directly. Its operations receive explicit collaborators for settings, active-file state, database and storage access, replication, logging, and host events. No consumer locates a conflict Module or retains the queue.
|
||||
|
||||
- conflict policy decisions;
|
||||
- two `QueueProcessor` owners;
|
||||
- cancellation signalling;
|
||||
- access to settings and active-file state; and
|
||||
- registration into the conflict Service.
|
||||
The scheduling queue remains one state owner. It publishes `conflictProcessQueueCount`, coalesces pending checks for the same path, and makes `ensureAllProcessed()` wait for conflict resolution to finish. Repeated resolver invocations for one path retain only the newest waiting request and close an active comparison for that path before waiting for the per-file resolver, while comparisons for other paths remain open. Resolution remains host-neutral and communicates dialogue cancellation through `services.context.events`, so CLI, WebApp, and Obsidian compositions use their own selected event channel.
|
||||
|
||||
Its queues are class fields which dereference `this.services` during field initialisation, and its public handlers are bound later in `onBindFunction()`.
|
||||
Interactive resolution is a separate Obsidian-owned serviceFeature. It registers the manual conflict handler, commands, start-up scan, unresolved-message contribution, cancellation listener, and unload clean-up. Its postponed-conflict set, active dialogue, and dialogue queue are private, session-local state. Manual comparisons are shown one at a time: a request for the active file publishes `EVENT_CONFLICT_CANCELLED` to cancel and replace its dialogue, while a request for another file waits. A resolution received through replication closes an open dialogue for the resolved path through the same event, or discards its waiting request before a stale dialogue can open. On unload, the feature drops waiting requests and publishes the same event for the active path before the host event channel is retired, so the dialogue closes and its waiting operation completes. The feature receives a dialogue-opening adapter and connects to the common feature only through the conflict Service; it does not expose an Obsidian application or dialogue as a general capability.
|
||||
|
||||
A bounded change to this area should prefer a shape such as:
|
||||
Both operation layers acquire the active local database through an operation-time accessor. Composition occurs before the database is opened, and a reset may replace the active instance, so retaining the database object at composition time would violate both start-up and reset boundaries.
|
||||
|
||||
```typescript
|
||||
interface ConflictCheckContext {
|
||||
readonly checkQueue: QueueProcessor<FilePathWithPrefix, unknown>;
|
||||
readonly resolveQueue: QueueProcessor<FilePathWithPrefix, unknown>;
|
||||
}
|
||||
|
||||
interface ConflictCheckDependencies {
|
||||
readonly conflict: ConflictCapability;
|
||||
readonly currentSettings: () => ConflictSettings;
|
||||
readonly getActiveFilePath: () => FilePathWithPrefix | undefined;
|
||||
readonly log: LogFunction;
|
||||
}
|
||||
|
||||
function queueConflictCheck(
|
||||
context: ConflictCheckContext,
|
||||
dependencies: ConflictCheckDependencies,
|
||||
path: FilePathWithPrefix
|
||||
): Promise<void> {
|
||||
// Make the decision and enqueue through explicit collaborators.
|
||||
}
|
||||
|
||||
export function useConflictChecking(host: ConflictCheckingHost): void {
|
||||
const context = createConflictCheckContext(host);
|
||||
host.services.conflict.queueCheckFor.setHandler((path) => queueConflictCheck(context, dependencies, path));
|
||||
}
|
||||
```
|
||||
|
||||
The exact extraction should be made only when conflict-checking behaviour changes. The example describes the intended ownership boundary; it is not a request to convert the Module in an unrelated documentation change.
|
||||
`ConflictResolveModal` remains a focused class. One instance owns one dialogue's result promise, event subscription, and close lifetime, which is stable identity and resource ownership rather than application composition. This preserves the distinction between a useful object lifetime and a legacy Module used as a service locator.
|
||||
|
||||
## Interaction-based testing
|
||||
|
||||
|
||||
@@ -2,6 +2,88 @@
|
||||
|
||||
This document contains earlier published releases from the 1.0 line of the [current Self-hosted LiveSync release history](../../updates.md). Beta and release-candidate builds published before 1.0.0 are recorded in the [1.0 preview history](1.0-previews.md). Earlier release lines continue in the [0.25 history](0.25.md) and the [legacy history](legacy.md).
|
||||
|
||||
## 1.0.21
|
||||
|
||||
26th August, 2026
|
||||
|
||||
It is becoming more 'ordinary' with each release, but please let me know if anything has become less convenient.
|
||||
|
||||
### Interface and translation
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Remote Configuration section headings no longer overlap their contents when scrolling on mobile. Action buttons in Remote Configuration, Maintenance, and Patches now remain inside the settings pane on narrow screens.
|
||||
|
||||
## 1.0.20
|
||||
|
||||
~~1.0.19~~ was cancelled because prerelease validation exposed an incorrect warning at start-up.
|
||||
|
||||
25th August, 2026
|
||||
|
||||
I know this is the second time I have said it, but I had grown quite fond of the settings screen. It seems, however, that a simpler, healthier life is called for.
|
||||
|
||||
### Interface and translation
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Compatibility pause warnings now direct you to the dedicated compatibility review instead of the Change Log.
|
||||
- The Obsidian 1.13 settings page now waits for saved settings before choosing its initial layout. This prevents a spurious missing-replicator warning at start-up, keeps configured devices on the Synchronisation-first layout even when automatic synchronisation triggers are disabled, and keeps Quick Setup first on unconfigured devices.
|
||||
|
||||
#### Improved
|
||||
|
||||
- Settings page names, controls in General Settings, Quick Setup actions, and Advanced controls now use Obsidian 1.13's native settings interface and global search, while retaining their familiar icons. The landing page keeps Remote Configuration and Sync Settings together, places Appearance, Logging, and Extra menus under General Settings, and groups maintenance, optional features, advanced settings, and help by purpose. Earlier supported Obsidian versions continue to use the pane-based interface.
|
||||
- Settings changes which require database initialisation now use a focused Setup Manager dialogue to choose between existing synchronisation data and the files in the current Vault. The selected reset or rebuild is reserved before the settings are saved, while cancelling offers a separate, explicit settings-only fallback.
|
||||
|
||||
## 1.0.18
|
||||
|
||||
24th August, 2026
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Reset and rebuild workflows now use the local database selected by their updated settings, preventing stale data from reopening after a **Database Suffix** change. If database initialisation does not complete, the workflow remains paused instead of continuing with incomplete state.
|
||||
|
||||
#### Improved
|
||||
|
||||
- Rebuilds now recheck restored file events against the current Vault, use current file contents, and finish processing them before the plug-in reports readiness.
|
||||
|
||||
## 1.0.17
|
||||
|
||||
23rd August, 2026
|
||||
|
||||
### Interface and translation
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Settings generated from the settings manifest, Setup Wizard configuration summaries, and warnings about externally changed settings now honour **Display language** when a translation is available, instead of remaining in English (PR #1123). Thank you to @nimula for the contribution!
|
||||
|
||||
### Peer-to-peer synchronisation
|
||||
|
||||
#### Improved
|
||||
|
||||
- P2P connection profiles now provide four **P2P message size** presets and a **Connection path** choice between **Automatic** and **TURN relay only**. Smaller messages can improve compatibility on paths which fragment or drop larger WebRTC messages, while relay-only routing requires a configured TURN server. P2P connection strings and encrypted Setup URIs preserve both choices.
|
||||
- Thank you to @andrewschreiber for the detailed fragmentation diagnosis and working 800-byte threshold in vrtmrz/livesync-commonlib#97, which informed this compatibility design.
|
||||
- An optional self-hosted Coturn Compose starter is now available for P2P deployments that need a TURN relay. It uses a pinned upstream image and documents its network, credential, security, and verification boundaries.
|
||||
|
||||
## 1.0.16
|
||||
|
||||
19th August, 2026
|
||||
|
||||
### Conflict handling and recovery
|
||||
|
||||
#### Fixed
|
||||
|
||||
- **Back to this revision** in Document History now restores the selected content as a new non-deleted successor revision before reflecting it to the Vault. A readable revision restored after a logical deletion therefore remains restored through later synchronisation instead of being overwritten by the deletion.
|
||||
- If the file changes while restoration is in progress, the operation stops instead of extending a stale revision. Existing conflicts remain available through **Inspect conflicts and file/database differences**.
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Improved
|
||||
|
||||
- One-shot CouchDB synchronisation now releases stalled web-compatible connection checks before replication starts, so a later synchronisation can make a fresh attempt (Commonlib 0.1.16).
|
||||
- The 60-second safeguard applies only to pre-replication checks. It does not limit ordinary synchronisation, and the **Use Internal API** path is unchanged.
|
||||
|
||||
## 1.0.15
|
||||
|
||||
15th August, 2026
|
||||
|
||||
@@ -11,6 +11,26 @@
|
||||
|
||||
Note: The figure is drawn as single-directional, between two devices for demonstration purposes. Everything actually occurs bi-directionally between many devices at the same time.
|
||||
|
||||
## File events and storage writes
|
||||
|
||||
File events describe changes observed in the Vault. Commonlib filters and
|
||||
serialises those events before updating file Metadata in the local database.
|
||||
A queued `DELETE` therefore requests a database change; it is not itself an
|
||||
instruction to delete the physical file. A rename out of the selected files
|
||||
can also become a database deletion while the destination remains on disk.
|
||||
|
||||
The opposite direction starts with database Metadata. Replicated changes and
|
||||
full scans can call the database-to-storage handler, which writes or removes
|
||||
Vault files subject to its conflict and content-preservation rules. Preventing
|
||||
a stale file event from deleting Metadata and applying a valid replicated
|
||||
deletion are separate decisions.
|
||||
|
||||
Commonlib's [Storage events and database-to-storage reflection](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/storage-events-and-reflection.md)
|
||||
documents the event boundary, the deletion revalidation introduced in
|
||||
Commonlib 0.1.23, and its limits. In particular, deletion protection does not
|
||||
promise full support for external folder case changes or convergence of path
|
||||
spelling.
|
||||
|
||||
## Current technical references
|
||||
|
||||
- [Database Data Structures](datastructure.md) describes current Metadata and
|
||||
@@ -22,6 +42,9 @@ Note: The figure is drawn as single-directional, between two devices for demonst
|
||||
defines the current revision-tree and file-provenance rules.
|
||||
- [Chunk Retrieval and Waiting](design_docs/chunk_retrieval_and_waiting.md)
|
||||
defines missing-Chunk arrival and quiescence handling.
|
||||
- [Path component length compatibility](design_docs/path_component_length_compatibility.md)
|
||||
explains why 255 UTF-8 bytes is an Android and Linux compatibility warning,
|
||||
rather than a universal rule for deciding whether a path is valid.
|
||||
- [Data Compression](specs_data_compression.md) and [Garbage Collection
|
||||
V3](specs_garbage_collection.md) describe their respective storage and
|
||||
maintenance contracts.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "1.0.24",
|
||||
"version": "1.0.27",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
|
||||
Generated
+174
-446
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.24",
|
||||
"version": "1.0.27",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.24",
|
||||
"version": "1.0.27",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"src/apps/cli",
|
||||
@@ -23,16 +23,15 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.21",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.23",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"fflate": "^0.8.2",
|
||||
"idb": "^8.0.3",
|
||||
"markdown-it": "^14.2.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"obsidian": "^1.13.1",
|
||||
"octagonal-wheels": "^0.1.53",
|
||||
"octagonal-wheels": "^0.1.54",
|
||||
"qrcode-generator": "^1.4.4",
|
||||
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
|
||||
},
|
||||
@@ -56,7 +55,7 @@
|
||||
"@types/pouchdb-mapreduce": "^6.1.10",
|
||||
"@types/pouchdb-replication": "^6.4.7",
|
||||
"@types/transform-pouch": "^1.0.6",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@typescript-eslint/parser": "8.69.0",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@vrtmrz/obsidian-test-session": "0.2.6",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
@@ -86,13 +85,13 @@
|
||||
"svelte": "5.56.3",
|
||||
"svelte-check": "^4.6.0",
|
||||
"svelte-eslint-parser": "^1.8.0",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"svelte-preprocess": "6.0.5",
|
||||
"terser": "^5.39.0",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"transform-pouch": "^2.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.69.0",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.8",
|
||||
"yaml": "^2.8.2"
|
||||
@@ -2054,29 +2053,43 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@humanfs/types": "^0.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/node": {
|
||||
"version": "0.16.7",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
|
||||
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
|
||||
"version": "0.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
|
||||
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@humanfs/core": "^0.19.1",
|
||||
"@humanfs/core": "^0.19.2",
|
||||
"@humanfs/types": "^0.15.0",
|
||||
"@humanwhocodes/retry": "^0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/types": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
|
||||
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/module-importer": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
|
||||
@@ -4157,17 +4170,56 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz",
|
||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz",
|
||||
"integrity": "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
"@typescript-eslint/typescript-estree": "8.56.1",
|
||||
"@typescript-eslint/visitor-keys": "8.56.1",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.69.0",
|
||||
"@typescript-eslint/type-utils": "8.69.0",
|
||||
"@typescript-eslint/utils": "8.69.0",
|
||||
"@typescript-eslint/visitor-keys": "8.69.0",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.69.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||
"version": "7.0.8",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz",
|
||||
"integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz",
|
||||
"integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.69.0",
|
||||
"@typescript-eslint/types": "8.69.0",
|
||||
"@typescript-eslint/typescript-estree": "8.69.0",
|
||||
"@typescript-eslint/visitor-keys": "8.69.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4179,18 +4231,18 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
|
||||
"integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==",
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz",
|
||||
"integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.56.1",
|
||||
"@typescript-eslint/types": "^8.56.1",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.69.0",
|
||||
"@typescript-eslint/types": "^8.69.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4201,18 +4253,18 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz",
|
||||
"integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==",
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz",
|
||||
"integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
"@typescript-eslint/visitor-keys": "8.56.1"
|
||||
"@typescript-eslint/types": "8.69.0",
|
||||
"@typescript-eslint/visitor-keys": "8.69.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -4223,9 +4275,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz",
|
||||
"integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==",
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz",
|
||||
"integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4236,13 +4288,38 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz",
|
||||
"integrity": "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.69.0",
|
||||
"@typescript-eslint/typescript-estree": "8.69.0",
|
||||
"@typescript-eslint/utils": "8.69.0",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz",
|
||||
"integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==",
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz",
|
||||
"integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4254,139 +4331,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz",
|
||||
"integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==",
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz",
|
||||
"integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.56.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
"@typescript-eslint/visitor-keys": "8.56.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz",
|
||||
"integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.61.1",
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"@typescript-eslint/typescript-estree": "8.61.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz",
|
||||
"integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.61.1",
|
||||
"@typescript-eslint/types": "^8.61.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz",
|
||||
"integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"@typescript-eslint/visitor-keys": "8.61.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz",
|
||||
"integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz",
|
||||
"integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz",
|
||||
"integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.61.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.61.1",
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"@typescript-eslint/visitor-keys": "8.61.1",
|
||||
"@typescript-eslint/project-service": "8.69.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.69.0",
|
||||
"@typescript-eslint/types": "8.69.0",
|
||||
"@typescript-eslint/visitor-keys": "8.69.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -4404,15 +4358,17 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz",
|
||||
"integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==",
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz",
|
||||
"integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.69.0",
|
||||
"@typescript-eslint/types": "8.69.0",
|
||||
"@typescript-eslint/typescript-estree": "8.69.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -4420,29 +4376,20 @@
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils/node_modules/eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz",
|
||||
"integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==",
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz",
|
||||
"integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
"@typescript-eslint/types": "8.69.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4620,9 +4567,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.1.21",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.21.tgz",
|
||||
"integrity": "sha512-AGuZ3eqBP37HJXEkTSpJ5M5bvTx2lYNq+6Q5NuCPZeGdbv7g6cujGvccVR5ozGfKdHGSyFZND5x1oFS9crRhUg==",
|
||||
"version": "0.1.23",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.23.tgz",
|
||||
"integrity": "sha512-hsaz2N04qNqM9HL0B+d5G/do1T0fe6Y4gVK3IueXvEnM6HM+3Jp17mdjbLn93FUOxkUVMcn4M+zIwPuppryVbw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
@@ -7214,9 +7161,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
@@ -9682,9 +9629,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/octagonal-wheels": {
|
||||
"version": "0.1.53",
|
||||
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.53.tgz",
|
||||
"integrity": "sha512-4NJsb96Sk6rJXhrTyjAY5GRIoWMFJsFp56b5ba9fV8/87ys2HCjMo0hior4F8k4ma72pLTJT7i6DvuihHxSsMA==",
|
||||
"version": "0.1.54",
|
||||
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.54.tgz",
|
||||
"integrity": "sha512-Je3ancYhjKX7UY2K19T/qTjG8C9nK8YVrACr5naIf78mN4bbjQkYyWmlj+ooifV/moWVsQrp4fEWz/7mv6It3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"idb": "^8.0.3"
|
||||
@@ -11521,9 +11468,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-preprocess": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-6.0.3.tgz",
|
||||
"integrity": "sha512-PLG2k05qHdhmRG7zR/dyo5qKvakhm8IJ+hD2eFRQmMLHp7X3eJnjeupUtvuRpbNiF31RjVw45W+abDwHEmP5OA==",
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-6.0.5.tgz",
|
||||
"integrity": "sha512-sgwew5yV/2eMeQobIWgAxCNarKwiTUDIc3siAUbq3sp0G6ONtzk0W+wJihMdqjbYb3iGU3ubpGv0usnnuXT3qg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -11541,7 +11488,7 @@
|
||||
"stylus": ">=0.55",
|
||||
"sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0",
|
||||
"svelte": "^4.0.0 || ^5.0.0-next.100 || ^5.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
"typescript": "^5.0.0 || ^6.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@babel/core": {
|
||||
@@ -11993,9 +11940,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -12007,16 +11954,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz",
|
||||
"integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==",
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.69.0.tgz",
|
||||
"integrity": "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"@typescript-eslint/typescript-estree": "8.61.1",
|
||||
"@typescript-eslint/utils": "8.61.1"
|
||||
"@typescript-eslint/eslint-plugin": "8.69.0",
|
||||
"@typescript-eslint/parser": "8.69.0",
|
||||
"@typescript-eslint/typescript-estree": "8.69.0",
|
||||
"@typescript-eslint/utils": "8.69.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -12030,225 +11977,6 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz",
|
||||
"integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.61.1",
|
||||
"@typescript-eslint/type-utils": "8.61.1",
|
||||
"@typescript-eslint/utils": "8.61.1",
|
||||
"@typescript-eslint/visitor-keys": "8.61.1",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.61.1",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz",
|
||||
"integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.61.1",
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"@typescript-eslint/typescript-estree": "8.61.1",
|
||||
"@typescript-eslint/visitor-keys": "8.61.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz",
|
||||
"integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.61.1",
|
||||
"@typescript-eslint/types": "^8.61.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz",
|
||||
"integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"@typescript-eslint/visitor-keys": "8.61.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz",
|
||||
"integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz",
|
||||
"integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"@typescript-eslint/typescript-estree": "8.61.1",
|
||||
"@typescript-eslint/utils": "8.61.1",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz",
|
||||
"integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz",
|
||||
"integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.61.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.61.1",
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"@typescript-eslint/visitor-keys": "8.61.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz",
|
||||
"integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
@@ -12937,11 +12665,11 @@
|
||||
},
|
||||
"src/apps/cli": {
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"version": "1.0.24-cli",
|
||||
"version": "1.0.27-cli",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"octagonal-wheels": "^0.1.53",
|
||||
"octagonal-wheels": "^0.1.54",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||
"pouchdb-core": "^9.0.0",
|
||||
@@ -12955,28 +12683,28 @@
|
||||
"werift": "^0.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.9.3",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
},
|
||||
"src/apps/webapp": {
|
||||
"name": "livesync-webapp",
|
||||
"version": "1.0.24-webapp",
|
||||
"version": "1.0.27-webapp",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.53"
|
||||
"octagonal-wheels": "^0.1.54"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"svelte": "5.56.3",
|
||||
"typescript": "5.9.3",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
},
|
||||
"src/apps/webpeer": {
|
||||
"version": "1.0.24-webpeer",
|
||||
"version": "1.0.27-webpeer",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.53"
|
||||
"octagonal-wheels": "^0.1.54"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
@@ -12984,7 +12712,7 @@
|
||||
"eslint-plugin-svelte": "^3.19.0",
|
||||
"svelte": "5.56.3",
|
||||
"svelte-check": "^4.6.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
+18
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.24",
|
||||
"version": "1.0.27",
|
||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
@@ -30,6 +30,7 @@
|
||||
"i18n:json2yaml": "tsx _tools/json2yaml.ts",
|
||||
"i18n:yaml2json": "tsx _tools/yaml2json.ts",
|
||||
"test:unit": "vitest run --config vitest.config.unit.ts",
|
||||
"test:release-process": "vitest run --config vitest.config.unit.ts utils/release-process.unit.spec.ts",
|
||||
"build:browser-apps": "npm run build --workspace livesync-webapp --workspace webpeer",
|
||||
"test:browser-apps": "npm run test:browser --workspace livesync-webapp && npm run test:browser --workspace webpeer",
|
||||
"test:browser-apps:pages": "deno test -A --no-check --frozen --config test/browser-apps/deno.json --lock test/browser-apps/deno.lock test/browser-apps/pages/browser-smoke.test.ts",
|
||||
@@ -46,7 +47,7 @@
|
||||
"test:e2e:cli:p2p": "npm run test:e2e:p2p --workspace self-hosted-livesync-cli",
|
||||
"test:e2e:cli:all": "npm run test:e2e:all --workspace self-hosted-livesync-cli",
|
||||
"test:integration": "npx dotenv-cli -e .env -e .test.env -- vitest run --config vitest.config.integration.ts",
|
||||
"test:unit:coverage": "vitest run --config vitest.config.unit.ts --coverage",
|
||||
"test:unit:coverage": "vitest run --config vitest.config.unit.ts --coverage --exclude utils/release-process.unit.spec.ts",
|
||||
"test:e2e:obsidian:install-appimage": "tsx test/e2e-obsidian/scripts/install-appimage.ts",
|
||||
"test:e2e:obsidian:runner": "vitest run --config vitest.config.e2e-runner.ts",
|
||||
"test:e2e:obsidian:discover": "tsx test/e2e-obsidian/scripts/discover.ts",
|
||||
@@ -69,10 +70,12 @@
|
||||
"test:e2e:obsidian:cli-to-obsidian-sync": "tsx test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts",
|
||||
"test:e2e:obsidian:minio-upload": "tsx test/e2e-obsidian/scripts/minio-upload.ts",
|
||||
"test:e2e:obsidian:object-storage-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts",
|
||||
"test:e2e:obsidian:object-storage-custom-http-handler-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts --custom-http-handler",
|
||||
"test:e2e:obsidian:p2p-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts",
|
||||
"pretest:e2e:obsidian:p2p-connection-check": "npm run build && npm run build --workspace webpeer",
|
||||
"test:e2e:obsidian:p2p-connection-check": "tsx test/e2e-obsidian/scripts/p2p-connection-check.ts",
|
||||
"test:e2e:obsidian:p2p-connection-check:services": "npm run test:e2e:obsidian:p2p-connection-check -- --manage-p2p",
|
||||
"test:e2e:obsidian:partial-startup-file-failure": "tsx test/e2e-obsidian/scripts/partial-startup-file-failure.ts",
|
||||
"test:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts",
|
||||
"test:e2e:obsidian:setup-uri-workflow": "tsx test/e2e-obsidian/scripts/setup-uri-workflow.ts",
|
||||
"test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts",
|
||||
@@ -126,7 +129,7 @@
|
||||
"@types/pouchdb-mapreduce": "^6.1.10",
|
||||
"@types/pouchdb-replication": "^6.4.7",
|
||||
"@types/transform-pouch": "^1.0.6",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@typescript-eslint/parser": "8.69.0",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@vrtmrz/obsidian-test-session": "0.2.6",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
@@ -156,13 +159,13 @@
|
||||
"svelte": "5.56.3",
|
||||
"svelte-check": "^4.6.0",
|
||||
"svelte-eslint-parser": "^1.8.0",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"svelte-preprocess": "6.0.5",
|
||||
"terser": "^5.39.0",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"transform-pouch": "^2.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.69.0",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.8",
|
||||
"yaml": "^2.8.2"
|
||||
@@ -177,16 +180,15 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.21",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.23",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"fflate": "^0.8.2",
|
||||
"idb": "^8.0.3",
|
||||
"markdown-it": "^14.2.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"obsidian": "^1.13.1",
|
||||
"octagonal-wheels": "^0.1.53",
|
||||
"octagonal-wheels": "^0.1.54",
|
||||
"qrcode-generator": "^1.4.4",
|
||||
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
|
||||
},
|
||||
@@ -202,5 +204,11 @@
|
||||
"src/apps/cli",
|
||||
"src/apps/webpeer",
|
||||
"src/apps/webapp"
|
||||
]
|
||||
],
|
||||
"allowScripts": {
|
||||
"esbuild@0.28.1": true,
|
||||
"leveldown@5.6.0": true,
|
||||
"leveldown@6.1.1": true,
|
||||
"svelte-preprocess": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Self-hosted LiveSync technical paper manuscript
|
||||
|
||||
This directory contains a technical manuscript prepared in the format of the Journal of Open Source Software (JOSS) to document the design intent, architecture, and workflow context of Self-hosted LiveSync.
|
||||
|
||||
This document is not currently published as a formal journal paper; rather, it serves as an architectural overview explaining the project's background and replication model (describing Self-hosted LiveSync 1.0.23 pinned to Commonlib 0.1.21). If you reference or utilise Self-hosted LiveSync in academic research, laboratory workflows, or technical publications, citing the software via [CITATION.cff](../CITATION.cff) or this manuscript is greatly appreciated.
|
||||
|
||||
## Contents
|
||||
|
||||
- [paper.md](paper.md): English manuscript.
|
||||
- [paper.ja.md](paper.ja.md): Japanese reference translation.
|
||||
- [paper.bib](paper.bib): Shared bibliography.
|
||||
|
||||
## Citing Self-hosted LiveSync
|
||||
|
||||
Please refer to the repository's [CITATION.cff](../CITATION.cff) file or the metadata recorded in [paper.bib](paper.bib) if you wish to cite this software in your research papers, technical reports, or presentations.
|
||||
|
||||
## Feedback and Contributions
|
||||
|
||||
For corrections, suggestions, or questions regarding the manuscript, please open an [issue](https://github.com/vrtmrz/obsidian-livesync/issues) or submit a pull request.
|
||||
|
||||
---
|
||||
|
||||
## 本原稿について
|
||||
|
||||
本ディレクトリーには、Journal of Open Source Software(JOSS)の形式を想定し、Self-hosted LiveSync の設計意図やアーキテクチャー、および運用の背景をまとめた原稿を配置しています。
|
||||
|
||||
本稿は現時点で正式に出版された論文ではなく、プロジェクトの背景や同期モデルを整理した技術資料として作成されたものです(Commonlib 0.1.21 に固定された Self-hosted LiveSync 1.0.23 を基準としています)。もし学術研究、実験ノートの管理、あるいは技術レポート等で Self-hosted LiveSync を利用・言及される機会がありましたら、リポジトリーの [CITATION.cff](../CITATION.cff) や本稿を引用していただけますと幸いです。
|
||||
日本語版は翻訳メモリーを使用可能な逐語訳として記述されており、技術的な意味論の正確性は英語版を基準としています。
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
@inproceedings{kleppmann2019localfirst,
|
||||
author = {Kleppmann, Martin and Wiggins, Adam and van Hardenberg, Peter and McGranaghan, Mark},
|
||||
title = {Local-first software: you own your data, in spite of the cloud},
|
||||
booktitle = {Proceedings of the 2019 ACM SIGPLAN International Symposium on New Ideas, New Paradigms, and Reflections on Programming and Software},
|
||||
pages = {154--178},
|
||||
year = {2019},
|
||||
publisher = {Association for Computing Machinery},
|
||||
doi = {10.1145/3359591.3359737},
|
||||
url = {https://doi.org/10.1145/3359591.3359737}
|
||||
}
|
||||
|
||||
@software{selfhostedlivesync,
|
||||
author = {{vorotamoroz} and {Self-hosted LiveSync Contributors}},
|
||||
title = {vrtmrz/obsidian-livesync: 1.0.23},
|
||||
version = {1.0.23},
|
||||
year = {2026},
|
||||
publisher = {Zenodo},
|
||||
doi = {10.5281/zenodo.22247183},
|
||||
url = {https://doi.org/10.5281/zenodo.22247183}
|
||||
}
|
||||
|
||||
@software{commonlib,
|
||||
author = {{vorotamoroz} and {livesync-commonlib Contributors}},
|
||||
title = {vrtmrz/livesync-commonlib: 0.1.19},
|
||||
version = {0.1.19},
|
||||
year = {2026},
|
||||
publisher = {Zenodo},
|
||||
doi = {10.5281/zenodo.22074979},
|
||||
url = {https://doi.org/10.5281/zenodo.22074979}
|
||||
}
|
||||
|
||||
@software{commonlib021,
|
||||
author = {{vorotamoroz} and {livesync-commonlib Contributors}},
|
||||
title = {livesync-commonlib: Platform-independent replication and synchronisation engine for Self-hosted LiveSync},
|
||||
version = {0.1.21},
|
||||
year = {2026},
|
||||
publisher = {npm},
|
||||
url = {https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.21.tgz}
|
||||
}
|
||||
|
||||
@software{fancykit,
|
||||
author = {{vorotamoroz}},
|
||||
title = {vrtmrz/fancy-kit: Fancy Kit repository snapshot 2026.08.24.1},
|
||||
version = {fancy-kit-2026.08.24.1},
|
||||
year = {2026},
|
||||
publisher = {Zenodo},
|
||||
doi = {10.5281/zenodo.22088208},
|
||||
url = {https://doi.org/10.5281/zenodo.22088208}
|
||||
}
|
||||
|
||||
@misc{selfhostedlivesyncrepo,
|
||||
author = {{vorotamoroz} and {Self-hosted LiveSync Contributors}},
|
||||
title = {Self-hosted LiveSync source repository},
|
||||
year = {2026},
|
||||
url = {https://github.com/vrtmrz/obsidian-livesync},
|
||||
urldate = {2026-09-02}
|
||||
}
|
||||
|
||||
@misc{obsidian,
|
||||
author = {{Dynalist Inc.}},
|
||||
title = {Obsidian: A knowledge base that works on local Markdown files},
|
||||
year = {2026},
|
||||
url = {https://obsidian.md}
|
||||
}
|
||||
|
||||
@misc{couchdb,
|
||||
author = {{The Apache Software Foundation}},
|
||||
title = {Apache CouchDB: Seamless multi-master syncing database with an intuitive HTTP/JSON API},
|
||||
year = {2026},
|
||||
url = {https://couchdb.apache.org}
|
||||
}
|
||||
|
||||
@misc{couchdbreplication,
|
||||
author = {{The Apache Software Foundation}},
|
||||
title = {{CouchDB} Replication Protocol},
|
||||
year = {2026},
|
||||
url = {https://docs.couchdb.org/en/stable/replication/protocol.html},
|
||||
urldate = {2026-09-07}
|
||||
}
|
||||
|
||||
@misc{pouchdb,
|
||||
author = {{PouchDB Authors}},
|
||||
title = {PouchDB: The Database that Syncs!},
|
||||
year = {2026},
|
||||
url = {https://pouchdb.com}
|
||||
}
|
||||
|
||||
@misc{webrtc,
|
||||
author = {{World Wide Web Consortium}},
|
||||
title = {WebRTC 1.0: Real-Time Communication Between Browsers},
|
||||
year = {2021},
|
||||
url = {https://www.w3.org/TR/2021/REC-webrtc-20210126/},
|
||||
urldate = {2026-09-07}
|
||||
}
|
||||
|
||||
@misc{obsidiansync,
|
||||
author = {{Dynalist Inc.}},
|
||||
title = {Obsidian Sync: Secure, end-to-end encrypted synchronisation service},
|
||||
year = {2026},
|
||||
url = {https://obsidian.md/sync}
|
||||
}
|
||||
|
||||
@misc{obsidiangit,
|
||||
author = {Denis Olehov and {Obsidian Git Contributors}},
|
||||
title = {Obsidian Git: Backup and synchronise your Obsidian vault with Git},
|
||||
year = {2026},
|
||||
url = {https://github.com/Vinzent03/obsidian-git}
|
||||
}
|
||||
|
||||
@misc{syncthing,
|
||||
author = {{The Syncthing Authors}},
|
||||
title = {Syncthing: Open Source Continuous File Synchronization},
|
||||
year = {2026},
|
||||
url = {https://syncthing.net}
|
||||
}
|
||||
|
||||
@misc{syncthingsync,
|
||||
author = {{The Syncthing Authors}},
|
||||
title = {Understanding Synchronization},
|
||||
year = {2026},
|
||||
url = {https://docs.syncthing.net/users/syncing.html},
|
||||
urldate = {2026-09-07}
|
||||
}
|
||||
|
||||
@misc{gitfetch,
|
||||
author = {{Git Contributors}},
|
||||
title = {git-fetch: Download objects and refs from another repository},
|
||||
year = {2026},
|
||||
url = {https://git-scm.com/docs/git-fetch},
|
||||
urldate = {2026-09-07}
|
||||
}
|
||||
|
||||
@misc{automergeconflicts,
|
||||
author = {{Automerge Contributors}},
|
||||
title = {Automerge: Conflicts},
|
||||
year = {2026},
|
||||
url = {https://automerge.org/docs/reference/documents/conflicts/},
|
||||
urldate = {2026-09-07}
|
||||
}
|
||||
|
||||
@misc{remotelysave,
|
||||
author = {fyears and {Remotely Save Contributors}},
|
||||
title = {Remotely Save: Sync non-official Obsidian plugin},
|
||||
year = {2026},
|
||||
url = {https://github.com/remotely-save/remotely-save}
|
||||
}
|
||||
|
||||
@misc{trystero,
|
||||
author = {Dan Motzenbecker},
|
||||
title = {Trystero: Serverless WebRTC matchmaking and data channels},
|
||||
year = {2026},
|
||||
url = {https://github.com/dmotz/trystero}
|
||||
}
|
||||
|
||||
@misc{obsidianplugin,
|
||||
author = {{Obsidian Community Plugins}},
|
||||
title = {Self-hosted LiveSync in the Obsidian Community Plugin Directory},
|
||||
year = {2026},
|
||||
url = {https://community.obsidian.md/plugins/obsidian-livesync},
|
||||
urldate = {2026-09-02}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# Summary
|
||||
|
||||
Self-hosted LiveSync は、ローカルの Markdown ファイルとして文書を保存するノートアプリ Obsidian [@obsidian] 向けのオープンソース同期プラグインである。ユーザーが管理するストレージまたは直接のピアツーピア接続を介し、ノートや添付ファイルを収めたディレクトリー(Vault)をデスクトップとモバイルデバイス間で同期する。
|
||||
|
||||
本プラグインにより、ユーザーはオフラインで編集を行い、再接続後に同期できる。2台のオフライン端末で同一ノートを別々に編集した場合のように編集の衝突が生じても、即座の解決を強制したり競合する変更を無条件に上書きしたりすることはない。ファイル形式や設定されたポリシーに応じて、重複しない変更箇所の自動マージや、競合する版を後から比較・解決するための保持が可能である。組み込みの検査ツールは、競合や内容の欠落の調査を支援し、コピーが残っている場合の復旧を支援する。
|
||||
|
||||
本ソフトウエアは、データの保存先を自ら管理しながら複数デバイスで記録を継続する必要がある研究者、エンジニア、および実務者のワークフローに対応する。
|
||||
|
||||
# Statement of Need
|
||||
|
||||
研究やエンジニアリングのワークフローは、長期間蓄積されるノート、観察記録、設計上の決定事項、および関連ファイルに依存している。著者の業務では、管理下にある各デバイスの導入ソフトウエアを制御し、業務ファイルを自身の管理下にあるインフラで扱い、運用実績のあるサーバーソフトウエアを採用する必要があった。これらの制約から、デスクトップとモバイル双方の Obsidian 内で直接動作し、外部クライアントデーモンを必要としない同期エンジンを開発した。
|
||||
|
||||
オフライン端末で別々に行った編集の競合は、変更を交換した際に認識される。意図しない編集や削除、並行した変更の乖離、あるいはデータベースの状態とは独立した外部ツールによるファイル変更も起こりうる。競合する版を保持せずに単一の版で上書きしてしまうと、ユーザーが変更を確認して判断する前に情報が失われるおそれがある。
|
||||
|
||||
フィールドワークやモバイルでの作業中、研究者や実務者は、競合する編集内容をレビューする前であっても、観察の記録とデバイス間でのノート転送を続ける必要がある。Self-hosted LiveSync は、このように記録と競合解決を分けて進める作業を支援する。並行ブランチが未解決のままでも複製を継続でき、競合する編集は、レビューまたは設定されたポリシーによって解決されるまで保護される。
|
||||
|
||||
# State of the Field
|
||||
|
||||
ローカルファーストソフトウエアは、ユーザーデータの唯一の所有者としてのホスト型サービスへの依存を避けつつ、ローカルにおける可用性と、複数デバイス間での同期や協調を両立させる [@kleppmann2019localfirst]。Obsidian エコシステム内では、いくつかのツールが異なるアプローチから複数デバイス間の同期に対応している。Obsidian Sync は統合されたホスト型サービスを提供し [@obsidiansync]、Obsidian Git はバージョン管理指向の push/pull ワークフローを提供し [@obsidiangit]、Syncthing はファイルシステム層で動作し [@syncthing]、Remotely Save はクラウドやセルフホスト型ストレージの複数の API に Obsidian を接続する [@remotelysave]。
|
||||
|
||||
これらのアプローチは、並行する変更の表現方法が異なる。Syncthing は競合コピーを通常のファイルとして他のデバイスへ転送し [@syncthingsync]、Git は分岐した履歴をマージ前に取得できる [@gitfetch]。Obsidian Git はデスクトップおよびモバイル上でこの操作を自動化している [@obsidiangit]。Conflict-free Replicated Data Types(CRDT)でも複数の選択肢を検査でき、Automerge は同じオブジェクトプロパティーへの並行した代入を保持する [@automergeconflicts]。Self-hosted LiveSync は、競合するファイルの各バージョンを、メタデータドキュメントのリビジョンツリー上の末端リビジョン(leaf、各分岐の現在の版)として保持し、設定されたポリシーまたはユーザーによる明示的な操作によって解決されるまで維持する。
|
||||
|
||||
これらの既存ツールはそれぞれ異なる運用上の要請に応えている。ホスト型サービスは導入の平易さを重視し、外部のファイル同期ツールは任意のファイルシステムツリーを対象とし、バージョン管理ツールは明示的なコミットワークフローを前提としている。一方、著者の環境では管理対象デバイス上でバックグラウンドクライアントデーモンの実行が制限されており、かつ競合する版の保持と、その版に結び付いたファイル更新を扱うためには、複製処理とローカルファイル操作を直接統合する必要があった。そのため、Self-hosted LiveSync は外部デーモンを介さず Obsidian 内で直接動作するプラグインとして構築され、複数のバックエンドで同一のリビジョンセマンティクスを維持するために、中核ロジックをプラットホーム非依存のエンジンとして分離する構成が採用された。
|
||||
|
||||
Self-hosted LiveSync は新しいデータベース複製アルゴリズムを導入するものではない。むしろその貢献は、リビジョン認識可能なデータベースのセマンティクスを、外部から編集できるファイル Vault へ適用した点にある。Content-addressed なチャンク、デバイスローカルな来歴情報、および組み込みの復旧ツールにより、通常のノート作成ワークフローを損なうことなく、競合のレビューを保留しながら編集を継続できるようにしている。
|
||||
|
||||
# Software Design
|
||||
|
||||
共通の複製サービスおよび競合処理サービスは `@vrtmrz/livesync-commonlib` [@commonlib021] として公開されており、Obsidian プラグイン、コマンドラインインターフェース(CLI)、Web アプリケーション、および Web Peer で利用されている。
|
||||
|
||||
## Revision-aware Vault representation
|
||||
|
||||
Vault の各ファイルは、ローカルの PouchDB [@pouchdb] 内で、パス、サイズ、更新日時、および分割されたチャンクドキュメントへの参照を含むメタデータドキュメントとして表現される。チャンクは Content-addressed であり、同一のコンテンツ領域を持つリビジョン間や異なるファイル間で再利用できる。複数デバイス間で並行して更新が行われると、メタデータドキュメントの周囲に競合する複数の leaf(子を持たない末端リビジョン)が形成される。PouchDB はデフォルトの取得対象として決定論的な winner(選出された leaf)を選出するが、この選択は内部的なタイブレークに過ぎず、その winner がより新しい、より安全である、あるいは特定のデバイスの Vault に表示されているバージョンであることを証明するものではない。
|
||||
|
||||
並行する更新によってブランチ $\alpha$ と $\beta$ に分岐した場合、両方の leaf は解決前に他のデバイスへ複製される。自動3方向マージは、両方の leaf と最も近い利用可能な共通祖先についてメタデータ本文およびチャンクが読み取り可能である場合に、Markdown(`.md`)、Canvas(`.canvas`)、および JSON(`.json`)ファイルを対象として適用される(その他の形式は対象外である)。Markdown では、同一オフセットへの並行した挿入は即座に失敗とせず、更新日時に応じて順次連結して統合できる。CouchDB の複製プロトコルは祖先リビジョンの識別子を伝播するものの祖先の内容は取得しないため [@couchdbreplication]、祖先の履歴や内容が欠落している場合、あるいは互換性のない編集衝突が生じた場合、同期エンジンは自動マージを保留する。自動マージが無効または適用不能であり両方の版が読み取り可能である場合、JSON ファイルおよび内容の異なるバイナリーファイルは互換性のための動作として、「常に新しいファイルで上書きする」が無効であっても更新日時によって解決され、このオプションを有効にするとテキストの競合にも当該解決が拡張される。それ以外の場合、テキストの競合は手動解決のために保持され、両方の版が読み取り可能であれば2方向の差分(two-way diff)によって直接比較できる。
|
||||
|
||||
## Device-local branch provenance
|
||||
|
||||
データベースは競合する複数のブランチを同時に保持できるが、ローカルの Vault は任意のパスに対して単一の実体ファイルしか配置できない。ローカルファイルがどのブランチを表しているかを識別するため、本プラグインは正確なデータベースリビジョンと観測されたローカルの更新日時をデバイスローカルな Key-Value ストアに保存する。このリビジョンは当該パスの**ブランチアンカー**として機能し、データベースから Vault への実体化、または Vault からデータベースへの書き込みが成功した後に更新される。
|
||||
|
||||
未解決の競合が存在する状態において、ローカルで行われた編集や論理削除はアンカーされたリビジョンの子となり、競合する leaf を損なうことなく、その特定のブランチを前進させる。パスをまたぐリネームでは、移動先を保存した上で、アンカーされた移動元のブランチのみを論理削除する。この状態で来歴情報が利用できない場合、本プラグインはファイルのバイト列が利用可能な既存の単一リビジョン本文と厳密に一致する場合に限り Vault 内のファイルをそのリビジョンにひもづけ、それ以外の場合はパスや日時から勝手に推測せず、手動解決すべき競合として保持する。競合が存在しない通常時は、通常の書き込みによって単に現在のデータベースリビジョンが前進する。
|
||||
|
||||
組み込みのコンフリクトインスペクターは、現在の winner、すべての conflict leaf、および最も近い利用可能な共通祖先を検査する。インスペクターは欠落したチャンクやファイル/データベース間の差異を報告し、現在の leaf を明示的に選択して操作できるようにする。変更を伴う操作は実行前にリビジョンを再確認し、古い画面状態によってすでに末端ではなくなったリビジョンを誤って削除したり前進させたりするのを防止する。
|
||||
|
||||
## Transport-independent replication
|
||||
|
||||
CouchDB のリビジョンモデルを基準に、Self-hosted LiveSync はデータベースの表現を通信トランスポートから分離し、バックエンドにかかわらずファイルメタデータのリビジョン識別子と競合する leaf をそのまま複製する。CouchDB [@couchdb] ではネイティブなリビジョン複製を利用する。S3 互換オブジェクトストレージでは、メタデータドキュメントの末端リビジョンと祖先リビジョンの識別子をジャーナルに記録し、新しいローカルリビジョンを作成せずに適用する一方、チャンクドキュメントは内容由来の識別子を保持し、新しいローカルリビジョンとして保存される。WebRTC ピアツーピア(P2P)アダプター [@webrtc] は、Trystero [@trystero] の DataChannels と RPC ベースのレプリケーション shim によりドキュメント要求をバッチ処理し、同一のリビジョンセマンティクスをピア間で直接保持する。CouchDB およびジャーナル転送においては、Web Streams が転送をパイプライン処理し、転送中にメモリーへ保持されるデータ量を抑制する。
|
||||
|
||||
これらのトランスポートは柔軟に組み合わせられる。P2P 同期は参加デバイスが同時にオンラインである必要があるが、中央の CouchDB やオブジェクトストレージを併用することで、オフライン期間を挟んだデバイス間でも同期できる。すべての通信方式でコンテンツのエンドツーエンド暗号化とパス難読化をサポートしている。P2P では接続交渉時のセッション記述が暗号化されるが、シグナリングリレーやネットワークサービスからは接続時刻やネットワークアドレスを観測できる。
|
||||
|
||||
## Retention and recovery
|
||||
|
||||
分岐した各ブランチは未変更のチャンクを共有するため、競合する leaf を保持するために生じるコストは主に新規チャンクとリビジョンメタデータに限られる。蓄積した保存領域はリモートデータベースの再構築によって回収できるほか、CouchDB 向けには、明示的に開始するベータ版のガベージコレクションにより、現在の winner、すべての conflict leaf、および未解決の競合を検査するために必要な、利用可能な祖先から到達可能なチャンクを保護しながらインプレースで回収できる。過去のリビジョンで置き換えられたチャンクは後から回収されうるため、過去のリビジョン本文は無条件のバックアップではない。
|
||||
|
||||
必要なチャンクが欠落している場合でも、読み取り不能な現在のリビジョンはリビジョンツリーに残り、競合処理によって自動的に破棄されることはない。欠落したチャンクが他のデバイスに残っている場合があるため、それらの再接続と同期を待って復旧操作を保留できる。競合インスペクターは影響を受けるリビジョンを明示し、取得の再試行や明示的な復旧操作を支援する。復旧には、デバイス、リモートストレージ、またはバックアップに内容が残っている必要がある。
|
||||
|
||||
# Research Impact Statement
|
||||
|
||||
Self-hosted LiveSync は、著者が複数のデバイスやプラットホームを対象に行うソフトウエア開発業務から生まれた。この作業では、主たるデバイスを利用できない状況でも、各デバイスでスクリーンショットを取得し、観察記録を保存する必要があった。同じワークフローは、現在では著者の先行技術調査にも利用されており、先行文献の読解に伴うメモや考察を同期するために用いられている。競合する版が保持されることで、分岐した記録が即座に上書きされず、後から比較・確認することが可能になる。
|
||||
|
||||
ユニットテストおよび結合テストは、リビジョンの系譜、チャンクの到達可能性、利用できない内容、およびホストの構成を対象とする。CLI および実環境の Obsidian によるシナリオでは、競合する leaf が残っている状態での編集、論理削除、およびリネームを含め、競合の伝播と解決を検証する。3ノードの P2P シナリオでは、未解決の leaf が解決前にデバイス間を移動できることを確認している。再利用可能なヘッドレステスト基盤は独立してアーカイブされている [@fancykit]。決定論的なフィクスチャーを用いて同一の生成データ上で P2P と CouchDB の経路を比較しているが、制御されたローカル測定値が普遍的な性能を示すわけではない。
|
||||
|
||||
2026年9月2日時点で、Obsidian プラグインディレクトリーでは 90万回以上のダウンロード、デスクトップおよびモバイルのサポート、ならびに公式の Research カテゴリーへの配置が報告されている [@obsidianplugin]。GitHub リポジトリーでは 12,200件以上のスター、440件のフォーク、および広範なユーザーコミュニティーからの貢献が記録されている [@selfhostedlivesyncrepo]。これらの数値自体は研究上の直接的な影響を証明するものではないが、本ソフトウエアがコミュニティーに受容され、単一のプライベートなワークフローを超えて運用されている証拠を提供する。
|
||||
|
||||
本稿で説明したソフトウエアは Self-hosted LiveSync 1.0.23 [@selfhostedlivesync] であり、MIT ライセンスの下でリリースされ、Commonlib 0.1.21 [@commonlib021] に固定されている。プラグイン、再利用可能なテストハーネス [@fancykit]、および以前の Commonlib 0.1.19 のスナップショット [@commonlib] は Zenodo に恒久的にアーカイブされており、プラットホーム非依存のロジックが独立したテストと再利用を可能にしている。
|
||||
|
||||
# AI Usage Disclosure
|
||||
|
||||
2026年7月から9月にかけて、コード探索、テストおよびベンチマークの足場作り、CI およびドキュメントの編集、原稿の推敲および校正、レビュー、引用の検証、ならびに結果の要約に GPT-5 を使用した OpenAI Codex が利用された。また、Codex を通じて GPT-6 も 9月の原稿レビューおよび改訂を支援した。本原稿の準備において、その他の生成 AI ツールは使用されていない。GitHub Copilot(モデルおよびバージョンは未記録)は、本リリースに含まれるコミットの実装、テスト、およびドキュメント作成を支援した。Google Gemini(Gemini Flash バージョン 3.5 から 3.8)は、リソースチェックおよび関連するコードベースの検証に使用された。人間の著者自身がすべての支援出力をレビュー、編集、および検証し、主要な設計判断を行い、関連する検証コマンドおよびベンチマークコマンドを実行した。著者は、提出された資料の正確性、独創性、ライセンス、および倫理的コンプライアンスについて引き続き全責任を負う。
|
||||
|
||||
# Acknowledgements
|
||||
|
||||
著者は、プロジェクトの貢献者、ユーザー、ならびに PouchDB、CouchDB、および Trystero のアップストリームメンテナーに感謝の意を表する。本プロジェクトは、GitHub Sponsors を通じたコミュニティーの支援、JetBrains からの開発ツールライセンス、および OpenAI の Codex for Open Source プログラムによる支援を受けている。
|
||||
|
||||
# References
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
title: 'Self-hosted LiveSync: Inspectable and recoverable replication for local-first Obsidian vaults'
|
||||
tags:
|
||||
- local-first software
|
||||
- synchronisation
|
||||
- CouchDB
|
||||
- PouchDB
|
||||
- WebRTC
|
||||
- Obsidian
|
||||
- TypeScript
|
||||
authors:
|
||||
- name: 'vorotamoroz'
|
||||
affiliation: 1
|
||||
corresponding: true
|
||||
affiliations:
|
||||
- name: 'Independent Researcher'
|
||||
index: 1
|
||||
date: 5 September 2026
|
||||
bibliography: paper.bib
|
||||
---
|
||||
|
||||
# Summary
|
||||
|
||||
Self-hosted LiveSync is an open-source synchronisation plug-in for Obsidian [@obsidian], a note-taking application that stores documents as local Markdown files. It replicates a user's vault—a directory containing notes and attachments—across desktop and mobile devices using user-controlled storage or direct peer-to-peer connections.
|
||||
|
||||
The plug-in allows users to continue editing offline and synchronise upon reconnection, even when conflicting edits arise—such as when two disconnected devices modify the same note concurrently. Rather than forcing immediate reconciliation or unconditionally overwriting competing changes, the system supports automatic merging of non-overlapping edits and preserves competing versions for deferred review, depending on file formats and configured policies. Built-in inspection tools help users investigate conflicts or missing content and recover files when surviving copies exist.
|
||||
|
||||
The software serves researchers, engineers, and practitioners who require continued note-taking across multiple devices while controlling their data storage.
|
||||
|
||||
# Statement of Need
|
||||
|
||||
Research and engineering workflows depend on long-lived notes, observations, design decisions, and supporting files. The author's work required managing the software installed on each device, keeping files on infrastructure under personal control, and using server software with an established operational record. These constraints motivated a synchronisation engine running directly inside Obsidian across desktop and mobile platforms without external client daemons.
|
||||
|
||||
Conflicts arising from concurrent edits on disconnected devices are recognised only after devices exchange updates. An edit or deletion may be unintended, concurrent modifications may diverge, or external tools may update files independently of database events. Overwriting with a single version without retaining competing revisions risks irreversibly discarding information before users can evaluate the divergence.
|
||||
|
||||
During fieldwork and mobile operations, researchers and practitioners often need to continue recording observations and transferring notes between devices before reviewing competing edits. Self-hosted LiveSync supports this separation of recording and reconciliation: replication proceeds while concurrent branches remain unresolved, protecting competing edits until they can be reviewed or resolved according to configured policies.
|
||||
|
||||
# State of the Field
|
||||
|
||||
Local-first software combines local availability with multi-device synchronisation and collaboration while avoiding dependence on a hosted service as the sole owner of user data [@kleppmann2019localfirst]. Within the Obsidian ecosystem, Obsidian Sync provides an integrated hosted service [@obsidiansync]; Obsidian Git provides version-control-oriented push and pull workflows [@obsidiangit]; Syncthing operates at the filesystem layer [@syncthing]; and Remotely Save connects Obsidian to several cloud and self-hosted storage APIs [@remotelysave].
|
||||
|
||||
These approaches differ in how they represent concurrent changes. Syncthing propagates conflict copies as ordinary files [@syncthingsync], while Git can fetch divergent histories into separate tracking branches before merging them [@gitfetch], an approach automated on desktop and mobile by Obsidian Git [@obsidiangit]. Conflict-free replicated data types (CRDTs) can also expose alternatives: Automerge retains concurrent assignments to an object property for inspection [@automergeconflicts]. Self-hosted LiveSync retains competing file versions as leaves—the current versions of divergent branches—in the metadata document's revision tree until resolved by configured policies or explicit user action.
|
||||
|
||||
These existing tools address distinct operational needs: hosted services prioritise turnkey convenience, external file synchronisers manage arbitrary filesystem trees, and version-control tools introduce explicit commit workflows. In the author's environment, however, managed devices prohibited background client daemons, while retaining competing revisions alongside their associated file updates required integrating replication directly with local file operations. Self-hosted LiveSync was therefore implemented as an Obsidian plug-in running entirely within the application runtime, backed by a decoupled, platform-independent engine to maintain uniform revision semantics across backends.
|
||||
|
||||
Self-hosted LiveSync does not introduce a new database replication algorithm; rather, its contribution lies in applying revision-aware database semantics to an externally editable file vault. Content-addressed chunks, device-local branch provenance, and built-in recovery tools support continued editing while conflict review is deferred, protecting divergent work without altering standard note-taking workflows.
|
||||
|
||||
# Software Design
|
||||
|
||||
The shared replication and conflict-handling services are published as `@vrtmrz/livesync-commonlib` [@commonlib021] and used by the Obsidian plug-in, command-line interface (CLI), web application, and web peer.
|
||||
|
||||
## Revision-aware Vault representation
|
||||
|
||||
Each Vault file is represented in local PouchDB [@pouchdb] by a metadata document containing its path, size, modification time, and references to separate chunk documents. Chunks are content-addressed and can therefore be reused across revisions and files with identical content regions. When concurrent updates occur across devices, they form multiple competing leaves around the metadata document. PouchDB selects a deterministic winner for default retrieval, but this choice is an internal tie-breaker rather than evidence that the winner is newer, safer, or the version represented by a particular device's Vault.
|
||||
|
||||
When concurrent updates diverge into branches $\alpha$ and $\beta$, both leaves replicate to other devices before resolution. Automatic three-way merging applies to Markdown (`.md`), Canvas (`.canvas`), and JSON (`.json`) files when both leaves and their nearest available shared ancestor are readable; other formats are excluded. In Markdown, concurrent insertions at the same offset concatenate sequentially by modification time. Because CouchDB replication transfers ancestry identifiers without ancestor content [@couchdbreplication], missing ancestral history or conflicting edits defer automatic merging. When automatic merging is disabled or inapplicable and both versions are readable, JSON and differing binary files resolve by modification time as a compatibility fallback, even when 'Always overwrite with a newer file' is disabled; enabling that option extends modification-time resolution to text conflicts. Otherwise, competing text versions remain for manual resolution, and can be compared via a two-way diff when both leaves are readable.
|
||||
|
||||
## Device-local branch provenance
|
||||
|
||||
While the database can retain competing branches concurrently, a local Vault can instantiate only a single concrete file at any given path. To resolve which branch a local file represents, the plug-in stores an exact database revision and observed local modification time in a device-local key-value store. This revision serves as the path's **branch anchor**, updated after a successful database-to-Vault reflection or Vault-to-database write.
|
||||
|
||||
During active conflicts, local edits or logical deletions become children of the anchored revision, advancing that branch while keeping competing leaves intact. Cross-path renames store the target before logically deleting only the anchored source branch. If provenance is unavailable, the plug-in binds a file to an existing revision only when its bytes match exactly one available revision body; otherwise, it retains the conflict for manual resolution rather than guessing from paths or timestamps. Without active conflicts, ordinary writes simply advance the database revision.
|
||||
|
||||
The built-in conflict inspector examines the current winner, every conflict leaf, and the nearest available shared ancestor. It reports missing chunks and file/database differences, permitting operations on an explicitly selected current leaf. Mutating operations recheck the revision beforehand, preventing a stale inspection from deleting or extending a superseded branch.
|
||||
|
||||
## Transport-independent replication
|
||||
|
||||
Using CouchDB's revision model as a baseline, Self-hosted LiveSync decouples database representation from network transport, replicating file-metadata revision identifiers and competing leaves intact across backends. CouchDB [@couchdb] provides native revision-aware replication. S3-compatible storage journals metadata leaf revisions and ancestry identifiers without synthesising new revisions, while chunk documents are stored as new local revisions with content-derived identifiers. The WebRTC peer-to-peer (P2P) adapter [@webrtc] uses Trystero [@trystero] DataChannels and an RPC-based replication shim to batch document requests while preserving identical revision semantics directly between peers. In CouchDB and journal transfers, Web Streams pipeline data to limit the amount of data buffered in memory during transfer.
|
||||
|
||||
Transports combine flexibly: while P2P requires concurrent online presence, pairing it with CouchDB or object storage bridges offline intervals. All transports support end-to-end content encryption and path obfuscation. P2P encrypts session descriptions during connection negotiation, though signalling relays and network services can still observe connection timing and network addresses.
|
||||
|
||||
## Retention and recovery
|
||||
|
||||
Because alternative branches share unchanged chunks, retaining competing leaves incurs storage and transfer costs primarily for new chunks and revision metadata. Remote database rebuilds reclaim space, while an explicitly initiated beta garbage-collection workflow provides in-place CouchDB cleanup by protecting chunks reachable from the current winner, every conflict leaf, and the available ancestry needed to inspect active conflicts. Because superseded chunks may be collected, historical revisions are not an unconditional backup.
|
||||
|
||||
When chunks are missing, unreadable current revisions remain in the tree rather than being automatically discarded during conflict processing. Because missing chunks may still exist on other devices, users can defer recovery until they reconnect and synchronise. The conflict inspector identifies affected revisions and facilitates retrieval retries and explicit recovery actions. Recovery ultimately requires surviving content on a device, in remote storage, or in a backup.
|
||||
|
||||
# Research Impact Statement
|
||||
|
||||
Self-hosted LiveSync originated in the author's multi-platform software engineering workflows, capturing screenshots and recording observations across multiple devices, including when a primary device was unavailable. Today, the same workflow supports the author's patent prior-art investigations, synchronising notes and reflections made while reading prior patent literature. Retaining competing revisions allows divergent observations to be compared after the fact rather than overwritten immediately.
|
||||
|
||||
Unit and integration tests cover revision ancestry, chunk reachability, unavailable content, and host composition. CLI and real-Obsidian scenarios exercise conflict propagation and resolution, including edits, logical deletions, and renames while competing leaves remain active. A three-node P2P scenario verifies that unresolved leaves move between devices before resolution. Reusable headless test infrastructure is archived independently [@fancykit]. Deterministic fixtures compare P2P and CouchDB paths over identical generated data; controlled local measurements do not establish universal performance.
|
||||
|
||||
As of 2 September 2026, the Obsidian plug-in directory reported more than 900,000 downloads, desktop and mobile support, and placement in its Research category [@obsidianplugin]. The GitHub repository recorded over 12,200 stars, 440 forks, and contributions from a broad user community [@selfhostedlivesyncrepo]. These figures demonstrate community adoption rather than direct research impact, but they provide evidence that the software operates beyond a single private workflow.
|
||||
|
||||
The software described here is Self-hosted LiveSync 1.0.23 [@selfhostedlivesync], released under the MIT licence and pinned to Commonlib 0.1.21 [@commonlib021]. Zenodo archives the plug-in, the reusable test harness [@fancykit], and an earlier Commonlib 0.1.19 snapshot [@commonlib]. Platform-independent logic supports independent testing and reuse.
|
||||
|
||||
# AI Usage Disclosure
|
||||
|
||||
OpenAI Codex using GPT-5 was used from July to September 2026 for code navigation, test and benchmark scaffolding, CI and documentation edits, manuscript editing, proofreading, review, citation verification, and result summarisation. GPT-6 assisted with September manuscript review and revision through Codex. No other generative AI tools prepared the manuscript. GitHub Copilot assisted with commits in this release, and Google Gemini (Flash versions 3.5 to 3.8) supported codebase verification. The human author validated all assisted outputs, made core design decisions, ran verification commands, and remains responsible for the accuracy, originality, licensing, and ethical compliance of the submitted materials.
|
||||
|
||||
# Acknowledgements
|
||||
|
||||
The author acknowledges project contributors, users, and upstream maintainers of PouchDB, CouchDB, and Trystero. The project has received community support through GitHub Sponsors, development-tool licensing from JetBrains, and support through OpenAI's Codex for Open Source programme.
|
||||
|
||||
# References
|
||||
|
||||
+6
-11
@@ -22,17 +22,16 @@ import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/comp
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { AbstractModule } from "./modules/AbstractModule";
|
||||
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
|
||||
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
|
||||
import { ModuleLiveSyncMain } from "./modules/main/ModuleLiveSyncMain";
|
||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { ModuleBasicMenu } from "./modules/essential/ModuleBasicMenu";
|
||||
import { usePrepareDatabaseForUse } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/prepareDatabaseForUse";
|
||||
import type { Constructor } from "@vrtmrz/livesync-commonlib/compat/common/utils.type";
|
||||
import { useReplicationScheduling, type ReplicationSchedulingControl } from "./serviceFeatures/replicationScheduling";
|
||||
import { createCentralReplicatorProviderDefinitions } from "./common/replicatorProviders";
|
||||
import { useReplicationFeature } from "./serviceFeatures/replication";
|
||||
import { useConflictResolutionFeature } from "./serviceFeatures/conflictResolution";
|
||||
import { useBasicCommandsFeature } from "./serviceFeatures/basicCommands";
|
||||
|
||||
/** Focused views returned by serviceFeatures which the host may consume during composition. */
|
||||
export interface LiveSyncCoreFeatureViews {
|
||||
@@ -45,10 +44,7 @@ export class LiveSyncBaseCore<
|
||||
T extends ServiceContext = ServiceContext,
|
||||
TCommands extends IMinimumLiveSyncCommands = IMinimumLiveSyncCommands,
|
||||
>
|
||||
implements
|
||||
LiveSyncLocalDBEnv,
|
||||
LiveSyncCouchDBReplicatorEnv,
|
||||
HasSettings<ObsidianLiveSyncSettings>
|
||||
implements LiveSyncLocalDBEnv, LiveSyncCouchDBReplicatorEnv, HasSettings<ObsidianLiveSyncSettings>
|
||||
{
|
||||
addOns = [] as TCommands[];
|
||||
|
||||
@@ -95,9 +91,10 @@ export class LiveSyncBaseCore<
|
||||
for (const addOn of addOns) {
|
||||
this._registerAddOn(addOn);
|
||||
}
|
||||
// Register host features and add-ons before replication, then bind
|
||||
// Compose late core features after host features and add-ons, then bind
|
||||
// legacy modules so lifecycle handlers observe the required order.
|
||||
useReplicationFeature(this);
|
||||
useBasicCommandsFeature(this);
|
||||
this.bindModuleFunctions();
|
||||
}
|
||||
/**
|
||||
@@ -157,10 +154,7 @@ export class LiveSyncBaseCore<
|
||||
|
||||
public registerModules(extraModules: AbstractModule[] = []) {
|
||||
this._registerModule(new ModuleLiveSyncMain(this));
|
||||
this._registerModule(new ModuleConflictChecker(this));
|
||||
this._registerModule(new ModuleConflictResolver(this));
|
||||
this._registerModule(new ModuleResolvingMismatchedTweaks(this));
|
||||
this._registerModule(new ModuleBasicMenu(this));
|
||||
|
||||
for (const module of extraModules) {
|
||||
this._registerModule(module);
|
||||
@@ -290,6 +284,7 @@ export class LiveSyncBaseCore<
|
||||
* (Please refer `serviceFeatures` for more details)
|
||||
*/
|
||||
initialiseServiceFeatures(): LiveSyncCoreFeatureViews {
|
||||
useConflictResolutionFeature(this);
|
||||
useTargetFilters(this);
|
||||
// enable target filter feature.
|
||||
usePrepareDatabaseForUse(this);
|
||||
|
||||
@@ -82,9 +82,11 @@ RUN apt-get update \
|
||||
|
||||
WORKDIR /deps
|
||||
|
||||
# package.json lists only the packages that the CLI requires
|
||||
COPY src/apps/cli/package.json ./package.json
|
||||
RUN npm install --omit=dev
|
||||
# Remove build-only dependencies before resolving the standalone runtime tree.
|
||||
# npm --omit=dev omits them from disk, but still resolves their peer graph.
|
||||
COPY src/apps/cli/package.json ./package.json
|
||||
RUN npm pkg delete devDependencies \
|
||||
&& npm install --omit=dev
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Stage 3 — runtime
|
||||
|
||||
@@ -15,7 +15,10 @@ import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_b
|
||||
import type { CLICommandContext, CLIOptions } from "./types";
|
||||
import { toArrayBuffer, toDatabaseRelativePath } from "./utils";
|
||||
import { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./p2p";
|
||||
import { performFullScan } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import {
|
||||
performFullScan,
|
||||
VaultScanResults,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
@@ -529,7 +532,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
writeStderrLine(standardIo, "[Command] mirror");
|
||||
const log = (msg: unknown) => writeStderrLine(standardIo, `[Mirror] ${String(msg)}`);
|
||||
const errorManager = new UnresolvedErrorManager(core.services.appLifecycle, core.services.context.events);
|
||||
return await performFullScan(core, log, errorManager, false, true);
|
||||
return (await performFullScan(core, log, errorManager, false, true)) === VaultScanResults.COMPLETED;
|
||||
}
|
||||
|
||||
if (options.command === "remote-add") {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.24-cli",
|
||||
"version": "1.0.27-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -37,7 +37,7 @@
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"octagonal-wheels": "^0.1.53",
|
||||
"octagonal-wheels": "^0.1.54",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||
"pouchdb-core": "^9.0.0",
|
||||
@@ -51,7 +51,7 @@
|
||||
"werift": "^0.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.9.3",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.24-webapp",
|
||||
"version": "1.0.27-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
@@ -15,12 +15,12 @@
|
||||
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webapp/browser-smoke.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.53"
|
||||
"octagonal-wheels": "^0.1.54"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"svelte": "5.56.3",
|
||||
"typescript": "5.9.3",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.24-webpeer",
|
||||
"version": "1.0.27-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -15,7 +15,7 @@
|
||||
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webpeer/browser-smoke.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.53"
|
||||
"octagonal-wheels": "^0.1.54"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-plugin-svelte": "^3.19.0",
|
||||
@@ -23,7 +23,7 @@
|
||||
"@tsconfig/svelte": "^5.0.8",
|
||||
"svelte": "5.56.3",
|
||||
"svelte-check": "^4.6.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4212,6 +4212,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "等待就绪...",
|
||||
"zh-tw": "正在等待就緒⋯",
|
||||
},
|
||||
"moduleLog.pathComponentTooLong": {
|
||||
def: "This path contains a file or folder name longer than ${maxBytes} UTF-8 bytes. It may not work on some Android and Linux file systems.",
|
||||
},
|
||||
"moduleLog.showLog": {
|
||||
def: "Show Log",
|
||||
es: "Mostrar registro",
|
||||
@@ -10414,6 +10417,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "Use Remote Configuration",
|
||||
"zh-tw": "使用遠端設定",
|
||||
},
|
||||
"Ui.Common.LocalDatabaseInitialisationFailed": {
|
||||
def: "Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.",
|
||||
},
|
||||
"Ui.Common.Signal.Caution": {
|
||||
def: "CAUTION",
|
||||
es: "PRECAUCIÓN",
|
||||
@@ -10442,6 +10448,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "警告",
|
||||
"zh-tw": "警告",
|
||||
},
|
||||
"Ui.Common.SomeFilesCouldNotBeSynchronised": {
|
||||
def: "Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.",
|
||||
},
|
||||
"Ui.Settings.Advanced.LocalDatabaseTweak": {
|
||||
def: "Local Database Tweak",
|
||||
es: "Ajuste fino de la base de datos local",
|
||||
|
||||
@@ -483,6 +483,7 @@
|
||||
"moduleLiveSyncMain.optionResumeAndRestart": "Resume and restart Obsidian",
|
||||
"moduleLiveSyncMain.titleScramEnabled": "Scram Enabled",
|
||||
"moduleLocalDatabase.logWaitingForReady": "Waiting for ready...",
|
||||
"moduleLog.pathComponentTooLong": "This path contains a file or folder name longer than ${maxBytes} UTF-8 bytes. It may not work on some Android and Linux file systems.",
|
||||
"moduleLog.showLog": "Show Log",
|
||||
"moduleMigration.fix0256.buttons.checkItLater": "Check it later",
|
||||
"moduleMigration.fix0256.buttons.DismissForever": "I have fixed it, and do not ask again",
|
||||
@@ -1142,10 +1143,12 @@
|
||||
"TweakMismatchResolve.Title.AutoAcceptCompatible": "Auto-Accept Available",
|
||||
"TweakMismatchResolve.Title.TweakResolving": "Configuration Mismatch Detected",
|
||||
"TweakMismatchResolve.Title.UseRemoteConfig": "Use Remote Configuration",
|
||||
"Ui.Common.LocalDatabaseInitialisationFailed": "Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.",
|
||||
"Ui.Common.Signal.Caution": "CAUTION",
|
||||
"Ui.Common.Signal.Danger": "DANGER",
|
||||
"Ui.Common.Signal.Notice": "NOTICE",
|
||||
"Ui.Common.Signal.Warning": "WARNING",
|
||||
"Ui.Common.SomeFilesCouldNotBeSynchronised": "Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.",
|
||||
"Ui.Settings.Advanced.LocalDatabaseTweak": "Local Database Tweak",
|
||||
"Ui.Settings.Advanced.MemoryCache": "Memory Cache",
|
||||
"Ui.Settings.Advanced.TransferTweak": "Transfer Tweak",
|
||||
|
||||
@@ -732,6 +732,9 @@ moduleLiveSyncMain:
|
||||
moduleLocalDatabase:
|
||||
logWaitingForReady: Waiting for ready...
|
||||
moduleLog:
|
||||
pathComponentTooLong: >-
|
||||
This path contains a file or folder name longer than ${maxBytes} UTF-8
|
||||
bytes. It may not work on some Android and Linux file systems.
|
||||
showLog: Show Log
|
||||
moduleMigration:
|
||||
fix0256:
|
||||
@@ -2126,6 +2129,8 @@ xxhash64 (Fastest): xxhash64 (Fastest)
|
||||
"This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": "This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer."
|
||||
Ui:
|
||||
Common:
|
||||
LocalDatabaseInitialisationFailed: Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.
|
||||
SomeFilesCouldNotBeSynchronised: Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.
|
||||
Signal:
|
||||
Caution: CAUTION
|
||||
Danger: DANGER
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export const ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY = 255;
|
||||
|
||||
export interface OversizedPathComponent {
|
||||
component: string;
|
||||
utf8Bytes: number;
|
||||
}
|
||||
|
||||
const utf8Encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Return path components which exceed the conservative Android/Linux
|
||||
* compatibility boundary.
|
||||
*
|
||||
* Obsidian paths use forward slashes. The limit applies to each file or
|
||||
* folder name, not to the combined Vault-relative path.
|
||||
*/
|
||||
export function findPathComponentsExceedingUtf8Limit(
|
||||
path: string,
|
||||
maxBytes: number = ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY
|
||||
): OversizedPathComponent[] {
|
||||
return path
|
||||
.split("/")
|
||||
.filter((component) => component.length > 0)
|
||||
.map((component) => ({ component, utf8Bytes: utf8Encoder.encode(component).byteLength }))
|
||||
.filter(({ utf8Bytes }) => utf8Bytes > maxBytes);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY,
|
||||
findPathComponentsExceedingUtf8Limit,
|
||||
} from "./pathCompatibility.ts";
|
||||
|
||||
describe("findPathComponentsExceedingUtf8Limit", () => {
|
||||
it("accepts 255 UTF-8 bytes and reports 256 UTF-8 bytes", () => {
|
||||
expect(findPathComponentsExceedingUtf8Limit("a".repeat(255))).toEqual([]);
|
||||
expect(findPathComponentsExceedingUtf8Limit("a".repeat(256))).toEqual([
|
||||
{
|
||||
component: "a".repeat(256),
|
||||
utf8Bytes: 256,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts UTF-8 bytes rather than JavaScript characters", () => {
|
||||
expect(findPathComponentsExceedingUtf8Limit("界".repeat(85))).toEqual([]);
|
||||
expect(findPathComponentsExceedingUtf8Limit(`${"界".repeat(85)}a`)).toEqual([
|
||||
{
|
||||
component: `${"界".repeat(85)}a`,
|
||||
utf8Bytes: 256,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not apply the component limit to the whole path", () => {
|
||||
const path = `${"a".repeat(200)}/${"b".repeat(200)}`;
|
||||
|
||||
expect(new TextEncoder().encode(path).byteLength).toBeGreaterThan(
|
||||
ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY
|
||||
);
|
||||
expect(findPathComponentsExceedingUtf8Limit(path)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports an oversized folder component as well as an oversized file name", () => {
|
||||
const folder = "界".repeat(86);
|
||||
const file = `${"b".repeat(256)}.md`;
|
||||
|
||||
expect(findPathComponentsExceedingUtf8Limit(`parent/${folder}/${file}`)).toEqual([
|
||||
{ component: folder, utf8Bytes: 258 },
|
||||
{ component: file, utf8Bytes: 259 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,23 @@ describe("LiveSync-owned translation catalogue", () => {
|
||||
expect($msg("moduleCheckRemoteSize.optionIncreaseLimit", { newMax: "800" }, "def")).toBe("increase to 800MB");
|
||||
});
|
||||
|
||||
it("keeps the active-file path compatibility warning concise", () => {
|
||||
const oversizedComponent = `${"界".repeat(86)} (258 bytes)`;
|
||||
|
||||
expect(
|
||||
$msg(
|
||||
"moduleLog.pathComponentTooLong",
|
||||
{
|
||||
maxBytes: "255",
|
||||
components: oversizedComponent,
|
||||
},
|
||||
"def"
|
||||
)
|
||||
).toBe(
|
||||
"This path contains a file or folder name longer than 255 UTF-8 bytes. It may not work on some Android and Linux file systems."
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Commonlib's canonical English when the application catalogue has no translation", () => {
|
||||
setLang("es");
|
||||
|
||||
|
||||
+16
-9
@@ -6,7 +6,6 @@ import { HiddenFileSync } from "./features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts";
|
||||
// import { ModuleDev } from "./modules/extras/ModuleDev.ts";
|
||||
|
||||
import { ModuleInteractiveConflictResolver } from "./modules/features/ModuleInteractiveConflictResolver.ts";
|
||||
import { ModuleLog } from "./modules/features/ModuleLog.ts";
|
||||
import { ModuleObsidianEvents } from "./modules/essentialObsidian/ModuleObsidianEvents.ts";
|
||||
import { ModuleObsidianSettingDialogue } from "./modules/features/ModuleObsidianSettingTab.ts";
|
||||
@@ -26,10 +25,8 @@ import type { ServiceModules } from "./types.ts";
|
||||
import { setNoticeClass } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/wrapper";
|
||||
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||
import { LiveSyncBaseCore } from "./LiveSyncBaseCore.ts";
|
||||
import { ModuleObsidianMenu } from "./modules/essentialObsidian/ModuleObsidianMenu.ts";
|
||||
import { ModuleObsidianSettingsAsMarkdown } from "./modules/features/ModuleObsidianSettingAsMarkdown.ts";
|
||||
import { SetupManager } from "./modules/features/SetupManager.ts";
|
||||
import { ModuleMigration } from "./modules/essential/ModuleMigration.ts";
|
||||
import { enableI18nFeature } from "./serviceFeatures/onLayoutReady/enablei18n.ts";
|
||||
import { useOfflineScanner } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
|
||||
@@ -38,7 +35,10 @@ import { useRedFlagFeatures } from "./serviceFeatures/redFlag.ts";
|
||||
import { useSetupProtocolFeature } from "./serviceFeatures/setupObsidian/setupProtocol.ts";
|
||||
import { useSetupQRCodeFeature } from "@/serviceFeatures/setupObsidian/qrCode";
|
||||
import { useSetupURIFeature } from "@/serviceFeatures/setupObsidian/setupUri";
|
||||
import { useSetupManagerHandlersFeature } from "./serviceFeatures/setupObsidian/setupManagerHandlers.ts";
|
||||
import {
|
||||
showOnboardingInvitation,
|
||||
useSetupManagerHandlersFeature,
|
||||
} from "./serviceFeatures/setupObsidian/setupManagerHandlers.ts";
|
||||
import { useP2PReplicatorCommands, useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { useP2PReplicatorUI } from "./serviceFeatures/useP2PReplicatorUI.ts";
|
||||
import { useReviewHarness } from "./serviceFeatures/useReviewHarness.ts";
|
||||
@@ -46,6 +46,10 @@ import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync
|
||||
import { useCompatibilityReview } from "./serviceFeatures/compatibilityReview.ts";
|
||||
import { createObsidianCompatibilityReviewUi } from "./serviceFeatures/compatibilityReviewObsidian.ts";
|
||||
import { createFileReflectionProvenance } from "./serviceModules/FileReflectionProvenance.ts";
|
||||
import { useInteractiveConflictResolutionFeature } from "./serviceFeatures/interactiveConflictResolution";
|
||||
import { ConflictResolveModal } from "./modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||
import { useObsidianReplicationRibbonFeature } from "./serviceFeatures/obsidianReplicationRibbon.ts";
|
||||
import { useStartupLifecycleFeature } from "./serviceFeatures/startupLifecycle";
|
||||
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
|
||||
export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
core: LiveSyncCore;
|
||||
@@ -145,7 +149,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
setNoticeClass(Notice);
|
||||
|
||||
const serviceHub = new ObsidianServiceHub(this);
|
||||
let waitForCompatibilityReview = (): Promise<void> => Promise.resolve();
|
||||
|
||||
this.core = new LiveSyncBaseCore(
|
||||
serviceHub,
|
||||
@@ -156,15 +159,12 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
const extraModules = [
|
||||
new ModuleObsidianEvents(this, core),
|
||||
new ModuleObsidianSettingDialogue(this, core),
|
||||
new ModuleObsidianMenu(core),
|
||||
new ModuleObsidianSettingsAsMarkdown(core),
|
||||
new ModuleLog(this, core),
|
||||
new ModuleObsidianDocumentHistory(this, core),
|
||||
new ModuleInteractiveConflictResolver(this, core),
|
||||
new ModuleObsidianGlobalHistory(this, core),
|
||||
// new ModuleDev(this, core),
|
||||
new SetupManager(core), // this should be moved to core?
|
||||
new ModuleMigration(core, () => waitForCompatibilityReview()),
|
||||
];
|
||||
return extraModules;
|
||||
},
|
||||
@@ -187,6 +187,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
setupManager.registerP2PSetupConnectionProbe(replicator.connectionProbe);
|
||||
useP2PReplicatorCommands(core, replicator);
|
||||
useP2PReplicatorUI(core, core, replicator, createInteractiveP2PReplication(replicator));
|
||||
useObsidianReplicationRibbonFeature(core);
|
||||
useRemoteConfiguration(core);
|
||||
|
||||
useSetupProtocolFeature(core, setupManager);
|
||||
@@ -196,11 +197,17 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
useOfflineScanner(core);
|
||||
useRedFlagFeatures(core);
|
||||
useCheckRemoteSize(core);
|
||||
useInteractiveConflictResolutionFeature(core, (filename, conflictCheckResult) => {
|
||||
return new ConflictResolveModal(this.app, filename, conflictCheckResult);
|
||||
});
|
||||
const compatibilityReview = useCompatibilityReview(
|
||||
core,
|
||||
createObsidianCompatibilityReviewUi(core.confirm)
|
||||
);
|
||||
waitForCompatibilityReview = () => compatibilityReview.openReview();
|
||||
useStartupLifecycleFeature(core, {
|
||||
inviteToOnboarding: () => showOnboardingInvitation(core, setupManager),
|
||||
waitForCompatibilityReview: () => compatibilityReview.openReview(),
|
||||
});
|
||||
useReviewHarness(core, this, compatibilityReview);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import { LOG_LEVEL_NOTICE, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
||||
import { sendValue } from "octagonal-wheels/messagepassing/signal";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
|
||||
export class ModuleConflictChecker extends AbstractModule {
|
||||
async _queueConflictCheckIfOpen(file: FilePathWithPrefix): Promise<void> {
|
||||
const path = file;
|
||||
if (this.settings.checkConflictOnlyOnOpen) {
|
||||
const af = this.services.vault.getActiveFilePath();
|
||||
if (af && af != path) {
|
||||
this._log(`${file} is conflicted, merging process has been postponed.`, LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.services.conflict.queueCheckFor(path);
|
||||
}
|
||||
|
||||
async _queueConflictCheck(file: FilePathWithPrefix): Promise<void> {
|
||||
const optionalConflictResult = await this.services.conflict.getOptionalConflictCheckMethod(file);
|
||||
if (optionalConflictResult == true) {
|
||||
// The conflict has been resolved by another process.
|
||||
return;
|
||||
} else if (optionalConflictResult === "newer") {
|
||||
// The conflict should be resolved by the newer entry.
|
||||
await this.services.conflict.resolveByNewest(file);
|
||||
} else {
|
||||
this.conflictCheckQueue.enqueue(file);
|
||||
}
|
||||
}
|
||||
|
||||
_waitForAllConflictProcessed(): Promise<boolean> {
|
||||
return this.conflictResolveQueue.waitForAllProcessed();
|
||||
}
|
||||
|
||||
// TODO-> Move to ModuleConflictResolver?
|
||||
conflictResolveQueue = new QueueProcessor(
|
||||
async (filenames: FilePathWithPrefix[]) => {
|
||||
const filename = filenames[0];
|
||||
return await this.services.conflict.resolve(filename);
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
// No need to limit concurrency to `1` here, subsequent process will handle it,
|
||||
// And, some cases, we do not need to synchronised. (e.g., auto-merge available).
|
||||
// Therefore, limiting global concurrency is performed on resolver with the UI.
|
||||
concurrentLimit: 10,
|
||||
delay: 0,
|
||||
keepResultUntilDownstreamConnected: false,
|
||||
}
|
||||
).replaceEnqueueProcessor((queue, newEntity) => {
|
||||
const filename = newEntity;
|
||||
sendValue("cancel-resolve-conflict:" + filename, true);
|
||||
const newQueue = [...queue].filter((e) => e != newEntity);
|
||||
return [...newQueue, newEntity];
|
||||
});
|
||||
|
||||
conflictCheckQueue = // First process - Check is the file actually need resolve -
|
||||
new QueueProcessor(
|
||||
(files: FilePathWithPrefix[]) => {
|
||||
const filename = files[0];
|
||||
return Promise.resolve([filename]);
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 10,
|
||||
delay: 0,
|
||||
keepResultUntilDownstreamConnected: true,
|
||||
pipeTo: this.conflictResolveQueue,
|
||||
totalRemainingReactiveSource: this.services.conflict.conflictProcessQueueCount,
|
||||
}
|
||||
);
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.conflict.queueCheckForIfOpen.setHandler(this._queueConflictCheckIfOpen.bind(this));
|
||||
services.conflict.queueCheckFor.setHandler(this._queueConflictCheck.bind(this));
|
||||
services.conflict.ensureAllProcessed.setHandler(this._waitForAllConflictProcessed.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
CANCELLED,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
MISSING_OR_ERROR,
|
||||
NOT_CONFLICTED,
|
||||
type diff_check_result,
|
||||
type FilePathWithPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { isCustomisationSyncMetadata, isPluginMetadata } from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
|
||||
import { TARGET_IS_NEW } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols";
|
||||
import { compareMTime, displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import diff_match_patch from "diff-match-patch";
|
||||
import { stripAllPrefixes, isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
export class ModuleConflictResolver extends AbstractModule {
|
||||
private async _resolveConflictByDeletingRev(
|
||||
path: FilePathWithPrefix,
|
||||
deleteRevision: string,
|
||||
subTitle = "",
|
||||
showNotice = true
|
||||
): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> {
|
||||
const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`;
|
||||
if (!(await this.core.fileHandler.deleteRevisionFromDB(path, deleteRevision))) {
|
||||
this._log(
|
||||
`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, path);
|
||||
this._log(
|
||||
`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
if ((await this.core.databaseFileAccess.getConflictedRevs(path)).length != 0) {
|
||||
this._log(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
if (isPluginMetadata(path) || isCustomisationSyncMetadata(path)) {
|
||||
this._log(`${title} ${path} is a plugin metadata file, no need to write to storage`, LOG_LEVEL_INFO);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
// If no conflicts were found, write the resolved content to the storage.
|
||||
if (!(await this.core.fileHandler.dbToStorage(path, stripAllPrefixes(path), true))) {
|
||||
this._log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
const level = subTitle.indexOf("same") !== -1 || !showNotice ? LOG_LEVEL_INFO : LOG_LEVEL_NOTICE;
|
||||
this._log(`${path} has been merged automatically`, level);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
|
||||
async checkConflictAndPerformAutoMerge(path: FilePathWithPrefix): Promise<diff_check_result> {
|
||||
//
|
||||
const ret = await this.localDatabase.tryAutoMerge(path, !this.settings.disableMarkdownAutoMerge);
|
||||
if ("ok" in ret) {
|
||||
return ret.ok;
|
||||
}
|
||||
|
||||
if ("result" in ret) {
|
||||
const p = ret.result;
|
||||
// Merged content is coming.
|
||||
// 1. Store the merged content to the storage
|
||||
if (!(await this.core.databaseFileAccess.storeContent(path, p))) {
|
||||
this._log(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
|
||||
return await this.services.conflict.resolveByDeletingRevision(path, ret.conflictedRev, "Sensible");
|
||||
}
|
||||
|
||||
const { rightRev, leftLeaf, rightLeaf } = ret;
|
||||
|
||||
// should be one or more conflicts;
|
||||
if (leftLeaf == false) {
|
||||
// what's going on..
|
||||
this._log(`could not get current revisions:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
if (rightLeaf == false) {
|
||||
// A locally unreadable conflict leaf may still be recoverable from another
|
||||
// replica or backup. Keep it visible for explicit repair instead of treating
|
||||
// missing chunks as evidence that the branch is obsolete.
|
||||
this._log(`could not read conflicted revision ${rightRev}:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
|
||||
const isSame = leftLeaf.data == rightLeaf.data && leftLeaf.deleted == rightLeaf.deleted;
|
||||
const isBinary = !isPlainText(path);
|
||||
const alwaysNewer = this.settings.resolveConflictsByNewerFile;
|
||||
if (isSame || isBinary || alwaysNewer) {
|
||||
const result = compareMTime(leftLeaf.mtime, rightLeaf.mtime);
|
||||
let loser = leftLeaf;
|
||||
// if (lMtime > rMtime) {
|
||||
if (result != TARGET_IS_NEW) {
|
||||
loser = rightLeaf;
|
||||
}
|
||||
const subTitle = [
|
||||
`${isSame ? "same" : ""}`,
|
||||
`${isBinary ? "binary" : ""}`,
|
||||
`${alwaysNewer ? "alwaysNewer" : ""}`,
|
||||
]
|
||||
.filter((e) => e.trim())
|
||||
.join(",");
|
||||
return await this.services.conflict.resolveByDeletingRevision(path, loser.rev, subTitle);
|
||||
}
|
||||
// make diff.
|
||||
const dmp = new diff_match_patch();
|
||||
const diff = dmp.diff_main(leftLeaf.data, rightLeaf.data);
|
||||
dmp.diff_cleanupSemantic(diff);
|
||||
this._log(`conflict(s) found:${path}`);
|
||||
return {
|
||||
left: leftLeaf,
|
||||
right: rightLeaf,
|
||||
diff: diff,
|
||||
};
|
||||
}
|
||||
|
||||
private async _resolveConflict(filename: FilePathWithPrefix): Promise<void> {
|
||||
// const filename = filenames[0];
|
||||
return await serialized(`conflict-resolve:${filename}`, async () => {
|
||||
const conflictCheckResult = await this.checkConflictAndPerformAutoMerge(filename);
|
||||
if (conflictCheckResult === NOT_CONFLICTED) {
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
this._log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === MISSING_OR_ERROR || conflictCheckResult === CANCELLED) {
|
||||
// nothing to do.
|
||||
this._log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === AUTO_MERGED) {
|
||||
//auto resolved, but need check again;
|
||||
if (this.settings.syncAfterMerge && !this.services.appLifecycle.isSuspended()) {
|
||||
//Wait for the running replication, if not running replication, run it once.
|
||||
await this.services.replication.replicateUnattendedByEvent({
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
this._log("[conflict] Automatically merged, but we have to check it again");
|
||||
await this.services.conflict.queueCheckFor(filename);
|
||||
return;
|
||||
}
|
||||
if (this.settings.showMergeDialogOnlyOnActive) {
|
||||
const af = this.services.vault.getActiveFilePath();
|
||||
if (af && af != filename) {
|
||||
this._log(
|
||||
`[conflict] ${filename} is conflicted. Merging process has been postponed to the file have got opened.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._log("[conflict] Manual merge required!");
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
await this.services.conflict.resolveByUserInteraction(filename, conflictCheckResult);
|
||||
});
|
||||
}
|
||||
|
||||
private async _anyResolveConflictByNewest(filename: FilePathWithPrefix, showNotice = true): Promise<boolean> {
|
||||
const currentRev = await this.core.databaseFileAccess.fetchEntryMeta(filename, undefined, true);
|
||||
if (currentRev == false) {
|
||||
this._log(`Could not get current revision of ${filename}`);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const revs = await this.core.databaseFileAccess.getConflictedRevs(filename);
|
||||
if (revs.length == 0) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
const mTimeAndRev = (
|
||||
[
|
||||
[currentRev.mtime, currentRev._rev],
|
||||
...(await Promise.all(
|
||||
revs.map(async (rev) => {
|
||||
const leaf = await this.core.databaseFileAccess.fetchEntryMeta(filename, rev);
|
||||
if (leaf == false) {
|
||||
return [0, rev];
|
||||
}
|
||||
return [leaf.mtime, rev];
|
||||
})
|
||||
)),
|
||||
] as [number, string][]
|
||||
).sort((a, b) => {
|
||||
const diff = b[0] - a[0];
|
||||
if (diff == 0) {
|
||||
return a[1].localeCompare(b[1], "en", { numeric: true });
|
||||
}
|
||||
return diff;
|
||||
});
|
||||
// console.warn(mTimeAndRev);
|
||||
this._log(
|
||||
`Resolving conflict by newest: ${filename} (Newest: ${new Date(mTimeAndRev[0][0]).toLocaleString()}) (${mTimeAndRev.length} revisions exists)`
|
||||
);
|
||||
for (let i = 1; i < mTimeAndRev.length; i++) {
|
||||
this._log(
|
||||
`conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}`
|
||||
);
|
||||
await this._resolveConflictByDeletingRev(filename, mTimeAndRev[i][1], "NEWEST", showNotice);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private async _resolveAllConflictedFilesByNewerOnes() {
|
||||
this._log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE);
|
||||
|
||||
const files = await this.core.storageAccess.getFileNames();
|
||||
|
||||
let i = 0;
|
||||
for (const file of files) {
|
||||
i++;
|
||||
if (i % 10 === 0)
|
||||
this._log(
|
||||
`Check and Processing ${i} / ${files.length}`,
|
||||
LOG_LEVEL_NOTICE,
|
||||
"resolveAllConflictedFilesByNewerOnes"
|
||||
);
|
||||
await this._anyResolveConflictByNewest(file, false);
|
||||
}
|
||||
this._log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
|
||||
}
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.conflict.resolveByDeletingRevision.setHandler(this._resolveConflictByDeletingRev.bind(this));
|
||||
services.conflict.resolve.setHandler(this._resolveConflict.bind(this));
|
||||
services.conflict.resolveByNewest.setHandler(this._anyResolveConflictByNewest.bind(this));
|
||||
services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(
|
||||
this._resolveAllConflictedFilesByNewerOnes.bind(this)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
MISSING_OR_ERROR,
|
||||
type FilePathWithPrefix,
|
||||
type MetaEntry,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ModuleConflictResolver } from "./ModuleConflictResolver";
|
||||
|
||||
function createModule(files: FilePathWithPrefix[] = []) {
|
||||
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
|
||||
const tryAutoMerge = vi.fn();
|
||||
const queueCheckFor = vi.fn(async () => undefined);
|
||||
const resolveByUserInteraction = vi.fn(async () => false);
|
||||
const core = {
|
||||
_services: {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
setting: {
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
conflict: {
|
||||
resolveByNewest: vi.fn(async () => true),
|
||||
resolveByDeletingRevision,
|
||||
resolveByUserInteraction,
|
||||
queueCheckFor,
|
||||
},
|
||||
appLifecycle: {
|
||||
isSuspended: vi.fn(() => false),
|
||||
},
|
||||
replication: {
|
||||
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
vault: {
|
||||
getActiveFilePath: vi.fn(() => undefined),
|
||||
},
|
||||
},
|
||||
settings: DEFAULT_SETTINGS,
|
||||
fileHandler: {
|
||||
deleteRevisionFromDB: vi.fn(async () => true),
|
||||
dbToStorage: vi.fn(async () => true),
|
||||
},
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs: vi.fn(async () => []),
|
||||
storeContent: vi.fn(async () => true),
|
||||
},
|
||||
localDatabase: {
|
||||
tryAutoMerge,
|
||||
},
|
||||
storageAccess: {
|
||||
getFileNames: vi.fn(async () => files),
|
||||
},
|
||||
} as any;
|
||||
Object.defineProperty(core, "services", { get: () => core._services });
|
||||
|
||||
const module = new ModuleConflictResolver(core);
|
||||
module._log = vi.fn();
|
||||
return { module, queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction, tryAutoMerge };
|
||||
}
|
||||
|
||||
describe("ModuleConflictResolver bulk newest resolution", () => {
|
||||
it("retains the success notice for a non-bulk newest resolution", async () => {
|
||||
const { module } = createModule();
|
||||
const path = "example.md" as FilePathWithPrefix;
|
||||
module.core.databaseFileAccess.fetchEntryMeta = vi.fn(
|
||||
async (_path: unknown, rev?: string): Promise<MetaEntry> =>
|
||||
({
|
||||
_id: "doc-id",
|
||||
_rev: rev ?? "2-current",
|
||||
path,
|
||||
ctime: 1,
|
||||
mtime: rev ? 1 : 2,
|
||||
size: 0,
|
||||
children: [],
|
||||
type: "plain",
|
||||
eden: {},
|
||||
}) as unknown as MetaEntry
|
||||
);
|
||||
module.core.databaseFileAccess.getConflictedRevs = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(["1-old"])
|
||||
.mockResolvedValue([]);
|
||||
|
||||
await (module as any)._anyResolveConflictByNewest(path);
|
||||
|
||||
expect(module._log).toHaveBeenLastCalledWith(`${path} has been merged automatically`, LOG_LEVEL_NOTICE);
|
||||
});
|
||||
|
||||
it("logs a successful bulk newest resolution without displaying a notice", async () => {
|
||||
const { module } = createModule();
|
||||
const path = "example.md" as FilePathWithPrefix;
|
||||
module.core.databaseFileAccess.fetchEntryMeta = vi.fn(
|
||||
async (_path: unknown, rev?: string): Promise<MetaEntry> =>
|
||||
({
|
||||
_id: "doc-id",
|
||||
_rev: rev ?? "2-current",
|
||||
path,
|
||||
ctime: 1,
|
||||
mtime: rev ? 1 : 2,
|
||||
size: 0,
|
||||
children: [],
|
||||
type: "plain",
|
||||
eden: {},
|
||||
}) as unknown as MetaEntry
|
||||
);
|
||||
module.core.databaseFileAccess.getConflictedRevs = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(["1-old"])
|
||||
.mockResolvedValue([]);
|
||||
|
||||
await (module as any)._anyResolveConflictByNewest(path, false);
|
||||
|
||||
expect(module._log).toHaveBeenLastCalledWith(`${path} has been merged automatically`, LOG_LEVEL_INFO);
|
||||
});
|
||||
|
||||
it("updates notice-level progress once every ten checked files", async () => {
|
||||
const files = Array.from({ length: 11 }, (_, index) => `note-${index}.md` as FilePathWithPrefix);
|
||||
const { module } = createModule(files);
|
||||
const resolveByNewest = vi.spyOn(module as any, "_anyResolveConflictByNewest").mockResolvedValue(true);
|
||||
|
||||
await (module as any)._resolveAllConflictedFilesByNewerOnes();
|
||||
|
||||
expect(resolveByNewest).toHaveBeenCalledTimes(11);
|
||||
expect(resolveByNewest).toHaveBeenCalledWith(files[0], false);
|
||||
expect(module._log).toHaveBeenCalledWith(
|
||||
"Check and Processing 10 / 11",
|
||||
LOG_LEVEL_NOTICE,
|
||||
"resolveAllConflictedFilesByNewerOnes"
|
||||
);
|
||||
expect(module._log).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleConflictResolver independent same-path creation", () => {
|
||||
const path = "independently-created.md" as FilePathWithPrefix;
|
||||
|
||||
function leaf(rev: string, data: string, mtime: number) {
|
||||
return {
|
||||
rev,
|
||||
data,
|
||||
mtime,
|
||||
ctime: mtime,
|
||||
deleted: false,
|
||||
} as any;
|
||||
}
|
||||
|
||||
it("collapses one duplicate revision when independently created files have identical content", async () => {
|
||||
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
|
||||
const leftLeaf = leaf("1-left", "Same content\n", 1000);
|
||||
const rightLeaf = leaf("1-right", "Same content\n", 2000);
|
||||
tryAutoMerge.mockResolvedValue({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
});
|
||||
|
||||
const result = await module.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(resolveByDeletingRevision).toHaveBeenCalledOnce();
|
||||
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-left", "same");
|
||||
});
|
||||
|
||||
it("returns a manual diff when independently created files have different content", async () => {
|
||||
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
|
||||
const leftLeaf = leaf("1-left", "Left content\n", 1000);
|
||||
const rightLeaf = leaf("1-right", "Right content\n", 2000);
|
||||
tryAutoMerge.mockResolvedValue({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
});
|
||||
|
||||
const result = await module.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toMatchObject({ left: leftLeaf, right: rightLeaf });
|
||||
expect(result).toHaveProperty("diff");
|
||||
expect(resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleConflictResolver sensible merge hand-off", () => {
|
||||
it("keeps an unreadable non-winner revision unresolved", async () => {
|
||||
const path = "missing-conflict-body.md" as FilePathWithPrefix;
|
||||
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
|
||||
tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "3-current",
|
||||
rightRev: "2-unreadable",
|
||||
leftLeaf: {
|
||||
rev: "3-current",
|
||||
data: "Readable current body\n",
|
||||
ctime: 1,
|
||||
mtime: 3,
|
||||
deleted: false,
|
||||
},
|
||||
rightLeaf: false,
|
||||
});
|
||||
|
||||
const result = await module.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(MISSING_OR_ERROR);
|
||||
expect(resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores the merged body and removes the resolved conflict leaf", async () => {
|
||||
const path = "sensible.md" as FilePathWithPrefix;
|
||||
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
|
||||
tryAutoMerge.mockResolvedValue({
|
||||
result: "Title\nLeft changed\nRight changed\n",
|
||||
conflictedRev: "2-right",
|
||||
});
|
||||
|
||||
const result = await module.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(module.core.databaseFileAccess.storeContent).toHaveBeenCalledWith(
|
||||
path,
|
||||
"Title\nLeft changed\nRight changed\n"
|
||||
);
|
||||
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "Sensible");
|
||||
});
|
||||
|
||||
it("commits a sensible pair before rechecking the remaining manual pair", async () => {
|
||||
const path = "three-versions.md" as FilePathWithPrefix;
|
||||
const { module, queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction, tryAutoMerge } =
|
||||
createModule();
|
||||
const remainingManualPair = {
|
||||
leftRev: "3-merged",
|
||||
rightRev: "2-third",
|
||||
leftLeaf: { rev: "3-merged", data: "Merged\n", ctime: 1, mtime: 3 },
|
||||
rightLeaf: { rev: "2-third", data: "Overlapping\n", ctime: 1, mtime: 2 },
|
||||
};
|
||||
tryAutoMerge
|
||||
.mockResolvedValueOnce({
|
||||
result: "Merged\n",
|
||||
conflictedRev: "2-second",
|
||||
})
|
||||
.mockResolvedValueOnce(remainingManualPair);
|
||||
|
||||
await (module as any)._resolveConflict(path);
|
||||
|
||||
expect(module.core.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "Merged\n");
|
||||
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-second", "Sensible");
|
||||
expect(queueCheckFor).toHaveBeenCalledWith(path);
|
||||
expect(resolveByUserInteraction).not.toHaveBeenCalled();
|
||||
|
||||
await (module as any)._resolveConflict(path);
|
||||
|
||||
expect(tryAutoMerge).toHaveBeenCalledTimes(2);
|
||||
expect(resolveByUserInteraction).toHaveBeenCalledWith(
|
||||
path,
|
||||
expect.objectContaining({
|
||||
left: remainingManualPair.leftLeaf,
|
||||
right: remainingManualPair.rightLeaf,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { AbstractModule } from "@/modules/AbstractModule";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { copyFileDatabaseInfo } from "@/serviceFeatures/fileDatabaseInfo";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
// Separated Module for basic menu commands, which are not related to obsidian specific features. It is expected to be used in other platforms with minimal changes.
|
||||
// However, it is odd that it has here at all; it really ought to be in each respective feature. It will likely be moved eventually. Until now, addCommand pointed to Obsidian's version.
|
||||
export class ModuleBasicMenu extends AbstractModule {
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
this.addCommand({
|
||||
id: "livesync-replicate",
|
||||
name: $msg("Sync now"),
|
||||
callback: async () => {
|
||||
await this.services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-dump",
|
||||
name: $msg("Copy database information for the active file"),
|
||||
checkCallback: (checking) => {
|
||||
const file = this.services.vault.getActiveFilePath();
|
||||
if (!file) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => copyFileDatabaseInfo(this.core, file));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-toggle",
|
||||
name: "Toggle LiveSync",
|
||||
callback: async () => {
|
||||
if (this.settings.liveSync) {
|
||||
this.settings.liveSync = false;
|
||||
this._log("LiveSync Disabled.", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
this.settings.liveSync = true;
|
||||
this._log("LiveSync Enabled.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await this.services.control.applySettings();
|
||||
await this.services.setting.saveSettingData();
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-suspendall",
|
||||
name: "Toggle All Sync.",
|
||||
callback: async () => {
|
||||
if (this.services.appLifecycle.isSuspended()) {
|
||||
this.services.appLifecycle.setSuspended(false);
|
||||
this._log("Self-hosted LiveSync resumed", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
this.services.appLifecycle.setSuspended(true);
|
||||
this._log("Self-hosted LiveSync suspended", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await this.services.control.applySettings();
|
||||
await this.services.setting.saveSettingData();
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "livesync-scan-files",
|
||||
name: "Scan storage and database again",
|
||||
checkCallback: (checking) => {
|
||||
if (!this.settings.useAdvancedMode) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => this.services.vault.scanVault(true));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "livesync-runbatch",
|
||||
name: $msg("Apply pending changes now"),
|
||||
callback: async () => {
|
||||
await this.services.fileProcessing.commitPendingFileEvents();
|
||||
},
|
||||
});
|
||||
|
||||
// TODO, Replicator is possibly one of features. It should be moved to features.
|
||||
this.addCommand({
|
||||
id: "livesync-abortsync",
|
||||
name: "Abort synchronization immediately",
|
||||
checkCallback: (checking) => {
|
||||
if (!this.settings.useAdvancedMode) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => this.services.replication.stopActiveTransfer());
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Command } from "@/deps";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { ModuleBasicMenu } from "./ModuleBasicMenu";
|
||||
|
||||
type RegisteredCommand = Command & {
|
||||
checkCallback?: (checking: boolean) => boolean | void;
|
||||
};
|
||||
|
||||
function createFixture() {
|
||||
const commands: RegisteredCommand[] = [];
|
||||
const settings = {
|
||||
liveSync: false,
|
||||
useAdvancedMode: false,
|
||||
enableDebugTools: false,
|
||||
};
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn((command: RegisteredCommand) => {
|
||||
commands.push(command);
|
||||
return command;
|
||||
}),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
replication: {
|
||||
replicateUserInitiated: vi.fn(async () => ({ status: "completed" as const })),
|
||||
stopActiveTransfer: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
vault: {
|
||||
getActiveFilePath: vi.fn((): string | null => "note.md"),
|
||||
scanVault: vi.fn(async () => undefined),
|
||||
},
|
||||
control: {
|
||||
applySettings: vi.fn(async () => undefined),
|
||||
},
|
||||
setting: {
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
appLifecycle: {
|
||||
isSuspended: vi.fn(() => false),
|
||||
setSuspended: vi.fn(),
|
||||
},
|
||||
fileProcessing: {
|
||||
commitPendingFileEvents: vi.fn(async () => true),
|
||||
},
|
||||
UI: {
|
||||
promptCopyToClipboard: vi.fn(async (_title: string, _value: string) => true),
|
||||
},
|
||||
path: {
|
||||
path2id: vi.fn(async () => "f:note"),
|
||||
},
|
||||
};
|
||||
const core = {
|
||||
settings,
|
||||
_services: services,
|
||||
services,
|
||||
localDatabase: {
|
||||
getDBEntry: vi.fn(async () => false),
|
||||
localDatabase: {
|
||||
get: vi.fn(async () => ({
|
||||
_id: "f:note",
|
||||
_rev: "2-current",
|
||||
_conflicts: [],
|
||||
path: "note.md",
|
||||
ctime: 100,
|
||||
mtime: 200,
|
||||
size: 12,
|
||||
type: "plain",
|
||||
children: ["h:private-chunk-id"],
|
||||
eden: {},
|
||||
})),
|
||||
},
|
||||
getDBEntryMeta: vi.fn(async () => ({
|
||||
_id: "f:note",
|
||||
_rev: "2-current",
|
||||
_conflicts: [],
|
||||
path: "note.md",
|
||||
ctime: 100,
|
||||
mtime: 200,
|
||||
size: 12,
|
||||
type: "plain",
|
||||
datatype: "plain",
|
||||
data: "",
|
||||
children: ["h:private-chunk-id"],
|
||||
eden: {},
|
||||
})),
|
||||
allDocsRaw: vi.fn(async () => ({
|
||||
rows: [{ id: "h:private-chunk-id", key: "h:private-chunk-id", value: { rev: "1-chunk" } }],
|
||||
})),
|
||||
},
|
||||
storageAccess: {
|
||||
isExistsIncludeHidden: vi.fn(async () => true),
|
||||
statHidden: vi.fn(async () => ({ ctime: 100, mtime: 200, size: 12, type: "file" })),
|
||||
},
|
||||
replicator: {
|
||||
terminateSync: vi.fn(),
|
||||
},
|
||||
};
|
||||
const module = new ModuleBasicMenu(core as never);
|
||||
|
||||
return {
|
||||
commands,
|
||||
core,
|
||||
module,
|
||||
services,
|
||||
settings,
|
||||
getCommand(id: string) {
|
||||
const command = commands.find((candidate) => candidate.id === id);
|
||||
expect(command, `command ${id}`).toBeDefined();
|
||||
return command!;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ModuleBasicMenu command palette", () => {
|
||||
it("uses clear user-facing names without changing the established command IDs", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
expect(fixture.getCommand("livesync-replicate").name).toBe("Sync now");
|
||||
expect(fixture.getCommand("livesync-runbatch").name).toBe("Apply pending changes now");
|
||||
});
|
||||
|
||||
it("keeps Sync now progress quiet while retaining failure-recovery authority", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
await fixture.getCommand("livesync-replicate").callback?.();
|
||||
|
||||
expect(fixture.services.replication.replicateUserInitiated).toHaveBeenCalledWith({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps maintenance commands out of the normal palette", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(false);
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(false);
|
||||
|
||||
fixture.settings.useAdvancedMode = true;
|
||||
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(true);
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(true);
|
||||
});
|
||||
|
||||
it("routes an explicit stop through the active provider capability", async () => {
|
||||
const fixture = createFixture();
|
||||
fixture.settings.useAdvancedMode = true;
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(false)).toBe(true);
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.services.replication.stopActiveTransfer).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(fixture.core.replicator.terminateSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps active-file database information available and opens it in a copy dialogue", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
const command = fixture.getCommand("livesync-dump");
|
||||
expect(command.name).toBe("Copy database information for the active file");
|
||||
expect(command.checkCallback?.(true)).toBe(true);
|
||||
|
||||
command.checkCallback?.(false);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.services.UI.promptCopyToClipboard).toHaveBeenCalledOnce();
|
||||
});
|
||||
const [title, report] = fixture.services.UI.promptCopyToClipboard.mock.calls[0];
|
||||
expect(title).toBe("Database information for note.md");
|
||||
expect(report).toContain("note.md");
|
||||
expect(report).toContain("2-current");
|
||||
expect(report).toContain("h:private-chunk-id");
|
||||
expect(report).toContain("1-chunk");
|
||||
expect(fixture.core.localDatabase.getDBEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the active-file database report when no file is active", async () => {
|
||||
const fixture = createFixture();
|
||||
fixture.services.vault.getActiveFilePath.mockReturnValue(null);
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
expect(fixture.getCommand("livesync-dump").checkCallback?.(true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,346 +0,0 @@
|
||||
import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
Logger,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { EVENT_REQUEST_RUN_DOCTOR, EVENT_REQUEST_RUN_FIX_INCOMPLETE, eventHub } from "@/common/events.ts";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { performDoctorConsultation, RebuildOptions } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
|
||||
import { isValidPath } from "@/common/utils.ts";
|
||||
import { isMetaEntry } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
isDeletedEntry,
|
||||
isDocContentSame,
|
||||
isLoadedEntry,
|
||||
readAsBlob,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { countCompromisedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { SetupManager } from "@/modules/features/SetupManager.ts";
|
||||
import { showOnboardingInvitation } from "@/serviceFeatures/setupObsidian/setupManagerHandlers.ts";
|
||||
import {
|
||||
runConfiguredStartupLifecycle,
|
||||
runStartupEntryLifecycle,
|
||||
} from "@/serviceFeatures/configuredStartupLifecycle.ts";
|
||||
import { disableLegacyBulkChunkPreSend } from "@/common/compatibilitySettings.ts";
|
||||
|
||||
type ErrorInfo = {
|
||||
path: string;
|
||||
recordedSize: number;
|
||||
actualSize: number;
|
||||
storageSize: number;
|
||||
contentMatched: boolean;
|
||||
isConflicted?: boolean;
|
||||
};
|
||||
|
||||
const INCOMPLETE_DOCUMENT_NOTICE_GROUP = "startup-integrity-check";
|
||||
|
||||
interface CompromisedChunkCounter {
|
||||
countCompromisedChunks(): Promise<number | boolean>;
|
||||
}
|
||||
|
||||
function hasCompromisedChunkCounter(value: object | undefined): value is CompromisedChunkCounter {
|
||||
return (
|
||||
value !== undefined && "countCompromisedChunks" in value && typeof value.countCompromisedChunks === "function"
|
||||
);
|
||||
}
|
||||
|
||||
export class ModuleMigration extends AbstractModule<LiveSyncCore> {
|
||||
constructor(
|
||||
core: LiveSyncCore,
|
||||
private readonly waitForCompatibilityReview: () => Promise<void> = () => Promise.resolve()
|
||||
) {
|
||||
super(core);
|
||||
}
|
||||
|
||||
async migrateUsingDoctor(skipRebuild: boolean = false, activateReason = "updated", forceRescan = false) {
|
||||
const { shouldRebuild, shouldRebuildLocal, isModified, settings } = await performDoctorConsultation(
|
||||
{
|
||||
confirm: this.core.confirm,
|
||||
translate: this.services.context.translate,
|
||||
},
|
||||
this.settings,
|
||||
{
|
||||
localRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
|
||||
remoteRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
|
||||
activateReason,
|
||||
forceRescan,
|
||||
}
|
||||
);
|
||||
if (isModified) {
|
||||
this.settings = settings;
|
||||
await this.saveSettings();
|
||||
}
|
||||
if (!skipRebuild) {
|
||||
if (shouldRebuild) {
|
||||
await this.core.rebuilder.scheduleRebuild();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
} else if (shouldRebuildLocal) {
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async migrateDisableBulkSend() {
|
||||
if (disableLegacyBulkChunkPreSend(this.settings)) {
|
||||
this._log($msg("moduleMigration.logBulkSendCorrupted"), LOG_LEVEL_NOTICE);
|
||||
await this.saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
initialMessage() {
|
||||
const manager = this.core.getModule(SetupManager);
|
||||
showOnboardingInvitation(this.core, manager);
|
||||
}
|
||||
|
||||
async hasIncompleteDocs(force: boolean = false): Promise<boolean> {
|
||||
const incompleteDocsChecked = (await this.core.kvDB.get<boolean>("checkIncompleteDocs")) || false;
|
||||
if (incompleteDocsChecked && !force) {
|
||||
this._log("Incomplete docs check already done, skipping.", LOG_LEVEL_VERBOSE);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
const noticeGroups = this.core.services.context.noticeGroups;
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "checking", {
|
||||
message: "Checking for incomplete documents...",
|
||||
});
|
||||
this._log("Checking for incomplete documents...", LOG_LEVEL_VERBOSE);
|
||||
|
||||
try {
|
||||
const errorFiles = [] as ErrorInfo[];
|
||||
for await (const metaDoc of this.localDatabase.findAllNormalDocs({ conflicts: true })) {
|
||||
const path = this.getPath(metaDoc);
|
||||
|
||||
if (!isValidPath(path)) {
|
||||
continue;
|
||||
}
|
||||
if (!(await this.services.vault.isTargetFile(path))) {
|
||||
continue;
|
||||
}
|
||||
if (!isMetaEntry(metaDoc)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const doc = await this.localDatabase.getDBEntryFromMeta(metaDoc);
|
||||
if (!doc || !isLoadedEntry(doc)) {
|
||||
continue;
|
||||
}
|
||||
if (isDeletedEntry(doc)) {
|
||||
continue;
|
||||
}
|
||||
const isConflicted = metaDoc?._conflicts && metaDoc._conflicts.length > 0;
|
||||
|
||||
let storageFileContent;
|
||||
try {
|
||||
storageFileContent = await this.core.storageAccess.readHiddenFileBinary(path);
|
||||
} catch (e) {
|
||||
Logger(`Failed to read file ${path}: Possibly unprocessed or missing`);
|
||||
Logger(e, LOG_LEVEL_VERBOSE);
|
||||
continue;
|
||||
}
|
||||
// const storageFileBlob = createBlob(storageFileContent);
|
||||
const sizeOnStorage = storageFileContent.byteLength;
|
||||
const recordedSize = doc.size;
|
||||
const docBlob = readAsBlob(doc);
|
||||
const actualSize = docBlob.size;
|
||||
if (
|
||||
recordedSize !== actualSize ||
|
||||
sizeOnStorage !== actualSize ||
|
||||
sizeOnStorage !== recordedSize ||
|
||||
isConflicted
|
||||
) {
|
||||
const contentMatched = await isDocContentSame(doc.data, storageFileContent);
|
||||
errorFiles.push({
|
||||
path,
|
||||
recordedSize,
|
||||
actualSize,
|
||||
storageSize: sizeOnStorage,
|
||||
contentMatched,
|
||||
isConflicted,
|
||||
});
|
||||
Logger(
|
||||
`Size mismatch for ${path}: ${recordedSize} (DB Recorded) , ${actualSize} (DB Stored) , ${sizeOnStorage} (Storage Stored), ${contentMatched ? "Content Matched" : "Content Mismatched"} ${isConflicted ? "Conflicted" : "Not Conflicted"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (errorFiles.length == 0) {
|
||||
Logger("No size mismatches found", LOG_LEVEL_INFO);
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: "No size mismatches found",
|
||||
});
|
||||
await this.core.kvDB.set("checkIncompleteDocs", true);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
Logger(`Found ${errorFiles.length} size mismatches`, LOG_LEVEL_INFO);
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: `Found ${errorFiles.length} size mismatches`,
|
||||
});
|
||||
// We have to repair them following rules and situations:
|
||||
// A. DB Recorded != DB Stored
|
||||
// A.1. DB Recorded == Storage Stored
|
||||
// Possibly recoverable from storage. Just overwrite the DB content with storage content.
|
||||
// A.2. Neither
|
||||
// Probably it cannot be resolved on this device. Even if the storage content is larger than DB Recorded, it possibly corrupted.
|
||||
// We do not fix it automatically. Leave it as is. Possibly other device can do this.
|
||||
// B. DB Recorded == DB Stored , < Storage Stored
|
||||
// Very fragile, if DB Recorded size is less than Storage Stored size, we possibly repair the content (The issue was `unexpectedly shortened file`).
|
||||
// We do not fix it automatically, but it will be automatically overwritten in other process.
|
||||
// C. DB Recorded == DB Stored , > Storage Stored
|
||||
// Probably restored by the user by resolving A or B on other device, We should overwrite the storage
|
||||
// Also do not fix it automatically. It should be overwritten by replication.
|
||||
const recoverable = errorFiles.filter((e) => {
|
||||
return e.recordedSize === e.storageSize && !e.isConflicted;
|
||||
});
|
||||
const unrecoverable = errorFiles.filter((e) => {
|
||||
return e.recordedSize !== e.storageSize || e.isConflicted;
|
||||
});
|
||||
const fileInfo = (e: (typeof errorFiles)[0]) => {
|
||||
return `${e.path} (M: ${e.recordedSize}, A: ${e.actualSize}, S: ${e.storageSize}) ${e.isConflicted ? "(Conflicted)" : ""}`;
|
||||
};
|
||||
const messageUnrecoverable =
|
||||
unrecoverable.length > 0
|
||||
? $msg("moduleMigration.fix0256.messageUnrecoverable", {
|
||||
filesNotRecoverable: unrecoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
|
||||
})
|
||||
: "";
|
||||
|
||||
const message = $msg("moduleMigration.fix0256.message", {
|
||||
files: recoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
|
||||
messageUnrecoverable,
|
||||
});
|
||||
const CHECK_IT_LATER = $msg("moduleMigration.fix0256.buttons.checkItLater");
|
||||
const FIX = $msg("moduleMigration.fix0256.buttons.fix");
|
||||
const DISMISS = $msg("moduleMigration.fix0256.buttons.DismissForever");
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(message, [CHECK_IT_LATER, FIX, DISMISS], {
|
||||
title: $msg("moduleMigration.fix0256.title"),
|
||||
defaultAction: CHECK_IT_LATER,
|
||||
});
|
||||
if (ret == FIX) {
|
||||
for (const file of recoverable) {
|
||||
// Overwrite the database with the files on the storage
|
||||
const stubFile = await this.core.storageAccess.getFileStub(file.path);
|
||||
if (stubFile == null) {
|
||||
Logger(`Could not find stub file for ${file.path}`, LOG_LEVEL_NOTICE);
|
||||
continue;
|
||||
}
|
||||
|
||||
stubFile.stat.mtime = Date.now();
|
||||
const result = await this.core.fileHandler.storeFileToDB(stubFile, true, false);
|
||||
if (result) {
|
||||
Logger(`Successfully restored ${file.path} from storage`);
|
||||
} else {
|
||||
Logger(`Failed to restore ${file.path} from storage`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
}
|
||||
} else if (ret === DISMISS) {
|
||||
// User chose to dismiss the issue
|
||||
await this.core.kvDB.set("checkIncompleteDocs", true);
|
||||
}
|
||||
|
||||
return Promise.resolve(true);
|
||||
} catch (error) {
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: "The incomplete document check could not be completed.",
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
noticeGroups.finish(INCOMPLETE_DOCUMENT_NOTICE_GROUP);
|
||||
}
|
||||
}
|
||||
|
||||
async hasCompromisedChunks(): Promise<boolean> {
|
||||
Logger(`Checking for compromised chunks...`, LOG_LEVEL_VERBOSE);
|
||||
if (!this.settings.encrypt) {
|
||||
// If not encrypted, we do not need to check for compromised chunks.
|
||||
return true;
|
||||
}
|
||||
// Check local database for compromised chunks
|
||||
const localCompromised = await countCompromisedChunks(this.localDatabase.localDatabase);
|
||||
const remote = this.services.replicator.getActiveReplicator();
|
||||
const remoteCompromised =
|
||||
this.services.API.isOnline && hasCompromisedChunkCounter(remote)
|
||||
? await remote.countCompromisedChunks()
|
||||
: 0;
|
||||
if (localCompromised === false) {
|
||||
Logger(`Failed to count compromised chunks in local database`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (remoteCompromised === false) {
|
||||
Logger(`Failed to count compromised chunks in remote database`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (remoteCompromised === 0 && localCompromised === 0) {
|
||||
return true;
|
||||
}
|
||||
Logger(
|
||||
`Found compromised chunks : ${localCompromised} in local, ${remoteCompromised} in remote`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
const title = $msg("moduleMigration.insecureChunkExist.title");
|
||||
const msg = $msg("moduleMigration.insecureChunkExist.message");
|
||||
const REBUILD = $msg("moduleMigration.insecureChunkExist.buttons.rebuild");
|
||||
const FETCH = $msg("moduleMigration.insecureChunkExist.buttons.fetch");
|
||||
const DISMISS = $msg("moduleMigration.insecureChunkExist.buttons.later");
|
||||
const buttons = [REBUILD, FETCH, DISMISS];
|
||||
if (remoteCompromised != 0) {
|
||||
buttons.splice(buttons.indexOf(FETCH), 1);
|
||||
}
|
||||
const result = await this.core.confirm.askSelectStringDialogue(msg, buttons, {
|
||||
title,
|
||||
defaultAction: DISMISS,
|
||||
timeout: 0,
|
||||
});
|
||||
if (result === REBUILD) {
|
||||
// Rebuild the database
|
||||
await this.core.rebuilder.scheduleRebuild();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
} else if (result === FETCH) {
|
||||
// Fetch the latest data from remote
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
} else {
|
||||
// User chose to dismiss the issue
|
||||
this._log($msg("moduleMigration.insecureChunkExist.laterMessage"), LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async _everyOnFirstInitialize(): Promise<boolean> {
|
||||
return await runConfiguredStartupLifecycle({
|
||||
databaseReady: this.localDatabase.isReady,
|
||||
reportDatabaseNotReady: () => this._log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE),
|
||||
hasCompromisedChunks: () => this.hasCompromisedChunks(),
|
||||
hasIncompleteDocuments: () => this.hasIncompleteDocs(),
|
||||
waitForCompatibilityReview: () => this.waitForCompatibilityReview(),
|
||||
runDoctor: () => this.migrateUsingDoctor(false),
|
||||
migrateBulkSend: () => this.migrateDisableBulkSend(),
|
||||
});
|
||||
}
|
||||
_everyOnLayoutReady(): Promise<boolean> {
|
||||
const shouldInitialiseDatabase = runStartupEntryLifecycle({
|
||||
configured: this.settings.isConfigured === true,
|
||||
inviteToOnboarding: () => this.initialMessage(),
|
||||
});
|
||||
if (!shouldInitialiseDatabase) return Promise.resolve(false);
|
||||
eventHub.onEvent(EVENT_REQUEST_RUN_DOCTOR, async (reason) => {
|
||||
await this.migrateUsingDoctor(false, reason, true);
|
||||
});
|
||||
eventHub.onEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE, async () => {
|
||||
await this.hasIncompleteDocs(true);
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
super.onBindFunction(core, services);
|
||||
services.appLifecycle.onLayoutReady.addHandler(this._everyOnLayoutReady.bind(this));
|
||||
services.appLifecycle.onFirstInitialise.addHandler(this._everyOnFirstInitialize.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/modules/features/SetupManager.ts", () => ({
|
||||
SetupManager: class SetupManager {},
|
||||
}));
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
isValidPath: () => true,
|
||||
}));
|
||||
|
||||
import { ModuleMigration } from "./ModuleMigration.ts";
|
||||
|
||||
async function* noDocuments() {
|
||||
return;
|
||||
}
|
||||
|
||||
async function* failedDocumentScan() {
|
||||
throw new Error("scan failed");
|
||||
}
|
||||
|
||||
function createMigration(
|
||||
findAllNormalDocs: typeof noDocuments | typeof failedDocumentScan = noDocuments,
|
||||
settings = { sendChunksBulk: false, sendChunksBulkMaxSize: 1 }
|
||||
) {
|
||||
const noticeGroups = {
|
||||
setItem: vi.fn(),
|
||||
finish: vi.fn(() => true),
|
||||
};
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
context: { noticeGroups },
|
||||
setting: { saveSettingData: vi.fn(async () => undefined) },
|
||||
vault: { isTargetFile: vi.fn(async () => true) },
|
||||
path: { getPath: vi.fn() },
|
||||
};
|
||||
const core = {
|
||||
_services: services,
|
||||
services,
|
||||
kvDB: {
|
||||
get: vi.fn(async () => false),
|
||||
set: vi.fn(async () => undefined),
|
||||
},
|
||||
localDatabase: { findAllNormalDocs },
|
||||
storageAccess: {},
|
||||
settings,
|
||||
};
|
||||
return {
|
||||
migration: new ModuleMigration(core as never),
|
||||
noticeGroups,
|
||||
saveSettingData: services.setting.saveSettingData,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ModuleMigration obsolete-setting migration", () => {
|
||||
it("persists the removal of an enabled automatic bulk chunk pre-send setting", async () => {
|
||||
const settings = { sendChunksBulk: true, sendChunksBulkMaxSize: 16 };
|
||||
const { migration, saveSettingData } = createMigration(noDocuments, settings);
|
||||
|
||||
await migration.migrateDisableBulkSend();
|
||||
|
||||
expect(settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 1 });
|
||||
expect(saveSettingData).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not persist an already disabled automatic bulk chunk pre-send setting", async () => {
|
||||
const settings = { sendChunksBulk: false, sendChunksBulkMaxSize: 16 };
|
||||
const { migration, saveSettingData } = createMigration(noDocuments, settings);
|
||||
|
||||
await migration.migrateDisableBulkSend();
|
||||
|
||||
expect(settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 16 });
|
||||
expect(saveSettingData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleMigration incomplete-document notice", () => {
|
||||
it("keeps the check and its result in one persistent named group", async () => {
|
||||
const { migration, noticeGroups } = createMigration();
|
||||
|
||||
await expect(migration.hasIncompleteDocs()).resolves.toBe(true);
|
||||
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(1, "startup-integrity-check", "checking", {
|
||||
message: "Checking for incomplete documents...",
|
||||
});
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(2, "startup-integrity-check", "result", {
|
||||
message: "No size mismatches found",
|
||||
});
|
||||
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
|
||||
});
|
||||
|
||||
it("finishes the group with a failure result when the scan throws", async () => {
|
||||
const { migration, noticeGroups } = createMigration(failedDocumentScan);
|
||||
|
||||
await expect(migration.hasIncompleteDocs()).rejects.toThrow("scan failed");
|
||||
|
||||
expect(noticeGroups.setItem).toHaveBeenLastCalledWith("startup-integrity-check", "result", {
|
||||
message: "The incomplete document check could not be completed.",
|
||||
});
|
||||
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
// This file is based on a file that was published by the @remotely-save, under the Apache 2 License.
|
||||
// I would love to express my deepest gratitude to the original authors for their hard work and dedication. Without their contributions, this project would not have been possible.
|
||||
// This file was originally based on code published by @remotely-save under the Apache License 2.0.
|
||||
// I would like to express my gratitude to the original authors for their work.
|
||||
//
|
||||
// Original Implementation is here: https://github.com/remotely-save/remotely-save/blob/28b99557a864ef59c19d2ad96101196e401718f0/src/remoteForS3.ts
|
||||
// Original implementation: https://github.com/remotely-save/remotely-save/blob/28b99557a864ef59c19d2ad96101196e401718f0/src/remoteForS3.ts
|
||||
|
||||
import { FetchHttpHandler, type FetchHttpHandlerOptions } from "@smithy/fetch-http-handler";
|
||||
import { HttpRequest, HttpResponse } from "@smithy/protocol-http";
|
||||
@@ -102,6 +102,7 @@ export class ObsHttpHandler extends FetchHttpHandler {
|
||||
method: method,
|
||||
url: url,
|
||||
contentType: contentType,
|
||||
throw: false,
|
||||
};
|
||||
|
||||
const raceOfPromises = [
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { HttpRequest } from "@smithy/protocol-http";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const requestUrlMock = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
(param: { body?: string | ArrayBuffer }) => Promise<{
|
||||
(param: { body?: string | ArrayBuffer; throw?: boolean }) => Promise<{
|
||||
headers: Record<string, string>;
|
||||
status: number;
|
||||
arrayBuffer: ArrayBuffer;
|
||||
@@ -28,6 +29,42 @@ function requestWithBody(body: unknown) {
|
||||
});
|
||||
}
|
||||
|
||||
function mockS3ErrorResponse(status: number, code?: string) {
|
||||
requestUrlMock.mockImplementation(async (param) => {
|
||||
if (param.throw !== false) {
|
||||
throw new Error(`Request failed, status ${status}`);
|
||||
}
|
||||
return {
|
||||
headers: { "content-type": "application/xml" },
|
||||
status,
|
||||
arrayBuffer: new TextEncoder().encode(code ? `<Error><Code>${code}</Code></Error>` : "").buffer,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createS3Client() {
|
||||
return new S3Client({
|
||||
region: "us-east-1",
|
||||
credentials: {
|
||||
accessKeyId: "access-key",
|
||||
secretAccessKey: "secret-key",
|
||||
},
|
||||
endpoint: "https://objects.example.com",
|
||||
forcePathStyle: true,
|
||||
maxAttempts: 1,
|
||||
requestHandler: new ObsHttpHandler(),
|
||||
});
|
||||
}
|
||||
|
||||
function getMissingObject(client: S3Client) {
|
||||
return client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: "bucket",
|
||||
Key: "missing.json",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe("ObsHttpHandler request bodies", () => {
|
||||
beforeEach(() => {
|
||||
requestUrlMock.mockReset();
|
||||
@@ -58,3 +95,56 @@ describe("ObsHttpHandler request bodies", () => {
|
||||
expect(requestUrlMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ObsHttpHandler response handling", () => {
|
||||
beforeEach(() => {
|
||||
requestUrlMock.mockReset();
|
||||
});
|
||||
|
||||
it("returns an HTTP error response to the Smithy client", async () => {
|
||||
mockS3ErrorResponse(404, "NoSuchKey");
|
||||
const request = new HttpRequest({
|
||||
protocol: "https:",
|
||||
hostname: "objects.example.com",
|
||||
method: "GET",
|
||||
path: "/bucket/missing.json",
|
||||
headers: {},
|
||||
});
|
||||
|
||||
const result = await new ObsHttpHandler().handle(request);
|
||||
|
||||
expect(requestUrlMock).toHaveBeenCalledWith(expect.objectContaining({ throw: false }));
|
||||
expect(result.response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ code: "NoSuchKey", name: "NoSuchKey" },
|
||||
{ code: undefined, name: "NotFound" },
|
||||
])("lets the S3 client classify a missing object as $name", async ({ code, name }) => {
|
||||
mockS3ErrorResponse(404, code);
|
||||
|
||||
await expect(getMissingObject(createS3Client())).rejects.toMatchObject({
|
||||
name,
|
||||
$metadata: { httpStatusCode: 404 },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: 403, code: "AccessDenied" },
|
||||
{ status: 500, code: "InternalError" },
|
||||
])("keeps an S3 $status response distinct from a missing object", async ({ status, code }) => {
|
||||
mockS3ErrorResponse(status, code);
|
||||
|
||||
await expect(getMissingObject(createS3Client())).rejects.toMatchObject({
|
||||
name: code,
|
||||
$metadata: { httpStatusCode: status },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a transport failure", async () => {
|
||||
const failure = new Error("network failed");
|
||||
requestUrlMock.mockRejectedValue(failure);
|
||||
|
||||
await expect(new ObsHttpHandler().handle(requestWithBody(new ArrayBuffer(0)))).rejects.toBe(failure);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({ addIcon: vi.fn() }));
|
||||
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { ModuleObsidianMenu } from "./ModuleObsidianMenu";
|
||||
|
||||
describe("ModuleObsidianMenu ribbon", () => {
|
||||
it("retains visible progress and full interaction authority", async () => {
|
||||
let runRibbonAction: (() => Promise<void>) | undefined;
|
||||
const addClass = vi.fn();
|
||||
const replicateUserInitiated = vi.fn(async () => ({ status: "completed" as const }));
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
addRibbonIcon: vi.fn((_icon: string, _title: string, callback: () => Promise<void>) => {
|
||||
runRibbonAction = callback;
|
||||
return { addClass };
|
||||
}),
|
||||
},
|
||||
replication: { replicateUserInitiated },
|
||||
};
|
||||
const module = new ModuleObsidianMenu({ _services: services, services } as never);
|
||||
|
||||
await module._everyOnloadStart();
|
||||
await runRibbonAction?.();
|
||||
|
||||
expect(replicateUserInitiated).toHaveBeenCalledWith({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
expect(addClass).toHaveBeenCalledWith("livesync-ribbon-replicate");
|
||||
});
|
||||
});
|
||||
@@ -6,12 +6,11 @@ import {
|
||||
type diff_result,
|
||||
type FilePathWithPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED, eventHub } from "@/common/events.ts";
|
||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { POSTPONED, type MergeDialogResult } from "@/serviceFeatures/interactiveConflictResolution/types";
|
||||
|
||||
export const POSTPONED = Symbol("postponed");
|
||||
|
||||
export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string;
|
||||
export { POSTPONED, type MergeDialogResult };
|
||||
|
||||
export type ConflictResolveModalOptions = {
|
||||
readOnly?: boolean;
|
||||
@@ -35,7 +34,7 @@ export class ConflictResolveModal extends Modal {
|
||||
readOnly: boolean = false;
|
||||
localName: string = "Base";
|
||||
remoteName: string = "Conflicted";
|
||||
offEvent?: ReturnType<typeof eventHub.onEvent>;
|
||||
private eventSubscriptions?: AbortController;
|
||||
currentDiffIndex = -1;
|
||||
diffView!: HTMLDivElement;
|
||||
diffNavIndicator!: HTMLSpanElement;
|
||||
@@ -112,20 +111,31 @@ export class ConflictResolveModal extends Modal {
|
||||
|
||||
override onOpen() {
|
||||
const { contentEl } = this;
|
||||
if (this.offEvent) {
|
||||
this.offEvent();
|
||||
}
|
||||
this.eventSubscriptions?.abort();
|
||||
const eventSubscriptions = new AbortController();
|
||||
this.eventSubscriptions = eventSubscriptions;
|
||||
eventHub.onceEvent(
|
||||
EVENT_PLUGIN_UNLOADED,
|
||||
() => {
|
||||
this.sendResponse(CANCELLED);
|
||||
},
|
||||
{ signal: eventSubscriptions.signal }
|
||||
);
|
||||
if (!this.readOnly) {
|
||||
// Cancel an older dialogue for this path before subscribing this
|
||||
// instance. Emitting after subscription would close the replacement
|
||||
// itself; the instance-owned result promise then completes the older
|
||||
// caller even when it only begins waiting after this event.
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, this.filename);
|
||||
this.offEvent = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => {
|
||||
if (path === this.filename) {
|
||||
this.sendResponse(CANCELLED);
|
||||
}
|
||||
});
|
||||
eventHub.onEvent(
|
||||
EVENT_CONFLICT_CANCELLED,
|
||||
(path) => {
|
||||
if (path === this.filename) {
|
||||
this.sendResponse(CANCELLED);
|
||||
}
|
||||
},
|
||||
{ signal: eventSubscriptions.signal }
|
||||
);
|
||||
}
|
||||
this.titleEl.setText(this.title);
|
||||
contentEl.empty();
|
||||
@@ -216,9 +226,8 @@ export class ConflictResolveModal extends Modal {
|
||||
override onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
if (this.offEvent) {
|
||||
this.offEvent();
|
||||
}
|
||||
this.eventSubscriptions?.abort();
|
||||
this.eventSubscriptions = undefined;
|
||||
if (this.consumed) {
|
||||
return;
|
||||
}
|
||||
|
||||
+43
-22
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { POSTPONED, ConflictResolveModal } from "./ConflictResolveModal.ts";
|
||||
import { CANCELLED, type diff_result, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED, eventHub } from "@/common/events.ts";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
App: class App {},
|
||||
@@ -24,12 +25,7 @@ vi.mock("@/deps.ts", () => ({
|
||||
};
|
||||
element.createDiv = vi.fn(() => this.createElement());
|
||||
element.createEl = vi.fn((_tag: string, _options?: unknown, callback?: (child: unknown) => void) => {
|
||||
if (
|
||||
_tag === "button" &&
|
||||
typeof _options === "object" &&
|
||||
_options !== null &&
|
||||
"text" in _options
|
||||
) {
|
||||
if (_tag === "button" && typeof _options === "object" && _options !== null && "text" in _options) {
|
||||
this.createdButtons.push(String((_options as { text: unknown }).text));
|
||||
}
|
||||
const child = this.createElement();
|
||||
@@ -93,23 +89,50 @@ describe("ConflictResolveModal result lifecycle", () => {
|
||||
expect(replacementState).toBe("still-open");
|
||||
});
|
||||
|
||||
it("closes for an external resolution of the same file and ignores other files", async () => {
|
||||
const filename = "resolved-elsewhere.md" as FilePathWithPrefix;
|
||||
const modal = new ConflictResolveModal({} as never, filename, conflict);
|
||||
modal.onOpen();
|
||||
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, "other.md" as FilePathWithPrefix);
|
||||
const stateAfterOtherFile = await Promise.race([
|
||||
modal.waitForResult(),
|
||||
new Promise<"still-open">((resolve) => setTimeout(() => resolve("still-open"), 25)),
|
||||
]);
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
|
||||
await expect(modal.waitForResult()).resolves.toBe(CANCELLED);
|
||||
expect(stateAfterOtherFile).toBe("still-open");
|
||||
});
|
||||
|
||||
it("closes and completes its result when the plug-in unloads", async () => {
|
||||
const modal = new ConflictResolveModal(
|
||||
{} as never,
|
||||
"open-during-unload.md" as FilePathWithPrefix,
|
||||
conflict
|
||||
);
|
||||
modal.onOpen();
|
||||
|
||||
eventHub.emitEvent(EVENT_PLUGIN_UNLOADED);
|
||||
const result = await Promise.race([
|
||||
modal.waitForResult(),
|
||||
new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 25)),
|
||||
]);
|
||||
modal.sendResponse(CANCELLED);
|
||||
|
||||
expect(result).toBe(CANCELLED);
|
||||
});
|
||||
|
||||
it("renders a read-only comparison with no resolution actions", () => {
|
||||
const ReadOnlyModal = ConflictResolveModal as unknown as new (
|
||||
...args: unknown[]
|
||||
) => ConflictResolveModal & { createdButtons: string[] };
|
||||
const modal = new ReadOnlyModal(
|
||||
{},
|
||||
"repair-preview.md",
|
||||
conflict,
|
||||
false,
|
||||
undefined,
|
||||
{
|
||||
readOnly: true,
|
||||
title: "Vault and database revision",
|
||||
localName: "Vault file",
|
||||
remoteName: "Database revision",
|
||||
}
|
||||
);
|
||||
const modal = new ReadOnlyModal({}, "repair-preview.md", conflict, false, undefined, {
|
||||
readOnly: true,
|
||||
title: "Vault and database revision",
|
||||
localName: "Vault file",
|
||||
remoteName: "Database revision",
|
||||
});
|
||||
|
||||
modal.onOpen();
|
||||
|
||||
@@ -124,9 +147,7 @@ describe("ConflictResolveModal result lifecycle", () => {
|
||||
it("does not cancel an active conflict dialogue when a read-only comparison opens for the same file", async () => {
|
||||
const filename = "repair-alongside-conflict.md" as FilePathWithPrefix;
|
||||
const previous = new ConflictResolveModal({} as never, filename, conflict);
|
||||
const ReadOnlyModal = ConflictResolveModal as unknown as new (
|
||||
...args: unknown[]
|
||||
) => ConflictResolveModal;
|
||||
const ReadOnlyModal = ConflictResolveModal as unknown as new (...args: unknown[]) => ConflictResolveModal;
|
||||
const comparison = new ReadOnlyModal({}, filename, conflict, false, undefined, {
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
import {
|
||||
CANCELLED,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
MISSING_OR_ERROR,
|
||||
type DocumentID,
|
||||
type FilePathWithPrefix,
|
||||
type diff_result,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ConflictResolveModal, POSTPONED } from "./InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||
import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts";
|
||||
import { displayRev } from "@/common/utils.ts";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR, eventHub } from "@/common/events.ts";
|
||||
import { $msg } from "@/common/translation.ts";
|
||||
import type { Editor, MarkdownFileInfo, MarkdownView } from "@/deps.ts";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
private postponedConflictEpisodes = new Set<FilePathWithPrefix>();
|
||||
|
||||
private async getConflictVersionCount(filename: FilePathWithPrefix): Promise<number | undefined> {
|
||||
try {
|
||||
const conflictCount = (await this.core.databaseFileAccess.getConflictedRevs(filename)).length;
|
||||
return conflictCount === 0 ? 0 : conflictCount + 1;
|
||||
} catch (error) {
|
||||
this._log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(error, LOG_LEVEL_VERBOSE);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async getActiveConflictMessages(): Promise<string[]> {
|
||||
const filename = this.services.vault.getActiveFilePath();
|
||||
if (!filename) return [];
|
||||
const versionCount = await this.getConflictVersionCount(filename);
|
||||
if (versionCount === 0) {
|
||||
this.postponedConflictEpisodes.delete(filename);
|
||||
return [];
|
||||
}
|
||||
if (versionCount !== undefined && versionCount >= 3) {
|
||||
return [
|
||||
$msg("This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", {
|
||||
COUNT: `${versionCount}`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (versionCount === 2 || this.postponedConflictEpisodes.has(filename)) {
|
||||
return [$msg("This file has unresolved conflicts.")];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private async refreshConflictState(filename: FilePathWithPrefix): Promise<void> {
|
||||
if ((await this.getConflictVersionCount(filename)) === 0) {
|
||||
this.postponedConflictEpisodes.delete(filename);
|
||||
}
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
}
|
||||
|
||||
private async requestConflictResolution(filename: FilePathWithPrefix): Promise<void> {
|
||||
this.postponedConflictEpisodes.delete(filename);
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
await this.services.conflict.queueCheckFor(filename);
|
||||
await this.services.conflict.ensureAllProcessed();
|
||||
}
|
||||
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
this.addCommand({
|
||||
id: "livesync-checkdoc-conflicted",
|
||||
name: "Resolve if conflicted.",
|
||||
editorCallback: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => {
|
||||
const file = view.file;
|
||||
if (!file) return;
|
||||
void this.requestConflictResolution(file.path as FilePathWithPrefix);
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-conflictcheck",
|
||||
name: "Pick a file to resolve conflict",
|
||||
callback: async () => {
|
||||
await this.pickFileForResolve();
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-all-conflictcheck",
|
||||
name: "Resolve all conflicted files",
|
||||
callback: async () => {
|
||||
await this.allConflictCheck();
|
||||
},
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async _anyResolveConflictByUI(filename: FilePathWithPrefix, conflictCheckResult: diff_result): Promise<boolean> {
|
||||
// UI for resolving conflicts should one-by-one.
|
||||
return await serialized(`conflict-resolve-ui`, async () => {
|
||||
if (this.postponedConflictEpisodes.has(filename)) {
|
||||
this._log(`Merge: Postponed ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
return false;
|
||||
}
|
||||
this._log("Merge:open conflict dialog", LOG_LEVEL_VERBOSE);
|
||||
const dialog = new ConflictResolveModal(this.app, filename, conflictCheckResult);
|
||||
dialog.open();
|
||||
const selected = await dialog.waitForResult();
|
||||
if (selected === POSTPONED) {
|
||||
this.postponedConflictEpisodes.add(filename);
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
this._log(`Merge: Postponed ${filename}`, LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
if (selected === CANCELLED) {
|
||||
// Cancelled by UI, or another conflict.
|
||||
this._log(`Merge: Cancelled ${filename}`, LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
const testDoc = await this.localDatabase.getDBEntry(filename, { conflicts: true }, false, true, true);
|
||||
if (testDoc === false) {
|
||||
this._log(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
if (!testDoc._conflicts || testDoc._conflicts.length === 0) {
|
||||
this._log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
await this.refreshConflictState(filename);
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
testDoc._rev !== conflictCheckResult.left.rev ||
|
||||
!testDoc._conflicts.includes(conflictCheckResult.right.rev)
|
||||
) {
|
||||
this._log(
|
||||
`Merge: The compared revisions changed while the dialogue was open: ${filename}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
await this.refreshConflictState(filename);
|
||||
await this.services.conflict.queueCheckFor(filename);
|
||||
return false;
|
||||
}
|
||||
const toDelete = selected;
|
||||
// const toKeep = conflictCheckResult.left.rev != toDelete ? conflictCheckResult.left.rev : conflictCheckResult.right.rev;
|
||||
if (toDelete === LEAVE_TO_SUBSEQUENT) {
|
||||
// Concatenate both conflicted revisions.
|
||||
// Create a new file by concatenating both conflicted revisions.
|
||||
const p = conflictCheckResult.diff.map((e) => e[1]).join("");
|
||||
const delRev = conflictCheckResult.right.rev;
|
||||
if (!(await this.core.databaseFileAccess.storeContent(filename, p))) {
|
||||
this._log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
|
||||
if (
|
||||
(await this.services.conflict.resolveByDeletingRevision(filename, delRev, "UI Concatenated")) ==
|
||||
MISSING_OR_ERROR
|
||||
) {
|
||||
this._log(
|
||||
`Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} else if (
|
||||
typeof toDelete === "string" &&
|
||||
(toDelete === conflictCheckResult.left.rev || toDelete === conflictCheckResult.right.rev)
|
||||
) {
|
||||
// Select one of the conflicted revision to delete.
|
||||
if (
|
||||
(await this.services.conflict.resolveByDeletingRevision(filename, toDelete, "UI Selected")) ==
|
||||
MISSING_OR_ERROR
|
||||
) {
|
||||
this._log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
this._log(`Merge: Something went wrong: ${filename}, (${String(toDelete)})`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
// In here, some merge has been processed.
|
||||
// So we have to run replication if configured.
|
||||
// TODO: Make this is as a event request
|
||||
if (this.settings.syncAfterMerge && !this.services.appLifecycle.isSuspended()) {
|
||||
await this.services.replication.replicateUnattendedByEvent({
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
// And, check it again.
|
||||
await this.services.conflict.queueCheckFor(filename);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
async allConflictCheck() {
|
||||
let notifyIfEmpty = true;
|
||||
while (await this.pickFileForResolve(notifyIfEmpty)) {
|
||||
notifyIfEmpty = false;
|
||||
}
|
||||
}
|
||||
|
||||
async pickFileForResolve(notifyIfEmpty = true) {
|
||||
const notes: { id: DocumentID; path: FilePathWithPrefix; dispPath: string; mtime: number }[] = [];
|
||||
for await (const doc of this.localDatabase.findAllDocs({ conflicts: true })) {
|
||||
if (!("_conflicts" in doc)) continue;
|
||||
notes.push({
|
||||
id: doc._id,
|
||||
path: this.getPath(doc),
|
||||
dispPath: this.getPathWithoutPrefix(doc),
|
||||
mtime: doc.mtime,
|
||||
});
|
||||
}
|
||||
notes.sort((a, b) => b.mtime - a.mtime);
|
||||
const notesList = notes.map((e) => e.dispPath);
|
||||
if (notesList.length == 0) {
|
||||
if (notifyIfEmpty) {
|
||||
this._log("There are no conflicted documents", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const target = await this.core.confirm.askSelectString("File to resolve conflict", notesList);
|
||||
if (target) {
|
||||
const targetItem = notes.find((e) => e.dispPath == target)!;
|
||||
await this.requestConflictResolution(targetItem.path);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async _allScanStat(): Promise<boolean> {
|
||||
const notes: { path: string; mtime: number }[] = [];
|
||||
this._log(`Checking conflicted files`, LOG_LEVEL_VERBOSE);
|
||||
try {
|
||||
for await (const doc of this.localDatabase.findAllDocs({ conflicts: true })) {
|
||||
if (!("_conflicts" in doc)) continue;
|
||||
notes.push({ path: this.getPath(doc), mtime: doc.mtime });
|
||||
}
|
||||
if (notes.length > 0) {
|
||||
this.core.confirm.askInPopup(
|
||||
`conflicting-detected-on-safety`,
|
||||
`Some files have been left conflicted! Press {HERE} to resolve them, or you can do it later by "Pick a file to resolve conflict`,
|
||||
(anchor) => {
|
||||
anchor.text = "HERE";
|
||||
anchor.addEventListener("click", () => {
|
||||
fireAndForget(() => this.allConflictCheck());
|
||||
});
|
||||
}
|
||||
);
|
||||
this._log(
|
||||
`Some files have been left conflicted! Please resolve them by "Pick a file to resolve conflict". The list is written in the log.`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
for (const note of notes) {
|
||||
this._log(`Conflicted: ${note.path}`);
|
||||
}
|
||||
} else {
|
||||
this._log(`There are no conflicting files`, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
} catch (e) {
|
||||
this._log(`Error while scanning conflicted files...`, LOG_LEVEL_NOTICE);
|
||||
this._log(e, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onScanningStartupIssues.addHandler(this._allScanStat.bind(this));
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
services.appLifecycle.getUnresolvedMessages.addHandler(this.getActiveConflictMessages.bind(this));
|
||||
services.conflict.resolveByUserInteraction.addHandler(this._anyResolveConflictByUI.bind(this));
|
||||
eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (filename) => {
|
||||
fireAndForget(() => this.refreshConflictState(filename));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
CANCELLED,
|
||||
DEFAULT_SETTINGS,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
LOG_LEVEL_NOTICE,
|
||||
type FilePathWithPrefix,
|
||||
type diff_result,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
const modalState = vi.hoisted(() => ({
|
||||
constructed: 0,
|
||||
result: undefined as unknown,
|
||||
postponed: Symbol("postponed"),
|
||||
}));
|
||||
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
displayRev: (revision: string) => revision,
|
||||
}));
|
||||
|
||||
vi.mock("./InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||
POSTPONED: modalState.postponed,
|
||||
ConflictResolveModal: class ConflictResolveModal {
|
||||
constructor() {
|
||||
modalState.constructed++;
|
||||
}
|
||||
|
||||
open() {}
|
||||
|
||||
async waitForResult() {
|
||||
return modalState.result;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { ModuleInteractiveConflictResolver } from "./ModuleInteractiveConflictResolver.ts";
|
||||
|
||||
const path = "note.md" as FilePathWithPrefix;
|
||||
const conflict: diff_result = {
|
||||
left: { rev: "2-left", data: "left", ctime: 1, mtime: 2 },
|
||||
right: { rev: "2-right", data: "right", ctime: 1, mtime: 2 },
|
||||
diff: [],
|
||||
};
|
||||
|
||||
async function* documents(items: unknown[]) {
|
||||
for (const item of items) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
|
||||
function createModule(conflictedRevisions: string[] = ["2-right"]) {
|
||||
const handlers = {
|
||||
unresolvedMessages: undefined as undefined | (() => Promise<string[]>),
|
||||
};
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
appLifecycle: {
|
||||
getUnresolvedMessages: {
|
||||
addHandler: vi.fn((handler: () => Promise<string[]>) => {
|
||||
handlers.unresolvedMessages = handler;
|
||||
}),
|
||||
},
|
||||
onScanningStartupIssues: { addHandler: vi.fn() },
|
||||
onInitialise: { addHandler: vi.fn() },
|
||||
isSuspended: vi.fn(() => false),
|
||||
},
|
||||
conflict: {
|
||||
resolveByUserInteraction: { addHandler: vi.fn() },
|
||||
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
|
||||
queueCheckFor: vi.fn(async () => undefined),
|
||||
ensureAllProcessed: vi.fn(async () => true),
|
||||
},
|
||||
replication: {
|
||||
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
vault: { getActiveFilePath: vi.fn(() => path) },
|
||||
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
|
||||
};
|
||||
const core = {
|
||||
_services: services,
|
||||
services,
|
||||
settings: { ...DEFAULT_SETTINGS, syncAfterMerge: false },
|
||||
localDatabase: {
|
||||
getDBEntry: vi.fn(async (): Promise<false | { _rev: string; _conflicts?: string[] }> => false),
|
||||
findAllDocs: vi.fn(() => documents([])),
|
||||
},
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs: vi.fn(async () => conflictedRevisions),
|
||||
storeContent: vi.fn(async () => true),
|
||||
},
|
||||
confirm: {
|
||||
askSelectString: vi.fn(async (): Promise<string | undefined> => undefined),
|
||||
},
|
||||
};
|
||||
const plugin = { app: {} };
|
||||
const module = new ModuleInteractiveConflictResolver(plugin as never, core as never);
|
||||
module._log = vi.fn();
|
||||
return { core, handlers, module, services };
|
||||
}
|
||||
|
||||
describe("ModuleInteractiveConflictResolver postponement", () => {
|
||||
beforeEach(() => {
|
||||
modalState.constructed = 0;
|
||||
modalState.result = modalState.postponed;
|
||||
});
|
||||
|
||||
it("does not reopen an unchanged conflict after the user chooses Not now", async () => {
|
||||
const { module } = createModule();
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(modalState.constructed).toBe(1);
|
||||
});
|
||||
|
||||
it("does not treat cancellation by another conflict dialogue as Not now", async () => {
|
||||
const { module } = createModule();
|
||||
modalState.result = CANCELLED;
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(modalState.constructed).toBe(2);
|
||||
});
|
||||
|
||||
it("allows an explicit resolution request to reopen a postponed conflict", async () => {
|
||||
const { module, services } = createModule();
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
await (module as any).requestConflictResolution(path);
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(services.conflict.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
expect(services.conflict.ensureAllProcessed).toHaveBeenCalledOnce();
|
||||
expect(modalState.constructed).toBe(2);
|
||||
});
|
||||
|
||||
it("opens a later conflict after the postponed conflict episode has resolved", async () => {
|
||||
const conflictedRevisions = ["2-right"];
|
||||
const { module } = createModule(conflictedRevisions);
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
conflictedRevisions.splice(0);
|
||||
await (module as any).refreshConflictState(path);
|
||||
conflictedRevisions.push("4-later");
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(modalState.constructed).toBe(2);
|
||||
});
|
||||
|
||||
it("contributes the active conflict to the existing unresolved-message display", async () => {
|
||||
const { core, handlers, module, services } = createModule();
|
||||
|
||||
module.onBindFunction(core as never, services as never);
|
||||
|
||||
expect(services.appLifecycle.getUnresolvedMessages.addHandler).toHaveBeenCalledOnce();
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
});
|
||||
|
||||
it("removes the active warning once the conflict has resolved", async () => {
|
||||
const conflictedRevisions = ["2-right"];
|
||||
const { core, handlers, module, services } = createModule(conflictedRevisions);
|
||||
module.onBindFunction(core as never, services as never);
|
||||
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
conflictedRevisions.splice(0);
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("reports the number of live versions and reduces it after each resolved pair", async () => {
|
||||
const conflictedRevisions = ["2-second", "2-third"];
|
||||
const { core, handlers, module, services } = createModule(conflictedRevisions);
|
||||
module.onBindFunction(core as never, services as never);
|
||||
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([
|
||||
"This file has 3 unresolved versions. They will be reviewed one pair at a time.",
|
||||
]);
|
||||
|
||||
conflictedRevisions.shift();
|
||||
await (module as any).refreshConflictState(path);
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
|
||||
conflictedRevisions.shift();
|
||||
await (module as any).refreshConflictState(path);
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("reconstructs the remaining pair after a postponed session is restarted", async () => {
|
||||
const conflictedRevisions = ["2-second", "2-third"];
|
||||
const firstSession = createModule(conflictedRevisions);
|
||||
|
||||
await firstSession.module._anyResolveConflictByUI(path, conflict);
|
||||
conflictedRevisions.shift();
|
||||
|
||||
const restartedSession = createModule(conflictedRevisions);
|
||||
restartedSession.module.onBindFunction(restartedSession.core as never, restartedSession.services as never);
|
||||
await expect(restartedSession.handlers.unresolvedMessages?.()).resolves.toEqual([
|
||||
"This file has unresolved conflicts.",
|
||||
]);
|
||||
|
||||
await restartedSession.module._anyResolveConflictByUI(path, {
|
||||
left: { rev: "3-merged", data: "merged", ctime: 1, mtime: 3 },
|
||||
right: { rev: "2-third", data: "third", ctime: 1, mtime: 2 },
|
||||
diff: [],
|
||||
});
|
||||
|
||||
expect(modalState.constructed).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes the compared right leaf when concatenating a deterministically selected pair", async () => {
|
||||
const { core, module, services } = createModule(["2-unrelated", "2-right"]);
|
||||
modalState.result = LEAVE_TO_SUBSEQUENT;
|
||||
core.localDatabase.getDBEntry.mockResolvedValue({
|
||||
_rev: "2-left",
|
||||
_conflicts: ["2-unrelated", "2-right"],
|
||||
});
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(core.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "");
|
||||
expect(services.conflict.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
|
||||
});
|
||||
|
||||
it("rechecks the live leaves instead of applying a stale dialogue selection", async () => {
|
||||
const { core, module, services } = createModule(["2-other"]);
|
||||
modalState.result = "2-right";
|
||||
core.localDatabase.getDBEntry.mockResolvedValue({
|
||||
_rev: "3-new-winner",
|
||||
_conflicts: ["2-other"],
|
||||
});
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(services.conflict.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(services.conflict.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleInteractiveConflictResolver file selection", () => {
|
||||
beforeEach(() => {
|
||||
modalState.constructed = 0;
|
||||
modalState.result = modalState.postponed;
|
||||
});
|
||||
|
||||
it("does not show a no-conflicts notice when an automatic repeat reaches its normal end", async () => {
|
||||
const { core, module } = createModule();
|
||||
core.localDatabase.findAllDocs
|
||||
.mockImplementationOnce(() =>
|
||||
documents([
|
||||
{
|
||||
_id: "note-id",
|
||||
_rev: "2-left",
|
||||
_conflicts: ["2-right"],
|
||||
path,
|
||||
mtime: 2,
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(() => documents([]));
|
||||
core.confirm.askSelectString.mockResolvedValue(path);
|
||||
|
||||
await module.allConflictCheck();
|
||||
|
||||
expect(core.confirm.askSelectString).toHaveBeenCalledOnce();
|
||||
expect(module._log).not.toHaveBeenCalledWith("There are no conflicted documents", LOG_LEVEL_NOTICE);
|
||||
});
|
||||
|
||||
it("shows one no-conflicts notice for an explicit selection request which starts empty", async () => {
|
||||
const { module } = createModule();
|
||||
|
||||
await module.pickFileForResolve();
|
||||
|
||||
expect(module._log).toHaveBeenCalledTimes(1);
|
||||
expect(module._log).toHaveBeenCalledWith("There are no conflicted documents", LOG_LEVEL_NOTICE);
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,10 @@ import { MARK_LOG_NETWORK_ERROR, MARK_LOG_SEPARATOR } from "@vrtmrz/livesync-com
|
||||
import { NetworkWarningStyles } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { generateReport } from "@/common/reportTool.ts";
|
||||
import {
|
||||
ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY,
|
||||
findPathComponentsExceedingUtf8Limit,
|
||||
} from "@/common/pathCompatibility.ts";
|
||||
|
||||
// This module cannot be a core module because it depends on the Obsidian UI.
|
||||
|
||||
@@ -293,6 +297,14 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
reasonWarn.push("Some platforms may be unable to process this file correctly: " + labels.join(" "));
|
||||
}
|
||||
}
|
||||
const oversizedPathComponents = findPathComponentsExceedingUtf8Limit(thisFile.path);
|
||||
if (oversizedPathComponents.length > 0) {
|
||||
reasonWarn.push(
|
||||
$msg("moduleLog.pathComponentTooLong", {
|
||||
maxBytes: `${ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
// Case Sensitivity
|
||||
if (this.services.vault.shouldCheckCaseInsensitively()) {
|
||||
const f = (await this.core.storageAccess.getFiles())
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
MetadataDocumentRepairResults,
|
||||
OfflineScanUnresolvedReasons,
|
||||
repairMetadataDocumentIdentity,
|
||||
VaultScanResults,
|
||||
type MetadataDocumentIdentityIssue,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import {
|
||||
@@ -292,7 +293,8 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
)) === repairAction,
|
||||
repair: async (repairRequest) =>
|
||||
await repairMetadataDocumentIdentity(this.core, repairRequest),
|
||||
requestOrdinaryScan: async () => await this.services.vault.scanVault(true, false),
|
||||
requestOrdinaryScan: async () =>
|
||||
(await this.services.vault.scanVault(true, false)) === VaultScanResults.COMPLETED,
|
||||
});
|
||||
|
||||
if (execution.status === MetadataIdentityRepairExecutions.CANCELLED) return;
|
||||
|
||||
@@ -412,7 +412,9 @@ export function paneMaintenance(
|
||||
.setDisabled(false)
|
||||
.onClick(async () => {
|
||||
await this.services.database.resetDatabase();
|
||||
await this.services.databaseEvents.initialiseDatabase();
|
||||
if (!(await this.services.databaseEvents.initialiseDatabase())) {
|
||||
Logger($msg("Ui.Common.LocalDatabaseInitialisationFailed"), LOG_LEVEL_NOTICE);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -93,7 +93,7 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("paneMaintenance Fresh Start Wipe", () => {
|
||||
describe("paneMaintenance", () => {
|
||||
it("does not announce success when the remote wipe reports failure", async () => {
|
||||
const updateCheckPointInfo = vi.fn(async () => undefined);
|
||||
const resetRemoteBucket = vi.fn(async () => false);
|
||||
@@ -140,4 +140,49 @@ describe("paneMaintenance Fresh Start Wipe", () => {
|
||||
);
|
||||
expect(maintenanceHarness.logger).not.toHaveBeenCalledWith("Deleted all data on remote server", "notice");
|
||||
});
|
||||
|
||||
it("reports when database initialisation after a local reset does not complete", async () => {
|
||||
const resetDatabase = vi.fn(async () => undefined);
|
||||
const initialiseDatabase = vi.fn(async () => false);
|
||||
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
|
||||
then(callback: (paneEl: HTMLElement) => void) {
|
||||
if (heading === "Reset") {
|
||||
callback({} as HTMLElement);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
const host = {
|
||||
core: {},
|
||||
createEl: vi.fn(),
|
||||
editingSettings: {},
|
||||
isConfiguredAs: vi.fn(),
|
||||
onlyOnCouchDB: vi.fn(),
|
||||
onlyOnCouchDBOrMinIO: vi.fn(),
|
||||
onlyOnMinIO: vi.fn(),
|
||||
services: {
|
||||
appLifecycle: { askRestart: vi.fn() },
|
||||
database: { resetDatabase },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
setting: { saveSettingData: vi.fn() },
|
||||
},
|
||||
};
|
||||
|
||||
paneMaintenance.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
const deleteLocalDatabase = maintenanceHarness.createdSettings.find(
|
||||
({ name }) => name === "Delete local database to reset or uninstall Self-hosted LiveSync"
|
||||
);
|
||||
if (!deleteLocalDatabase?.click) {
|
||||
throw new Error("Delete local database action was not registered");
|
||||
}
|
||||
|
||||
await deleteLocalDatabase.click();
|
||||
|
||||
expect(resetDatabase).toHaveBeenCalledOnce();
|
||||
expect(initialiseDatabase).toHaveBeenCalledOnce();
|
||||
expect(maintenanceHarness.logger).toHaveBeenCalledWith(
|
||||
"Ui.Common.LocalDatabaseInitialisationFailed",
|
||||
"notice"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-brows
|
||||
import { ExtraSuffixIndexedDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { migrateDatabases } from "./settingUtils.ts";
|
||||
import { usesLegacyIndexedDBAdapter } from "@/common/compatibilitySettings.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
|
||||
export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
|
||||
void addPanel(paneEl, "Compatibility (Metadata)").then((paneEl) => {
|
||||
@@ -142,7 +143,9 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
|
||||
|
||||
this.addOnSaved("additionalSuffixOfDatabaseName", async (key) => {
|
||||
Logger("Suffix has been changed. Reopening database...", LOG_LEVEL_NOTICE);
|
||||
await this.services.databaseEvents.initialiseDatabase();
|
||||
if (!(await this.services.databaseEvents.initialiseDatabase())) {
|
||||
Logger($msg("Ui.Common.LocalDatabaseInitialisationFailed"), LOG_LEVEL_NOTICE);
|
||||
}
|
||||
});
|
||||
|
||||
new Setting(paneEl).autoWireDropDown("hashAlg", {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { panePatches } from "./PanePatches.ts";
|
||||
|
||||
const remediationHarness = vi.hoisted(() => {
|
||||
@@ -14,11 +15,13 @@ const remediationHarness = vi.hoisted(() => {
|
||||
};
|
||||
const setButtonClassState = vi.fn();
|
||||
const setSettingClassState = vi.fn();
|
||||
const logger = vi.fn();
|
||||
|
||||
return {
|
||||
createSpan,
|
||||
dateElement,
|
||||
inputEl,
|
||||
logger,
|
||||
setButtonClassState,
|
||||
setSettingClassState,
|
||||
textComponent,
|
||||
@@ -59,9 +62,25 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
autoWireToggle(): this {
|
||||
return this;
|
||||
}
|
||||
|
||||
autoWireText(): this {
|
||||
return this;
|
||||
}
|
||||
|
||||
autoWireDropDown(): this {
|
||||
return this;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: (message: string) => message,
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/logger", () => ({
|
||||
Logger: remediationHarness.logger,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, "activeDocument");
|
||||
vi.clearAllMocks();
|
||||
@@ -69,7 +88,7 @@ afterEach(() => {
|
||||
remediationHarness.inputEl.type = "";
|
||||
});
|
||||
|
||||
describe("panePatches remediation setting", () => {
|
||||
describe("panePatches", () => {
|
||||
it("creates the status element in the setting control instead of the document", () => {
|
||||
const hierarchyError = new DOMException(
|
||||
"Failed to execute 'appendChild' on 'Node': Only one element on document allowed.",
|
||||
@@ -115,4 +134,36 @@ describe("panePatches remediation setting", () => {
|
||||
);
|
||||
expect(remediationHarness.setButtonClassState).toHaveBeenCalledWith("sls-setting-additional-action", true);
|
||||
});
|
||||
|
||||
it("reports when database reinitialisation after a suffix change does not complete", async () => {
|
||||
const initialiseDatabase = vi.fn(async () => false);
|
||||
let onSuffixSaved: (() => Promise<void>) | undefined;
|
||||
const host = {
|
||||
addOnSaved: vi.fn((key: string, callback: () => Promise<void>) => {
|
||||
if (key === "additionalSuffixOfDatabaseName") onSuffixSaved = callback;
|
||||
}),
|
||||
services: {
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
};
|
||||
const addPanel = vi.fn((_paneEl: HTMLElement, title: string) => ({
|
||||
then(callback: (paneEl: HTMLElement) => void) {
|
||||
if (title === "Edge case addressing (Database)") {
|
||||
callback({} as HTMLElement);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
|
||||
panePatches.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
if (!onSuffixSaved) throw new Error("Database suffix save handler was not registered");
|
||||
|
||||
await onSuffixSaved();
|
||||
|
||||
expect(initialiseDatabase).toHaveBeenCalledOnce();
|
||||
expect(remediationHarness.logger).toHaveBeenCalledWith(
|
||||
"Ui.Common.LocalDatabaseInitialisationFailed",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/ser
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { initialiseWorkerModule } from "@vrtmrz/livesync-commonlib/compat/worker/bgWorker";
|
||||
import { manifestVersion, packageVersion } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvVars";
|
||||
import { VaultScanResults } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
|
||||
export class ModuleLiveSyncMain extends AbstractModule {
|
||||
async _onLiveSyncReady() {
|
||||
@@ -42,11 +43,17 @@ export class ModuleLiveSyncMain extends AbstractModule {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const isInitialized = await this.services.databaseEvents.initialiseDatabase(false, false);
|
||||
if (!isInitialized) {
|
||||
// Ordinary start-up may continue when individual files could not be
|
||||
// processed. Explicit Fetch and Rebuild flows retain the strict default.
|
||||
const initialisationResult = await this.services.databaseEvents.initialiseDatabase(false, false, false, true);
|
||||
if (initialisationResult === VaultScanResults.FAILED) {
|
||||
this._log($msg("Ui.Common.LocalDatabaseInitialisationFailed"), LOG_LEVEL_NOTICE);
|
||||
//TODO:stop all sync.
|
||||
return false;
|
||||
}
|
||||
if (initialisationResult === VaultScanResults.COMPLETED_WITH_FILE_FAILURES) {
|
||||
this._log($msg("Ui.Common.SomeFilesCouldNotBeSynchronised"), LOG_LEVEL_NOTICE);
|
||||
}
|
||||
if (!(await this.core.services.appLifecycle.onFirstInitialise())) return false;
|
||||
// await this.core.$$realizeSettingSyncMode();
|
||||
await this.services.control.applySettings();
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_LAYOUT_READY: "layout-ready",
|
||||
EVENT_PLUGIN_LOADED: "plugin-loaded",
|
||||
EVENT_REQUEST_RELOAD_SETTING_TAB: "reload-setting-tab",
|
||||
EVENT_SETTING_SAVED: "setting-saved",
|
||||
eventHub: {
|
||||
emitEvent: vi.fn(),
|
||||
onEvent: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: (message: string) => message,
|
||||
setLang: vi.fn(),
|
||||
}));
|
||||
|
||||
import { ModuleLiveSyncMain } from "./ModuleLiveSyncMain.ts";
|
||||
|
||||
describe("ModuleLiveSyncMain", () => {
|
||||
it("reports a database preparation failure at the application boundary", async () => {
|
||||
const initialiseDatabase = vi.fn(async () => false);
|
||||
const log = vi.fn();
|
||||
const host = {
|
||||
core: {
|
||||
services: {
|
||||
appLifecycle: {
|
||||
onLayoutReady: vi.fn(async () => true),
|
||||
},
|
||||
},
|
||||
},
|
||||
services: {
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
_log: log,
|
||||
};
|
||||
|
||||
const result = await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, true);
|
||||
expect(log).toHaveBeenCalledWith("Ui.Common.LocalDatabaseInitialisationFailed", LOG_LEVEL_NOTICE);
|
||||
});
|
||||
|
||||
it("warns when start-up continues with individual file failures", async () => {
|
||||
const initialiseDatabase = vi.fn(async () => "completed-with-file-failures");
|
||||
const log = vi.fn();
|
||||
const appLifecycle = {
|
||||
onLayoutReady: vi.fn(async () => true),
|
||||
onFirstInitialise: vi.fn(async () => true),
|
||||
onScanningStartupIssues: vi.fn(async () => true),
|
||||
};
|
||||
const host = {
|
||||
core: {
|
||||
services: { appLifecycle },
|
||||
},
|
||||
services: {
|
||||
appLifecycle,
|
||||
control: { applySettings: vi.fn(async () => undefined) },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
_log: log,
|
||||
};
|
||||
|
||||
const result = await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(log).toHaveBeenCalledWith("Ui.Common.SomeFilesCouldNotBeSynchronised", LOG_LEVEL_NOTICE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { copyFileDatabaseInfo, type FileDatabaseInfoCore } from "@/serviceFeatures/fileDatabaseInfo";
|
||||
|
||||
/**
|
||||
* Services required by the platform-independent command palette actions.
|
||||
*
|
||||
* The database report deliberately receives a structural adapter rather than
|
||||
* the whole host. This keeps the report helper independent from the core while
|
||||
* retaining the same local database, storage, settings, path, and UI sources.
|
||||
*/
|
||||
export type BasicCommandsHost = NecessaryServices<
|
||||
| "API"
|
||||
| "appLifecycle"
|
||||
| "control"
|
||||
| "database"
|
||||
| "fileProcessing"
|
||||
| "path"
|
||||
| "replication"
|
||||
| "setting"
|
||||
| "UI"
|
||||
| "vault",
|
||||
"storageAccess"
|
||||
>;
|
||||
|
||||
function createFileDatabaseInfoCore(host: BasicCommandsHost): FileDatabaseInfoCore {
|
||||
const { services, serviceModules } = host;
|
||||
return {
|
||||
localDatabase: services.database.localDatabase,
|
||||
services: {
|
||||
path: services.path,
|
||||
UI: services.UI,
|
||||
},
|
||||
settings: services.setting.currentSettings(),
|
||||
storageAccess: serviceModules.storageAccess,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the platform-independent command palette actions.
|
||||
*
|
||||
* Registration remains tied to `onInitialise`, matching the legacy module's
|
||||
* timing and allowing hosts to compose the feature before the lifecycle runs.
|
||||
*/
|
||||
export function useBasicCommandsFeature(host: BasicCommandsHost): void {
|
||||
const { services } = host;
|
||||
const log = createInstanceLogFunction("SF:BasicCommands", services.API);
|
||||
|
||||
services.appLifecycle.onInitialise.addHandler(() => {
|
||||
services.API.addCommand({
|
||||
id: "livesync-replicate",
|
||||
name: $msg("Sync now"),
|
||||
callback: async () => {
|
||||
await services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
id: "livesync-dump",
|
||||
name: $msg("Copy database information for the active file"),
|
||||
checkCallback: (checking) => {
|
||||
const file = services.vault.getActiveFilePath();
|
||||
if (!file) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => copyFileDatabaseInfo(createFileDatabaseInfoCore(host), file));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
id: "livesync-toggle",
|
||||
name: "Toggle LiveSync",
|
||||
callback: async () => {
|
||||
const settings = services.setting.currentSettings();
|
||||
if (settings.liveSync) {
|
||||
settings.liveSync = false;
|
||||
log("LiveSync Disabled.", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
settings.liveSync = true;
|
||||
log("LiveSync Enabled.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await services.control.applySettings();
|
||||
await services.setting.saveSettingData();
|
||||
},
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
id: "livesync-suspendall",
|
||||
name: "Toggle All Sync.",
|
||||
callback: async () => {
|
||||
if (services.appLifecycle.isSuspended()) {
|
||||
services.appLifecycle.setSuspended(false);
|
||||
log("Self-hosted LiveSync resumed", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
services.appLifecycle.setSuspended(true);
|
||||
log("Self-hosted LiveSync suspended", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await services.control.applySettings();
|
||||
await services.setting.saveSettingData();
|
||||
},
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
id: "livesync-scan-files",
|
||||
name: "Scan storage and database again",
|
||||
checkCallback: (checking) => {
|
||||
if (!services.setting.currentSettings().useAdvancedMode) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => services.vault.scanVault(true));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
id: "livesync-runbatch",
|
||||
name: $msg("Apply pending changes now"),
|
||||
callback: async () => {
|
||||
await services.fileProcessing.commitPendingFileEvents();
|
||||
},
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
id: "livesync-abortsync",
|
||||
name: "Abort synchronization immediately",
|
||||
checkCallback: (checking) => {
|
||||
if (!services.setting.currentSettings().useAdvancedMode) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => services.replication.stopActiveTransfer());
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ICommandCompat } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { copyFileDatabaseInfo } from "./fileDatabaseInfo";
|
||||
import { useBasicCommandsFeature, type BasicCommandsHost } from "./basicCommands";
|
||||
|
||||
vi.mock("./fileDatabaseInfo", () => ({
|
||||
copyFileDatabaseInfo: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
type RegisteredCommand = ICommandCompat & {
|
||||
checkCallback?: (checking: boolean) => boolean | void;
|
||||
};
|
||||
|
||||
function createFixture() {
|
||||
const commands: RegisteredCommand[] = [];
|
||||
const initialiseHandlers: Array<() => Promise<unknown>> = [];
|
||||
const settings = {
|
||||
liveSync: false,
|
||||
useAdvancedMode: false,
|
||||
};
|
||||
const api = {
|
||||
addCommand: vi.fn((command: RegisteredCommand) => {
|
||||
commands.push(command);
|
||||
return command;
|
||||
}),
|
||||
addLog: vi.fn(),
|
||||
};
|
||||
const services = {
|
||||
API: api,
|
||||
appLifecycle: {
|
||||
onInitialise: {
|
||||
addHandler: vi.fn((handler: () => Promise<unknown>) => {
|
||||
initialiseHandlers.push(handler);
|
||||
}),
|
||||
},
|
||||
isSuspended: vi.fn(() => false),
|
||||
setSuspended: vi.fn(),
|
||||
},
|
||||
control: {
|
||||
applySettings: vi.fn(async () => undefined),
|
||||
},
|
||||
database: {
|
||||
localDatabase: { databaseMarker: "local" },
|
||||
},
|
||||
fileProcessing: {
|
||||
commitPendingFileEvents: vi.fn(async () => true),
|
||||
},
|
||||
path: {
|
||||
path2id: vi.fn(async () => "f:note"),
|
||||
},
|
||||
replication: {
|
||||
replicateUserInitiated: vi.fn(async () => ({ status: "completed" as const })),
|
||||
stopActiveTransfer: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
setting: {
|
||||
currentSettings: vi.fn(() => settings),
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
UI: {
|
||||
promptCopyToClipboard: vi.fn(async () => true),
|
||||
},
|
||||
vault: {
|
||||
getActiveFilePath: vi.fn((): string | undefined => "note.md"),
|
||||
scanVault: vi.fn(async () => true),
|
||||
},
|
||||
};
|
||||
const serviceModules = {
|
||||
storageAccess: {
|
||||
isExistsIncludeHidden: vi.fn(async () => true),
|
||||
statHidden: vi.fn(async () => ({ ctime: 0, mtime: 0, size: 0, type: "file" })),
|
||||
},
|
||||
};
|
||||
const host = { services, serviceModules } as unknown as BasicCommandsHost;
|
||||
|
||||
return {
|
||||
api,
|
||||
commands,
|
||||
host,
|
||||
initialiseHandlers,
|
||||
services,
|
||||
serviceModules,
|
||||
settings,
|
||||
getCommand(id: string) {
|
||||
const command = commands.find((candidate) => candidate.id === id);
|
||||
expect(command, `command ${id}`).toBeDefined();
|
||||
return command!;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function initialise(fixture: ReturnType<typeof createFixture>) {
|
||||
useBasicCommandsFeature(fixture.host);
|
||||
expect(fixture.initialiseHandlers).toHaveLength(1);
|
||||
expect(fixture.commands).toHaveLength(0);
|
||||
await fixture.initialiseHandlers[0]?.();
|
||||
}
|
||||
|
||||
describe("useBasicCommandsFeature", () => {
|
||||
it("registers all established commands only when initialisation runs", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
useBasicCommandsFeature(fixture.host);
|
||||
|
||||
expect(fixture.services.appLifecycle.onInitialise.addHandler).toHaveBeenCalledOnce();
|
||||
expect(fixture.api.addCommand).not.toHaveBeenCalled();
|
||||
|
||||
await fixture.initialiseHandlers[0]?.();
|
||||
|
||||
expect(fixture.commands.map(({ id }) => id)).toEqual([
|
||||
"livesync-replicate",
|
||||
"livesync-dump",
|
||||
"livesync-toggle",
|
||||
"livesync-suspendall",
|
||||
"livesync-scan-files",
|
||||
"livesync-runbatch",
|
||||
"livesync-abortsync",
|
||||
]);
|
||||
expect(fixture.getCommand("livesync-replicate").name).toBe("Sync now");
|
||||
expect(fixture.getCommand("livesync-dump").name).toBe("Copy database information for the active file");
|
||||
expect(fixture.getCommand("livesync-toggle").name).toBe("Toggle LiveSync");
|
||||
expect(fixture.getCommand("livesync-suspendall").name).toBe("Toggle All Sync.");
|
||||
expect(fixture.getCommand("livesync-scan-files").name).toBe("Scan storage and database again");
|
||||
expect(fixture.getCommand("livesync-runbatch").name).toBe("Apply pending changes now");
|
||||
expect(fixture.getCommand("livesync-abortsync").name).toBe("Abort synchronization immediately");
|
||||
});
|
||||
|
||||
it("retains the manual replication authority and quiet progress presentation", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
await fixture.getCommand("livesync-replicate").callback?.();
|
||||
|
||||
expect(fixture.services.replication.replicateUserInitiated).toHaveBeenCalledWith({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles LiveSync and persists the updated setting", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
await fixture.getCommand("livesync-toggle").callback?.();
|
||||
|
||||
expect(fixture.settings.liveSync).toBe(true);
|
||||
expect(fixture.api.addLog).toHaveBeenCalledWith("LiveSync Enabled.", expect.anything(), "");
|
||||
expect(fixture.services.control.applySettings).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.setting.saveSettingData).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.control.applySettings.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
fixture.services.setting.saveSettingData.mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
await fixture.getCommand("livesync-toggle").callback?.();
|
||||
|
||||
expect(fixture.settings.liveSync).toBe(false);
|
||||
expect(fixture.api.addLog).toHaveBeenCalledWith("LiveSync Disabled.", expect.anything(), "");
|
||||
});
|
||||
|
||||
it("toggles all synchronisation through the app lifecycle and persists it", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
await fixture.getCommand("livesync-suspendall").callback?.();
|
||||
|
||||
expect(fixture.services.appLifecycle.setSuspended).toHaveBeenCalledWith(true);
|
||||
expect(fixture.api.addLog).toHaveBeenCalledWith("Self-hosted LiveSync suspended", expect.anything(), "");
|
||||
expect(fixture.services.control.applySettings).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.setting.saveSettingData).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.control.applySettings.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
fixture.services.setting.saveSettingData.mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
fixture.services.appLifecycle.isSuspended.mockReturnValue(true);
|
||||
await fixture.getCommand("livesync-suspendall").callback?.();
|
||||
|
||||
expect(fixture.services.appLifecycle.setSuspended).toHaveBeenLastCalledWith(false);
|
||||
expect(fixture.api.addLog).toHaveBeenCalledWith("Self-hosted LiveSync resumed", expect.anything(), "");
|
||||
});
|
||||
|
||||
it("keeps advanced maintenance checks gated and invokes their exact actions", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(false);
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(false);
|
||||
|
||||
fixture.settings.useAdvancedMode = true;
|
||||
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(true);
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(true);
|
||||
expect(fixture.services.vault.scanVault).not.toHaveBeenCalled();
|
||||
expect(fixture.services.replication.stopActiveTransfer).not.toHaveBeenCalled();
|
||||
|
||||
fixture.getCommand("livesync-scan-files").checkCallback?.(false);
|
||||
fixture.getCommand("livesync-abortsync").checkCallback?.(false);
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.services.vault.scanVault).toHaveBeenCalledWith(true);
|
||||
expect(fixture.services.replication.stopActiveTransfer).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it("copies active-file database information through a narrow structural adapter", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
const dump = fixture.getCommand("livesync-dump");
|
||||
vi.mocked(copyFileDatabaseInfo).mockClear();
|
||||
expect(dump.checkCallback?.(true)).toBe(true);
|
||||
expect(copyFileDatabaseInfo).not.toHaveBeenCalled();
|
||||
dump.checkCallback?.(false);
|
||||
|
||||
await vi.waitFor(() => expect(copyFileDatabaseInfo).toHaveBeenCalledOnce());
|
||||
const [adapter, path] = vi.mocked(copyFileDatabaseInfo).mock.calls[0] ?? [];
|
||||
expect(path).toBe("note.md");
|
||||
expect(adapter).toEqual({
|
||||
localDatabase: fixture.services.database.localDatabase,
|
||||
services: {
|
||||
path: fixture.services.path,
|
||||
UI: fixture.services.UI,
|
||||
},
|
||||
settings: fixture.settings,
|
||||
storageAccess: fixture.serviceModules.storageAccess,
|
||||
});
|
||||
expect(adapter).not.toBe(fixture.host);
|
||||
});
|
||||
|
||||
it("commits pending file events from the batch command", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
await fixture.getCommand("livesync-runbatch").callback?.();
|
||||
|
||||
expect(fixture.services.fileProcessing.commitPendingFileEvents).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps the active-file report unavailable without an active file", async () => {
|
||||
const fixture = createFixture();
|
||||
fixture.services.vault.getActiveFilePath.mockReturnValue(undefined);
|
||||
await initialise(fixture);
|
||||
|
||||
expect(fixture.getCommand("livesync-dump").checkCallback?.(true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
runConfiguredStartupLifecycle,
|
||||
runStartupEntryLifecycle,
|
||||
type ConfiguredStartupLifecycleRuntime,
|
||||
} from "./configuredStartupLifecycle";
|
||||
|
||||
function createRuntime(): ConfiguredStartupLifecycleRuntime & { events: string[] } {
|
||||
const events: string[] = [];
|
||||
return {
|
||||
events,
|
||||
databaseReady: true,
|
||||
reportDatabaseNotReady: vi.fn(() => events.push("database-not-ready")),
|
||||
hasCompromisedChunks: vi.fn(async () => {
|
||||
events.push("compromised-chunks");
|
||||
return true;
|
||||
}),
|
||||
hasIncompleteDocuments: vi.fn(async () => {
|
||||
events.push("incomplete-documents");
|
||||
return true;
|
||||
}),
|
||||
waitForCompatibilityReview: vi.fn(async () => {}),
|
||||
runDoctor: vi.fn(async () => {
|
||||
events.push("doctor");
|
||||
return true;
|
||||
}),
|
||||
migrateBulkSend: vi.fn(async () => {
|
||||
events.push("bulk-send");
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("runConfiguredStartupLifecycle", () => {
|
||||
it("runs configured checks in order before allowing initialisation", async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(true);
|
||||
|
||||
expect(runtime.events).toEqual(["compromised-chunks", "incomplete-documents", "doctor", "bulk-send"]);
|
||||
});
|
||||
|
||||
it("keeps Config Doctor behind the initial compatibility review", async () => {
|
||||
const runtime = createRuntime();
|
||||
Object.assign(runtime, {
|
||||
waitForCompatibilityReview: vi.fn(async () => {
|
||||
runtime.events.push("compatibility-review");
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(true);
|
||||
|
||||
expect(runtime.events).toEqual([
|
||||
"compromised-chunks",
|
||||
"incomplete-documents",
|
||||
"compatibility-review",
|
||||
"doctor",
|
||||
"bulk-send",
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops before onboarding or checks when the database is unavailable", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.databaseReady = false;
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
|
||||
expect(runtime.events).toEqual(["database-not-ready"]);
|
||||
});
|
||||
|
||||
it("stops the configured sequence at the first failed check", async () => {
|
||||
const runtime = createRuntime();
|
||||
vi.mocked(runtime.hasIncompleteDocuments).mockImplementation(async () => {
|
||||
runtime.events.push("incomplete-documents");
|
||||
return false;
|
||||
});
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
|
||||
expect(runtime.events).toEqual(["compromised-chunks", "incomplete-documents"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runStartupEntryLifecycle", () => {
|
||||
it("offers onboarding and stops before database initialisation on an unconfigured Vault", () => {
|
||||
const inviteToOnboarding = vi.fn();
|
||||
|
||||
expect(
|
||||
runStartupEntryLifecycle({
|
||||
configured: false,
|
||||
inviteToOnboarding,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(inviteToOnboarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("allows a configured Vault to continue to database initialisation", () => {
|
||||
const inviteToOnboarding = vi.fn();
|
||||
|
||||
expect(
|
||||
runStartupEntryLifecycle({
|
||||
configured: true,
|
||||
inviteToOnboarding,
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(inviteToOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
LOG_LEVEL_NOTICE,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { IConflictService, IVaultService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
||||
|
||||
export type ConflictCheckingSettings = Pick<ObsidianLiveSyncSettings, "checkConflictOnlyOnOpen">;
|
||||
|
||||
export interface ConflictCheckingDependencies {
|
||||
readonly conflict: Pick<
|
||||
IConflictService,
|
||||
"getOptionalConflictCheckMethod" | "queueCheckFor" | "resolve" | "resolveByNewest"
|
||||
>;
|
||||
readonly conflictProcessQueueCount: ReactiveSource<number>;
|
||||
readonly currentSettings: () => ConflictCheckingSettings;
|
||||
readonly vault: Pick<IVaultService, "getActiveFilePath">;
|
||||
readonly log: LogFunction;
|
||||
}
|
||||
|
||||
export interface ConflictCheckingHandlers {
|
||||
readonly queueCheckForIfOpen: (file: FilePathWithPrefix) => Promise<void>;
|
||||
readonly queueCheckFor: (file: FilePathWithPrefix) => Promise<void>;
|
||||
readonly ensureAllProcessed: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Create conflict-checking handlers while retaining scheduling state privately. */
|
||||
export function createConflictCheckingHandlers(dependencies: ConflictCheckingDependencies): ConflictCheckingHandlers {
|
||||
const conflictQueue = new QueueProcessor<FilePathWithPrefix, void>(
|
||||
async (filenames: FilePathWithPrefix[]) => {
|
||||
const filename = filenames[0];
|
||||
return await dependencies.conflict.resolve(filename);
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
// No need to limit concurrency to `1` here, subsequent process will handle it,
|
||||
// and some cases do not need to be synchronised (for example, auto-merge).
|
||||
// Global concurrency is limited by the resolver with the UI.
|
||||
concurrentLimit: 10,
|
||||
delay: 0,
|
||||
keepResultUntilDownstreamConnected: false,
|
||||
totalRemainingReactiveSource: dependencies.conflictProcessQueueCount,
|
||||
}
|
||||
).replaceEnqueueProcessor((queue, newEntity) => {
|
||||
const newQueue = [...queue].filter((entry) => entry != newEntity);
|
||||
return [...newQueue, newEntity];
|
||||
});
|
||||
|
||||
const queueCheckForIfOpen = async (file: FilePathWithPrefix): Promise<void> => {
|
||||
const path = file;
|
||||
if (dependencies.currentSettings().checkConflictOnlyOnOpen) {
|
||||
const activeFile: FilePath | undefined = dependencies.vault.getActiveFilePath();
|
||||
if (activeFile && activeFile != path) {
|
||||
dependencies.log(`${file} is conflicted, merging process has been postponed.`, LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await dependencies.conflict.queueCheckFor(path);
|
||||
};
|
||||
|
||||
const queueCheckFor = async (file: FilePathWithPrefix): Promise<void> => {
|
||||
const optionalConflictResult = await dependencies.conflict.getOptionalConflictCheckMethod(file);
|
||||
if (optionalConflictResult == true) {
|
||||
// The conflict has been resolved by another process.
|
||||
return;
|
||||
} else if (optionalConflictResult === "newer") {
|
||||
// The conflict should be resolved by the newer entry.
|
||||
await dependencies.conflict.resolveByNewest(file);
|
||||
} else {
|
||||
conflictQueue.enqueue(file);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureAllProcessed = (): Promise<boolean> => conflictQueue.waitForAllProcessed();
|
||||
|
||||
return {
|
||||
queueCheckForIfOpen,
|
||||
queueCheckFor,
|
||||
ensureAllProcessed,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,883 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
MISSING_OR_ERROR,
|
||||
NOT_CONFLICTED,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type MetaEntry,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import type { ConflictResolutionHost } from "./index";
|
||||
import { createConflictResolutionOperations, useConflictResolutionFeature } from "./index";
|
||||
import type { ConflictResolutionOperationsDependencies } from "./operations";
|
||||
|
||||
type ConflictLeaf = {
|
||||
rev: string;
|
||||
data: string;
|
||||
ctime: number;
|
||||
mtime: number;
|
||||
deleted?: boolean;
|
||||
};
|
||||
|
||||
type HarnessOptions = {
|
||||
files?: FilePathWithPrefix[];
|
||||
settings?: Partial<ObsidianLiveSyncSettings>;
|
||||
activeFile?: FilePathWithPrefix;
|
||||
compose?: boolean;
|
||||
};
|
||||
|
||||
function createHarness(options: HarnessOptions = {}) {
|
||||
const context = createServiceContext();
|
||||
const conflict = new InjectableConflictService(context);
|
||||
const settings = { ...DEFAULT_SETTINGS, ...options.settings };
|
||||
const tryAutoMerge = vi.fn();
|
||||
const databaseFileAccess = {
|
||||
fetchEntryMeta: vi.fn(),
|
||||
getConflictedRevs: vi.fn(async () => [] as string[]),
|
||||
storeContent: vi.fn(async () => true),
|
||||
};
|
||||
const fileHandler = {
|
||||
dbToStorage: vi.fn(async () => true),
|
||||
deleteRevisionFromDB: vi.fn(async () => true),
|
||||
};
|
||||
const addLog = vi.fn();
|
||||
const storageAccess = {
|
||||
getFileNames: vi.fn(async () => options.files ?? []),
|
||||
};
|
||||
const activeFile = vi.fn(() => options.activeFile);
|
||||
const services = {
|
||||
API: { addLog },
|
||||
appLifecycle: { isSuspended: vi.fn(() => false) },
|
||||
conflict,
|
||||
context,
|
||||
database: { localDatabase: { tryAutoMerge } },
|
||||
replication: { replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })) },
|
||||
setting: { currentSettings: vi.fn(() => settings) },
|
||||
vault: { getActiveFilePath: activeFile },
|
||||
};
|
||||
const serviceModules = { databaseFileAccess, fileHandler, storageAccess };
|
||||
|
||||
if (options.compose !== false) {
|
||||
useConflictResolutionFeature({ services, serviceModules } as unknown as ConflictResolutionHost);
|
||||
}
|
||||
|
||||
return {
|
||||
addLog,
|
||||
conflict,
|
||||
context,
|
||||
databaseFileAccess,
|
||||
fileHandler,
|
||||
services,
|
||||
serviceModules,
|
||||
storageAccess,
|
||||
tryAutoMerge,
|
||||
};
|
||||
}
|
||||
|
||||
function leaf(rev: string, data: string, mtime: number): ConflictLeaf {
|
||||
return { rev, data, mtime, ctime: mtime, deleted: false };
|
||||
}
|
||||
|
||||
function metadata(path: FilePathWithPrefix, rev: string, mtime: number): MetaEntry {
|
||||
return {
|
||||
_id: "doc-id",
|
||||
_rev: rev,
|
||||
path,
|
||||
ctime: mtime,
|
||||
mtime,
|
||||
size: 0,
|
||||
children: [],
|
||||
type: "plain",
|
||||
eden: {},
|
||||
} as unknown as MetaEntry;
|
||||
}
|
||||
|
||||
function createOperationsHarness() {
|
||||
const events = { emitEvent: vi.fn() };
|
||||
const tryAutoMerge = vi.fn();
|
||||
const databaseFileAccess = {
|
||||
fetchEntryMeta: vi.fn(),
|
||||
getConflictedRevs: vi.fn(async () => [] as string[]),
|
||||
storeContent: vi.fn(async () => true),
|
||||
};
|
||||
const fileHandler = {
|
||||
dbToStorage: vi.fn(async () => true),
|
||||
deleteRevisionFromDB: vi.fn(async () => true),
|
||||
};
|
||||
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
|
||||
const queueCheckFor = vi.fn(async () => undefined);
|
||||
const resolveByUserInteraction = vi.fn(async () => false);
|
||||
const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const }));
|
||||
const dependencies = {
|
||||
events,
|
||||
databaseFileAccess,
|
||||
fileHandler,
|
||||
localDatabase: () => ({ tryAutoMerge }),
|
||||
conflict: { queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction },
|
||||
replication: { replicateUnattendedByEvent },
|
||||
appLifecycle: { isSuspended: vi.fn(() => false) },
|
||||
vault: { getActiveFilePath: vi.fn(() => undefined) },
|
||||
storageAccess: { getFileNames: vi.fn(async () => [] as FilePathWithPrefix[]) },
|
||||
currentSettings: vi.fn(() => ({
|
||||
disableMarkdownAutoMerge: false,
|
||||
resolveConflictsByNewerFile: false,
|
||||
syncAfterMerge: true,
|
||||
showMergeDialogOnlyOnActive: false,
|
||||
})),
|
||||
log: vi.fn(),
|
||||
} as unknown as ConflictResolutionOperationsDependencies;
|
||||
|
||||
return {
|
||||
...dependencies,
|
||||
dependencies,
|
||||
operations: createConflictResolutionOperations(dependencies),
|
||||
events,
|
||||
tryAutoMerge,
|
||||
databaseFileAccess,
|
||||
fileHandler,
|
||||
resolveByDeletingRevision,
|
||||
resolveByUserInteraction,
|
||||
queueCheckFor,
|
||||
replicateUnattendedByEvent,
|
||||
};
|
||||
}
|
||||
|
||||
describe("conflict resolution serviceFeature", () => {
|
||||
it("keeps resolver operations on narrow collaborators and extension seams", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "same.md" as FilePathWithPrefix;
|
||||
const leftLeaf = leaf("1-left", "Same content\n", 1000);
|
||||
const rightLeaf = leaf("1-right", "Same content\n", 2000);
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
});
|
||||
|
||||
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-left", "same");
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes the older conflict leaf when matching current content is newer", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "same-newer-current.md" as FilePathWithPrefix;
|
||||
const leftLeaf = leaf("2-left", "Same content\n", 3000);
|
||||
const rightLeaf = leaf("1-right", "Same content\n", 2000);
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
});
|
||||
|
||||
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-right", "same");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
description: "binary content",
|
||||
path: "different.png" as FilePathWithPrefix,
|
||||
resolveConflictsByNewerFile: false,
|
||||
expectedSubtitle: "binary",
|
||||
},
|
||||
{
|
||||
description: "the newer-file policy",
|
||||
path: "different.md" as FilePathWithPrefix,
|
||||
resolveConflictsByNewerFile: true,
|
||||
expectedSubtitle: "alwaysNewer",
|
||||
},
|
||||
])(
|
||||
"retains automatic resolution for $description",
|
||||
async ({ path, resolveConflictsByNewerFile, expectedSubtitle }) => {
|
||||
const harness = createOperationsHarness();
|
||||
const leftLeaf = leaf("1-left", "Left content\n", 1000);
|
||||
const rightLeaf = leaf("2-right", "Right content\n", 2000);
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
});
|
||||
const operations = createConflictResolutionOperations({
|
||||
...harness.dependencies,
|
||||
currentSettings: () => ({
|
||||
disableMarkdownAutoMerge: false,
|
||||
resolveConflictsByNewerFile,
|
||||
syncAfterMerge: false,
|
||||
showMergeDialogOnlyOnActive: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-left", expectedSubtitle);
|
||||
}
|
||||
);
|
||||
|
||||
it("acquires the active local database for each resolution attempt", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "database-reset.md" as FilePathWithPrefix;
|
||||
const firstTryAutoMerge = vi.fn(async () => ({ ok: NOT_CONFLICTED as typeof NOT_CONFLICTED }));
|
||||
const replacementTryAutoMerge = vi.fn(async () => ({ ok: NOT_CONFLICTED as typeof NOT_CONFLICTED }));
|
||||
let activeDatabase = { tryAutoMerge: firstTryAutoMerge };
|
||||
const dependencies = {
|
||||
...harness.dependencies,
|
||||
localDatabase: () => activeDatabase,
|
||||
};
|
||||
const operations = createConflictResolutionOperations(dependencies);
|
||||
|
||||
await operations.checkConflictAndPerformAutoMerge(path);
|
||||
activeDatabase = { tryAutoMerge: replacementTryAutoMerge };
|
||||
await operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(firstTryAutoMerge).toHaveBeenCalledOnce();
|
||||
expect(replacementTryAutoMerge).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns a manual diff for independently created files with different content", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "independently-created.md" as FilePathWithPrefix;
|
||||
const leftLeaf = leaf("1-left", "Left content\n", 1000);
|
||||
const rightLeaf = leaf("1-right", "Right content\n", 2000);
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
});
|
||||
|
||||
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toMatchObject({ left: leftLeaf, right: rightLeaf });
|
||||
expect(result).toHaveProperty("diff");
|
||||
expect(harness.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores a sensible merge before resolving its conflict leaf", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "sensible.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
result: "Title\nLeft changed\nRight changed\n",
|
||||
conflictedRev: "2-right",
|
||||
});
|
||||
|
||||
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(harness.databaseFileAccess.storeContent).toHaveBeenCalledWith(
|
||||
path,
|
||||
"Title\nLeft changed\nRight changed\n"
|
||||
);
|
||||
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "Sensible");
|
||||
});
|
||||
|
||||
it("keeps the conflict leaf when sensible merged content cannot be stored", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "failed-sensible.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
result: "Merged content\n",
|
||||
conflictedRev: "2-right",
|
||||
});
|
||||
harness.databaseFileAccess.storeContent.mockResolvedValue(false);
|
||||
|
||||
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(MISSING_OR_ERROR);
|
||||
expect(harness.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops before emitting or reflecting when conflict revision deletion fails", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "failed-delete.md" as FilePathWithPrefix;
|
||||
harness.fileHandler.deleteRevisionFromDB.mockResolvedValue(false);
|
||||
|
||||
const result = await harness.operations.resolveByDeletingRevision(path, "2-right", "UI Selected");
|
||||
|
||||
expect(result).toBe(MISSING_OR_ERROR);
|
||||
expect(harness.events.emitEvent).not.toHaveBeenCalled();
|
||||
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a safe error and logs when reflecting the resolved revision fails", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "failed-reflection.md" as FilePathWithPrefix;
|
||||
harness.fileHandler.dbToStorage.mockResolvedValue(false);
|
||||
|
||||
const result = await harness.operations.resolveByDeletingRevision(path, "2-right", "UI Selected");
|
||||
|
||||
expect(result).toBe(MISSING_OR_ERROR);
|
||||
expect(harness.fileHandler.dbToStorage).toHaveBeenCalledWith(path, path, true);
|
||||
expect(harness.dependencies.log).toHaveBeenCalledWith(
|
||||
`Could not write the resolved content to the storage: ${path}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["plugin metadata", "ps:plugin-metadata" as FilePathWithPrefix],
|
||||
["customisation metadata", "ix:customisation-metadata" as FilePathWithPrefix],
|
||||
])("does not reflect %s into the Vault after deleting its revision", async (_description, path) => {
|
||||
const harness = createOperationsHarness();
|
||||
|
||||
const result = await harness.operations.resolveByDeletingRevision(path, "2-right", "UI Selected");
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "2-right");
|
||||
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rechecks a remaining manual pair after committing a sensible merge", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "three-versions.md" as FilePathWithPrefix;
|
||||
const remainingManualPair = {
|
||||
leftRev: "3-merged",
|
||||
rightRev: "2-third",
|
||||
leftLeaf: leaf("3-merged", "Merged\n", 3),
|
||||
rightLeaf: leaf("2-third", "Overlapping\n", 2),
|
||||
};
|
||||
harness.tryAutoMerge
|
||||
.mockResolvedValueOnce({
|
||||
result: "Merged\n",
|
||||
conflictedRev: "2-second",
|
||||
})
|
||||
.mockResolvedValueOnce(remainingManualPair);
|
||||
|
||||
await harness.operations.resolve(path);
|
||||
|
||||
expect(harness.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "Merged\n");
|
||||
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-second", "Sensible");
|
||||
expect(harness.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
expect(harness.resolveByUserInteraction).not.toHaveBeenCalled();
|
||||
|
||||
await harness.operations.resolve(path);
|
||||
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledTimes(2);
|
||||
expect(harness.resolveByUserInteraction).toHaveBeenCalledWith(
|
||||
path,
|
||||
expect.objectContaining({
|
||||
left: remainingManualPair.leftLeaf,
|
||||
right: remainingManualPair.rightLeaf,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("requeues and replicates through collaborators after an automatic merge", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "merged.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({ ok: AUTO_MERGED });
|
||||
|
||||
await harness.operations.resolve(path);
|
||||
|
||||
expect(harness.replicateUnattendedByEvent).toHaveBeenCalledWith({
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
expect(harness.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
|
||||
it("postpones a manual merge until its file is active when configured", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "inactive.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "2-left",
|
||||
rightRev: "2-right",
|
||||
leftLeaf: leaf("2-left", "Left\n", 1),
|
||||
rightLeaf: leaf("2-right", "Right\n", 2),
|
||||
});
|
||||
const dependencies = {
|
||||
...harness.dependencies,
|
||||
currentSettings: () => ({
|
||||
disableMarkdownAutoMerge: false,
|
||||
resolveConflictsByNewerFile: false,
|
||||
syncAfterMerge: false,
|
||||
showMergeDialogOnlyOnActive: true,
|
||||
}),
|
||||
vault: { getActiveFilePath: () => "other.md" as FilePath },
|
||||
};
|
||||
const operations = createConflictResolutionOperations(dependencies);
|
||||
|
||||
await operations.resolve(path);
|
||||
|
||||
expect(harness.resolveByUserInteraction).not.toHaveBeenCalled();
|
||||
expect(dependencies.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Merging process has been postponed"),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a manual merge when the active-only setting matches the current file", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "active.md" as FilePathWithPrefix;
|
||||
const activePath = "active.md" as FilePath;
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "2-left",
|
||||
rightRev: "2-right",
|
||||
leftLeaf: leaf("2-left", "Left\n", 1),
|
||||
rightLeaf: leaf("2-right", "Right\n", 2),
|
||||
});
|
||||
const operations = createConflictResolutionOperations({
|
||||
...harness.dependencies,
|
||||
currentSettings: () => ({
|
||||
disableMarkdownAutoMerge: false,
|
||||
resolveConflictsByNewerFile: false,
|
||||
syncAfterMerge: false,
|
||||
showMergeDialogOnlyOnActive: true,
|
||||
}),
|
||||
vault: { getActiveFilePath: () => activePath },
|
||||
});
|
||||
|
||||
await operations.resolve(path);
|
||||
|
||||
expect(harness.resolveByUserInteraction).toHaveBeenCalledWith(
|
||||
path,
|
||||
expect.objectContaining({ left: expect.anything(), right: expect.anything() })
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels an active same-file dialogue before serialising a repeated resolution", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "repeated.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "2-left",
|
||||
rightRev: "2-right",
|
||||
leftLeaf: leaf("2-left", "Left\n", 1),
|
||||
rightLeaf: leaf("2-right", "Right\n", 2),
|
||||
});
|
||||
let finishDialogue: ((result: boolean) => void) | undefined;
|
||||
harness.resolveByUserInteraction.mockImplementation(
|
||||
async () => await new Promise<boolean>((resolve) => (finishDialogue = resolve))
|
||||
);
|
||||
harness.events.emitEvent.mockImplementation((event, filename) => {
|
||||
if (event === EVENT_CONFLICT_CANCELLED && filename === path && finishDialogue) {
|
||||
const finish = finishDialogue;
|
||||
finishDialogue = undefined;
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
|
||||
const first = harness.operations.resolve(path);
|
||||
await vi.waitFor(() => expect(harness.resolveByUserInteraction).toHaveBeenCalledOnce());
|
||||
const replacement = harness.operations.resolve(path);
|
||||
|
||||
await vi.waitFor(() => expect(harness.resolveByUserInteraction).toHaveBeenCalledTimes(2));
|
||||
expect(harness.events.emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
|
||||
|
||||
finishDialogue?.(false);
|
||||
await Promise.all([first, replacement]);
|
||||
});
|
||||
|
||||
it("passes only the newest waiting same-file resolution to the interactive resolver", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "repeated-three-times.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "2-left",
|
||||
rightRev: "2-right",
|
||||
leftLeaf: leaf("2-left", "Left\n", 1),
|
||||
rightLeaf: leaf("2-right", "Right\n", 2),
|
||||
});
|
||||
let finishFirstDialogue: ((result: boolean) => void) | undefined;
|
||||
harness.resolveByUserInteraction
|
||||
.mockImplementationOnce(
|
||||
async () => await new Promise<boolean>((resolve) => (finishFirstDialogue = resolve))
|
||||
)
|
||||
.mockResolvedValue(false);
|
||||
harness.events.emitEvent.mockImplementation((event, filename) => {
|
||||
if (event === EVENT_CONFLICT_CANCELLED && filename === path && finishFirstDialogue) {
|
||||
const finish = finishFirstDialogue;
|
||||
finishFirstDialogue = undefined;
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
|
||||
const first = harness.operations.resolve(path);
|
||||
await vi.waitFor(() => expect(harness.resolveByUserInteraction).toHaveBeenCalledOnce());
|
||||
const superseded = harness.operations.resolve(path);
|
||||
const replacement = harness.operations.resolve(path);
|
||||
|
||||
await Promise.all([first, superseded, replacement]);
|
||||
|
||||
expect(harness.resolveByUserInteraction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not open a superseded dialogue after conflict inspection completes", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "superseded-during-inspection.md" as FilePathWithPrefix;
|
||||
const manualConflict = {
|
||||
leftRev: "2-left",
|
||||
rightRev: "2-right",
|
||||
leftLeaf: leaf("2-left", "Left\n", 1),
|
||||
rightLeaf: leaf("2-right", "Right\n", 2),
|
||||
};
|
||||
let finishFirstInspection!: (result: typeof manualConflict) => void;
|
||||
harness.tryAutoMerge
|
||||
.mockImplementationOnce(
|
||||
async () => await new Promise<typeof manualConflict>((resolve) => (finishFirstInspection = resolve))
|
||||
)
|
||||
.mockResolvedValue(manualConflict);
|
||||
harness.resolveByUserInteraction.mockResolvedValue(false);
|
||||
|
||||
const superseded = harness.operations.resolve(path);
|
||||
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledOnce());
|
||||
const replacement = harness.operations.resolve(path);
|
||||
finishFirstInspection(manualConflict);
|
||||
|
||||
await Promise.all([superseded, replacement]);
|
||||
|
||||
expect(harness.resolveByUserInteraction).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("registers all conflict service operations during composition", () => {
|
||||
const harness = createHarness({ compose: false });
|
||||
const registrations = [
|
||||
vi.spyOn(harness.conflict.queueCheckForIfOpen, "setHandler"),
|
||||
vi.spyOn(harness.conflict.queueCheckFor, "setHandler"),
|
||||
vi.spyOn(harness.conflict.ensureAllProcessed, "setHandler"),
|
||||
vi.spyOn(harness.conflict.resolveByDeletingRevision, "setHandler"),
|
||||
vi.spyOn(harness.conflict.resolve, "setHandler"),
|
||||
vi.spyOn(harness.conflict.resolveByNewest, "setHandler"),
|
||||
vi.spyOn(harness.conflict.resolveAllConflictedFilesByNewerOnes, "setHandler"),
|
||||
];
|
||||
|
||||
useConflictResolutionFeature({
|
||||
services: harness.services,
|
||||
serviceModules: harness.serviceModules,
|
||||
} as unknown as ConflictResolutionHost);
|
||||
|
||||
for (const registration of registrations) {
|
||||
expect(registration).toHaveBeenCalledOnce();
|
||||
expect(registration).toHaveBeenCalledWith(expect.any(Function));
|
||||
}
|
||||
});
|
||||
|
||||
it("applies the active-file gate and deduplicates pending checks for one path", async () => {
|
||||
const path = "postponed.md" as FilePathWithPrefix;
|
||||
const harness = createHarness({
|
||||
activeFile: "other.md" as FilePathWithPrefix,
|
||||
settings: { checkConflictOnlyOnOpen: true },
|
||||
});
|
||||
harness.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
|
||||
|
||||
await harness.conflict.queueCheckForIfOpen(path);
|
||||
expect(harness.tryAutoMerge).not.toHaveBeenCalled();
|
||||
|
||||
harness.services.vault.getActiveFilePath = vi.fn(() => path);
|
||||
await Promise.all([harness.conflict.queueCheckFor(path), harness.conflict.queueCheckFor(path)]);
|
||||
await harness.conflict.ensureAllProcessed();
|
||||
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledOnce();
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledWith(path, true);
|
||||
expect(harness.addLog).toHaveBeenCalledWith(
|
||||
`${path} is conflicted, merging process has been postponed.`,
|
||||
LOG_LEVEL_NOTICE,
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the active-file gate is disabled", { checkConflictOnlyOnOpen: false }, "other.md" as FilePathWithPrefix],
|
||||
["the requested path is active", { checkConflictOnlyOnOpen: true }, "active.md" as FilePathWithPrefix],
|
||||
])("reaches the resolver when %s", async (_description, settings, activeFile) => {
|
||||
const path = "active.md" as FilePathWithPrefix;
|
||||
const harness = createHarness({ settings, activeFile });
|
||||
harness.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
|
||||
|
||||
await harness.conflict.queueCheckForIfOpen(path);
|
||||
await harness.conflict.ensureAllProcessed();
|
||||
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledOnce();
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledWith(path, true);
|
||||
});
|
||||
|
||||
it("limits concurrent conflict checks and reports queued and active work", async () => {
|
||||
const paths = Array.from({ length: 11 }, (_, index) => `concurrent-${index}.md` as FilePathWithPrefix);
|
||||
const harness = createHarness();
|
||||
const finishByPath = new Map<FilePathWithPrefix, () => void>();
|
||||
harness.tryAutoMerge.mockImplementation(
|
||||
async (path: FilePathWithPrefix) =>
|
||||
await new Promise<{ ok: typeof NOT_CONFLICTED }>((resolve) => {
|
||||
finishByPath.set(path, () => resolve({ ok: NOT_CONFLICTED }));
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(paths.map(async (path) => await harness.conflict.queueCheckFor(path)));
|
||||
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledTimes(10));
|
||||
|
||||
expect(harness.conflict.conflictProcessQueueCount.value).toBe(11);
|
||||
let allProcessed = false;
|
||||
const completion = harness.conflict.ensureAllProcessed().then((result) => {
|
||||
allProcessed = true;
|
||||
return result;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(allProcessed).toBe(false);
|
||||
|
||||
const finishFirst = finishByPath.get(paths[0]);
|
||||
finishByPath.delete(paths[0]);
|
||||
finishFirst?.();
|
||||
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledTimes(11));
|
||||
for (const finish of finishByPath.values()) finish();
|
||||
|
||||
await expect(completion).resolves.toBe(true);
|
||||
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
|
||||
});
|
||||
|
||||
it("replaces an older same-path check while every resolver slot is occupied", async () => {
|
||||
const occupiedPaths = Array.from({ length: 10 }, (_, index) => `occupied-${index}.md` as FilePathWithPrefix);
|
||||
const repeatedPath = "waiting-replacement.md" as FilePathWithPrefix;
|
||||
const harness = createHarness();
|
||||
const finishers: Array<{ path: FilePathWithPrefix; finish: () => void }> = [];
|
||||
harness.tryAutoMerge.mockImplementation(
|
||||
async (path: FilePathWithPrefix) =>
|
||||
await new Promise<{ ok: typeof NOT_CONFLICTED }>((resolve) => {
|
||||
finishers.push({ path, finish: () => resolve({ ok: NOT_CONFLICTED }) });
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(occupiedPaths.map(async (path) => await harness.conflict.queueCheckFor(path)));
|
||||
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledTimes(10));
|
||||
|
||||
await harness.conflict.queueCheckFor(repeatedPath);
|
||||
await harness.conflict.queueCheckFor(repeatedPath);
|
||||
for (const { finish } of finishers.filter(({ path }) => path !== repeatedPath)) finish();
|
||||
|
||||
await vi.waitFor(() => expect(harness.conflict.conflictProcessQueueCount.value).toBe(1));
|
||||
expect(harness.tryAutoMerge.mock.calls.filter(([path]) => path === repeatedPath)).toHaveLength(1);
|
||||
|
||||
finishers.find(({ path }) => path === repeatedPath)?.finish();
|
||||
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("waits for a conflict check requeued by an automatic merge", async () => {
|
||||
const path = "requeued-automatic-merge.md" as FilePathWithPrefix;
|
||||
const harness = createHarness({ settings: { syncAfterMerge: false } });
|
||||
let finishFirst!: (result: { ok: typeof AUTO_MERGED }) => void;
|
||||
harness.tryAutoMerge
|
||||
.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<{ ok: typeof AUTO_MERGED }>((resolve) => {
|
||||
finishFirst = resolve;
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ ok: NOT_CONFLICTED });
|
||||
|
||||
await harness.conflict.queueCheckFor(path);
|
||||
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledOnce());
|
||||
const completion = harness.conflict.ensureAllProcessed();
|
||||
|
||||
finishFirst({ ok: AUTO_MERGED });
|
||||
|
||||
await expect(completion).resolves.toBe(true);
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledTimes(2);
|
||||
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
|
||||
});
|
||||
|
||||
it("drains repeated manual resolutions for a file with more than two conflicting versions", async () => {
|
||||
const path = "requeued-manual-merge.md" as FilePathWithPrefix;
|
||||
const harness = createHarness({ settings: { syncAfterMerge: false } });
|
||||
const firstPair = {
|
||||
leftRev: "3-current",
|
||||
rightRev: "2-second",
|
||||
leftLeaf: leaf("3-current", "Current\n", 3),
|
||||
rightLeaf: leaf("2-second", "Second\n", 2),
|
||||
};
|
||||
const remainingPair = {
|
||||
leftRev: "4-merged",
|
||||
rightRev: "2-third",
|
||||
leftLeaf: leaf("4-merged", "Merged\n", 4),
|
||||
rightLeaf: leaf("2-third", "Third\n", 2),
|
||||
};
|
||||
harness.tryAutoMerge
|
||||
.mockResolvedValueOnce(firstPair)
|
||||
.mockResolvedValueOnce(remainingPair)
|
||||
.mockResolvedValueOnce({ ok: NOT_CONFLICTED });
|
||||
const resolvePair = vi.fn(async (filename: FilePathWithPrefix) => {
|
||||
await harness.conflict.queueCheckFor(filename);
|
||||
return false;
|
||||
});
|
||||
const unregister = harness.conflict.resolveByUserInteraction.addHandler(resolvePair);
|
||||
|
||||
try {
|
||||
await harness.conflict.queueCheckFor(path);
|
||||
|
||||
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(true);
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledTimes(3);
|
||||
expect(resolvePair).toHaveBeenCalledTimes(2);
|
||||
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("honours optional conflict handlers before entering the check queue", async () => {
|
||||
const path = "optional.md" as FilePathWithPrefix;
|
||||
const harness = createHarness();
|
||||
harness.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
|
||||
|
||||
const unregisterResolved = harness.conflict.getOptionalConflictCheckMethod.addHandler(async () => true);
|
||||
await harness.conflict.queueCheckFor(path);
|
||||
await harness.conflict.ensureAllProcessed();
|
||||
expect(harness.tryAutoMerge).not.toHaveBeenCalled();
|
||||
unregisterResolved();
|
||||
|
||||
const unregisterNewer = harness.conflict.getOptionalConflictCheckMethod.addHandler(async () => "newer");
|
||||
harness.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
|
||||
await harness.conflict.queueCheckFor(path);
|
||||
expect(harness.databaseFileAccess.fetchEntryMeta).toHaveBeenCalledWith(path, undefined, true);
|
||||
unregisterNewer();
|
||||
});
|
||||
|
||||
it("keeps unreadable conflict revisions available for explicit repair", async () => {
|
||||
const path = "missing-conflict-body.md" as FilePathWithPrefix;
|
||||
const harness = createHarness();
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "3-current",
|
||||
rightRev: "2-unreadable",
|
||||
leftLeaf: leaf("3-current", "Readable current body\n", 3),
|
||||
rightLeaf: false,
|
||||
});
|
||||
|
||||
await harness.conflict.resolve(path);
|
||||
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
||||
expect(harness.addLog).toHaveBeenCalledWith(
|
||||
`could not read conflicted revision 2-unreadable:${path}`,
|
||||
LOG_LEVEL_NOTICE,
|
||||
""
|
||||
);
|
||||
expect(MISSING_OR_ERROR).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves revisions and returns a safe error when the current leaf is unreadable", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "unreadable-current.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "3-current",
|
||||
rightRev: "2-conflict",
|
||||
leftLeaf: false,
|
||||
rightLeaf: leaf("2-conflict", "Conflict body\n", 2),
|
||||
});
|
||||
|
||||
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(MISSING_OR_ERROR);
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
||||
expect(harness.dependencies.log).toHaveBeenCalledWith(
|
||||
`could not get current revisions:${path}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves an identical pair, emits cancellation, and rechecks after merging", async () => {
|
||||
const path = "independently-created.md" as FilePathWithPrefix;
|
||||
const harness = createHarness({ settings: { syncAfterMerge: false } });
|
||||
const cancelled: FilePathWithPrefix[] = [];
|
||||
harness.context.events.onEvent(EVENT_CONFLICT_CANCELLED, (filename) => cancelled.push(filename));
|
||||
const leftLeaf = leaf("1-left", "Same content\n", 1000);
|
||||
const rightLeaf = leaf("1-right", "Same content\n", 2000);
|
||||
harness.tryAutoMerge
|
||||
.mockResolvedValueOnce({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: NOT_CONFLICTED });
|
||||
|
||||
await harness.conflict.resolve(path);
|
||||
await harness.conflict.ensureAllProcessed();
|
||||
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "1-left");
|
||||
expect(harness.fileHandler.dbToStorage).toHaveBeenCalledWith(path, path, true);
|
||||
expect(cancelled).toEqual([path, path]);
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledTimes(2);
|
||||
expect(AUTO_MERGED).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses deterministic revision ordering and suppresses notices during bulk resolution", async () => {
|
||||
const files = Array.from({ length: 11 }, (_, index) => `note-${index}.md` as FilePathWithPrefix);
|
||||
const harness = createHarness({ files });
|
||||
harness.databaseFileAccess.fetchEntryMeta.mockImplementation(async (path: FilePathWithPrefix, rev?: string) =>
|
||||
metadata(path, rev ?? "2-current", rev ? 1 : 2)
|
||||
);
|
||||
let conflictInspection = 0;
|
||||
harness.databaseFileAccess.getConflictedRevs.mockImplementation(async () =>
|
||||
conflictInspection++ % 2 === 0 ? ["1-old"] : []
|
||||
);
|
||||
|
||||
await harness.conflict.resolveAllConflictedFilesByNewerOnes();
|
||||
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledTimes(11);
|
||||
expect(harness.addLog).toHaveBeenCalledWith(
|
||||
"Check and Processing 10 / 11",
|
||||
LOG_LEVEL_NOTICE,
|
||||
"resolveAllConflictedFilesByNewerOnes"
|
||||
);
|
||||
expect(harness.addLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining("has been merged automatically"),
|
||||
LOG_LEVEL_INFO,
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
it("uses revision identifiers to break newest-resolution timestamp ties", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "same-time.md" as FilePathWithPrefix;
|
||||
harness.databaseFileAccess.fetchEntryMeta.mockImplementation(
|
||||
async (filename: FilePathWithPrefix, revision?: string) => metadata(filename, revision ?? "2-3", 1000)
|
||||
);
|
||||
harness.databaseFileAccess.getConflictedRevs
|
||||
.mockResolvedValueOnce(["2-10", "2-2"])
|
||||
.mockResolvedValueOnce(["2-10"])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await harness.operations.resolveByNewest(path);
|
||||
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenNthCalledWith(1, path, "2-3");
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenNthCalledWith(2, path, "2-10");
|
||||
expect(harness.dependencies.log).toHaveBeenLastCalledWith(
|
||||
`${path} has been merged automatically`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ description: "no conflicts remain", conflicts: [] as string[], expectedDeletes: [] as string[] },
|
||||
{
|
||||
description: "conflict metadata is unreadable",
|
||||
conflicts: ["2-unreadable"],
|
||||
expectedDeletes: ["2-unreadable"],
|
||||
},
|
||||
])("handles the case where $description while resolving by newest", async ({ conflicts, expectedDeletes }) => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "newest-policy.md" as FilePathWithPrefix;
|
||||
harness.databaseFileAccess.fetchEntryMeta.mockImplementation(
|
||||
async (_filename: FilePathWithPrefix, revision?: string) =>
|
||||
revision === undefined ? metadata(path, "3-current", 1000) : false
|
||||
);
|
||||
harness.databaseFileAccess.getConflictedRevs.mockResolvedValueOnce(conflicts).mockResolvedValueOnce([]);
|
||||
|
||||
await expect(harness.operations.resolveByNewest(path)).resolves.toBe(true);
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledTimes(expectedDeletes.length);
|
||||
for (const [index, revision] of expectedDeletes.entries()) {
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenNthCalledWith(index + 1, path, revision);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import type { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { createConflictCheckingHandlers } from "./checker";
|
||||
import { createConflictResolutionOperations } from "./operations";
|
||||
|
||||
type ConflictResolutionServices = NecessaryServices<
|
||||
"API" | "appLifecycle" | "conflict" | "database" | "replication" | "setting" | "vault",
|
||||
"databaseFileAccess" | "fileHandler" | "storageAccess"
|
||||
>;
|
||||
|
||||
export type ConflictResolutionHost = ConflictResolutionServices & {
|
||||
readonly services: ConflictResolutionServices["services"] & {
|
||||
readonly conflict: InjectableConflictService<ServiceContext>;
|
||||
};
|
||||
};
|
||||
|
||||
/** Compose the host-neutral conflict checker and resolver handlers. */
|
||||
export function useConflictResolutionFeature(host: ConflictResolutionHost): void {
|
||||
const { services, serviceModules } = host;
|
||||
const log = createInstanceLogFunction("SF:ConflictResolution", services.API);
|
||||
const operations = createConflictResolutionOperations({
|
||||
events: services.context.events,
|
||||
databaseFileAccess: serviceModules.databaseFileAccess,
|
||||
fileHandler: serviceModules.fileHandler,
|
||||
localDatabase: () => services.database.localDatabase,
|
||||
conflict: services.conflict,
|
||||
replication: services.replication,
|
||||
appLifecycle: services.appLifecycle,
|
||||
vault: services.vault,
|
||||
storageAccess: serviceModules.storageAccess,
|
||||
currentSettings: () => services.setting.currentSettings(),
|
||||
log,
|
||||
});
|
||||
const checking = createConflictCheckingHandlers({
|
||||
conflict: services.conflict,
|
||||
conflictProcessQueueCount: services.conflict.conflictProcessQueueCount,
|
||||
currentSettings: () => services.setting.currentSettings(),
|
||||
vault: services.vault,
|
||||
log,
|
||||
});
|
||||
|
||||
services.conflict.queueCheckForIfOpen.setHandler(checking.queueCheckForIfOpen);
|
||||
services.conflict.queueCheckFor.setHandler(checking.queueCheckFor);
|
||||
services.conflict.ensureAllProcessed.setHandler(checking.ensureAllProcessed);
|
||||
services.conflict.resolveByDeletingRevision.setHandler(operations.resolveByDeletingRevision);
|
||||
services.conflict.resolve.setHandler(operations.resolve);
|
||||
services.conflict.resolveByNewest.setHandler(operations.resolveByNewest);
|
||||
services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(operations.resolveAllConflictedFilesByNewerOnes);
|
||||
}
|
||||
|
||||
export { createConflictCheckingHandlers } from "./checker";
|
||||
export type { ConflictCheckingDependencies, ConflictCheckingHandlers } from "./checker";
|
||||
export type {
|
||||
ConflictResolutionOperations,
|
||||
ConflictResolutionOperationsDependencies,
|
||||
ConflictResolutionSettings,
|
||||
} from "./operations";
|
||||
export { createConflictResolutionOperations } from "./operations";
|
||||
@@ -0,0 +1,308 @@
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
CANCELLED,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
MISSING_OR_ERROR,
|
||||
NOT_CONFLICTED,
|
||||
type diff_check_result,
|
||||
type FilePathWithPrefix,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { isCustomisationSyncMetadata, isPluginMetadata } from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
|
||||
import { TARGET_IS_NEW } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols";
|
||||
import { compareMTime, displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
|
||||
import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler";
|
||||
import type {
|
||||
IAppLifecycleService,
|
||||
IConflictService,
|
||||
IReplicationService,
|
||||
IVaultService,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import type { LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
|
||||
import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { isLockAcquired, serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import diff_match_patch from "diff-match-patch";
|
||||
import { stripAllPrefixes, isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
|
||||
export type ConflictResolutionSettings = Pick<
|
||||
ObsidianLiveSyncSettings,
|
||||
"disableMarkdownAutoMerge" | "resolveConflictsByNewerFile" | "syncAfterMerge" | "showMergeDialogOnlyOnActive"
|
||||
>;
|
||||
|
||||
export interface ConflictResolutionOperationsDependencies {
|
||||
readonly events: Pick<LiveSyncEventHub, "emitEvent">;
|
||||
readonly databaseFileAccess: Pick<DatabaseFileAccess, "fetchEntryMeta" | "getConflictedRevs" | "storeContent">;
|
||||
readonly fileHandler: Pick<IFileHandler, "deleteRevisionFromDB" | "dbToStorage">;
|
||||
readonly localDatabase: () => Pick<LiveSyncLocalDB, "tryAutoMerge">;
|
||||
readonly conflict: Pick<
|
||||
IConflictService,
|
||||
"queueCheckFor" | "resolveByDeletingRevision" | "resolveByUserInteraction"
|
||||
>;
|
||||
readonly replication: Pick<IReplicationService, "replicateUnattendedByEvent">;
|
||||
readonly appLifecycle: Pick<IAppLifecycleService, "isSuspended">;
|
||||
readonly vault: Pick<IVaultService, "getActiveFilePath">;
|
||||
readonly storageAccess: Pick<StorageAccess, "getFileNames">;
|
||||
readonly currentSettings: () => ConflictResolutionSettings;
|
||||
readonly log: LogFunction;
|
||||
}
|
||||
|
||||
export interface ConflictResolutionOperations {
|
||||
readonly resolveByDeletingRevision: (
|
||||
path: FilePathWithPrefix,
|
||||
deleteRevision: string,
|
||||
subTitle?: string,
|
||||
showNotice?: boolean
|
||||
) => Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED>;
|
||||
readonly checkConflictAndPerformAutoMerge: (path: FilePathWithPrefix) => Promise<diff_check_result>;
|
||||
readonly resolve: (filename: FilePathWithPrefix) => Promise<void>;
|
||||
readonly resolveByNewest: (filename: FilePathWithPrefix, showNotice?: boolean) => Promise<boolean>;
|
||||
readonly resolveAllConflictedFilesByNewerOnes: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createConflictResolutionOperations(
|
||||
dependencies: ConflictResolutionOperationsDependencies
|
||||
): ConflictResolutionOperations {
|
||||
const latestResolveRequestByFilename = new Map<FilePathWithPrefix, number>();
|
||||
let nextResolveRequestId = 0;
|
||||
|
||||
const resolveByDeletingRevision = async (
|
||||
path: FilePathWithPrefix,
|
||||
deleteRevision: string,
|
||||
subTitle = "",
|
||||
showNotice = true
|
||||
): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> => {
|
||||
const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`;
|
||||
if (!(await dependencies.fileHandler.deleteRevisionFromDB(path, deleteRevision))) {
|
||||
dependencies.log(
|
||||
`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, path);
|
||||
dependencies.log(
|
||||
`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
if ((await dependencies.databaseFileAccess.getConflictedRevs(path)).length != 0) {
|
||||
dependencies.log(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
if (isPluginMetadata(path) || isCustomisationSyncMetadata(path)) {
|
||||
dependencies.log(`${title} ${path} is a plugin metadata file, no need to write to storage`, LOG_LEVEL_INFO);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
// If no conflicts were found, write the resolved content to the storage.
|
||||
if (!(await dependencies.fileHandler.dbToStorage(path, stripAllPrefixes(path), true))) {
|
||||
dependencies.log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
const level = subTitle.indexOf("same") !== -1 || !showNotice ? LOG_LEVEL_INFO : LOG_LEVEL_NOTICE;
|
||||
dependencies.log(`${path} has been merged automatically`, level);
|
||||
return AUTO_MERGED;
|
||||
};
|
||||
|
||||
const checkConflictAndPerformAutoMerge = async (path: FilePathWithPrefix): Promise<diff_check_result> => {
|
||||
const ret = await dependencies
|
||||
.localDatabase()
|
||||
.tryAutoMerge(path, !dependencies.currentSettings().disableMarkdownAutoMerge);
|
||||
if ("ok" in ret) {
|
||||
return ret.ok;
|
||||
}
|
||||
|
||||
if ("result" in ret) {
|
||||
const p = ret.result;
|
||||
// 1. Store the merged content to the storage.
|
||||
if (!(await dependencies.databaseFileAccess.storeContent(path, p))) {
|
||||
dependencies.log(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
// 2. Delete the conflicted revision and reflect the result if all conflicts are gone.
|
||||
return await dependencies.conflict.resolveByDeletingRevision(path, ret.conflictedRev, "Sensible");
|
||||
}
|
||||
|
||||
const { rightRev, leftLeaf, rightLeaf } = ret;
|
||||
|
||||
// Should be one or more conflicts.
|
||||
if (leftLeaf == false) {
|
||||
dependencies.log(`could not get current revisions:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
if (rightLeaf == false) {
|
||||
// A locally unreadable conflict leaf may still be recoverable from another
|
||||
// replica or backup. Keep it visible for explicit repair instead of treating
|
||||
// missing chunks as evidence that the branch is obsolete.
|
||||
dependencies.log(`could not read conflicted revision ${rightRev}:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
|
||||
const isSame = leftLeaf.data == rightLeaf.data && leftLeaf.deleted == rightLeaf.deleted;
|
||||
const isBinary = !isPlainText(path);
|
||||
const alwaysNewer = dependencies.currentSettings().resolveConflictsByNewerFile;
|
||||
if (isSame || isBinary || alwaysNewer) {
|
||||
const result = compareMTime(leftLeaf.mtime, rightLeaf.mtime);
|
||||
let loser = leftLeaf;
|
||||
// If lMtime > rMtime.
|
||||
if (result != TARGET_IS_NEW) {
|
||||
loser = rightLeaf;
|
||||
}
|
||||
const subTitle = [
|
||||
`${isSame ? "same" : ""}`,
|
||||
`${isBinary ? "binary" : ""}`,
|
||||
`${alwaysNewer ? "alwaysNewer" : ""}`,
|
||||
]
|
||||
.filter((e) => e.trim())
|
||||
.join(",");
|
||||
return await dependencies.conflict.resolveByDeletingRevision(path, loser.rev, subTitle);
|
||||
}
|
||||
// Make diff.
|
||||
const dmp = new diff_match_patch();
|
||||
const diff = dmp.diff_main(leftLeaf.data, rightLeaf.data);
|
||||
dmp.diff_cleanupSemantic(diff);
|
||||
dependencies.log(`conflict(s) found:${path}`);
|
||||
return {
|
||||
left: leftLeaf,
|
||||
right: rightLeaf,
|
||||
diff: diff,
|
||||
};
|
||||
};
|
||||
|
||||
const resolve = async (filename: FilePathWithPrefix): Promise<void> => {
|
||||
const requestId = ++nextResolveRequestId;
|
||||
latestResolveRequestByFilename.set(filename, requestId);
|
||||
const serialisationKey = `conflict-resolve:${filename}`;
|
||||
if (isLockAcquired(serialisationKey)) {
|
||||
// A later check for the same file makes any open comparison stale.
|
||||
// Close it before waiting for the current resolver to release the
|
||||
// per-file lock. Dialogues for other paths remain untouched.
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
}
|
||||
return await serialized(serialisationKey, async () => {
|
||||
if (latestResolveRequestByFilename.get(filename) !== requestId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const conflictCheckResult = await checkConflictAndPerformAutoMerge(filename);
|
||||
if (latestResolveRequestByFilename.get(filename) !== requestId) {
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === NOT_CONFLICTED) {
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
dependencies.log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === MISSING_OR_ERROR || conflictCheckResult === CANCELLED) {
|
||||
// Nothing to do.
|
||||
dependencies.log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === AUTO_MERGED) {
|
||||
// Auto resolved, but need to check again.
|
||||
if (dependencies.currentSettings().syncAfterMerge && !dependencies.appLifecycle.isSuspended()) {
|
||||
// Wait for the running replication, if not running replication, run it once.
|
||||
await dependencies.replication.replicateUnattendedByEvent({
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
dependencies.log("[conflict] Automatically merged, but we have to check it again");
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
return;
|
||||
}
|
||||
if (dependencies.currentSettings().showMergeDialogOnlyOnActive) {
|
||||
const activeFile = dependencies.vault.getActiveFilePath();
|
||||
if (activeFile && activeFile != filename) {
|
||||
dependencies.log(
|
||||
`[conflict] ${filename} is conflicted. Merging process has been postponed to the file have got opened.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
dependencies.log("[conflict] Manual merge required!");
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
await dependencies.conflict.resolveByUserInteraction(filename, conflictCheckResult);
|
||||
} finally {
|
||||
if (latestResolveRequestByFilename.get(filename) === requestId) {
|
||||
latestResolveRequestByFilename.delete(filename);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resolveByNewest = async (filename: FilePathWithPrefix, showNotice = true): Promise<boolean> => {
|
||||
const currentRev = await dependencies.databaseFileAccess.fetchEntryMeta(filename, undefined, true);
|
||||
if (currentRev == false) {
|
||||
dependencies.log(`Could not get current revision of ${filename}`);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const revs = await dependencies.databaseFileAccess.getConflictedRevs(filename);
|
||||
if (revs.length == 0) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
const mTimeAndRev = (
|
||||
[
|
||||
[currentRev.mtime, currentRev._rev],
|
||||
...(await Promise.all(
|
||||
revs.map(async (rev) => {
|
||||
const leaf = await dependencies.databaseFileAccess.fetchEntryMeta(filename, rev);
|
||||
if (leaf == false) {
|
||||
return [0, rev];
|
||||
}
|
||||
return [leaf.mtime, rev];
|
||||
})
|
||||
)),
|
||||
] as [number, string][]
|
||||
).sort((a, b) => {
|
||||
const diff = b[0] - a[0];
|
||||
if (diff == 0) {
|
||||
return a[1].localeCompare(b[1], "en", { numeric: true });
|
||||
}
|
||||
return diff;
|
||||
});
|
||||
dependencies.log(
|
||||
`Resolving conflict by newest: ${filename} (Newest: ${new Date(mTimeAndRev[0][0]).toLocaleString()}) (${mTimeAndRev.length} revisions exists)`
|
||||
);
|
||||
for (let i = 1; i < mTimeAndRev.length; i++) {
|
||||
dependencies.log(
|
||||
`conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}`
|
||||
);
|
||||
await resolveByDeletingRevision(filename, mTimeAndRev[i][1], "NEWEST", showNotice);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const resolveAllConflictedFilesByNewerOnes = async (): Promise<void> => {
|
||||
dependencies.log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE);
|
||||
|
||||
const files = await dependencies.storageAccess.getFileNames();
|
||||
|
||||
let i = 0;
|
||||
for (const file of files) {
|
||||
i++;
|
||||
if (i % 10 === 0)
|
||||
dependencies.log(
|
||||
`Check and Processing ${i} / ${files.length}`,
|
||||
LOG_LEVEL_NOTICE,
|
||||
"resolveAllConflictedFilesByNewerOnes"
|
||||
);
|
||||
await resolveByNewest(file, false);
|
||||
}
|
||||
dependencies.log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
|
||||
};
|
||||
|
||||
return {
|
||||
resolveByDeletingRevision,
|
||||
checkConflictAndPerformAutoMerge,
|
||||
resolve,
|
||||
resolveByNewest,
|
||||
resolveAllConflictedFilesByNewerOnes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import type { Editor, MarkdownFileInfo, MarkdownView } from "@/deps.ts";
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED } from "@/common/events.ts";
|
||||
import { createInteractiveConflictResolutionOperations } from "./operations";
|
||||
import type { ConflictResolveDialogueFactory } from "./types";
|
||||
|
||||
export type InteractiveConflictResolutionHost = NecessaryServices<
|
||||
"API" | "UI" | "appLifecycle" | "conflict" | "replication" | "vault" | "database" | "setting" | "path",
|
||||
"databaseFileAccess"
|
||||
>;
|
||||
|
||||
export function useInteractiveConflictResolutionFeature(
|
||||
host: InteractiveConflictResolutionHost,
|
||||
createDialogue: ConflictResolveDialogueFactory
|
||||
): void {
|
||||
const services = host.services;
|
||||
const operations = createInteractiveConflictResolutionOperations({
|
||||
events: services.context.events,
|
||||
databaseFileAccess: host.serviceModules.databaseFileAccess,
|
||||
localDatabase: () => services.database.localDatabase,
|
||||
confirm: services.UI.confirm,
|
||||
path: services.path,
|
||||
vault: services.vault,
|
||||
appLifecycle: services.appLifecycle,
|
||||
conflict: services.conflict,
|
||||
replication: services.replication,
|
||||
currentSettings: () => services.setting.currentSettings(),
|
||||
createDialogue,
|
||||
log: createInstanceLogFunction("SF:InteractiveConflictResolution", services.API),
|
||||
});
|
||||
|
||||
services.appLifecycle.onScanningStartupIssues.addHandler(operations.scanStartupIssues);
|
||||
services.appLifecycle.onInitialise.addHandler(() => {
|
||||
services.API.addCommand({
|
||||
id: "livesync-checkdoc-conflicted",
|
||||
name: "Resolve if conflicted.",
|
||||
editorCallback: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => {
|
||||
const file = view.file;
|
||||
if (!file) return;
|
||||
void operations.requestConflictResolution(file.path as FilePathWithPrefix);
|
||||
},
|
||||
});
|
||||
services.API.addCommand({
|
||||
id: "livesync-conflictcheck",
|
||||
name: "Pick a file to resolve conflict",
|
||||
callback: async () => {
|
||||
await operations.pickFileForResolve();
|
||||
},
|
||||
});
|
||||
services.API.addCommand({
|
||||
id: "livesync-all-conflictcheck",
|
||||
name: "Resolve all conflicted files",
|
||||
callback: async () => {
|
||||
await operations.allConflictCheck();
|
||||
},
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
services.appLifecycle.getUnresolvedMessages.addHandler(operations.getActiveConflictMessages);
|
||||
services.conflict.resolveByUserInteraction.addHandler(operations.resolveByUserInteraction);
|
||||
const eventSubscriptions = new AbortController();
|
||||
const dispose = () => {
|
||||
// Stop the refresh listener before cancellation so that unloading does
|
||||
// not start a database read which can race with database disposal.
|
||||
eventSubscriptions.abort();
|
||||
operations.dispose();
|
||||
};
|
||||
services.context.events.onEvent(
|
||||
EVENT_CONFLICT_CANCELLED,
|
||||
(filename) => {
|
||||
operations.invalidateWaitingResolution(filename);
|
||||
fireAndForget(() => operations.refreshConflictState(filename));
|
||||
},
|
||||
{ signal: eventSubscriptions.signal }
|
||||
);
|
||||
services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, dispose, { signal: eventSubscriptions.signal });
|
||||
services.appLifecycle.onUnload.addHandler(() => {
|
||||
dispose();
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
|
||||
export { createInteractiveConflictResolutionOperations } from "./operations";
|
||||
export type {
|
||||
InteractiveConflictResolutionOperations,
|
||||
InteractiveConflictResolutionOperationsDependencies,
|
||||
} from "./operations";
|
||||
export { POSTPONED } from "./types";
|
||||
export type { ConflictResolveDialogue, ConflictResolveDialogueFactory, MergeDialogResult } from "./types";
|
||||
+898
@@ -0,0 +1,898 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
CANCELLED,
|
||||
DEFAULT_SETTINGS,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
MISSING_OR_ERROR,
|
||||
type FilePathWithPrefix,
|
||||
type diff_result,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR, EVENT_PLUGIN_UNLOADED } from "@/common/events";
|
||||
import {
|
||||
createInteractiveConflictResolutionOperations,
|
||||
type InteractiveConflictResolutionOperationsDependencies,
|
||||
} from "./operations";
|
||||
import { POSTPONED, type ConflictResolveDialogueFactory, type MergeDialogResult } from "./types";
|
||||
import { useInteractiveConflictResolutionFeature } from "./index";
|
||||
|
||||
const path = "note.md" as FilePathWithPrefix;
|
||||
const conflict: diff_result = {
|
||||
left: { rev: "2-left", data: "left", ctime: 1, mtime: 2 },
|
||||
right: { rev: "2-right", data: "right", ctime: 1, mtime: 2 },
|
||||
diff: [],
|
||||
};
|
||||
|
||||
async function* documents(items: unknown[]) {
|
||||
for (const item of items) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
|
||||
function createOperations(conflictedRevisions: string[] = ["2-right"]) {
|
||||
const context = createServiceContext();
|
||||
let dialogueResult: unknown = POSTPONED;
|
||||
const constructed = { value: 0 };
|
||||
const getDBEntry = vi.fn(async (): Promise<false | { _rev: string; _conflicts?: string[] }> => false);
|
||||
const findAllDocs = vi.fn(() => documents([]));
|
||||
const askSelectString = vi.fn(async (): Promise<string> => "");
|
||||
const askInPopup = vi.fn();
|
||||
const queueCheckFor = vi.fn(async () => undefined);
|
||||
const ensureAllProcessed = vi.fn(async () => true);
|
||||
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
|
||||
const getConflictedRevs = vi.fn(async () => conflictedRevisions);
|
||||
const storeContent = vi.fn(async () => true);
|
||||
const isSuspended = vi.fn(() => false);
|
||||
const getActiveFilePath = vi.fn((): FilePathWithPrefix | undefined => path);
|
||||
const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const }));
|
||||
const currentSettings = vi.fn(() => ({ syncAfterMerge: false }));
|
||||
const log = vi.fn();
|
||||
const operations = createInteractiveConflictResolutionOperations({
|
||||
events: context.events,
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs,
|
||||
storeContent,
|
||||
},
|
||||
localDatabase: () => ({ getDBEntry, findAllDocs }),
|
||||
confirm: {
|
||||
askSelectString,
|
||||
askInPopup,
|
||||
},
|
||||
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
|
||||
vault: { getActiveFilePath },
|
||||
appLifecycle: { isSuspended },
|
||||
conflict: { queueCheckFor, ensureAllProcessed, resolveByDeletingRevision },
|
||||
replication: {
|
||||
replicateUnattendedByEvent,
|
||||
},
|
||||
currentSettings,
|
||||
createDialogue: vi.fn(() => {
|
||||
constructed.value++;
|
||||
return {
|
||||
open: vi.fn(),
|
||||
waitForResult: vi.fn(async () => dialogueResult),
|
||||
};
|
||||
}),
|
||||
log,
|
||||
} as unknown as InteractiveConflictResolutionOperationsDependencies);
|
||||
return {
|
||||
askSelectString,
|
||||
askInPopup,
|
||||
constructed,
|
||||
context,
|
||||
dialogueResult: {
|
||||
get value() {
|
||||
return dialogueResult;
|
||||
},
|
||||
set value(value: unknown) {
|
||||
dialogueResult = value;
|
||||
},
|
||||
},
|
||||
findAllDocs,
|
||||
getDBEntry,
|
||||
getActiveFilePath,
|
||||
getConflictedRevs,
|
||||
ensureAllProcessed,
|
||||
currentSettings,
|
||||
isSuspended,
|
||||
log,
|
||||
operations,
|
||||
queueCheckFor,
|
||||
replicateUnattendedByEvent,
|
||||
resolveByDeletingRevision,
|
||||
storeContent,
|
||||
conflictedRevisions,
|
||||
};
|
||||
}
|
||||
|
||||
type ControlledDialogue = {
|
||||
readonly filename: FilePathWithPrefix;
|
||||
readonly open: ReturnType<typeof vi.fn>;
|
||||
readonly finish: (result: MergeDialogResult) => void;
|
||||
readonly waitForResult: () => Promise<MergeDialogResult>;
|
||||
};
|
||||
|
||||
function createControlledDialogueFactory(
|
||||
context: ReturnType<typeof createServiceContext>,
|
||||
dialogues: ControlledDialogue[]
|
||||
): ConflictResolveDialogueFactory {
|
||||
const createDialogue = vi.fn((filename: FilePathWithPrefix) => {
|
||||
let settle!: (result: MergeDialogResult) => void;
|
||||
let offConflictCancelled: (() => void) | undefined;
|
||||
const result = new Promise<MergeDialogResult>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
const finish = (dialogueResult: MergeDialogResult) => {
|
||||
offConflictCancelled?.();
|
||||
offConflictCancelled = undefined;
|
||||
settle(dialogueResult);
|
||||
};
|
||||
const dialogue: ControlledDialogue = {
|
||||
filename,
|
||||
open: vi.fn(() => {
|
||||
offConflictCancelled = context.events.onEvent(EVENT_CONFLICT_CANCELLED, (cancelledFilename) => {
|
||||
if (cancelledFilename === filename) {
|
||||
finish(CANCELLED);
|
||||
}
|
||||
});
|
||||
}),
|
||||
finish,
|
||||
waitForResult: () => result,
|
||||
};
|
||||
dialogues.push(dialogue);
|
||||
return dialogue;
|
||||
});
|
||||
return createDialogue as unknown as ConflictResolveDialogueFactory;
|
||||
}
|
||||
|
||||
function createDialogueConcurrencyHarness() {
|
||||
const context = createServiceContext();
|
||||
const emitEvent = vi.spyOn(context.events, "emitEvent");
|
||||
const dialogues: ControlledDialogue[] = [];
|
||||
const createDialogue = createControlledDialogueFactory(context, dialogues);
|
||||
const operations = createInteractiveConflictResolutionOperations({
|
||||
events: context.events,
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs: vi.fn(async () => ["2-right"]),
|
||||
storeContent: vi.fn(async () => true),
|
||||
},
|
||||
localDatabase: () => ({
|
||||
getDBEntry: vi.fn(async () => false),
|
||||
findAllDocs: vi.fn(() => documents([])),
|
||||
}),
|
||||
confirm: { askSelectString: vi.fn(), askInPopup: vi.fn() },
|
||||
path: { getPath: vi.fn() },
|
||||
vault: { getActiveFilePath: vi.fn(() => path) },
|
||||
appLifecycle: { isSuspended: vi.fn(() => false) },
|
||||
conflict: {
|
||||
queueCheckFor: vi.fn(async () => undefined),
|
||||
ensureAllProcessed: vi.fn(async () => true),
|
||||
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
|
||||
},
|
||||
replication: {
|
||||
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
currentSettings: () => ({ syncAfterMerge: false }),
|
||||
createDialogue: createDialogue as ConflictResolveDialogueFactory,
|
||||
log: vi.fn(),
|
||||
} as unknown as InteractiveConflictResolutionOperationsDependencies);
|
||||
return { context, createDialogue, dialogues, emitEvent, operations };
|
||||
}
|
||||
|
||||
describe("interactive conflict resolution operations", () => {
|
||||
it("replaces an active same-file dialogue with only the newest waiting request", async () => {
|
||||
const fixture = createDialogueConcurrencyHarness();
|
||||
|
||||
const first = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(fixture.dialogues).toHaveLength(1));
|
||||
const superseded = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
const replacement = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
await vi.waitFor(() => expect(fixture.dialogues).toHaveLength(2));
|
||||
expect(fixture.emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
|
||||
expect(fixture.dialogues[1].filename).toBe(path);
|
||||
expect(fixture.createDialogue).toHaveBeenCalledTimes(2);
|
||||
|
||||
fixture.dialogues[1].finish(CANCELLED);
|
||||
await Promise.all([first, superseded, replacement]);
|
||||
});
|
||||
|
||||
it("keeps a same-file replacement request when its cancellation refresh sees the active dialogue", async () => {
|
||||
const dialogues: ControlledDialogue[] = [];
|
||||
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
|
||||
const resolveByUserInteraction = fixture.handlers.resolveByUserInteraction!;
|
||||
|
||||
const first = resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(dialogues).toHaveLength(1));
|
||||
const replacement = resolveByUserInteraction(path, conflict);
|
||||
|
||||
await vi.waitFor(() => expect(dialogues).toHaveLength(2));
|
||||
expect(dialogues[1]?.filename).toBe(path);
|
||||
|
||||
dialogues[1]?.finish(CANCELLED);
|
||||
await Promise.all([first, replacement]);
|
||||
});
|
||||
|
||||
it("keeps a different file waiting until the active dialogue finishes", async () => {
|
||||
const fixture = createDialogueConcurrencyHarness();
|
||||
const otherPath = "other.md" as FilePathWithPrefix;
|
||||
|
||||
const first = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(fixture.dialogues).toHaveLength(1));
|
||||
const waiting = fixture.operations.resolveByUserInteraction(otherPath, conflict);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fixture.dialogues).toHaveLength(1);
|
||||
expect(fixture.emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, otherPath);
|
||||
|
||||
fixture.dialogues[0].finish(CANCELLED);
|
||||
await vi.waitFor(() => expect(fixture.dialogues).toHaveLength(2));
|
||||
expect(fixture.dialogues[1].filename).toBe(otherPath);
|
||||
fixture.dialogues[1].finish(CANCELLED);
|
||||
await Promise.all([first, waiting]);
|
||||
});
|
||||
|
||||
it("does not reopen an unchanged conflict after the user chooses Not now", async () => {
|
||||
const { constructed, operations } = createOperations();
|
||||
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(constructed.value).toBe(1);
|
||||
});
|
||||
|
||||
it("does not treat cancellation by another conflict dialogue as Not now", async () => {
|
||||
const { constructed, dialogueResult, operations } = createOperations();
|
||||
dialogueResult.value = CANCELLED;
|
||||
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(constructed.value).toBe(2);
|
||||
});
|
||||
|
||||
it("allows an explicit resolution request to reopen a postponed conflict", async () => {
|
||||
const { constructed, dialogueResult, ensureAllProcessed, operations, queueCheckFor } = createOperations();
|
||||
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
await operations.requestConflictResolution(path);
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(queueCheckFor).toHaveBeenCalledWith(path);
|
||||
expect(queueCheckFor).toHaveBeenCalledOnce();
|
||||
expect(ensureAllProcessed).toHaveBeenCalledOnce();
|
||||
expect(constructed.value).toBe(2);
|
||||
expect(dialogueResult.value).toBe(POSTPONED);
|
||||
});
|
||||
|
||||
it("opens a later conflict after the postponed conflict episode has resolved", async () => {
|
||||
const conflictedRevisions = ["2-right"];
|
||||
const { constructed, operations } = createOperations(conflictedRevisions);
|
||||
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
conflictedRevisions.splice(0);
|
||||
await operations.refreshConflictState(path);
|
||||
conflictedRevisions.push("4-later");
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(constructed.value).toBe(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "cannot be read", result: false as const, refreshes: false },
|
||||
{ label: "has no conflicts", result: { _rev: "2-left", _conflicts: [] as string[] }, refreshes: true },
|
||||
])("exits safely when the live database $label after a user selection", async ({ result, refreshes }) => {
|
||||
const fixture = createOperations();
|
||||
fixture.dialogueResult.value = "2-right";
|
||||
fixture.getDBEntry.mockResolvedValue(result);
|
||||
const emitEvent = vi.spyOn(fixture.context.events, "emitEvent");
|
||||
|
||||
await expect(fixture.operations.resolveByUserInteraction(path, conflict)).resolves.toBe(false);
|
||||
|
||||
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
if (refreshes) {
|
||||
expect(fixture.getConflictedRevs).toHaveBeenCalledWith(path);
|
||||
expect(emitEvent).toHaveBeenCalledWith(EVENT_ON_UNRESOLVED_ERROR);
|
||||
} else {
|
||||
expect(fixture.getConflictedRevs).not.toHaveBeenCalled();
|
||||
expect(emitEvent).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("contributes the active conflict to the existing unresolved-message display", async () => {
|
||||
const { operations } = createOperations();
|
||||
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
});
|
||||
|
||||
it("returns no unresolved message when there is no active file", async () => {
|
||||
const { getActiveFilePath, getConflictedRevs, operations } = createOperations();
|
||||
getActiveFilePath.mockReturnValue(undefined);
|
||||
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual([]);
|
||||
|
||||
expect(getConflictedRevs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs a failed conflict inspection and returns no unresolved messages", async () => {
|
||||
const fixture = createOperations();
|
||||
const error = new Error("database unavailable");
|
||||
fixture.getConflictedRevs.mockRejectedValue(error);
|
||||
|
||||
await expect(fixture.operations.getActiveConflictMessages()).resolves.toEqual([]);
|
||||
|
||||
expect(fixture.log).toHaveBeenCalledWith("Could not inspect the conflict state of note.md", expect.anything());
|
||||
expect(fixture.log).toHaveBeenCalledWith(error, expect.anything());
|
||||
});
|
||||
|
||||
it("removes the active warning once the conflict has resolved", async () => {
|
||||
const conflictedRevisions = ["2-right"];
|
||||
const { operations } = createOperations(conflictedRevisions);
|
||||
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
conflictedRevisions.splice(0);
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("reports the number of live versions and reduces it after each resolved pair", async () => {
|
||||
const conflictedRevisions = ["2-second", "2-third"];
|
||||
const { operations } = createOperations(conflictedRevisions);
|
||||
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual([
|
||||
"This file has 3 unresolved versions. They will be reviewed one pair at a time.",
|
||||
]);
|
||||
|
||||
conflictedRevisions.shift();
|
||||
await operations.refreshConflictState(path);
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
|
||||
conflictedRevisions.shift();
|
||||
await operations.refreshConflictState(path);
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("reconstructs the remaining pair after a postponed session is restarted", async () => {
|
||||
const conflictedRevisions = ["2-second", "2-third"];
|
||||
const firstSession = createOperations(conflictedRevisions);
|
||||
|
||||
await firstSession.operations.resolveByUserInteraction(path, conflict);
|
||||
conflictedRevisions.shift();
|
||||
|
||||
const restartedSession = createOperations(conflictedRevisions);
|
||||
await expect(restartedSession.operations.getActiveConflictMessages()).resolves.toEqual([
|
||||
"This file has unresolved conflicts.",
|
||||
]);
|
||||
|
||||
await restartedSession.operations.resolveByUserInteraction(path, {
|
||||
left: { rev: "3-merged", data: "merged", ctime: 1, mtime: 3 },
|
||||
right: { rev: "2-third", data: "third", ctime: 1, mtime: 2 },
|
||||
diff: [],
|
||||
});
|
||||
|
||||
expect(restartedSession.constructed.value).toBe(1);
|
||||
});
|
||||
|
||||
it("stores the exact concatenated diff before deleting a deterministically selected pair", async () => {
|
||||
const fixture = createOperations(["2-unrelated", "2-right"]);
|
||||
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
|
||||
fixture.getDBEntry.mockResolvedValue({
|
||||
_rev: "2-left",
|
||||
_conflicts: ["2-unrelated", "2-right"],
|
||||
});
|
||||
await fixture.operations.resolveByUserInteraction(path, {
|
||||
...conflict,
|
||||
diff: [
|
||||
[0, "left"],
|
||||
[1, "\n"],
|
||||
[1, "right"],
|
||||
],
|
||||
});
|
||||
|
||||
expect(fixture.storeContent).toHaveBeenCalledWith(path, "left\nright");
|
||||
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
|
||||
});
|
||||
|
||||
it("does not replicate or requeue when concatenated revision deletion fails", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
|
||||
fixture.resolveByDeletingRevision.mockResolvedValue(MISSING_OR_ERROR);
|
||||
|
||||
await fixture.operations.resolveByUserInteraction(path, {
|
||||
...conflict,
|
||||
diff: [
|
||||
[0, "left"],
|
||||
[1, "\nright"],
|
||||
],
|
||||
});
|
||||
|
||||
expect(fixture.storeContent).toHaveBeenCalledWith(path, "left\nright");
|
||||
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rechecks the live leaves instead of applying a stale dialogue selection", async () => {
|
||||
const fixture = createOperations(["2-other"]);
|
||||
fixture.dialogueResult.value = "2-right";
|
||||
fixture.getDBEntry.mockResolvedValue({
|
||||
_rev: "3-new-winner",
|
||||
_conflicts: ["2-other"],
|
||||
});
|
||||
|
||||
await fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
|
||||
it("does not delete a revision when concatenated content cannot be stored", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.storeContent.mockResolvedValue(false);
|
||||
|
||||
await fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not replicate or requeue when selected revision deletion fails", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.dialogueResult.value = "2-right";
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.resolveByDeletingRevision.mockResolvedValue(MISSING_OR_ERROR);
|
||||
|
||||
await fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unexpected dialogue result without changing revisions", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.dialogueResult.value = "unexpected-result";
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
|
||||
await fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(fixture.log).toHaveBeenCalledWith(
|
||||
`Merge: Something went wrong: ${path}, (unexpected-result)`,
|
||||
expect.anything()
|
||||
);
|
||||
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replicates and requeues after a selected revision is resolved", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.dialogueResult.value = "2-right";
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
|
||||
|
||||
await fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Selected");
|
||||
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledWith({
|
||||
trigger: "merge",
|
||||
interaction: expect.anything(),
|
||||
});
|
||||
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
|
||||
it("requeues without replication while the app is suspended", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.dialogueResult.value = "2-right";
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
|
||||
fixture.isSuspended.mockReturnValue(true);
|
||||
|
||||
await fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
|
||||
it("does not show a no-conflicts notice when an automatic repeat reaches its normal end", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.findAllDocs
|
||||
.mockImplementationOnce(() =>
|
||||
documents([
|
||||
{
|
||||
_id: "note-id",
|
||||
_rev: "2-left",
|
||||
_conflicts: ["2-right"],
|
||||
path,
|
||||
mtime: 2,
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(() => documents([]));
|
||||
fixture.askSelectString.mockResolvedValue(path);
|
||||
|
||||
await fixture.operations.allConflictCheck();
|
||||
|
||||
expect(fixture.askSelectString).toHaveBeenCalledOnce();
|
||||
expect(fixture.log).not.toHaveBeenCalledWith("There are no conflicted documents", expect.anything());
|
||||
});
|
||||
|
||||
it("shows one no-conflicts notice for an explicit selection request which starts empty", async () => {
|
||||
const fixture = createOperations();
|
||||
|
||||
await fixture.operations.pickFileForResolve();
|
||||
|
||||
expect(fixture.askSelectString).not.toHaveBeenCalled();
|
||||
expect(fixture.log).toHaveBeenCalledWith("There are no conflicted documents", expect.anything());
|
||||
});
|
||||
|
||||
it("sorts multiple picker candidates newest first and returns safely on cancellation", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.findAllDocs.mockReturnValue(
|
||||
documents([
|
||||
{ _id: "ordinary-id", path: "ordinary.md", mtime: 40 },
|
||||
{ _id: "old-id", path: "old.md", _conflicts: ["2-old"], mtime: 10 },
|
||||
{ _id: "new-id", path: "new.md", _conflicts: ["2-new"], mtime: 30 },
|
||||
{ _id: "middle-id", path: "middle.md", _conflicts: ["2-middle"], mtime: 20 },
|
||||
])
|
||||
);
|
||||
fixture.askSelectString.mockResolvedValue("");
|
||||
|
||||
await expect(fixture.operations.pickFileForResolve()).resolves.toBe(false);
|
||||
|
||||
expect(fixture.askSelectString).toHaveBeenCalledWith("File to resolve conflict", [
|
||||
"new.md",
|
||||
"middle.md",
|
||||
"old.md",
|
||||
]);
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
expect(fixture.ensureAllProcessed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports conflicted documents and wires the startup popup action", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.findAllDocs.mockImplementation(() =>
|
||||
documents([
|
||||
{ _id: "ordinary-id", path: "ordinary.md", mtime: 30 },
|
||||
{ _id: "first-id", path: "first.md", _conflicts: ["2-first"], mtime: 10 },
|
||||
{ _id: "second-id", path: "second.md", _conflicts: ["2-second"], mtime: 20 },
|
||||
])
|
||||
);
|
||||
|
||||
await expect(fixture.operations.scanStartupIssues()).resolves.toBe(true);
|
||||
|
||||
expect(fixture.askInPopup).toHaveBeenCalledWith(
|
||||
"conflicting-detected-on-safety",
|
||||
expect.stringContaining("Some files have been left conflicted!"),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(fixture.log).toHaveBeenCalledWith("Conflicted: first.md");
|
||||
expect(fixture.log).toHaveBeenCalledWith("Conflicted: second.md");
|
||||
expect(fixture.log).not.toHaveBeenCalledWith("Conflicted: ordinary.md");
|
||||
|
||||
const popupAction = fixture.askInPopup.mock.calls[0]?.[2] as (anchor: HTMLAnchorElement) => void;
|
||||
const addEventListener = vi.fn();
|
||||
const anchor = { text: "", addEventListener } as unknown as HTMLAnchorElement;
|
||||
popupAction(anchor);
|
||||
|
||||
expect(anchor.text).toBe("HERE");
|
||||
expect(addEventListener).toHaveBeenCalledWith("click", expect.any(Function));
|
||||
|
||||
const clickHandler = addEventListener.mock.calls[0]?.[1] as () => void;
|
||||
clickHandler();
|
||||
await vi.waitFor(() =>
|
||||
expect(fixture.askSelectString).toHaveBeenCalledWith("File to resolve conflict", ["second.md", "first.md"])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createFeatureHarness(
|
||||
createDialogueForContext?: (context: ReturnType<typeof createServiceContext>) => ConflictResolveDialogueFactory
|
||||
) {
|
||||
const context = createServiceContext();
|
||||
type FeatureLocalDatabase = {
|
||||
getDBEntry: (...args: unknown[]) => Promise<unknown>;
|
||||
findAllDocs: (...args: unknown[]) => AsyncGenerator<unknown>;
|
||||
};
|
||||
const initialLocalDatabase = {
|
||||
getDBEntry: vi.fn(async () => false),
|
||||
findAllDocs: vi.fn(() => documents([])),
|
||||
};
|
||||
let activeLocalDatabase: FeatureLocalDatabase = initialLocalDatabase;
|
||||
const getLocalDatabase = vi.fn(() => activeLocalDatabase);
|
||||
const replaceLocalDatabase = (replacement: FeatureLocalDatabase) => {
|
||||
activeLocalDatabase = replacement;
|
||||
};
|
||||
const database = {} as { readonly localDatabase: ReturnType<typeof getLocalDatabase> };
|
||||
Object.defineProperty(database, "localDatabase", { get: getLocalDatabase });
|
||||
const handlers = {
|
||||
initialise: undefined as undefined | (() => Promise<boolean>),
|
||||
onUnload: undefined as undefined | (() => Promise<boolean>),
|
||||
scanning: undefined as undefined | (() => Promise<boolean>),
|
||||
unresolvedMessages: undefined as undefined | (() => Promise<string[]>),
|
||||
resolveByUserInteraction: undefined as
|
||||
| undefined
|
||||
| ((filename: FilePathWithPrefix, result: diff_result) => Promise<boolean>),
|
||||
};
|
||||
const getConflictedRevs = vi.fn(async () => ["2-right"]);
|
||||
const services = {
|
||||
API: {
|
||||
addCommand: vi.fn(),
|
||||
addLog: vi.fn(),
|
||||
},
|
||||
UI: {
|
||||
confirm: {
|
||||
askSelectString: vi.fn(async () => ""),
|
||||
askInPopup: vi.fn(),
|
||||
},
|
||||
},
|
||||
appLifecycle: {
|
||||
getUnresolvedMessages: {
|
||||
addHandler: vi.fn((handler: () => Promise<string[]>) => {
|
||||
handlers.unresolvedMessages = handler;
|
||||
}),
|
||||
},
|
||||
onInitialise: {
|
||||
addHandler: vi.fn((handler: () => Promise<boolean>) => {
|
||||
handlers.initialise = handler;
|
||||
}),
|
||||
},
|
||||
onScanningStartupIssues: {
|
||||
addHandler: vi.fn((handler: () => Promise<boolean>) => {
|
||||
handlers.scanning = handler;
|
||||
}),
|
||||
},
|
||||
onUnload: {
|
||||
addHandler: vi.fn((handler: () => Promise<boolean>) => {
|
||||
handlers.onUnload = handler;
|
||||
}),
|
||||
},
|
||||
isSuspended: vi.fn(() => false),
|
||||
},
|
||||
conflict: {
|
||||
resolveByUserInteraction: {
|
||||
addHandler: vi.fn(
|
||||
(handler: (filename: FilePathWithPrefix, result: diff_result) => Promise<boolean>) => {
|
||||
handlers.resolveByUserInteraction = handler;
|
||||
}
|
||||
),
|
||||
},
|
||||
queueCheckFor: vi.fn(async () => undefined),
|
||||
ensureAllProcessed: vi.fn(async () => true),
|
||||
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
|
||||
},
|
||||
replication: {
|
||||
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
vault: { getActiveFilePath: vi.fn(() => path) },
|
||||
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
|
||||
database,
|
||||
setting: { currentSettings: vi.fn(() => ({ ...DEFAULT_SETTINGS, syncAfterMerge: false })) },
|
||||
context,
|
||||
};
|
||||
const serviceModules = {
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs,
|
||||
storeContent: vi.fn(async () => true),
|
||||
},
|
||||
};
|
||||
const createDialogue =
|
||||
createDialogueForContext?.(context) ??
|
||||
vi.fn(() => ({
|
||||
open: vi.fn(),
|
||||
waitForResult: vi.fn(async (): Promise<typeof POSTPONED> => POSTPONED),
|
||||
}));
|
||||
|
||||
useInteractiveConflictResolutionFeature({ services, serviceModules } as never, createDialogue);
|
||||
return {
|
||||
context,
|
||||
createDialogue,
|
||||
getConflictedRevs,
|
||||
getLocalDatabase,
|
||||
handlers,
|
||||
initialLocalDatabase,
|
||||
replaceLocalDatabase,
|
||||
services,
|
||||
};
|
||||
}
|
||||
|
||||
describe("interactive conflict resolution feature composition", () => {
|
||||
it("registers lifecycle, command, conflict, and cancellation handlers", async () => {
|
||||
const fixture = createFeatureHarness();
|
||||
|
||||
expect(fixture.services.appLifecycle.onScanningStartupIssues.addHandler).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.appLifecycle.onInitialise.addHandler).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.appLifecycle.getUnresolvedMessages.addHandler).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.conflict.resolveByUserInteraction.addHandler).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.appLifecycle.onUnload.addHandler).toHaveBeenCalledOnce();
|
||||
expect(fixture.getLocalDatabase).not.toHaveBeenCalled();
|
||||
|
||||
await fixture.handlers.initialise?.();
|
||||
expect(fixture.services.API.addCommand).toHaveBeenCalledTimes(3);
|
||||
expect(fixture.services.API.addCommand).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ id: "livesync-checkdoc-conflicted", name: "Resolve if conflicted." })
|
||||
);
|
||||
expect(fixture.services.API.addCommand).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ id: "livesync-conflictcheck", name: "Pick a file to resolve conflict" })
|
||||
);
|
||||
expect(fixture.services.API.addCommand).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({ id: "livesync-all-conflictcheck", name: "Resolve all conflicted files" })
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes unresolved state through the host context event channel and disposes the listener", async () => {
|
||||
const fixture = createFeatureHarness();
|
||||
|
||||
fixture.context.events.emitEvent(EVENT_CONFLICT_CANCELLED, path);
|
||||
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
|
||||
|
||||
await fixture.handlers.onUnload?.();
|
||||
fixture.getConflictedRevs.mockClear();
|
||||
fixture.context.events.emitEvent(EVENT_CONFLICT_CANCELLED, path);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fixture.getConflictedRevs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the active dialogue and drops waiting dialogues on unload", async () => {
|
||||
const dialogues: ControlledDialogue[] = [];
|
||||
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
|
||||
const resolveByUserInteraction = fixture.handlers.resolveByUserInteraction!;
|
||||
|
||||
const active = resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(dialogues).toHaveLength(1));
|
||||
const waiting = resolveByUserInteraction("waiting.md" as FilePathWithPrefix, conflict);
|
||||
await Promise.resolve();
|
||||
|
||||
fixture.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
|
||||
let completed = false;
|
||||
const allResolutions = Promise.all([active, waiting]).then(() => {
|
||||
completed = true;
|
||||
});
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
||||
const completedOnUnload = completed;
|
||||
|
||||
if (!completedOnUnload) {
|
||||
dialogues[0]?.finish(CANCELLED);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
dialogues[1]?.finish(CANCELLED);
|
||||
await allResolutions;
|
||||
}
|
||||
|
||||
expect(completedOnUnload).toBe(true);
|
||||
expect(dialogues).toHaveLength(1);
|
||||
await fixture.handlers.onUnload?.();
|
||||
});
|
||||
|
||||
it("rejects a resolution request after unload without opening a dialogue", async () => {
|
||||
const dialogues: ControlledDialogue[] = [];
|
||||
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
|
||||
const resolveByUserInteraction = fixture.handlers.resolveByUserInteraction!;
|
||||
|
||||
await fixture.handlers.onUnload?.();
|
||||
await expect(resolveByUserInteraction(path, conflict)).resolves.toBe(false);
|
||||
|
||||
expect(dialogues).toHaveLength(0);
|
||||
expect(fixture.createDialogue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops a waiting dialogue when its conflict is resolved elsewhere", async () => {
|
||||
const dialogues: ControlledDialogue[] = [];
|
||||
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
|
||||
const resolveByUserInteraction = fixture.handlers.resolveByUserInteraction!;
|
||||
const waitingPath = "resolved-while-waiting.md" as FilePathWithPrefix;
|
||||
|
||||
const active = resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(dialogues).toHaveLength(1));
|
||||
const waiting = resolveByUserInteraction(waitingPath, conflict);
|
||||
await Promise.resolve();
|
||||
|
||||
fixture.context.events.emitEvent(EVENT_CONFLICT_CANCELLED, waitingPath);
|
||||
dialogues[0].finish(CANCELLED);
|
||||
let completed = false;
|
||||
const allResolutions = Promise.all([active, waiting]).then(() => {
|
||||
completed = true;
|
||||
});
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
||||
const completedWithoutOpening = completed;
|
||||
|
||||
if (!completedWithoutOpening) {
|
||||
dialogues[1]?.finish(CANCELLED);
|
||||
await allResolutions;
|
||||
}
|
||||
|
||||
expect(completedWithoutOpening).toBe(true);
|
||||
expect(dialogues).toHaveLength(1);
|
||||
await fixture.handlers.onUnload?.();
|
||||
});
|
||||
|
||||
it("uses the replacement local database for operations after a reset", async () => {
|
||||
const fixture = createFeatureHarness();
|
||||
const replacementLocalDatabase = {
|
||||
getDBEntry: vi.fn(async () => false),
|
||||
findAllDocs: vi.fn(() => documents([])),
|
||||
};
|
||||
|
||||
await fixture.handlers.scanning?.();
|
||||
fixture.replaceLocalDatabase(replacementLocalDatabase);
|
||||
await fixture.handlers.scanning?.();
|
||||
|
||||
expect(fixture.initialLocalDatabase.findAllDocs).toHaveBeenCalledOnce();
|
||||
expect(replacementLocalDatabase.findAllDocs).toHaveBeenCalledOnce();
|
||||
expect(fixture.getLocalDatabase).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("routes registered command callbacks to conflict operations", async () => {
|
||||
const fixture = createFeatureHarness();
|
||||
await fixture.handlers.initialise?.();
|
||||
const commands = fixture.services.API.addCommand.mock.calls.map(
|
||||
([command]) => command as Record<string, unknown>
|
||||
);
|
||||
|
||||
(commands[0].editorCallback as (editor: unknown, view: unknown) => void)({}, { file: { path } });
|
||||
await vi.waitFor(() => expect(fixture.services.conflict.ensureAllProcessed).toHaveBeenCalledOnce());
|
||||
await (commands[1].callback as () => Promise<void>)();
|
||||
await (commands[2].callback as () => Promise<void>)();
|
||||
|
||||
expect(fixture.services.conflict.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
expect(fixture.getLocalDatabase).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reads the host's current sync setting after a composed resolution", async () => {
|
||||
const fixture = createFeatureHarness(() => () => ({
|
||||
open: vi.fn(),
|
||||
waitForResult: vi.fn(async (): Promise<MergeDialogResult> => "2-right"),
|
||||
}));
|
||||
fixture.replaceLocalDatabase({
|
||||
getDBEntry: vi.fn(async () => ({ _rev: "2-left", _conflicts: ["2-right"] })),
|
||||
findAllDocs: vi.fn(() => documents([])),
|
||||
});
|
||||
fixture.services.setting.currentSettings.mockReturnValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
syncAfterMerge: true,
|
||||
});
|
||||
|
||||
await fixture.handlers.resolveByUserInteraction?.(path, conflict);
|
||||
|
||||
expect(fixture.services.setting.currentSettings).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.replication.replicateUnattendedByEvent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does nothing when the editor command has no active file", async () => {
|
||||
const fixture = createFeatureHarness();
|
||||
await fixture.handlers.initialise?.();
|
||||
const command = fixture.services.API.addCommand.mock.calls[0]?.[0] as {
|
||||
editorCallback: (editor: unknown, view: { file: null }) => void;
|
||||
};
|
||||
|
||||
command.editorCallback({}, { file: null });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fixture.services.conflict.queueCheckFor).not.toHaveBeenCalled();
|
||||
expect(fixture.services.conflict.ensureAllProcessed).not.toHaveBeenCalled();
|
||||
expect(fixture.createDialogue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a failed startup scan without escaping the lifecycle handler", async () => {
|
||||
const fixture = createFeatureHarness();
|
||||
async function* failedDocuments() {
|
||||
throw new Error("database unavailable");
|
||||
}
|
||||
fixture.replaceLocalDatabase({
|
||||
getDBEntry: vi.fn(async () => false),
|
||||
findAllDocs: vi.fn(() => failedDocuments()),
|
||||
});
|
||||
|
||||
await expect(fixture.handlers.scanning?.()).resolves.toBe(false);
|
||||
expect(fixture.services.API.addLog).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
import {
|
||||
CANCELLED,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
MISSING_OR_ERROR,
|
||||
type DocumentID,
|
||||
type FilePathWithPrefix,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type diff_result,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
|
||||
import type {
|
||||
IAppLifecycleService,
|
||||
IConflictService,
|
||||
IPathService,
|
||||
IReplicationService,
|
||||
IVaultService,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
|
||||
import type { LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR } from "@/common/events.ts";
|
||||
import { $msg } from "@/common/translation.ts";
|
||||
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import {
|
||||
POSTPONED,
|
||||
type ConflictResolveDialogue,
|
||||
type ConflictResolveDialogueFactory,
|
||||
type MergeDialogResult,
|
||||
} from "./types";
|
||||
|
||||
export interface InteractiveConflictResolutionOperationsDependencies {
|
||||
readonly events: Pick<LiveSyncEventHub, "emitEvent">;
|
||||
readonly databaseFileAccess: Pick<DatabaseFileAccess, "getConflictedRevs" | "storeContent">;
|
||||
readonly localDatabase: () => Pick<LiveSyncLocalDB, "getDBEntry" | "findAllDocs">;
|
||||
readonly confirm: Pick<Confirm, "askSelectString" | "askInPopup">;
|
||||
readonly path: Pick<IPathService, "getPath">;
|
||||
readonly vault: Pick<IVaultService, "getActiveFilePath">;
|
||||
readonly appLifecycle: Pick<IAppLifecycleService, "isSuspended">;
|
||||
readonly conflict: Pick<IConflictService, "queueCheckFor" | "ensureAllProcessed" | "resolveByDeletingRevision">;
|
||||
readonly replication: Pick<IReplicationService, "replicateUnattendedByEvent">;
|
||||
readonly currentSettings: () => Pick<ObsidianLiveSyncSettings, "syncAfterMerge">;
|
||||
readonly createDialogue: ConflictResolveDialogueFactory;
|
||||
readonly log: LogFunction;
|
||||
}
|
||||
|
||||
export interface InteractiveConflictResolutionOperations {
|
||||
readonly dispose: () => void;
|
||||
readonly invalidateWaitingResolution: (filename: FilePathWithPrefix) => void;
|
||||
readonly getActiveConflictMessages: () => Promise<string[]>;
|
||||
readonly refreshConflictState: (filename: FilePathWithPrefix) => Promise<void>;
|
||||
readonly requestConflictResolution: (filename: FilePathWithPrefix) => Promise<void>;
|
||||
readonly resolveByUserInteraction: (
|
||||
filename: FilePathWithPrefix,
|
||||
conflictCheckResult: diff_result
|
||||
) => Promise<boolean>;
|
||||
readonly allConflictCheck: () => Promise<void>;
|
||||
readonly pickFileForResolve: (notifyIfEmpty?: boolean) => Promise<boolean>;
|
||||
readonly scanStartupIssues: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function createInteractiveConflictResolutionOperations(
|
||||
dependencies: InteractiveConflictResolutionOperationsDependencies
|
||||
): InteractiveConflictResolutionOperations {
|
||||
// This state deliberately belongs to one feature composition. It must not
|
||||
// survive a plug-in unload or be shared with another host context.
|
||||
const postponedConflictEpisodes = new Set<FilePathWithPrefix>();
|
||||
const dialogueSerialisationKey = Symbol("conflict-resolve-ui");
|
||||
const latestRequestByFilename = new Map<FilePathWithPrefix, number>();
|
||||
let nextRequestId = 0;
|
||||
let activeDialogue: { filename: FilePathWithPrefix; dialogue: ConflictResolveDialogue } | undefined;
|
||||
let disposed = false;
|
||||
|
||||
const invalidateWaitingResolution = (filename: FilePathWithPrefix): void => {
|
||||
// An active dialogue consumes this event itself. Removing its request
|
||||
// here would also remove a same-path replacement which emitted the
|
||||
// event to close that active dialogue. A non-active request is stale
|
||||
// and must not open after an external resolution.
|
||||
if (activeDialogue?.filename === filename) return;
|
||||
latestRequestByFilename.delete(filename);
|
||||
};
|
||||
|
||||
const dispose = (): void => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
latestRequestByFilename.clear();
|
||||
postponedConflictEpisodes.clear();
|
||||
const activeFilename = activeDialogue?.filename;
|
||||
if (activeFilename !== undefined) {
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, activeFilename);
|
||||
activeDialogue = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getConflictVersionCount = async (filename: FilePathWithPrefix): Promise<number | undefined> => {
|
||||
try {
|
||||
const conflictCount = (await dependencies.databaseFileAccess.getConflictedRevs(filename)).length;
|
||||
return conflictCount === 0 ? 0 : conflictCount + 1;
|
||||
} catch (error) {
|
||||
dependencies.log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
dependencies.log(error, LOG_LEVEL_VERBOSE);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getActiveConflictMessages = async (): Promise<string[]> => {
|
||||
const filename = dependencies.vault.getActiveFilePath();
|
||||
if (!filename) return [];
|
||||
const versionCount = await getConflictVersionCount(filename);
|
||||
if (versionCount === 0) {
|
||||
postponedConflictEpisodes.delete(filename);
|
||||
return [];
|
||||
}
|
||||
if (versionCount !== undefined && versionCount >= 3) {
|
||||
return [
|
||||
$msg("This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", {
|
||||
COUNT: `${versionCount}`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (versionCount === 2 || postponedConflictEpisodes.has(filename)) {
|
||||
return [$msg("This file has unresolved conflicts.")];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const refreshConflictState = async (filename: FilePathWithPrefix): Promise<void> => {
|
||||
if ((await getConflictVersionCount(filename)) === 0) {
|
||||
postponedConflictEpisodes.delete(filename);
|
||||
}
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
};
|
||||
|
||||
const requestConflictResolution = async (filename: FilePathWithPrefix): Promise<void> => {
|
||||
postponedConflictEpisodes.delete(filename);
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
await dependencies.conflict.ensureAllProcessed();
|
||||
};
|
||||
|
||||
const resolveByUserInteraction = async (
|
||||
filename: FilePathWithPrefix,
|
||||
conflictCheckResult: diff_result
|
||||
): Promise<boolean> => {
|
||||
if (disposed) return false;
|
||||
const requestId = ++nextRequestId;
|
||||
if (activeDialogue?.filename === filename) {
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
activeDialogue = undefined;
|
||||
}
|
||||
latestRequestByFilename.set(filename, requestId);
|
||||
|
||||
// UI for resolving different files should proceed one-by-one. A newer
|
||||
// request for the active file replaces its dialogue instead of waiting
|
||||
// behind a comparison which is already stale.
|
||||
return await serialized(dialogueSerialisationKey, async () => {
|
||||
if (disposed || latestRequestByFilename.get(filename) !== requestId) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (postponedConflictEpisodes.has(filename)) {
|
||||
dependencies.log(`Merge: Postponed ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
return false;
|
||||
}
|
||||
dependencies.log("Merge:open conflict dialog", LOG_LEVEL_VERBOSE);
|
||||
const dialogue = dependencies.createDialogue(filename, conflictCheckResult);
|
||||
activeDialogue = { filename, dialogue };
|
||||
let selected: MergeDialogResult;
|
||||
try {
|
||||
dialogue.open();
|
||||
selected = await dialogue.waitForResult();
|
||||
} finally {
|
||||
if (activeDialogue?.dialogue === dialogue) {
|
||||
activeDialogue = undefined;
|
||||
}
|
||||
}
|
||||
if (selected === POSTPONED) {
|
||||
postponedConflictEpisodes.add(filename);
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
dependencies.log(`Merge: Postponed ${filename}`, LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
if (selected === CANCELLED) {
|
||||
// Cancelled by UI, or another conflict.
|
||||
dependencies.log(`Merge: Cancelled ${filename}`, LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
const testDoc = await dependencies
|
||||
.localDatabase()
|
||||
.getDBEntry(filename, { conflicts: true }, false, true, true);
|
||||
if (testDoc === false) {
|
||||
dependencies.log(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
if (!testDoc._conflicts || testDoc._conflicts.length === 0) {
|
||||
dependencies.log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
await refreshConflictState(filename);
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
testDoc._rev !== conflictCheckResult.left.rev ||
|
||||
!testDoc._conflicts.includes(conflictCheckResult.right.rev)
|
||||
) {
|
||||
dependencies.log(
|
||||
`Merge: The compared revisions changed while the dialogue was open: ${filename}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
await refreshConflictState(filename);
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
return false;
|
||||
}
|
||||
const toDelete = selected;
|
||||
// const toKeep = conflictCheckResult.left.rev != toDelete ? conflictCheckResult.left.rev : conflictCheckResult.right.rev;
|
||||
if (toDelete === LEAVE_TO_SUBSEQUENT) {
|
||||
// Concatenate both conflicted revisions.
|
||||
// Create a new file by concatenating both conflicted revisions.
|
||||
const p = conflictCheckResult.diff.map((e) => e[1]).join("");
|
||||
const delRev = conflictCheckResult.right.rev;
|
||||
if (!(await dependencies.databaseFileAccess.storeContent(filename, p))) {
|
||||
dependencies.log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
|
||||
if (
|
||||
(await dependencies.conflict.resolveByDeletingRevision(filename, delRev, "UI Concatenated")) ==
|
||||
MISSING_OR_ERROR
|
||||
) {
|
||||
dependencies.log(
|
||||
`Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} else if (
|
||||
typeof toDelete === "string" &&
|
||||
(toDelete === conflictCheckResult.left.rev || toDelete === conflictCheckResult.right.rev)
|
||||
) {
|
||||
// Select one of the conflicted revision to delete.
|
||||
if (
|
||||
(await dependencies.conflict.resolveByDeletingRevision(filename, toDelete, "UI Selected")) ==
|
||||
MISSING_OR_ERROR
|
||||
) {
|
||||
dependencies.log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
dependencies.log(
|
||||
`Merge: Something went wrong: ${filename}, (${String(toDelete)})`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// In here, some merge has been processed.
|
||||
// So we have to run replication if configured.
|
||||
// TODO: Make this is as a event request
|
||||
if (dependencies.currentSettings().syncAfterMerge && !dependencies.appLifecycle.isSuspended()) {
|
||||
await dependencies.replication.replicateUnattendedByEvent({
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
// And, check it again.
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
return false;
|
||||
} finally {
|
||||
if (latestRequestByFilename.get(filename) === requestId) {
|
||||
latestRequestByFilename.delete(filename);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const pickFileForResolve = async (notifyIfEmpty = true): Promise<boolean> => {
|
||||
const notes: { id: DocumentID; path: FilePathWithPrefix; dispPath: string; mtime: number }[] = [];
|
||||
for await (const doc of dependencies.localDatabase().findAllDocs({ conflicts: true })) {
|
||||
if (!("_conflicts" in doc)) continue;
|
||||
notes.push({
|
||||
id: doc._id,
|
||||
path: dependencies.path.getPath(doc),
|
||||
dispPath: stripAllPrefixes(dependencies.path.getPath(doc)),
|
||||
mtime: doc.mtime,
|
||||
});
|
||||
}
|
||||
notes.sort((a, b) => b.mtime - a.mtime);
|
||||
const notesList = notes.map((e) => e.dispPath);
|
||||
if (notesList.length == 0) {
|
||||
if (notifyIfEmpty) {
|
||||
dependencies.log("There are no conflicted documents", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const target = await dependencies.confirm.askSelectString("File to resolve conflict", notesList);
|
||||
if (target) {
|
||||
const targetItem = notes.find((e) => e.dispPath == target)!;
|
||||
await requestConflictResolution(targetItem.path);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const allConflictCheck = async (): Promise<void> => {
|
||||
let notifyIfEmpty = true;
|
||||
while (await pickFileForResolve(notifyIfEmpty)) {
|
||||
notifyIfEmpty = false;
|
||||
}
|
||||
};
|
||||
|
||||
const scanStartupIssues = async (): Promise<boolean> => {
|
||||
const notes: { path: string; mtime: number }[] = [];
|
||||
dependencies.log(`Checking conflicted files`, LOG_LEVEL_VERBOSE);
|
||||
try {
|
||||
for await (const doc of dependencies.localDatabase().findAllDocs({ conflicts: true })) {
|
||||
if (!("_conflicts" in doc)) continue;
|
||||
notes.push({ path: dependencies.path.getPath(doc), mtime: doc.mtime });
|
||||
}
|
||||
if (notes.length > 0) {
|
||||
dependencies.confirm.askInPopup(
|
||||
`conflicting-detected-on-safety`,
|
||||
`Some files have been left conflicted! Press {HERE} to resolve them, or you can do it later by "Pick a file to resolve conflict`,
|
||||
(anchor) => {
|
||||
anchor.text = "HERE";
|
||||
anchor.addEventListener("click", () => {
|
||||
fireAndForget(() => allConflictCheck());
|
||||
});
|
||||
}
|
||||
);
|
||||
dependencies.log(
|
||||
`Some files have been left conflicted! Please resolve them by "Pick a file to resolve conflict". The list is written in the log.`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
for (const note of notes) {
|
||||
dependencies.log(`Conflicted: ${note.path}`);
|
||||
}
|
||||
} else {
|
||||
dependencies.log(`There are no conflicting files`, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
} catch (error) {
|
||||
dependencies.log(`Error while scanning conflicted files...`, LOG_LEVEL_NOTICE);
|
||||
dependencies.log(error, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
dispose,
|
||||
invalidateWaitingResolution,
|
||||
getActiveConflictMessages,
|
||||
refreshConflictState,
|
||||
requestConflictResolution,
|
||||
resolveByUserInteraction,
|
||||
allConflictCheck,
|
||||
pickFileForResolve,
|
||||
scanStartupIssues,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
CANCELLED,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
type diff_result,
|
||||
type FilePathWithPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
export const POSTPONED = Symbol("postponed");
|
||||
|
||||
export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string;
|
||||
|
||||
export interface ConflictResolveDialogue {
|
||||
open(): void;
|
||||
waitForResult(): Promise<MergeDialogResult>;
|
||||
}
|
||||
|
||||
export type ConflictResolveDialogueFactory = (
|
||||
filename: FilePathWithPrefix,
|
||||
conflictCheckResult: diff_result
|
||||
) => ConflictResolveDialogue;
|
||||
+22
-18
@@ -1,27 +1,35 @@
|
||||
import { addIcon } from "@/deps.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
// Obsidian specific menu commands.
|
||||
export class ModuleObsidianMenu extends AbstractModule {
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
// UI
|
||||
addIcon(
|
||||
"replicate",
|
||||
`<g transform="matrix(1.15 0 0 1.15 -8.31 -9.52)" fill="currentColor" fill-rule="evenodd">
|
||||
|
||||
export type ObsidianReplicationRibbonHost = NecessaryServices<"API" | "appLifecycle" | "replication", never>;
|
||||
|
||||
/** The established SVG used by the Obsidian replication ribbon action. */
|
||||
const REPLICATE_ICON_SVG = `<g transform="matrix(1.15 0 0 1.15 -8.31 -9.52)" fill="currentColor" fill-rule="evenodd">
|
||||
<path d="m85 22.2c-0.799-4.74-4.99-8.37-9.88-8.37-0.499 0-1.1 0.101-1.6 0.101-2.4-3.03-6.09-4.94-10.3-4.94-6.09 0-11.2 4.14-12.8 9.79-5.59 1.11-9.78 6.05-9.78 12 0 6.76 5.39 12.2 12 12.2h29.9c5.79 0 10.1-4.74 10.1-10.6 0-4.84-3.29-8.88-7.68-10.2zm-2.99 14.7h-29.5c-2.3-0.202-4.29-1.51-5.29-3.53-0.899-2.12-0.699-4.54 0.698-6.46 1.2-1.61 2.99-2.52 4.89-2.52 0.299 0 0.698 0 0.998 0.101l1.8 0.303v-2.02c0-3.63 2.4-6.76 5.89-7.57 0.599-0.101 1.2-0.202 1.8-0.202 2.89 0 5.49 1.62 6.79 4.24l0.598 1.21 1.3-0.504c0.599-0.202 1.3-0.303 2-0.303 1.3 0 2.5 0.404 3.59 1.11 1.6 1.21 2.6 3.13 2.6 5.15v1.61h2c2.6 0 4.69 2.12 4.69 4.74-0.099 2.52-2.2 4.64-4.79 4.64z"/>
|
||||
<path d="m53.2 49.2h-41.6c-1.8 0-3.2 1.4-3.2 3.2v28.6c0 1.8 1.4 3.2 3.2 3.2h15.8v4h-7v6h24v-6h-7v-4h15.8c1.8 0 3.2-1.4 3.2-3.2v-28.6c0-1.8-1.4-3.2-3.2-3.2zm-2.8 29h-36v-23h36z"/>
|
||||
<path d="m73 49.2c1.02 1.29 1.53 2.97 1.53 4.56 0 2.97-1.74 5.65-4.39 7.04v-4.06l-7.46 7.33 7.46 7.14v-4.06c7.66-1.98 12.2-9.61 10-17-0.102-0.297-0.205-0.595-0.307-0.892z"/>
|
||||
<path d="m24.1 43c-0.817-0.991-1.53-2.97-1.53-4.56 0-2.97 1.74-5.65 4.39-7.04v4.06l7.46-7.33-7.46-7.14v4.06c-7.66 1.98-12.2 9.61-10 17 0.102 0.297 0.205 0.595 0.307 0.892z"/>
|
||||
</g>`
|
||||
);
|
||||
</g>`;
|
||||
|
||||
this.addRibbonIcon("replicate", $msg("moduleObsidianMenu.replicate"), async () => {
|
||||
await this.services.replication.replicateUserInitiated({
|
||||
/**
|
||||
* Register the Obsidian-only replication ribbon action.
|
||||
*
|
||||
* The icon and ribbon element are intentionally kept out of the generic Basic
|
||||
* commands feature; other hosts can compose the latter without Obsidian UI.
|
||||
*/
|
||||
export function useObsidianReplicationRibbonFeature(host: ObsidianReplicationRibbonHost): void {
|
||||
const { services } = host;
|
||||
|
||||
services.appLifecycle.onInitialise.addHandler(() => {
|
||||
addIcon("replicate", REPLICATE_ICON_SVG);
|
||||
|
||||
services.API.addRibbonIcon("replicate", $msg("moduleObsidianMenu.replicate"), async () => {
|
||||
await services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
@@ -29,9 +37,5 @@ export class ModuleObsidianMenu extends AbstractModule {
|
||||
}).addClass("livesync-ribbon-replicate");
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const addIcon = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/deps.ts", () => ({ addIcon }));
|
||||
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { useObsidianReplicationRibbonFeature, type ObsidianReplicationRibbonHost } from "./obsidianReplicationRibbon";
|
||||
|
||||
describe("useObsidianReplicationRibbonFeature", () => {
|
||||
it("registers the established icon and ribbon callback during initialisation", async () => {
|
||||
let initialise: (() => Promise<unknown>) | undefined;
|
||||
let ribbonCallback: (() => Promise<void>) | undefined;
|
||||
const addClass = vi.fn();
|
||||
const replicateUserInitiated = vi.fn(async () => ({ status: "completed" as const }));
|
||||
const addRibbonIcon = vi.fn((_icon: string, _title: string, callback: () => Promise<void>) => {
|
||||
ribbonCallback = callback;
|
||||
return { addClass } as unknown as HTMLElement;
|
||||
});
|
||||
const host = {
|
||||
services: {
|
||||
API: { addRibbonIcon },
|
||||
appLifecycle: {
|
||||
onInitialise: {
|
||||
addHandler: vi.fn((handler: () => Promise<unknown>) => {
|
||||
initialise = handler;
|
||||
}),
|
||||
},
|
||||
},
|
||||
replication: { replicateUserInitiated },
|
||||
},
|
||||
} as unknown as ObsidianReplicationRibbonHost;
|
||||
|
||||
useObsidianReplicationRibbonFeature(host);
|
||||
|
||||
expect(addIcon).not.toHaveBeenCalled();
|
||||
expect(addRibbonIcon).not.toHaveBeenCalled();
|
||||
|
||||
await expect(initialise?.()).resolves.toBe(true);
|
||||
|
||||
expect(addIcon).toHaveBeenCalledWith("replicate", expect.any(String));
|
||||
expect(addIcon).toHaveBeenCalledWith("replicate", expect.stringContaining("c-7.66 1.98-12.2 9.61-10 17"));
|
||||
expect(addRibbonIcon).toHaveBeenCalledWith(
|
||||
"replicate",
|
||||
$msg("moduleObsidianMenu.replicate"),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(addClass).toHaveBeenCalledWith("livesync-ribbon-replicate");
|
||||
|
||||
await ribbonCallback?.();
|
||||
expect(replicateUserInitiated).toHaveBeenCalledWith({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||
import type PouchDB from "pouchdb-core";
|
||||
import { promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { $msg } from "@/common/translation";
|
||||
|
||||
const KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT = "replicationResultProcessorSnapshot";
|
||||
const REPROCESS_BATCH_SIZE = 100;
|
||||
@@ -79,6 +80,14 @@ export class ReplicateResultProcessor {
|
||||
private logError(e: unknown) {
|
||||
Logger(e, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
private reportVaultReflectionFailure(entry: MetaEntry, cause?: unknown) {
|
||||
this.log(
|
||||
`Live replication could not reflect ${this.getPath(entry)} from the local database to the Vault; this path remains eligible for a later Vault scan.`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
if (cause !== undefined) this.logError(cause);
|
||||
Logger($msg("Ui.Common.SomeFilesCouldNotBeSynchronised"), LOG_LEVEL_NOTICE);
|
||||
}
|
||||
constructor(private readonly context: ReplicateResultProcessorContext) {}
|
||||
|
||||
private get localDatabase() {
|
||||
@@ -510,8 +519,16 @@ export class ReplicateResultProcessor {
|
||||
this.log(`Processed by other processor: ${docNote}`, LOG_LEVEL_DEBUG);
|
||||
} else if (this.services.vault.isValidPath(this.getPath(doc))) {
|
||||
// Apply to storage if the path is valid
|
||||
await this.applyToStorage(doc as MetaEntry);
|
||||
this.log(`Processed: ${docNote}`, LOG_LEVEL_DEBUG);
|
||||
try {
|
||||
const reflected = await this.applyToStorage(doc as MetaEntry);
|
||||
if (!reflected) {
|
||||
this.reportVaultReflectionFailure(doc as MetaEntry);
|
||||
return;
|
||||
}
|
||||
this.log(`Processed: ${docNote}`, LOG_LEVEL_DEBUG);
|
||||
} catch (error) {
|
||||
this.reportVaultReflectionFailure(doc as MetaEntry, error);
|
||||
}
|
||||
} else {
|
||||
// Should process, but have an invalid path
|
||||
this.log(`Unprocessed (Invalid path): ${docNote}`, LOG_LEVEL_VERBOSE);
|
||||
@@ -525,9 +542,10 @@ export class ReplicateResultProcessor {
|
||||
* @returns
|
||||
*/
|
||||
protected applyToStorage(entry: MetaEntry) {
|
||||
return this.withCounting(async () => {
|
||||
await this.services.replication.processSynchroniseResult(entry);
|
||||
}, this.services.replication.storageApplyingCount);
|
||||
return this.withCounting(
|
||||
() => this.services.replication.processSynchroniseResult(entry),
|
||||
this.services.replication.storageApplyingCount
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,13 @@ import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { VER, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
defaultLogger,
|
||||
LOG_LEVEL_DEBUG,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
setGlobalLogFunction,
|
||||
} from "octagonal-wheels/common/logger";
|
||||
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
|
||||
|
||||
function note(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
|
||||
@@ -21,12 +28,12 @@ function note(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
|
||||
|
||||
type SetupOptions = {
|
||||
applicationReady?: boolean;
|
||||
processSynchroniseResult?: (entry: unknown) => Promise<void>;
|
||||
processSynchroniseResult?: (entry: unknown) => Promise<boolean>;
|
||||
setSnapshot?: (key: string, value: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
function setup(options: SetupOptions = {}) {
|
||||
const processSynchroniseResult = vi.fn(options.processSynchroniseResult ?? (async () => undefined));
|
||||
const processSynchroniseResult = vi.fn(options.processSynchroniseResult ?? (async () => true));
|
||||
const setSnapshot = vi.fn(options.setSnapshot ?? (async () => undefined));
|
||||
const runBoundedLocalApplicationActivity = vi.fn(async (task: () => Promise<void>) => await task());
|
||||
const onCloseActiveReplication = vi.fn(async () => true);
|
||||
@@ -120,7 +127,7 @@ describe("ReplicateResultProcessor", () => {
|
||||
});
|
||||
|
||||
it("keeps one local application activity until every replicated document has been applied", async () => {
|
||||
const applying = promiseWithResolvers<void>();
|
||||
const applying = promiseWithResolvers<boolean>();
|
||||
let activityFinished = false;
|
||||
const { processor, processSynchroniseResult, runBoundedLocalApplicationActivity } = setup({
|
||||
processSynchroniseResult: async () => applying.promise,
|
||||
@@ -139,7 +146,7 @@ describe("ReplicateResultProcessor", () => {
|
||||
});
|
||||
expect(activityFinished).toBe(false);
|
||||
|
||||
applying.resolve();
|
||||
applying.resolve(true);
|
||||
|
||||
await vi.waitFor(() => expect(activityFinished).toBe(true));
|
||||
});
|
||||
@@ -160,7 +167,7 @@ describe("ReplicateResultProcessor", () => {
|
||||
});
|
||||
|
||||
it("releases and reacquires local application activity around processing suspension", async () => {
|
||||
const applying = promiseWithResolvers<void>();
|
||||
const applying = promiseWithResolvers<boolean>();
|
||||
let completedActivities = 0;
|
||||
const { processor, processSynchroniseResult, runBoundedLocalApplicationActivity } = setup({
|
||||
processSynchroniseResult: async () => applying.promise,
|
||||
@@ -178,7 +185,47 @@ describe("ReplicateResultProcessor", () => {
|
||||
processor.resume();
|
||||
await vi.waitFor(() => expect(runBoundedLocalApplicationActivity).toHaveBeenCalledTimes(2));
|
||||
|
||||
applying.resolve();
|
||||
applying.resolve(true);
|
||||
await vi.waitFor(() => expect(completedActivities).toBe(2));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["returns false", async () => false, undefined],
|
||||
["throws", async () => Promise.reject(new Error("File name too long")), "File name too long"],
|
||||
])("reports when Vault reflection %s", async (_description, processSynchroniseResult, errorMessage) => {
|
||||
const log = vi.fn((_message: unknown, _level?: number) => undefined);
|
||||
setGlobalLogFunction(log);
|
||||
try {
|
||||
const { processor } = setup({ processSynchroniseResult });
|
||||
|
||||
processor.enqueueAll([note("unreflectable")]);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
"Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.",
|
||||
LOG_LEVEL_NOTICE,
|
||||
undefined
|
||||
)
|
||||
);
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
"[ReplicateResultProcessor] Live replication could not reflect unreflectable.md from the local database to the Vault; this path remains eligible for a later Vault scan.",
|
||||
LOG_LEVEL_VERBOSE,
|
||||
undefined
|
||||
);
|
||||
if (errorMessage !== undefined) {
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: errorMessage }),
|
||||
LOG_LEVEL_VERBOSE,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
expect(log).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("Processed: unreflectable.md"),
|
||||
LOG_LEVEL_DEBUG,
|
||||
undefined
|
||||
);
|
||||
} finally {
|
||||
setGlobalLogFunction(defaultLogger);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,10 @@ const taskMocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("octagonal-wheels/concurrency/task", () => taskMocks);
|
||||
|
||||
import { ModuleConflictResolver } from "@/modules/coreFeatures/ModuleConflictResolver";
|
||||
import {
|
||||
createConflictResolutionOperations,
|
||||
type ConflictResolutionOperationsDependencies,
|
||||
} from "@/serviceFeatures/conflictResolution";
|
||||
import { ModuleObsidianEvents } from "@/modules/essentialObsidian/ModuleObsidianEvents";
|
||||
import {
|
||||
createReplicationSchedulingContext,
|
||||
@@ -224,18 +227,34 @@ describe("automatic replication triggers while P2P is active", () => {
|
||||
const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const }));
|
||||
const queueCheckFor = vi.fn(async () => undefined);
|
||||
const path = "merged.md" as FilePathWithPrefix;
|
||||
const module = {
|
||||
settings: p2pSettings({ syncAfterMerge: true }),
|
||||
services: {
|
||||
appLifecycle: { isSuspended: vi.fn(() => false) },
|
||||
conflict: { queueCheckFor },
|
||||
replication: { replicateUnattendedByEvent },
|
||||
const operations = createConflictResolutionOperations({
|
||||
events: { emitEvent: vi.fn() },
|
||||
databaseFileAccess: {
|
||||
fetchEntryMeta: vi.fn(),
|
||||
getConflictedRevs: vi.fn(async () => []),
|
||||
storeContent: vi.fn(async () => true),
|
||||
},
|
||||
checkConflictAndPerformAutoMerge: vi.fn(async () => AUTO_MERGED),
|
||||
_log: vi.fn(),
|
||||
};
|
||||
fileHandler: {
|
||||
deleteRevisionFromDB: vi.fn(async () => true),
|
||||
dbToStorage: vi.fn(async () => true),
|
||||
},
|
||||
localDatabase: () => ({
|
||||
tryAutoMerge: vi.fn(async () => ({ ok: AUTO_MERGED })),
|
||||
}),
|
||||
conflict: {
|
||||
queueCheckFor,
|
||||
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
|
||||
resolveByUserInteraction: vi.fn(async () => false),
|
||||
},
|
||||
replication: { replicateUnattendedByEvent },
|
||||
appLifecycle: { isSuspended: vi.fn(() => false) },
|
||||
vault: { getActiveFilePath: vi.fn(() => undefined) },
|
||||
storageAccess: { getFileNames: vi.fn(async () => []) },
|
||||
currentSettings: () => p2pSettings({ syncAfterMerge: true }),
|
||||
log: vi.fn(),
|
||||
} as unknown as ConflictResolutionOperationsDependencies);
|
||||
|
||||
await (ModuleConflictResolver.prototype as any)._resolveConflict.call(module, path);
|
||||
await operations.resolve(path);
|
||||
|
||||
expect(replicateUnattendedByEvent).toHaveBeenCalledOnce();
|
||||
expect(replicateUnattendedByEvent).toHaveBeenCalledWith({
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { disableLegacyBulkChunkPreSend } from "@/common/compatibilitySettings";
|
||||
import type { LegacyBulkSendSettings } from "./types";
|
||||
|
||||
/** Collaborators required to persist the obsolete bulk-send setting migration. */
|
||||
export interface BulkSettingMigrationDependencies {
|
||||
readonly settings: LegacyBulkSendSettings;
|
||||
readonly log: LogFunction;
|
||||
readonly saveSettings: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the removed automatic bulk chunk pre-send setting, retaining the
|
||||
* former notice text and persistence boundary.
|
||||
*/
|
||||
export async function migrateBulkSendSetting(dependencies: BulkSettingMigrationDependencies): Promise<void> {
|
||||
if (disableLegacyBulkChunkPreSend(dependencies.settings)) {
|
||||
dependencies.log($msg("moduleMigration.logBulkSendCorrupted"), LOG_LEVEL_NOTICE);
|
||||
await dependencies.saveSettings();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { migrateBulkSendSetting, type BulkSettingMigrationDependencies } from "./bulkSettingMigration";
|
||||
|
||||
function createDependencies(settings: { sendChunksBulk: boolean; sendChunksBulkMaxSize: number }) {
|
||||
const dependencies: BulkSettingMigrationDependencies = {
|
||||
settings,
|
||||
log: vi.fn(),
|
||||
saveSettings: vi.fn(async () => undefined),
|
||||
};
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
describe("migrateBulkSendSetting", () => {
|
||||
it("disables and persists an enabled obsolete bulk-send setting", async () => {
|
||||
const dependencies = createDependencies({ sendChunksBulk: true, sendChunksBulkMaxSize: 16 });
|
||||
|
||||
await migrateBulkSendSetting(dependencies);
|
||||
|
||||
expect(dependencies.settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 1 });
|
||||
expect(dependencies.log).toHaveBeenCalledWith(expect.any(String), expect.anything());
|
||||
expect(dependencies.saveSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not persist an already disabled obsolete setting", async () => {
|
||||
const dependencies = createDependencies({ sendChunksBulk: false, sendChunksBulkMaxSize: 16 });
|
||||
|
||||
await migrateBulkSendSetting(dependencies);
|
||||
|
||||
expect(dependencies.settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 16 });
|
||||
expect(dependencies.log).not.toHaveBeenCalled();
|
||||
expect(dependencies.saveSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
Logger,
|
||||
type LOG_LEVEL,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { countCompromisedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
|
||||
import type { Rebuilder } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseRebuilder";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import { $msg } from "@/common/translation";
|
||||
|
||||
interface CompromisedChunkCounter {
|
||||
countCompromisedChunks(): Promise<number | boolean>;
|
||||
}
|
||||
|
||||
/** Focused collaborators for checking and recovering insecure chunks. */
|
||||
export interface CompromisedChunksDependencies {
|
||||
readonly settings: Pick<ObsidianLiveSyncSettings, "encrypt">;
|
||||
readonly localDatabase: {
|
||||
readonly localDatabase: Parameters<typeof countCompromisedChunks>[0];
|
||||
};
|
||||
readonly isOnline: boolean | (() => boolean);
|
||||
readonly getActiveReplicator: () => object | undefined;
|
||||
readonly confirm: Pick<Confirm, "askSelectStringDialogue">;
|
||||
readonly rebuilder: Pick<Rebuilder, "scheduleRebuild" | "scheduleFetch">;
|
||||
readonly performRestart: () => void;
|
||||
readonly log: (message: unknown, level?: LOG_LEVEL) => void;
|
||||
}
|
||||
|
||||
function hasCompromisedChunkCounter(value: object | undefined): value is CompromisedChunkCounter {
|
||||
return (
|
||||
value !== undefined && "countCompromisedChunks" in value && typeof value.countCompromisedChunks === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function readOnline(value: boolean | (() => boolean)): boolean {
|
||||
return typeof value === "function" ? value() : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check local and active-remote databases for insecure chunks and apply the
|
||||
* former rebuild, fetch, or dismiss dialogue semantics.
|
||||
*/
|
||||
export async function checkCompromisedChunks(dependencies: CompromisedChunksDependencies): Promise<boolean> {
|
||||
Logger(`Checking for compromised chunks...`, LOG_LEVEL_VERBOSE);
|
||||
if (!dependencies.settings.encrypt) {
|
||||
// If not encrypted, we do not need to check for compromised chunks.
|
||||
return true;
|
||||
}
|
||||
// Check local database for compromised chunks
|
||||
const localCompromised = await countCompromisedChunks(dependencies.localDatabase.localDatabase);
|
||||
const remote = dependencies.getActiveReplicator();
|
||||
const remoteCompromised =
|
||||
readOnline(dependencies.isOnline) && hasCompromisedChunkCounter(remote)
|
||||
? await remote.countCompromisedChunks()
|
||||
: 0;
|
||||
if (localCompromised === false) {
|
||||
Logger(`Failed to count compromised chunks in local database`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (remoteCompromised === false) {
|
||||
Logger(`Failed to count compromised chunks in remote database`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (remoteCompromised === 0 && localCompromised === 0) {
|
||||
return true;
|
||||
}
|
||||
Logger(`Found compromised chunks : ${localCompromised} in local, ${remoteCompromised} in remote`, LOG_LEVEL_NOTICE);
|
||||
const title = $msg("moduleMigration.insecureChunkExist.title");
|
||||
const msg = $msg("moduleMigration.insecureChunkExist.message");
|
||||
const REBUILD = $msg("moduleMigration.insecureChunkExist.buttons.rebuild");
|
||||
const FETCH = $msg("moduleMigration.insecureChunkExist.buttons.fetch");
|
||||
const DISMISS = $msg("moduleMigration.insecureChunkExist.buttons.later");
|
||||
const buttons = [REBUILD, FETCH, DISMISS];
|
||||
if (remoteCompromised != 0) {
|
||||
buttons.splice(buttons.indexOf(FETCH), 1);
|
||||
}
|
||||
const result = await dependencies.confirm.askSelectStringDialogue(msg, buttons, {
|
||||
title,
|
||||
defaultAction: DISMISS,
|
||||
timeout: 0,
|
||||
});
|
||||
if (result === REBUILD) {
|
||||
// Rebuild the database
|
||||
await dependencies.rebuilder.scheduleRebuild();
|
||||
dependencies.performRestart();
|
||||
return false;
|
||||
} else if (result === FETCH) {
|
||||
// Fetch the latest data from remote
|
||||
await dependencies.rebuilder.scheduleFetch();
|
||||
dependencies.performRestart();
|
||||
return false;
|
||||
} else {
|
||||
// User chose to dismiss the issue
|
||||
dependencies.log($msg("moduleMigration.insecureChunkExist.laterMessage"), LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { countCompromisedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
|
||||
import { checkCompromisedChunks, type CompromisedChunksDependencies } from "./compromisedChunks";
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation", () => ({
|
||||
countCompromisedChunks: vi.fn(),
|
||||
}));
|
||||
|
||||
function selectButton(index: number) {
|
||||
return async (...args: unknown[]): Promise<string | false> => {
|
||||
const buttons = args[1] as readonly string[];
|
||||
return buttons[index] ?? false;
|
||||
};
|
||||
}
|
||||
|
||||
function createDependencies() {
|
||||
const askSelectStringDialogue = vi.fn(selectButton(2));
|
||||
const scheduleRebuild = vi.fn(async () => true);
|
||||
const scheduleFetch = vi.fn(async () => true);
|
||||
const performRestart = vi.fn();
|
||||
const getActiveReplicator = vi.fn((): object | undefined => undefined);
|
||||
const log = vi.fn();
|
||||
const dependencies: CompromisedChunksDependencies = {
|
||||
settings: { encrypt: true },
|
||||
localDatabase: { localDatabase: {} as never },
|
||||
isOnline: true,
|
||||
getActiveReplicator,
|
||||
confirm: { askSelectStringDialogue },
|
||||
rebuilder: { scheduleRebuild, scheduleFetch },
|
||||
performRestart,
|
||||
log,
|
||||
};
|
||||
return {
|
||||
askSelectStringDialogue,
|
||||
dependencies,
|
||||
getActiveReplicator,
|
||||
log,
|
||||
performRestart,
|
||||
scheduleFetch,
|
||||
scheduleRebuild,
|
||||
};
|
||||
}
|
||||
|
||||
describe("checkCompromisedChunks", () => {
|
||||
it("skips the database scan when encryption is disabled", async () => {
|
||||
const fixture = createDependencies();
|
||||
fixture.dependencies.settings.encrypt = false;
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(countCompromisedChunks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows start-up when local and active remote databases contain no compromised chunks", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(0);
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits when local chunk inspection fails", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(false);
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(fixture.askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
expect(fixture.performRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits when the active remote chunk inspection fails", async () => {
|
||||
const fixture = createDependencies();
|
||||
const remoteCount = vi.fn(async () => false);
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(0);
|
||||
fixture.getActiveReplicator.mockReturnValue({ countCompromisedChunks: remoteCount });
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(remoteCount).toHaveBeenCalledOnce();
|
||||
expect(fixture.askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
|
||||
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(fixture.performRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the fetch choice when compromised chunks are found on the remote", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(1);
|
||||
fixture.getActiveReplicator.mockReturnValue({
|
||||
countCompromisedChunks: vi.fn(async () => 2),
|
||||
});
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(1));
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.askSelectStringDialogue).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ timeout: 0 })
|
||||
);
|
||||
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[1]).toHaveLength(2);
|
||||
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(fixture.log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("schedules the selected recovery and stops start-up", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(1);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(0));
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(fixture.scheduleRebuild).toHaveBeenCalledOnce();
|
||||
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(fixture.performRestart).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("fetches local-only compromised chunks when FETCH is selected", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(1);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(1));
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[1]).toHaveLength(3);
|
||||
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
|
||||
expect(fixture.scheduleFetch).toHaveBeenCalledOnce();
|
||||
expect(fixture.performRestart).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps start-up running when compromised chunks are explicitly dismissed", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(1);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(2));
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
|
||||
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(fixture.performRestart).not.toHaveBeenCalled();
|
||||
expect(fixture.log).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { performDoctorConsultation, RebuildOptions } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import type { Rebuilder } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseRebuilder";
|
||||
import type { MessageTranslator } from "@vrtmrz/livesync-commonlib/context";
|
||||
|
||||
/** Collaborators required to run one Config Doctor consultation. */
|
||||
export interface ConfigDoctorDependencies {
|
||||
readonly confirm: Confirm;
|
||||
readonly translate: MessageTranslator;
|
||||
readonly settings: ObsidianLiveSyncSettings;
|
||||
readonly setSettings: (settings: ObsidianLiveSyncSettings) => void;
|
||||
readonly saveSettings: () => Promise<void>;
|
||||
readonly rebuilder: Pick<Rebuilder, "scheduleRebuild" | "scheduleFetch">;
|
||||
readonly performRestart: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Config Doctor and, when requested by its result, reserve the next-start
|
||||
* rebuild or fetch operation before restarting the application.
|
||||
*
|
||||
* The positional arguments retain the established defaults and operation
|
||||
* semantics for both start-up and request-event callers.
|
||||
*/
|
||||
export async function runConfigDoctor(
|
||||
dependencies: ConfigDoctorDependencies,
|
||||
skipRebuild: boolean = false,
|
||||
activateReason = "updated",
|
||||
forceRescan = false
|
||||
): Promise<boolean> {
|
||||
const { shouldRebuild, shouldRebuildLocal, isModified, settings } = await performDoctorConsultation(
|
||||
{
|
||||
confirm: dependencies.confirm,
|
||||
translate: dependencies.translate,
|
||||
},
|
||||
dependencies.settings,
|
||||
{
|
||||
localRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
|
||||
remoteRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
|
||||
activateReason,
|
||||
forceRescan,
|
||||
}
|
||||
);
|
||||
if (isModified) {
|
||||
dependencies.setSettings(settings);
|
||||
await dependencies.saveSettings();
|
||||
}
|
||||
if (!skipRebuild) {
|
||||
if (shouldRebuild) {
|
||||
await dependencies.rebuilder.scheduleRebuild();
|
||||
dependencies.performRestart();
|
||||
return false;
|
||||
} else if (shouldRebuildLocal) {
|
||||
await dependencies.rebuilder.scheduleFetch();
|
||||
dependencies.performRestart();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { performDoctorConsultation } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
|
||||
import { runConfigDoctor, type ConfigDoctorDependencies } from "./configDoctor";
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/configForDoc", async () => {
|
||||
const actual = await vi.importActual<typeof import("@vrtmrz/livesync-commonlib/compat/common/configForDoc")>(
|
||||
"@vrtmrz/livesync-commonlib/compat/common/configForDoc"
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
performDoctorConsultation: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
function createDependencies() {
|
||||
const settings = { isConfigured: true } as never;
|
||||
const setSettings = vi.fn();
|
||||
const saveSettings = vi.fn(async () => undefined);
|
||||
const scheduleRebuild = vi.fn(async () => true);
|
||||
const scheduleFetch = vi.fn(async () => true);
|
||||
const performRestart = vi.fn();
|
||||
const dependencies: ConfigDoctorDependencies = {
|
||||
confirm: {} as never,
|
||||
translate: String,
|
||||
settings,
|
||||
setSettings,
|
||||
saveSettings,
|
||||
rebuilder: { scheduleRebuild, scheduleFetch },
|
||||
performRestart,
|
||||
};
|
||||
return { dependencies, performRestart, saveSettings, scheduleFetch, scheduleRebuild, setSettings, settings };
|
||||
}
|
||||
|
||||
describe("runConfigDoctor", () => {
|
||||
it("persists a modified setting and keeps the configured start-up sequence running", async () => {
|
||||
const fixture = createDependencies();
|
||||
const nextSettings = { isConfigured: true, changed: true } as never;
|
||||
vi.mocked(performDoctorConsultation).mockResolvedValue({
|
||||
settings: nextSettings,
|
||||
shouldRebuild: false,
|
||||
shouldRebuildLocal: false,
|
||||
isModified: true,
|
||||
});
|
||||
|
||||
await expect(runConfigDoctor(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(performDoctorConsultation).toHaveBeenCalledWith(
|
||||
{ confirm: fixture.dependencies.confirm, translate: fixture.dependencies.translate },
|
||||
fixture.settings,
|
||||
expect.objectContaining({
|
||||
activateReason: "updated",
|
||||
forceRescan: false,
|
||||
})
|
||||
);
|
||||
expect(fixture.setSettings).toHaveBeenCalledWith(nextSettings);
|
||||
expect(fixture.saveSettings).toHaveBeenCalledOnce();
|
||||
expect(fixture.performRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("schedules a rebuild and restarts when Doctor requires remote reconstruction", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(performDoctorConsultation).mockResolvedValue({
|
||||
settings: fixture.settings,
|
||||
shouldRebuild: true,
|
||||
shouldRebuildLocal: false,
|
||||
isModified: false,
|
||||
});
|
||||
|
||||
await expect(runConfigDoctor(fixture.dependencies, false, "manual", true)).resolves.toBe(false);
|
||||
|
||||
expect(fixture.scheduleRebuild).toHaveBeenCalledOnce();
|
||||
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(fixture.performRestart).toHaveBeenCalledOnce();
|
||||
expect(performDoctorConsultation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ activateReason: "manual", forceRescan: true })
|
||||
);
|
||||
});
|
||||
|
||||
it("schedules a local fetch and restarts when Doctor requires local reconstruction", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(performDoctorConsultation).mockResolvedValue({
|
||||
settings: fixture.settings,
|
||||
shouldRebuild: false,
|
||||
shouldRebuildLocal: true,
|
||||
isModified: false,
|
||||
});
|
||||
|
||||
await expect(runConfigDoctor(fixture.dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
|
||||
expect(fixture.scheduleFetch).toHaveBeenCalledOnce();
|
||||
expect(fixture.performRestart).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("skips both recovery schedules and restart when rebuilds are skipped", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(performDoctorConsultation).mockResolvedValue({
|
||||
settings: fixture.settings,
|
||||
shouldRebuild: true,
|
||||
shouldRebuildLocal: true,
|
||||
isModified: false,
|
||||
});
|
||||
|
||||
await expect(runConfigDoctor(fixture.dependencies, true)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
|
||||
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(fixture.performRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+12
-16
@@ -1,34 +1,30 @@
|
||||
export interface ConfiguredStartupLifecycleRuntime {
|
||||
databaseReady: boolean;
|
||||
reportDatabaseNotReady(): void;
|
||||
hasCompromisedChunks(): Promise<boolean>;
|
||||
hasIncompleteDocuments(): Promise<boolean>;
|
||||
waitForCompatibilityReview(): Promise<void>;
|
||||
runDoctor(): Promise<boolean>;
|
||||
migrateBulkSend(): Promise<void>;
|
||||
}
|
||||
import type { ConfiguredStartupLifecycleOperations, StartupLifecycleValue } from "./types";
|
||||
|
||||
export interface StartupEntryLifecycleRuntime {
|
||||
configured: boolean;
|
||||
inviteToOnboarding(): void;
|
||||
function readValue<T>(value: StartupLifecycleValue<T>): T {
|
||||
return typeof value === "function" ? (value as () => T)() : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps an unconfigured Vault outside database initialisation and all
|
||||
* configured-only start-up work while offering an explicit setup action.
|
||||
*/
|
||||
export interface StartupEntryLifecycleRuntime {
|
||||
readonly configured: StartupLifecycleValue<boolean>;
|
||||
readonly inviteToOnboarding: () => void;
|
||||
}
|
||||
|
||||
export function runStartupEntryLifecycle(runtime: StartupEntryLifecycleRuntime): boolean {
|
||||
if (runtime.configured) return true;
|
||||
if (readValue(runtime.configured)) return true;
|
||||
runtime.inviteToOnboarding();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Separates the inert, unconfigured startup path from checks which must run
|
||||
* Separates the inert, unconfigured start-up path from checks which must run
|
||||
* before an already configured device is allowed to synchronise.
|
||||
*/
|
||||
export async function runConfiguredStartupLifecycle(runtime: ConfiguredStartupLifecycleRuntime): Promise<boolean> {
|
||||
if (!runtime.databaseReady) {
|
||||
export async function runConfiguredStartupLifecycle(runtime: ConfiguredStartupLifecycleOperations): Promise<boolean> {
|
||||
if (!readValue(runtime.databaseReady)) {
|
||||
runtime.reportDatabaseNotReady();
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
runConfiguredStartupLifecycle,
|
||||
runStartupEntryLifecycle,
|
||||
type StartupEntryLifecycleRuntime,
|
||||
} from "./configuredStartupLifecycle";
|
||||
import type { ConfiguredStartupLifecycleOperations } from "./types";
|
||||
|
||||
function createRuntime(events: string[] = []): ConfiguredStartupLifecycleOperations {
|
||||
return {
|
||||
databaseReady: true,
|
||||
reportDatabaseNotReady: vi.fn(() => events.push("database-not-ready")),
|
||||
hasCompromisedChunks: vi.fn(async () => {
|
||||
events.push("compromised-chunks");
|
||||
return true;
|
||||
}),
|
||||
hasIncompleteDocuments: vi.fn(async () => {
|
||||
events.push("incomplete-documents");
|
||||
return true;
|
||||
}),
|
||||
waitForCompatibilityReview: vi.fn(async () => {
|
||||
events.push("compatibility-review");
|
||||
}),
|
||||
runDoctor: vi.fn(async () => {
|
||||
events.push("doctor");
|
||||
return true;
|
||||
}),
|
||||
migrateBulkSend: vi.fn(async () => {
|
||||
events.push("bulk-send");
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("runConfiguredStartupLifecycle", () => {
|
||||
it("runs all configured checks in their established order", async () => {
|
||||
const events: string[] = [];
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(createRuntime(events))).resolves.toBe(true);
|
||||
|
||||
expect(events).toEqual([
|
||||
"compromised-chunks",
|
||||
"incomplete-documents",
|
||||
"compatibility-review",
|
||||
"doctor",
|
||||
"bulk-send",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not invoke later operations after database or integrity failure", async () => {
|
||||
const events: string[] = [];
|
||||
const runtime = createRuntime(events);
|
||||
Object.assign(runtime, { databaseReady: false });
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
expect(events).toEqual(["database-not-ready"]);
|
||||
|
||||
Object.assign(runtime, { databaseReady: true });
|
||||
vi.mocked(runtime.hasCompromisedChunks).mockImplementation(async () => {
|
||||
events.push("compromised-chunks");
|
||||
return false;
|
||||
});
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
expect(events).toEqual(["database-not-ready", "compromised-chunks"]);
|
||||
expect(runtime.hasIncompleteDocuments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not invoke later operations when incomplete-document checking fails", async () => {
|
||||
const events: string[] = [];
|
||||
const runtime = createRuntime(events);
|
||||
vi.mocked(runtime.hasIncompleteDocuments).mockImplementation(async () => {
|
||||
events.push("incomplete-documents");
|
||||
return false;
|
||||
});
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
|
||||
expect(events).toEqual(["compromised-chunks", "incomplete-documents"]);
|
||||
expect(runtime.waitForCompatibilityReview).not.toHaveBeenCalled();
|
||||
expect(runtime.runDoctor).not.toHaveBeenCalled();
|
||||
expect(runtime.migrateBulkSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not migrate bulk-send settings when Config Doctor fails", async () => {
|
||||
const events: string[] = [];
|
||||
const runtime = createRuntime(events);
|
||||
vi.mocked(runtime.runDoctor).mockImplementation(async () => {
|
||||
events.push("doctor");
|
||||
return false;
|
||||
});
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
|
||||
expect(events).toEqual(["compromised-chunks", "incomplete-documents", "compatibility-review", "doctor"]);
|
||||
expect(runtime.migrateBulkSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls Config Doctor without start-up-only arguments", async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
await runConfiguredStartupLifecycle(runtime);
|
||||
|
||||
expect(runtime.runDoctor).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runStartupEntryLifecycle", () => {
|
||||
it("invites an unconfigured Vault and stops configured start-up", () => {
|
||||
const inviteToOnboarding = vi.fn();
|
||||
const runtime: StartupEntryLifecycleRuntime = {
|
||||
configured: false,
|
||||
inviteToOnboarding,
|
||||
};
|
||||
|
||||
expect(runStartupEntryLifecycle(runtime)).toBe(false);
|
||||
expect(inviteToOnboarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("admits a configured Vault without inviting it to onboarding", () => {
|
||||
const inviteToOnboarding = vi.fn();
|
||||
|
||||
expect(
|
||||
runStartupEntryLifecycle({
|
||||
configured: true,
|
||||
inviteToOnboarding,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(inviteToOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
Logger,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import {
|
||||
isDeletedEntry,
|
||||
isDocContentSame,
|
||||
isLoadedEntry,
|
||||
readAsBlob,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { isMetaEntry } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
|
||||
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
|
||||
import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler";
|
||||
import type { KeyValueDatabase } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { isValidPath } from "@/common/utils";
|
||||
import type { StartupPathReader } from "./types";
|
||||
|
||||
type NoticeGroups = {
|
||||
setItem(groupKey: string, itemKey: string, item: { message: string }): void;
|
||||
finish(groupKey: string): void;
|
||||
};
|
||||
|
||||
/** Focused collaborators for the incomplete-document integrity scan and repair. */
|
||||
export interface IncompleteDocumentsDependencies {
|
||||
readonly localDatabase: Pick<LiveSyncLocalDB, "findAllNormalDocs" | "getDBEntryFromMeta">;
|
||||
readonly getPath: StartupPathReader;
|
||||
readonly isTargetFile: (path: string) => Promise<boolean>;
|
||||
readonly storageAccess: Pick<StorageAccess, "readHiddenFileBinary" | "getFileStub">;
|
||||
readonly fileHandler: Pick<IFileHandler, "storeFileToDB">;
|
||||
readonly keyValueDB: Pick<KeyValueDatabase, "get" | "set">;
|
||||
readonly noticeGroups: NoticeGroups;
|
||||
readonly confirm: Pick<Confirm, "askSelectStringDialogue">;
|
||||
readonly log: LogFunction;
|
||||
}
|
||||
|
||||
type ErrorInfo = {
|
||||
path: string;
|
||||
recordedSize: number;
|
||||
actualSize: number;
|
||||
storageSize: number;
|
||||
contentMatched: boolean;
|
||||
isConflicted?: boolean;
|
||||
};
|
||||
|
||||
const INCOMPLETE_DOCUMENT_NOTICE_GROUP = "startup-integrity-check";
|
||||
|
||||
/**
|
||||
* Scan database metadata against hidden storage and preserve the former
|
||||
* recoverable-file dialogue and repair rules.
|
||||
*/
|
||||
export async function checkIncompleteDocuments(
|
||||
dependencies: IncompleteDocumentsDependencies,
|
||||
force: boolean = false
|
||||
): Promise<boolean> {
|
||||
const incompleteDocsChecked = (await dependencies.keyValueDB.get<boolean>("checkIncompleteDocs")) || false;
|
||||
if (incompleteDocsChecked && !force) {
|
||||
dependencies.log("Incomplete docs check already done, skipping.", LOG_LEVEL_VERBOSE);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
const noticeGroups = dependencies.noticeGroups;
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "checking", {
|
||||
message: "Checking for incomplete documents...",
|
||||
});
|
||||
dependencies.log("Checking for incomplete documents...", LOG_LEVEL_VERBOSE);
|
||||
|
||||
try {
|
||||
const errorFiles = [] as ErrorInfo[];
|
||||
for await (const metaDoc of dependencies.localDatabase.findAllNormalDocs({ conflicts: true })) {
|
||||
const path = dependencies.getPath(metaDoc);
|
||||
|
||||
if (!isValidPath(path)) {
|
||||
continue;
|
||||
}
|
||||
if (!(await dependencies.isTargetFile(path))) {
|
||||
continue;
|
||||
}
|
||||
if (!isMetaEntry(metaDoc)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const doc = await dependencies.localDatabase.getDBEntryFromMeta(metaDoc);
|
||||
if (!doc || !isLoadedEntry(doc)) {
|
||||
continue;
|
||||
}
|
||||
if (isDeletedEntry(doc)) {
|
||||
continue;
|
||||
}
|
||||
const isConflicted = metaDoc?._conflicts && metaDoc._conflicts.length > 0;
|
||||
|
||||
let storageFileContent;
|
||||
try {
|
||||
storageFileContent = await dependencies.storageAccess.readHiddenFileBinary(path);
|
||||
} catch (e) {
|
||||
Logger(`Failed to read file ${path}: Possibly unprocessed or missing`);
|
||||
Logger(e, LOG_LEVEL_VERBOSE);
|
||||
continue;
|
||||
}
|
||||
// const storageFileBlob = createBlob(storageFileContent);
|
||||
const sizeOnStorage = storageFileContent.byteLength;
|
||||
const recordedSize = doc.size;
|
||||
const docBlob = readAsBlob(doc);
|
||||
const actualSize = docBlob.size;
|
||||
if (
|
||||
recordedSize !== actualSize ||
|
||||
sizeOnStorage !== actualSize ||
|
||||
sizeOnStorage !== recordedSize ||
|
||||
isConflicted
|
||||
) {
|
||||
const contentMatched = await isDocContentSame(doc.data, storageFileContent);
|
||||
errorFiles.push({
|
||||
path,
|
||||
recordedSize,
|
||||
actualSize,
|
||||
storageSize: sizeOnStorage,
|
||||
contentMatched,
|
||||
isConflicted,
|
||||
});
|
||||
Logger(
|
||||
`Size mismatch for ${path}: ${recordedSize} (DB Recorded) , ${actualSize} (DB Stored) , ${sizeOnStorage} (Storage Stored), ${contentMatched ? "Content Matched" : "Content Mismatched"} ${isConflicted ? "Conflicted" : "Not Conflicted"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (errorFiles.length == 0) {
|
||||
Logger("No size mismatches found", LOG_LEVEL_INFO);
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: "No size mismatches found",
|
||||
});
|
||||
await dependencies.keyValueDB.set("checkIncompleteDocs", true);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
Logger(`Found ${errorFiles.length} size mismatches`, LOG_LEVEL_INFO);
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: `Found ${errorFiles.length} size mismatches`,
|
||||
});
|
||||
// We have to repair them following rules and situations:
|
||||
// A. DB Recorded != DB Stored
|
||||
// A.1. DB Recorded == Storage Stored
|
||||
// Possibly recoverable from storage. Just overwrite the DB content with storage content.
|
||||
// A.2. Neither
|
||||
// Probably it cannot be resolved on this device. Even if the storage content is larger than DB Recorded, it possibly corrupted.
|
||||
// We do not fix it automatically. Leave it as is. Possibly other device can do this.
|
||||
// B. DB Recorded == DB Stored , < Storage Stored
|
||||
// Very fragile, if DB Recorded size is less than Storage Stored size, we possibly repair the content (The issue was `unexpectedly shortened file`).
|
||||
// We do not fix it automatically, but it will be automatically overwritten in other process.
|
||||
// C. DB Recorded == DB Stored , > Storage Stored
|
||||
// Probably restored by the user by resolving A or B on other device, We should overwrite the storage
|
||||
// Also do not fix it automatically. It should be overwritten by replication.
|
||||
const recoverable = errorFiles.filter((e) => {
|
||||
return e.recordedSize === e.storageSize && !e.isConflicted;
|
||||
});
|
||||
const unrecoverable = errorFiles.filter((e) => {
|
||||
return e.recordedSize !== e.storageSize || e.isConflicted;
|
||||
});
|
||||
const fileInfo = (e: (typeof errorFiles)[0]) => {
|
||||
return `${e.path} (M: ${e.recordedSize}, A: ${e.actualSize}, S: ${e.storageSize}) ${e.isConflicted ? "(Conflicted)" : ""}`;
|
||||
};
|
||||
const messageUnrecoverable =
|
||||
unrecoverable.length > 0
|
||||
? $msg("moduleMigration.fix0256.messageUnrecoverable", {
|
||||
filesNotRecoverable: unrecoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
|
||||
})
|
||||
: "";
|
||||
|
||||
const message = $msg("moduleMigration.fix0256.message", {
|
||||
files: recoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
|
||||
messageUnrecoverable,
|
||||
});
|
||||
const CHECK_IT_LATER = $msg("moduleMigration.fix0256.buttons.checkItLater");
|
||||
const FIX = $msg("moduleMigration.fix0256.buttons.fix");
|
||||
const DISMISS = $msg("moduleMigration.fix0256.buttons.DismissForever");
|
||||
const ret = await dependencies.confirm.askSelectStringDialogue(message, [CHECK_IT_LATER, FIX, DISMISS], {
|
||||
title: $msg("moduleMigration.fix0256.title"),
|
||||
defaultAction: CHECK_IT_LATER,
|
||||
});
|
||||
if (ret == FIX) {
|
||||
for (const file of recoverable) {
|
||||
// Overwrite the database with the files on the storage
|
||||
const stubFile = await dependencies.storageAccess.getFileStub(file.path);
|
||||
if (stubFile == null) {
|
||||
Logger(`Could not find stub file for ${file.path}`, LOG_LEVEL_NOTICE);
|
||||
continue;
|
||||
}
|
||||
|
||||
stubFile.stat.mtime = Date.now();
|
||||
const result = await dependencies.fileHandler.storeFileToDB(stubFile, true, false);
|
||||
if (result) {
|
||||
Logger(`Successfully restored ${file.path} from storage`);
|
||||
} else {
|
||||
Logger(`Failed to restore ${file.path} from storage`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
}
|
||||
} else if (ret === DISMISS) {
|
||||
// User chose to dismiss the issue
|
||||
await dependencies.keyValueDB.set("checkIncompleteDocs", true);
|
||||
}
|
||||
|
||||
return Promise.resolve(true);
|
||||
} catch (error) {
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: "The incomplete document check could not be completed.",
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
noticeGroups.finish(INCOMPLETE_DOCUMENT_NOTICE_GROUP);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { LoadedEntry, MetaEntry, UXFileInfoStub } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { checkIncompleteDocuments, type IncompleteDocumentsDependencies } from "./incompleteDocuments";
|
||||
|
||||
vi.mock("@/common/utils", () => ({
|
||||
isValidPath: () => true,
|
||||
}));
|
||||
|
||||
type DocumentFixture = {
|
||||
meta: MetaEntry;
|
||||
loaded: LoadedEntry;
|
||||
storage: ArrayBuffer;
|
||||
stub: UXFileInfoStub;
|
||||
};
|
||||
|
||||
type FindAllNormalDocs = () => AsyncGenerator<MetaEntry>;
|
||||
|
||||
async function* noDocuments(): AsyncGenerator<MetaEntry> {
|
||||
return;
|
||||
}
|
||||
|
||||
async function* failedDocumentScan(): AsyncGenerator<MetaEntry> {
|
||||
throw new Error("scan failed");
|
||||
}
|
||||
|
||||
function documentFixture(
|
||||
path: string,
|
||||
options: { recordedSize?: number; storageContent?: string; conflicts?: string[] } = {}
|
||||
): DocumentFixture {
|
||||
const storageContent = options.storageContent ?? "hello";
|
||||
const meta = {
|
||||
_id: `f:${path}`,
|
||||
_rev: "1-test",
|
||||
path,
|
||||
ctime: 1,
|
||||
mtime: 2,
|
||||
size: options.recordedSize ?? storageContent.length,
|
||||
children: ["h:test"],
|
||||
type: "plain",
|
||||
eden: {},
|
||||
...(options.conflicts ? { _conflicts: options.conflicts } : {}),
|
||||
} as MetaEntry;
|
||||
const loaded = {
|
||||
...meta,
|
||||
data: "abc",
|
||||
datatype: "plain",
|
||||
} as LoadedEntry;
|
||||
const stub = {
|
||||
name: path.split("/").pop() ?? path,
|
||||
path,
|
||||
stat: {
|
||||
ctime: 1,
|
||||
mtime: 2,
|
||||
size: storageContent.length,
|
||||
type: "file",
|
||||
},
|
||||
} as UXFileInfoStub;
|
||||
return {
|
||||
meta,
|
||||
loaded,
|
||||
storage: new TextEncoder().encode(storageContent).buffer as ArrayBuffer,
|
||||
stub,
|
||||
};
|
||||
}
|
||||
|
||||
function documentsFrom(fixtures: DocumentFixture[]): FindAllNormalDocs {
|
||||
return async function* () {
|
||||
yield* fixtures.map((fixture) => fixture.meta);
|
||||
};
|
||||
}
|
||||
|
||||
function selectButton(index: number) {
|
||||
return async (...args: unknown[]): Promise<string | false> => {
|
||||
const buttons = args[1] as readonly string[];
|
||||
return buttons[index] ?? false;
|
||||
};
|
||||
}
|
||||
|
||||
function createDependencies(findAllNormalDocs: FindAllNormalDocs = noDocuments, fixtures: DocumentFixture[] = []) {
|
||||
const fixtureByPath = new Map<string, DocumentFixture>(
|
||||
fixtures.map((fixture) => [fixture.meta.path as string, fixture])
|
||||
);
|
||||
const noticeGroups = {
|
||||
setItem: vi.fn(),
|
||||
finish: vi.fn(),
|
||||
};
|
||||
const getFixture = (path: string) => fixtureByPath.get(path);
|
||||
const getDBEntryFromMeta = vi.fn(async (meta: { path: string }) => getFixture(meta.path)?.loaded);
|
||||
const readHiddenFileBinary = vi.fn(async (path: string) => getFixture(path)?.storage ?? new ArrayBuffer(0));
|
||||
const getFileStub = vi.fn(async (path: string) => getFixture(path)?.stub ?? null);
|
||||
const storeFileToDB = vi.fn(async () => true);
|
||||
const askSelectStringDialogue = vi.fn();
|
||||
const dependencies = {
|
||||
localDatabase: {
|
||||
findAllNormalDocs,
|
||||
getDBEntryFromMeta,
|
||||
},
|
||||
getPath: vi.fn((entry: { path: string }) => entry.path),
|
||||
isTargetFile: vi.fn(async () => true),
|
||||
storageAccess: {
|
||||
readHiddenFileBinary,
|
||||
getFileStub,
|
||||
},
|
||||
fileHandler: { storeFileToDB },
|
||||
keyValueDB: {
|
||||
get: vi.fn(async () => false),
|
||||
set: vi.fn(async () => undefined),
|
||||
},
|
||||
noticeGroups,
|
||||
confirm: { askSelectStringDialogue },
|
||||
log: vi.fn(),
|
||||
} as unknown as IncompleteDocumentsDependencies;
|
||||
return {
|
||||
askSelectStringDialogue,
|
||||
dependencies,
|
||||
getFileStub,
|
||||
noticeGroups,
|
||||
readHiddenFileBinary,
|
||||
storeFileToDB,
|
||||
};
|
||||
}
|
||||
|
||||
describe("checkIncompleteDocuments", () => {
|
||||
it("keeps the check and result in one persistent named group", async () => {
|
||||
const { dependencies, noticeGroups } = createDependencies();
|
||||
|
||||
await expect(checkIncompleteDocuments(dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(1, "startup-integrity-check", "checking", {
|
||||
message: "Checking for incomplete documents...",
|
||||
});
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(2, "startup-integrity-check", "result", {
|
||||
message: "No size mismatches found",
|
||||
});
|
||||
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
|
||||
expect(dependencies.keyValueDB.set).toHaveBeenCalledWith("checkIncompleteDocs", true);
|
||||
});
|
||||
|
||||
it("skips the non-forced check after a successful prior scan", async () => {
|
||||
const { dependencies, noticeGroups } = createDependencies();
|
||||
vi.mocked(dependencies.keyValueDB.get).mockResolvedValue(true);
|
||||
|
||||
await expect(checkIncompleteDocuments(dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(noticeGroups.setItem).not.toHaveBeenCalled();
|
||||
expect(noticeGroups.finish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finishes the group with a failure result when the scan throws", async () => {
|
||||
const { dependencies, noticeGroups } = createDependencies(failedDocumentScan);
|
||||
|
||||
await expect(checkIncompleteDocuments(dependencies)).rejects.toThrow("scan failed");
|
||||
|
||||
expect(noticeGroups.setItem).toHaveBeenLastCalledWith("startup-integrity-check", "result", {
|
||||
message: "The incomplete document check could not be completed.",
|
||||
});
|
||||
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
|
||||
});
|
||||
|
||||
it("repairs a recoverable document when FIX is selected", async () => {
|
||||
const recoverable = documentFixture("recoverable.md");
|
||||
const fixture = createDependencies(documentsFrom([recoverable]), [recoverable]);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(1));
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[1]).toHaveLength(3);
|
||||
expect(fixture.getFileStub).toHaveBeenCalledWith("recoverable.md");
|
||||
expect(fixture.storeFileToDB).toHaveBeenCalledWith(recoverable.stub, true, false);
|
||||
expect(fixture.dependencies.keyValueDB.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves recoverable documents unchanged when CHECK_IT_LATER is selected", async () => {
|
||||
const recoverable = documentFixture("recoverable.md");
|
||||
const fixture = createDependencies(documentsFrom([recoverable]), [recoverable]);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(0));
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.getFileStub).not.toHaveBeenCalled();
|
||||
expect(fixture.storeFileToDB).not.toHaveBeenCalled();
|
||||
expect(fixture.dependencies.keyValueDB.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records a permanent dismissal of recoverable document warnings", async () => {
|
||||
const recoverable = documentFixture("recoverable.md");
|
||||
const fixture = createDependencies(documentsFrom([recoverable]), [recoverable]);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(2));
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.getFileStub).not.toHaveBeenCalled();
|
||||
expect(fixture.storeFileToDB).not.toHaveBeenCalled();
|
||||
expect(fixture.dependencies.keyValueDB.set).toHaveBeenCalledWith("checkIncompleteDocs", true);
|
||||
});
|
||||
|
||||
it("stores only recoverable, non-conflicted documents from a mixed scan", async () => {
|
||||
const recoverable = documentFixture("recoverable.md");
|
||||
const unrecoverable = documentFixture("unrecoverable.md", { recordedSize: 4 });
|
||||
const conflicted = documentFixture("conflicted.md", { conflicts: ["2-conflict"] });
|
||||
const fixture = createDependencies(documentsFrom([recoverable, unrecoverable, conflicted]), [
|
||||
recoverable,
|
||||
unrecoverable,
|
||||
conflicted,
|
||||
]);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(1));
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.getFileStub).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.getFileStub).toHaveBeenCalledWith("recoverable.md");
|
||||
expect(fixture.storeFileToDB).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.storeFileToDB).toHaveBeenCalledWith(recoverable.stub, true, false);
|
||||
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[0]).toEqual(expect.stringContaining("unrecoverable.md"));
|
||||
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[0]).toEqual(expect.stringContaining("conflicted.md"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export { runConfiguredStartupLifecycle, runStartupEntryLifecycle } from "./configuredStartupLifecycle";
|
||||
export type { StartupEntryLifecycleRuntime } from "./configuredStartupLifecycle";
|
||||
export { runConfigDoctor } from "./configDoctor";
|
||||
export { checkCompromisedChunks } from "./compromisedChunks";
|
||||
export { checkIncompleteDocuments } from "./incompleteDocuments";
|
||||
export { migrateBulkSendSetting } from "./bulkSettingMigration";
|
||||
export { STARTUP_LIFECYCLE_LAYOUT_PRIORITY, useStartupLifecycleFeature } from "./startupLifecycle";
|
||||
export type { BulkSettingMigrationDependencies } from "./bulkSettingMigration";
|
||||
export type { CompromisedChunksDependencies } from "./compromisedChunks";
|
||||
export type { ConfigDoctorDependencies } from "./configDoctor";
|
||||
export type { IncompleteDocumentsDependencies } from "./incompleteDocuments";
|
||||
export type {
|
||||
ConfiguredStartupLifecycleOperations,
|
||||
LegacyBulkSendSettings,
|
||||
StartupLifecycleContext,
|
||||
StartupLifecycleFeatureOptions,
|
||||
StartupLifecycleHost,
|
||||
StartupLifecycleValue,
|
||||
StartupPathReader,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const operationMocks = vi.hoisted(() => ({
|
||||
checkCompromisedChunks: vi.fn(),
|
||||
checkIncompleteDocuments: vi.fn(),
|
||||
migrateBulkSendSetting: vi.fn(),
|
||||
runConfigDoctor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./compromisedChunks", () => ({
|
||||
checkCompromisedChunks: operationMocks.checkCompromisedChunks,
|
||||
}));
|
||||
vi.mock("./incompleteDocuments", () => ({
|
||||
checkIncompleteDocuments: operationMocks.checkIncompleteDocuments,
|
||||
}));
|
||||
vi.mock("./bulkSettingMigration", () => ({
|
||||
migrateBulkSendSetting: operationMocks.migrateBulkSendSetting,
|
||||
}));
|
||||
vi.mock("./configDoctor", () => ({
|
||||
runConfigDoctor: operationMocks.runConfigDoctor,
|
||||
}));
|
||||
|
||||
import { useStartupLifecycleFeature, type StartupLifecycleHost } from "./index";
|
||||
|
||||
describe("useStartupLifecycleFeature default operation wiring", () => {
|
||||
it("maps the host services to every configured start-up operation in order", async () => {
|
||||
const order: string[] = [];
|
||||
const log = vi.fn();
|
||||
const settings = { isConfigured: true, encrypt: true, sendChunksBulk: false, sendChunksBulkMaxSize: 1 };
|
||||
const localDatabase = { isReady: true, localDatabase: { name: "local" } };
|
||||
const confirm = { askSelectStringDialogue: vi.fn() };
|
||||
const activeReplicator = { name: "remote" };
|
||||
const storageAccess = { name: "storage" };
|
||||
const fileHandler = { name: "file-handler" };
|
||||
const rebuilder = { name: "rebuilder" };
|
||||
const kvDB = { name: "key-value" };
|
||||
const addLayoutHandler = vi.fn();
|
||||
const addFirstInitialiseHandler = vi.fn();
|
||||
const setting = {
|
||||
settings,
|
||||
currentSettings: vi.fn(() => settings),
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
};
|
||||
const appLifecycle = {
|
||||
onLayoutReady: { addHandler: addLayoutHandler },
|
||||
onFirstInitialise: { addHandler: addFirstInitialiseHandler },
|
||||
performRestart: vi.fn(),
|
||||
};
|
||||
const path = { getPath: vi.fn(() => "note.md") };
|
||||
const vault = { isTargetFile: vi.fn(async () => true) };
|
||||
const host = {
|
||||
services: {
|
||||
API: { isOnline: true },
|
||||
UI: { confirm },
|
||||
appLifecycle,
|
||||
context: {
|
||||
events: { onEvent: vi.fn(() => vi.fn()) },
|
||||
noticeGroups: { setItem: vi.fn(), finish: vi.fn() },
|
||||
translate: String,
|
||||
},
|
||||
database: { localDatabase },
|
||||
keyValueDB: { kvDB },
|
||||
path,
|
||||
replicator: { getActiveReplicator: vi.fn(() => activeReplicator) },
|
||||
setting,
|
||||
vault,
|
||||
},
|
||||
serviceModules: { fileHandler, rebuilder, storageAccess },
|
||||
} as unknown as StartupLifecycleHost;
|
||||
|
||||
operationMocks.checkCompromisedChunks.mockImplementation(async () => {
|
||||
order.push("compromised");
|
||||
return true;
|
||||
});
|
||||
operationMocks.checkIncompleteDocuments.mockImplementation(async () => {
|
||||
order.push("incomplete");
|
||||
return true;
|
||||
});
|
||||
operationMocks.runConfigDoctor.mockImplementation(async () => {
|
||||
order.push("doctor");
|
||||
return true;
|
||||
});
|
||||
operationMocks.migrateBulkSendSetting.mockImplementation(async () => {
|
||||
order.push("bulk");
|
||||
});
|
||||
const waitForCompatibilityReview = vi.fn(async () => {
|
||||
order.push("compatibility");
|
||||
});
|
||||
|
||||
useStartupLifecycleFeature(host, {
|
||||
inviteToOnboarding: vi.fn(),
|
||||
waitForCompatibilityReview,
|
||||
log,
|
||||
});
|
||||
|
||||
expect(operationMocks.checkCompromisedChunks).not.toHaveBeenCalled();
|
||||
expect(operationMocks.checkIncompleteDocuments).not.toHaveBeenCalled();
|
||||
expect(operationMocks.runConfigDoctor).not.toHaveBeenCalled();
|
||||
expect(operationMocks.migrateBulkSendSetting).not.toHaveBeenCalled();
|
||||
expect(waitForCompatibilityReview).not.toHaveBeenCalled();
|
||||
|
||||
const layoutAdmission = addLayoutHandler.mock.calls[0]?.[0] as () => Promise<boolean>;
|
||||
const firstInitialise = addFirstInitialiseHandler.mock.calls[0]?.[0] as () => Promise<boolean>;
|
||||
await expect(layoutAdmission()).resolves.toBe(true);
|
||||
await expect(firstInitialise()).resolves.toBe(true);
|
||||
|
||||
expect(order).toEqual(["compromised", "incomplete", "compatibility", "doctor", "bulk"]);
|
||||
|
||||
const compromised = operationMocks.checkCompromisedChunks.mock.calls[0]?.[0];
|
||||
expect(compromised).toMatchObject({ settings, localDatabase, confirm, rebuilder, log });
|
||||
expect(compromised?.isOnline()).toBe(true);
|
||||
expect(compromised?.getActiveReplicator()).toBe(activeReplicator);
|
||||
compromised?.performRestart();
|
||||
expect(appLifecycle.performRestart).toHaveBeenCalledOnce();
|
||||
|
||||
const [incomplete, force] = operationMocks.checkIncompleteDocuments.mock.calls[0] ?? [];
|
||||
expect(force).toBe(false);
|
||||
expect(incomplete).toMatchObject({ localDatabase, storageAccess, fileHandler, keyValueDB: kvDB, confirm, log });
|
||||
expect(incomplete?.getPath({} as never)).toBe("note.md");
|
||||
await expect(incomplete?.isTargetFile("note.md")).resolves.toBe(true);
|
||||
|
||||
const doctor = operationMocks.runConfigDoctor.mock.calls[0]?.[0];
|
||||
expect(doctor).toMatchObject({ confirm, settings, rebuilder });
|
||||
const nextSettings = { ...settings, liveSync: true } as never;
|
||||
doctor?.setSettings(nextSettings);
|
||||
expect(setting.settings).toBe(nextSettings);
|
||||
await doctor?.saveSettings();
|
||||
expect(setting.saveSettingData).toHaveBeenCalledOnce();
|
||||
|
||||
const bulk = operationMocks.migrateBulkSendSetting.mock.calls[0]?.[0];
|
||||
expect(bulk).toMatchObject({ settings, log });
|
||||
await bulk?.saveSettings();
|
||||
expect(setting.saveSettingData).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { EVENT_REQUEST_RUN_DOCTOR, EVENT_REQUEST_RUN_FIX_INCOMPLETE, EVENT_SETTING_SAVED } from "@/common/events";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import type {
|
||||
StartupLifecycleHost,
|
||||
StartupLifecycleFeatureOptions,
|
||||
ConfiguredStartupLifecycleOperations,
|
||||
} from "./types";
|
||||
import { runConfiguredStartupLifecycle, runStartupEntryLifecycle } from "./configuredStartupLifecycle";
|
||||
import { runConfigDoctor } from "./configDoctor";
|
||||
import { checkCompromisedChunks } from "./compromisedChunks";
|
||||
import { checkIncompleteDocuments } from "./incompleteDocuments";
|
||||
import { migrateBulkSendSetting } from "./bulkSettingMigration";
|
||||
|
||||
/** Layout admission runs after ordinary priority-0 host handlers. */
|
||||
export const STARTUP_LIFECYCLE_LAYOUT_PRIORITY = 1 as const;
|
||||
|
||||
function readValue<T>(value: T | (() => T)): T {
|
||||
return typeof value === "function" ? (value as () => T)() : value;
|
||||
}
|
||||
|
||||
function createDefaultOperations(
|
||||
host: StartupLifecycleHost,
|
||||
options: StartupLifecycleFeatureOptions,
|
||||
log: ReturnType<typeof createInstanceLogFunction>
|
||||
): ConfiguredStartupLifecycleOperations {
|
||||
const { services } = host;
|
||||
const defaultOperations = {
|
||||
databaseReady: () => services.database.localDatabase.isReady,
|
||||
reportDatabaseNotReady: () => log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE),
|
||||
hasCompromisedChunks: () =>
|
||||
checkCompromisedChunks({
|
||||
settings: services.setting.currentSettings(),
|
||||
localDatabase: services.database.localDatabase,
|
||||
isOnline: () => services.API.isOnline,
|
||||
getActiveReplicator: () => services.replicator.getActiveReplicator(),
|
||||
confirm: services.UI.confirm,
|
||||
rebuilder: host.serviceModules.rebuilder,
|
||||
performRestart: () => services.appLifecycle.performRestart(),
|
||||
log,
|
||||
}),
|
||||
hasIncompleteDocuments: (force = false) =>
|
||||
checkIncompleteDocuments(
|
||||
{
|
||||
localDatabase: services.database.localDatabase,
|
||||
getPath: (entry) => services.path.getPath(entry),
|
||||
isTargetFile: (path) => services.vault.isTargetFile(path),
|
||||
storageAccess: host.serviceModules.storageAccess,
|
||||
fileHandler: host.serviceModules.fileHandler,
|
||||
keyValueDB: services.keyValueDB.kvDB,
|
||||
noticeGroups: services.context.noticeGroups,
|
||||
confirm: services.UI.confirm,
|
||||
log,
|
||||
},
|
||||
force
|
||||
),
|
||||
runDoctor: (skipRebuild = false, activateReason = "updated", forceRescan = false) =>
|
||||
runConfigDoctor(
|
||||
{
|
||||
confirm: services.UI.confirm,
|
||||
translate: services.context.translate,
|
||||
settings: services.setting.currentSettings(),
|
||||
setSettings: (settings) => {
|
||||
services.setting.settings = settings;
|
||||
},
|
||||
saveSettings: () => services.setting.saveSettingData(),
|
||||
rebuilder: host.serviceModules.rebuilder,
|
||||
performRestart: () => services.appLifecycle.performRestart(),
|
||||
},
|
||||
skipRebuild,
|
||||
activateReason,
|
||||
forceRescan
|
||||
),
|
||||
migrateBulkSend: () =>
|
||||
migrateBulkSendSetting({
|
||||
settings: services.setting.currentSettings(),
|
||||
log,
|
||||
saveSettings: () => services.setting.saveSettingData(),
|
||||
}),
|
||||
} satisfies Omit<ConfiguredStartupLifecycleOperations, "waitForCompatibilityReview">;
|
||||
|
||||
return {
|
||||
databaseReady: options.databaseReady ?? defaultOperations.databaseReady,
|
||||
reportDatabaseNotReady: options.reportDatabaseNotReady ?? defaultOperations.reportDatabaseNotReady,
|
||||
hasCompromisedChunks: options.hasCompromisedChunks ?? defaultOperations.hasCompromisedChunks,
|
||||
hasIncompleteDocuments: options.hasIncompleteDocuments ?? defaultOperations.hasIncompleteDocuments,
|
||||
waitForCompatibilityReview: options.waitForCompatibilityReview,
|
||||
runDoctor: options.runDoctor ?? defaultOperations.runDoctor,
|
||||
migrateBulkSend: options.migrateBulkSend ?? defaultOperations.migrateBulkSend,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose configured Vault admission, start-up integrity checks, migrations,
|
||||
* and their request events around one host-owned service context.
|
||||
*
|
||||
* Event listeners are deliberately registered from the successful layout
|
||||
* admission handler. An unconfigured Vault therefore cannot receive a
|
||||
* Config Doctor or incomplete-document request before onboarding.
|
||||
*/
|
||||
export function useStartupLifecycleFeature(host: StartupLifecycleHost, options: StartupLifecycleFeatureOptions): void {
|
||||
const log = options.log ?? createInstanceLogFunction("SF:StartupLifecycle", host.services.API);
|
||||
const operations = createDefaultOperations(host, options, log);
|
||||
let layoutAdmitted = false;
|
||||
let layoutEvaluated = false;
|
||||
let generationRetired = false;
|
||||
let eventsBound = false;
|
||||
let eventUnsubscribers: Array<() => void> = [];
|
||||
|
||||
const isConfigured = () => {
|
||||
const configured = options.configured;
|
||||
return configured === undefined
|
||||
? host.services.setting.currentSettings().isConfigured === true
|
||||
: readValue(configured);
|
||||
};
|
||||
|
||||
const isDatabaseReady = () => readValue(operations.databaseReady);
|
||||
|
||||
const retireGeneration = () => {
|
||||
if (generationRetired) return;
|
||||
generationRetired = true;
|
||||
layoutAdmitted = false;
|
||||
for (const unsubscribe of eventUnsubscribers) unsubscribe();
|
||||
eventUnsubscribers = [];
|
||||
};
|
||||
|
||||
const bindRequestEvents = () => {
|
||||
if (eventsBound || generationRetired) return;
|
||||
eventsBound = true;
|
||||
eventUnsubscribers = [
|
||||
host.services.context.events.onEvent(EVENT_REQUEST_RUN_DOCTOR, async (reason) => {
|
||||
if (!layoutAdmitted || generationRetired || !isConfigured() || !isDatabaseReady()) return;
|
||||
await operations.runDoctor(false, reason, true);
|
||||
}),
|
||||
host.services.context.events.onEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE, async () => {
|
||||
if (!layoutAdmitted || generationRetired || !isConfigured() || !isDatabaseReady()) return;
|
||||
await operations.hasIncompleteDocuments(true);
|
||||
}),
|
||||
host.services.context.events.onEvent(EVENT_SETTING_SAVED, (settings) => {
|
||||
if (settings.isConfigured !== true) retireGeneration();
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
const layoutAdmission = (): Promise<boolean> => {
|
||||
if (generationRetired) return Promise.resolve(false);
|
||||
if (layoutEvaluated) {
|
||||
if (layoutAdmitted && !isConfigured()) retireGeneration();
|
||||
return Promise.resolve(layoutAdmitted);
|
||||
}
|
||||
|
||||
layoutEvaluated = true;
|
||||
const admitted = runStartupEntryLifecycle({
|
||||
configured: isConfigured,
|
||||
inviteToOnboarding: options.inviteToOnboarding,
|
||||
});
|
||||
if (!admitted) {
|
||||
retireGeneration();
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
layoutAdmitted = true;
|
||||
bindRequestEvents();
|
||||
return Promise.resolve(true);
|
||||
};
|
||||
|
||||
const firstInitialise = async (): Promise<boolean> => {
|
||||
if (!layoutAdmitted || generationRetired || !isConfigured()) return false;
|
||||
return await runConfiguredStartupLifecycle(operations);
|
||||
};
|
||||
|
||||
host.services.appLifecycle.onLayoutReady.addHandler(layoutAdmission, STARTUP_LIFECYCLE_LAYOUT_PRIORITY);
|
||||
host.services.appLifecycle.onFirstInitialise.addHandler(firstInitialise);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EVENT_REQUEST_RUN_DOCTOR, EVENT_REQUEST_RUN_FIX_INCOMPLETE, EVENT_SETTING_SAVED } from "@/common/events";
|
||||
vi.mock("@/common/utils", () => ({
|
||||
isValidPath: () => true,
|
||||
}));
|
||||
import {
|
||||
STARTUP_LIFECYCLE_LAYOUT_PRIORITY,
|
||||
useStartupLifecycleFeature,
|
||||
type StartupLifecycleFeatureOptions,
|
||||
type StartupLifecycleHost,
|
||||
} from "./index";
|
||||
|
||||
function createHost() {
|
||||
const addLayoutHandler = vi.fn();
|
||||
const addFirstInitialiseHandler = vi.fn();
|
||||
const eventHandlers = new Map<
|
||||
string,
|
||||
{ callback: (...args: unknown[]) => unknown; unsubscribe: ReturnType<typeof vi.fn> }
|
||||
>();
|
||||
const onEvent = vi.fn((event: string, callback: (...args: unknown[]) => unknown) => {
|
||||
const unsubscribe = vi.fn();
|
||||
eventHandlers.set(event, { callback, unsubscribe });
|
||||
return unsubscribe;
|
||||
});
|
||||
const host = {
|
||||
services: {
|
||||
API: {},
|
||||
UI: {},
|
||||
appLifecycle: {
|
||||
onLayoutReady: { addHandler: addLayoutHandler },
|
||||
onFirstInitialise: { addHandler: addFirstInitialiseHandler },
|
||||
},
|
||||
context: {
|
||||
events: { onEvent },
|
||||
noticeGroups: {},
|
||||
translate: String,
|
||||
},
|
||||
setting: {
|
||||
currentSettings: vi.fn(() => ({ isConfigured: true })),
|
||||
},
|
||||
},
|
||||
serviceModules: {},
|
||||
} as unknown as StartupLifecycleHost;
|
||||
return { addFirstInitialiseHandler, addLayoutHandler, eventHandlers, host, onEvent };
|
||||
}
|
||||
|
||||
function createOptions(events: string[] = []): StartupLifecycleFeatureOptions {
|
||||
return {
|
||||
inviteToOnboarding: vi.fn(() => events.push("invite")),
|
||||
waitForCompatibilityReview: vi.fn(async () => {
|
||||
events.push("compatibility-review");
|
||||
}),
|
||||
databaseReady: true,
|
||||
reportDatabaseNotReady: vi.fn(() => events.push("database-not-ready")),
|
||||
hasCompromisedChunks: vi.fn(async () => {
|
||||
events.push("compromised-chunks");
|
||||
return true;
|
||||
}),
|
||||
hasIncompleteDocuments: vi.fn(async () => {
|
||||
events.push("incomplete-documents");
|
||||
return true;
|
||||
}),
|
||||
runDoctor: vi.fn(async () => {
|
||||
events.push("doctor");
|
||||
return true;
|
||||
}),
|
||||
migrateBulkSend: vi.fn(async () => {
|
||||
events.push("bulk-send");
|
||||
}),
|
||||
log: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("useStartupLifecycleFeature", () => {
|
||||
it("registers layout admission at priority 1 and first-initialise in the established order", async () => {
|
||||
const events: string[] = [];
|
||||
const { addFirstInitialiseHandler, addLayoutHandler, host } = createHost();
|
||||
const options = createOptions(events);
|
||||
useStartupLifecycleFeature(host, options);
|
||||
|
||||
expect(addLayoutHandler).toHaveBeenCalledWith(expect.any(Function), STARTUP_LIFECYCLE_LAYOUT_PRIORITY);
|
||||
expect(addFirstInitialiseHandler).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
const layoutAdmission = addLayoutHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
await expect(layoutAdmission()).resolves.toBe(true);
|
||||
const firstInitialise = addFirstInitialiseHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
await expect(firstInitialise()).resolves.toBe(true);
|
||||
expect(events).toEqual([
|
||||
"compromised-chunks",
|
||||
"incomplete-documents",
|
||||
"compatibility-review",
|
||||
"doctor",
|
||||
"bulk-send",
|
||||
]);
|
||||
});
|
||||
|
||||
it("short-circuits first-initialise when database readiness or an integrity check fails", async () => {
|
||||
const events: string[] = [];
|
||||
const { addFirstInitialiseHandler, addLayoutHandler, host } = createHost();
|
||||
const options = createOptions(events);
|
||||
let databaseReady = false;
|
||||
Object.assign(options, { databaseReady: () => databaseReady });
|
||||
|
||||
useStartupLifecycleFeature(host, options);
|
||||
const layoutAdmission = addLayoutHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
await expect(layoutAdmission()).resolves.toBe(true);
|
||||
const firstInitialise = addFirstInitialiseHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
await expect(firstInitialise()).resolves.toBe(false);
|
||||
expect(events).toEqual(["database-not-ready"]);
|
||||
|
||||
databaseReady = true;
|
||||
vi.mocked(options.hasCompromisedChunks!).mockImplementation(async () => {
|
||||
events.push("compromised-chunks");
|
||||
return false;
|
||||
});
|
||||
await expect(firstInitialise()).resolves.toBe(false);
|
||||
expect(events).toEqual(["database-not-ready", "compromised-chunks"]);
|
||||
expect(options.hasIncompleteDocuments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not re-admit a Vault which was unconfigured at its first layout invocation", async () => {
|
||||
const { addFirstInitialiseHandler, addLayoutHandler, eventHandlers, host } = createHost();
|
||||
const inviteToOnboarding = vi.fn();
|
||||
const options = {
|
||||
...createOptions(),
|
||||
configured: false,
|
||||
inviteToOnboarding,
|
||||
} satisfies StartupLifecycleFeatureOptions;
|
||||
useStartupLifecycleFeature(host, options);
|
||||
const layoutAdmission = addLayoutHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
|
||||
await expect(layoutAdmission()).resolves.toBe(false);
|
||||
expect(inviteToOnboarding).toHaveBeenCalledOnce();
|
||||
expect(eventHandlers.has(EVENT_REQUEST_RUN_DOCTOR)).toBe(false);
|
||||
expect(eventHandlers.has(EVENT_REQUEST_RUN_FIX_INCOMPLETE)).toBe(false);
|
||||
|
||||
Object.assign(options, { configured: true });
|
||||
await expect(layoutAdmission()).resolves.toBe(false);
|
||||
expect(eventHandlers.has(EVENT_REQUEST_RUN_DOCTOR)).toBe(false);
|
||||
expect(eventHandlers.has(EVENT_REQUEST_RUN_FIX_INCOMPLETE)).toBe(false);
|
||||
|
||||
const firstInitialise = addFirstInitialiseHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
await expect(firstInitialise()).resolves.toBe(false);
|
||||
expect(options.runDoctor).not.toHaveBeenCalled();
|
||||
expect(eventHandlers.has(EVENT_SETTING_SAVED)).toBe(false);
|
||||
});
|
||||
|
||||
it("retires an admitted generation when settings become unconfigured and guards request races", async () => {
|
||||
const { addFirstInitialiseHandler, addLayoutHandler, eventHandlers, host } = createHost();
|
||||
let configured = true;
|
||||
let databaseReady = true;
|
||||
const options = {
|
||||
...createOptions(),
|
||||
configured: () => configured,
|
||||
databaseReady: () => databaseReady,
|
||||
} satisfies StartupLifecycleFeatureOptions;
|
||||
useStartupLifecycleFeature(host, options);
|
||||
const layoutAdmission = addLayoutHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
|
||||
await expect(layoutAdmission()).resolves.toBe(true);
|
||||
expect(eventHandlers.get(EVENT_REQUEST_RUN_DOCTOR)).toBeDefined();
|
||||
expect(eventHandlers.get(EVENT_REQUEST_RUN_FIX_INCOMPLETE)).toBeDefined();
|
||||
|
||||
const runDoctor = eventHandlers.get(EVENT_REQUEST_RUN_DOCTOR)!.callback as (reason: string) => Promise<void>;
|
||||
const fixIncomplete = eventHandlers.get(EVENT_REQUEST_RUN_FIX_INCOMPLETE)!.callback as () => Promise<void>;
|
||||
const settingSaved = eventHandlers.get(EVENT_SETTING_SAVED)!.callback as (settings: unknown) => unknown;
|
||||
|
||||
await settingSaved({ isConfigured: true });
|
||||
expect(eventHandlers.get(EVENT_REQUEST_RUN_DOCTOR)!.unsubscribe).not.toHaveBeenCalled();
|
||||
expect(eventHandlers.get(EVENT_REQUEST_RUN_FIX_INCOMPLETE)!.unsubscribe).not.toHaveBeenCalled();
|
||||
|
||||
databaseReady = false;
|
||||
await runDoctor("database race");
|
||||
await fixIncomplete();
|
||||
expect(options.runDoctor).not.toHaveBeenCalled();
|
||||
expect(options.hasIncompleteDocuments).not.toHaveBeenCalled();
|
||||
|
||||
databaseReady = true;
|
||||
configured = false;
|
||||
await runDoctor("configuration race");
|
||||
await fixIncomplete();
|
||||
expect(options.runDoctor).not.toHaveBeenCalled();
|
||||
expect(options.hasIncompleteDocuments).not.toHaveBeenCalled();
|
||||
|
||||
await settingSaved({ isConfigured: false });
|
||||
expect(eventHandlers.get(EVENT_REQUEST_RUN_DOCTOR)!.unsubscribe).toHaveBeenCalledOnce();
|
||||
expect(eventHandlers.get(EVENT_REQUEST_RUN_FIX_INCOMPLETE)!.unsubscribe).toHaveBeenCalledOnce();
|
||||
expect(eventHandlers.get(EVENT_SETTING_SAVED)!.unsubscribe).toHaveBeenCalledOnce();
|
||||
|
||||
configured = true;
|
||||
await expect(layoutAdmission()).resolves.toBe(false);
|
||||
await runDoctor("retired generation");
|
||||
await fixIncomplete();
|
||||
expect(options.runDoctor).not.toHaveBeenCalled();
|
||||
expect(options.hasIncompleteDocuments).not.toHaveBeenCalled();
|
||||
|
||||
const firstInitialise = addFirstInitialiseHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
await expect(firstInitialise()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("keeps doctor and incomplete-document request operations behind layout admission", async () => {
|
||||
const { addLayoutHandler, eventHandlers, host, onEvent } = createHost();
|
||||
const options = createOptions();
|
||||
useStartupLifecycleFeature(host, options);
|
||||
const layoutAdmission = addLayoutHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
|
||||
await layoutAdmission();
|
||||
await layoutAdmission();
|
||||
expect(onEvent.mock.calls.filter(([event]) => event === EVENT_REQUEST_RUN_DOCTOR)).toHaveLength(1);
|
||||
expect(onEvent.mock.calls.filter(([event]) => event === EVENT_REQUEST_RUN_FIX_INCOMPLETE)).toHaveLength(1);
|
||||
|
||||
const runDoctor = eventHandlers.get(EVENT_REQUEST_RUN_DOCTOR)!.callback as (reason: string) => Promise<void>;
|
||||
const fixIncomplete = eventHandlers.get(EVENT_REQUEST_RUN_FIX_INCOMPLETE)!.callback as () => Promise<void>;
|
||||
await runDoctor("manual request");
|
||||
await fixIncomplete();
|
||||
|
||||
expect(options.runDoctor).toHaveBeenCalledWith(false, "manual request", true);
|
||||
expect(options.hasIncompleteDocuments).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AnyEntry, ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import type { LiveSyncEventHub, ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import type { ObsidianNoticeGroups } from "@/modules/services/ObsidianNoticeGroups";
|
||||
|
||||
/** A value which may be read once or evaluated when a lifecycle handler runs. */
|
||||
export type StartupLifecycleValue<T> = T | (() => T);
|
||||
|
||||
/** The minimum Context extension needed by the Obsidian start-up integrity check. */
|
||||
export type StartupLifecycleContext = ServiceContext & {
|
||||
readonly events: LiveSyncEventHub;
|
||||
readonly noticeGroups: Pick<ObsidianNoticeGroups, "setItem" | "finish">;
|
||||
};
|
||||
|
||||
/** Services and ServiceModules consumed by the start-up feature composer. */
|
||||
export type StartupLifecycleHost = NecessaryServices<
|
||||
"API" | "UI" | "appLifecycle" | "setting" | "replicator" | "vault" | "path" | "keyValueDB" | "database",
|
||||
"storageAccess" | "fileHandler" | "rebuilder"
|
||||
> & {
|
||||
services: NecessaryServices<
|
||||
"API" | "UI" | "appLifecycle" | "setting" | "replicator" | "vault" | "path" | "keyValueDB" | "database",
|
||||
"storageAccess" | "fileHandler" | "rebuilder"
|
||||
>["services"] & {
|
||||
context: StartupLifecycleContext;
|
||||
};
|
||||
};
|
||||
|
||||
/** Focused operations which make up the configured Vault first-initialise gate. */
|
||||
export interface ConfiguredStartupLifecycleOperations {
|
||||
readonly databaseReady: StartupLifecycleValue<boolean>;
|
||||
readonly reportDatabaseNotReady: () => void;
|
||||
readonly hasCompromisedChunks: () => Promise<boolean>;
|
||||
readonly hasIncompleteDocuments: (force?: boolean) => Promise<boolean>;
|
||||
readonly waitForCompatibilityReview: () => Promise<void>;
|
||||
readonly runDoctor: (skipRebuild?: boolean, activateReason?: string, forceRescan?: boolean) => Promise<boolean>;
|
||||
readonly migrateBulkSend: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Explicit host decisions and optional operation overrides for composition. */
|
||||
export interface StartupLifecycleFeatureOptions extends Partial<ConfiguredStartupLifecycleOperations> {
|
||||
/** Invites an unconfigured Vault to begin onboarding. */
|
||||
readonly inviteToOnboarding: () => void;
|
||||
/** Waits for the compatibility review before opening Config Doctor. */
|
||||
readonly waitForCompatibilityReview: () => Promise<void>;
|
||||
/** Current configured-state query; defaults to the loaded setting. */
|
||||
readonly configured?: StartupLifecycleValue<boolean>;
|
||||
/** Logger used by the default operations. */
|
||||
readonly log?: LogFunction;
|
||||
}
|
||||
|
||||
/** Minimal mutable settings view required by the obsolete bulk-send migration. */
|
||||
export type LegacyBulkSendSettings = Pick<ObsidianLiveSyncSettings, "sendChunksBulk" | "sendChunksBulkMaxSize">;
|
||||
|
||||
/** Keep path conversion visible at the incomplete-document operation boundary. */
|
||||
export type StartupPathReader = (entry: AnyEntry) => string;
|
||||
@@ -166,9 +166,15 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
|
||||
|
||||
`test:e2e:obsidian:startup-scan` starts from a CouchDB fixture using current settings with its device-local compatibility marker already acknowledged, stops Obsidian, writes a note directly into the Vault, restarts the same isolated Vault and profile without rewriting its plug-in data, and verifies from CouchDB that the start-up scan picked up the offline file. Onboarding remains covered by `onboarding-invitation`; this scenario owns the ordinary configured restart and start-up scan.
|
||||
|
||||
`test:e2e:obsidian:partial-startup-file-failure` is a focused Linux release-acceptance scenario for an ordinary configured restart. It stores one valid database-only note and one database-only note whose path component is 258 UTF-8 bytes, then restarts the same isolated Vault and profile. On a Linux test Vault which enforces the conventional 255-byte component limit, the scenario requires the valid file to be reflected, the application to become ready, the partial-failure Notice to appear, and the failed path to remain readable and eligible for a later scan with its exact path in the verbose log. It remains outside `local-suite` because the failure fixture is deliberately platform-specific.
|
||||
|
||||
`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 a new, empty Vault in the first real Obsidian session 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 initial Setup URI produced by the provisioning tool. 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 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 conflict 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: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 the ordinary workflow because they require directory-aware rename handling.
|
||||
|
||||
During focused development, `E2E_OBSIDIAN_ONLY_PARENT_CASE_DELETION=true` runs an Issue #1168 check which renames `parent/test3` to `parent/Test3` through external `node:fs/promises.rename` while Vault A is open, and verifies that the note content, Metadata, and Chunk references are not logically deleted locally, remotely, or after restart. It accepts either case spelling on Vault B, so it does not provide directory rename support or exact case convergence between devices. The natural Obsidian event sequence and resulting database state are evidence for the selected build; an existing-version reproduction result must be reported separately from fixed-version safety evidence.
|
||||
|
||||
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 conflict 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:security-seed-reconnect` is a focused CouchDB release-acceptance workflow. Device A first recognises an initial remote Security Seed, stops automatic replication while remaining open, and creates an unsent note. The runner replaces only the Security Seed in the managed remote synchronisation-parameter fixture. Device A must retain its deliberately stale cached value until the next one-shot synchronisation, refresh it before sending, and upload an HKDF-encrypted payload which uses the replacement value. A fresh device B must decrypt that note and send an encrypted note back; the original device A then receives the return journey with its Vault and isolated profile preserved. Desktop Obsidian may enforce a single application instance, so the two device sessions run sequentially after the same-process stale-cache assertion has completed.
|
||||
|
||||
@@ -256,6 +262,7 @@ Useful environment variables:
|
||||
- `E2E_OBSIDIAN_FILE_TIMEOUT_MS`: timeout for waiting until a note created through Obsidian's vault API is reflected to disk.
|
||||
- `E2E_OBSIDIAN_CORE_READY_TIMEOUT_MS`: timeout for waiting until Self-hosted LiveSync reports that its core lifecycle and local database are ready.
|
||||
- `E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS`: timeout for waiting until a file appears in Self-hosted LiveSync's local database.
|
||||
- `E2E_OBSIDIAN_ONLY_PARENT_CASE_DELETION=true`: run only the focused external parent-directory case-rename protection check in `two-vault-sync`.
|
||||
- `E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS`: timeout for waiting until CouchDB contains uploaded E2E documents.
|
||||
- `E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS`: timeout for an observed remote activity to enter or leave its status boundary; default is 30 seconds.
|
||||
- `E2E_OBSIDIAN_DIAGNOSTICS_DIR`: directory for screenshots and status snapshots, including the Security Seed reconnect stages; default is `/tmp/obsidian-livesync-e2e`.
|
||||
|
||||
@@ -19,6 +19,7 @@ export type SetupState = {
|
||||
endpoint: string;
|
||||
bucket: string;
|
||||
bucketPrefix: string;
|
||||
useCustomRequestHandler: boolean;
|
||||
p2pEnabled: boolean;
|
||||
p2pRelays: string;
|
||||
p2pRoomId: string;
|
||||
@@ -354,6 +355,7 @@ export async function readSetupState(cliBinary: string, environment: NodeJS.Proc
|
||||
"endpoint:settings.endpoint||'',",
|
||||
"bucket:settings.bucket||'',",
|
||||
"bucketPrefix:settings.bucketPrefix||'',",
|
||||
"useCustomRequestHandler:settings.useCustomRequestHandler===true,",
|
||||
"p2pEnabled:settings.P2P_Enabled===true,",
|
||||
"p2pRelays:settings.P2P_relays||'',",
|
||||
"p2pRoomId:settings.P2P_roomID||'',",
|
||||
|
||||
@@ -287,19 +287,28 @@ export async function captureObsidianElement(
|
||||
await mkdir(dirname(screenshotPath), { recursive: true });
|
||||
|
||||
await withObsidianPage(port, async (page) => {
|
||||
try {
|
||||
const element = await resolveElement(page);
|
||||
await element.waitFor({ state: "visible", timeout: timeoutMs });
|
||||
await element.screenshot({
|
||||
path: screenshotPath,
|
||||
animations: "disabled",
|
||||
style: ".notice-container { visibility: hidden !important; }",
|
||||
});
|
||||
} catch (error) {
|
||||
const failurePath = screenshotPath.replace(/\.png$/u, ".failure.png");
|
||||
await page.screenshot({ path: failurePath, fullPage: true });
|
||||
console.error(`UI element failure screenshot: ${failurePath}`);
|
||||
throw error;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const element = await resolveElement(page);
|
||||
await element.waitFor({ state: "visible", timeout: timeoutMs });
|
||||
await element.screenshot({
|
||||
path: screenshotPath,
|
||||
animations: "disabled",
|
||||
style: ".notice-container { visibility: hidden !important; }",
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
const detachedDuringCapture =
|
||||
error instanceof Error && error.message.includes("not attached to the DOM");
|
||||
if (detachedDuringCapture && attempt < 2) {
|
||||
await page.waitForTimeout(50);
|
||||
continue;
|
||||
}
|
||||
const failurePath = screenshotPath.replace(/\.png$/u, ".failure.png");
|
||||
await page.screenshot({ path: failurePath, fullPage: true });
|
||||
console.error(`UI element failure screenshot: ${failurePath}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
waitForLocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianElement, withObsidianPage } from "../runner/ui.ts";
|
||||
import { captureObsidianElement, captureObsidianPage, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const path = "conflict-dialog-policy.md";
|
||||
@@ -14,6 +14,12 @@ const baseContent = "Conflict dialogue policy\n\nShared base.\n";
|
||||
const leftContent = "Conflict dialogue policy\n\nChanged on the left.\n";
|
||||
const rightContent = "Conflict dialogue policy\n\nChanged on the right.\n";
|
||||
const thirdContent = "Conflict dialogue policy\n\nChanged on the third branch.\n";
|
||||
const repeatedPath = "conflict-dialog-repeated.md";
|
||||
const activePath = "conflict-dialog-active.md";
|
||||
const waitingPath = "conflict-dialog-waiting.md";
|
||||
const externallyResolvedWaitingPath = "conflict-dialog-resolved-while-waiting.md";
|
||||
const unloadActivePath = "conflict-dialog-unload-active.md";
|
||||
const unloadWaitingPath = "conflict-dialog-unload-waiting.md";
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_CONFLICT_DIALOG_TIMEOUT_MS ?? 10000);
|
||||
|
||||
type ConflictFixture = {
|
||||
@@ -24,16 +30,35 @@ type ConflictFixture = {
|
||||
|
||||
type ObsidianTestApp = {
|
||||
commands?: { executeCommandById(commandId: string): boolean };
|
||||
plugins?: {
|
||||
disablePlugin(pluginId: string): Promise<void>;
|
||||
enablePlugin(pluginId: string): Promise<void>;
|
||||
plugins?: Record<
|
||||
string,
|
||||
| {
|
||||
core?: {
|
||||
services?: {
|
||||
conflict?: { ensureAllProcessed(): Promise<boolean> };
|
||||
};
|
||||
};
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
};
|
||||
};
|
||||
|
||||
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
|
||||
type ObsidianTestGlobal = typeof globalThis & {
|
||||
app?: ObsidianTestApp;
|
||||
__livesyncConflictChecksCompleted?: boolean;
|
||||
__livesyncWaitingConflictCompleted?: boolean;
|
||||
};
|
||||
|
||||
async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv, targetPath = path): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const path=${JSON.stringify(targetPath)};`,
|
||||
`const content=${JSON.stringify(baseContent)};`,
|
||||
"let file=app.vault.getAbstractFileByPath(path);",
|
||||
"if(!file) file=await app.vault.create(path,content);",
|
||||
@@ -49,13 +74,14 @@ async function createManualConflict(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
baseRev: string,
|
||||
contents: readonly string[]
|
||||
contents: readonly string[],
|
||||
targetPath = path
|
||||
): Promise<ConflictFixture> {
|
||||
return await evalObsidianJson<ConflictFixture>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const path=${JSON.stringify(targetPath)};`,
|
||||
`const baseRev=${JSON.stringify(baseRev)};`,
|
||||
`const contents=${JSON.stringify(contents)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
@@ -81,12 +107,16 @@ async function createManualConflict(
|
||||
);
|
||||
}
|
||||
|
||||
async function readConflictFixture(cliBinary: string, env: NodeJS.ProcessEnv): Promise<ConflictFixture> {
|
||||
async function readConflictFixture(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
targetPath = path
|
||||
): Promise<ConflictFixture> {
|
||||
return await evalObsidianJson<ConflictFixture>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const path=${JSON.stringify(targetPath)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true,revs:true},true);",
|
||||
"if(!meta?._rev){",
|
||||
@@ -106,13 +136,14 @@ async function readConflictFixture(cliBinary: string, env: NodeJS.ProcessEnv): P
|
||||
async function waitForConflictCount(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
expectedConflictCount: number
|
||||
expectedConflictCount: number,
|
||||
targetPath = path
|
||||
): Promise<ConflictFixture> {
|
||||
const deadline = Date.now() + uiTimeoutMs;
|
||||
let fixture = await readConflictFixture(cliBinary, env);
|
||||
let fixture = await readConflictFixture(cliBinary, env, targetPath);
|
||||
while (fixture.conflicts.length !== expectedConflictCount && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
fixture = await readConflictFixture(cliBinary, env);
|
||||
fixture = await readConflictFixture(cliBinary, env, targetPath);
|
||||
}
|
||||
if (fixture.conflicts.length !== expectedConflictCount) {
|
||||
throw new Error(
|
||||
@@ -122,12 +153,17 @@ async function waitForConflictCount(
|
||||
return fixture;
|
||||
}
|
||||
|
||||
async function requestConflictCheck(cliBinary: string, env: NodeJS.ProcessEnv, waitForCompletion = false) {
|
||||
async function requestConflictCheck(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
waitForCompletion = false,
|
||||
targetPath = path
|
||||
) {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const path=${JSON.stringify(targetPath)};`,
|
||||
`const waitForCompletion=${JSON.stringify(waitForCompletion)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"await core.services.conflict.queueCheckFor(path);",
|
||||
@@ -159,13 +195,14 @@ async function applyReplicatedConflictResolution(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
revisionToDelete: string,
|
||||
expectedConflictCount = 0
|
||||
expectedConflictCount = 0,
|
||||
targetPath = path
|
||||
): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const path=${JSON.stringify(targetPath)};`,
|
||||
`const revisionToDelete=${JSON.stringify(revisionToDelete)};`,
|
||||
`const expectedConflictCount=${JSON.stringify(expectedConflictCount)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
@@ -191,10 +228,135 @@ async function applyReplicatedConflictResolution(
|
||||
);
|
||||
}
|
||||
|
||||
function conflictDialogue(page: Parameters<Parameters<typeof withObsidianPage>[1]>[0]) {
|
||||
return page.locator(".modal-container").filter({
|
||||
function conflictDialogue(page: Parameters<Parameters<typeof withObsidianPage>[1]>[0], targetPath?: string) {
|
||||
const dialogues = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Conflicting changes" }),
|
||||
});
|
||||
return targetPath === undefined ? dialogues : dialogues.filter({ hasText: targetPath });
|
||||
}
|
||||
|
||||
async function createTwoVersionConflict(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
targetPath: string
|
||||
): Promise<ConflictFixture> {
|
||||
await createAndOpenBaseFile(cliBinary, env, targetPath);
|
||||
const base = await waitForLocalDatabaseEntry(cliBinary, env, targetPath);
|
||||
const fixture = await createManualConflict(cliBinary, env, base.rev, [leftContent, rightContent], targetPath);
|
||||
if (fixture.conflicts.length !== 1) {
|
||||
throw new Error(`Expected exactly two live leaves for ${targetPath}: ${JSON.stringify(fixture)}`);
|
||||
}
|
||||
return fixture;
|
||||
}
|
||||
|
||||
async function setShowMergeDialogOnlyOnActive(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
enabled: boolean
|
||||
): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(()=>{",
|
||||
`const enabled=${JSON.stringify(enabled)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"core.settings.showMergeDialogOnlyOnActive=enabled;",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function requestRepeatedConflictChecks(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
targetPath: string,
|
||||
count: number
|
||||
): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(targetPath)};`,
|
||||
`const count=${JSON.stringify(count)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"await Promise.all(Array.from({length:count},()=>core.services.conflict.queueCheckFor(path)));",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function requestInteractiveConflictResolution(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
targetPath: string,
|
||||
fixture: ConflictFixture,
|
||||
trackCompletion = false
|
||||
): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(()=>{",
|
||||
`const path=${JSON.stringify(targetPath)};`,
|
||||
`const currentRev=${JSON.stringify(fixture.currentRev)};`,
|
||||
`const conflictRev=${JSON.stringify(fixture.conflicts[0])};`,
|
||||
`const trackCompletion=${JSON.stringify(trackCompletion)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"if(trackCompletion) globalThis.__livesyncWaitingConflictCompleted=false;",
|
||||
"const pending=core.services.conflict.resolveByUserInteraction(path,{",
|
||||
"left:{rev:currentRev,data:'Current branch',ctime:1,mtime:2},",
|
||||
"right:{rev:conflictRev,data:'Conflict branch',ctime:1,mtime:3},",
|
||||
"diff:[[0,'Current and conflict branches']],",
|
||||
"});",
|
||||
"if(trackCompletion) void pending.then(()=>{globalThis.__livesyncWaitingConflictCompleted=true;});",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function disableLiveSyncAndWaitForConflictChecks(port: number): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const host = globalThis as ObsidianTestGlobal;
|
||||
const conflict = host.app?.plugins?.plugins?.["obsidian-livesync"]?.core?.services?.conflict;
|
||||
if (conflict === undefined) throw new Error("LiveSync conflict service is unavailable before unload");
|
||||
host.__livesyncConflictChecksCompleted = false;
|
||||
void conflict.ensureAllProcessed().then(() => {
|
||||
host.__livesyncConflictChecksCompleted = true;
|
||||
});
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
const plugins = (globalThis as ObsidianTestGlobal).app?.plugins;
|
||||
if (plugins === undefined) throw new Error("Obsidian plug-in manager is unavailable");
|
||||
await plugins.disablePlugin("obsidian-livesync");
|
||||
});
|
||||
await conflictDialogue(page).waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const host = globalThis as ObsidianTestGlobal;
|
||||
return (
|
||||
host.__livesyncConflictChecksCompleted === true && host.__livesyncWaitingConflictCompleted === true
|
||||
);
|
||||
},
|
||||
undefined,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function enableLiveSync(port: number): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate(async () => {
|
||||
const plugins = (globalThis as ObsidianTestGlobal).app?.plugins;
|
||||
if (plugins === undefined) throw new Error("Obsidian plug-in manager is unavailable");
|
||||
await plugins.enablePlugin("obsidian-livesync");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -261,7 +423,17 @@ async function main(): Promise<void> {
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
const actionButtonBounds = await modal.locator(".conflict-action-button").evaluateAll((buttons) =>
|
||||
});
|
||||
const firstDialogueScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"conflict-dialog-three-versions.png",
|
||||
(page) => conflictDialogue(page).locator(".modal").first()
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const modal = conflictDialogue(page);
|
||||
const actionButtons = modal.locator(".conflict-action-button");
|
||||
await actionButtons.nth(3).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const actionButtonBounds = await actionButtons.evaluateAll((buttons) =>
|
||||
buttons.map((button) => {
|
||||
const bounds = button.getBoundingClientRect();
|
||||
return { top: bounds.top, bottom: bounds.bottom };
|
||||
@@ -273,16 +445,20 @@ async function main(): Promise<void> {
|
||||
(bounds, index) => index > 0 && bounds.top < actionButtonBounds[index - 1].bottom
|
||||
)
|
||||
) {
|
||||
const buttonDetails = await modal.locator("button").evaluateAll((buttons) =>
|
||||
buttons.map((button) => ({
|
||||
text: button.textContent,
|
||||
className: button.className,
|
||||
}))
|
||||
);
|
||||
throw new Error(
|
||||
`Conflict action buttons are not stacked vertically: ${JSON.stringify(actionButtonBounds)}`
|
||||
`Conflict action buttons are not stacked vertically: ${JSON.stringify({
|
||||
actionButtonBounds,
|
||||
buttonDetails,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
});
|
||||
const firstDialogueScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"conflict-dialog-three-versions.png",
|
||||
(page) => conflictDialogue(page).locator(".modal").first()
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const modal = conflictDialogue(page);
|
||||
await modal.getByRole("button", { name: "Concat both", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
@@ -415,11 +591,186 @@ async function main(): Promise<void> {
|
||||
.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
// Keep concurrent manual comparisons independent from the active-file
|
||||
// gate so that the dialogue serialisation policy is exercised directly.
|
||||
await setShowMergeDialogOnlyOnActive(cliBinary, session.cliEnv, false);
|
||||
|
||||
await createTwoVersionConflict(cliBinary, session.cliEnv, repeatedPath);
|
||||
await requestConflictCheck(cliBinary, session.cliEnv, false, repeatedPath);
|
||||
const repeatedSession = session;
|
||||
await withObsidianPage(repeatedSession.remoteDebuggingPort, async (page) => {
|
||||
const modal = conflictDialogue(page, repeatedPath);
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const original = await modal.elementHandle();
|
||||
if (original === null) throw new Error("Could not retain the original same-file conflict dialogue");
|
||||
|
||||
await requestRepeatedConflictChecks(cliBinary, repeatedSession.cliEnv, repeatedPath, 3);
|
||||
await page.waitForFunction((element) => !element.isConnected, original, { timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await page.waitForTimeout(250);
|
||||
const visibleCount = await conflictDialogue(page, repeatedPath).evaluateAll(
|
||||
(elements) => elements.filter((element) => element.getClientRects().length > 0).length
|
||||
);
|
||||
if (visibleCount !== 1) {
|
||||
throw new Error(`Expected one newest same-file dialogue, but found ${visibleCount}`);
|
||||
}
|
||||
});
|
||||
const repeatedDialogueScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"conflict-dialog-same-file-replacement.png",
|
||||
(page) => conflictDialogue(page, repeatedPath).locator(".modal").first()
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const modal = conflictDialogue(page, repeatedPath);
|
||||
await modal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
await waitForConflictChecks(cliBinary, session.cliEnv);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.waitForTimeout(500);
|
||||
if (await conflictDialogue(page, repeatedPath).isVisible()) {
|
||||
throw new Error("A superseded same-file conflict dialogue opened after the newest request completed");
|
||||
}
|
||||
});
|
||||
|
||||
await createTwoVersionConflict(cliBinary, session.cliEnv, activePath);
|
||||
await createTwoVersionConflict(cliBinary, session.cliEnv, waitingPath);
|
||||
const externallyResolvedWaitingFixture = await createTwoVersionConflict(
|
||||
cliBinary,
|
||||
session.cliEnv,
|
||||
externallyResolvedWaitingPath
|
||||
);
|
||||
await requestConflictCheck(cliBinary, session.cliEnv, false, activePath);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await conflictDialogue(page, activePath).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
await requestConflictCheck(cliBinary, session.cliEnv, false, waitingPath);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.waitForTimeout(500);
|
||||
if (!(await conflictDialogue(page, activePath).isVisible())) {
|
||||
throw new Error("A different-file request closed the active conflict dialogue");
|
||||
}
|
||||
if (await conflictDialogue(page, waitingPath).isVisible()) {
|
||||
throw new Error("A different-file conflict dialogue opened before the active dialogue completed");
|
||||
}
|
||||
});
|
||||
const differentFileWaitingScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"conflict-dialog-different-file-waiting.png",
|
||||
(page) => conflictDialogue(page, activePath).locator(".modal").first()
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const activeModal = conflictDialogue(page, activePath);
|
||||
await activeModal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await activeModal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
await conflictDialogue(page, waitingPath).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
await requestInteractiveConflictResolution(
|
||||
cliBinary,
|
||||
session.cliEnv,
|
||||
externallyResolvedWaitingPath,
|
||||
externallyResolvedWaitingFixture,
|
||||
true
|
||||
);
|
||||
await applyReplicatedConflictResolution(
|
||||
cliBinary,
|
||||
session.cliEnv,
|
||||
externallyResolvedWaitingFixture.conflicts[0],
|
||||
0,
|
||||
externallyResolvedWaitingPath
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
if (!(await conflictDialogue(page, waitingPath).isVisible())) {
|
||||
throw new Error("Resolving a waiting file elsewhere closed the active different-file dialogue");
|
||||
}
|
||||
if (await conflictDialogue(page, externallyResolvedWaitingPath).isVisible()) {
|
||||
throw new Error("A conflict dialogue opened for a waiting file which was already resolved elsewhere");
|
||||
}
|
||||
const waitingModal = conflictDialogue(page, waitingPath);
|
||||
await waitingModal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await waitingModal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
await page.waitForFunction(
|
||||
() => (globalThis as ObsidianTestGlobal).__livesyncWaitingConflictCompleted === true,
|
||||
undefined,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
await page.waitForTimeout(250);
|
||||
if (await conflictDialogue(page, externallyResolvedWaitingPath).isVisible()) {
|
||||
throw new Error("A resolved waiting-file dialogue opened after the active dialogue completed");
|
||||
}
|
||||
});
|
||||
await waitForConflictChecks(cliBinary, session.cliEnv);
|
||||
|
||||
await createTwoVersionConflict(cliBinary, session.cliEnv, unloadActivePath);
|
||||
const unloadWaitingFixture = await createTwoVersionConflict(cliBinary, session.cliEnv, unloadWaitingPath);
|
||||
await requestConflictCheck(cliBinary, session.cliEnv, false, unloadActivePath);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await conflictDialogue(page, unloadActivePath).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
await requestInteractiveConflictResolution(
|
||||
cliBinary,
|
||||
session.cliEnv,
|
||||
unloadWaitingPath,
|
||||
unloadWaitingFixture,
|
||||
true
|
||||
);
|
||||
const beforeUnloadScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"conflict-dialog-before-unload.png",
|
||||
(page) => conflictDialogue(page, unloadActivePath).locator(".modal").first()
|
||||
);
|
||||
await disableLiveSyncAndWaitForConflictChecks(session.remoteDebuggingPort);
|
||||
const afterUnloadScreenshot = await captureObsidianPage(
|
||||
session.remoteDebuggingPort,
|
||||
"conflict-dialog-after-unload.png",
|
||||
async (page) => {
|
||||
await page.waitForTimeout(250);
|
||||
if (await conflictDialogue(page).isVisible()) {
|
||||
throw new Error("A conflict dialogue remained visible after LiveSync was unloaded");
|
||||
}
|
||||
const pluginStillLoaded = await page.evaluate(
|
||||
() => (globalThis as ObsidianTestGlobal).app?.plugins?.plugins?.["obsidian-livesync"] !== undefined
|
||||
);
|
||||
if (pluginStillLoaded) throw new Error("LiveSync remained loaded after disablePlugin completed");
|
||||
}
|
||||
);
|
||||
|
||||
await enableLiveSync(session.remoteDebuggingPort);
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.waitForTimeout(500);
|
||||
if (await conflictDialogue(page).isVisible()) {
|
||||
throw new Error("A stale conflict dialogue reopened after LiveSync was enabled again");
|
||||
}
|
||||
});
|
||||
await createAndOpenBaseFile(cliBinary, session.cliEnv, unloadActivePath);
|
||||
await requestConflictCheck(cliBinary, session.cliEnv, false, unloadActivePath);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await conflictDialogue(page, unloadActivePath).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
const afterReloadScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"conflict-dialog-after-reload.png",
|
||||
(page) => conflictDialogue(page, unloadActivePath).locator(".modal").first()
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const modal = conflictDialogue(page, unloadActivePath);
|
||||
await modal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
await waitForConflictChecks(cliBinary, session.cliEnv);
|
||||
|
||||
console.log(
|
||||
"Real Obsidian reviewed three versions pairwise, retained the completed stage across restart, suppressed an ordinary repeat prompt after Not now, reopened the dialogue after the explicit command, and cleared both postponed and open-dialogue states after replicated resolutions."
|
||||
"Real Obsidian preserved pairwise conflict resolution and dialogue presentation; replaced only stale same-file dialogues; serialised different files; discarded externally resolved waiting requests; and drained active and waiting requests across unload and reload."
|
||||
);
|
||||
console.log(`Dialogue screenshot: ${firstDialogueScreenshot}`);
|
||||
console.log(`Postponed warning screenshot: ${warningScreenshot}`);
|
||||
console.log(`Same-file replacement screenshot: ${repeatedDialogueScreenshot}`);
|
||||
console.log(`Different-file waiting screenshot: ${differentFileWaitingScreenshot}`);
|
||||
console.log(`Before unload screenshot: ${beforeUnloadScreenshot}`);
|
||||
console.log(`After unload screenshot: ${afterUnloadScreenshot}`);
|
||||
console.log(`After reload screenshot: ${afterReloadScreenshot}`);
|
||||
} finally {
|
||||
if (session) {
|
||||
await session.app.stop();
|
||||
|
||||
@@ -15,6 +15,7 @@ const testSteps: Step[] = [
|
||||
{ name: "smoke", args: ["run", "test:e2e:obsidian:smoke"] },
|
||||
{ name: "onboarding invitation", args: ["run", "test:e2e:obsidian:onboarding-invitation"] },
|
||||
{ name: "Svelte dialogue mounts", args: ["run", "test:e2e:obsidian:dialog-mounts"] },
|
||||
{ name: "conflict dialogue policy", args: ["run", "test:e2e:obsidian:conflict-dialog-policy"] },
|
||||
{ name: "revision repair", args: ["run", "test:e2e:obsidian:revision-repair"] },
|
||||
{ name: "settings UI", args: ["run", "test:e2e:obsidian:settings-ui"] },
|
||||
{ name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] },
|
||||
@@ -34,6 +35,10 @@ const testSteps: Step[] = [
|
||||
name: "Object Storage Setup URI workflow",
|
||||
args: ["run", "test:e2e:obsidian:object-storage-setup-uri-workflow"],
|
||||
},
|
||||
{
|
||||
name: "Object Storage Custom HTTP Handler Setup URI workflow",
|
||||
args: ["run", "test:e2e:obsidian:object-storage-custom-http-handler-setup-uri-workflow"],
|
||||
},
|
||||
{ name: "P2P Setup URI workflow", args: ["run", "test:e2e:obsidian:p2p-setup-uri-workflow"] },
|
||||
{ name: "startup scan", args: ["run", "test:e2e:obsidian:startup-scan"] },
|
||||
{ name: "provisioned Setup URI workflow", args: ["run", "test:e2e:obsidian:setup-uri-workflow"] },
|
||||
|
||||
@@ -45,7 +45,10 @@ import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const captures: SetupCaptureNames = { scenario: "object-storage-setup-uri", guide: "object-storage-setup" };
|
||||
const useCustomRequestHandler = process.argv.includes("--custom-http-handler");
|
||||
const captures: SetupCaptureNames = useCustomRequestHandler
|
||||
? { scenario: "object-storage-custom-http-handler-setup-uri", guide: "object-storage-custom-http-handler-setup" }
|
||||
: { scenario: "object-storage-setup-uri", guide: "object-storage-setup" };
|
||||
const noteFromFirst = "E2E/object-storage/from-first.md";
|
||||
const noteFromSecond = "E2E/object-storage/from-second.md";
|
||||
const firstContent =
|
||||
@@ -94,7 +97,8 @@ async function runDeno(script: string, environment: NodeJS.ProcessEnv): Promise<
|
||||
|
||||
async function generateBootstrapSetupURI(
|
||||
objectStorage: ObjectStorageConfig,
|
||||
bucketPrefix: string
|
||||
bucketPrefix: string,
|
||||
useCustomRequestHandler: boolean
|
||||
): Promise<SetupArtifact> {
|
||||
const setupPassphrase = randomBytes(24).toString("base64url");
|
||||
const output = await runDeno("utils/setup/generate_setup_uri.ts", {
|
||||
@@ -107,6 +111,7 @@ async function generateBootstrapSetupURI(
|
||||
region: objectStorage.region,
|
||||
force_path_style: String(objectStorage.forcePathStyle),
|
||||
bucket_prefix: bucketPrefix,
|
||||
...(useCustomRequestHandler ? { use_custom_request_handler: "true" } : {}),
|
||||
passphrase: randomBytes(24).toString("base64url"),
|
||||
uri_passphrase: setupPassphrase,
|
||||
});
|
||||
@@ -251,7 +256,7 @@ async function main(): Promise<void> {
|
||||
|
||||
const objectStorage = await loadObjectStorageConfig();
|
||||
const bucketPrefix = makeUniqueBucketPrefix("setup-uri-workflow");
|
||||
const bootstrapArtifact = await generateBootstrapSetupURI(objectStorage, bucketPrefix);
|
||||
const bootstrapArtifact = await generateBootstrapSetupURI(objectStorage, bucketPrefix, useCustomRequestHandler);
|
||||
const vaultA = await createTemporaryVault();
|
||||
const vaultB = await createTemporaryVault();
|
||||
const [portA, portB] = sessionPorts();
|
||||
@@ -285,6 +290,11 @@ async function main(): Promise<void> {
|
||||
bucketPrefix,
|
||||
"The first device did not activate the unique bucket prefix."
|
||||
);
|
||||
assertEqual(
|
||||
firstState.useCustomRequestHandler,
|
||||
useCustomRequestHandler,
|
||||
"The first device did not preserve the expected Custom HTTP Handler setting."
|
||||
);
|
||||
|
||||
await writeNote(context.cliBinary, sessionA.cliEnv, noteFromFirst, firstContent);
|
||||
await pushLocalChanges(context.cliBinary, sessionA.cliEnv);
|
||||
@@ -330,6 +340,11 @@ async function main(): Promise<void> {
|
||||
bucketPrefix,
|
||||
"The second device did not import the unique bucket prefix."
|
||||
);
|
||||
assertEqual(
|
||||
secondState.useCustomRequestHandler,
|
||||
useCustomRequestHandler,
|
||||
"The second device did not import the expected Custom HTTP Handler setting."
|
||||
);
|
||||
await pushLocalChanges(context.cliBinary, sessionB.cliEnv);
|
||||
await waitForPathContent(vaultB, noteFromFirst, firstContent);
|
||||
screenshots.push(
|
||||
@@ -337,7 +352,7 @@ async function main(): Promise<void> {
|
||||
portB,
|
||||
noteFromFirst,
|
||||
"Object Storage from the first device",
|
||||
"guide-object-storage-setup-first-to-second.png"
|
||||
`guide-${captures.guide}-first-to-second.png`
|
||||
)
|
||||
);
|
||||
|
||||
@@ -357,12 +372,14 @@ async function main(): Promise<void> {
|
||||
portA,
|
||||
noteFromSecond,
|
||||
"Object Storage from the second device",
|
||||
"guide-object-storage-setup-second-to-first.png"
|
||||
`guide-${captures.guide}-second-to-first.png`
|
||||
)
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Object Storage Setup URI and two-device roundtrip succeeded. Screenshots: ${screenshots.join(", ")}`
|
||||
`Object Storage Setup URI and two-device roundtrip succeeded with the ${
|
||||
useCustomRequestHandler ? "Custom HTTP Handler" : "default HTTP handler"
|
||||
}. Screenshots: ${screenshots.join(", ")}`
|
||||
);
|
||||
} finally {
|
||||
await stopSessions(context).catch((error: unknown) => {
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Proves that one file which cannot be reflected during an ordinary start-up
|
||||
* does not keep the entire configured application unready.
|
||||
*
|
||||
* The fixture relies on the conventional Linux 255-byte path component
|
||||
* limit. It stores one ordinary note and one note with a 258-byte component in
|
||||
* the local database, then restarts the same real Obsidian Vault and profile.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
assertCouchDbReachable,
|
||||
createCouchDbDatabase,
|
||||
deleteCouchDbDatabase,
|
||||
loadCouchDbConfig,
|
||||
makeUniqueDatabaseName,
|
||||
} from "../runner/couchdb.ts";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import {
|
||||
assertEqual,
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
prepareRemote,
|
||||
waitForLiveSyncCoreReady,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
|
||||
|
||||
const validPath = "E2E/partial-startup-valid.md";
|
||||
const oversizedComponent = `${"界".repeat(85)}.md`;
|
||||
const failedPath = `E2E/${oversizedComponent}`;
|
||||
const validContent = `# Partial start-up\n\n${"V".repeat(4096)}\n`;
|
||||
const failedContent = `# Retry this file\n\n${"R".repeat(4096)}\n`;
|
||||
const partialFailureNotice =
|
||||
"Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.";
|
||||
const failedPathLog =
|
||||
`Offline scan failed to synchronise ${failedPath} between storage and the local database; ` +
|
||||
"this path remains eligible for a later scan.";
|
||||
const assertionTimeoutMs = Number(process.env.E2E_OBSIDIAN_CORE_READY_TIMEOUT_MS ?? 20000);
|
||||
|
||||
type SeededEntry = {
|
||||
id: string;
|
||||
path: string;
|
||||
revision: string;
|
||||
children: string[];
|
||||
};
|
||||
|
||||
type FailedPathState = {
|
||||
appReady: boolean;
|
||||
databaseReady: boolean;
|
||||
fileExists: boolean;
|
||||
entryReadable: boolean;
|
||||
metadataRevision?: string;
|
||||
provenance: { revision: string; observedStorageMtime?: number } | null;
|
||||
logText: string;
|
||||
};
|
||||
|
||||
type RetryState = Omit<FailedPathState, "databaseReady" | "logText"> & {
|
||||
scanResult: string | false;
|
||||
};
|
||||
|
||||
async function seedDatabaseOnlyEntries(cliBinary: string, env: NodeJS.ProcessEnv): Promise<SeededEntry[]> {
|
||||
return await evalObsidianJson<SeededEntry[]>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const fixtures=${JSON.stringify([
|
||||
{ path: validPath, content: validContent },
|
||||
{ path: failedPath, content: failedContent },
|
||||
])};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const seeded=[];",
|
||||
"for(const {path,content} of fixtures){",
|
||||
" if(app.vault.getAbstractFileByPath(path)!==null){",
|
||||
" throw new Error(`Database-only fixture already exists in the Vault: ${path}`);",
|
||||
" }",
|
||||
" const blob=new Blob([content],{type:'text/plain'});",
|
||||
" const id=await core.services.path.path2id(path);",
|
||||
" const now=Date.now();",
|
||||
" const result=await core.localDatabase.putDBEntry({",
|
||||
" _id:id,path,data:blob,ctime:now,mtime:now,",
|
||||
" size:(await blob.arrayBuffer()).byteLength,children:[],",
|
||||
" datatype:'plain',type:'plain',eden:{},",
|
||||
" });",
|
||||
" if(!result?.ok) throw new Error(`Could not seed database-only fixture: ${path}`);",
|
||||
" const metadata=await core.localDatabase.getDBEntryMeta(path,undefined,true);",
|
||||
" if(!metadata) throw new Error(`Could not reload seeded Metadata: ${path}`);",
|
||||
" seeded.push({id,path,revision:result.rev,children:metadata.children??[]});",
|
||||
"}",
|
||||
"return JSON.stringify(seeded);",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function observePartialFailureNotice(remoteDebuggingPort: number): Promise<void> {
|
||||
await withObsidianPage(remoteDebuggingPort, async (page) => {
|
||||
await page
|
||||
.locator(".notice")
|
||||
.filter({ hasText: partialFailureNotice })
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: assertionTimeoutMs });
|
||||
});
|
||||
}
|
||||
|
||||
async function inspectFailedPathState(cliBinary: string, env: NodeJS.ProcessEnv): Promise<FailedPathState> {
|
||||
return await evalObsidianJson<FailedPathState>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(failedPath)};`,
|
||||
`const expectedLog=${JSON.stringify(failedPathLog)};`,
|
||||
`const timeoutMs=${JSON.stringify(assertionTimeoutMs)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const metadata=await core.localDatabase.getDBEntryMeta(path,undefined,true);",
|
||||
"const entry=await core.localDatabase.getDBEntry(path,undefined,false,true,true);",
|
||||
"const provenanceStore=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');",
|
||||
"const provenance=(await provenanceStore.get(path))??null;",
|
||||
"await core.services.API.showWindow('log-log');",
|
||||
"const deadline=Date.now()+timeoutMs;",
|
||||
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
|
||||
"let logText='';",
|
||||
"while(Date.now()<deadline){",
|
||||
" logText=Array.from(document.querySelectorAll('.logpane .log pre'))",
|
||||
" .map((element)=>element.textContent??'').join('\\n');",
|
||||
" if(logText.includes(expectedLog)) break;",
|
||||
" await sleep(100);",
|
||||
"}",
|
||||
"for(const leaf of app.workspace.getLeavesOfType('log-log')) leaf.detach();",
|
||||
"return JSON.stringify({",
|
||||
" appReady:core.services.appLifecycle.isReady(),",
|
||||
" databaseReady:core.services.database.isDatabaseReady(),",
|
||||
" fileExists:app.vault.getAbstractFileByPath(path)!==null,",
|
||||
" entryReadable:entry!==false,",
|
||||
" metadataRevision:metadata?._rev,",
|
||||
" provenance,",
|
||||
" logText,",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function retryFailedPath(cliBinary: string, env: NodeJS.ProcessEnv): Promise<RetryState> {
|
||||
return await evalObsidianJson<RetryState>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(failedPath)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const scanResult=await core.services.vault.scanVault(false,false,true);",
|
||||
"const metadata=await core.localDatabase.getDBEntryMeta(path,undefined,true);",
|
||||
"const entry=await core.localDatabase.getDBEntry(path,undefined,false,true,true);",
|
||||
"const provenanceStore=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');",
|
||||
"return JSON.stringify({",
|
||||
" scanResult,",
|
||||
" appReady:core.services.appLifecycle.isReady(),",
|
||||
" fileExists:app.vault.getAbstractFileByPath(path)!==null,",
|
||||
" entryReadable:entry!==false,",
|
||||
" metadataRevision:metadata?._rev,",
|
||||
" provenance:(await provenanceStore.get(path))??null,",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (process.platform !== "linux") {
|
||||
throw new Error("The partial start-up file-failure scenario currently requires a Linux test Vault.");
|
||||
}
|
||||
assertEqual(
|
||||
Buffer.byteLength(oversizedComponent, "utf8"),
|
||||
258,
|
||||
"The failing path component no longer exercises the intended UTF-8 byte boundary."
|
||||
);
|
||||
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) {
|
||||
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||
}
|
||||
|
||||
const couchDb = await loadCouchDbConfig();
|
||||
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "partial-startup-file-failure");
|
||||
const couchDbSettings = {
|
||||
uri: couchDb.uri,
|
||||
username: couchDb.username,
|
||||
password: couchDb.password,
|
||||
dbName,
|
||||
};
|
||||
const vault = await createTemporaryVault("obsidian-livesync-partial-startup-");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
|
||||
try {
|
||||
await assertCouchDbReachable(couchDb);
|
||||
await createCouchDbDatabase(couchDb, dbName);
|
||||
|
||||
console.log(`Using Obsidian executable: ${binary}`);
|
||||
console.log(`Temporary vault: ${vault.path}`);
|
||||
console.log(`Temporary CouchDB database: ${dbName}`);
|
||||
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary: cli.binary,
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData: createE2eCouchDbPluginData(couchDbSettings, {
|
||||
showVerboseLog: true,
|
||||
lessInformationInLog: false,
|
||||
}),
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
await prepareRemote(cli.binary, session.cliEnv);
|
||||
|
||||
const seeded = await seedDatabaseOnlyEntries(cli.binary, session.cliEnv);
|
||||
const validSeed = seeded.find((entry) => entry.path === validPath);
|
||||
const failedSeed = seeded.find((entry) => entry.path === failedPath);
|
||||
if (!validSeed || !failedSeed) throw new Error("The database-only start-up fixtures were incomplete.");
|
||||
if (validSeed.children.length === 0 || failedSeed.children.length === 0) {
|
||||
throw new Error("The database-only fixtures did not create independently stored chunks.");
|
||||
}
|
||||
|
||||
await session.app.stop();
|
||||
session = undefined;
|
||||
|
||||
let partialNoticeObserved = false;
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary: cli.binary,
|
||||
vault,
|
||||
pluginStartup: "natural",
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
lifecycle: {
|
||||
afterPluginLoad: async ({ remoteDebuggingPort }) => {
|
||||
await observePartialFailureNotice(remoteDebuggingPort);
|
||||
partialNoticeObserved = true;
|
||||
},
|
||||
},
|
||||
});
|
||||
const readiness = await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
assertEqual(readiness.configured, true, "Self-hosted LiveSync lost its configuration on restart.");
|
||||
assertEqual(partialNoticeObserved, true, "The partial start-up failure Notice was not observed.");
|
||||
|
||||
assertEqual(
|
||||
await readFile(join(vault.path, validPath), "utf8"),
|
||||
validContent,
|
||||
"The valid database-only file was not reflected during the same start-up scan."
|
||||
);
|
||||
|
||||
const state = await inspectFailedPathState(cli.binary, session.cliEnv);
|
||||
assertEqual(state.databaseReady, true, "The local database did not remain ready after one file failed.");
|
||||
assertEqual(state.appReady, true, "One file failure kept the application unready.");
|
||||
assertEqual(state.fileExists, false, "The overlong path was unexpectedly reflected to the Linux Vault.");
|
||||
assertEqual(state.entryReadable, true, "The failed database entry was no longer readable.");
|
||||
assertEqual(state.metadataRevision, failedSeed.revision, "The failed database entry revision changed.");
|
||||
assertEqual(state.provenance, null, "A failed reflection was recorded as successful provenance.");
|
||||
assertEqual(
|
||||
state.logText.includes(failedPathLog),
|
||||
true,
|
||||
"The verbose log did not identify the path which failed during the start-up scan."
|
||||
);
|
||||
|
||||
const retry = await retryFailedPath(cli.binary, session.cliEnv);
|
||||
assertEqual(
|
||||
retry.scanResult,
|
||||
"completed-with-file-failures",
|
||||
"A later scan did not retry and report the same individual file failure."
|
||||
);
|
||||
assertEqual(retry.appReady, true, "Retrying the failed path cleared application readiness.");
|
||||
assertEqual(retry.fileExists, false, "The overlong path was unexpectedly reflected during retry.");
|
||||
assertEqual(retry.entryReadable, true, "Retrying removed the failed database entry.");
|
||||
assertEqual(retry.metadataRevision, failedSeed.revision, "Retrying changed the failed database revision.");
|
||||
assertEqual(retry.provenance, null, "Retrying recorded a failed reflection as successful provenance.");
|
||||
|
||||
console.log(`Ordinary start-up remained ready, reflected ${validPath}, and retained ${failedPath} for retry.`);
|
||||
} finally {
|
||||
if (session) {
|
||||
await session.app.stop();
|
||||
}
|
||||
await vault.dispose();
|
||||
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
|
||||
await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ const focusedScenarios = new Set([
|
||||
"smoke",
|
||||
"onboarding-invitation",
|
||||
"dialog-mounts",
|
||||
"conflict-dialog-policy",
|
||||
"revision-repair",
|
||||
"document-history-nav",
|
||||
"document-history-restore",
|
||||
@@ -20,7 +21,9 @@ const focusedScenarios = new Set([
|
||||
"cli-to-obsidian-sync",
|
||||
"minio-upload",
|
||||
"object-storage-setup-uri-workflow",
|
||||
"object-storage-custom-http-handler-setup-uri-workflow",
|
||||
"p2p-setup-uri-workflow",
|
||||
"partial-startup-file-failure",
|
||||
"startup-scan",
|
||||
"setup-uri-workflow",
|
||||
"two-vault-sync",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { assertLocatorWithinViewport, assertNoHorizontalOverflow } from "@vrtmrz/obsidian-test-session";
|
||||
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
|
||||
@@ -597,26 +598,42 @@ async function verifyCompatibilityReview(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<void> {
|
||||
async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<string> {
|
||||
const screenshot = await captureObsidianDialogue(
|
||||
obsidianRemoteDebuggingPort(),
|
||||
"config-doctor-after-compatibility-review.png",
|
||||
async (page) => {
|
||||
const doctor = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }),
|
||||
});
|
||||
await doctor.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await doctor.getByText("Per-file-saved customization sync", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await doctor.getByText("Enhance chunk size", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
if ((await doctor.getByText("Data Compression", { exact: true }).count()) !== 0) {
|
||||
throw new Error("Config Doctor still treats supported Data Compression as a problem.");
|
||||
}
|
||||
await assertLocatorWithinViewport(page, doctor.locator(".modal").last(), {
|
||||
label: "Config Doctor dialogue",
|
||||
});
|
||||
await assertNoHorizontalOverflow(page, doctor.locator(".modal").last(), {
|
||||
label: "Config Doctor dialogue",
|
||||
});
|
||||
}
|
||||
);
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const doctor = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }),
|
||||
});
|
||||
await doctor.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await doctor.getByText("Per-file-saved customization sync", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await doctor.getByText("Enhance chunk size", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
if ((await doctor.getByText("Data Compression", { exact: true }).count()) !== 0) {
|
||||
throw new Error("Config Doctor still treats supported Data Compression as a problem.");
|
||||
}
|
||||
await doctor.getByRole("button", { name: /No, and do not ask again/u }).click();
|
||||
await doctor.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
async function verifyEffectiveSettings(): Promise<"declarative" | "imperative"> {
|
||||
@@ -1051,7 +1068,8 @@ async function main(): Promise<void> {
|
||||
await resumePendingCompatibilityReviewForSettings();
|
||||
} else {
|
||||
await verifyCompatibilityReview();
|
||||
await verifyConfigDoctorFollowsCompatibilityReview();
|
||||
const configDoctorScreenshot = await verifyConfigDoctorFollowsCompatibilityReview();
|
||||
console.log(`Config Doctor screenshot: ${configDoctorScreenshot}`);
|
||||
}
|
||||
settingsRenderer = await verifyEffectiveSettings();
|
||||
const initialisation = await verifyPendingSettingsInitialisationFlow();
|
||||
|
||||
@@ -4,8 +4,48 @@ import {
|
||||
inspectObsidianServiceContextContract,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const BASIC_COMMAND_IDS = [
|
||||
"livesync-replicate",
|
||||
"livesync-dump",
|
||||
"livesync-toggle",
|
||||
"livesync-suspendall",
|
||||
"livesync-scan-files",
|
||||
"livesync-runbatch",
|
||||
"livesync-abortsync",
|
||||
] as const;
|
||||
|
||||
type ObsidianCommandHost = typeof globalThis & {
|
||||
app?: { commands?: { commands?: Record<string, unknown> } };
|
||||
};
|
||||
|
||||
async function assertMenuFeaturesAreComposed(remoteDebuggingPort: number): Promise<void> {
|
||||
await withObsidianPage(remoteDebuggingPort, async (page) => {
|
||||
const registered = await page.evaluate((commandIds) => {
|
||||
const commands = (globalThis as ObsidianCommandHost).app?.commands?.commands ?? {};
|
||||
return commandIds.filter((id) => commands[`obsidian-livesync:${id}`] !== undefined);
|
||||
}, BASIC_COMMAND_IDS);
|
||||
if (registered.length !== BASIC_COMMAND_IDS.length) {
|
||||
const missing = BASIC_COMMAND_IDS.filter((id) => !registered.includes(id));
|
||||
throw new Error(`Extracted basic commands were not composed: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
const ribbonCount = await page.locator(".livesync-ribbon-replicate").count();
|
||||
if (ribbonCount !== 1) {
|
||||
throw new Error(`Expected one extracted replication ribbon action, found ${ribbonCount}.`);
|
||||
}
|
||||
|
||||
const preservedRibbonPathCount = await page
|
||||
.locator('.livesync-ribbon-replicate path[d*="c-7.66 1.98-12.2 9.61-10 17"]')
|
||||
.count();
|
||||
if (preservedRibbonPathCount !== 1) {
|
||||
throw new Error("The extracted replication ribbon does not preserve its established icon path.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
@@ -34,6 +74,8 @@ async function main(): Promise<void> {
|
||||
console.log(
|
||||
`Obsidian service Context contract passed: ${contextContract.contextType}, ${contextContract.serviceContextMismatches.length} mismatches.`
|
||||
);
|
||||
await assertMenuFeaturesAreComposed(session.remoteDebuggingPort);
|
||||
console.log("Extracted basic commands and replication ribbon were composed exactly once.");
|
||||
await new Promise((resolve) => setTimeout(resolve, Number(process.env.E2E_OBSIDIAN_SMOKE_TIMEOUT_MS ?? 1000)));
|
||||
console.log("Obsidian stayed alive after the plug-in readiness check.");
|
||||
} finally {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, rename as renameFilesystemPath, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import {
|
||||
assertCouchDbReachable,
|
||||
createCouchDbDatabase,
|
||||
deleteCouchDbDatabase,
|
||||
fetchAllCouchDbDocs,
|
||||
loadCouchDbConfig,
|
||||
makeUniqueDatabaseName,
|
||||
waitForCouchDbDocs,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
import { captureObsidianPage } from "../runner/ui.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
|
||||
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000";
|
||||
@@ -47,6 +49,11 @@ const conflictRenameFromPath = "E2E/two-vault/conflict-operations/rename-source.
|
||||
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";
|
||||
const parentCaseRenameFromDirectoryPath = "E2E/two-vault/parent/test3";
|
||||
const parentCaseRenameToDirectoryPath = "E2E/two-vault/parent/Test3";
|
||||
const parentCaseRenameFromPath = `${parentCaseRenameFromDirectoryPath}/note.md`;
|
||||
const parentCaseRenameToPath = `${parentCaseRenameToDirectoryPath}/note.md`;
|
||||
const parentCaseEventObserverKey = "__livesyncE2eParentCaseEventObserver";
|
||||
|
||||
type RunnerContext = {
|
||||
binary: string;
|
||||
@@ -68,6 +75,34 @@ type FileConflictState = {
|
||||
}[];
|
||||
};
|
||||
|
||||
type ParentCaseVaultEvent = {
|
||||
type: "create" | "delete" | "rename";
|
||||
path: string;
|
||||
oldPath: string | null;
|
||||
};
|
||||
|
||||
type ParentCaseMetadataState = {
|
||||
id: string;
|
||||
found: boolean;
|
||||
rev: string | null;
|
||||
path: string | null;
|
||||
deleted: boolean;
|
||||
children: string[];
|
||||
contentMatches: boolean;
|
||||
childrenMatch: boolean;
|
||||
chunksPresent: boolean;
|
||||
chunkReferenceCount: number;
|
||||
availableChunkCount: number;
|
||||
};
|
||||
|
||||
type ParentCaseRemoteMetadataState = {
|
||||
id: string;
|
||||
rev: string | null;
|
||||
path: string | null;
|
||||
deleted: boolean;
|
||||
children: string[];
|
||||
};
|
||||
|
||||
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
|
||||
const fullPath = join(vaultPath, path);
|
||||
await mkdir(dirname(fullPath), { recursive: true });
|
||||
@@ -94,6 +129,57 @@ async function pathExists(vaultPath: string, path: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function installParentCaseEventObserver(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
observedPaths: readonly string[]
|
||||
): Promise<string> {
|
||||
return await evalObsidianJson<string>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const key=${JSON.stringify(parentCaseEventObserverKey)};`,
|
||||
`const observedPaths=${JSON.stringify(observedPaths)};`,
|
||||
"const previous=globalThis[key];",
|
||||
"if(previous){for(const ref of previous.refs??[]) app.vault.offref(ref);}",
|
||||
"const events=[];",
|
||||
"const record=(type,file,oldPath)=>{",
|
||||
" const path=typeof file?.path==='string'?file.path:'';",
|
||||
" const previousPath=typeof oldPath==='string'?oldPath:null;",
|
||||
" if(!observedPaths.includes(path)&&(!previousPath||!observedPaths.includes(previousPath))) return;",
|
||||
" globalThis[key].lastEventAt=Date.now();",
|
||||
" if(events.length<32) events.push({type,path,oldPath:previousPath});",
|
||||
"};",
|
||||
"const refs=[",
|
||||
" app.vault.on('create',(file)=>record('create',file)),",
|
||||
" app.vault.on('delete',(file)=>record('delete',file)),",
|
||||
" app.vault.on('rename',(file,oldPath)=>record('rename',file,oldPath)),",
|
||||
"];",
|
||||
"globalThis[key]={events,refs,lastEventAt:Date.now()};",
|
||||
"return JSON.stringify(app.plugins.plugins['obsidian-livesync'].core.services.API.getAppVersion());",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function takeParentCaseEventEvidence(cliBinary: string, env: NodeJS.ProcessEnv): Promise<ParentCaseVaultEvent[]> {
|
||||
return await evalObsidianJson<ParentCaseVaultEvent[]>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const key=${JSON.stringify(parentCaseEventObserverKey)};`,
|
||||
"const observer=globalThis[key];",
|
||||
"if(!observer) return JSON.stringify([]);",
|
||||
"const events=Array.isArray(observer.events)?observer.events.slice(0,32):[];",
|
||||
"try{for(const ref of observer.refs??[]) app.vault.offref(ref);}finally{delete globalThis[key];}",
|
||||
"return JSON.stringify(events);",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function stopTrackedSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
|
||||
if (!context.activeSessions.has(session)) return;
|
||||
await session.app.stop();
|
||||
@@ -141,6 +227,165 @@ async function waitForPathDeleted(
|
||||
throw new Error(`Timed out waiting for deleted file: ${join(vaultPath, path)}`);
|
||||
}
|
||||
|
||||
async function waitForExactObsidianPath(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
path: string,
|
||||
oldPath: string,
|
||||
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000)
|
||||
): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const expectedPath=${JSON.stringify(path)};`,
|
||||
`const oldPath=${JSON.stringify(oldPath)};`,
|
||||
`const observerKey=${JSON.stringify(parentCaseEventObserverKey)};`,
|
||||
`const timeoutMs=${JSON.stringify(timeoutMs)};`,
|
||||
"const deadline=Date.now()+timeoutMs;",
|
||||
"let observedPath=null;",
|
||||
"while(Date.now()<deadline){",
|
||||
" const files=app.vault.getFiles();",
|
||||
" const file=files.find((candidate)=>candidate.path===expectedPath);",
|
||||
" observedPath=typeof file?.path==='string'?file.path:null;",
|
||||
" const observer=globalThis[observerKey];",
|
||||
" if(observedPath===expectedPath&&!files.some((candidate)=>candidate.path===oldPath)&&observer?.events.length>0&&Date.now()-observer.lastEventAt>=500) return JSON.stringify({path:observedPath});",
|
||||
" await new Promise((resolve)=>setTimeout(resolve,100));",
|
||||
"}",
|
||||
"throw new Error(`Timed out waiting for Obsidian to recognise the exact path: ${JSON.stringify({expectedPath,observedPath})}`);",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForEitherPathContent(
|
||||
vaultPath: string,
|
||||
paths: readonly string[],
|
||||
expectedContent: string,
|
||||
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000)
|
||||
): Promise<{ path: string }> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastPath: string | null = null;
|
||||
let contentMatched = false;
|
||||
while (Date.now() < deadline) {
|
||||
for (const path of paths) {
|
||||
if (!(await pathExists(vaultPath, path))) continue;
|
||||
lastPath = path;
|
||||
contentMatched = (await readVaultFile(vaultPath, path)) === expectedContent;
|
||||
if (contentMatched) return { path };
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for content at either case variant: ${JSON.stringify({
|
||||
paths,
|
||||
lastPath,
|
||||
contentMatched,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForParentCaseMetadata(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
id: string,
|
||||
expectedPath: string,
|
||||
expectedContent: string,
|
||||
expectedChildren: readonly string[],
|
||||
expectedRevision?: string
|
||||
): Promise<ParentCaseMetadataState> {
|
||||
const timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000);
|
||||
return await evalObsidianJson<ParentCaseMetadataState>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const id=${JSON.stringify(id)};`,
|
||||
`const expectedPath=${JSON.stringify(expectedPath)};`,
|
||||
`const expectedContent=${JSON.stringify(expectedContent)};`,
|
||||
`const expectedChildren=${JSON.stringify(expectedChildren)};`,
|
||||
`const expectedRevision=${JSON.stringify(expectedRevision ?? null)};`,
|
||||
`const timeoutMs=${JSON.stringify(timeoutMs)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const deadline=Date.now()+timeoutMs;",
|
||||
"let state={id,found:false,rev:null,path:null,deleted:false,children:[],contentMatches:false,childrenMatch:false,chunksPresent:false,chunkReferenceCount:0,availableChunkCount:0};",
|
||||
"while(Date.now()<deadline){",
|
||||
" await core.services.fileProcessing.commitPendingFileEvents();",
|
||||
" const raw=await core.localDatabase.getRaw(id,{revs_info:true}).catch(()=>null);",
|
||||
" const row=((await core.localDatabase.allDocsRaw({keys:[id],include_docs:true})).rows??[])[0];",
|
||||
" const rawDoc=raw??row?.doc??null;",
|
||||
" const deleted=Boolean(raw?.deleted||raw?._deleted||row?.value?.deleted||row?.doc?.deleted||row?.doc?._deleted);",
|
||||
" const children=Array.isArray(rawDoc?.children)?rawDoc.children:[];",
|
||||
" const rev=rawDoc?._rev??row?.value?.rev??null;",
|
||||
" if(deleted) throw new Error(`Parent case rename marked Metadata as deleted (deleted or _deleted): ${JSON.stringify({id,rev,path:rawDoc?.path??null})}`);",
|
||||
" if(rawDoc){",
|
||||
" const loaded=await core.localDatabase.getDBEntry(expectedPath,{rev},false,true,true).catch(()=>false);",
|
||||
" const content=loaded===false?'':Array.isArray(loaded.data)?loaded.data.join(''):typeof loaded.data==='string'?loaded.data:'';",
|
||||
" const chunkRows=children.length===0?{rows:[]}:await core.localDatabase.allDocsRaw({keys:children,include_docs:true});",
|
||||
" const availableChunkCount=chunkRows.rows.filter((chunkRow)=>Boolean(chunkRow.doc)&&!Boolean(chunkRow.value?.deleted)&&!Boolean(chunkRow.doc?.deleted)&&!Boolean(chunkRow.doc?._deleted)).length;",
|
||||
" state={id,found:true,rev,path:rawDoc?.path??null,deleted:false,children,contentMatches:content===expectedContent,childrenMatch:children.length===expectedChildren.length&&children.every((child,index)=>child===expectedChildren[index]),chunksPresent:availableChunkCount===children.length&&children.length===expectedChildren.length,chunkReferenceCount:children.length,availableChunkCount};",
|
||||
" if(state.contentMatches&&state.childrenMatch&&state.chunksPresent&&(!expectedRevision||state.rev===expectedRevision)) return JSON.stringify(state);",
|
||||
" }",
|
||||
" await new Promise((resolve)=>setTimeout(resolve,250));",
|
||||
"}",
|
||||
"throw new Error(`Timed out waiting for parent case Metadata and Chunks: ${JSON.stringify(state)}`);",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForParentCaseRemoteMetadata(
|
||||
context: RunnerContext,
|
||||
entry: LocalDatabaseEntry
|
||||
): Promise<ParentCaseRemoteMetadataState> {
|
||||
const timeoutMs = Number(process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ?? 15000);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastState: ParentCaseRemoteMetadataState | null = null;
|
||||
while (Date.now() < deadline) {
|
||||
const response = await fetchAllCouchDbDocs(context.couchDb, context.dbName);
|
||||
const row = response.rows.find((candidate) => candidate.id === entry.id);
|
||||
const doc = row?.doc;
|
||||
const deleted = Boolean(row?.value.deleted || doc?.deleted || doc?._deleted);
|
||||
lastState = {
|
||||
id: entry.id,
|
||||
rev: row?.value.rev ?? doc?._rev ?? null,
|
||||
path: doc?.path ?? null,
|
||||
deleted,
|
||||
children: Array.isArray(doc?.children) ? doc.children : [],
|
||||
};
|
||||
if (deleted) {
|
||||
throw new Error(
|
||||
`Parent case rename uploaded deleted remote Metadata: ${JSON.stringify({
|
||||
id: entry.id,
|
||||
rev: lastState.rev,
|
||||
path: lastState.path,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
doc &&
|
||||
lastState.children.length === entry.children.length &&
|
||||
lastState.children.every(
|
||||
(child, index) =>
|
||||
child === entry.children[index] &&
|
||||
response.rows.some(
|
||||
(chunk) =>
|
||||
chunk.id === child &&
|
||||
chunk.doc &&
|
||||
!chunk.value.deleted &&
|
||||
!chunk.doc.deleted &&
|
||||
!chunk.doc._deleted
|
||||
)
|
||||
)
|
||||
) {
|
||||
return lastState;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`Timed out waiting for non-deleted remote Metadata: ${JSON.stringify(lastState)}`);
|
||||
}
|
||||
|
||||
async function writeNoteViaObsidian(cliBinary: string, env: NodeJS.ProcessEnv, path: string, content: string) {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
@@ -559,6 +804,145 @@ async function runCaseOnlyRename(
|
||||
console.log("Two-vault case-only note rename round-tripped without a tombstone.");
|
||||
}
|
||||
|
||||
async function runParentCaseDeletionProtection(
|
||||
context: RunnerContext,
|
||||
vaultA: TemporaryVault,
|
||||
vaultB: TemporaryVault
|
||||
): Promise<void> {
|
||||
const fileContent = "# Parent case rename\n\nThe document must remain live after an external parent rename.\n";
|
||||
const parentCaseOverrides = {
|
||||
handleFilenameCaseSensitive: false,
|
||||
batchSave: false,
|
||||
};
|
||||
const observedPaths = [
|
||||
parentCaseRenameFromDirectoryPath,
|
||||
parentCaseRenameToDirectoryPath,
|
||||
parentCaseRenameFromPath,
|
||||
parentCaseRenameToPath,
|
||||
];
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
let observerInstalled = false;
|
||||
let obsidianVersion: string | undefined;
|
||||
let observedEvents: ParentCaseVaultEvent[] = [];
|
||||
let localMetadataEvidence: ParentCaseMetadataState | undefined;
|
||||
let remoteMetadataEvidence: ParentCaseRemoteMetadataState | undefined;
|
||||
let restartedMetadataEvidence: ParentCaseMetadataState | undefined;
|
||||
|
||||
try {
|
||||
session = await startConfiguredSession(context, vaultA, parentCaseOverrides);
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, parentCaseRenameFromPath, fileContent);
|
||||
const initialEntry = await uploadNote(context, session, parentCaseRenameFromPath);
|
||||
if (initialEntry.children.length === 0) {
|
||||
throw new Error(`Parent case fixture did not retain a Chunk reference: ${initialEntry.id}`);
|
||||
}
|
||||
await stopTrackedSession(context, session);
|
||||
session = undefined;
|
||||
|
||||
session = await startConfiguredSession(context, vaultB, parentCaseOverrides);
|
||||
await syncAndApply(context, session);
|
||||
await waitForPathContent(vaultB.path, parentCaseRenameFromPath, (content) => content === fileContent);
|
||||
await stopTrackedSession(context, session);
|
||||
session = undefined;
|
||||
|
||||
session = await startConfiguredSession(context, vaultA, parentCaseOverrides);
|
||||
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, parentCaseRenameFromPath);
|
||||
obsidianVersion = await installParentCaseEventObserver(context.cliBinary, session.cliEnv, observedPaths);
|
||||
observerInstalled = true;
|
||||
|
||||
await renameFilesystemPath(
|
||||
join(vaultA.path, parentCaseRenameFromDirectoryPath),
|
||||
join(vaultA.path, parentCaseRenameToDirectoryPath)
|
||||
);
|
||||
await waitForExactObsidianPath(
|
||||
context.cliBinary,
|
||||
session.cliEnv,
|
||||
parentCaseRenameToPath,
|
||||
parentCaseRenameFromPath
|
||||
);
|
||||
localMetadataEvidence = await waitForParentCaseMetadata(
|
||||
context.cliBinary,
|
||||
session.cliEnv,
|
||||
initialEntry.id,
|
||||
parentCaseRenameToPath,
|
||||
fileContent,
|
||||
initialEntry.children
|
||||
);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
const remoteMetadata = await waitForParentCaseRemoteMetadata(context, initialEntry);
|
||||
remoteMetadataEvidence = remoteMetadata;
|
||||
observedEvents = await takeParentCaseEventEvidence(context.cliBinary, session.cliEnv);
|
||||
observerInstalled = false;
|
||||
await stopTrackedSession(context, session);
|
||||
session = undefined;
|
||||
|
||||
session = await startConfiguredSession(context, vaultB, parentCaseOverrides);
|
||||
await syncAndApply(context, session);
|
||||
await waitForParentCaseMetadata(
|
||||
context.cliBinary,
|
||||
session.cliEnv,
|
||||
initialEntry.id,
|
||||
parentCaseRenameToPath,
|
||||
fileContent,
|
||||
initialEntry.children,
|
||||
remoteMetadata.rev ?? undefined
|
||||
);
|
||||
await waitForEitherPathContent(vaultB.path, [parentCaseRenameFromPath, parentCaseRenameToPath], fileContent);
|
||||
await stopTrackedSession(context, session);
|
||||
session = undefined;
|
||||
|
||||
session = await startConfiguredSession(context, vaultA, parentCaseOverrides);
|
||||
await syncAndApply(context, session);
|
||||
await waitForEitherPathContent(vaultA.path, [parentCaseRenameFromPath, parentCaseRenameToPath], fileContent);
|
||||
restartedMetadataEvidence = await waitForParentCaseMetadata(
|
||||
context.cliBinary,
|
||||
session.cliEnv,
|
||||
initialEntry.id,
|
||||
parentCaseRenameToPath,
|
||||
fileContent,
|
||||
initialEntry.children,
|
||||
remoteMetadata.rev ?? undefined
|
||||
);
|
||||
await stopTrackedSession(context, session);
|
||||
session = undefined;
|
||||
} catch (error) {
|
||||
if (session) {
|
||||
await captureObsidianPage(session.remoteDebuggingPort, "parent-case-deletion-failure.png", async () => {})
|
||||
.then((path) => console.error(`Parent case failure screenshot: ${path}`))
|
||||
.catch((captureError: unknown) => {
|
||||
console.warn(captureError instanceof Error ? captureError.message : captureError);
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (observerInstalled && session) {
|
||||
try {
|
||||
observedEvents = await takeParentCaseEventEvidence(context.cliBinary, session.cliEnv);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Could not collect parent case rename event evidence: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (session) await stopTrackedSession(context, session);
|
||||
} finally {
|
||||
console.log(
|
||||
`Parent case rename evidence: ${JSON.stringify({
|
||||
obsidianVersion,
|
||||
events: observedEvents,
|
||||
localMetadata: localMetadataEvidence ?? null,
|
||||
remoteMetadata: remoteMetadataEvidence ?? null,
|
||||
restartedMetadata: restartedMetadataEvidence ?? null,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("External parent case rename preserved the note Metadata, Chunks, and content.");
|
||||
}
|
||||
|
||||
async function runEncryptedRoundTrip(
|
||||
context: RunnerContext,
|
||||
vaultA: TemporaryVault,
|
||||
@@ -1002,8 +1386,12 @@ async function main(): Promise<void> {
|
||||
console.log(`Temporary CouchDB database: ${dbName}`);
|
||||
console.log(`Temporary encrypted CouchDB database: ${encryptedDbName}`);
|
||||
|
||||
const onlyParentCaseDeletion = process.env.E2E_OBSIDIAN_ONLY_PARENT_CASE_DELETION === "true";
|
||||
if (onlyParentCaseDeletion) {
|
||||
await runParentCaseDeletionProtection(context, vaultA, vaultB);
|
||||
}
|
||||
const onlyConflictOperations = process.env.E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS === "true";
|
||||
if (!onlyConflictOperations) {
|
||||
if (!onlyParentCaseDeletion && !onlyConflictOperations) {
|
||||
await runCreateUpdateDelete(context, vaultA, vaultB);
|
||||
await runRename(context, vaultA, vaultB);
|
||||
await runCaseOnlyRename(context, vaultA, vaultB);
|
||||
@@ -1011,10 +1399,13 @@ async function main(): Promise<void> {
|
||||
await runMarkdownAutoMerge(context, vaultA, vaultB);
|
||||
}
|
||||
}
|
||||
if (onlyConflictOperations || process.env.E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS === "true") {
|
||||
if (
|
||||
!onlyParentCaseDeletion &&
|
||||
(onlyConflictOperations || process.env.E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS === "true")
|
||||
) {
|
||||
await runConflictTimeStorageOperations(context, vaultA, vaultB);
|
||||
}
|
||||
if (!onlyConflictOperations) {
|
||||
if (!onlyParentCaseDeletion && !onlyConflictOperations) {
|
||||
await runTargetMismatch(context, vaultA, vaultB);
|
||||
await runEncryptedRoundTrip(encryptedContext, encryptedVaultA, encryptedVaultB);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user