mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-07 12:15:46 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
002cf57116 | ||
|
|
15abe344bb | ||
|
|
a9e64860d5 | ||
|
|
741551be3a | ||
|
|
5f69d301f0 | ||
|
|
b42df389fb | ||
|
|
6a46966957 |
@@ -0,0 +1,267 @@
|
||||
# Architectural Decision Record: Fast Fetch Persistence and Completion Semantics
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Fast Fetch accelerates Fast Setup (Simple Fetch) by reading CouchDB's continuous
|
||||
changes feed directly, decrypting each document, and writing batches to the local
|
||||
database. It then allows LiveSync to reflect the completed database into the
|
||||
Vault.
|
||||
|
||||
This path deliberately bypasses PouchDB's ordinary replication machinery. It
|
||||
must therefore reproduce the correctness guarantees on which the rest of the
|
||||
initialisation workflow relies:
|
||||
|
||||
- a remote document is decrypted and validated before it is written locally;
|
||||
- every result from a batch write is checked;
|
||||
- a checkpoint represents the last contiguous remote sequence which is durable
|
||||
in the local database; and
|
||||
- successful completion means that the captured remote target has been reached,
|
||||
rather than that an estimated number of documents has been received.
|
||||
|
||||
The existing implementation combines line parsing, decryption, persistence, and
|
||||
completion checks within one broad error handler. A decryption or persistence
|
||||
failure can consequently be reported as a malformed JSON line and skipped. A
|
||||
batch write can also resolve while containing individual failed results. In both
|
||||
cases the stream may continue, advance its checkpoint incorrectly, or wait
|
||||
indefinitely for a completion condition which the failed row would have
|
||||
satisfied.
|
||||
|
||||
Fast Fetch also estimates the number of documents from the changes feed's
|
||||
`pending` value. That value is useful for progress reporting, but it is not an
|
||||
authoritative completion boundary. The CouchDB sequence token is opaque and must
|
||||
be handled using CouchDB's sequence semantics, without numeric-prefix comparison
|
||||
or inferred row counts. On clustered CouchDB, database information and the
|
||||
changes feed may encode the same position with different opaque tokens, so an
|
||||
`update_seq` from database information must not be compared directly with a
|
||||
changes-feed row.
|
||||
|
||||
## Decision
|
||||
|
||||
### Remote snapshot and completion
|
||||
|
||||
Fast Fetch must obtain an authoritative target token from a normal changes-feed
|
||||
snapshot before consuming the stream. A request from `since=now` with no result
|
||||
rows provides a token in the same sequence domain as the streamed rows. If that
|
||||
target cannot be obtained, the fetch fails instead of falling back to a database
|
||||
information token, a document-count estimate, or another approximate sequence.
|
||||
|
||||
The target token is treated as opaque. Fast Fetch completes only after the row
|
||||
for the captured target has been processed and all work up to that row has been
|
||||
persisted successfully. When a status request proves that no changes exist after
|
||||
the current durable checkpoint, the captured target may be checkpointed without
|
||||
opening the continuous stream. This includes an empty remote database. Changes
|
||||
made remotely after the target was captured are outside this Fast Fetch snapshot
|
||||
and are left for subsequent ordinary replication.
|
||||
|
||||
The estimated document count remains available for progress reporting only. It
|
||||
must not determine success.
|
||||
|
||||
### Processing and persistence
|
||||
|
||||
Each non-blank line from the changes feed is processed through these ordered
|
||||
stages:
|
||||
|
||||
1. parse and validate the changes-feed row;
|
||||
2. decrypt and validate its document, when a document is present;
|
||||
3. add the document to the pending local batch;
|
||||
4. persist the batch; and
|
||||
5. inspect every result returned by the batch write.
|
||||
|
||||
With `new_edits: false`, PouchDB follows CouchDB behaviour and may omit successful
|
||||
results. Fast Fetch therefore inspects every returned result and treats any
|
||||
error result as a failed batch. An empty result is valid when PouchDB accepted
|
||||
the complete batch.
|
||||
|
||||
The checkpoint may advance only to the last contiguous sequence for which all
|
||||
preceding documents are durable. A row which legitimately requires no local
|
||||
write may advance the checkpoint only after any preceding buffered documents
|
||||
have been flushed successfully. The target sequence is committed under the same
|
||||
rule before the operation reports success.
|
||||
|
||||
If a batch is partly written, its checkpoint is not advanced. Retrying the batch
|
||||
with `new_edits: false` is expected to be idempotent, including for documents
|
||||
which were accepted during the first attempt.
|
||||
|
||||
Blank heartbeat lines are ignored. Malformed rows are failures; they are not
|
||||
silently skipped. Logs may describe the stage and sequence involved, but must
|
||||
not include the raw changes-feed line because it may be large or sensitive.
|
||||
|
||||
The continuous changes request and its decoded reader must be terminated on
|
||||
every exit. Releasing a reader lock alone does not cancel the underlying
|
||||
request. Failure and completion paths therefore abort the request and attempt
|
||||
to cancel the reader before the bounded remote-activity scope ends.
|
||||
|
||||
### Failure classification and retry
|
||||
|
||||
The streaming boundary returns a small structured failure with a stage and an
|
||||
explicit retryability decision. The initial stages are:
|
||||
|
||||
```typescript
|
||||
type StreamingFetchFailureStage = "transport" | "authentication" | "protocol" | "decryption" | "storage";
|
||||
```
|
||||
|
||||
This type is an internal behavioural contract, not a user-interface status
|
||||
enumeration. It may carry safe diagnostic context, such as an HTTP status or a
|
||||
sequence token, without carrying document content.
|
||||
|
||||
Only explicitly recognised transient transport failures are retried
|
||||
automatically. Examples include an interrupted connection, HTTP 408, HTTP 429,
|
||||
and selected HTTP 5xx responses. Authentication, protocol, decryption, and local
|
||||
storage failures are terminal by default. A future implementation may recognise
|
||||
a narrower retryable case, but it must do so explicitly rather than retry every
|
||||
exception.
|
||||
|
||||
Each retry resumes from the last durable contiguous checkpoint. Retry exhaustion
|
||||
returns an actionable classified failure to the caller.
|
||||
|
||||
### Initialisation lifecycle
|
||||
|
||||
Fast Fetch success is the only path which may continue with the offline scan,
|
||||
finish the rebuild, resume Vault reflection, clear the Fast Fetch checkpoint,
|
||||
remove the flag file, or forget the remembered initialisation choice.
|
||||
|
||||
The LiveSync initialisation boundary uses an explicit suspension policy.
|
||||
Ordinary Fetch and Rebuild resume Vault reflection when they finish, SCRAM keeps
|
||||
file watching suspended, and Fast Fetch keeps both file watching and replication
|
||||
result parsing suspended only when initialisation fails. Fast Fetch asserts both
|
||||
suspensions before it begins and owns their final state: success clears both,
|
||||
whereas a false result or exception sets both. This final assignment also covers
|
||||
a late failure after rebuild finalisation and the legacy
|
||||
`doNotSuspendOnFetching` path.
|
||||
|
||||
On failure:
|
||||
|
||||
- the local checkpoint and any durably fetched documents are retained for a
|
||||
later retry;
|
||||
- the local database is not marked as resolved;
|
||||
- Vault reflection remains suspended;
|
||||
- the offline scan and rebuild finalisation are not run; and
|
||||
- the flag file and remembered initialisation choice remain available so that
|
||||
restart recovery can offer the same operation again.
|
||||
|
||||
Any bounded remote-activity scope must still be released in a `finally` path, as
|
||||
defined by [Bounded Remote Activity](2026_07_bounded_remote_activity.md). Keeping
|
||||
Vault reflection suspended does not permit a wake lock or similar resource to
|
||||
leak.
|
||||
|
||||
## Ownership
|
||||
|
||||
The responsibilities are divided at three injectable boundaries.
|
||||
|
||||
### Streaming Fetch
|
||||
|
||||
The Commonlib streaming implementation owns HTTP response validation, NDJSON
|
||||
parsing, invocation of the decryption delegate, batch-write result validation,
|
||||
contiguous checkpoint advancement, target-sequence completion, and classified
|
||||
failures. It does not know about the Vault, setup dialogues, flag files, or
|
||||
LiveSync settings.
|
||||
|
||||
### Rebuilder
|
||||
|
||||
The Commonlib rebuilder owns the local database lifecycle, checkpoint storage,
|
||||
retry policy, marking a completed database as resolved, optional resumption of
|
||||
reflection, and final checkpoint removal. It does not parse changes-feed rows or
|
||||
interpret user-interface choices.
|
||||
|
||||
### LiveSync Fast Setup
|
||||
|
||||
LiveSync owns the setup choices, suspension of initial Vault reflection,
|
||||
invocation of Fast Fetch, the success-only offline scan and rebuild finalisation,
|
||||
and cleanup of flag files and remembered choices. It does not reinterpret
|
||||
document, encryption, or storage failures as successful setup.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
This decision does not:
|
||||
|
||||
- change the Metadata and Chunks formats, encryption scheme, security seed, or
|
||||
path obfuscation;
|
||||
- change ordinary PouchDB replication, Standard Fetch, or offline-scan
|
||||
semantics;
|
||||
- provide a transaction spanning CouchDB and the local database;
|
||||
- skip corrupt or unreadable documents and continue with a partial database;
|
||||
- add an automatic fallback from Fast Fetch to Standard Fetch;
|
||||
- define the detailed failure dialogue or other setup user-interface changes;
|
||||
or
|
||||
- require Fast Fetch to include remote changes made after its captured target.
|
||||
|
||||
An explicit Standard Fetch choice remains available when a user needs the
|
||||
ordinary replication path. Any automatic fallback or richer recovery dialogue
|
||||
requires a separate decision because it changes user-visible setup behaviour.
|
||||
|
||||
## Verification
|
||||
|
||||
The implementation is verified primarily with London School interaction tests,
|
||||
using mocks at each owned boundary to prove collaboration and call order.
|
||||
|
||||
### Streaming Fetch unit tests
|
||||
|
||||
Inject the HTTP stream, decryption delegate, local batch writer, and checkpoint
|
||||
writer. Verify that:
|
||||
|
||||
- the order is decrypt, persist, inspect results, then checkpoint;
|
||||
- parsing, decryption, and batch-result failures prevent checkpoint advancement
|
||||
and completion;
|
||||
- a partly failed batch leaves the checkpoint unchanged and reports a storage
|
||||
failure;
|
||||
- rows without a local write flush earlier buffered documents before advancing;
|
||||
- an estimated document count cannot complete the fetch;
|
||||
- the captured target cannot complete the fetch before its batch is durable;
|
||||
- authentication and malformed-protocol responses are terminal;
|
||||
- recognised transient transport failures are classified as retryable; and
|
||||
- diagnostics do not log the raw changes-feed line.
|
||||
|
||||
### Rebuilder unit tests
|
||||
|
||||
Inject the streaming operation and lifecycle collaborators. Verify that:
|
||||
|
||||
- transient failures retry from the latest durable checkpoint;
|
||||
- terminal failures are attempted once;
|
||||
- success marks the database as resolved, resumes reflection when requested, and
|
||||
clears the checkpoint in that order; and
|
||||
- failure does not mark the database as resolved, resume reflection, or clear
|
||||
the checkpoint.
|
||||
|
||||
### LiveSync orchestration unit tests
|
||||
|
||||
Inject Fast Fetch, the offline scanner, rebuild finalisation, and cleanup
|
||||
collaborators. Verify that failure performs none of the success-only actions and
|
||||
leaves initial Vault reflection suspended. Verify that success retains the
|
||||
existing setup sequence and cleanup.
|
||||
|
||||
### Integration and E2E tests
|
||||
|
||||
Commonlib's CouchDB integration test remains responsible for the real HTTP
|
||||
changes feed, opaque sequence tokens, and local batch persistence. It should
|
||||
include a data set large enough to cross a batch boundary and confirm that the
|
||||
final checkpoint equals the captured target.
|
||||
|
||||
LiveSync's real Obsidian Setup URI workflow remains responsible for the actual
|
||||
Fast Fetch selection, E2EE passphrase, Vault reflection, ordinary file round
|
||||
trip, and hidden-file synchronisation. Injected parsing, decryption, and
|
||||
persistence failures remain unit-test responsibilities; repeating them through
|
||||
the real Obsidian E2E does not add coverage for an unchanged framework boundary.
|
||||
This follows [Real Obsidian E2E](2026_06_real_obsidian_e2e.md).
|
||||
|
||||
## Consequences
|
||||
|
||||
- A deterministic document failure which previously appeared to be skipped now
|
||||
fails Fast Fetch. This is an intentional safety improvement because the local
|
||||
database is known to be incomplete.
|
||||
- Partial durable work and its contiguous checkpoint can be reused by a later
|
||||
attempt without exposing the partial database to the Vault.
|
||||
- Retry delays are no longer spent on authentication, corrupt content, protocol,
|
||||
or local persistence failures which cannot repair themselves.
|
||||
- Progress totals remain approximate and may change without affecting
|
||||
correctness.
|
||||
- The implementation requires coordinated changes in Commonlib and LiveSync.
|
||||
Commonlib remains the authoritative package for streaming and rebuilder
|
||||
behaviour; LiveSync consumes an immutable Commonlib release and owns its setup
|
||||
orchestration.
|
||||
- Ordinary replication remains unchanged and continues to provide the reference
|
||||
correctness contract for decrypting, persisting, and checkpointing replicated
|
||||
documents.
|
||||
+2
-2
@@ -39,7 +39,7 @@ Use this path only when the remote database is new, or when this device is inten
|
||||
|
||||

