mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-06 10:47:05 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6305e6dd68 | ||
|
|
cfb3cec6ec | ||
|
|
0789e47c17 | ||
|
|
bbbd6fb174 | ||
|
|
14a133588d | ||
|
|
7110b9eebf | ||
|
|
e018cab039 | ||
|
|
5d251d1f92 | ||
|
|
95fa2b13f9 | ||
|
|
f3c85c1aef | ||
|
|
d2c32da30d | ||
|
|
c3e12cf946 | ||
|
|
c84383a44b | ||
|
|
b90ef3716c | ||
|
|
e1195629b9 | ||
|
|
0ecb73924a | ||
|
|
188b749326 | ||
|
|
6abc5cba64 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.26",
|
||||
"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
+12
-12
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.24",
|
||||
"version": "1.0.26",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.24",
|
||||
"version": "1.0.26",
|
||||
"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.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",
|
||||
@@ -4620,9 +4620,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",
|
||||
@@ -12937,11 +12937,11 @@
|
||||
},
|
||||
"src/apps/cli": {
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"version": "1.0.24-cli",
|
||||
"version": "1.0.26-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",
|
||||
@@ -12962,9 +12962,9 @@
|
||||
},
|
||||
"src/apps/webapp": {
|
||||
"name": "livesync-webapp",
|
||||
"version": "1.0.24-webapp",
|
||||
"version": "1.0.26-webapp",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.53"
|
||||
"octagonal-wheels": "^0.1.54"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
@@ -12974,9 +12974,9 @@
|
||||
}
|
||||
},
|
||||
"src/apps/webpeer": {
|
||||
"version": "1.0.24-webpeer",
|
||||
"version": "1.0.26-webpeer",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.53"
|
||||
"octagonal-wheels": "^0.1.54"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.24",
|
||||
"version": "1.0.26",
|
||||
"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",
|
||||
@@ -73,6 +73,7 @@
|
||||
"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",
|
||||
@@ -177,7 +178,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -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.26-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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.24-webapp",
|
||||
"version": "1.0.26-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
@@ -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/webapp/browser-smoke.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.53"
|
||||
"octagonal-wheels": "^0.1.54"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.24-webpeer",
|
||||
"version": "1.0.26-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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -22,6 +22,7 @@ const focusedScenarios = new Set([
|
||||
"minio-upload",
|
||||
"object-storage-setup-uri-workflow",
|
||||
"p2p-setup-uri-workflow",
|
||||
"partial-startup-file-failure",
|
||||
"startup-scan",
|
||||
"setup-uri-workflow",
|
||||
"two-vault-sync",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+21
-4
@@ -12,19 +12,36 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 1.0.26
|
||||
|
||||
~~1.0.25~~ was cancelled because pre-release validation found that LiveSync could appear to finish synchronising even though Android had not written a received file to the Vault; the warning appeared only after restart.
|
||||
|
||||
6th September, 2026
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Conflict resolution dialogues now close when the same file is resolved elsewhere or the plug-in unloads. Requests for different files are shown one at a time, while a newer request for the same file replaces the stale dialogue.
|
||||
- Files inside a folder are no longer silently removed from synchronisation when an external tool changes only the letter case of that folder while Obsidian is running. This prevents the stale deletion from reaching other devices or later removing the local file. Moving files into ignored or otherwise excluded locations retains the existing behaviour, and the folder-name case itself may still differ between devices. (#1168)
|
||||
- A problem processing one file during ordinary start-up no longer prevents every other file from synchronising. LiveSync warns about the affected files and can retry them later; Fetch and Rebuild still stop if they cannot finish safely. (#1164)
|
||||
- When LiveSync cannot finish preparing this device for synchronisation, it now says that synchronisation is unavailable and directs you to generate a report, instead of remaining at 'Not ready'. (#1164)
|
||||
|
||||
#### Improved
|
||||
|
||||
- Start-up now keeps unconfigured Vaults on the onboarding path without running configured-only checks or accepting Config Doctor and incomplete-document repair requests. Returning a configured Vault to an unconfigured state also retires those requests for the current plug-in process, so completing setup admits them only after the requested restart.
|
||||
- When LiveSync cannot write a received file to the Vault, it now warns immediately instead of appearing to have synchronised it successfully. The generated report identifies the affected path, and a later scan can try it again.
|
||||
|
||||
### Testing
|
||||
### Conflict handling and recovery
|
||||
|
||||
- Start-up migrations, integrity checks, Config Doctor, basic commands, and the Obsidian replication ribbon now have focused regression tests for their service composition. Real Obsidian checks cover unconfigured onboarding, configured start-up scanning, Config Doctor detection and layout, command registration, and the established ribbon icon.
|
||||
#### Improved
|
||||
|
||||
- Conflict resolution dialogues now close when the same file is resolved elsewhere or when the plug-in unloads. Requests for different files are shown one at a time, while a newer request for the same file replaces the older one.
|
||||
|
||||
### Setup and compatibility
|
||||
|
||||
#### Improved
|
||||
|
||||
- Unconfigured Vaults now stay focused on setup instead of running Config Doctor or incomplete-document checks before they can be used. Returning a configured Vault to an unconfigured state also stops those checks until the requested restart. (#1161)
|
||||
- When the active file contains a file or folder name longer than 255 UTF-8 bytes, LiveSync now explains that the path may not work on some Android and Linux file systems. It does not rename or reject the file. (#1164)
|
||||
|
||||
## 1.0.24
|
||||
|
||||
|
||||
+2
-1
@@ -36,5 +36,6 @@
|
||||
"1.0.21": "1.7.2",
|
||||
"1.0.22": "1.7.2",
|
||||
"1.0.23": "1.7.2",
|
||||
"1.0.24": "1.7.2"
|
||||
"1.0.24": "1.7.2",
|
||||
"1.0.26": "1.7.2"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user