|
||||
|
||||
8. A newly provisioned database may show `Fetch Remote Configuration Failed` because it does not contain a saved preferred configuration yet. If this is a genuinely new setup, select `Skip and proceed`. Otherwise, stop and investigate before continuing.
|
||||
8. A newly provisioned database may show `No Synchronisation Settings Found`. Select `Use this device's settings` for the intended new database. If you expected existing settings, cancel and check the selected remote. Cancellation restarts with Vault and database reflection paused; keep LiveSync disabled until the remote has been checked. If `Could Not Read Synchronisation Settings` appears, retry, or use this device's settings to continue the overwrite. The overwrite still requires a working connection.
|
||||
|
||||

|
||||
|
||||
@@ -134,7 +134,7 @@ Use this path when CouchDB is ready but a Setup URI is unavailable. It configure
|
||||
8. Select `Create or connect to database and continue`. Onboarding requires this connection test to succeed.
|
||||
9. Review `Setup Complete: Preparing to Initialise Server`, then select `Restart and Initialise Server`.
|
||||
10. Read the final overwrite warning. Select `I Understand, Overwrite Server` only when this device is intentionally the source of truth and a current backup exists.
|
||||
11. A newly created database can show `Fetch Remote Configuration Failed` because it does not yet contain a saved preferred configuration. Select `Skip and proceed` only for this known new database.
|
||||
11. A newly created database can show `No Synchronisation Settings Found`. Select `Use this device's settings` for this known new database. If you expected existing settings, cancel and check the selected remote. Cancellation restarts with Vault and database reflection paused; keep LiveSync disabled until the remote has been checked. If `Could Not Read Synchronisation Settings` appears, retry, or use this device's settings to continue the overwrite. The overwrite still requires a working connection.
|
||||
12. Acknowledge `All optional features are disabled`, then keep Obsidian open until the initialisation progress has cleared.
|
||||
|
||||
Create and synchronise an ordinary test note. Once it has reached CouchDB, follow [Create a Setup URI for another device](#create-a-setup-uri-for-another-device), then [Add another device](#add-another-device). This keeps the second device aligned with the remote profile and encryption settings which the first device actually applied.
|
||||
|
||||
@@ -50,7 +50,7 @@ Use a new bucket prefix, or a prefix whose contents you deliberately intend to r
|
||||
|
||||

|
||||
|
||||
6. A new prefix may show `Fetch Remote Configuration Failed` because it has no saved configuration. Select `Skip and proceed` only for a genuinely new prefix. Otherwise, stop and check the endpoint, credentials, bucket, and prefix.
|
||||
6. A new prefix may show `No Synchronisation Settings Found`. Select `Use this device's settings` for a genuinely new prefix. If you expected existing settings, cancel and check the endpoint, credentials, bucket, and prefix. Cancellation restarts with Vault and database reflection paused; keep LiveSync disabled until the remote has been checked. If `Could Not Read Synchronisation Settings` appears, retry, or use this device's settings to continue the overwrite. The overwrite still requires a working connection.
|
||||
|
||||

|
||||
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"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
+9
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"src/apps/cli",
|
||||
@@ -23,7 +23,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.4",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.6",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
@@ -4775,9 +4775,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.4.tgz",
|
||||
"integrity": "sha512-qQst1QCZEgfxwpdjnjFmXE3JNy4ATKhN8ihJSUUho4mdWC1O4zyRRhmQjg0GHu5hQPZokufr2mrP4Hur8niDNg==",
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.6.tgz",
|
||||
"integrity": "sha512-rKpiTZYZRLaYcBQ2gcSyPGa9HONtataIB75dfP9+6BFlq/KzXimhKf1RqWxkAJj5GGzKmhtq+vXXExA+vAshMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
@@ -15924,7 +15924,7 @@
|
||||
},
|
||||
"src/apps/cli": {
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"version": "1.0.5-cli",
|
||||
"version": "1.0.6-cli",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
@@ -15949,7 +15949,7 @@
|
||||
},
|
||||
"src/apps/webapp": {
|
||||
"name": "livesync-webapp",
|
||||
"version": "1.0.5-webapp",
|
||||
"version": "1.0.6-webapp",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
@@ -15961,7 +15961,7 @@
|
||||
}
|
||||
},
|
||||
"src/apps/webpeer": {
|
||||
"version": "1.0.5-webpeer",
|
||||
"version": "1.0.6-webpeer",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"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",
|
||||
@@ -177,7 +177,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.4",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.6",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.5-cli",
|
||||
"version": "1.0.6-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.5-webapp",
|
||||
"version": "1.0.6-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.5-webpeer",
|
||||
"version": "1.0.6-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -49,6 +49,18 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"Connect to existing database and continue": "Connect to existing database and continue",
|
||||
"Test connection and save": "Test connection and save",
|
||||
"Save without connecting": "Save without connecting",
|
||||
"Use this device's settings": "Use this device's settings",
|
||||
Retry: "Retry",
|
||||
"No Synchronisation Settings Found": "No Synchronisation Settings Found",
|
||||
"The selected remote has no saved synchronisation settings. This is normal for a new remote. Use this device's settings, or cancel if you expected existing settings.":
|
||||
"The selected remote has no saved synchronisation settings. This is normal for a new remote. Use this device's settings, or cancel if you expected existing settings.",
|
||||
"Could Not Read Synchronisation Settings": "Could Not Read Synchronisation Settings",
|
||||
"Could not read the remote's synchronisation settings. Check the connection and credentials, then retry.":
|
||||
"Could not read the remote's synchronisation settings. Check the connection and credentials, then retry.",
|
||||
"Could not read the remote's synchronisation settings. Retry, or continue the overwrite with this device's settings. A working connection is still required.":
|
||||
"Could not read the remote's synchronisation settings. Retry, or continue the overwrite with this device's settings. A working connection is still required.",
|
||||
"Skips checking and applying synchronisation settings from the remote.":
|
||||
"Skips checking and applying synchronisation settings from the remote.",
|
||||
"Enter a complete HTTP or HTTPS URL.": "Enter a complete HTTP or HTTPS URL.",
|
||||
"CouchDB validates the database name when you connect. The name must not be empty.":
|
||||
"CouchDB validates the database name when you connect. The name must not be empty.",
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
type RemoteDBSettings,
|
||||
IncompatibleChangesInSpecificPattern,
|
||||
CompatibleButLossyChanges,
|
||||
type RemotePreferredTweakResult,
|
||||
RemotePreferredTweakStatuses,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { escapeMarkdownValue } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
@@ -256,22 +258,21 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
return "IGNORE";
|
||||
}
|
||||
|
||||
async _fetchRemotePreferredTweakValues(trialSetting: RemoteDBSettings): Promise<TweakValues | false> {
|
||||
const replicator = await this.services.replicator.getNewReplicator(trialSetting);
|
||||
if (!replicator) {
|
||||
this._log("The remote type is not supported for fetching preferred tweak values.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (await replicator.tryConnectRemote(trialSetting)) {
|
||||
const preferred = await replicator.getRemotePreferredTweakValues(trialSetting);
|
||||
if (preferred) {
|
||||
return preferred;
|
||||
async _fetchRemotePreferredTweakValues(trialSetting: RemoteDBSettings): Promise<RemotePreferredTweakResult> {
|
||||
try {
|
||||
const replicator = await this.services.replicator.getNewReplicator(trialSetting);
|
||||
if (!replicator) {
|
||||
this._log("The remote type does not support preferred tweak values.", LOG_LEVEL_NOTICE);
|
||||
return { status: RemotePreferredTweakStatuses.UNSUPPORTED };
|
||||
}
|
||||
this._log("Failed to get the preferred tweak values from the remote server.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
return await replicator.getRemotePreferredTweakValues(trialSetting);
|
||||
} catch (ex) {
|
||||
this._log("Failed to get the preferred tweak values from the remote.", LOG_LEVEL_NOTICE);
|
||||
return {
|
||||
status: RemotePreferredTweakStatuses.UNAVAILABLE,
|
||||
error: ex,
|
||||
};
|
||||
}
|
||||
this._log("Failed to connect to the remote server.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
|
||||
async _checkAndAskUseRemoteConfiguration(
|
||||
@@ -281,8 +282,8 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
return { result: false, requireFetch: false };
|
||||
}
|
||||
const preferred = await this.services.tweakValue.fetchRemotePreferred(trialSetting);
|
||||
if (preferred) {
|
||||
return await this.services.tweakValue.askUseRemoteConfiguration(trialSetting, preferred);
|
||||
if (preferred.status === RemotePreferredTweakStatuses.AVAILABLE) {
|
||||
return await this.services.tweakValue.askUseRemoteConfiguration(trialSetting, preferred.values);
|
||||
}
|
||||
return { result: false, requireFetch: false };
|
||||
}
|
||||
|
||||
@@ -54,6 +54,36 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
}
|
||||
|
||||
describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
it("returns an unconfigured remote result without a separate connection preflight", async () => {
|
||||
const { module, core } = createModule();
|
||||
const tryConnectRemote = vi.fn(async () => true);
|
||||
const getRemotePreferredTweakValues = vi.fn(async () => ({
|
||||
status: "not-configured" as const,
|
||||
reason: "milestone-missing" as const,
|
||||
}));
|
||||
core._services.replicator = {
|
||||
getNewReplicator: vi.fn(async () => ({ tryConnectRemote, getRemotePreferredTweakValues })),
|
||||
};
|
||||
|
||||
await expect(module._fetchRemotePreferredTweakValues(core.settings)).resolves.toEqual({
|
||||
status: "not-configured",
|
||||
reason: "milestone-missing",
|
||||
});
|
||||
expect(getRemotePreferredTweakValues).toHaveBeenCalledOnce();
|
||||
expect(tryConnectRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns unsupported when no replicator implements the remote type", async () => {
|
||||
const { module, core } = createModule();
|
||||
core._services.replicator = {
|
||||
getNewReplicator: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
await expect(module._fetchRemotePreferredTweakValues(core.settings)).resolves.toEqual({
|
||||
status: "unsupported",
|
||||
});
|
||||
});
|
||||
|
||||
it("should enable and auto-accept compatible mismatches when the preference is undefined", async () => {
|
||||
const { module, core, askSelectStringDialogue, applyPartial } = createModule({
|
||||
autoAcceptCompatibleTweak: undefined,
|
||||
|
||||
@@ -141,10 +141,11 @@
|
||||
</Instruction>
|
||||
<Instruction>
|
||||
<ExtraItems title={translateMessage("Advanced")}>
|
||||
<Check
|
||||
title={translateMessage("Prevent fetching configuration from server")}
|
||||
bind:value={preventFetchingConfig}
|
||||
/>
|
||||
<Check title={translateMessage("Use this device's settings")} bind:value={preventFetchingConfig}>
|
||||
<InfoNote>
|
||||
{translateMessage("Skips checking and applying synchronisation settings from the remote.")}
|
||||
</InfoNote>
|
||||
</Check>
|
||||
</ExtraItems>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
|
||||
@@ -129,7 +129,9 @@
|
||||
{#if !isP2P}
|
||||
<Instruction>
|
||||
<ExtraItems title={msg("Advanced")}>
|
||||
<Check title={msg("Prevent fetching configuration from server")} bind:value={preventFetchingConfig} />
|
||||
<Check title={msg("Use this device's settings")} bind:value={preventFetchingConfig}>
|
||||
<InfoNote>{msg("Skips checking and applying synchronisation settings from the remote.")}</InfoNote>
|
||||
</Check>
|
||||
</ExtraItems>
|
||||
</Instruction>
|
||||
{/if}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
synchroniseAllFilesBetweenDBandStorage,
|
||||
type FullScanOptions,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import { adjustSettingToRemoteIfNeeded, processVaultInitialisation } from "./redFlag";
|
||||
import { adjustSettingToRemoteIfNeeded, cancelScheduledInitialisation, processVaultInitialisation } from "./redFlag";
|
||||
|
||||
export const SIMPLE_FETCH_STAGE1_REMOTE_WINS = "Overwrite all with remote files";
|
||||
export const SIMPLE_FETCH_STAGE1_NEWER_WINS = "Compare time and take newer";
|
||||
@@ -194,9 +194,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
||||
if (result === "cancelled") {
|
||||
log("Fetch cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await cleanupFlag();
|
||||
host.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
return await cancelScheduledInitialisation(host, cleanupFlag);
|
||||
}
|
||||
if (result === "aborted") {
|
||||
log("Fetch exited by user.", LOG_LEVEL_NOTICE);
|
||||
@@ -208,9 +206,14 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
||||
return undefined; // Let the detailed setup flow handle it.
|
||||
}
|
||||
|
||||
return await processVaultInitialisation(host, log, async () => {
|
||||
const settings = host.services.setting.currentSettings();
|
||||
await adjustSettingToRemoteIfNeeded(host, log, { preventFetchingConfig: false }, settings);
|
||||
const settings = host.services.setting.currentSettings();
|
||||
if (!(await adjustSettingToRemoteIfNeeded(host, log, { preventFetchingConfig: false }, settings))) {
|
||||
log("Fetch initialisation cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
return await cancelScheduledInitialisation(host, cleanupFlag);
|
||||
}
|
||||
|
||||
const performFastSetup = async () => {
|
||||
// 1. Perform fast DB fetch (download remote DB content to local DB)
|
||||
await host.serviceModules.rebuilder.$fetchLocalDBFast(false);
|
||||
|
||||
@@ -246,5 +249,6 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
log("Simple fetch and scan operation completed.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
return await processVaultInitialisation(host, log, performFastSetup, "keep-on-failure");
|
||||
}
|
||||
|
||||
+159
-71
@@ -10,7 +10,10 @@ import RebuildEverything from "@/modules/features/SetupWizard/dialogs/RebuildEve
|
||||
import { extractObject } from "octagonal-wheels/object";
|
||||
import { REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { TweakValuesShouldMatchedTemplate } from "@vrtmrz/livesync-commonlib/compat/common/models/tweak.definition";
|
||||
import {
|
||||
RemotePreferredTweakStatuses,
|
||||
TweakValuesShouldMatchedTemplate,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/models/tweak.definition";
|
||||
import type {
|
||||
FetchEverythingResult,
|
||||
RebuildEverythingResult,
|
||||
@@ -19,6 +22,7 @@ import { askAndPerformFastSetupOnScheduledFetchAll } from "./redFlag.simpleFetch
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { isP2PMainRemote } from "@/common/remoteConfiguration";
|
||||
import { $msg } from "@/common/translation";
|
||||
|
||||
/**
|
||||
* Flag file handler interface, similar to target filter pattern.
|
||||
@@ -29,6 +33,8 @@ interface FlagFileHandler {
|
||||
handle: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
type InitialisationOperation = "fetch" | "rebuild";
|
||||
|
||||
export async function isFlagFileExist(host: NecessaryServices<never, "storageAccess">, path: string) {
|
||||
const redFlagExist = await host.serviceModules.storageAccess.isExists(
|
||||
host.serviceModules.storageAccess.normalisePath(path)
|
||||
@@ -158,9 +164,7 @@ export function createFetchAllFlagHandler(
|
||||
await host.services.UI.dialogManager.openWithExplicitCancel<FetchEverythingResult>(FetchEverything);
|
||||
if (method === "cancelled") {
|
||||
log("Fetch everything cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
await cleanupFlag();
|
||||
host.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
return await cancelScheduledInitialisation(host, cleanupFlag);
|
||||
}
|
||||
const { vault, extra } = method;
|
||||
const settings = await Promise.resolve(host.services.setting.currentSettings());
|
||||
@@ -185,9 +189,11 @@ export function createFetchAllFlagHandler(
|
||||
},
|
||||
} as const;
|
||||
|
||||
if (!(await adjustSettingToRemoteIfNeeded(host, log, extra, settings))) {
|
||||
log("Fetch initialisation cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
return await cancelScheduledInitialisation(host, cleanupFlag);
|
||||
}
|
||||
return await processVaultInitialisation(host, log, async () => {
|
||||
const settings = host.services.setting.currentSettings();
|
||||
await adjustSettingToRemoteIfNeeded(host, log, extra, settings);
|
||||
const vaultStateToAction = mapVaultStateToAction[vault];
|
||||
const { makeLocalChunkBeforeSync, makeLocalFilesBeforeSync } = vaultStateToAction;
|
||||
log(
|
||||
@@ -217,115 +223,184 @@ export function createFetchAllFlagHandler(
|
||||
/**
|
||||
* Adjust setting to remote configuration.
|
||||
* @param config current configuration to retrieve remote preferred config
|
||||
* @returns updated configuration if applied, otherwise null.
|
||||
* @param operation operation which will consume the selected configuration
|
||||
* @returns whether initialisation may continue.
|
||||
*/
|
||||
export async function adjustSettingToRemote(
|
||||
host: NecessaryServices<"tweakValue" | "UI" | "setting", never>,
|
||||
log: LogFunction,
|
||||
config: ObsidianLiveSyncSettings
|
||||
) {
|
||||
// Fetch remote configuration unless prevented.
|
||||
const SKIP_FETCH = "Skip and proceed";
|
||||
const RETRY_FETCH = "Retry (recommended)";
|
||||
let canProceed = false;
|
||||
do {
|
||||
const remoteTweaks = await host.services.tweakValue.fetchRemotePreferred(config);
|
||||
if (!remoteTweaks) {
|
||||
config: ObsidianLiveSyncSettings,
|
||||
operation: InitialisationOperation = "fetch"
|
||||
): Promise<boolean> {
|
||||
while (true) {
|
||||
const remoteResult = await host.services.tweakValue.fetchRemotePreferred(config);
|
||||
if (remoteResult.status === RemotePreferredTweakStatuses.NOT_CONFIGURED) {
|
||||
const useDeviceSettings = $msg("Use this device's settings");
|
||||
const cancelInitialisation = $msg("Cancel");
|
||||
log(`Remote synchronisation settings are not configured (${remoteResult.reason}).`, LOG_LEVEL_INFO);
|
||||
const choice = await host.services.UI.confirm.askSelectStringDialogue(
|
||||
"Could not fetch configuration from remote. If you are new to the Self-hosted LiveSync, this might be expected. If not, you should check your network or server settings.",
|
||||
[SKIP_FETCH, RETRY_FETCH] as const,
|
||||
$msg(
|
||||
"The selected remote has no saved synchronisation settings. This is normal for a new remote. Use this device's settings, or cancel if you expected existing settings."
|
||||
),
|
||||
[useDeviceSettings, cancelInitialisation] as const,
|
||||
{
|
||||
defaultAction: RETRY_FETCH,
|
||||
defaultAction: useDeviceSettings,
|
||||
timeout: 0,
|
||||
title: "Fetch Remote Configuration Failed",
|
||||
title: $msg("No Synchronisation Settings Found"),
|
||||
}
|
||||
);
|
||||
if (choice === SKIP_FETCH) {
|
||||
canProceed = true;
|
||||
}
|
||||
} else {
|
||||
const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks);
|
||||
// Check if any necessary tweak value is different from current config.
|
||||
const differentItems = Object.entries(necessary).filter(([key, value]) => {
|
||||
return config[key as keyof ObsidianLiveSyncSettings] !== value;
|
||||
});
|
||||
if (differentItems.length === 0) {
|
||||
log("Remote configuration matches local configuration. No changes applied.", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
await host.services.UI.confirm.askSelectStringDialogue(
|
||||
"Your settings differed slightly from the server's. The plug-in has supplemented the incompatible parts with the server settings!",
|
||||
["OK"] as const,
|
||||
return choice === useDeviceSettings;
|
||||
}
|
||||
if (remoteResult.status === RemotePreferredTweakStatuses.UNAVAILABLE) {
|
||||
const retryRemoteSettings = $msg("Retry");
|
||||
const useDeviceSettings = $msg("Use this device's settings");
|
||||
const cancelInitialisation = $msg("Cancel");
|
||||
log("Could not read synchronisation settings from the remote.", LOG_LEVEL_NOTICE);
|
||||
log(remoteResult.error, LOG_LEVEL_VERBOSE);
|
||||
if (operation === "rebuild") {
|
||||
const choice = await host.services.UI.confirm.askSelectStringDialogue(
|
||||
$msg(
|
||||
"Could not read the remote's synchronisation settings. Retry, or continue the overwrite with this device's settings. A working connection is still required."
|
||||
),
|
||||
[retryRemoteSettings, useDeviceSettings, cancelInitialisation] as const,
|
||||
{
|
||||
defaultAction: "OK",
|
||||
defaultAction: retryRemoteSettings,
|
||||
timeout: 0,
|
||||
title: $msg("Could Not Read Synchronisation Settings"),
|
||||
}
|
||||
);
|
||||
if (choice === retryRemoteSettings) continue;
|
||||
return choice === useDeviceSettings;
|
||||
}
|
||||
|
||||
config = {
|
||||
...config,
|
||||
...(Object.fromEntries(differentItems) as Partial<ObsidianLiveSyncSettings>),
|
||||
} satisfies ObsidianLiveSyncSettings;
|
||||
await host.services.setting.applyExternalSettings(config, true);
|
||||
log("Remote configuration applied.", LOG_LEVEL_NOTICE);
|
||||
canProceed = true;
|
||||
const updatedConfig = host.services.setting.currentSettings();
|
||||
return updatedConfig;
|
||||
const choice = await host.services.UI.confirm.askSelectStringDialogue(
|
||||
$msg(
|
||||
"Could not read the remote's synchronisation settings. Check the connection and credentials, then retry."
|
||||
),
|
||||
[retryRemoteSettings, cancelInitialisation] as const,
|
||||
{
|
||||
defaultAction: retryRemoteSettings,
|
||||
timeout: 0,
|
||||
title: $msg("Could Not Read Synchronisation Settings"),
|
||||
}
|
||||
);
|
||||
if (choice === retryRemoteSettings) continue;
|
||||
return false;
|
||||
}
|
||||
} while (!canProceed);
|
||||
if (remoteResult.status === RemotePreferredTweakStatuses.UNSUPPORTED) {
|
||||
log("Remote synchronisation settings are not supported by this remote type.", LOG_LEVEL_INFO);
|
||||
return true;
|
||||
}
|
||||
|
||||
const remoteTweaks = remoteResult.values;
|
||||
const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks);
|
||||
// Check if any necessary tweak value is different from current config.
|
||||
const differentItems = Object.entries(necessary).filter(([key, value]) => {
|
||||
return config[key as keyof ObsidianLiveSyncSettings] !== value;
|
||||
});
|
||||
if (differentItems.length === 0) {
|
||||
log("Remote configuration matches local configuration. No changes applied.", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
await host.services.UI.confirm.askSelectStringDialogue(
|
||||
"Your settings differed slightly from the server's. The plug-in has supplemented the incompatible parts with the server settings!",
|
||||
["OK"] as const,
|
||||
{
|
||||
defaultAction: "OK",
|
||||
timeout: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
config = {
|
||||
...config,
|
||||
...(Object.fromEntries(differentItems) as Partial<ObsidianLiveSyncSettings>),
|
||||
} satisfies ObsidianLiveSyncSettings;
|
||||
await host.services.setting.applyExternalSettings(config, true);
|
||||
log("Remote configuration applied.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust setting to remote if needed.
|
||||
* @param extra result of dialogues that may contain preventFetchingConfig flag (e.g, from FetchEverything or RebuildEverything)
|
||||
* @param config current configuration to retrieve remote preferred config
|
||||
* @param operation operation which will consume the selected configuration
|
||||
*/
|
||||
export async function adjustSettingToRemoteIfNeeded(
|
||||
host: NecessaryServices<"tweakValue" | "UI" | "setting", never>,
|
||||
log: LogFunction,
|
||||
extra: { preventFetchingConfig: boolean },
|
||||
config: ObsidianLiveSyncSettings
|
||||
) {
|
||||
if (extra && extra.preventFetchingConfig) {
|
||||
return;
|
||||
extra: { preventFetchingConfig: boolean } | null,
|
||||
config: ObsidianLiveSyncSettings,
|
||||
operation: InitialisationOperation = "fetch"
|
||||
): Promise<boolean> {
|
||||
if (extra?.preventFetchingConfig) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// P2P has no centralised remote configuration; skip to avoid a spurious
|
||||
// "Failed to connect to the remote server" error dialog.
|
||||
if (config.remoteType === REMOTE_P2P) {
|
||||
log("Remote configuration fetch skipped (P2P mode).", LOG_LEVEL_INFO);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remote configuration fetched and applied.
|
||||
if (await adjustSettingToRemote(host, log, config)) {
|
||||
config = host.services.setting.currentSettings();
|
||||
} else {
|
||||
const canProceed = await adjustSettingToRemote(host, log, config, operation);
|
||||
if (!canProceed) {
|
||||
log("Remote configuration not applied.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
// log(JSON.stringify(config), LOG_LEVEL_VERBOSE);
|
||||
return canProceed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process vault initialisation with suspending file watching and sync.
|
||||
* @param proc process to be executed during initialisation, should return true if can be continued, false if app is unable to continue the process.
|
||||
* @param keepSuspending whether to keep suspending file watching after the process.
|
||||
* @returns result of the process, or false if error occurs.
|
||||
* Cancel a scheduled Fetch or Rebuild without changing the selected automatic
|
||||
* synchronisation mode. The persisted Scram switches keep both reflection
|
||||
* directions paused until the existing start-up dialogue resumes them.
|
||||
*/
|
||||
export async function cancelScheduledInitialisation(
|
||||
host: NecessaryServices<"setting" | "appLifecycle", never>,
|
||||
cleanupFlag: () => Promise<void>
|
||||
): Promise<false> {
|
||||
await host.services.setting.applyPartial(
|
||||
{
|
||||
suspendFileWatching: true,
|
||||
suspendParseReplicationResult: true,
|
||||
},
|
||||
true
|
||||
);
|
||||
await cleanupFlag();
|
||||
host.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
|
||||
type InitialisationSuspensionPolicy = "resume" | "keep" | "keep-on-failure";
|
||||
|
||||
/**
|
||||
* Process Vault initialisation with file watching and synchronisation suspended.
|
||||
* @param proc Process to execute during initialisation. It returns true only when normal operation may resume.
|
||||
* @param suspensionPolicy Final file-reflection state. `keep-on-failure` controls both reflection directions so a partly completed Fast Setup remains isolated.
|
||||
* @returns The result of the process, or false if an error occurs.
|
||||
*/
|
||||
export async function processVaultInitialisation(
|
||||
host: NecessaryServices<"setting", never>,
|
||||
log: LogFunction,
|
||||
proc: () => Promise<boolean>,
|
||||
keepSuspending = false
|
||||
suspensionPolicy: InitialisationSuspensionPolicy = "resume"
|
||||
) {
|
||||
let completed = false;
|
||||
try {
|
||||
// Disable batch saving and file watching during initialisation.
|
||||
await host.services.setting.applyPartial({ batchSave: false }, false);
|
||||
await host.services.setting.suspendAllSync();
|
||||
await host.services.setting.suspendExtraSync();
|
||||
await host.services.setting.applyPartial({ suspendFileWatching: true }, true);
|
||||
await host.services.setting.applyPartial(
|
||||
suspensionPolicy === "keep-on-failure"
|
||||
? { suspendFileWatching: true, suspendParseReplicationResult: true }
|
||||
: { suspendFileWatching: true },
|
||||
true
|
||||
);
|
||||
try {
|
||||
const result = await proc();
|
||||
completed = result;
|
||||
return result;
|
||||
} catch (ex) {
|
||||
log("Error during vault initialisation process.", LOG_LEVEL_NOTICE);
|
||||
@@ -337,9 +412,21 @@ export async function processVaultInitialisation(
|
||||
log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
} finally {
|
||||
if (!keepSuspending) {
|
||||
// Re-enable file watching after initialisation.
|
||||
if (suspensionPolicy === "resume") {
|
||||
await host.services.setting.applyPartial({ suspendFileWatching: false }, true);
|
||||
} else if (suspensionPolicy === "keep") {
|
||||
await host.services.setting.applyPartial({ suspendFileWatching: true }, true);
|
||||
} else {
|
||||
// Fast Setup owns both directions at this boundary. Reasserting the
|
||||
// outcome also covers a late failure after finishRebuild started to
|
||||
// resume reflection, and the legacy doNotSuspendOnFetching path.
|
||||
await host.services.setting.applyPartial(
|
||||
{
|
||||
suspendFileWatching: !completed,
|
||||
suspendParseReplicationResult: !completed,
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,12 +480,13 @@ export function createRebuildFlagHandler(
|
||||
>(RebuildEverything, { isP2P: isP2PMainRemote(settings) });
|
||||
if (method === "cancelled") {
|
||||
log("Rebuild everything cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
await cleanupFlag();
|
||||
host.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
return await cancelScheduledInitialisation(host, cleanupFlag);
|
||||
}
|
||||
const { extra } = method;
|
||||
await adjustSettingToRemoteIfNeeded(host, log, extra, settings);
|
||||
if (!(await adjustSettingToRemoteIfNeeded(host, log, extra, settings, "rebuild"))) {
|
||||
log("Rebuild initialisation cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
return await cancelScheduledInitialisation(host, cleanupFlag);
|
||||
}
|
||||
return await processVaultInitialisation(host, log, async () => {
|
||||
await host.serviceModules.rebuilder.$rebuildEverything();
|
||||
await cleanupFlag();
|
||||
@@ -445,7 +533,7 @@ export function createSuspendFlagHandler(
|
||||
await host.services.setting.applyPartial({ writeLogToTheFile: true }, true);
|
||||
return Promise.resolve(false);
|
||||
},
|
||||
true
|
||||
"keep"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -68,6 +68,8 @@ const createLoggerMock = (): LogFunction => {
|
||||
return vi.fn();
|
||||
};
|
||||
|
||||
const availableRemoteTweaks = (values: Record<string, unknown>) => ({ status: "available", values }) as const;
|
||||
|
||||
const createStorageAccessMock = () => {
|
||||
const files: Set<string> = new Set();
|
||||
return {
|
||||
@@ -149,7 +151,9 @@ const createRebuilderMock = () => {
|
||||
|
||||
const createTweakValueMock = () => {
|
||||
return {
|
||||
fetchRemotePreferred: vi.fn(() => Promise.resolve<any>(null)),
|
||||
fetchRemotePreferred: vi.fn(() =>
|
||||
Promise.resolve<any>({ status: "unavailable", error: new Error("Remote unavailable") })
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -321,13 +325,13 @@ describe("Red Flag Feature", () => {
|
||||
() => {
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
false
|
||||
"resume"
|
||||
);
|
||||
|
||||
expect(host.mocks.setting.currentSettings().suspendFileWatching).toBe(false);
|
||||
});
|
||||
|
||||
it("should keep suspending when keepSuspending is true", async () => {
|
||||
it("should keep suspending when the policy is keep", async () => {
|
||||
const host = createHostMock();
|
||||
const log = createLoggerMock();
|
||||
|
||||
@@ -337,7 +341,7 @@ describe("Red Flag Feature", () => {
|
||||
() => {
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
true
|
||||
"keep"
|
||||
);
|
||||
|
||||
expect(host.mocks.setting.currentSettings().suspendFileWatching).toBe(true);
|
||||
@@ -353,7 +357,7 @@ describe("Red Flag Feature", () => {
|
||||
() => {
|
||||
throw new Error("Process failed");
|
||||
},
|
||||
false
|
||||
"resume"
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
@@ -459,9 +463,9 @@ describe("Red Flag Feature", () => {
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL);
|
||||
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
||||
batchSave: false,
|
||||
} as any);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
||||
availableRemoteTweaks({ batchSave: false })
|
||||
);
|
||||
|
||||
const handler = createFetchAllFlagHandler(host as any, log);
|
||||
const result = await handler.handle();
|
||||
@@ -487,9 +491,9 @@ describe("Red Flag Feature", () => {
|
||||
backup: "backup_skipped",
|
||||
extra: { preventFetchingConfig: false },
|
||||
});
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
||||
batchSave: false,
|
||||
} as any);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
||||
availableRemoteTweaks({ batchSave: false })
|
||||
);
|
||||
const handler = createFetchAllFlagHandler(host as any, log);
|
||||
const result = await handler.handle();
|
||||
|
||||
@@ -506,9 +510,9 @@ describe("Red Flag Feature", () => {
|
||||
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(false);
|
||||
|
||||
const handler = createFetchAllFlagHandler(host as any, log);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
||||
batchSave: false,
|
||||
} as any);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
||||
availableRemoteTweaks({ batchSave: false })
|
||||
);
|
||||
const result = await handler.handle();
|
||||
|
||||
expect(result).toBe(false);
|
||||
@@ -525,9 +529,9 @@ describe("Red Flag Feature", () => {
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL);
|
||||
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
||||
batchSave: false,
|
||||
} as any);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
||||
availableRemoteTweaks({ batchSave: false })
|
||||
);
|
||||
const handler = createFetchAllFlagHandler(host as any, log);
|
||||
const result = await handler.handle();
|
||||
|
||||
@@ -755,6 +759,79 @@ describe("Red Flag Feature", () => {
|
||||
});
|
||||
|
||||
describe("askAndPerformFastSetupOnScheduledFetchAll", () => {
|
||||
it("releases both reflection suspensions after Fast Setup succeeds", async () => {
|
||||
const host = createHostMock();
|
||||
const log = createLoggerMock();
|
||||
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
Object.assign(host.mocks.setting.settings, {
|
||||
doNotSuspendOnFetching: true,
|
||||
suspendParseReplicationResult: true,
|
||||
});
|
||||
host.mocks.ui.confirm.confirmWithMessage
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ batchSave: false }));
|
||||
|
||||
await expect(askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag)).resolves.toBe(true);
|
||||
|
||||
expect(host.mocks.setting.currentSettings()).toMatchObject({
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Vault reflection suspended and preserves recovery state when Fast Fetch fails", async () => {
|
||||
const host = createHostMock();
|
||||
const log = createLoggerMock();
|
||||
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
host.mocks.ui.confirm.confirmWithMessage
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ batchSave: false }));
|
||||
host.mocks.rebuilder.$fetchLocalDBFast.mockRejectedValueOnce(new Error("cannot decrypt remote document"));
|
||||
|
||||
await expect(askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag)).resolves.toBe(false);
|
||||
|
||||
expect(host.mocks.setting.currentSettings()).toMatchObject({
|
||||
suspendFileWatching: true,
|
||||
suspendParseReplicationResult: true,
|
||||
});
|
||||
expect(host.mocks.rebuilder.finishRebuild).not.toHaveBeenCalled();
|
||||
expect(cleanupFlag).not.toHaveBeenCalled();
|
||||
expect(host.mocks.setting.deleteSmallConfig).not.toHaveBeenCalledWith("simple-fetch-mode");
|
||||
});
|
||||
|
||||
it("re-suspends both reflection directions when finalisation fails after releasing them", async () => {
|
||||
const host = createHostMock();
|
||||
const log = createLoggerMock();
|
||||
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
host.mocks.ui.confirm.confirmWithMessage
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ batchSave: false }));
|
||||
host.mocks.rebuilder.finishRebuild.mockImplementationOnce(async () => {
|
||||
await host.mocks.setting.applyPartial(
|
||||
{
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
true
|
||||
);
|
||||
throw new Error("Vault scan failed after reflection resumed");
|
||||
});
|
||||
|
||||
await expect(askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag)).resolves.toBe(false);
|
||||
|
||||
expect(host.mocks.setting.currentSettings()).toMatchObject({
|
||||
suspendFileWatching: true,
|
||||
suspendParseReplicationResult: true,
|
||||
});
|
||||
expect(cleanupFlag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should remember quick flow choices while the scheduled fetch is pending", async () => {
|
||||
const host = createHostMock();
|
||||
const log = createLoggerMock();
|
||||
@@ -763,7 +840,7 @@ describe("Red Flag Feature", () => {
|
||||
host.mocks.ui.confirm.confirmWithMessage
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue({ batchSave: false } as any);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ batchSave: false }));
|
||||
host.mocks.rebuilder.$fetchLocalDBFast.mockRejectedValueOnce(new Error("offline"));
|
||||
|
||||
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
|
||||
@@ -781,7 +858,7 @@ describe("Red Flag Feature", () => {
|
||||
host.mocks.ui.confirm.confirmWithMessage
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue({ batchSave: false } as any);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ batchSave: false }));
|
||||
|
||||
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
|
||||
|
||||
@@ -831,6 +908,65 @@ describe("Red Flag Feature", () => {
|
||||
expect(host.mocks.rebuilder.$fetchLocalDBFast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should preserve automatic synchronisation choices and enter Scram when quick Fetch is cancelled", async () => {
|
||||
const host = createHostMock();
|
||||
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(host.mocks.setting.settings, {
|
||||
liveSync: true,
|
||||
periodicReplication: true,
|
||||
syncOnSave: true,
|
||||
syncOnEditorSave: true,
|
||||
syncOnStart: true,
|
||||
syncOnFileOpen: true,
|
||||
syncAfterMerge: true,
|
||||
suspendParseReplicationResult: false,
|
||||
});
|
||||
host.mocks.ui.confirm.confirmWithMessage
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
||||
status: "not-configured",
|
||||
reason: "milestone-missing",
|
||||
});
|
||||
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel");
|
||||
|
||||
const result = await askAndPerformFastSetupOnScheduledFetchAll(
|
||||
host as any,
|
||||
createLoggerMock(),
|
||||
cleanupFlag
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(host.mocks.rebuilder.$fetchLocalDBFast).not.toHaveBeenCalled();
|
||||
expect(host.mocks.setting.suspendAllSync).not.toHaveBeenCalled();
|
||||
expect(host.mocks.setting.applyPartial).toHaveBeenCalledWith(
|
||||
{
|
||||
suspendFileWatching: true,
|
||||
suspendParseReplicationResult: true,
|
||||
},
|
||||
true
|
||||
);
|
||||
expect(host.mocks.setting.currentSettings()).toMatchObject({
|
||||
liveSync: true,
|
||||
periodicReplication: true,
|
||||
syncOnSave: true,
|
||||
syncOnEditorSave: true,
|
||||
syncOnStart: true,
|
||||
syncOnFileOpen: true,
|
||||
syncAfterMerge: true,
|
||||
suspendFileWatching: true,
|
||||
suspendParseReplicationResult: true,
|
||||
});
|
||||
expect(cleanupFlag).toHaveBeenCalledOnce();
|
||||
expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalledOnce();
|
||||
expect(host.mocks.setting.applyPartial.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
cleanupFlag.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(cleanupFlag.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
host.mocks.appLifecycle.performRestart.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it("should reboot and return false when sync has failures and user chooses rerun", async () => {
|
||||
const host = createHostMock();
|
||||
const log = createLoggerMock();
|
||||
@@ -839,7 +975,9 @@ describe("Red Flag Feature", () => {
|
||||
host.mocks.ui.confirm.confirmWithMessage
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ batchSave: false } as any);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
||||
availableRemoteTweaks({ batchSave: false })
|
||||
);
|
||||
(synchroniseAllFilesBetweenDBandStorage as any).mockResolvedValueOnce(false);
|
||||
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Reboot to re-run the process");
|
||||
|
||||
@@ -859,7 +997,9 @@ describe("Red Flag Feature", () => {
|
||||
host.mocks.ui.confirm.confirmWithMessage
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
|
||||
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ batchSave: false } as any);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
||||
availableRemoteTweaks({ batchSave: false })
|
||||
);
|
||||
(synchroniseAllFilesBetweenDBandStorage as any).mockResolvedValueOnce(false);
|
||||
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce(
|
||||
"Finalise the process and resume normal operation"
|
||||
@@ -1017,9 +1157,9 @@ describe("Red Flag Feature", () => {
|
||||
const host = createHostMock();
|
||||
const config = { batchSave: true } as any;
|
||||
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
||||
batchSave: false,
|
||||
} as any);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
||||
availableRemoteTweaks({ batchSave: false })
|
||||
);
|
||||
|
||||
await adjustSettingToRemoteIfNeeded(
|
||||
host as any,
|
||||
@@ -1047,7 +1187,9 @@ describe("Red Flag Feature", () => {
|
||||
const differentConfig = {
|
||||
[key]: differentValue,
|
||||
};
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(differentConfig as any);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
||||
availableRemoteTweaks(differentConfig)
|
||||
);
|
||||
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("OK");
|
||||
|
||||
await adjustSettingToRemote(host as any, createLoggerMock(), config);
|
||||
@@ -1074,7 +1216,9 @@ describe("Red Flag Feature", () => {
|
||||
const differentConfig = {
|
||||
[key]: differentValue,
|
||||
};
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(differentConfig as any);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
||||
availableRemoteTweaks(differentConfig)
|
||||
);
|
||||
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("OK");
|
||||
|
||||
await adjustSettingToRemote(host as any, createLoggerMock(), config);
|
||||
@@ -1084,32 +1228,71 @@ describe("Red Flag Feature", () => {
|
||||
}
|
||||
);
|
||||
|
||||
it("should show dialog when remote fetch fails", async () => {
|
||||
it("should explain that missing remote settings are normal for a new database without offering retry", async () => {
|
||||
const host = createHostMock();
|
||||
const log = createLoggerMock();
|
||||
const config = { batchSave: true } as any;
|
||||
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(null);
|
||||
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Skip and proceed");
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
||||
status: "not-configured",
|
||||
reason: "milestone-missing",
|
||||
});
|
||||
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Use this device's settings");
|
||||
|
||||
await adjustSettingToRemote(host as any, log, config);
|
||||
await expect(adjustSettingToRemote(host as any, log, config)).resolves.toBe(true);
|
||||
|
||||
expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalled();
|
||||
expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalledWith(
|
||||
"The selected remote has no saved synchronisation settings. This is normal for a new remote. Use this device's settings, or cancel if you expected existing settings.",
|
||||
["Use this device's settings", "Cancel"],
|
||||
{
|
||||
defaultAction: "Use this device's settings",
|
||||
timeout: 0,
|
||||
title: "No Synchronisation Settings Found",
|
||||
}
|
||||
);
|
||||
expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledOnce();
|
||||
expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should retry when user selects retry option", async () => {
|
||||
it("should retry only when remote settings are unavailable", async () => {
|
||||
const host = createHostMock();
|
||||
const log = createLoggerMock();
|
||||
const config = { batchSave: true } as any;
|
||||
const failure = new Error("network failed");
|
||||
|
||||
host.mocks.tweakValue.fetchRemotePreferred
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ batchSave: false } as any);
|
||||
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Retry (recommended)");
|
||||
.mockResolvedValueOnce({ status: "unavailable", error: failure })
|
||||
.mockResolvedValueOnce({ status: "available", values: { batchSave: false } } as any);
|
||||
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Retry");
|
||||
|
||||
await adjustSettingToRemote(host as any, log, config);
|
||||
await expect(adjustSettingToRemote(host as any, log, config)).resolves.toBe(true);
|
||||
|
||||
expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledTimes(2);
|
||||
expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalledWith(
|
||||
"Could not read the remote's synchronisation settings. Check the connection and credentials, then retry.",
|
||||
["Retry", "Cancel"],
|
||||
{
|
||||
defaultAction: "Retry",
|
||||
timeout: 0,
|
||||
title: "Could Not Read Synchronisation Settings",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("should cancel initialisation instead of proceeding after an unavailable remote", async () => {
|
||||
const host = createHostMock();
|
||||
const config = { batchSave: true } as any;
|
||||
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
||||
status: "unavailable",
|
||||
error: new Error("network failed"),
|
||||
});
|
||||
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel");
|
||||
|
||||
await expect(adjustSettingToRemote(host as any, createLoggerMock(), config)).resolves.toBe(false);
|
||||
|
||||
expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledOnce();
|
||||
expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should log when no changes needed", async () => {
|
||||
@@ -1117,9 +1300,9 @@ describe("Red Flag Feature", () => {
|
||||
const log = createLoggerMock();
|
||||
const config = { batchSave: false } as any;
|
||||
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
||||
batchSave: false,
|
||||
} as any);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
||||
availableRemoteTweaks({ batchSave: false })
|
||||
);
|
||||
|
||||
await adjustSettingToRemote(host as any, log, config);
|
||||
|
||||
@@ -1131,8 +1314,11 @@ describe("Red Flag Feature", () => {
|
||||
const log = createLoggerMock();
|
||||
const config = { batchSave: true } as any;
|
||||
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(null);
|
||||
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Skip and proceed");
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
||||
status: "not-configured",
|
||||
reason: "preferred-values-missing",
|
||||
});
|
||||
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Use this device's settings");
|
||||
|
||||
await adjustSettingToRemoteIfNeeded(host as any, log, null as any, config);
|
||||
|
||||
@@ -1239,7 +1425,7 @@ describe("Red Flag Feature", () => {
|
||||
() => {
|
||||
return Promise.resolve(false);
|
||||
},
|
||||
true
|
||||
"keep"
|
||||
);
|
||||
|
||||
expect(host.mocks.setting.currentSettings().suspendFileWatching).toBe(true);
|
||||
@@ -1460,6 +1646,93 @@ describe("Red Flag Feature", () => {
|
||||
});
|
||||
|
||||
describe("flagHandlerToEventHandler integration", () => {
|
||||
it("should stop a detailed Fetch when remote-setting initialisation is cancelled", async () => {
|
||||
const host = createHostMock();
|
||||
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
||||
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
|
||||
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({
|
||||
vault: "independent",
|
||||
extra: { preventFetchingConfig: false },
|
||||
});
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
||||
status: "not-configured",
|
||||
reason: "milestone-missing",
|
||||
});
|
||||
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel");
|
||||
|
||||
const result = await createFetchAllFlagHandler(host as any, createLoggerMock()).handle();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(host.mocks.rebuilder.$fetchLocal).not.toHaveBeenCalled();
|
||||
expect(host.mocks.setting.suspendAllSync).not.toHaveBeenCalled();
|
||||
expect(host.mocks.setting.applyPartial).toHaveBeenCalledWith(
|
||||
{
|
||||
suspendFileWatching: true,
|
||||
suspendParseReplicationResult: true,
|
||||
},
|
||||
true
|
||||
);
|
||||
expect(host.mocks.storageAccess.files.has(FlagFilesOriginal.FETCH_ALL)).toBe(false);
|
||||
expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should stop Rebuild before deleting local data when remote-setting initialisation is cancelled", async () => {
|
||||
const host = createHostMock();
|
||||
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
|
||||
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({
|
||||
extra: { preventFetchingConfig: false },
|
||||
});
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
||||
status: "not-configured",
|
||||
reason: "milestone-missing",
|
||||
});
|
||||
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel");
|
||||
|
||||
const result = await createRebuildFlagHandler(host as any, createLoggerMock()).handle();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(host.mocks.rebuilder.$rebuildEverything).not.toHaveBeenCalled();
|
||||
expect(host.mocks.setting.suspendAllSync).not.toHaveBeenCalled();
|
||||
expect(host.mocks.setting.applyPartial).toHaveBeenCalledWith(
|
||||
{
|
||||
suspendFileWatching: true,
|
||||
suspendParseReplicationResult: true,
|
||||
},
|
||||
true
|
||||
);
|
||||
expect(host.mocks.storageAccess.files.has(FlagFilesOriginal.REBUILD_ALL)).toBe(false);
|
||||
expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should let Rebuild use this device's settings when remote settings are unavailable", async () => {
|
||||
const host = createHostMock();
|
||||
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
|
||||
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({
|
||||
extra: { preventFetchingConfig: false },
|
||||
});
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
||||
status: "unavailable",
|
||||
error: new Error("network failed"),
|
||||
});
|
||||
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Use this device's settings");
|
||||
|
||||
const result = await createRebuildFlagHandler(host as any, createLoggerMock()).handle();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalledWith(
|
||||
"Could not read the remote's synchronisation settings. Retry, or continue the overwrite with this device's settings. A working connection is still required.",
|
||||
["Retry", "Use this device's settings", "Cancel"],
|
||||
{
|
||||
defaultAction: "Retry",
|
||||
timeout: 0,
|
||||
title: "Could Not Read Synchronisation Settings",
|
||||
}
|
||||
);
|
||||
expect(host.mocks.rebuilder.$rebuildEverything).toHaveBeenCalledOnce();
|
||||
expect(host.mocks.storageAccess.files.has(FlagFilesOriginal.REBUILD_ALL)).toBe(false);
|
||||
expect(host.mocks.appLifecycle.performRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return true when flag does not exist", async () => {
|
||||
const host = createHostMock();
|
||||
const log = createLoggerMock();
|
||||
@@ -1476,7 +1749,7 @@ describe("Red Flag Feature", () => {
|
||||
const log = createLoggerMock();
|
||||
|
||||
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({});
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(availableRemoteTweaks({}));
|
||||
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
|
||||
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
|
||||
|
||||
@@ -1554,9 +1827,7 @@ describe("Red Flag Feature", () => {
|
||||
it("should handle fetchAll flag with flagHandlerToEventHandler identical", async () => {
|
||||
const host = createHostMock();
|
||||
const log = createLoggerMock();
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue({
|
||||
customChunkSize: 1,
|
||||
} as any);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ customChunkSize: 1 }));
|
||||
|
||||
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
||||
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
|
||||
@@ -1574,9 +1845,9 @@ describe("Red Flag Feature", () => {
|
||||
it("should handle rebuildAll flag with flagHandlerToEventHandler", async () => {
|
||||
const host = createHostMock();
|
||||
const log = createLoggerMock();
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
||||
customChunkSize: 1,
|
||||
} as any);
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
||||
availableRemoteTweaks({ customChunkSize: 1 })
|
||||
);
|
||||
|
||||
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
|
||||
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({ extra: {} });
|
||||
|
||||
@@ -254,17 +254,23 @@ export async function confirmRebuild(port: number, captures: SetupCaptureNames):
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
export async function skipMissingRemoteConfiguration(port: number, captures: SetupCaptureNames): Promise<string> {
|
||||
const title = "Fetch Remote Configuration Failed";
|
||||
export async function continueWithoutRemoteSettings(port: number, captures: SetupCaptureNames): Promise<string> {
|
||||
const title = "No Synchronisation Settings Found";
|
||||
const screenshot = await captureGuideDialogue(
|
||||
port,
|
||||
`guide-${captures.guide}-missing-remote-configuration.png`,
|
||||
title
|
||||
);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, title)
|
||||
.getByRole("button", { name: "Skip and proceed" })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
const modal = modalByTitle(page, title);
|
||||
await modal
|
||||
.getByText("This is normal for a new remote.", { exact: false })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await modal.getByRole("button", { name: "Cancel", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await modal.getByRole("button", { name: "Use this device's settings" }).click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
modalByTitle,
|
||||
resumeCompatibilityReviewIfShown,
|
||||
selectRadioOption,
|
||||
skipMissingRemoteConfiguration,
|
||||
continueWithoutRemoteSettings,
|
||||
type SetupArtifact,
|
||||
} from "../runner/setupUri.ts";
|
||||
import { captureObsidianPage, withObsidianPage } from "../runner/ui.ts";
|
||||
@@ -278,7 +278,7 @@ async function main(): Promise<void> {
|
||||
screenshots.push(...(await enterManualCouchDBSettings(session.remoteDebuggingPort, couchDb, dbName)));
|
||||
screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new", captures));
|
||||
screenshots.push(await confirmRebuild(session.remoteDebuggingPort, captures));
|
||||
screenshots.push(await skipMissingRemoteConfiguration(session.remoteDebuggingPort, captures));
|
||||
screenshots.push(await continueWithoutRemoteSettings(session.remoteDebuggingPort, captures));
|
||||
screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort, captures));
|
||||
const state = await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv);
|
||||
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
finishInitialisation,
|
||||
generateSetupURIFromDevice,
|
||||
resumeCompatibilityReviewIfShown,
|
||||
skipMissingRemoteConfiguration,
|
||||
continueWithoutRemoteSettings,
|
||||
type SetupArtifact,
|
||||
type SetupCaptureNames,
|
||||
} from "../runner/setupUri.ts";
|
||||
@@ -226,7 +226,7 @@ async function main(): Promise<void> {
|
||||
screenshots.push(await enterSetupURI(portA, "new", bootstrapArtifact, captures));
|
||||
screenshots.push(await captureAndStartInitialisation(portA, "new", captures));
|
||||
screenshots.push(await confirmRebuild(portA, captures));
|
||||
screenshots.push(await skipMissingRemoteConfiguration(portA, captures));
|
||||
screenshots.push(await continueWithoutRemoteSettings(portA, captures));
|
||||
screenshots.push(await acknowledgeDisabledOptionalFeatures(portA, captures));
|
||||
const firstState = await finishInitialisation(portA, context.cliBinary, sessionA.cliEnv);
|
||||
await resumeCompatibilityReviewIfShown(portA);
|
||||
|
||||
@@ -300,8 +300,8 @@ async function confirmRebuild(port: number): Promise<string> {
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
async function skipMissingRemoteConfiguration(port: number): Promise<string> {
|
||||
const title = "Fetch Remote Configuration Failed";
|
||||
async function continueWithoutRemoteSettings(port: number): Promise<string> {
|
||||
const title = "No Synchronisation Settings Found";
|
||||
const screenshot = await captureObsidianDialogue(
|
||||
port,
|
||||
"setup-uri-first-missing-remote-configuration.png",
|
||||
@@ -309,16 +309,20 @@ async function skipMissingRemoteConfiguration(port: number): Promise<string> {
|
||||
const modal = modalByTitle(page, title);
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await modal
|
||||
.getByText("If you are new to the Self-hosted LiveSync, this might be expected.", {
|
||||
.getByText("This is normal for a new remote.", {
|
||||
exact: false,
|
||||
})
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await modal.getByRole("button", { name: "Cancel", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
}
|
||||
);
|
||||
await captureGuideDialogue(port, "guide-quick-setup-missing-remote-configuration.png", title);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, title)
|
||||
.getByRole("button", { name: "Skip and proceed" })
|
||||
.getByRole("button", { name: "Use this device's settings" })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
return screenshot;
|
||||
@@ -736,7 +740,7 @@ async function main(): Promise<void> {
|
||||
await enterSetupURI(session.remoteDebuggingPort, "new", artifact);
|
||||
screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new"));
|
||||
screenshots.push(await confirmRebuild(session.remoteDebuggingPort));
|
||||
screenshots.push(await skipMissingRemoteConfiguration(session.remoteDebuggingPort));
|
||||
screenshots.push(await continueWithoutRemoteSettings(session.remoteDebuggingPort));
|
||||
screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort));
|
||||
const firstCompletion = await finishInitialisation(
|
||||
session.remoteDebuggingPort,
|
||||
|
||||
+18
@@ -12,6 +12,24 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Setup and compatibility
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Fast Setup now completes only after the captured CouchDB changes target has been persisted. Decryption, protocol, and local write failures stop the operation without finalising an incomplete database, while transient interruptions resume from the last durable checkpoint (#1065).
|
||||
|
||||
## 1.0.6
|
||||
|
||||
6th August, 2026
|
||||
|
||||
I know that onboarding, and other parts which feel unclear or confusing, still need improvement. Please do report any such cases.
|
||||
|
||||
### Setup and compatibility
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Initial setup now distinguishes an empty remote with no saved synchronisation settings from a failed remote read. New remotes can use this device's settings without an unnecessary retry; Fetch pauses on unreadable settings, while Rebuild can explicitly continue with this device's settings. Cancelling preserves the selected automatic synchronisation mode and restarts with Vault and database reflection paused (#1064). Thank you to @mateus2k2 for the follow-up report!
|
||||
|
||||
## 1.0.5
|
||||
|
||||
5th August, 2026
|
||||
|
||||
+2
-1
@@ -17,5 +17,6 @@
|
||||
"1.0.2": "1.7.2",
|
||||
"1.0.3": "1.7.2",
|
||||
"1.0.4": "1.7.2",
|
||||
"1.0.5": "1.7.2"
|
||||
"1.0.5": "1.7.2",
|
||||
"1.0.6": "1.7.2"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user