Compare commits

...
73 Commits
Author SHA1 Message Date
vorotamoroz 2d00fcfb47 Refine the release note structure and tone 2026-09-16 10:22:30 +00:00
vorotamoroz 64063b4a2c Releasing 1.0.29 2026-09-16 10:14:17 +00:00
vorotamoroz d1405f2fb4 Clarify the managed TURN provider label 2026-09-16 09:12:05 +00:00
vorotamoroz 98284005c9 Clarify managed TURN routing and credential lifetime 2026-09-16 08:20:52 +00:00
vorotamoroz cf9d671b8a Use published Commonlib 0.1.25 2026-09-16 07:35:11 +00:00
vorotamoroz 1ba61a5052 Use Commonlib settings snapshot cleanup 2026-09-16 05:02:57 +00:00
vorotamoroz 11a07b26af Use host preparation for TURN connection settings 2026-09-16 03:43:09 +00:00
vorotamoroz a565070809 Use profile storage for TURN source settings 2026-09-15 18:28:20 +00:00
vorotamoroz ab55eb5aff Use existing Setup URI and QR sharing for TURN settings 2026-09-15 17:18:40 +00:00
vorotamoroz 93bc161f20 Add optional Cloudflare TURN credentials and secure profile sharing 2026-09-15 16:20:03 +00:00
vorotamoroz ba297d1233 Merge pull request #1192 from vrtmrz/docs/credit-cli-scan-contribution
docs: credit CLI scan fix contributors
2026-09-15 23:20:09 +09:00
vorotamoroz 28884c3fd2 docs: credit CLI scan fix contributors 2026-09-15 14:02:41 +00:00
vorotamoroz 7200cb5aff Merge pull request #1191 from vrtmrz/fix/cli-vault-startup-scan
fix(cli): prepare daemon and mirror Vaults during startup
2026-09-15 22:52:52 +09:00
vorotamoroz d082b409c0 test: use RustFS for S3 service fixtures 2026-09-15 13:32:55 +00:00
vorotamoroz 6f0892a825 ci(cli): run the daemon startup regression in CI 2026-09-15 12:36:08 +00:00
vorotamoroz 70cfbd43a9 fix(cli): prepare daemon and mirror Vaults during startup 2026-09-15 12:06:35 +00:00
vorotamoroz 7c1c913f1d fix(cli): enumerate current Vault files independently of cache 2026-09-15 12:05:24 +00:00
vorotamoroz b3d01598ac Merge pull request #1186 from vrtmrz/1_0_28
Releasing 1.0.28
2026-09-09 11:31:04 +09:00
vorotamoroz b9625b87ce Prepare 1.0.28 release notes 2026-09-09 01:28:03 +00:00
github-actions[bot] 2f9e82ec08 Releasing 1.0.28 2026-09-09 01:23:58 +00:00
vorotamoroz c21896714f Clarify contribution acknowledgment in README
Added a note regarding contributions and corrections.
2026-09-09 10:06:37 +09:00
vorotamoroz 51e3e46b38 Merge pull request #1183 from vrtmrz/doc/paper
Add documents that are setting out the approach in words.
2026-09-09 09:44:22 +09:00
vorotamoroz 9c88aa1226 Merge pull request #1184 from vrtmrz/fix/tweak-compatibility
Fix synchronisation setting compatibility recovery
2026-09-09 09:38:48 +09:00
vorotamoroz e1dbd3eeb3 Merge main for current release and TypeScript compatibility 2026-09-08 17:26:23 +00:00
vorotamoroz 78c2ccc15a Fix synchronisation setting compatibility recovery 2026-09-08 17:16:01 +00:00
vorotamoroz a7a2f14a7a Update Japanese version description in README
Revised the description of the Japanese version to clarify its purpose as a reference translation using translation memory.
2026-09-09 01:39:06 +09:00
vorotamoroz ac9b458fc9 added: added a document setting out the approach in words. 2026-09-08 12:45:33 +01:00
vorotamoroz dd280a4c5e Merge pull request #1181 from vrtmrz/1_0_27
Release 1.0.27: fix empty Object Storage setup
2026-09-08 00:01:48 +09:00
vorotamoroz e640a0e005 Prepare 1.0.27 release notes 2026-09-07 12:32:08 +00:00
github-actions[bot] 76bd9086ad Releasing 1.0.27 2026-09-07 12:28:41 +00:00
vorotamoroz 35a8a4bc78 Merge pull request #1178 from vrtmrz/chore/upgrade-typescript-6
Upgrade TypeScript toolchain to 6.0
2026-09-06 15:39:58 +09:00
vorotamoroz 94766f9d43 Merge remote-tracking branch 'origin/main' into chore/upgrade-typescript-6 2026-09-06 06:30:30 +00:00
vorotamoroz 9678575fb3 Merge pull request #1177 from vrtmrz/chore/refresh-ci-and-test-maintenance
Refresh CI runtimes and maintenance checks
2026-09-06 15:29:26 +09:00
vorotamoroz 57a92af568 Merge pull request #1176 from vrtmrz/fix/issue-1166-r2-initialisation
Fix first-time R2 setup through the Custom HTTP Handler
2026-09-06 15:27:03 +09:00
vorotamoroz 6fda957456 Upgrade TypeScript toolchain to 6.0 2026-09-06 06:16:10 +00:00
vorotamoroz 3e4a109862 Refresh CI runtimes and maintenance checks 2026-09-06 05:28:32 +00:00
vorotamoroz 1f545f46cc Cover both Object Storage setup handler variants 2026-09-06 04:28:07 +00:00
vorotamoroz 59188872fc Fix first-time Object Storage setup through custom handler 2026-09-06 04:07:20 +00:00
vorotamoroz a5056ab157 Release Self-hosted LiveSync 1.0.26 (#1175)
Merge the validated 1.0.26 release commit into main after stable promotion.
2026-09-06 11:36:17 +09:00
vorotamoroz 6305e6dd68 Prepare 1.0.26 release notes 2026-09-06 01:40:55 +00:00
github-actions[bot] cfb3cec6ec Releasing 1.0.26 2026-09-06 01:28:56 +00:00
vorotamoroz 0789e47c17 Merge pull request #1173 from vrtmrz/fix/live-vault-reflection-failure-notice
Warn immediately when live Vault reflection fails
2026-09-05 22:08:20 +09:00
vorotamoroz bbbd6fb174 Merge pull request #1174 from vrtmrz/fix/prevent-stale-file-deletions
Prevent stale file deletions after parent case changes
2026-09-05 17:02:50 +09:00
vorotamoroz 14a133588d Use Commonlib 0.1.23 for stale deletion protection 2026-09-05 06:19:47 +00:00
vorotamoroz 7110b9eebf Add parent case deletion regression coverage 2026-09-05 03:38:12 +00:00
vorotamoroz e018cab039 Warn when live Vault reflection fails 2026-09-05 02:10:52 +00:00
vorotamoroz 5d251d1f92 Merge pull request #1171 from vrtmrz/test/partial-startup-file-failure-e2e
test: cover partial start-up file failures in real Obsidian
2026-09-05 03:13:18 +09:00
vorotamoroz 95fa2b13f9 test: cover partial start-up file failures in Obsidian 2026-09-04 17:50:06 +00:00
vorotamoroz f3c85c1aef Merge pull request #1170 from vrtmrz/fix/issue-1164-readable-path-warning
Keep active-file path compatibility warnings readable
2026-09-05 01:58:33 +09:00
vorotamoroz d2c32da30d Merge pull request #1169 from vrtmrz/fix/cli-docker-runtime-dependencies
Avoid resolving CLI development peers in Docker runtime
2026-09-05 01:52:22 +09:00
vorotamoroz c3e12cf946 Avoid resolving CLI development peers in Docker runtime 2026-09-04 16:25:36 +00:00
vorotamoroz c84383a44b Keep active-file path warnings readable 2026-09-04 16:21:05 +00:00
vorotamoroz b90ef3716c Merge pull request #1167 from vrtmrz/fix/issue-1164-partial-scan-readiness
Keep synchronisation ready after partial startup scans
2026-09-05 00:44:54 +09:00
vorotamoroz e1195629b9 Use Commonlib 0.1.22 2026-09-04 15:12:47 +00:00
vorotamoroz 0ecb73924a Document diagnostic and notice ownership 2026-09-04 14:10:40 +00:00
vorotamoroz 188b749326 Warn after partial startup scans 2026-09-04 13:54:36 +00:00
vorotamoroz 6abc5cba64 Keep startup ready after individual file failures 2026-09-04 12:50:46 +00:00
vorotamoroz 045a328697 Merge pull request #1162 from vrtmrz/refactor/conflict-resolution-service-features
Refactor conflict resolution into service features
2026-09-04 18:31:00 +09:00
vorotamoroz b6b9ce3ba1 Document conflict dialogue lifecycle fixes 2026-09-04 08:30:42 +00:00
vorotamoroz f06f33cbf4 Strengthen conflict resolution regression coverage 2026-09-04 08:16:14 +00:00
vorotamoroz 56a1a19d2c Merge latest main into conflict resolution refactor 2026-09-04 05:35:39 +00:00
vorotamoroz 84be444689 Simplify conflict scheduling and lifecycle subscriptions 2026-09-04 05:15:45 +00:00
vorotamoroz 54d276f5e5 Merge pull request #1161 from vrtmrz/refactor/startup-lifecycle-service-features
Refactor startup lifecycle into service features
2026-09-04 11:37:47 +09:00
vorotamoroz f70bdbbbe0 refactor: clarify startup operation defaults 2026-09-04 02:30:40 +00:00
vorotamoroz 22a835519b fix: preserve startup UI behaviour 2026-09-04 01:55:29 +00:00
vorotamoroz ad91776ad9 Test conflict dialogue concurrency and unload lifecycle 2026-09-04 01:47:29 +00:00
vorotamoroz 6ea906b575 Refactor conflict resolution into service features 2026-09-03 12:35:58 +00:00
vorotamoroz 338aecd888 Refactor startup lifecycle into service features 2026-09-03 12:20:05 +00:00
vorotamoroz 3b2d5aa5af Merge pull request #1160 from vrtmrz/docs/current-data-structure-reference
Correct the database structure reference for 1.0
2026-09-03 16:39:46 +09:00
vorotamoroz f055222160 Document current database structure boundary 2026-09-03 07:23:15 +00:00
vorotamoroz 77882c677b Merge pull request #1159 from vrtmrz/docs/replicator-architecture
Document the implemented Replicator architecture
2026-09-03 15:57:31 +09:00
vorotamoroz 2461d37ead Document the implemented Replicator architecture
- mark capability and lifecycle ADRs as accepted
- add lifecycle, fencing, and provider-extension guidance
- split project terminology into a dedicated glossary
2026-09-03 06:42:34 +00:00
vorotamoroz d00c5ecc56 Merge pull request #1158 from vrtmrz/1_0_24
Releasing 1.0.24
2026-09-03 14:47:22 +09:00
175 changed files with 12013 additions and 3853 deletions
+5 -5
View File
@@ -56,7 +56,7 @@ jobs:
case "$SELECTED_TASK" in
test:ci)
TASK_MATRIX='["test:setup-put-cat","test:mirror","test:daemon","test:push-pull","test:decoupled-vault","test:sync-two-local","test:sync-locked-remote","test:remote-commands","test:e2e-matrix:couchdb-enc0","test:e2e-matrix:couchdb-enc1","test:e2e-matrix:minio-enc0","test:e2e-matrix:minio-enc1"]'
TASK_MATRIX='["test:setup-put-cat","test:mirror","test:daemon","test:daemon-startup","test:push-pull","test:decoupled-vault","test:sync-two-local","test:sync-locked-remote","test:remote-commands","test:e2e-matrix:couchdb-enc0","test:e2e-matrix:couchdb-enc1","test:e2e-matrix:minio-enc0","test:e2e-matrix:minio-enc1"]'
;;
test:local)
TASK_MATRIX='["test:setup-put-cat","test:mirror","test:daemon"]'
@@ -84,10 +84,10 @@ jobs:
task: ${{ fromJson(needs.prepare.outputs.task_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: 'npm'
@@ -99,7 +99,7 @@ jobs:
deno-version: v2.x
- name: Cache Deno dependencies
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/deno
key: ${{ runner.os }}-deno-${{ hashFiles('src/apps/cli/testdeno/deno.lock', 'src/apps/cli/testdeno/deno.json') }}
@@ -151,7 +151,7 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Show Docker versions
run: |
+6 -6
View File
@@ -46,7 +46,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Derive image tag
id: meta
@@ -86,22 +86,22 @@ jobs:
echo "push=${PUSH}" >> $GITHUB_OUTPUT
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: "24.x"
cache: "npm"
@@ -128,7 +128,7 @@ jobs:
- name: Build and push
if: ${{ steps.e2e.outcome == 'success' || (github.event_name == 'workflow_dispatch' && inputs.force) }}
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: src/apps/cli/Dockerfile
+2 -2
View File
@@ -22,10 +22,10 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: 'npm'
+2 -2
View File
@@ -38,7 +38,7 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Show Docker versions
run: |
@@ -83,7 +83,7 @@ jobs:
- name: Upload benchmark results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: cli-p2p-compose-smoke-results
path: test/bench-network/bench-results/**
+5 -5
View File
@@ -36,10 +36,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: 'npm'
@@ -85,10 +85,10 @@ jobs:
run: npm run test:browser-apps:pages
- name: Configure GitHub Pages
uses: actions/configure-pages@v5
uses: actions/configure-pages@v6
- name: Upload GitHub Pages artifact
uses: actions/upload-pages-artifact@v3
uses: actions/upload-pages-artifact@v5
with:
path: _site
@@ -103,4 +103,4 @@ jobs:
steps:
- name: Deploy GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
+2 -2
View File
@@ -47,13 +47,13 @@ jobs:
fi
echo "name=${BRANCH}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
ref: ${{ steps.branch.outputs.name }}
fetch-depth: 0
- name: Use Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: "24.x"
+2 -2
View File
@@ -33,13 +33,13 @@ jobs:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
ref: ${{ inputs.base_branch }}
fetch-depth: 0
- name: Use Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: "24.x"
cache: npm
+3 -3
View File
@@ -26,12 +26,12 @@ jobs:
id-token: write
attestations: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
ref: ${{ inputs.tag }}
- name: Use Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '24.x'
- name: Validate release
@@ -61,7 +61,7 @@ jobs:
manifest.json
styles.css
- name: Create Release and Upload Assets
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: |
main.js
+11 -8
View File
@@ -65,10 +65,10 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: 'npm'
@@ -113,10 +113,10 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: 'npm'
@@ -127,6 +127,9 @@ jobs:
- name: Run source checks
run: npm run check
- name: Run release process tests
run: npm run test:release-process
- name: Run unit tests suite with coverage
run: npm run test:unit:coverage
@@ -135,7 +138,7 @@ jobs:
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: unit-coverage-report
path: coverage/**
@@ -146,7 +149,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Detect LiveSync-owned integration tests
id: integration_tests
@@ -165,7 +168,7 @@ jobs:
- name: Setup Node.js
if: ${{ steps.integration_tests.outputs.present == 'true' }}
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: 'npm'
@@ -196,7 +199,7 @@ jobs:
if: ${{ steps.integration_tests.outputs.present == 'true' }}
run: npm run test:docker-couchdb:start
- name: Start MinIO container
- name: Start RustFS container
if: ${{ steps.integration_tests.outputs.present == 'true' }}
run: npm run test:docker-s3:start
+5 -4
View File
@@ -5,10 +5,11 @@ When working on this repository (writing code, comments, documentation, or commi
## Required Reference Files
Before making changes to documentation, user-facing text, or settings:
1. Read [docs/terms.md](docs/terms.md) for terminology, vocabulary conventions, and technical definitions.
2. Read [docs/settings.md](docs/settings.md) (and [docs/settings_ja.md](docs/settings_ja.md)) for UI settings and setting key mappings.
3. Read [docs/troubleshooting.md](docs/troubleshooting.md) for troubleshooting guidelines and common recovery steps (such as flag files and SCRAM state).
4. Read [devs.md](devs.md) for development workflows, module architecture, and testing infrastructure.
1. Read [docs/terms.md](docs/terms.md) for documentation style and vocabulary conventions.
2. Read [docs/glossary.md](docs/glossary.md) for user-facing, operational, developer, and design terminology.
3. Read [docs/settings.md](docs/settings.md) (and [docs/settings_ja.md](docs/settings_ja.md)) for UI settings and setting key mappings.
4. Read [docs/troubleshooting.md](docs/troubleshooting.md) for troubleshooting guidelines and common recovery steps (such as flag files and SCRAM state).
5. Read [devs.md](devs.md) for development workflows, module architecture, and testing infrastructure.
---
+24
View File
@@ -0,0 +1,24 @@
cff-version: 1.2.0
message: "If you use this software, please cite it using the metadata from this file."
title: "Self-hosted LiveSync"
abstract: "Self-hosted LiveSync is an open-source synchronisation plug-in for Obsidian that replicates note vaults and supporting files across desktop and mobile devices using user-controlled servers, object storage, or direct peer-to-peer connections."
type: software
authors:
- name: "vorotamoroz"
website: "https://github.com/vrtmrz"
- name: "Self-hosted LiveSync Contributors"
repository-code: "https://github.com/vrtmrz/obsidian-livesync"
url: "https://github.com/vrtmrz/obsidian-livesync"
version: 1.0.23
doi: 10.5281/zenodo.22247183
date-released: "2026-09-05"
license: MIT
keywords:
- obsidian
- obsidian-plugin
- synchronisation
- local-first
- couchdb
- pouchdb
- webrtc
- peer-to-peer
+4 -1
View File
@@ -57,7 +57,10 @@ To maintain consistency across the project, we ask that you follow the establish
- **Affirmative Phrasing**: Avoid asking questions using negative forms in user-facing dialogue. Use affirmative questions to prevent translation and interpretation discrepancies.
- **Specific Words**: Use 'dialogue' for documentation and user-facing messages (use 'dialog' only inside source code). Use the hyphenated form 'plug-in' in user-facing text (use 'plugin' only in configuration settings or technical contexts).
For a detailed list of vocabulary conventions and terms, please refer to [docs/terms.md](docs/terms.md).
For writing conventions, see [Documentation style and vocabulary conventions](docs/terms.md).
Project-specific meanings are defined in the [Project glossary](docs/glossary.md),
including internal developer and design terms which might not appear in the
user interface.
### 3. Translations
+42 -7
View File
@@ -85,11 +85,11 @@ To facilitate development and testing, the build process can automatically copy
Regression tests remain in the suite owned by the implementation under test. Plug-in tests may be co-located with their source, while independent application tests remain under `test/apps/` or `test/browser-apps/` so that they stay outside the Community Review source boundary. Prefix a case or group with `compatibility:` when it protects a persisted input or state which current releases still accept, and with `retirement guard:` when it prevents a removed setting, control, or notification from returning. Remove or replace a compatibility case only when the corresponding input is no longer accepted or an equivalent maintained case preserves the contract. Remove a retirement guard only when another current contract makes the old behaviour unreachable. Do not preserve a disconnected historical test as an executable specification when no maintained runner invokes it; Git history is the reference for retired test infrastructure.
- **CLI E2E** (`src/apps/cli/testdeno/`): Host-independent consumer workflows. The canonical Compose P2P suite covers ordinary two-peer synchronisation, replacement of the current replicator followed by transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. Its lifecycle entry point is included only in the Docker test build and does not add a public CLI command. Run `npm run test:e2e:cli` for the ordinary suite or `npm run test:e2e:cli:p2p` for P2P validation.
- **CLI E2E** (`src/apps/cli/testdeno/`): Host-independent consumer workflows. The canonical Compose P2P suite covers ordinary two-peer synchronisation, replacement of the current Replicator followed by transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. Its lifecycle entry point is included only in the Docker test build and does not add a public CLI command. Run `npm run test:e2e:cli` for the ordinary suite or `npm run test:e2e:cli:p2p` for P2P validation.
- **Self-hosted setup tools** (`utils/couchdb/`, `utils/setup/`, and `utils/flyio/`): Deno contract tests consume the exact locked Commonlib registry package, verify current CouchDB, Object Storage, and random-room P2P Setup URI defaults and remote profiles, and keep CouchDB administration separate from package-owned LiveSync database-version negotiation. `unit-ci` also provisions a real temporary CouchDB database and verifies its version document against the installed Commonlib package. Run `npm run test:setup-tools` for the local contract gate.
- **Real Obsidian E2E** (`test/e2e-obsidian/`): Local-first scripts that launch real Obsidian with temporary vaults and the built Self-hosted LiveSync plug-in. Use these for boot-up sequence, vault reflection, RedFlag flows, Fast Setup (Simple Fetch), settings dialogues, restart-sensitive workflows, Object Storage regressions, and other behaviour that depends on Obsidian itself. Run focused scripts such as `npm run test:e2e:obsidian:two-vault-sync`, or use `npm run test:e2e:obsidian:local-suite:services` to run the broader local suite with CouchDB and MinIO fixtures managed by the wrapper.
- **Real Obsidian E2E** (`test/e2e-obsidian/`): Local-first scripts that launch real Obsidian with temporary vaults and the built Self-hosted LiveSync plug-in. Use these for boot-up sequence, vault reflection, RedFlag flows, Fast Setup (Simple Fetch), settings dialogues, restart-sensitive workflows, Object Storage regressions, and other behaviour that depends on Obsidian itself. Run focused scripts such as `npm run test:e2e:obsidian:two-vault-sync`, or use `npm run test:e2e:obsidian:local-suite:services` to run the broader local suite with CouchDB and RustFS fixtures managed by the wrapper.
- **Docker Services**: Service-backed tests use CouchDB and MinIO (S3). Canonical P2P validation owns its relay through the CLI Compose runner:
- **Docker Services**: Service-backed tests use CouchDB and RustFS (S3). Canonical P2P validation owns its relay through the CLI Compose runner:
```bash
npm run test:docker-all:start # Start all test services
@@ -129,6 +129,19 @@ Changes spanning both repositories must first produce a packed Commonlib artefac
## Architecture
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.
For shared synchronisation-setting comparisons, directional reconstruction
consequences, and the lifetime of a recovery decision, see
[Tweak compatibility and recovery](docs/design_docs/tweak_compatibility.md).
### Service composition and legacy Modules
The application is composed from Services, ServiceModules, serviceFeatures, add-ons, and a legacy Module layer:
@@ -138,7 +151,7 @@ The application is composed from Services, ServiceModules, serviceFeatures, add-
- **serviceFeature**: a typed composition function which accepts only its declared Services and ServiceModules. It registers lifecycle handlers, commands, user-interface bindings, or other host glue, and may return a focused view. It is not a runtime registry entry.
- **AbstractModule** and **AbstractObsidianModule**: the legacy application Module layer. Existing Modules are loaded by the application and bound after the Service graph has been composed; this broad core access is not the preferred dependency boundary for new orchestration.
The normal composition order is the Service Hub, replicator-provider registration, ServiceModules, serviceFeatures, add-ons, and finally legacy Module binding. A serviceFeature may therefore consume an already constructed ServiceModule. Preferring a serviceFeature for new composition is a dependency-boundary rule, not an initialisation-order rule.
The normal composition order is the Service Hub, Replicator provider registration, ServiceModules, serviceFeatures, add-ons, and finally legacy Module binding. A serviceFeature may therefore consume an already constructed ServiceModule. Preferring a serviceFeature for new composition is a dependency-boundary rule, not an initialisation-order rule.
Mutable state is permitted in a serviceFeature. State alone is not a reason to create a class, a ServiceModule, or retain an AbstractModule. Prefer one private context, with module-level functions which receive that context, when identity and polymorphism are not part of the contract. Separate the state, transitions, and invariants from the surrounding function which registers lifecycle handlers and connects downstream effects. Give the stateful boundary narrow collaborators rather than `LiveSyncBaseCore`.
@@ -169,7 +182,14 @@ Legacy Modules remain grouped by directory:
- **Service Hub** (`src/modules/services/`): Central service registry using dependency injection
- **Common Library** (`@vrtmrz/livesync-commonlib`): Platform-independent synchronisation logic, shared with the CLI, WebApp, WebPeer, and external tools
Commonlib owns one stable `LiveSyncP2PService`, its `P2PRoomSessionOwner`, and the replaceable Trystero room session. Host commands, event handlers, and views consume the focused transport, connection-probe admission, directory, peer-admission, transfer, change-relay, configuration, and diagnostic views returned by the service feature. They must not retain the deprecated compatibility Replicator as an ordinary service locator, close Trystero-owned raw peers, or install another Trystero transport generation at the application root. The exact as-built ownership and shutdown boundaries are recorded in Commonlib's `docs/p2p-transport-lifecycle.md` design document.
See [Replicator architecture](docs/design_docs/replicator_architecture.md) for
the implemented provider contract, active Replicator lifecycle, publication and
session fences, P2P ownership exception, compatibility boundaries, and the
steps required to add a built-in provider.
Commonlib owns one stable `LiveSyncP2PService`, its `P2PRoomSessionOwner`, and the replaceable Trystero room session. Host commands, event handlers, and views consume the focused transport, connection-probe admission, directory, peer-admission, transfer, change-relay, configuration, and diagnostic views returned by the service feature. They must not retain the deprecated compatibility Replicator as an ordinary service locator, close Trystero-owned raw peers, or install another Trystero transport generation at the application root. The exact implemented ownership and shutdown boundaries are recorded in Commonlib's [P2P transport lifecycle](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/p2p-transport-lifecycle.md) design document.
The [TURN connection settings design](docs/design_docs/renewable_turn_credentials.md) describes how the host prepares temporary ICE credentials in a connection-only settings copy. It covers room reuse and expiry, replication continuation, profile persistence and sharing, and report redaction.
### Conflict Merge Policy
@@ -229,6 +249,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
@@ -254,14 +289,14 @@ Existing legacy Modules continue to register their handlers in `onBindFunction()
`Plugin.addSettingTab()`. Register a settings tab which reads persisted values
from the sequential `onSettingLoaded` lifecycle, seed its editing snapshot
before registration, and keep definition construction independent of local
database and replicator readiness. See
database and Replicator readiness. See
[the declarative settings adapter ADR](docs/adr/2026_08_declarative_settings_adapter.md).
- Use `this.services.setting.saveSettingData()` instead of using plugin methods directly
### Database Operations
- Local database operations through `LiveSyncLocalDB` (wraps PouchDB)
- Document types: `EntryDoc` (files), `EntryLeaf` (chunks), `PluginDataEntry` (plugin sync)
- Document types are owned by Commonlib. `EntryDoc` covers file Metadata, Chunks, database version information, Milestone information, Node information, and Chunk Packs. Current Customisation Sync data uses ordinary chunked Metadata in the `ix:` namespace rather than the application-local `PluginDataEntry` interface.
## Important Files
+21 -7
View File
@@ -1,9 +1,23 @@
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
# Architectural Decision Record: P2P Room and Transport Lifecycle
## Status
Accepted — implemented and verified through Commonlib owner tests, the Compose transport suite, and the real-Obsidian setup workflow.
The stable P2P service and room-session owner accepted in
[Replicator Capabilities and Lifecycle Orchestration — Part 2](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md)
supersede only this record's replaceable LiveSync P2P Replicator and ownership
of the replaceable result returned by the `serviceFeature`. This record remains
authoritative for serialised room operations, `room.leave()`, Trystero-owned
physical peers, and relay reconnection.
## Context
Self-hosted LiveSync uses Trystero's Nostr strategy for P2P discovery, signalling, and WebRTC transport. Three related resources have different owners and lifetimes:
@@ -12,7 +26,7 @@ Self-hosted LiveSync uses Trystero's Nostr strategy for P2P discovery, signallin
- Trystero owns the underlying WebRTC peers and may share one physical peer across more than one room; and
- Trystero's Nostr relay manager owns WebSocket clients shared by relay URL.
Closing every `RTCPeerConnection` returned by `room.getPeers()` bypasses Trystero's shared-peer manager. The manager may then retain a stale shared peer and prevent a replacement LiveSync replicator from discovering the same remote peer again.
Closing every `RTCPeerConnection` returned by `room.getPeers()` bypasses Trystero's shared-peer manager. The manager may then retain a stale shared peer and prevent a replacement LiveSync Replicator from discovering the same remote peer again.
Room departure and physical transport destruction are not equivalent. `room.leave()` sends the room-leave action, removes that room's actions and callbacks, and detaches its shared-peer binding. Trystero may retain a healthy physical WebRTC peer for later reuse after the last room binding has gone. The retained peer cannot carry actions for the room which has been left.
@@ -40,7 +54,7 @@ The explicit disconnect operation therefore has the following contract:
This operation is a logical LiveSync disconnection and a physical signalling-server disconnection. It does not promise that every browser-owned WebRTC object has been destroyed synchronously.
An explicit connect resumes relay reconnection before opening a new room. Settings application and database lifecycle replacement close the current LiveSync replicator, discard it, construct a new instance from the current settings, and open that current instance when the configured policy requires it. Commands, event handlers, and panes resolve the current service-feature result at the point of use rather than retaining an obsolete replicator.
An explicit connect resumes relay reconnection before opening a new room. Settings application and database lifecycle replacement close the current LiveSync Replicator, discard it, construct a new instance from the current settings, and open that current instance when the configured policy requires it. Commands, event handlers, and panes resolve the focused views returned by the current P2P `serviceFeature` at the point of use rather than retaining an obsolete Replicator.
Lifecycle operations on one `LiveSyncTrysteroReplicator` are serialised. A close requested while an open is in progress must leave no orphan room serving, and repeated opens must not create parallel rooms. No fixed delay is inserted between close and open: readiness is determined by the actual lifecycle operation and peer discovery.
@@ -50,7 +64,7 @@ P2P setup follows the transport's actual ownership model. Initialising the first
## Ownership
Commonlib owns the LiveSync-specific P2P service, RPC, command, and lifecycle composition. Trystero owns WebRTC peer creation, sharing, reuse, stale detection, and destruction, as well as relay-client reconstruction. The Self-hosted LiveSync host owns the current Commonlib service-feature result and supplies the platform services used by its current replicator.
Commonlib owns the LiveSync-specific P2P service, RPC, command, and lifecycle composition. Trystero owns WebRTC peer creation, sharing, reuse, stale detection, and destruction, as well as relay-client reconstruction. The Self-hosted LiveSync host owns the focused views returned by the current Commonlib P2P `serviceFeature` and supplies the platform services used by its current Replicator.
Self-hosted LiveSync does not add a separate root Trystero dependency. Tests which must observe relay sockets resolve the exact Trystero generation owned by the locked Commonlib package, avoiding two independent transport singletons in one process.
@@ -58,7 +72,7 @@ Self-hosted LiveSync does not add a separate root Trystero dependency. Tests whi
### Close every value returned by `room.getPeers()`
This bypasses Trystero's shared-peer manager and can prevent a replacement replicator from rediscovering the same peer.
This bypasses Trystero's shared-peer manager and can prevent a replacement Replicator from rediscovering the same peer.
### Add a fixed close-to-open delay
@@ -76,15 +90,15 @@ This interferes with Trystero's shared relay clients. The public pause and resum
Commonlib unit tests prove that normal P2P host closure calls `room.leave()` without directly closing Trystero-owned peer connections. Additional package tests cover the action API, replaceable peer-event subscriptions, multiple RPC transport disposers, serialised open and close operations, initialisation of the first device without a central remote, and Fetch running once for an additional device.
Self-hosted LiveSync unit tests prove that settings and database replacement leave panes on the current replicator, and that an explicit P2P rebuild bypasses the policy intended for ordinary replication.
Self-hosted LiveSync unit tests prove that settings and database replacement leave panes on the current Replicator, and that an explicit P2P rebuild bypasses the policy intended for ordinary replication.
The canonical Compose P2P suite uses a real local Nostr relay and WebRTC implementation. It covers ordinary two-peer synchronisation, replacement of the active LiveSync replicator followed by discovery and transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. The lifecycle scenario is exposed only through a Docker test build and an injected CLI command runner; it is not part of the public CLI command surface.
The canonical Compose P2P suite uses a real local Nostr relay and WebRTC implementation. It covers ordinary two-peer synchronisation, replacement of the active LiveSync Replicator followed by discovery and transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. The lifecycle scenario is exposed only through a Docker test build and an injected CLI command runner; it is not part of the public CLI command surface.
The real-Obsidian P2P Setup URI workflow creates the first device, generates the second-device URI from it, accepts each peer visibly, and verifies a two-way note round-trip through a local relay. A separate focused pane test covers the principal connection control and teardown without requiring a remote peer. Transport replacement and relay-socket lifecycle remain owned by the package and Compose tests rather than being duplicated in Obsidian.
## Consequences
- Replacing a P2P replicator no longer leaves host views or commands bound to an obsolete instance.
- Replacing a P2P Replicator no longer leaves host views or commands bound to an obsolete instance.
- Explicit signalling-server disconnection has a testable socket-level meaning without claiming immediate destruction of idle WebRTC objects.
- Settings which change the relay, room, passphrase, or TURN configuration can replace the whole LiveSync room safely.
- Trystero may reuse healthy peers across room lifecycles, reducing unnecessary renegotiation.
@@ -72,6 +72,7 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex
### Flag-file recovery order
- For a configured Vault, evaluate and persist the compatibility gate after settings load, before Obsidian layout-ready recovery begins. This blocks ordinary and one-shot replication even while the review dialogue has not yet opened. An existing unconfigured Vault follows the deferred rule above instead.
- Admit configured-only start-up work at priority 1, after ordinary priority-0 layout integration and before flag-file recovery. An unconfigured Vault offers onboarding and returns `false`, so recovery, compatibility review, database preparation, and configured-only request handling do not run. Treat this admission as a property of the current plug-in process: changing `isConfigured` from `false` to `true` requires the scheduled restart before configured work becomes available, and declining that restart deliberately leaves the current process inert. If an admitted process changes `isConfigured` to `false`, retire the Config Doctor and incomplete-document repair request handlers immediately, and recheck the current setting and database readiness when either handler runs.
- Preserve the existing ordered flag-file recovery handlers: SCRAM at priority 5, fetch-all at priority 10, and rebuild-all at priority 20. These files express an explicit recovery instruction and may invoke their focused storage or rebuild service while ordinary replication remains gated.
- Present the compatibility review at priority 30, after any selected recovery operation. A recovery handler which cancels start-up, keeps SCRAM active, or schedules a restart returns `false`, so the current process does not open a competing compatibility dialogue. If recovery completes and start-up continues, the dialogue opens before normal synchronisation is allowed to resume.
- Keep database preparation independent of an unanswered compatibility dialogue, because the compatibility gate already blocks replication. Before Config Doctor begins its interactive checks, await the active initial review so that the two update dialogues cannot overlap.
@@ -100,4 +101,4 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex
- Unit and Compose tests verify that ordinary P2P replication observes the policy, explicit P2P rebuild uses the setup bypass, and replacement leaves host actions on the current replicator.
- A real-Obsidian settings test verifies the dedicated summary and details dialogues, captures representative screenshots, confirms that the acknowledged internal version advances only after explicit resume, and confirms that the Change Log contains no acknowledgement control.
- The real-Obsidian CouchDB workflow starts from configured plug-in data without a device-local marker, verifies the copied-or-restored Vault explanation, resumes through the actual dialogue, and then completes remote metadata, chunk, and activity checks. The two-Vault workflow performs the same review once per isolated Vault before reusing the acknowledged device state for later process launches.
- Unit tests fix the layout-ready priority after the three flag-file recovery priorities, so a recovery which stops start-up cannot race the compatibility dialogue.
- Unit tests fix configured Vault admission at priority 1, the three flag-file recovery priorities at 5, 10, and 20, and compatibility review at priority 30. A recovery which stops start-up therefore cannot race the compatibility dialogue.
@@ -46,7 +46,7 @@ LiveSync will expose a separate `Connection path` choice:
- `Automatic` retains normal ICE selection and is the default.
- `TURN relay only` supplies `iceTransportPolicy: 'relay'` and prevents direct or server-reflexive candidates from being selected.
`TURN relay only` is enabled only when at least one syntactically valid `turn:` or `turns:` URL is configured. If the last valid TURN URL is removed while relay-only mode is selected, the dialogue restores `Automatic` and displays a concise explanation.
`TURN relay only` is enabled when a managed TURN provider is selected or at least one syntactically valid manual `turn:` or `turns:` URL is configured. If neither is available while relay-only mode is selected, the dialogue restores `Automatic` and displays a concise explanation. Selecting a managed provider does not itself force relay use; `Automatic` retains normal ICE selection.
The route policy is an ordinary P2P profile property. It is retained in P2P connection strings and encrypted Setup URIs so that an imported compatibility profile has reproducible transport behaviour.
@@ -60,7 +60,15 @@ The first settings revision retains the existing storage and dialogue contract o
A future interface may present the existing comma-separated value as ordered `turn:` and `turns:` URL rows without changing its serialised representation. A structured list of multiple credential profiles is deferred until a provider or self-hosted use case requires different credentials in the same P2P profile.
Static long-term credentials are the supported first stage. Managed providers may return short-lived credentials, but LiveSync must not store a provider API token or a Coturn shared authentication secret. A future managed-credential design needs a separately trusted HTTPS endpoint, expiry handling, refresh behaviour, failure reporting, and a clear Setup URI policy. It is not represented as another static password field.
Static long-term credentials remain supported. For managed credentials, a host preparation hook requests ICE settings and places them on a connection-only copy of `P2PSyncSetting`. Service-specific requests and validation belong under `src/integrations/`; Commonlib consumes that copy and owns room reuse, expiry checks, and replacement. It has no provider catalogue or source factory.
A user-supplied provider API token is persisted as a sensitive P2P profile setting and included in encrypted Setup URI sharing, so that participating devices can use the same configuration without repeated token entry. Existing profile-URI encryption covers the saved token; its flat runtime projection is omitted from persistence. Reports and logs redact the complete provider configuration and issued credentials, including inactive profiles and settings projections. Coturn's server-side shared authentication secret remains outside client settings.
Issued short-lived TURN credentials and their expiry remain in memory. The existing room reuse decision checks both the effective connection settings and credential validity. When reconciliation finds expired credentials, it uses the normal room retirement and replacement path with newly acquired credentials. Replacement may cancel an in-progress transfer; the next replication attempt uses stored checkpoints and revision comparison to retain received progress. Whether that next attempt starts automatically follows the existing synchronisation policy.
Time passing alone does not trigger acquisition or disconnection. This design adds no renewal timer, per-peer acquisition hook, configuration update on raw peers, or credential-driven ICE restart. Internal peer reconnection within an unchanged room does not guarantee fresh issuance. Acquisition failure is reported without changing the selected provider or route policy. See [TURN connection settings](../design_docs/renewable_turn_credentials.md) for the preparation hook, persistence and sharing formats, room replacement, and verified replication continuation behaviour.
Relay-only validation accepts a valid managed TURN configuration as well as the existing manual URL list. Failure to acquire usable TURN entries keeps relay-only mode selected and reports the connection failure; it does not restore `Automatic` silently.
### TURN allocation check and route diagnostics
@@ -1,8 +1,8 @@
---
date: 2026-08-27
commonlib-version: "0.1.20"
self-hosted-livesync-version: "1.0.21"
status: proposed
date: 2026-09-02
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.23"
status: accepted
series: replicator-capabilities-and-lifecycle
part: 1 of 3
---
@@ -15,15 +15,20 @@ then [Part 3: migration plan and verification](2026_08_replicator_capabilities_0
## Status
Proposed. This record defines the provider, capability, lifecycle, interaction,
Accepted and implemented in Commonlib 0.1.21 and Self-hosted LiveSync 1.0.23.
This record defines the provider, capability, lifecycle, interaction,
ownership, and probe boundaries required by current Self-hosted LiveSync
consumers. It is the generic part of the series; the P2P-specific ownership
rules live in Part 2, and implementation sequencing lives in Part 3.
rules live in Part 2, and the completed implementation sequence lives in Part
3. The current structure is summarised in the
[Replicator architecture](../design_docs/replicator_architecture.md) design
document.
The accepted P2P room and transport lifecycle record remains authoritative for
the current P2P implementation until Stage 3 in Part 3 is complete. The
supersession boundary for that record is stated in Part 2 and is not repeated
here.
Stage 3 in Part 3 is complete. The stable P2P service and room-session owner in
Part 2 supersede the replaceable LiveSync P2P Replicator ownership described by
the earlier P2P room and transport lifecycle record. That accepted record
remains authoritative for its retained Trystero room, physical-peer, and relay
ownership decisions.
## Context
@@ -235,12 +240,14 @@ supplies an exhaustive definition table for that set. The current catalogue is
CouchDB, Object Storage, and P2P; it is not a public third-party registration
API.
CouchDB is part of every current host composition. Object Storage and P2P are
compile-time composition choices and may be included or omitted without
changing the generic scheduling feature. Adding another current provider
requires a Commonlib kind and support declaration, host composition,
Setup/profile schema handling, and provider-specific tests. It does not require
a runtime plug-in registry or behaviour for unknown provider kinds.
Every current `LiveSyncBaseCore` host composes CouchDB and Object Storage.
`WebPeerRuntime` is a separate P2P-only host composition, and P2P remains a
compile-time feature choice for the other hosts. A host can include or omit a
provider without changing the generic scheduling feature. Adding another
current provider requires a Commonlib kind and support declaration, host
composition, Setup/profile schema handling, and provider-specific tests. It
does not require a runtime plug-in registry or behaviour for unknown provider
kinds.
Each provider definition supplies:
@@ -777,6 +784,7 @@ when every caller proves it to be the operation's identity.
## References
- [Project glossary](../glossary.md#developer-and-design-terms)
- [Part 2: P2P service and session lifecycle](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md)
- [Part 3: migration plan and verification](2026_08_replicator_capabilities_03_migration_plan.md)
- [Bounded Remote Activity](2026_07_bounded_remote_activity.md)
@@ -1,8 +1,8 @@
---
date: 2026-08-27
commonlib-version: "0.1.20"
self-hosted-livesync-version: "1.0.21"
status: proposed
date: 2026-09-02
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.23"
status: accepted
series: replicator-capabilities-and-lifecycle
part: 2 of 3
---
@@ -14,20 +14,24 @@ first, then continue with [Part 3: migration plan and verification](2026_08_repl
## Status
Proposed. This record defines the P2P service owner, room-session boundary,
narrow contract views, automation demands, replacement fencing, and trigger
Accepted and implemented in Commonlib 0.1.21 and Self-hosted LiveSync 1.0.23.
This record defines the P2P service owner, room-session boundary, narrow
contract views, automation demands, replacement fencing, and trigger
semantics. Generic provider and capability rules are owned by Part 1; the
implementation and verification order is owned by Part 3.
completed implementation and verification order is recorded in Part 3.
The implemented state is recorded separately in Commonlib's
`docs/p2p-transport-lifecycle.md` design document. It supersedes the
replaceable LiveSync P2P Replicator and current-result ownership described by
[P2P transport lifecycle](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/p2p-transport-lifecycle.md)
design document and summarised with the generic provider lifecycle in the
[Replicator architecture](../design_docs/replicator_architecture.md) design
document. It supersedes the replaceable LiveSync P2P Replicator and ownership
of the replaceable result returned by the `serviceFeature`, as described by
the accepted [P2P Room and Transport Lifecycle](2026_07_p2p_transport_lifecycle.md)
record. The accepted record's decisions about serialised room operations,
record.
The accepted record's decisions about serialised room operations,
`room.leave()`, Trystero-owned physical peers, and relay reconnection remain in
force. This ADR remains the decision and migration target; the Commonlib
design document records the names and ownership boundaries which actually
landed.
force. This ADR records the product decision; the Commonlib design document
records the implemented names and ownership boundaries.
## Scope and context
@@ -397,6 +401,7 @@ and does not publish a second P2P lifecycle owner.
## References
- [Project glossary](../glossary.md#developer-and-design-terms)
- [Part 1: core contract](2026_08_replicator_capabilities_01_core_contract.md)
- [Part 3: migration plan and verification](2026_08_replicator_capabilities_03_migration_plan.md)
- [P2P Room and Transport Lifecycle](2026_07_p2p_transport_lifecycle.md)
@@ -1,8 +1,8 @@
---
date: 2026-08-27
commonlib-version: "0.1.20"
self-hosted-livesync-version: "1.0.21"
status: proposed
date: 2026-09-02
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.23"
status: accepted
series: replicator-capabilities-and-lifecycle
part: 3 of 3
---
@@ -16,11 +16,14 @@ another runtime contract.
## Status
Proposed. The stages below are an implementation and verification order, not
independently releasable states. Commonlib and Self-hosted LiveSync must not
publish temporary support boundaries described by an incomplete stage. A
release follows only after the target matrix, ownership boundaries, and the
contracted production-consumer migrations in Parts 1 and 2 are complete.
Accepted and implemented in Commonlib 0.1.21 and Self-hosted LiveSync 1.0.23.
The stages below record the implementation and verification order; they were
not independently releasable states. The target matrix, ownership boundaries,
and contracted production-consumer migrations in Parts 1 and 2 are complete.
Items which Stage 7 explicitly defers remain separate compatibility work rather
than incomplete stages. The current result is summarised in the
[Replicator architecture](../design_docs/replicator_architecture.md) design
document.
## Migration rules
@@ -103,11 +106,11 @@ until every remaining demand has settled. The LiveSync feature-binding test
must not rely on the current registration order of equal-priority resume
handlers.
Until Stage 4 supplies target-aware unattended P2P, each host composition
declares generic `P2P_SyncOnReplication` as `not-implemented`. Its automatic
request settles without UI with an explicit blocked result. Existing AutoSync,
AutoWatch, and accepted incoming-request paths continue with the Stage 2 gate.
This is a temporary migration state, not the target matrix in Part 1.
Before Stage 4 supplied target-aware unattended P2P, each host composition
declared generic `P2P_SyncOnReplication` as `not-implemented`. Its automatic
request settled without UI with an explicit blocked result. Existing AutoSync,
AutoWatch, and accepted incoming-request paths continued with the Stage 2 gate.
This was a temporary migration state, not the target matrix in Part 1.
Apply and test the CLI scheduling precedence defined in Part 1, so the daemon
and scheduling context cannot schedule duplicate initial or recurring work.
@@ -178,10 +181,11 @@ Add ownership regressions immediately before implementation:
publishing the new database identity, while a failed candidate leaves one
observable disconnected state without reviving the fenced session.
When this stage lands, add a supersession note to the accepted P2P lifecycle
record and update `devs.md` from the replaceable concrete Replicator getter to
the stable contract views. Preserve the accepted Trystero peer and relay
ownership rules rather than rewriting their historical verification.
Completion of this stage added a bounded supersession note to the accepted P2P
lifecycle record and updated `devs.md` from the replaceable concrete Replicator
getter to the stable contract views. The accepted Trystero peer and relay
ownership rules remain in force rather than being rewritten as part of this
migration.
## Stage 4: add target-aware unattended P2P orchestration
@@ -203,14 +207,17 @@ shared-pane synchronisation.
Keep the detailed wait, session-demand, de-duplication, and session-epoch state
machine in Part 2 rather than expanding the generic provider contract. If
implementation evidence requires a refinement, amend Part 2 before completing
this stage. After Stage 3 is complete, Part 2 supersedes the
replaceable-Replicator and current-result ownership portions of the accepted
July 2026 record; its Trystero peer and relay decisions remain unchanged.
this stage. With Stage 3 complete, Part 2 supersedes the portions of the
accepted July 2026 record concerning the replaceable Replicator and ownership
of the replaceable result returned by the `serviceFeature`. Its Trystero peer
and relay decisions remain unchanged.
Commonlib's `docs/p2p-transport-lifecycle.md` design document records the
implemented Stage 3 and Stage 4 ownership, demand, automation, replacement,
and shutdown behaviour. This document remains the migration and verification
sequence rather than a second description of the implemented state.
Commonlib's
[P2P transport lifecycle](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/p2p-transport-lifecycle.md)
design document records the implemented Stage 3 and Stage 4 ownership, demand,
automation, replacement, and shutdown behaviour. This document remains the
migration and verification sequence rather than a second description of the
implemented state.
## Stage 5: separate active construction and flow-specific probes
@@ -231,8 +238,8 @@ making active construction private.
The provider-defined active-construction path is now private to
`ReplicatorService`. The public `getNewReplicator` handler remains as a
compatibility surface, but current Self-hosted LiveSync production code no
longer calls it. Its removal belongs to Stage 7 after any external compatibility
decision has been made.
longer calls it. Stage 7 reviewed its possible removal and deferred it pending
an external compatibility decision.
The current host composition has migrated CouchDB and Object Storage connection
checks, passphrase inspection, preferred-tweak reads, CLI remote status and
@@ -246,10 +253,11 @@ against the active relay binding held by the stable P2P service. The Stage 7
review identified and completed that remaining owner boundary; it did not
reopen the active-construction contract.
This position completes the Stage 5 construction and probe boundary. It is not
itself a release decision: the active-publication and truthful-attempt work in
Stage 6 remains required. Complete retirement of the compatibility facade is
not a prerequisite for issue 1140.
This position completed the Stage 5 construction and probe boundary. At that
intermediate point it was not itself a release decision: Stage 6 still had to
complete the active-publication lifecycle and return an exact outcome for each
attempt. Complete retirement of the compatibility facade was not a prerequisite
for issue 1140.
## Stage 6: harden the active lifecycle and exact attempt outcome
@@ -342,8 +350,8 @@ publication.
The first implementation regressions cover:
- replacement waiting for an admitted exact-context task while ignoring an
unrelated bounded activity;
- replacement waiting for a task admitted against the exact publication while
ignoring an unrelated bounded activity;
- context acquisition waiting for a queued replacement rather than returning a
stale or intermediate publication;
- rejecting central-remote administration releasing its reservation before
@@ -711,6 +719,7 @@ retirement remains a separately reviewed compatibility change.
## References
- [Project glossary](../glossary.md#developer-and-design-terms)
- [Part 1: core contract](2026_08_replicator_capabilities_01_core_contract.md)
- [Part 2: P2P service and session lifecycle](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md)
- [P2P Room and Transport Lifecycle](2026_07_p2p_transport_lifecycle.md)
+193 -144
View File
@@ -1,173 +1,222 @@
# Data Structures of Self-Hosted LiveSync
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
## Overview
# Database Data Structures
Self-hosted LiveSync uses the following types of documents:
## Scope and Authority
- Metadata
- Legacy Metadata
- Binary Metadata
- Plain Metadata
- Chunk
- Versioning
- Synchronise Information
- Synchronise Parameters
- Milestone Information
This document is a developer overview of the database structures used by the
current Self-hosted LiveSync 1.0 series. It is not a stable, forward-compatible
API for constructing CouchDB documents by hand.
## Description of Each Data Structure
The executable authority for document types, path and identifier encoding,
chunk splitting and hashing, encryption, compression, and content
reconstruction is the exact `@vrtmrz/livesync-commonlib` version recorded in
the repository lockfile. Commonlib owns this domain under the
[package-boundary decision](adr/2026_07_common_library_package_boundary.md).
When this overview and that installed package differ, correct this document
and treat the package behaviour as authoritative for the affected release.
All documents inherit from the `DatabaseEntry` interface. This is necessary for conflict resolution and deletion flags.
Three representations must be distinguished:
1. the decoded application representation used by Commonlib services;
2. the local PouchDB representation, including CouchDB revision metadata; and
3. the raw remote representation after any configured compression, E2EE, or
path-obfuscation transform.
The examples below describe the first two representations unless a section
explicitly discusses the raw remote representation. The exact raw remote shape
depends on the configured transforms and protocol version and cannot be
inferred from the decoded or local examples alone.
## Principal Document Families
- file Metadata, including compatibility-only legacy Metadata;
- Chunks and compatibility transport structures such as Chunk Packs;
- database version, synchronisation, Milestone, and Node information; and
- CouchDB revision and deletion records.
Commonlib's `EntryDoc` is a union across several of these families. It is not
synonymous with file Metadata.
## Common CouchDB Fields
Database documents share this base shape:
```ts
export interface DatabaseEntry {
_id: DocumentID;
_rev?: string;
_deleted?: boolean;
_conflicts?: string[];
}
```
### Versioning Document
- `_id` identifies one CouchDB document.
- `_rev` identifies one revision of that document.
- `_conflicts` is returned when conflict information is requested. It is
CouchDB revision metadata, not part of the persisted application document.
- `_deleted: true` creates a CouchDB tombstone. It is distinct from the
logical file-deletion field `deleted: true` described below.
This document stores version information for Self-hosted LiveSync.
The ID is fixed as `obsydian_livesync_version` [VERSIONING_DOCID]. Yes, the typo has become a curse.
When Self-hosted LiveSync detects changes to this document via Replication, it reads the version information and checks compatibility.
This internal database version is independent of the plug-in's SemVer version. The last version explicitly acknowledged on a device is stored through Commonlib's device-local configuration contract. When that version differs, or when a settings migration requires review, Self-hosted LiveSync presents a dedicated compatibility dialogue and blocks replication without changing the user's automatic synchronisation choices. A supported upgrade can resume only after explicit review. A downgrade from a newer acknowledged database version, or settings written by a future schema, remains blocked until a compatible plug-in is installed.
Please refer to negotiation.ts.
## File Metadata
### Synchronise Information Document
This document stores information that should be verified in synchronisation settings.
The ID is fixed as `syncinfo` [SYNCINFO_ID].
The information stored in this document is only the conditions necessary for synchronisation to succeed, and as of v0.25.43, only a random string is stored.
This document is only used during rebuilds from the settings screen for CouchDB-based synchronisation, making it like an appendix. It may be removed in the future.
### Synchronise Parameters Document
This document stores synchronisation parameters.
Synchronisation parameters include the protocol version and salt used for encryption, but do not include chunking settings.
The ID is fixed as `_local/obsidian_livesync_sync_parameters` [DOCID_SYNC_PARAMETERS] or `_obsidian_livesync_journal_sync_parameters.json` [DOCID_JOURNAL_SYNC_PARAMETERS].
This document exists only on the remote and not locally.
This document stores the following information.
It is read each time before connecting and is used to verify that E2EE settings match.
This mismatch cannot be ignored and synchronisation will be stopped.
Current files are stored as chunked Metadata. The following is a simplified
shape; the exported Commonlib declarations remain authoritative:
```ts
export interface SyncParameters extends DatabaseEntry {
_id: typeof DOCID_SYNC_PARAMETERS;
type: (typeof EntryTypes)["SYNC_PARAMETERS"];
protocolVersion: ProtocolVersion;
pbkdf2salt: string;
}
```
#### protocolVersion
This field indicates the protocol version used by the remote. Mostly, this value should be `2` (ProtocolVersions.ADVANCED_E2EE), which indicates safer E2EE support.
#### pbkdf2salt
This field stores the salt used for PBKDF2 key derivation on the remote. This salt and the passphrase provides E2EE encryption keys.
### Milestone Information Document
This document stores information about how the remote accepts and recognises clients.
The ID is fixed as `_local/obsidian_livesync_milestone` [MILESTONE_DOCID].
This document exists only on the remote and not locally.
This document is used to indicate synchronisation progress and includes the version range of accepted chunks for each node and adjustment values for each node.
Tweak Mismatched is determined based on the information in this document.
For details, please refer to LiveSyncReplicator.ts, LiveSyncJournalReplicator.ts, and LiveSyncDBFunctions.ts.
```ts
export interface EntryMilestoneInfo extends DatabaseEntry {
_id: typeof MILESTONE_DOCID;
type: EntryTypes["MILESTONE_INFO"];
created: number;
accepted_nodes: string[];
node_info: { [key: NodeKey]: NodeData };
locked: boolean;
cleaned?: boolean;
node_chunk_info: { [key: NodeKey]: ChunkVersionRange };
tweak_values: { [key: NodeKey]: TweakValues };
}
```
### locked
If the remote has been requested to lock out from any client, this is set to true.
When set to true, clients will stop synchronisation unless they are included in accepted_nodes.
### cleaned
If the remote has been cleaned up from any client, this is set to true.
In this case, clients will stop synchronisation as they need to rebuild again.
### Metadata Document
Metadata documents store metadata for Obsidian notes.
```ts
export interface MetadataDocument extends DatabaseEntry {
_id: DocumentID;
type ChunkedMetadata = DatabaseEntry & {
ctime: number;
mtime: number;
size: number;
deleted?: boolean;
eden: Record<string, EdenChunk>; // Obsolete
eden: Record<string, { data: string; epoch: number }>;
path: FilePathWithPrefix;
children: string[];
type: EntryTypes["NOTE_LEGACY" | "NOTE_BINARY" | "NOTE_PLAIN"];
}
```
### type
This field indicates the type of Metadata document.
By convention, Self-hosted LiveSync does not save the mime type of the file, but distinguishes them with this field. Please note this.
Possible values are as follows:
- NOTE_LEGACY: Legacy metadata document
- Please do not use
- NOTE_BINARY: Binary metadata document (newnote)
- NOTE_PLAIN: Plain metadata document (plain)
#### children
This field stores an array of Chunk Document IDs.
#### \_id, path
\_id is generated based on the path of the Obsidian note.
The validation and explicit repair contract for normal-file Metadata whose
actual ID does not match the ID derived from its stored path is defined in
[Normal-file Metadata Document ID Validation and Repair](design_docs/metadata_document_id_validation_and_repair.md).
- If the path starts with `_`, it is converted to `/_` for convenience.
- If Case Sensitive is disabled, it is converted to lowercase.
When Obfuscation is enabled, the path field contains `f:{obfuscated path}`.
The path field stores the path as is. However, when Obfuscation is enabled, the obfuscated path is stored.
When Property Encryption is enabled, the path field stores all properties including children, mtime, ctime, and size in an encrypted state. Please refer to encryption.ts.
### Chunk Document
```ts
export type EntryLeaf = DatabaseEntry & {
_id: DocumentID;
type: EntryTypes["CHUNK"];
data: string;
type: "plain" | "newnote";
};
```
Chunk documents store parts of note content.
`children` contains Chunk document IDs in reconstruction order. A normal save
persists every referenced Chunk before it persists the Metadata which names
those Chunks. The writes are separate database operations rather than one
atomic transaction, so another client may still observe the Metadata first.
The resulting retrieval contract is documented in
[Chunk Retrieval and Waiting](design_docs/chunk_retrieval_and_waiting.md).
- The type field is always `[CHUNK]`, `leaf`.
- The data field stores the chunk content.
- The \_id field is generated based on a hash of the content and the passphrase.
The current persisted file types are:
Hash functions used include xxHash and SHA-1, depending on settings.
Chunking methods used include Contextual Chunking and Rabin-Karp Chunking, depending on settings.
- `plain`, for text content represented by literal text Chunks; and
- `newnote`, for binary content represented by Base64 Chunks.
The compatibility-only `notes` type stores content directly in its `data`
field rather than in `children`. Existing data may be read through selected
legacy paths, but current writers do not create `notes` documents, and not
every current replication path accepts newly created legacy documents.
`datatype` appears on Commonlib's loaded and saving representations. The
current Metadata writer does not persist it, so it is absent from ordinary
current CouchDB Metadata.
`eden` remains in the shared type for existing data compatibility. New
configuration does not enable Eden, and current writers do not create
incubated Eden Chunks for a new configuration.
### Times and Size
`ctime` and `mtime` are Unix epoch times in milliseconds. `size` is the byte
size of the decoded file content supplied by the storage boundary. Current
storage adapters and generated Blob paths obtain it from filesystem metadata
or `Blob.size`; JavaScript `String.length` is a UTF-16 code-unit count and is
not a valid substitute for non-ASCII content.
### Paths, Identifiers, and Namespaces
At the decoded boundary, `path` records the logical path, including any
feature namespace prefix. `_id` is derived from that path by Commonlib's path
service:
- a path beginning with `_` receives a leading `/` in its document ID so that
CouchDB does not interpret it as a reserved identifier;
- the path is folded to lower case only when
`handleFilenameCaseSensitive` is disabled; and
- when path obfuscation is enabled, the body of the document ID is replaced
by an `f:` SHA-256-derived value. A feature prefix is retained, so an
obfuscated Hidden File Sync ID can begin with `i:f:`.
The main namespaces are:
| Prefix | Meaning |
| ------ | ------------------------------------------------------- |
| none | An ordinary Vault file |
| `i:` | Hidden File Sync Metadata |
| `ix:` | Customisation Sync Metadata |
| `ps:` | Compatibility namespace for plug-in storage data |
| `f:` | Obfuscated document-ID body |
| `h:` | Chunk document |
| `h:+` | Chunk whose identifier incorporates encryption material |
Namespaces identify storage and path handling; they do not by themselves
select an Entry `type`. Current Hidden File Sync and Customisation Sync writers
store chunked `plain` or `newnote` documents under `i:` and `ix:`. The
application-local `type: "plugin"` interface is not a Commonlib Entry type and
is not the current Customisation Sync storage format. Although Commonlib
retains an `internalfile` constant for compatibility, current Hidden File Sync
producers do not use it as their persisted type.
The decoded `path` does not become `f:{obfuscated path}`. Compression, E2EE,
and path obfuscation can change raw remote identifiers and properties.
Commonlib owns those transforms, and their exact representation depends on the
selected settings.
The validation and explicit repair contract for ordinary-file Metadata whose
stored `_id` does not agree with its decoded `path` is defined in
[Normal-file Metadata Document ID Validation and Repair](design_docs/metadata_document_id_validation_and_repair.md).
## Chunk Documents
```ts
export type EntryLeaf = DatabaseEntry & {
type: "leaf";
data: string;
isCorrupted?: boolean;
};
```
A `leaf` stores one content-addressed piece. For `plain` Metadata, `data` is
literal text. For `newnote` Metadata, `data` is Base64 text representing binary
bytes. Concatenating and decoding the children in order reconstructs the
decoded file content.
Commonlib's configured `HashManager` produces content-derived Chunk
identifiers. Their representation can vary with compatibility and encryption
settings. Historical hash algorithms remain readable only as compatibility
settings.
Chunk revisions are content-derived irrespective of the obsolete stored
`doNotUseFixedRevisionForChunks` setting. Compression and E2EE may transform a
Chunk's raw remote `data` and add representation markers, so the remote value
can differ from the decoded Chunk data.
## File Deletion
LiveSync distinguishes two operations:
- `deleted: true` is a logical deletion of a file. With Metadata retention
enabled, an ordinary current-file deletion preserves the existing Metadata
fields and Chunk references, updates `mtime`, and creates a new Metadata
revision. This permits the deletion to participate in synchronisation and
conflict history.
- `_deleted: true` is a CouchDB tombstone for one document revision. That
revision does not retain the application body. Tombstones are used by
explicit compatibility and clean-up paths.
A logical deletion does not clear `children` or set `size` to zero. The
`deleteMetadataOfDeletedFiles` setting can request an immediate tombstone
instead. Whether retained, logically deleted Metadata is later tombstoned
depends on the configured deletion-retention settings.
Branch-specific conflict operations and their ancestry requirements are defined
in the [Conflict Resolution specification](specs_conflict_resolution.md).
## Control Documents
The principal control documents are:
| Document | Identifier | Purpose |
| ---------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Version information | `obsydian_livesync_version` | Records the internal database version. The historical spelling is retained for compatibility. |
| Synchronisation information | `syncinfo` | Stores rebuild-related synchronisation information for CouchDB-based operation. |
| CouchDB synchronisation parameters | `_local/obsidian_livesync_sync_parameters` | Stores the protocol version and PBKDF2 salt on the remote. |
| Journal synchronisation parameters | `_obsidian_livesync_journal_sync_parameters.json` | Journal counterpart of the synchronisation-parameter record. |
| Milestone information | `_local/obsidian_livesync_milestone` | Records accepted Nodes, locking, clean-up state, Chunk version ranges, and synchronisation tweak values. |
| Node information | `_local/obsidian_livesync_nodeinfo` | Records the local Node identifier and compatibility markers. |
The `_local/` records are CouchDB-local documents and do not replicate like
ordinary Metadata and Chunks. Synchronisation parameters are checked before
connecting; an incompatible protocol or encryption configuration stops
synchronisation rather than being ignored.
@@ -7,7 +7,7 @@ Accepted for a limited implementation.
## Problem and scope
This document uses the independent revision properties defined under
[Revision](../terms.md#revision) and the general state model in
[Revision](../glossary.md#revision) and the general state model in
[Conflict resolution and revision provenance](../specs_conflict_resolution.md).
Document History can reconstruct an available historical revision from its
@@ -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.
@@ -0,0 +1,177 @@
---
date: 2026-09-16
commonlib-version: "0.1.25"
self-hosted-livesync-version: "1.0.28"
status: unreleased
---
# TURN credentials in P2P connection settings
## Purpose
This design addresses [Issue #1182](https://github.com/vrtmrz/obsidian-livesync/issues/1182)
by acquiring temporary TURN credentials on the device before opening a P2P room.
The [P2P transport compatibility ADR](../adr/2026_08_p2p_transport_compatibility.md)
records the connection and persistence policy.
LiveSync prepares a connection copy of `P2PSyncSetting`. Commonlib owns the room
lifecycle and consumes the resulting ICE settings. Service-specific HTTP and
validation remain under `src/integrations/`; Commonlib has no provider catalogue
or versioned acquisition descriptor. Cloudflare is the first optional integration.
Manual TURN configuration remains available without a provider account.
## Settings and ownership
| Setting | Meaning | Lifetime |
| --- | --- | --- |
| `P2P_managedType` | Provider identifier; `CF` selects Cloudflare | P2P profile |
| `P2P_managedId` | Provider key identifier; Cloudflare TURN Key ID | P2P profile |
| `P2P_managedToken` | Provider API token used to request credentials | P2P profile |
| `P2P_iceServers` | Prepared `RTCIceServer[]` | One room connection |
| `P2P_iceServersExpiresAt` | Absolute expiry in Unix milliseconds | One room connection |
The first three values use ordinary ConnStr query parameters `managedType`,
`managedId`, and `token`. The existing `appId` parameter continues to identify the
P2P application. Commonlib reads and writes the three scalar values so profile
editing and activation preserve them. The host interprets the provider identifier.
An absent identifier selects the existing manual fields; an unsupported identifier
produces an explicit error when a connection is requested.
Keep `P2P_turnServers`, `P2P_turnUsername`, and `P2P_turnCredential` for manual
configuration. Issuance does not overwrite them. Retain the complete ICE array:
individual entries can contain different credentials or STUN-only URLs.
## Host preparation
The optional `prepareP2PSettings(settings, signal)` composition hook receives a
snapshot of requested P2P settings. LiveSync supplies the same preparation function
to Obsidian, CLI, WebApp, and WebPeer using each host's HTTP adapter.
For a managed selection, the function validates the provider inputs, requests
credentials, and returns a connection copy:
```typescript
return {
...settings,
P2P_iceServers: iceServers,
P2P_iceServersExpiresAt: expiresAt,
};
```
Commonlib takes the prepared ICE fields into its session snapshot and passes that
snapshot through `ReplicatorHostEnv.settings`. The hook does not change the
requested room identity, persist settings, own replication, or schedule renewal.
Its HTTP request must settle on cancellation and has a bounded deadline. The room
owner also stops waiting for preparation when the connection request is retired.
An explicitly managed configuration requires a preparation hook and usable ICE
credentials; acquisition failure does not select a fallback provider or route.
Managed credential acquisition is independent of the connection path. `Automatic`
retains normal ICE selection, including direct candidates; only `TURN relay only`
forces relay use. Acquisition must still succeed before opening a managed room
when `Automatic` is selected.
## Room reuse and expiry
The active connection settings hold the issued credentials. They are the only
credential cache. The existing room reuse decision checks:
1. whether the requested database and connection settings still match; and
2. whether the active connection's credentials have enough remaining lifetime.
The static connection signature includes the provider type, key ID, and token.
It excludes the generated ICE array and expiry. Comparing the prepared and stored
settings directly would incorrectly trigger issuance on every reconciliation.
When reuse is unavailable, the owner retires the existing room, obtains a fresh
connection copy, and opens its replacement. It checks settings, room demand,
cancellation, and expiry again before publishing the replacement. A late result
cannot reopen a closed room or apply credentials requested for different settings.
Explicit reconnection acquires fresh credentials. Closing the room releases its
credential references. Preserve a 30-second connection-establishment margin.
Reconciliation runs at existing connection, settings, and lifecycle boundaries.
Time passing alone does not trigger acquisition or disconnection. There is no
renewal timer, per-peer acquisition, raw WebRTC configuration update, ICE restart,
or general retry mechanism. Trystero's internal peer reconnection within an
unchanged room uses that room's existing configuration.
Normal retirement may cancel an in-progress transfer. A later replication attempt
uses stored checkpoints and revision comparison to retain received progress.
An unfinished network message may be sent again. Whether another attempt starts
automatically continues to follow the existing synchronisation policy.
## Persistence, sharing, and privacy
Persist provider values only inside the selected P2P profile URI. Flat values in
runtime settings are a projection restored by profile activation. Profile edits
update that URI explicitly. General settings saves do not rebuild a P2P profile
from unrelated flat settings. Flat-settings migration creates and selects its
P2P profile once, independently of the selected main remote.
Existing whole-profile encryption covers the saved API token. The default mode
uses the existing built-in key; a user-supplied configuration passphrase has its
existing protection semantics. Failure to encrypt a managed profile leaves the
previous saved data intact. No separate encrypted-token field is added. A draft
containing provider credentials but no Group ID remains unsaved.
Setup URIs and ordinary settings QR codes already contain `remoteConfigurations`.
The provider values travel inside that profile URI, including inactive profiles.
Omit their duplicate flat projections from sharing. No new URI scheme, encoded QR
slot, or encryption envelope is needed. Setup URIs retain passphrase encryption;
QR codes retain their unencrypted format and 'FOR YOUR EYES ONLY' display.
Issued ICE credentials and expiry appear only in connection copies. Remove both
runtime fields at save, import, and sharing boundaries, including
`TrysteroReplicator.getAllConfig`, which starts from the session settings.
Incoming settings cannot install an issued credential override. Reports omit
runtime ICE fields, redact provider values, and retain scheme-only profile URIs.
Logs use safe errors and omit request headers, raw responses, and connection
signatures. Ordinary plaintext in process memory is permitted.
Markdown settings omit managed provider values and the profile collection with
its selections. If that group is omitted during import, preserve the corresponding
local P2P connection values as well as the profiles. This prevents combining an
imported room with the local provider token or overwriting the saved profile.
## Cloudflare integration
The UI presents `Manual` and `Managed (Cloudflare)`, with `TURN Key ID` and a masked
`TURN Key API Token` input for Cloudflare. It requires no account ID, custom
endpoint, SDK, credential broker, or renewal interval setting.
The provider function uses Cloudflare's
[credential-generation endpoint](https://developers.cloudflare.com/realtime/turn/generate-credentials/)
and converts its response into ICE servers. The implementation requests a fixed
24-hour lifetime and derives local expiry from the clock before the request starts.
This lifetime applies to the issued TURN credentials, not the provider API token.
There is currently no setting to change it.
The HTTP boundary uses the injected standard fetch adapter with cancellation,
a 15-second deadline, refused redirects, omitted cookies, and disabled caching.
It bounds the response to 32 KiB, 16 ICE entries, and 32 URLs, and validates URLs
and complete TURN credentials. These are local implementation limits. Keep this
validation at the provider boundary instead of repeating it in Commonlib.
The token is supplied and shared by the user on their devices. The provider
function sends the key ID, API token, and requested lifetime; it has no need for
Vault data, the Group ID, or the Vault passphrase.
## Setup and verification
The Setup connection test remains a signalling check. A separately owned trial
uses signalling-only settings and performs no managed TURN issuance. The existing
active-relay admission rule still applies. Success does not verify the API token,
TURN allocation, or document transfer. Actual room connections use the preparation
hook and preserve the selected route policy on failure.
Focused tests cover provider validation and cancellation, room reuse and expiry,
late results after configuration changes or closure, migration without duplicate
profiles, Markdown import through save/reload, safe acquisition failures, and
exclusion of runtime credentials from storage and sharing.
Validate Commonlib as an exact packed artefact before testing its LiveSync
consumer. Verify the changed settings and restart boundary in real Obsidian.
Previously observed provider issuance and relay synchronisation do not establish
expiry-driven reconnection for a revised build. Fresh TURN allocation after
expiry, mobile runtimes, and cross-network behaviour require their own runtime
verification; a surviving Trystero shared peer is not evidence of new allocation.
+333
View File
@@ -0,0 +1,333 @@
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
commonlib-source-commit: e770f617ff0fc88f4823226b0ab3aefdff50cc1e
status: accepted
---
# Replicator architecture
This is the implemented architecture for Self-hosted LiveSync 1.0.24 with `@vrtmrz/livesync-commonlib` 0.1.21. It is an implementation overview for developers maintaining the composition or adding a built-in provider. The corresponding Commonlib source was inspected at commit `e770f617ff0fc88f4823226b0ab3aefdff50cc1e`.
## Status, scope, and source-of-truth boundary
The plug-in repository is the source of truth for host composition, host scheduling, Obsidian/CLI/WebApp/WebPeer integration, provider declarations, and host-owned resource adapters. Commonlib is the source of truth for the provider contract, the active-publication state machine, typed replication runners, P2P service ownership, and Journal transport primitives. This repository consumes Commonlib as the published `0.1.21` package; it does not maintain a source mirror or a generated fallback.
The implementation has three built-in providers:
- CouchDB, composed by this repository;
- Object Storage, composed by this repository; and
- P2P, composed by Commonlib's `useP2PReplicatorFeature` in each application runtime which supports it.
The catalogue is closed at host composition. `src/common/replicatorProviders.ts` exhaustively composes the central providers, while the Commonlib P2P feature composes its P2P provider. This is not a runtime third-party registry: a provider cannot be added by registering a name, loading a plug-in, or supplying a setting at runtime. Adding a provider means changing the relevant Commonlib and host composition, then shipping and testing that composition.
The three capability ADRs record the decisions which led to this shape. They are useful decision history, but this document describes the current implementation and its ownership boundaries rather than repeating the ADR sequence.
## Terminology
The [Project glossary](../glossary.md#developer-and-design-terms) is canonical
for project-specific vocabulary in this document. In particular, see
[Replicator](../glossary.md#replicator),
[Replicator provider definition](../glossary.md#replicator-provider-definition),
[Active publication](../glossary.md#active-publication),
[Admission and reservation](../glossary.md#admission-and-reservation),
[Configuration identity](../glossary.md#configuration-identity),
[Capability](../glossary.md#capability),
[Central remote](../glossary.md#central-remote),
[Publication retirement](../glossary.md#publication-retirement), and
[Fence, generation, and epoch](../glossary.md#fence-generation-and-epoch).
The glossary also fixes the ownership meanings of
[Adjunct P2P transport](../glossary.md#adjunct-p2p-transport),
[Remote resource and probe](../glossary.md#remote-resource-and-probe),
[Non-owning adapter](../glossary.md#non-owning-adapter),
[P2P service, room session, and demand](../glossary.md#p2p-service-room-session-and-demand),
[Interaction authority](../glossary.md#interaction-authority),
[Journal remote epoch](../glossary.md#journal-remote-epoch),
[Replication outcome](../glossary.md#replication-outcome), and
[Suspension](../glossary.md#suspension).
The user-facing glossary also distinguishes
[OneShot Sync from Continuous replication](../glossary.md#user-facing-and-operational-terms).
Names shown in code font are source identifiers, not additional prose terms.
## Ownership and topology
The portable topology below names ownership rather than merely call order. An arrow means that the object or layer composes, invokes, or owns the next item.
```text
Application composition
Obsidian main / CLI / WebApp --> LiveSyncBaseCore --+
WebPeerRuntime ------------------------------------+--> Service Hub
|
+----------------------------------------+---------------------+
| |
v v
ReplicatorService (Commonlib) ReplicationService (Commonlib)
| |
+--> closed provider catalogue +--> typed replication runners
| CouchDB / Object Storage / P2P | readiness + outcomes
| |
+--> active publication <------ exact-publication admission ---+
provider + instance + identity
useP2PReplicatorFeature (Commonlib)
|
+--> registers the P2P provider, whose factory creates non-owning active adapters
|
+--> stable P2P service views and lifecycle
|
+--> P2PRoomSessionOwner
|
+--> P2PAutomationCoordinator
+--> current P2PRoomSession
|
+--> P2PHost / TrysteroReplicator
|
+--> Trystero room, relays, and physical peers
```
`LiveSyncBaseCore` receives a Service Hub, registers the central provider definitions, composes `serviceFeature` functions, and retains only focused views. `WebPeerRuntime` composes directly over its browser Service Hub because it is a P2P-only host. The P2P feature is composed by each supporting application runtime, so the CLI and web applications can use the same transport ownership without making the active adapter a second owner.
| Owner | Responsibility | Explicitly does not own |
| --- | --- | --- |
| Host composition | Selects the closed provider catalogue, supplies host adapters, and registers features before lifecycle work begins. | Active-instance retirement or operation admission. |
| Commonlib `ReplicatorService` | Provider registration, active publication, exact-publication reservations, serial lifecycle transitions, transfer stop during suspension or retirement, and physical close. | Replication readiness, trigger policy, or P2P room ownership. |
| Commonlib `ReplicationService` and its typed coordinator | User and unattended OneShot dispatch, Continuous startup, readiness, interaction authority, finite-activity accounting, outcomes, and failure hand-off. | Active publication replacement or scheduling policy. |
| Host replication scheduling `serviceFeature` | Decides when resume, Periodic, and Continuous requests may run, and fences stale scheduled work. | Provider construction, transfer mechanics, or active Replicator close. |
| Provider definition and runner | Declares what one remote kind supports and adapts its transfer results to typed outcomes. | Selecting when a request should run. |
| Stable P2P service and room-session owner | P2P demand, binding reconciliation, session retirement, finite room operations, automation state, and focused views. | The active P2P adapter's publication lifetime or Trystero's physical peers. |
## Request flow
1. Composition registers the central provider definitions before lifecycle-driven provider initialisation. P2P composition registers its own Commonlib provider and returns stable P2P views and lifecycle controls.
2. `ReplicatorService` serialises setting realisation, active-provider initialisation, database lifecycle transitions, suspension, and unload. `ReplicationService` owns per-request readiness. Resume work is separately owned by the host scheduling feature and the P2P service lifecycle.
3. `ReplicatorService` resolves the current `remoteType`, checks `isConfigured`, and computes the provider's opaque configuration identity. If the provider and identity are unchanged, the active publication is retained. If either changes, the replacement fence runs before a new publication is created.
4. A typed operation such as `runUserInitiated`, `runUnattended`, or `startContinuous` acquires the current publication after earlier queued lifecycle transitions have settled. It checks interaction authority, capability support, and readiness in the order required by the request type, then takes an immutable settings snapshot.
5. The operation admits a reservation against the exact publication and identity. The provider-specific runner owns the transfer; finite operations are counted as bounded remote activity, while continuous replication is not.
6. Provider resources are created through the declared resource factories when a feature needs a connection, preferred-tweak, Security Seed, or synchronisation-information probe. Resource ownership is explicit, and owned resources are disposed in the caller's `finally` path.
7. The runner returns a `ReplicationOutcome` rather than using `undefined` as a success signal. Documents delivered through `parseSynchroniseResult` are queued by the host result processor for local application; central compatibility recovery and Security Seed preflight remain host features around the typed operation.
8. Automatic `database-event`, `editor-save`, `file-open`, `merge`, `resume`, `periodic`, and `daemon` requests select unattended authority explicitly. Unattended work cannot prompt, select a peer interactively, or silently fall back to a legacy capability.
The active publication may change while a request is being prepared. The reservation keeps the admitted old instance alive until the operation settles; a new request admitted after the queued transition observes the new publication. A callback which holds a reservation must not await a lifecycle transition which itself waits for that reservation.
## Provider contract and current capability matrix
The Commonlib contract is deliberately small. A provider definition supplies the following shape (with the exact generic types omitted here for readability):
```typescript
{
kind,
diagnosticName,
readiness,
isConfigured(setting),
configurationIdentity(setting),
create(setting),
remoteResources,
centralRemoteAdministration?,
userInitiatedOneShot,
unattendedOneShot,
continuous,
stopActiveTransfer
}
```
`defineReplicatorProviderDefinitions` makes the definition map exhaustive for the selected `RemoteType` tuple and rejects duplicate, missing, extra, or mismatched runtime definitions. Capability declarations are explicit. `supported` adapts a provider runner to `ReplicationOutcome`; `not-implemented` and `not-applicable` produce typed blocked outcomes. The contract distinguishes user authority from `NO_INTERACTION`, so a provider cannot accidentally prompt from an unattended trigger.
The factory receives the fully merged effective settings. It returns a `ReplicatorInstance` with only four required lifecycle methods:
| Method | Contract |
| --- | --- |
| `initializeDatabaseForReplication()` | Prepare local state before publication. `false` rejects and disposes the candidate. |
| `openReplication(setting, keepAlive, showResult, ignoreCleanLock)` | Retained compatibility entry point for finite or Continuous work. A typed provider runner must convert its `void` or Boolean settlement to an explicit outcome; `void` is not finite success. |
| `terminateSync()` | Request cancellation of active transfer work and settle synchronously or asynchronously. It does not transfer ownership or replace physical close. |
| `closeReplication()` | Release resources owned by this instance. It runs only after admitted work drains; a non-owning adapter, such as P2P, must leave service-owned resources alone. |
Provider-specific methods remain on provider-specific interfaces or host-owned adapters. They are not added to the generic contract merely because an old compatibility class exposed them.
### Current capability matrix
| Capability | CouchDB | Object Storage | P2P |
| --- | --- | --- | --- |
| Readiness | Central remote preparation required | Central remote preparation required | Central preparation not applicable; peer readiness is provider-owned |
| Active Replicator factory | `LiveSyncCouchDBReplicator` | `LiveSyncJournalReplicator` | `P2PActiveReplicatorAdapter` over the stable P2P service |
| User-initiated OneShot | Supported | Supported | Supported, with explicit peer selection |
| Unattended OneShot | Supported | Supported | Supported for configured targets; no peer-selection prompt |
| Continuous replication | Supported | Not applicable | Not applicable |
| Stop active transfer | Supported | Supported | Supported; cancels finite operations while retaining the room when appropriate |
| Connection resource | Supported | Supported | Not applicable |
| Preferred-tweak resource | Supported | Supported | Not applicable |
| Security Seed resource | Supported | Supported | Not applicable |
| Synchronisation-information resource | Supported | Not applicable | Not applicable |
| Central remote administration | CouchDB administration supported | Object Storage administration supported | Not applicable |
The matrix is the current host composition, not a promise that every provider must support every row. A new provider must declare every resource kind and every operation capability, using `not-applicable` where the concept does not exist. Central remote administration is a cohesive capability: it covers the applicable verification milestone and mark-resolved, lock, and unlock mutations rather than exposing individual legacy helpers as generic operations.
## Active Replicator lifecycle and exact replacement fence
Commonlib's `ReplicatorService` serialises lifecycle transitions on one queue. It owns the active publication, reservations against that publication, transfer stop, and final close. The effective configuration identity is deliberately opaque; comparing it is valid, but inspecting or persisting it is not.
```text
absent
|
| configured lifecycle initialisation
v
candidate (private and not admitted)
| \
| initialised and current \ failed or stale --> closed; no active publication
v
active publication
| \ same provider and identity --> retained
| \ suspension ----------------> transfer stopped; publication retained
|
| provider, identity, database, or terminal lifecycle change
v
quiescing (removed from current; new admission fenced)
|
| stop --> drain exact reservations --> close --> complete retirement
v
absent --> optional replacement candidate
```
For a provider or effective-configuration change, the exact fence is:
1. Enqueue the transition on the serial lifecycle queue.
2. Read the current setting, resolve the closed provider definition, check `isConfigured`, and compute the effective identity.
3. If the current publication has a different provider or identity, call `beginRetirement`. This stops new reservations and removes the publication from the current slot.
4. Request the provider's `stopActiveTransfer` capability. The legacy `terminateSync` path is retained only for an untyped compatibility publication.
5. Await settlement of reservations admitted from the retiring publication. New operations cannot enter it.
6. Call the old instance's `closeReplication`.
7. Mark the retirement complete. No publication is installed between removal of the old publication and this completion.
8. Create a candidate through the provider definition, reset provider statistics, and yield the required microtask boundary.
9. Initialise the candidate for the current database and run `onBeforeReplicatorPublication` handlers.
10. Re-read the current setting, provider, and opaque identity. If any is stale, dispose the candidate and publish nothing; the queued lifecycle work will resolve the newer state.
11. Publish the provider, candidate instance, and identity atomically as the new active publication.
The same provider and identity retain the current publication. Database initialisation is a lifecycle boundary even when the setting identity is unchanged: the old publication is retired before the physical local database is replaced, then a candidate is created for the new database. A failed or stale candidate leaves the service without an active publication; it is not silently substituted with a previous instance.
If transfer stop fails, retirement still proceeds to draining and physical close. If physical close rejects, retirement remains fenced and no replacement can be published; a later serial transition may retry the same retirement. The service never restores admission to a publication once retirement has begun.
## Generation and epoch fences
These values protect different state machines. They must not be collapsed into one general-purpose generation.
| Fence | Owner and representation | Changes when | Protects | Does not protect |
| --- | --- | --- | --- | --- |
| Active publication identity | Commonlib `ActiveReplicatorPublication` object, containing provider, instance, and opaque configuration identity; not a numeric counter | Provider/configuration replacement, or a database lifecycle replacement | Admission and completion against the exact active instance | Delayed scheduling, P2P automation baselines, or Journal remote-wipe decisions |
| Host scheduling lifecycle generation | `ReplicationSchedulingContext.lifecycleGeneration` | Scheduling resumes after the lifecycle was disabled | Resume operations and timer callbacks from a previous host scheduling lifecycle | Provider replacement, P2P room callbacks, or Journal transfers |
| P2P service lifecycle generation | Private `P2PServiceState.lifecycleGeneration` | Explicit disconnect or host lifecycle closure | Delayed P2P AutoStart and service-level automatic demand | A room's operation set, automation baseline, or remote Journal epoch |
| P2P session object / epoch fence | The current `P2PRoomSession` object, its `acceptingOperations` flag, session abort signal, and the owner's lifecycle queue; there is no exported numeric P2P session epoch | Room binding replacement, retirement, or owner close | Room callbacks, finite operations, peer handlers, and stale candidate sessions | Cross-session automation deduplication and host scheduling |
| P2P automation generation | `P2PAutomationCoordinator.generation` | `beginLifecycle` or effective identity reconciliation (namespace or database object) | Completed peer baseline publication and stale automation completions | Room ownership, explicit disconnect veto, and physical peer connection ownership |
| Journal stop generation | `LiveSyncJournalReplicator.journalTransferStopGeneration` | `terminateSync` requests a stop | Admitted Journal transfers after setup and before `client.sync`; repeated stops share settlement | Provider publication identity and remote checkpoint/cache identity |
| Journal remote epoch | `CheckPointInfo.journalEpoch`, derived as `protocolVersion:pbkdf2salt` | Successfully read sync parameters yield a different value; a subsequent history probe decides whether checkpoint caches must be reset | Journal checkpoint and deduplication-cache reconciliation across remote histories | Local cancellation, provider retirement, or transfer admission |
In particular, a numeric value in one row cannot be used as evidence that an operation in another row is current. Failure to read Journal sync parameters does not produce a new remote epoch, and a P2P transport replacement does not by itself clear the automation coordinator's completed-peer baseline.
## Suspension, terminal retirement, and database replacement
| Event | `ReplicatorService` publication | P2P service and adapter | Result |
| --- | --- | --- | --- |
| Application suspension | Requests `stopActiveTransfer` and retains the active publication | `closeForLifecycle` closes the current room/session and invalidates delayed automation; the active adapter is non-owning and does not close the service through `closeReplication` | Suspension is reversible. Resumption schedules the appropriate P2P AutoStart and host replication work. |
| Provider setting or effective identity change | Runs the complete replacement fence | Reconciles or replaces the P2P room when its effective binding changes | The old publication/session cannot receive new work. |
| Database replacement or rebuild | Retires and closes the active publication before physical database teardown; database-ready events permit reinitialisation | Closes the P2P room before database destruction and creates a binding for the new database object | No provider or room may retain the old database. |
| Unload or terminal lifecycle close | Stops, drains, closes, and completes retirement; no active publication remains | `closeForLifecycle` clears owner demand, closes the current room, and invalidates automation | Terminal retirement is not resumed. |
Suspension and retirement therefore have different guarantees. `ReplicatorService` suspension stops transfer but intentionally retains the provider instance and publication. Terminal retirement removes admission, drains it, closes it, and does not publish a replacement unless a later lifecycle event explicitly initialises one. P2P transport is additionally closed on suspension because its service lifecycle owns a room session, but the active P2P adapter is only a compatibility handle and does not own that session.
## Owned resources and probes
| Resource or probe | Owner | Lifetime and disposal rule |
| --- | --- | --- |
| Active Replicator publication | Commonlib `ReplicatorService` | Publication retirement fences admission, drains reservations, calls `closeReplication`, and completes the retirement. |
| Central connection probe | Host resource factory in `src/common/replicatorResources/connection.ts` | A caller-owned CouchDB or Object Storage snapshot backed by a concrete Replicator/connection; dispose it in `finally`. It does not replace the active publication. |
| Preferred-tweak probe | Host `preferredTweak` resource factory | Read through the declared resource, then dispose the owned resource. |
| Security Seed probe | Host `securitySeed` resource factory and the replication preflight | Use `createRemoteResource` and `withOwnedRemoteResource`; reject an empty seed and always dispose the resource. It does not assume that an active Replicator exists. |
| Synchronisation-information probe | Host `synchronisationInformation` resource factory | Check or read through the resource and dispose it; an unavailable remote is not treated as a confirmed absence. |
| Central administration operation | Provider administration runner | Uses its declared verification and mutation ownership. A fresh connection may be owned by the operation; an active Journal client borrowed from the active provider is not disposed by the borrower. |
| Journal client and transfer set | `LiveSyncJournalReplicator` | The Journal Replicator owns the client and active transfer promises. `terminateSync` requests stop and awaits the shared settlement; `closeReplication` disposes the client without lazily creating one. |
| P2P room/session, finite operation set, and relay actions | `P2PRoomSessionOwner` and current `P2PRoomSession` | The owner serialises binding and demand changes. Session retirement rejects admission, aborts and awaits operations, disables broadcast, and disposes the session Replicator. |
| Physical Trystero peer connections | Trystero runtime | Commonlib must not close raw `room.getPeers()` connections merely because a logical room session retires; shared Trystero ownership may outlive an idle room callback. |
| Physical local database | Commonlib DatabaseService | Database lifecycle owns teardown and readiness. Replicator and P2P owners close before the database is destroyed. |
Probe callers must use the resource capability rather than reaching through `LiveSyncBaseCore.replicator`. This keeps a short-lived observation from acquiring ownership of the active transfer or publication.
## P2P special ownership and the non-owning adapter
P2P is composed as a `serviceFeature` and has more state than a central provider. It can remain enabled as an adjunct while CouchDB or Object Storage is the selected main remote; `ReplicatorService` publishes the P2P active adapter only when `remoteType` is P2P. The active-publication owner and the room-session owner are therefore deliberately independent.
The stable service owns persistent demand (`explicit`, `automatic`, or `rebuild-continuation`), finite-operation demand, the lifecycle queue, the effective binding, and the current room session. The room owner compares the local database object separately and includes the effective device name in its binding signature, so the binding is not interchangeable with the P2P provider's active-publication configuration identity. The automation coordinator owns automation-baseline deduplication. The current `P2PRoomSession` owns one room, peer handlers, advertisements, RPC, session cancellation, and finite operations. Trystero owns shared relay clients and physical peer connections.
`P2PActiveReplicatorAdapter` implements the minimal `ReplicatorInstance` view required by the provider contract. Its `initializeDatabaseForReplication`, `openReplication`, and `terminateSync` methods delegate to the P2P service. Its `closeReplication` is intentionally a no-op: closing the active adapter must not close the room, relay actions, finite-operation registry, or stable P2P service. The service lifecycle (`closeForLifecycle`, owner close, or binding reconciliation) is the only owner which retires the room.
The P2P connection probe follows the same boundary. An active compatible room may be observed; an incompatible active binding blocks the probe. An idle probe can run a caller-owned trial through the owner queue and must await clean-up. It must not publish itself as the active room or close resources owned by another session.
Automatic configured-target replication acquires finite room demand, waits for peer advertisements within a bounded window, evaluates admission without prompting, shares the baseline through `P2PAutomationCoordinator`, and returns an explicit completed, partial, blocked, cancelled, or failed outcome. Explicit disconnect veto remains distinct from host lifecycle closure. AutoStart cannot clear an explicit disconnect veto; rebuild continuation is a separately authorised path.
## Adding a built-in provider
Provider work crosses the Commonlib package boundary and this repository's host composition. The following sequence is the smallest complete path; omit a step only when the provider genuinely has no corresponding concept.
### First decide whether this is a provider
A new provider is appropriate when a remote kind needs a distinct active Replicator lifecycle, effective configuration identity, readiness policy, or replication roles. A new S3-compatible service or another backend which retains the Object Storage Journal protocol is usually an `IJournalStorage` adapter instead; see [Journal Replicator 2nd Edition](../design_docs_of_journalsync_2nd.md). A new read-only observation over an existing provider is usually a remote resource or a focused view. Neither case needs another active provider.
1. **Define the canonical remote kind in Commonlib.** Add the `RemoteType` value and its setting type in `src/common/models/setting.const.ts` and `src/common/models/setting.type.ts`, update exports such as `src/common/types.ts`, and add defaults or persistence fields only where the provider needs them. Add focused setting and migration tests.
2. **Implement the Replicator in Commonlib.** Place the provider-specific Replicator and transport code under `src/replication/<provider>/`. Implement the minimal `ReplicatorInstance` contract, cancellation, and close semantics. Keep provider-specific operations on focused facets. Add unit tests for success, cancellation, failure, stop, and replacement-sensitive clean-up.
3. **Define effective configuration identity.** Include every setting which changes the live binding, and exclude profile labels or policy-only settings. Normalise two spellings only when the runtime genuinely treats them as equivalent. Put shared identity logic in Commonlib when the provider is shared there; put the host projection in `src/common/replicatorConfigurationIdentity.ts` when this repository owns it. Test that equivalent effective settings retain an instance and binding changes replace it. Never expose identity values in logs, UI, or persistence.
4. **Add configuration and setup seams in Commonlib.** If the provider has a connection string, profile, migration, or document representation, update the applicable files, including `src/common/ConnectionString.ts`, `src/remoteConfigurations.ts`, `src/common/configForDoc.ts`, `src/API/processSetting.ts`, and their focused tests. These files are conditional: do not add a setting representation which the provider does not need.
5. **Declare the provider contract surface at its composition owner.** Add a central provider to the tuple and definition map in this repository's `src/common/replicatorProviders.ts`; add a P2P-like provider to the closed tuple owned by its Commonlib `serviceFeature`. In the definition, declare readiness, all four remote-resource capability entries, user and unattended OneShot runners, Continuous where applicable, `stopActiveTransfer`, and central administration where applicable. Extend `RemoteResource` kinds only for a genuinely cross-provider resource; do not encode provider-specific helpers as generic capabilities.
6. **Implement stateful transport ownership, if required.** For a P2P-like provider, add a stable service owner, focused views, lifecycle ownership, binding identity, session retirement, and automation fences in Commonlib, then compose it through a `serviceFeature`. Keep any active adapter non-owning if the service owns a replaceable transport. Add lifecycle, stale-callback, probe, and database-replacement tests before host integration.
7. **Compose the provider in each supporting application.** Central definitions are registered by `LiveSyncBaseCore`. A dedicated stateful feature must be composed from the applicable hosts: `src/main.ts`, `src/apps/cli/main.ts`, `src/apps/webapp/WebAppRuntime.ts`, and `src/apps/webpeer/src/WebPeerRuntime.ts`. Each selected catalogue must remain exhaustive and closed; add no runtime provider registry.
8. **Add host-owned resources and administration.** Add or extend `src/common/replicatorResources/` for connection, preferred-tweak, Security Seed, and synchronisation-information probes. Add provider-specific central verification and mutations in `src/common/centralRemoteAdministration.ts` when applicable. Test ownership, snapshots, the distinction between unavailable and absent states, postconditions, and disposal.
9. **Integrate setup and user-facing configuration.** Update the relevant setup dialogue files under `src/modules/features/SetupWizard/dialogs/`, including `dialogs/setupDialogTypes.ts`, SetupManager or setup features, remote configuration handling, and message resources under `src/common/messagesYAML` plus generated baked messages where required. Follow the terminology and settings mappings in the repository documentation, and add setup, serialisation, and migration tests.
10. **Integrate host operations and triggers.** Adapt only the capability call sites which the provider supports. Check `src/serviceFeatures/replicationScheduling.ts`, `src/serviceFeatures/replication/`, CLI commands under `src/apps/cli/commands`, and application-specific lifecycle composition. Ensure unattended paths use `NO_INTERACTION`, periodic and resume fallback obey capability outcomes, and no caller reaches for `getNewReplicator` or the `LiveSyncBaseCore.replicator` compatibility getter.
11. **Validate the package boundary.** In Commonlib, run its focused unit tests, build or pack the exact candidate artefact, and test the downstream LiveSync consumer against that artefact. In this repository, run provider map, configuration-identity, resource, central-administration, scheduling, and replication-feature unit tests. Add a real remote integration test, CLI E2E coverage, or real Obsidian E2E coverage for every boundary the provider claims to support.
A provider is complete only when its source ownership, replacement fence, capability matrix, setup path, and tests agree. Updating a setting type or adding a class without adding the closed composition definition does not make it a built-in provider.
## Compatibility seams and non-goals
- `ReplicatorService.getNewReplicator`, `getActiveReplicator`, and the `LiveSyncBaseCore.replicator` getter remain compatibility seams for existing callers. Beyond `ReplicatorInstance`, `LiveSyncBaseCore.replicator` exposes provider-specific members only as an optional compatibility view. None is a new provider extension point.
- `ReplicationService.performReplication` remains a direct legacy path through the active instance. New call sites use `replicateUserInitiated`, `replicateUnattended`, `replicateUnattendedByEvent`, `startContinuous`, or `stopActiveTransfer`, as appropriate.
- `LiveSyncAbstractReplicator` and other legacy classes may retain methods needed by existing modules. New features use typed provider capabilities, resource factories, and focused service views; they do not infer capabilities from a large legacy class.
- The generic contract does not unify directional Journal operations, Streaming replication, Chunk retrieval, remote-size inspection, garbage collection, repair workflows, or provider-specific administration. Those remain explicit provider or host features.
- The provider catalogue is not a public runtime registry, dynamic plug-in API, or settings-driven discovery mechanism. Unknown `RemoteType` values are composition/configuration failures, not third-party providers which the runtime should load.
- Commonlib remains an external authoritative package. This repository must not recreate `src/lib`, `_types`, or another source mirror to bypass the package boundary.
- P2P logical room retirement does not authorise closing shared raw `RTCPeerConnection` objects. Physical transport ownership remains with Trystero.
- No numeric P2P session epoch is exported. Session object identity, admission flags, abort signals, and the owner queue provide the fence; the P2P automation generation and the host scheduling generation protect different concerns.
- Suspension is not a database replacement or a successful replication result. It stops or closes the appropriate active work and relies on the next lifecycle event to resume, reconcile, or retire it.
## Source map
### This repository
| Concern | Source and tests |
| --- | --- |
| Host composition and compatibility boundary | [`LiveSyncBaseCore.ts`](../../src/LiveSyncBaseCore.ts), [`main.ts`](../../src/main.ts), [`src/apps/cli/main.ts`](../../src/apps/cli/main.ts), [`WebAppRuntime.ts`](../../src/apps/webapp/WebAppRuntime.ts), [`WebPeerRuntime.ts`](../../src/apps/webpeer/src/WebPeerRuntime.ts) |
| Closed central provider map | [`replicatorProviders.ts`](../../src/common/replicatorProviders.ts), [`replicatorProviders.unit.spec.ts`](../../src/common/replicatorProviders.unit.spec.ts) |
| Provider identities and resources | [`replicatorConfigurationIdentity.ts`](../../src/common/replicatorConfigurationIdentity.ts), [`replicatorResources/`](../../src/common/replicatorResources/index.ts), [`replicatorResources.unit.spec.ts`](../../src/common/replicatorResources.unit.spec.ts) |
| Central administration and preflight | [`centralRemoteAdministration.ts`](../../src/common/centralRemoteAdministration.ts), [`centralRemoteAdministration.unit.spec.ts`](../../src/common/centralRemoteAdministration.unit.spec.ts), [`replication/preflight.ts`](../../src/serviceFeatures/replication/preflight.ts) |
| Host scheduling and replication feature | [`replicationScheduling.ts`](../../src/serviceFeatures/replicationScheduling.ts), [`replicationScheduling.unit.spec.ts`](../../src/serviceFeatures/replicationScheduling.unit.spec.ts), [`replication/index.ts`](../../src/serviceFeatures/replication/index.ts) |
| Service graph and bounded local activity | [`ObsidianServices.ts`](../../src/modules/services/ObsidianServices.ts), [`ObsidianServiceHub.ts`](../../src/modules/services/ObsidianServiceHub.ts) |
| Architecture guidance | [`devs.md`](../../devs.md), [Service feature and legacy Module boundaries](service_feature_and_legacy_module_boundaries.md), [Project glossary](../glossary.md), [Documentation style and vocabulary conventions](../terms.md), [`docs/settings.md`](../settings.md), [`docs/troubleshooting.md`](../troubleshooting.md) |
### Commonlib 0.1.21
The exact package tree described here is [pinned at commit `e770f617ff0fc88f4823226b0ab3aefdff50cc1e`](https://github.com/vrtmrz/livesync-commonlib/tree/e770f617ff0fc88f4823226b0ab3aefdff50cc1e). The source and design-document links below target that commit.
| Concern | Commonlib source or design document at the pinned commit |
| --- | --- |
| Provider contract, outcomes, identities, and resource capabilities | [`src/replication/ReplicatorInstance.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/ReplicatorInstance.ts), [`src/replication/ReplicatorProvider.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/ReplicatorProvider.ts), [`src/replication/RemoteResource.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/RemoteResource.ts), [`src/replication/CentralRemoteAdministration.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/CentralRemoteAdministration.ts), [`src/replication/CentralCompatibility.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/CentralCompatibility.ts) |
| Active publication and typed operations | [`src/services/base/ReplicatorService.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/services/base/ReplicatorService.ts), [`activeReplicatorState.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/services/base/ReplicatorService.activeReplicatorState.ts), [`typedReplication.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/services/base/ReplicationService.typedReplication.ts), [`readiness.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/services/base/ReplicationService.readiness.ts) |
| P2P service, room ownership, and automation | [`P2PService.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/p2p/P2PService.ts), [`P2PRoomSessionOwner.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/trystero/P2PRoomSessionOwner.ts), [`P2PRoomSession.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/trystero/P2PRoomSession.ts), [`useP2PReplicatorFeature.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/trystero/useP2PReplicatorFeature.ts), [`P2PAutomationCoordinator.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/trystero/P2PAutomationCoordinator.ts) |
| P2P lifecycle design | [`docs/p2p-transport-lifecycle.md`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/docs/p2p-transport-lifecycle.md) |
| Database and service-feature lifecycle | [`docs/database-lifecycle.md`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/docs/database-lifecycle.md), [`docs/service-feature-composition.md`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/docs/service-feature-composition.md), [`docs/settings-lifecycle.md`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/docs/settings-lifecycle.md) |
| Journal transfer and remote epoch | [`LiveSyncJournalReplicator.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/journal/LiveSyncJournalReplicator.ts), [`JournalSyncCore.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/journal/JournalSyncCore.ts), [`JournalSyncTypes.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/journal/JournalSyncTypes.ts) |
| Journal storage adapter boundary | [`JournalStorageAdapter.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/journal/objectstore/JournalStorageAdapter.ts) |
### Decision records
- [Core provider contract and capabilities ADR](../adr/2026_08_replicator_capabilities_01_core_contract.md)
- [P2P service lifecycle ADR](../adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md)
- [Replicator migration plan ADR](../adr/2026_08_replicator_capabilities_03_migration_plan.md)
- [P2P Room and Transport Lifecycle ADR](../adr/2026_07_p2p_transport_lifecycle.md)
- [Bounded Remote Activity ADR](../adr/2026_07_bounded_remote_activity.md)
@@ -1,7 +1,7 @@
---
date: 2026-08-30
commonlib-version: "0.1.19"
self-hosted-livesync-version: "1.0.21"
date: 2026-09-04
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
@@ -34,8 +34,9 @@ Do not select `AbstractModule` or `AbstractObsidianModule` merely to obtain conv
3. construct and register built-in and host-supplied Modules;
4. compose the built-in Commonlib serviceFeatures;
5. compose host-supplied serviceFeatures;
6. construct add-ons; and
7. call `onBindFunction()` for each registered Module.
6. construct add-ons;
7. compose the late core serviceFeatures whose handlers must follow host features and add-ons; and
8. call `onBindFunction()` for each registered Module.
The Module constructor therefore runs before its handler bindings, while the complete Service Hub and ServiceModules already exist. `bindModuleFunctions()` then invokes every `onBindFunction()` and runs `__$checkInstanceBinding()`. That diagnostic compares underscore-prefixed prototype methods with method references found in the source text of `onBindFunction()`.
@@ -127,54 +128,31 @@ This split allows tests to verify:
The operation does not need an application Module identity.
### Ordered start-up composition and registration-only features
Configured Vault admission and the checks which follow database preparation are composed by `src/serviceFeatures/startupLifecycle/`. The directory keeps onboarding admission, compromised-chunk inspection, incomplete-document repair, Config Doctor, and the obsolete bulk-send setting migration as separate operations. One feature composer owns their order and receives the compatibility-review wait operation explicitly; an individual operation does not call the composer.
The layout-ready admission handler uses priority 1. This preserves ordinary priority-0 host integration before admission, while keeping an unconfigured Vault outside the flag-file recovery handlers at priorities 5, 10, and 20, and the compatibility review at priority 30. Admission belongs to one plug-in process: an initially unconfigured process remains inert until setup restarts it, and declining the requested restart does not trigger an in-process reconfiguration. Changing an admitted process back to unconfigured retires its Config Doctor and incomplete-document repair request handlers. The handlers also recheck the current configured state and database readiness when invoked, so a pending restart cannot expose partially initialised or retired state. The first-initialise handler rechecks admission before retaining the established order after the file watcher has been started: database readiness, compromised chunks, incomplete documents, compatibility review, Config Doctor, and the bulk-send setting migration.
Command and ribbon registration are serviceFeatures for the same dependency-visibility reason, but they are not start-up migrations. The basic commands remain a host-neutral feature composed by `LiveSyncBaseCore`, while the replication ribbon remains an Obsidian-only feature composed by the Obsidian host. Both retain `onInitialise` registration so moving them out of the Module list does not make their effects run during construction.
### Private state and ordered handlers: target filters
Commonlib's `targetFilter.ts` keeps each cache or readiness gate in the factory which owns one predicate. `useTargetFilters()` constructs those predicates and registers them in their required order.
The state remains private to the composed feature. It does not become a `LiveSyncBaseCore` property or a ServiceModule merely because it persists across calls.
### Legacy example to improve when touched: conflict checking
### Implemented composition: conflict resolution
`ModuleConflictChecker` currently combines:
Conflict checking and resolution are composed for every host by `useConflictResolutionFeature`. The feature owns its `QueueProcessor` privately and registers the conflict Service handlers directly. Its operations receive explicit collaborators for settings, active-file state, database and storage access, replication, logging, and host events. No consumer locates a conflict Module or retains the queue.
- conflict policy decisions;
- two `QueueProcessor` owners;
- cancellation signalling;
- access to settings and active-file state; and
- registration into the conflict Service.
The scheduling queue remains one state owner. It publishes `conflictProcessQueueCount`, coalesces pending checks for the same path, and makes `ensureAllProcessed()` wait for conflict resolution to finish. Repeated resolver invocations for one path retain only the newest waiting request and close an active comparison for that path before waiting for the per-file resolver, while comparisons for other paths remain open. Resolution remains host-neutral and communicates dialogue cancellation through `services.context.events`, so CLI, WebApp, and Obsidian compositions use their own selected event channel.
Its queues are class fields which dereference `this.services` during field initialisation, and its public handlers are bound later in `onBindFunction()`.
Interactive resolution is a separate Obsidian-owned serviceFeature. It registers the manual conflict handler, commands, start-up scan, unresolved-message contribution, cancellation listener, and unload clean-up. Its postponed-conflict set, active dialogue, and dialogue queue are private, session-local state. Manual comparisons are shown one at a time: a request for the active file publishes `EVENT_CONFLICT_CANCELLED` to cancel and replace its dialogue, while a request for another file waits. A resolution received through replication closes an open dialogue for the resolved path through the same event, or discards its waiting request before a stale dialogue can open. On unload, the feature drops waiting requests and publishes the same event for the active path before the host event channel is retired, so the dialogue closes and its waiting operation completes. The feature receives a dialogue-opening adapter and connects to the common feature only through the conflict Service; it does not expose an Obsidian application or dialogue as a general capability.
A bounded change to this area should prefer a shape such as:
Both operation layers acquire the active local database through an operation-time accessor. Composition occurs before the database is opened, and a reset may replace the active instance, so retaining the database object at composition time would violate both start-up and reset boundaries.
```typescript
interface ConflictCheckContext {
readonly checkQueue: QueueProcessor<FilePathWithPrefix, unknown>;
readonly resolveQueue: QueueProcessor<FilePathWithPrefix, unknown>;
}
interface ConflictCheckDependencies {
readonly conflict: ConflictCapability;
readonly currentSettings: () => ConflictSettings;
readonly getActiveFilePath: () => FilePathWithPrefix | undefined;
readonly log: LogFunction;
}
function queueConflictCheck(
context: ConflictCheckContext,
dependencies: ConflictCheckDependencies,
path: FilePathWithPrefix
): Promise<void> {
// Make the decision and enqueue through explicit collaborators.
}
export function useConflictChecking(host: ConflictCheckingHost): void {
const context = createConflictCheckContext(host);
host.services.conflict.queueCheckFor.setHandler((path) => queueConflictCheck(context, dependencies, path));
}
```
The exact extraction should be made only when conflict-checking behaviour changes. The example describes the intended ownership boundary; it is not a request to convert the Module in an unrelated documentation change.
`ConflictResolveModal` remains a focused class. One instance owns one dialogue's result promise, event subscription, and close lifetime, which is stable identity and resource ownership rather than application composition. This preserves the distinction between a useful object lifetime and a legacy Module used as a service locator.
## Interaction-based testing
+93
View File
@@ -0,0 +1,93 @@
---
date: 2026-09-08
commonlib-version: "0.1.24"
self-hosted-livesync-version: "1.0.27"
status: unreleased
---
# Tweak compatibility and recovery
This document describes the integration of Commonlib 0.1.24 with
Self-hosted LiveSync 1.0.27. Commonlib owns the interpretation of synchronisation
settings; LiveSync owns the dialogues and operations which consume that result.
## Shared assessment
`assessTweakCompatibility`, exported by Commonlib's `settings` entry point,
compares one snapshot of current settings with one snapshot of preferred settings.
The result contains the original values, effective values, differences, and
reconstruction consequences for each direction of adoption. It performs no
settings writes, translation, network requests, or database operations.
The interpretation of a missing value is specific to its setting. A missing
`handleFilenameCaseSensitive` means `false`, matching legacy conversion from paths to
document IDs. An explicitly enabled value is therefore different from either an
explicitly disabled value or a missing value. Settings whose historical missing
value has not been established remain unadvertised; the evaluator does not
invent defaults or turn every falsy value into an absent value.
The central replication gate, mismatch dialogues, and RedFlag Fetch preparation
consume the same effective differences. The P2P transport retains its separate
policy: ordinary representation differences warn without rejecting transfer,
while its existing passphrase and peer checks still apply. Sharing assessment
does not make the central replication policy appropriate for every transport.
## Host responsibilities
`ModuleResolvingMismatchedTweaks` renders the assessment supplied by the failed
attempt. A legacy recovery hint without an assessment is adapted through the
same Commonlib function. The remote profile review uses the trial settings as
its current snapshot, including when deciding whether compatible chunk settings
can be accepted automatically.
Each adoption direction has its own reconstruction consequence. The remote
values becoming local values can require local Fetch; the local values becoming
preferred remote values can require remote Rebuild. The host must not infer the
second consequence from the first. Existing explicit Fetch choices and manual
acceptance controls remain host decisions.
The common assessment identifies whether every known difference qualifies for
automatic alignment. LiveSync retains the opt-out and modification-time policy
which chooses a side. An unadvertised setting does not expand automatic
alignment to a case whose effect cannot be assessed.
Only defined, permitted settings are applied. A partial preferred configuration
must not erase a local value with `undefined`. Recommended settings outside the
set of settings which must match retain their existing adoption behaviour; RedFlag Fetch
continues to apply only the set of settings which must match. RedFlag Rebuild remains
authoritative from this device and does not adopt the remote configuration.
## Decision lifetime
An assessment describes one pair of inputs; it does not authorise a later
write. LiveSync checks the settings and active publication again after waiting
for a decision and before applying it. A changed target or changed settings
discard that decision. The signature used for this check can contain sensitive
configuration and must not be logged, persisted, or included in diagnostics.
The active publication reservation is not held while waiting for the dialogue.
Remote writes use the failed attempt's publication guard. Fetch and Rebuild use
their existing owners and propagate failure without claiming a successful
retry. A subsequent attempt must use fresh settings and respect publication
replacement rather than reusing the rejected attempt's settings snapshot.
Ordinary typed OneShot replication retains the failed outcome after its recovery
dialogue; the next synchronisation request is a separate attempt. Directional
replication can retry once after `CHECKAGAIN`, using freshly captured settings
and the same publication guard. Setting adoption does not turn the original
failed transfer into a completed transfer.
## Verification boundaries
Commonlib tests protect missing-value interpretation, representation differences,
directional consequences, immutable results, central admission, and P2P policy.
LiveSync unit tests protect ordinary versus reconstruction choices, trial-setting
selection, partial-setting adoption, and invalidated decisions. RedFlag tests
protect the distinction between adopting settings for Fetch and retaining local
settings for Rebuild.
Real-runtime verification must separately cover setting adoption, actual
replication, explicit Fetch, and replication after restart. A unit test which
mocks Fetch does not establish those behaviours, and a corrected mismatch
dialogue alone does not establish the cause of a reported persistent automatic
synchronisation failure.
+384
View File
@@ -0,0 +1,384 @@
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
# Project glossary
This glossary records stable, project-specific meanings used by Self-hosted
LiveSync. Ordinary English and established technology terms retain their usual
meanings unless they are defined here. Exact code identifiers, API names, and
user-interface labels retain their source spelling.
The sections describe the intended audience, not a visibility guarantee. A
term in the developer and design section can appear in code, tests, logs, or
diagnostics. That does not make it user-interface vocabulary or a public
extension contract.
## User-facing and operational terms
These terms can appear in the user interface, user documentation, setup and
recovery guidance, or diagnostics intended for users.
### AR
- **Boot-up sequence (boot sequence):** The initialisation process of the
plug-in when Obsidian starts. It begins with loading the plug-in, setting up
core services, loading saved settings, and opening the local database. After
the layout is ready, the plug-in checks for flag files, runs configuration
diagnostics, connects to the remote database, and begins file watching. The
sequence finishes when the plug-in is ready and operational.
- **Broken files (size mismatch):** A state where a file's Metadata and the
content stored in its Chunks do not match, causing file retrieval or
synchronisation failures. Inspect these mismatches with **Inspect conflicts
and file/database differences** in the Hatch pane, then handle one exact
revision at a time.
- **Chunk / Chunks:** Divided units of data stored in the database or Object
Storage to support efficient synchronisation.
- **Compaction:** A database maintenance procedure which discards old
historical document revisions to reduce remote database size.
- **Continuous replication:** A provider's long-running replication mode. It
remains active to exchange changes until stopped and is distinct from a
finite OneShot Sync operation.
- **Custom HTTP Handler / Use Internal API (CORS bypass settings):** Settings
which bypass CORS restrictions by routing requests through Obsidian's native
request APIs. There are separate settings for each central remote type:
- **S3-compatible Object Storage (`useCustomRequestHandler`):** Labelled
**Use Custom HTTP Handler** in the standard settings tab and **Use internal
API** in the Svelte Setup Wizard dialogue. It is represented as `useProxy`
in Setup URI query parameters for compatibility.
- **CouchDB (`useRequestAPI`):** Labelled **Use Request API to avoid
inevitable CORS problem** in the standard settings tab and **Use Internal
API** in the Svelte Setup Wizard dialogue. It is represented as
`useRequestAPI` in Setup URI query parameters.
- **Customisation Sync:** The feature which synchronises settings, snippets,
themes, and plug-ins. Write 'Customisation' with an 's' in documentation;
technical configuration and links can use `customization` where required.
- **Database Adapter (IDB and IndexedDB):** The local database storage
interface used by PouchDB. The `IDB` adapter is recommended because the older
`IndexedDB` adapter is obsolete and can cause memory leaks in LiveSync mode.
Switching adapters requires local data migration and an Obsidian restart,
but not a full database rebuild.
- **Database Suffix (`additionalSuffixOfDatabaseName`):** A suffix appended to
the database name so that multiple Vaults with the same name can synchronise
to the same remote server.
- **E2EE Algorithm:** The cryptographic algorithm version used for end-to-end
encryption. All synchronising devices must use a compatible version, such as
`V2` or `V1`.
- **Eden (Eden Chunks):** A sunset-compatibility optimisation in which newly
created Chunks are held inside the document until they stabilise, before
becoming independent Chunks.
- **Fast Setup (Simple Fetch):** The preferred automated initial
synchronisation flow for a secondary device. It uses Streaming replication
for the initial download and delays local file reflection to avoid temporary
synchronisation warnings.
- **Fast Fetch:** The CouchDB-specific Streaming replication path used by Fast
Setup. It reads the changes feed in bounded pages and persists a checkpoint
so an interrupted transfer can resume. An ineligible transport uses the
ordinary fetch path instead.
- **Flag files (`redflag.md`, `redflag2.md`, and `redflag3.md`):** Special
Markdown files or directories at the Vault root which stop the boot-up
sequence or trigger recovery work. `redflag.md` suspends all processes,
`redflag2.md` (`flag_rebuild.md`) triggers a full database rebuild, and
`redflag3.md` (`flag_fetch.md`) discards and fetches the local database again.
- **Garbage Collection (GC):** The maintenance process which identifies Chunk
documents not reachable from a current file or conflict branch, records
logical deletions for them, propagates those deletions, and requests remote
compaction to reclaim storage.
- **Hatch (Hatch pane):** The troubleshooting and maintenance section in the
plug-in settings. It contains diagnostics, database reset controls, status
reports, and advanced edge-case settings.
- **Hidden File Sync:** The feature which synchronises files in hidden
directories, such as `.obsidian`.
- **JWT Authentication:** An experimental CouchDB authentication option which
uses a JSON Web Token instead of standard credentials. It requires a private
key or secret, algorithm, expiry duration, subject, and key ID.
- **LiveSync:** This name has two established meanings: the shortened plug-in
name for Self-hosted LiveSync, and the Sync Mode for continuous, real-time
synchronisation. Prefer 'Continuous replication' in design documentation
when the mode, rather than the product, is meant.
- **livesync-serverpeer / WebPeer:** Specialised clients which assist WebRTC
peer-to-peer communication.
- **Metadata (file metadata):** A database document which stores file
properties, including its name, path, size, modification time, and references
to the Chunks containing its content. PouchDB or CouchDB revision metadata
carries conflict state; the file Metadata document has no separate history
field. Metadata and file content are stored separately.
- **OneShot Sync (OneShot replication):** One finite bidirectional
synchronisation operation, normally pull then push, which is requested
directly or by an event. It is distinct from Continuous replication.
- **Overwrite Server Data with This Device's Files:** A maintenance operation,
formerly named `Rebuild everything`, which discards the remote database and
rebuilds the local and remote databases from the current files on one
authoritative device.
- **Path Obfuscation:** A privacy option which encrypts file paths and folder
names on the remote server.
- **plug-in:** The spelling used in user-facing messages and general prose.
Retain `plugin` in code, configuration, and established technical names.
- **Remediation (`maxMTimeForReflectEvents`):** A recovery setting which limits
reflection of changes from the database to the Vault by ignoring file events
after a specified date and time.
- **Reset Synchronisation on This Device:** A maintenance operation, formerly
named `Fetch everything`, which discards the local database and rebuilds it
from the remote database.
### Revision
A revision is a version of one PouchDB or CouchDB document. Concurrent changes
can form a revision tree with more than one current branch.
Revision modifiers describe independent properties. More than one can apply to
the same revision:
- **leaf:** Has no known child revision.
- **winner:** Is the leaf selected by PouchDB or CouchDB as the current
document.
- **conflict:** Is another current leaf which was not selected as the winner.
- **Vault-matching:** Represents the same file content, or the same absent-file
state, as the current Vault. More than one revision can match.
- **displayed:** Is recorded by valid device-local file provenance as the
branch represented in the Vault. A pending local edit might no longer match
its bytes, but still extends this recorded branch.
- **logically deleted:** Represents absence of the file through a deletion
marker. A logically deleted revision can also be a leaf, winner, conflict,
or Vault-matching revision. An absent file retains no displayed provenance.
Avoid 'live revision' because it can mean either a current leaf or a
non-deleted revision. See
[Independent revision properties](specs_conflict_resolution.md#independent-revision-properties)
for the relationship between revision-tree roles, Vault state, and
device-local provenance.
### SZ
- **Scram (Scram Switches):** Emergency controls which suspend file watching or
database reflection to reduce the risk of corruption or unintended changes.
- **Security Seed:** The remote PBKDF2 salt used to derive the encryption key
for replication. It must be read from, or established on, the remote before
encrypted synchronisation.
- **Segmenter (Segmented-splitter):** A chunking method which divides files at
semantic boundaries, such as paragraphs or sections, rather than arbitrary
byte boundaries.
- **Self-hosted LiveSync:** The name of this plug-in. 'Self-hosted' is one
hyphenated word.
- **Setting Doctor (Config Doctor):** A diagnostic utility which identifies
configuration mismatches or suboptimal settings and presents recommended
values and reasons.
- **Setup URI:** An encrypted representation of plug-in settings and remote
configuration which can be transferred to another device and opened with a
passphrase.
- **Signalling relay (P2P):** A Nostr-compatible WebSocket relay used for peer
discovery and WebRTC connection negotiation. It does not store or transfer
Vault content. The project author operates a public relay as a best-effort
convenience, and users can supply another compatible relay.
- **Streaming replication (stream-based replication):** A transfer method
which downloads database documents as a continuous stream of events. Fast
Setup uses it to retrieve remote Metadata efficiently.
- **Sync Mode:** The trigger mechanism for synchronisation. Current modes are
**LiveSync**, for continuous replication, **Periodic Sync**, for work at a
configured interval, and **On Events**, for configured application events.
- **Synchronising devices:** Devices which participate in the same
synchronisation for a Vault. The term describes membership rather than
current activity, so it includes offline and idle devices.
- **TURN Server (WebRTC P2P):** A Traversal Using Relays around NAT server used
as an optional fallback when NAT or firewall rules prevent a direct WebRTC
connection. It relays encrypted WebRTC traffic and is distinct from the
signalling relay.
- **Update Thinning (Batch database update):** An optimisation which groups
local file edits over a short delay before committing them to the local
database, reducing database writes.
- **WebRTC P2P (peer-to-peer):** A synchronisation method which allows devices
to communicate directly without a central remote database.
## Developer and design terms
These definitions are stable vocabulary for architecture documents, ADRs,
implementation, tests, and code review. They might never appear in the user
interface. Inclusion here fixes their project meaning; it does not make the
named surface a public API or extension point.
### Active publication
The atomic publication of one Replicator provider, its `ReplicatorInstance`,
and its configuration identity, owned by Commonlib's `ReplicatorService`. Its
object identity is the admission fence for operations. An active publication
is also called the active Replicator publication, and its instance is the
**active Replicator**. 'Active publication' is more precise than 'current
Replicator' when admission or retirement matters.
### Adjunct P2P transport
P2P operating as an additional transport while CouchDB or Object Storage is
the selected main remote. It retains its own service and room-session
ownership; it is not the active Replicator for the main remote. Architecture
documents can shorten this to 'adjunct P2P' where the distinction is already
clear.
### Admission and reservation
**Admission** is permission for an operation to use one exact active
publication or P2P room session. A **reservation** records admitted work and
keeps its owner alive until that work settles. Retirement closes admission
before it waits for existing reservations, so later work cannot enter the
retiring generation.
### Bounded remote activity
A finite logical operation which can involve remote work, waiting, queueing, or
local result handling. Its lifetime is broader than an individual network
request. Continuous replication is not bounded remote activity. See the
[Bounded Remote Activity ADR](adr/2026_07_bounded_remote_activity.md).
### Capability
A typed declaration that a Replicator provider supports an operation or remote
resource, does not implement it, or considers it inapplicable. Capability
support is explicit; callers do not infer it from a legacy method, a Boolean
default, or a neutral return value.
### Central remote
A CouchDB or Object Storage remote which can require central preparation and
administration before replication. P2P is not a central remote. The **main
remote** is the `RemoteType` selected for the active Replicator; P2P can also
operate as an additional transport when a central remote is selected.
### Configuration identity
An opaque projection of the effective settings which determine whether an
existing provider instance or P2P binding can be retained. It can contain
credentials. Code can compare an identity for equality, but must not inspect,
log, persist, or display it.
### Fence, generation, and epoch
A **fence** prevents stale work or work admitted by one owner from affecting a
replacement owner or state. A **generation** normally changes when one local
lifecycle is invalidated. An **epoch** identifies one session or data history
where the owning contract uses that term. These values belong to distinct state
machines and are not interchangeable or evidence that another owner is
current.
### Focused view
A narrow interface exposing only the operations required by a consumer. It
delegates to a stable owner and does not independently own the underlying
mutable state or resource.
### Interaction authority
The explicit upper bound on user interaction permitted during an operation.
User-initiated work can receive selected permissions; unattended work carries
`NO_INTERACTION` and cannot open a dialogue, request peer selection, or obtain
authority through a fallback path.
### Journal remote epoch
The `protocolVersion:pbkdf2salt` value stored as
`CheckPointInfo.journalEpoch`. It identifies Journal checkpoint and
deduplication-cache history. It is data-history state, not a cancellation,
Replicator retirement, or operation-admission fence.
### Non-owning adapter
An adapter which implements a contract by delegating to another component
without owning the delegated resource. Closing it releases only resources
which the adapter itself owns. In particular, closing the active P2P adapter
does not close the stable P2P service or its room session.
### Owner and ownership
The **owner** is the single component responsible for creating, replacing,
stopping, and disposing a resource or stateful lifecycle. A borrower, adapter,
or focused view can use that resource only within its declared boundary and
must not perform the owner's lifecycle operations.
### P2P service, room session, and demand
The **P2P service** is the stable Commonlib owner which supplies focused views
and owns replaceable room sessions. A **P2P room session** is one active room
membership and the resources whose validity depends on it. **Demand** is one
persistent or finite reason for the owner to retain a room. Releasing one
demand does not close a room retained by another. A room's effective binding
includes the settings, local database object, and device identity which make
that session valid. A **session epoch** is the internal identity and fence of
one room-session object, not a persisted room name or a public numeric counter.
An **automation baseline** records peers for which the initial transfer
completed in the current logical automation lifecycle; it is owned
independently of a replaceable room session. A **configured target** is a
persisted peer name selected for unattended `P2P_SyncOnReplication`; the
request can wait for its advertisement, but cannot prompt for peer selection.
### Publication retirement
The lifecycle transition which removes an active publication from current
admission, asks its provider to stop transfer work, drains reservations for
that exact publication, closes the old instance, and marks retirement
complete. **Quiescing** is the state after admission has closed and before
retirement completes. A replacement cannot be published across an incomplete
retirement fence. A **candidate** is a newly constructed instance which remains
private until initialisation and freshness checks permit atomic publication.
### Remote resource and probe
A **remote resource** is a provider-declared, caller-owned object created from
one effective-settings snapshot for a bounded task. A **probe** is a bounded,
flow-specific validation or observation for connection, compatibility, setup,
or diagnostics. It can use an owned remote resource or an owner-arbitrated P2P
trial. It does not publish or replace the active Replicator, and the caller
disposes every resource which it owns.
### Replicator
The project abstraction which performs replication for one configured remote
kind and implements the `ReplicatorInstance` lifecycle contract. Use
'replication' for the process and 'Replicator' for this runtime abstraction. A
Replicator can be an owning transport implementation or a non-owning adapter;
the provider contract determines the boundary.
### Replicator provider definition
The exhaustive, host-composed declaration for one `RemoteType`: its
configuration identity, Replicator factory, readiness requirement,
capabilities, remote-resource factories, operation runners, and optional
central administration. The readiness requirement declares which layer must
establish operation preconditions. The provider catalogue is **closed
composition**, not a runtime registry: adding a provider requires changing,
shipping, and testing the owning composition. A **built-in provider** is one
included in that shipped catalogue. Architecture prose can shorten 'Replicator
provider definition' to 'provider'; it does not mean only the transport
instance.
### Replication outcome
The typed settlement of an attempted replication operation, represented by
`ReplicationOutcome`. Completed, partial, blocked, cancelled, and failed states
remain explicit; `undefined`, an empty value, or a compatibility default is not
treated as successful work.
### Service composition terms
- A **Service Hub** is the long-lived registry of service contracts for one
application composition.
- A **Service** owns a stable shared capability and its lifecycle.
- A **ServiceModule** is a host-created, long-lived stateful or resource-owning
capability shared through the typed `ServiceModules` record.
- A **serviceFeature** is a typed composition function which accepts declared
Services and ServiceModules, registers host integration, and can return a
focused view. It is not a runtime registry entry.
- A **legacy Module** is an existing application structure retained for
compatibility. New behaviour does not acquire the complete core merely to
imitate that locator pattern.
See [Service feature and legacy Module boundaries](design_docs/service_feature_and_legacy_module_boundaries.md)
for the selection and composition rules.
### Suspension
A reversible lifecycle action which stops active transfer work without
retiring the active Replicator publication. The P2P service also closes its
current room session during application suspension because that session has a
separate owner and lifecycle. Resumption can retain the Replicator instance and
open a new P2P room as required.
+37 -4
View File
@@ -18,7 +18,7 @@ flowchart LR
The signalling relay and TURN server have different roles:
- The **signalling relay** is required for peer discovery and connection negotiation. LiveSync uses Nostr-compatible WebSocket relays for this role. The relay does not store or transfer Vault contents.
- A **TURN server** is an optional fallback. WebRTC uses it to relay the encrypted peer connection only when the devices cannot establish a direct path through their networks.
- A **TURN server** is an optional fallback. WebRTC uses it to relay the encrypted peer connection when the devices cannot establish a direct path through their networks, or whenever **TURN relay only** is selected.
## The project's public signalling relay
@@ -40,16 +40,49 @@ Both settings contain server addresses, but they are not interchangeable.
| Setting | Required | Carries Vault contents | Purpose |
| --- | --- | --- | --- |
| **Signalling relay URLs** | Yes | No | Finds peers and exchanges the information needed to establish WebRTC connections. |
| **TURN server URLs** | Only when direct WebRTC connectivity fails | Encrypted WebRTC traffic | Relays traffic between peers when NAT or firewall rules prevent a direct path. |
| **TURN server URLs** | When direct WebRTC connectivity fails or **TURN relay only** is selected | Encrypted WebRTC traffic | Relays traffic between peers when NAT or firewall rules prevent a direct path. |
A TURN provider cannot read LiveSync's encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust. The project does not operate an official TURN service.
WebRTC encrypts data between the devices, including when it passes through TURN. The TURN provider cannot read the transferred data, but it can observe network addresses and traffic volume. This transport encryption also applies when LiveSync's optional database encryption is disabled. The project does not operate an official TURN service.
## TURN credentials
In **TURN configuration**, select **Manual** to enter your own TURN server URLs,
username, and credential, or select **Managed (Cloudflare)** to enter a **TURN Key ID** and
**TURN Key API Token**. Cloudflare is optional; the project does not require a
particular TURN provider or operate a credential broker. See Cloudflare's
[credential instructions](https://developers.cloudflare.com/realtime/turn/generate-credentials/)
for creating a TURN key and its API token.
The API token is saved with the P2P profile and included when sharing settings
through an existing Setup URI or QR code. Setup URIs retain their existing
passphrase encryption. QR codes retain their existing unencrypted format and
'FOR YOUR EYES ONLY' display. Missing provider settings use the ordinary manual
configuration defaults. Receiving clients need support for the selected provider
to acquire its temporary TURN credentials.
Markdown settings omit the connection profile group when it contains a managed
TURN provider, including inactive profiles, and importing those omitted settings
preserves this device's existing profiles. Diagnostic reports redact provider settings. The existing profile-URI
encryption also covers the saved token.
Each device requests temporary TURN credentials when opening a new room.
An existing room reuses its credentials while they remain valid. Cloudflare credentials have a requested lifetime
of 24 hours and remain in memory only. Expiry is checked when LiveSync next
reconciles the room connection. If necessary, it replaces the room and obtains
new credentials. There is no periodic renewal: if a long-lived room cannot
reconnect after credentials expire, disconnect and open the connection again.
Room replacement may interrupt replication. The next synchronisation keeps
received Metadata and Chunks, resumes from its saved checkpoint, and compares
revisions to fetch missing data. An unfinished network message can be sent
again. Automatic synchronisation follows the existing peer rules; after an
interrupted manual operation, use **Replicate now** again.
## Connection compatibility profiles
`P2P Configuration` includes a separate `Connection compatibility` section. Its defaults preserve the existing transport behaviour:
- **P2P message size** defaults to **Standard**. **Reduced**, **Conservative**, and **Maximum compatibility** progressively limit outgoing P2P messages when a network path appears to drop larger WebRTC messages. This is not a Vault Chunk size or an IP MTU. Smaller values add framing and processing overhead.
- **Connection path** defaults to **Automatic**, which lets WebRTC select a viable direct or TURN-relayed path. **TURN relay only** forces the encrypted connection through TURN and is available only when the profile contains at least one valid `turn:` or `turns:` URL.
- **Connection path** defaults to **Automatic**, which lets WebRTC select a viable direct or TURN-relayed path. **TURN relay only** forces the encrypted connection through TURN and is available when the profile contains a valid manual TURN URL or a configured TURN provider.
The sending device controls its outgoing message size. Select the same conservative preset on every device which may send across the constrained path. Existing devices do not receive the choice retrospectively merely because another device changed it.
+125
View File
@@ -2,6 +2,131 @@
This document contains earlier published releases from the 1.0 line of the [current Self-hosted LiveSync release history](../../updates.md). Beta and release-candidate builds published before 1.0.0 are recorded in the [1.0 preview history](1.0-previews.md). Earlier release lines continue in the [0.25 history](0.25.md) and the [legacy history](legacy.md).
## 1.0.23
2nd September, 2026
I am sorry to make this release while several pull requests are still awaiting merge, but I believe that the safeguards provided by this work are significant, so I have decided to release it. I will merge the remaining pull requests in turn. Thank you for bearing with me while I have been less active recently.
### Synchronisation and storage
#### Fixed
- **Sync now** once again keeps routine progress quiet, while still opening recovery dialogues when a decision is required. Repeated OneShot Sync requests received while an earlier attempt is running are now ignored instead of starting overlapping work.
## 1.0.22
1st September, 2026
I am sorry to make this release while several pull requests are still awaiting merge, but I believe that the safeguards provided by this work are significant, so I have decided to release it. I will merge the remaining pull requests in turn. Thank you for bearing with me while I have been less active recently.
### Synchronisation and storage
#### Fixed
- **Sync on Startup** now runs an immediate Object Storage synchronisation after start-up or resume, including migrated profiles which retain a Continuous setting that Object Storage cannot use.
- A temporarily unavailable Object Storage synchronisation-parameter read is no longer treated as a missing object and cannot regenerate the shared Security Seed. Flow-specific Security Seed checks also bypass an earlier process-cached result.
- Local database reset and plug-in unload now retire active replication through its owner before closing the database, without reporting a missing active Replicator or describing unload as a database reset.
- **Fresh Start Wipe** now reports an incomplete Object Storage deletion instead of announcing success, and releases its temporary storage client after each attempt.
### Peer-to-peer synchronisation
#### Fixed
- The P2P Setup connection test no longer interrupts an active P2P room. It observes an active compatible relay binding, blocks a test which would add another relay until P2P is disconnected, and uses a short-lived trial only while P2P is idle.
- User-initiated P2P synchronisation now reports success only after the requested target transfer completes.
- Optional WebApp P2P synchronisation now becomes ready after a successful local-file scan even when CouchDB remains unconfigured; failed preparation is not reported as ready.
- Unattended P2P synchronisation no longer raises Notice-level messages for missing configured targets, authentication rejection, configuration mismatch, or an overlapping transfer. User-initiated operations retain their existing feedback.
- P2P replication failure reasons now survive the JSON RPC boundary instead of reaching the requesting device as an empty object.
### Command-line interface
#### Fixed
- `mark-resolved`, `lock-remote`, and `unlock-remote` now return a non-zero exit code when the selected provider cannot verify the requested remote state. Use `--compat-remote-admin-exit-zero` to retain the former exit code for returned verification failures; unknown remote IDs and mutation errors still fail.
## 1.0.21
26th August, 2026
It is becoming more 'ordinary' with each release, but please let me know if anything has become less convenient.
### Interface and translation
#### Fixed
- Remote Configuration section headings no longer overlap their contents when scrolling on mobile. Action buttons in Remote Configuration, Maintenance, and Patches now remain inside the settings pane on narrow screens.
## 1.0.20
~~1.0.19~~ was cancelled because prerelease validation exposed an incorrect warning at start-up.
25th August, 2026
I know this is the second time I have said it, but I had grown quite fond of the settings screen. It seems, however, that a simpler, healthier life is called for.
### Interface and translation
#### Fixed
- Compatibility pause warnings now direct you to the dedicated compatibility review instead of the Change Log.
- The Obsidian 1.13 settings page now waits for saved settings before choosing its initial layout. This prevents a spurious missing-replicator warning at start-up, keeps configured devices on the Synchronisation-first layout even when automatic synchronisation triggers are disabled, and keeps Quick Setup first on unconfigured devices.
#### Improved
- Settings page names, controls in General Settings, Quick Setup actions, and Advanced controls now use Obsidian 1.13's native settings interface and global search, while retaining their familiar icons. The landing page keeps Remote Configuration and Sync Settings together, places Appearance, Logging, and Extra menus under General Settings, and groups maintenance, optional features, advanced settings, and help by purpose. Earlier supported Obsidian versions continue to use the pane-based interface.
- Settings changes which require database initialisation now use a focused Setup Manager dialogue to choose between existing synchronisation data and the files in the current Vault. The selected reset or rebuild is reserved before the settings are saved, while cancelling offers a separate, explicit settings-only fallback.
## 1.0.18
24th August, 2026
### Synchronisation and storage
#### Fixed
- Reset and rebuild workflows now use the local database selected by their updated settings, preventing stale data from reopening after a **Database Suffix** change. If database initialisation does not complete, the workflow remains paused instead of continuing with incomplete state.
#### Improved
- Rebuilds now recheck restored file events against the current Vault, use current file contents, and finish processing them before the plug-in reports readiness.
## 1.0.17
23rd August, 2026
### Interface and translation
#### Fixed
- Settings generated from the settings manifest, Setup Wizard configuration summaries, and warnings about externally changed settings now honour **Display language** when a translation is available, instead of remaining in English (PR #1123). Thank you to @nimula for the contribution!
### Peer-to-peer synchronisation
#### Improved
- P2P connection profiles now provide four **P2P message size** presets and a **Connection path** choice between **Automatic** and **TURN relay only**. Smaller messages can improve compatibility on paths which fragment or drop larger WebRTC messages, while relay-only routing requires a configured TURN server. P2P connection strings and encrypted Setup URIs preserve both choices.
- Thank you to @andrewschreiber for the detailed fragmentation diagnosis and working 800-byte threshold in vrtmrz/livesync-commonlib#97, which informed this compatibility design.
- An optional self-hosted Coturn Compose starter is now available for P2P deployments that need a TURN relay. It uses a pinned upstream image and documents its network, credential, security, and verification boundaries.
## 1.0.16
19th August, 2026
### Conflict handling and recovery
#### Fixed
- **Back to this revision** in Document History now restores the selected content as a new non-deleted successor revision before reflecting it to the Vault. A readable revision restored after a logical deletion therefore remains restored through later synchronisation instead of being overwritten by the deletion.
- If the file changes while restoration is in progress, the operation stops instead of extending a stale revision. Existing conflicts remain available through **Inspect conflicts and file/database differences**.
### Synchronisation and storage
#### Improved
- One-shot CouchDB synchronisation now releases stalled web-compatible connection checks before replication starts, so a later synchronisation can make a fresh attempt (Commonlib 0.1.16).
- The 60-second safeguard applies only to pre-replication checks. It does not limit ordinary synchronisation, and the **Use Internal API** path is unchanged.
## 1.0.15
15th August, 2026
+20 -1
View File
@@ -485,6 +485,25 @@ Setting key: P2P_AutoBroadcast
When enabled, this device notifies connected peers after a local change. The notification contains no Vault data. A receiving peer fetches the change only when it follows this device.
#### TURN configuration
Setting key: P2P_managedType
Select **Manual** for the existing TURN server fields, or **Managed (Cloudflare)** for a
TURN Key ID and TURN Key API Token. The API token is persisted with the profile
and included in Setup URI and QR code sharing. Issued temporary credentials are
kept in memory only. Reports redact the provider settings. See
[TURN credentials](p2p.md#turn-credentials) for sharing, expiry, and reconnect
behaviour.
#### TURN Key ID and TURN Key API Token
Setting keys: P2P_managedId, P2P_managedToken
These fields appear when **Managed (Cloudflare)** is selected. Enter the TURN key's ID and
its dedicated API token. The token field is masked. No account ID, custom
endpoint, or renewal interval is required.
#### TURN Server URLs (comma-separated)
Setting key: P2P_turnServers
@@ -515,7 +534,7 @@ The sender controls the size of its outgoing messages. Select the same conservat
Setting key: P2P_connectionPath
**Automatic** lets WebRTC select a viable direct or TURN-relayed path and is the default. **TURN relay only** forces `iceTransportPolicy: 'relay'` and is available only when the profile contains at least one valid `turn:` or `turns:` URL. Removing the last valid TURN URL while relay-only mode is selected restores **Automatic** and displays a Notice.
**Automatic** lets WebRTC select a viable direct or TURN-relayed path and is the default. **TURN relay only** forces `iceTransportPolicy: 'relay'` and is available when the profile contains a valid manual TURN URL or a configured TURN credential source. Removing the manual TURN configuration while relay-only mode is selected restores **Automatic** and displays a Notice. A selected credential source which cannot supply credentials prevents the connection from opening; it does not change the connection path.
This choice belongs to the P2P profile and is retained in P2P connection strings and encrypted Setup URIs. Separate profiles may use the same Group ID and credentials with different compatibility choices; only the selected P2P profile is active.
+1 -1
View File
@@ -20,7 +20,7 @@ Resolving a conflict writes the selected or merged result on one observed branch
### Independent revision properties
The modifiers defined under [Revision](terms.md#revision) describe independent properties, rather than exclusive revision types. The winner is a database-tree role, Vault-matching describes current file-state equality, and displayed identifies the device-local branch recorded for the Vault. The same revision commonly has all three properties, but synchronisation, conflicts, local edits, and missing provenance can separate them.
The modifiers defined under [Revision](glossary.md#revision) describe independent properties, rather than exclusive revision types. The winner is a database-tree role, Vault-matching describes current file-state equality, and displayed identifies the device-local branch recorded for the Vault. The same revision commonly has all three properties, but synchronisation, conflicts, local edits, and missing provenance can separate them.
| Situation | Winner | Vault-matching | Displayed |
| ---------------------------------------------------- | ------------------ | --------------------------------------------------- | ---------------------------------------------------- |
+39 -1
View File
@@ -11,6 +11,44 @@
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
Chunk shapes, identifier handling, deletion, and raw remote representations.
- [Replicator architecture](design_docs/replicator_architecture.md) describes
provider composition, active Replicator publication, retirement, and P2P
ownership.
- [Conflict resolution and revision provenance](specs_conflict_resolution.md)
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.
## Techniques to keep bandwidth consumption low.
![dedupe](../images/2.png)
![dedupe](../images/2.png)
+9 -94
View File
@@ -1,3 +1,10 @@
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
# Notes on Terminology, Spelling, Vocabulary Conventions
## Spelling and Vocabulary conventions
@@ -24,97 +31,5 @@ All guidelines and conventions listed below are disclosed and maintained solely
### Terminology
- Boot-up sequence (boot-sequence)
- The initialisation process of the plug-in when Obsidian starts. It starts with the loading of the plug-in, setting up core services, loading saved settings, and opening the local database. Once the layout is ready, the plug-in checks for the presence of flag files, runs configuration diagnostics, connects to the remote database, and begins file watching. The sequence finishes once the plug-in is fully ready and operational.
- Broken files (Size mismatch)
- A state where a file's metadata and the actual content stored in its chunks do not match, causing file retrieval or synchronisation failures. These mismatches can be inspected with `Inspect conflicts and file/database differences` on the Hatch pane, then handled one exact revision at a time.
- Chunk / Chunks
- Divided units of data stored in the database or object storage to facilitate efficient synchronisation.
- Compaction
- A database maintenance procedure that discards old historical document revisions to shrink the remote database size.
- Custom HTTP Handler / Use Internal API (CORS Bypass Settings)
- Settings used to bypass CORS restrictions by routing requests through Obsidian's native request APIs. There are two distinct settings under the hood depending on the remote server type:
- **For S3-compatible Object Storage (useCustomRequestHandler)**: Labeled as **"Use Custom HTTP Handler"** in the standard settings tab, **"Use internal API"** in the Svelte-based Setup Wizard dialogue, and represented as `useProxy` in the Setup URI's query parameters due to an unfortunate misunderstanding during development.
- **For CouchDB (useRequestAPI)**: Labeled as **"Use Request API to avoid `inevitable` CORS problem"** in the standard settings tab, **"Use Internal API"** in the Svelte-based Setup Wizard dialogue, and represented as `useRequestAPI` in the Setup URI's query parameters.
- Customisation Sync
- The feature that synchronises settings, snippets, themes, and plug-ins. Write with an "s" in documentation (`Customisation`), though technical configurations and links may use `customization`.
- Database Adapter (IDB vs. IndexedDB)
- The local database storage interface used by PouchDB. The `IDB` adapter is recommended since the older `IndexedDB` adapter is obsolete and known to cause memory leaks in `LiveSync` mode. Users can switch between these adapters without a full database rebuild, although a local data migration and an Obsidian restart are required.
- Database Suffix (additionalSuffixOfDatabaseName)
- A unique suffix appended to the database name to allow synchronising multiple vaults with the same name on the same remote server.
- E2EE Algorithm
- The cryptographic algorithm version used for end-to-end encryption. All synchronising devices must be configured with a compatible version (such as `V2` or `V1`).
- Eden (Eden Chunks)
- A performance optimisation where newly created chunks are held within the document until they stabilise, before graduating to independent chunks.
- Fast Setup (Simple Fetch)
- A simplified, automated initial synchronisation flow triggered when setting up subsequent devices or recovering a database. It bypasses the detailed step-by-step setup wizard dialogues, prompting the user with high-level data processing decisions and completing the initial download and local file scan in one continuous process.
- Flag files (redflag.md, redflag2.md, redflag3.md)
- Special Markdown files (or directories) placed at the root of the vault to stop the boot-up sequence or trigger recovery tasks. For instance, `redflag.md` suspends all processes, while `redflag2.md` (`flag_rebuild.md`) triggers a full database rebuild and `redflag3.md` (`flag_fetch.md`) discards the local database to fetch it again from the remote.
- Garbage Collection (GC)
- The process of identifying and purging unreferenced chunks (unused data) from local and remote databases to reclaim storage space.
- Hatch (Hatch pane)
- A dedicated troubleshooting and maintenance section in the plug-in settings, typically hidden behind a warning-labeled collapsible panel to prevent accidental misconfiguration. It contains diagnostic utilities, database reset controls, status reports, and advanced edge-case patches.
- Hidden File Sync
- The feature that synchronises files located in hidden directories (like `.obsidian`).
- JWT Authentication
- An experimental authentication option for CouchDB allowing secure token-based authentication instead of standard credentials. It requires a configured private key/secret, algorithm, expiration duration, subject, and key ID.
- LiveSync
- A very confusing term.
- As a shortened form of `Self-hosted LiveSync`.
- As the name of a synchronisation mode. This should be changed to `Continuous`, in contrast to `Periodic`.
- livesync-serverpeer / webpeer
- Pseudo-clients that assist in WebRTC peer-to-peer communication.
- Metadata (File metadata)
- A database document that stores properties of a file, including its filename, path, size, modification time, and references (hashes) of the chunks that comprise the file's content. Conflict state is carried by the surrounding PouchDB/CouchDB revision metadata rather than by a separate history field inside the file metadata document. In Self-hosted LiveSync, file metadata is stored separately from the actual file content to enable efficient synchronisation and versioning.
- OneShot Sync
- A single, immediate bidirectional synchronisation (pull then push) triggered on demand or on specific events, as opposed to continuous (live) replication.
- Overwrite Server Data with This Device's Files
- A maintenance operation (formerly known as `Rebuild everything`) that discards the remote database and reconstructs it by uploading all current local files as a fresh database, overwriting any remote changes.
- Path Obfuscation
- A privacy option that encrypts file paths and folder names on the remote server.
- plug-in
- We use the hyphenated form `plug-in` in user-facing messages and general documentation, while `plugin` may appear in codebase files, configuration settings, or technical contexts.
- Signalling relay (P2P)
- A Nostr-compatible WebSocket relay used for peer discovery and WebRTC connection negotiation. It does not store or transfer Vault contents. The project author operates a public relay as a best-effort convenience, and users can provide another compatible relay.
- Remediation (maxMTimeForReflectEvents)
- A recovery setting that restricts the propagation of changes from the database to local storage, ignoring any file events (such as accidental mass deletions) that occurred after a specified date and time.
- Reset Synchronisation on This Device
- A maintenance operation (formerly known as `Fetch everything`) that discards the local database and reconstructs it by downloading all data from the remote server.
#### Revision
A revision is a version of one PouchDB/CouchDB document. Concurrent changes can form a revision tree with more than one current branch.
Revision modifiers describe independent properties. More than one may apply to the same revision:
- **leaf**: Has no known child revision.
- **winner**: Is the leaf selected by PouchDB/CouchDB as the current document.
- **conflict**: Is another current leaf which was not selected as the winner.
- **Vault-matching**: Represents the same file contents, or the same absent-file state, as the current Vault. More than one revision may match.
- **displayed**: Is recorded by valid device-local file provenance as the branch represented in the Vault. A pending local edit may no longer match its bytes, but still extends this recorded branch.
- **logically deleted**: Represents the absence of the file through a deletion marker. A logically deleted revision may also be a leaf, winner, conflict, or Vault-matching revision. An absent file retains no displayed provenance.
Avoid **live revision** in prose because it can ambiguously mean either a current leaf or a non-deleted revision. See [Independent revision properties](specs_conflict_resolution.md#independent-revision-properties) for the relationship between revision-tree roles, Vault state, and device-local provenance.
- Scram (Scram Switches)
- Emergency controls in the settings that allow users to suspend file watching or database writes to prevent corruption.
- Segmenter (Segmented-splitter)
- A chunking method that divides files on semantic boundaries (such as paragraphs or sections) rather than arbitrary byte boundaries.
- Self-hosted LiveSync
- The name of this plug-in. `Self-hosted` is one word.
- Setting Doctor (Config Doctor)
- A diagnostic utility that checks for mismatches or suboptimal configurations, presenting users with ideal values and recommendation reasons to easily resolve issues during migration, configuration import, or general troubleshooting.
- Setup URI
- An encrypted representation of the plug-in's settings containing server configuration, which allows users to clone their configuration across devices securely using a passphrase.
- Streaming replication (Stream-based replication)
- A data transfer method that downloads database documents as a continuous stream of events. It is significantly faster than traditional chunk-by-chunk HTTP requests and is used during Fast Setup to retrieve remote metadata quickly.
- Sync Mode
- The replication trigger mechanism. Users can select from `On Events` (synchronising on local file changes), `Periodic and Events` (synchronising at fixed intervals as well as on events), or `LiveSync` (continuous, real-time synchronisation).
- Synchronising devices
- Devices which participate in the same synchronisation for a Vault. The term describes membership rather than current activity, so it includes offline and idle devices.
- TURN Server (WebRTC P2P)
- A Traversal Using Relays around NAT server used as an optional fallback to relay encrypted WebRTC traffic when strict NAT or firewall rules block a direct peer connection. It is distinct from the signalling relay.
- Update Thinning (Batch database update)
- An optimisation that groups multiple local file edits together over a short delay before committing them to the local database, reducing the number of database write operations.
- WebRTC P2P (Peer-to-Peer)
- A synchronisation method enabling direct communication between devices without a central server database.
Project-specific meanings are defined separately in the
[Project glossary](glossary.md).
+2
View File
@@ -88,6 +88,8 @@ Some settings must match across devices. LiveSync pauses synchronisation when th
Current releases automatically align compatible settings which control how new chunks are created, by default and where possible. This applies to the chunk hash algorithm, chunk size, and splitter version. Existing content remains readable across these choices, although using different choices can reduce chunk reuse and increase storage or transfer work. An explicit opt-out retains the manual review. A mismatch involving encryption, path obfuscation, file-name case handling, or any combination which includes one of those settings always remains a manual decision.
A missing legacy file-name case setting means case-insensitive handling. It matches an explicit disabled setting and does not require a rebuild for that difference. An explicitly enabled setting can use different document IDs and still requires a compatibility decision against either value. Other configuration differences shown in the dialogue must still be resolved.
The `Sync now` command keeps routine replication progress quiet so that it is convenient to assign to a keyboard shortcut; assign one in Obsidian if that suits your workflow. A quiet command may still open this dialogue when a mismatch or another decision requires your attention.
The available actions depend on when the mismatch is found:
+7
View File
@@ -63,6 +63,13 @@ export default defineConfig(
"@typescript-eslint/no-unnecessary-type-assertion": "warn",
},
},
{
files: ["src/integrations/**/*.ts"],
rules: {
// External-service integrations also run in Node and do not own window UI.
"obsidianmd/no-global-this": "off",
},
},
{
files: ["src/apps/**/*.{ts,js,mjs}"],
rules: {
+7
View File
@@ -99,6 +99,13 @@ export default defineConfig([
...ImportAliasRules("."),
},
},
{
files: ["src/integrations/**/*.ts"],
rules: {
// External-service integrations also run in Node and do not own window UI.
"obsidianmd/no-global-this": "off",
},
},
{
files: ["src/apps/**/*.ts"],
rules: {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "1.0.24",
"version": "1.0.29",
"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",
+174 -446
View File
@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
"version": "1.0.24",
"version": "1.0.29",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "1.0.24",
"version": "1.0.29",
"license": "MIT",
"workspaces": [
"src/apps/cli",
@@ -23,16 +23,15 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.21",
"@vrtmrz/livesync-commonlib": "0.1.25",
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
"fflate": "^0.8.2",
"idb": "^8.0.3",
"markdown-it": "^14.2.0",
"minimatch": "^10.2.5",
"obsidian": "^1.13.1",
"octagonal-wheels": "^0.1.53",
"octagonal-wheels": "^0.1.54",
"qrcode-generator": "^1.4.4",
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
},
@@ -56,7 +55,7 @@
"@types/pouchdb-mapreduce": "^6.1.10",
"@types/pouchdb-replication": "^6.4.7",
"@types/transform-pouch": "^1.0.6",
"@typescript-eslint/parser": "8.56.1",
"@typescript-eslint/parser": "8.69.0",
"@vitest/coverage-v8": "^4.1.8",
"@vrtmrz/obsidian-test-session": "0.2.6",
"dotenv-cli": "^11.0.0",
@@ -86,13 +85,13 @@
"svelte": "5.56.3",
"svelte-check": "^4.6.0",
"svelte-eslint-parser": "^1.8.0",
"svelte-preprocess": "^6.0.3",
"svelte-preprocess": "6.0.5",
"terser": "^5.39.0",
"tinyglobby": "^0.2.15",
"transform-pouch": "^2.0.0",
"tsx": "^4.21.0",
"typescript": "5.9.3",
"typescript-eslint": "^8.61.0",
"typescript": "6.0.3",
"typescript-eslint": "8.69.0",
"vite": "^8.0.16",
"vitest": "^4.1.8",
"yaml": "^2.8.2"
@@ -2054,29 +2053,43 @@
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/types": "^0.15.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanfs/core": "^0.19.2",
"@humanfs/types": "^0.15.0",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/types": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@@ -4157,17 +4170,56 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/parser": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz",
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz",
"integrity": "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
"@typescript-eslint/typescript-estree": "8.56.1",
"@typescript-eslint/visitor-keys": "8.56.1",
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.69.0",
"@typescript-eslint/type-utils": "8.69.0",
"@typescript-eslint/utils": "8.69.0",
"@typescript-eslint/visitor-keys": "8.69.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.69.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.8",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz",
"integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz",
"integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.69.0",
"@typescript-eslint/types": "8.69.0",
"@typescript-eslint/typescript-estree": "8.69.0",
"@typescript-eslint/visitor-keys": "8.69.0",
"debug": "^4.4.3"
},
"engines": {
@@ -4179,18 +4231,18 @@
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
"integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==",
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz",
"integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.56.1",
"@typescript-eslint/types": "^8.56.1",
"@typescript-eslint/tsconfig-utils": "^8.69.0",
"@typescript-eslint/types": "^8.69.0",
"debug": "^4.4.3"
},
"engines": {
@@ -4201,18 +4253,18 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz",
"integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==",
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz",
"integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.56.1",
"@typescript-eslint/visitor-keys": "8.56.1"
"@typescript-eslint/types": "8.69.0",
"@typescript-eslint/visitor-keys": "8.69.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4223,9 +4275,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz",
"integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==",
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz",
"integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4236,13 +4288,38 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz",
"integrity": "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.69.0",
"@typescript-eslint/typescript-estree": "8.69.0",
"@typescript-eslint/utils": "8.69.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz",
"integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==",
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz",
"integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4254,139 +4331,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz",
"integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==",
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz",
"integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.56.1",
"@typescript-eslint/tsconfig-utils": "8.56.1",
"@typescript-eslint/types": "8.56.1",
"@typescript-eslint/visitor-keys": "8.56.1",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz",
"integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/project-service": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz",
"integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.61.1",
"@typescript-eslint/types": "^8.61.1",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz",
"integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz",
"integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz",
"integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz",
"integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.61.1",
"@typescript-eslint/tsconfig-utils": "8.61.1",
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.1",
"@typescript-eslint/project-service": "8.69.0",
"@typescript-eslint/tsconfig-utils": "8.69.0",
"@typescript-eslint/types": "8.69.0",
"@typescript-eslint/visitor-keys": "8.69.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -4404,15 +4358,17 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz",
"integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==",
"node_modules/@typescript-eslint/utils": {
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz",
"integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.61.1",
"eslint-visitor-keys": "^5.0.0"
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.69.0",
"@typescript-eslint/types": "8.69.0",
"@typescript-eslint/typescript-estree": "8.69.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4420,29 +4376,20 @@
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/utils/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz",
"integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==",
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz",
"integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.56.1",
"@typescript-eslint/types": "8.69.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -4620,9 +4567,9 @@
}
},
"node_modules/@vrtmrz/livesync-commonlib": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.21.tgz",
"integrity": "sha512-AGuZ3eqBP37HJXEkTSpJ5M5bvTx2lYNq+6Q5NuCPZeGdbv7g6cujGvccVR5ozGfKdHGSyFZND5x1oFS9crRhUg==",
"version": "0.1.25",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.25.tgz",
"integrity": "sha512-uWlzcXi32EvrEx6OgKsSEuNQsY+PQDHPY3eq4Xd9W9lHKu2LNh5n1f2z+bMsfZJh1AGMuTkjjFW26X2Bvv/iog==",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.808.0",
@@ -7214,9 +7161,9 @@
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
@@ -9682,9 +9629,9 @@
"license": "MIT"
},
"node_modules/octagonal-wheels": {
"version": "0.1.53",
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.53.tgz",
"integrity": "sha512-4NJsb96Sk6rJXhrTyjAY5GRIoWMFJsFp56b5ba9fV8/87ys2HCjMo0hior4F8k4ma72pLTJT7i6DvuihHxSsMA==",
"version": "0.1.54",
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.54.tgz",
"integrity": "sha512-Je3ancYhjKX7UY2K19T/qTjG8C9nK8YVrACr5naIf78mN4bbjQkYyWmlj+ooifV/moWVsQrp4fEWz/7mv6It3A==",
"license": "MIT",
"dependencies": {
"idb": "^8.0.3"
@@ -11521,9 +11468,9 @@
}
},
"node_modules/svelte-preprocess": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-6.0.3.tgz",
"integrity": "sha512-PLG2k05qHdhmRG7zR/dyo5qKvakhm8IJ+hD2eFRQmMLHp7X3eJnjeupUtvuRpbNiF31RjVw45W+abDwHEmP5OA==",
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-6.0.5.tgz",
"integrity": "sha512-sgwew5yV/2eMeQobIWgAxCNarKwiTUDIc3siAUbq3sp0G6ONtzk0W+wJihMdqjbYb3iGU3ubpGv0usnnuXT3qg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -11541,7 +11488,7 @@
"stylus": ">=0.55",
"sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.100 || ^5.0.0",
"typescript": "^5.0.0"
"typescript": "^5.0.0 || ^6.0.0"
},
"peerDependenciesMeta": {
"@babel/core": {
@@ -11993,9 +11940,9 @@
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -12007,16 +11954,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz",
"integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==",
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.69.0.tgz",
"integrity": "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.1",
"@typescript-eslint/utils": "8.61.1"
"@typescript-eslint/eslint-plugin": "8.69.0",
"@typescript-eslint/parser": "8.69.0",
"@typescript-eslint/typescript-estree": "8.69.0",
"@typescript-eslint/utils": "8.69.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -12030,225 +11977,6 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz",
"integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/type-utils": "8.61.1",
"@typescript-eslint/utils": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.1",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.61.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz",
"integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.1",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/project-service": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz",
"integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.61.1",
"@typescript-eslint/types": "^8.61.1",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz",
"integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz",
"integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz",
"integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.1",
"@typescript-eslint/utils": "8.61.1",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/types": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz",
"integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz",
"integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.61.1",
"@typescript-eslint/tsconfig-utils": "8.61.1",
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.1",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz",
"integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.61.1",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/typescript-eslint/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/typescript-eslint/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
@@ -12937,11 +12665,11 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
"version": "1.0.24-cli",
"version": "1.0.29-cli",
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
"octagonal-wheels": "^0.1.53",
"octagonal-wheels": "^0.1.54",
"pouchdb-adapter-http": "^9.0.0",
"pouchdb-adapter-leveldb": "^9.0.0",
"pouchdb-core": "^9.0.0",
@@ -12955,28 +12683,28 @@
"werift": "^0.24.4"
},
"devDependencies": {
"typescript": "5.9.3",
"typescript": "6.0.3",
"vite": "^8.0.16",
"vitest": "^4.1.8"
}
},
"src/apps/webapp": {
"name": "livesync-webapp",
"version": "1.0.24-webapp",
"version": "1.0.29-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.53"
"octagonal-wheels": "^0.1.54"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"svelte": "5.56.3",
"typescript": "5.9.3",
"typescript": "6.0.3",
"vite": "^8.0.16"
}
},
"src/apps/webpeer": {
"version": "1.0.24-webpeer",
"version": "1.0.29-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.53"
"octagonal-wheels": "^0.1.54"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
@@ -12984,7 +12712,7 @@
"eslint-plugin-svelte": "^3.19.0",
"svelte": "5.56.3",
"svelte-check": "^4.6.0",
"typescript": "5.9.3",
"typescript": "6.0.3",
"vite": "^8.0.16"
}
}
+19 -10
View File
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "1.0.24",
"version": "1.0.29",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js",
"type": "module",
@@ -30,6 +30,7 @@
"i18n:json2yaml": "tsx _tools/json2yaml.ts",
"i18n:yaml2json": "tsx _tools/yaml2json.ts",
"test:unit": "vitest run --config vitest.config.unit.ts",
"test:release-process": "vitest run --config vitest.config.unit.ts utils/release-process.unit.spec.ts",
"build:browser-apps": "npm run build --workspace livesync-webapp --workspace webpeer",
"test:browser-apps": "npm run test:browser --workspace livesync-webapp && npm run test:browser --workspace webpeer",
"test:browser-apps:pages": "deno test -A --no-check --frozen --config test/browser-apps/deno.json --lock test/browser-apps/deno.lock test/browser-apps/pages/browser-smoke.test.ts",
@@ -46,7 +47,7 @@
"test:e2e:cli:p2p": "npm run test:e2e:p2p --workspace self-hosted-livesync-cli",
"test:e2e:cli:all": "npm run test:e2e:all --workspace self-hosted-livesync-cli",
"test:integration": "npx dotenv-cli -e .env -e .test.env -- vitest run --config vitest.config.integration.ts",
"test:unit:coverage": "vitest run --config vitest.config.unit.ts --coverage",
"test:unit:coverage": "vitest run --config vitest.config.unit.ts --coverage --exclude utils/release-process.unit.spec.ts",
"test:e2e:obsidian:install-appimage": "tsx test/e2e-obsidian/scripts/install-appimage.ts",
"test:e2e:obsidian:runner": "vitest run --config vitest.config.e2e-runner.ts",
"test:e2e:obsidian:discover": "tsx test/e2e-obsidian/scripts/discover.ts",
@@ -65,14 +66,17 @@
"test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts",
"test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts",
"test:e2e:obsidian:couchdb-upload": "tsx test/e2e-obsidian/scripts/couchdb-upload.ts",
"test:e2e:obsidian:tweak-compatibility": "tsx test/e2e-obsidian/scripts/tweak-compatibility.ts",
"test:e2e:obsidian:couchdb-manual-setup-workflow": "tsx test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts",
"test:e2e:obsidian:cli-to-obsidian-sync": "tsx test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts",
"test:e2e:obsidian:minio-upload": "tsx test/e2e-obsidian/scripts/minio-upload.ts",
"test:e2e:obsidian:object-storage-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts",
"test:e2e:obsidian:object-storage-custom-http-handler-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts --custom-http-handler",
"test:e2e:obsidian:p2p-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts",
"pretest:e2e:obsidian:p2p-connection-check": "npm run build && npm run build --workspace webpeer",
"test:e2e:obsidian:p2p-connection-check": "tsx test/e2e-obsidian/scripts/p2p-connection-check.ts",
"test:e2e:obsidian:p2p-connection-check:services": "npm run test:e2e:obsidian:p2p-connection-check -- --manage-p2p",
"test:e2e:obsidian:partial-startup-file-failure": "tsx test/e2e-obsidian/scripts/partial-startup-file-failure.ts",
"test:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts",
"test:e2e:obsidian:setup-uri-workflow": "tsx test/e2e-obsidian/scripts/setup-uri-workflow.ts",
"test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts",
@@ -126,7 +130,7 @@
"@types/pouchdb-mapreduce": "^6.1.10",
"@types/pouchdb-replication": "^6.4.7",
"@types/transform-pouch": "^1.0.6",
"@typescript-eslint/parser": "8.56.1",
"@typescript-eslint/parser": "8.69.0",
"@vitest/coverage-v8": "^4.1.8",
"@vrtmrz/obsidian-test-session": "0.2.6",
"dotenv-cli": "^11.0.0",
@@ -156,13 +160,13 @@
"svelte": "5.56.3",
"svelte-check": "^4.6.0",
"svelte-eslint-parser": "^1.8.0",
"svelte-preprocess": "^6.0.3",
"svelte-preprocess": "6.0.5",
"terser": "^5.39.0",
"tinyglobby": "^0.2.15",
"transform-pouch": "^2.0.0",
"tsx": "^4.21.0",
"typescript": "5.9.3",
"typescript-eslint": "^8.61.0",
"typescript": "6.0.3",
"typescript-eslint": "8.69.0",
"vite": "^8.0.16",
"vitest": "^4.1.8",
"yaml": "^2.8.2"
@@ -177,16 +181,15 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.21",
"@vrtmrz/livesync-commonlib": "0.1.25",
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
"fflate": "^0.8.2",
"idb": "^8.0.3",
"markdown-it": "^14.2.0",
"minimatch": "^10.2.5",
"obsidian": "^1.13.1",
"octagonal-wheels": "^0.1.53",
"octagonal-wheels": "^0.1.54",
"qrcode-generator": "^1.4.4",
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
},
@@ -202,5 +205,11 @@
"src/apps/cli",
"src/apps/webpeer",
"src/apps/webapp"
]
],
"allowScripts": {
"esbuild@0.28.1": true,
"leveldown@5.6.0": true,
"leveldown@6.1.1": true,
"svelte-preprocess": false
}
}
+30
View File
@@ -0,0 +1,30 @@
# Self-hosted LiveSync technical paper manuscript
This directory contains a technical manuscript prepared in the format of the Journal of Open Source Software (JOSS) to document the design intent, architecture, and workflow context of Self-hosted LiveSync.
This document is not currently published as a formal journal paper; rather, it serves as an architectural overview explaining the project's background and replication model (describing Self-hosted LiveSync 1.0.23 pinned to Commonlib 0.1.21). If you reference or utilise Self-hosted LiveSync in academic research, laboratory workflows, or technical publications, citing the software via [CITATION.cff](../CITATION.cff) or this manuscript is greatly appreciated.
## Contents
- [paper.md](paper.md): English manuscript.
- [paper.ja.md](paper.ja.md): Japanese reference translation.
- [paper.bib](paper.bib): Shared bibliography.
## Citing Self-hosted LiveSync
Please refer to the repository's [CITATION.cff](../CITATION.cff) file or the metadata recorded in [paper.bib](paper.bib) if you wish to cite this software in your research papers, technical reports, or presentations.
## Feedback and Contributions
For corrections, suggestions, or questions regarding the manuscript, please open an [issue](https://github.com/vrtmrz/obsidian-livesync/issues) or submit a pull request.
If I have overlooked or misrepresented anyone's contribution, please let me know.
---
## 本原稿について
本ディレクトリーには、Journal of Open Source SoftwareJOSS)の形式を想定し、Self-hosted LiveSync の設計意図やアーキテクチャー、および運用の背景をまとめた原稿を配置しています。
本稿は現時点で正式に出版された論文ではなく、プロジェクトの背景や同期モデルを整理した技術資料として作成されたものです(Commonlib 0.1.21 に固定された Self-hosted LiveSync 1.0.23 を基準としています)。もし学術研究、実験ノートの管理、あるいは技術レポート等で Self-hosted LiveSync を利用・言及される機会がありましたら、リポジトリーの [CITATION.cff](../CITATION.cff) や本稿を引用していただけますと幸いです。
日本語版は翻訳メモリーの使用を想定した逐語訳的な参考訳として位置づけられており、技術的意味論の正確性は英語版を基準としています。
+161
View File
@@ -0,0 +1,161 @@
@inproceedings{kleppmann2019localfirst,
author = {Kleppmann, Martin and Wiggins, Adam and van Hardenberg, Peter and McGranaghan, Mark},
title = {Local-first software: you own your data, in spite of the cloud},
booktitle = {Proceedings of the 2019 ACM SIGPLAN International Symposium on New Ideas, New Paradigms, and Reflections on Programming and Software},
pages = {154--178},
year = {2019},
publisher = {Association for Computing Machinery},
doi = {10.1145/3359591.3359737},
url = {https://doi.org/10.1145/3359591.3359737}
}
@software{selfhostedlivesync,
author = {{vorotamoroz} and {Self-hosted LiveSync Contributors}},
title = {vrtmrz/obsidian-livesync: 1.0.23},
version = {1.0.23},
year = {2026},
publisher = {Zenodo},
doi = {10.5281/zenodo.22247183},
url = {https://doi.org/10.5281/zenodo.22247183}
}
@software{commonlib,
author = {{vorotamoroz} and {livesync-commonlib Contributors}},
title = {vrtmrz/livesync-commonlib: 0.1.19},
version = {0.1.19},
year = {2026},
publisher = {Zenodo},
doi = {10.5281/zenodo.22074979},
url = {https://doi.org/10.5281/zenodo.22074979}
}
@software{commonlib021,
author = {{vorotamoroz} and {livesync-commonlib Contributors}},
title = {livesync-commonlib: Platform-independent replication and synchronisation engine for Self-hosted LiveSync},
version = {0.1.21},
year = {2026},
publisher = {npm},
url = {https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.21.tgz}
}
@software{fancykit,
author = {{vorotamoroz}},
title = {vrtmrz/fancy-kit: Fancy Kit repository snapshot 2026.08.24.1},
version = {fancy-kit-2026.08.24.1},
year = {2026},
publisher = {Zenodo},
doi = {10.5281/zenodo.22088208},
url = {https://doi.org/10.5281/zenodo.22088208}
}
@misc{selfhostedlivesyncrepo,
author = {{vorotamoroz} and {Self-hosted LiveSync Contributors}},
title = {Self-hosted LiveSync source repository},
year = {2026},
url = {https://github.com/vrtmrz/obsidian-livesync},
urldate = {2026-09-02}
}
@misc{obsidian,
author = {{Dynalist Inc.}},
title = {Obsidian: A knowledge base that works on local Markdown files},
year = {2026},
url = {https://obsidian.md}
}
@misc{couchdb,
author = {{The Apache Software Foundation}},
title = {Apache CouchDB: Seamless multi-master syncing database with an intuitive HTTP/JSON API},
year = {2026},
url = {https://couchdb.apache.org}
}
@misc{couchdbreplication,
author = {{The Apache Software Foundation}},
title = {{CouchDB} Replication Protocol},
year = {2026},
url = {https://docs.couchdb.org/en/stable/replication/protocol.html},
urldate = {2026-09-07}
}
@misc{pouchdb,
author = {{PouchDB Authors}},
title = {PouchDB: The Database that Syncs!},
year = {2026},
url = {https://pouchdb.com}
}
@misc{webrtc,
author = {{World Wide Web Consortium}},
title = {WebRTC 1.0: Real-Time Communication Between Browsers},
year = {2021},
url = {https://www.w3.org/TR/2021/REC-webrtc-20210126/},
urldate = {2026-09-07}
}
@misc{obsidiansync,
author = {{Dynalist Inc.}},
title = {Obsidian Sync: Secure, end-to-end encrypted synchronisation service},
year = {2026},
url = {https://obsidian.md/sync}
}
@misc{obsidiangit,
author = {Denis Olehov and {Obsidian Git Contributors}},
title = {Obsidian Git: Backup and synchronise your Obsidian vault with Git},
year = {2026},
url = {https://github.com/Vinzent03/obsidian-git}
}
@misc{syncthing,
author = {{The Syncthing Authors}},
title = {Syncthing: Open Source Continuous File Synchronization},
year = {2026},
url = {https://syncthing.net}
}
@misc{syncthingsync,
author = {{The Syncthing Authors}},
title = {Understanding Synchronization},
year = {2026},
url = {https://docs.syncthing.net/users/syncing.html},
urldate = {2026-09-07}
}
@misc{gitfetch,
author = {{Git Contributors}},
title = {git-fetch: Download objects and refs from another repository},
year = {2026},
url = {https://git-scm.com/docs/git-fetch},
urldate = {2026-09-07}
}
@misc{automergeconflicts,
author = {{Automerge Contributors}},
title = {Automerge: Conflicts},
year = {2026},
url = {https://automerge.org/docs/reference/documents/conflicts/},
urldate = {2026-09-07}
}
@misc{remotelysave,
author = {fyears and {Remotely Save Contributors}},
title = {Remotely Save: Sync non-official Obsidian plugin},
year = {2026},
url = {https://github.com/remotely-save/remotely-save}
}
@misc{trystero,
author = {Dan Motzenbecker},
title = {Trystero: Serverless WebRTC matchmaking and data channels},
year = {2026},
url = {https://github.com/dmotz/trystero}
}
@misc{obsidianplugin,
author = {{Obsidian Community Plugins}},
title = {Self-hosted LiveSync in the Obsidian Community Plugin Directory},
year = {2026},
url = {https://community.obsidian.md/plugins/obsidian-livesync},
urldate = {2026-09-02}
}
+76
View File
@@ -0,0 +1,76 @@
# Summary
Self-hosted LiveSync は、ローカルの Markdown ファイルとして文書を保存するノートアプリ Obsidian [@obsidian] 向けのオープンソース同期プラグインである。ユーザーが管理するストレージまたは直接のピアツーピア接続を介し、ノートや添付ファイルを収めたディレクトリー(Vault)をデスクトップとモバイルデバイス間で同期する。
本プラグインにより、ユーザーはオフラインで編集を行い、再接続後に同期できる。2台のオフライン端末で同一ノートを別々に編集した場合のように編集の衝突が生じても、即座の解決を強制したり競合する変更を無条件に上書きしたりすることはない。ファイル形式や設定されたポリシーに応じて、重複しない変更箇所の自動マージや、競合する版を後から比較・解決するための保持が可能である。組み込みの検査ツールは、競合や内容の欠落の調査を支援し、コピーが残っている場合の復旧を支援する。
本ソフトウエアは、データの保存先を自ら管理しながら複数デバイスで記録を継続する必要がある研究者、エンジニア、および実務者のワークフローに対応する。
# Statement of Need
研究やエンジニアリングのワークフローは、長期間蓄積されるノート、観察記録、設計上の決定事項、および関連ファイルに依存している。著者の業務では、管理下にある各デバイスの導入ソフトウエアを制御し、業務ファイルを自身の管理下にあるインフラで扱い、運用実績のあるサーバーソフトウエアを採用する必要があった。これらの制約から、デスクトップとモバイル双方の Obsidian 内で直接動作し、外部クライアントデーモンを必要としない同期エンジンを開発した。
オフライン端末で別々に行った編集の競合は、変更を交換した際に認識される。意図しない編集や削除、並行した変更の乖離、あるいはデータベースの状態とは独立した外部ツールによるファイル変更も起こりうる。競合する版を保持せずに単一の版で上書きしてしまうと、ユーザーが変更を確認して判断する前に情報が失われるおそれがある。
フィールドワークやモバイルでの作業中、研究者や実務者は、競合する編集内容をレビューする前であっても、観察の記録とデバイス間でのノート転送を続ける必要がある。Self-hosted LiveSync は、このように記録と競合解決を分けて進める作業を支援する。並行ブランチが未解決のままでも複製を継続でき、競合する編集は、レビューまたは設定されたポリシーによって解決されるまで保護される。
# State of the Field
ローカルファーストソフトウエアは、ユーザーデータの唯一の所有者としてのホスト型サービスへの依存を避けつつ、ローカルにおける可用性と、複数デバイス間での同期や協調を両立させる [@kleppmann2019localfirst]。Obsidian エコシステム内では、いくつかのツールが異なるアプローチから複数デバイス間の同期に対応している。Obsidian Sync は統合されたホスト型サービスを提供し [@obsidiansync]、Obsidian Git はバージョン管理指向の push/pull ワークフローを提供し [@obsidiangit]、Syncthing はファイルシステム層で動作し [@syncthing]、Remotely Save はクラウドやセルフホスト型ストレージの複数の API に Obsidian を接続する [@remotelysave]。
これらのアプローチは、並行する変更の表現方法が異なる。Syncthing は競合コピーを通常のファイルとして他のデバイスへ転送し [@syncthingsync]、Git は分岐した履歴をマージ前に取得できる [@gitfetch]。Obsidian Git はデスクトップおよびモバイル上でこの操作を自動化している [@obsidiangit]。Conflict-free Replicated Data TypesCRDT)でも複数の選択肢を検査でき、Automerge は同じオブジェクトプロパティーへの並行した代入を保持する [@automergeconflicts]。Self-hosted LiveSync は、競合するファイルの各バージョンを、メタデータドキュメントのリビジョンツリー上の末端リビジョン(leaf、各分岐の現在の版)として保持し、設定されたポリシーまたはユーザーによる明示的な操作によって解決されるまで維持する。
これらの既存ツールはそれぞれ異なる運用上の要請に応えている。ホスト型サービスは導入の平易さを重視し、外部のファイル同期ツールは任意のファイルシステムツリーを対象とし、バージョン管理ツールは明示的なコミットワークフローを前提としている。一方、著者の環境では管理対象デバイス上でバックグラウンドクライアントデーモンの実行が制限されており、かつ競合する版の保持と、その版に結び付いたファイル更新を扱うためには、複製処理とローカルファイル操作を直接統合する必要があった。そのため、Self-hosted LiveSync は外部デーモンを介さず Obsidian 内で直接動作するプラグインとして構築され、複数のバックエンドで同一のリビジョンセマンティクスを維持するために、中核ロジックをプラットホーム非依存のエンジンとして分離する構成が採用された。
Self-hosted LiveSync は新しいデータベース複製アルゴリズムを導入するものではない。むしろその貢献は、リビジョン認識可能なデータベースのセマンティクスを、外部から編集できるファイル Vault へ適用した点にある。Content-addressed なチャンク、デバイスローカルな来歴情報、および組み込みの復旧ツールにより、通常のノート作成ワークフローを損なうことなく、競合のレビューを保留しながら編集を継続できるようにしている。
# Software Design
共通の複製サービスおよび競合処理サービスは `@vrtmrz/livesync-commonlib` [@commonlib021] として公開されており、Obsidian プラグイン、コマンドラインインターフェース(CLI)、Web アプリケーション、および Web Peer で利用されている。
## Revision-aware Vault representation
Vault の各ファイルは、ローカルの PouchDB [@pouchdb] 内で、パス、サイズ、更新日時、および分割されたチャンクドキュメントへの参照を含むメタデータドキュメントとして表現される。チャンクは Content-addressed であり、同一のコンテンツ領域を持つリビジョン間や異なるファイル間で再利用できる。複数デバイス間で並行して更新が行われると、メタデータドキュメントの周囲に競合する複数の leaf(子を持たない末端リビジョン)が形成される。PouchDB はデフォルトの取得対象として決定論的な winner(選出された leaf)を選出するが、この選択は内部的なタイブレークに過ぎず、その winner がより新しい、より安全である、あるいは特定のデバイスの Vault に表示されているバージョンであることを証明するものではない。
並行する更新によってブランチ $\alpha$ と $\beta$ に分岐した場合、両方の leaf は解決前に他のデバイスへ複製される。自動3方向マージは、両方の leaf と最も近い利用可能な共通祖先についてメタデータ本文およびチャンクが読み取り可能である場合に、Markdown(`.md`)、Canvas`.canvas`)、および JSON`.json`)ファイルを対象として適用される(その他の形式は対象外である)。Markdown では、同一オフセットへの並行した挿入は即座に失敗とせず、更新日時に応じて順次連結して統合できる。CouchDB の複製プロトコルは祖先リビジョンの識別子を伝播するものの祖先の内容は取得しないため [@couchdbreplication]、祖先の履歴や内容が欠落している場合、あるいは互換性のない編集衝突が生じた場合、同期エンジンは自動マージを保留する。自動マージが無効または適用不能であり両方の版が読み取り可能である場合、JSON ファイルおよび内容の異なるバイナリーファイルは互換性のための動作として、「常に新しいファイルで上書きする」が無効であっても更新日時によって解決され、このオプションを有効にするとテキストの競合にも当該解決が拡張される。それ以外の場合、テキストの競合は手動解決のために保持され、両方の版が読み取り可能であれば2方向の差分(two-way diff)によって直接比較できる。
## Device-local branch provenance
データベースは競合する複数のブランチを同時に保持できるが、ローカルの Vault は任意のパスに対して単一の実体ファイルしか配置できない。ローカルファイルがどのブランチを表しているかを識別するため、本プラグインは正確なデータベースリビジョンと観測されたローカルの更新日時をデバイスローカルな Key-Value ストアに保存する。このリビジョンは当該パスの**ブランチアンカー**として機能し、データベースから Vault への実体化、または Vault からデータベースへの書き込みが成功した後に更新される。
未解決の競合が存在する状態において、ローカルで行われた編集や論理削除はアンカーされたリビジョンの子となり、競合する leaf を損なうことなく、その特定のブランチを前進させる。パスをまたぐリネームでは、移動先を保存した上で、アンカーされた移動元のブランチのみを論理削除する。この状態で来歴情報が利用できない場合、本プラグインはファイルのバイト列が利用可能な既存の単一リビジョン本文と厳密に一致する場合に限り Vault 内のファイルをそのリビジョンにひもづけ、それ以外の場合はパスや日時から勝手に推測せず、手動解決すべき競合として保持する。競合が存在しない通常時は、通常の書き込みによって単に現在のデータベースリビジョンが前進する。
組み込みのコンフリクトインスペクターは、現在の winner、すべての conflict leaf、および最も近い利用可能な共通祖先を検査する。インスペクターは欠落したチャンクやファイル/データベース間の差異を報告し、現在の leaf を明示的に選択して操作できるようにする。変更を伴う操作は実行前にリビジョンを再確認し、古い画面状態によってすでに末端ではなくなったリビジョンを誤って削除したり前進させたりするのを防止する。
## Transport-independent replication
CouchDB のリビジョンモデルを基準に、Self-hosted LiveSync はデータベースの表現を通信トランスポートから分離し、バックエンドにかかわらずファイルメタデータのリビジョン識別子と競合する leaf をそのまま複製する。CouchDB [@couchdb] ではネイティブなリビジョン複製を利用する。S3 互換オブジェクトストレージでは、メタデータドキュメントの末端リビジョンと祖先リビジョンの識別子をジャーナルに記録し、新しいローカルリビジョンを作成せずに適用する一方、チャンクドキュメントは内容由来の識別子を保持し、新しいローカルリビジョンとして保存される。WebRTC ピアツーピア(P2P)アダプター [@webrtc] は、Trystero [@trystero] の DataChannels と RPC ベースのレプリケーション shim によりドキュメント要求をバッチ処理し、同一のリビジョンセマンティクスをピア間で直接保持する。CouchDB およびジャーナル転送においては、Web Streams が転送をパイプライン処理し、転送中にメモリーへ保持されるデータ量を抑制する。
これらのトランスポートは柔軟に組み合わせられる。P2P 同期は参加デバイスが同時にオンラインである必要があるが、中央の CouchDB やオブジェクトストレージを併用することで、オフライン期間を挟んだデバイス間でも同期できる。すべての通信方式でコンテンツのエンドツーエンド暗号化とパス難読化をサポートしている。P2P では接続交渉時のセッション記述が暗号化されるが、シグナリングリレーやネットワークサービスからは接続時刻やネットワークアドレスを観測できる。
## Retention and recovery
分岐した各ブランチは未変更のチャンクを共有するため、競合する leaf を保持するために生じるコストは主に新規チャンクとリビジョンメタデータに限られる。蓄積した保存領域はリモートデータベースの再構築によって回収できるほか、CouchDB 向けには、明示的に開始するベータ版のガベージコレクションにより、現在の winner、すべての conflict leaf、および未解決の競合を検査するために必要な、利用可能な祖先から到達可能なチャンクを保護しながらインプレースで回収できる。過去のリビジョンで置き換えられたチャンクは後から回収されうるため、過去のリビジョン本文は無条件のバックアップではない。
必要なチャンクが欠落している場合でも、読み取り不能な現在のリビジョンはリビジョンツリーに残り、競合処理によって自動的に破棄されることはない。欠落したチャンクが他のデバイスに残っている場合があるため、それらの再接続と同期を待って復旧操作を保留できる。競合インスペクターは影響を受けるリビジョンを明示し、取得の再試行や明示的な復旧操作を支援する。復旧には、デバイス、リモートストレージ、またはバックアップに内容が残っている必要がある。
# Research Impact Statement
Self-hosted LiveSync は、著者が複数のデバイスやプラットホームを対象に行うソフトウエア開発業務から生まれた。この作業では、主たるデバイスを利用できない状況でも、各デバイスでスクリーンショットを取得し、観察記録を保存する必要があった。同じワークフローは、現在では著者の先行技術調査にも利用されており、先行文献の読解に伴うメモや考察を同期するために用いられている。競合する版が保持されることで、分岐した記録が即座に上書きされず、後から比較・確認することが可能になる。
ユニットテストおよび結合テストは、リビジョンの系譜、チャンクの到達可能性、利用できない内容、およびホストの構成を対象とする。CLI および実環境の Obsidian によるシナリオでは、競合する leaf が残っている状態での編集、論理削除、およびリネームを含め、競合の伝播と解決を検証する。3ノードの P2P シナリオでは、未解決の leaf が解決前にデバイス間を移動できることを確認している。再利用可能なヘッドレステスト基盤は独立してアーカイブされている [@fancykit]。決定論的なフィクスチャーを用いて同一の生成データ上で P2P と CouchDB の経路を比較しているが、制御されたローカル測定値が普遍的な性能を示すわけではない。
2026年9月2日時点で、Obsidian プラグインディレクトリーでは 90万回以上のダウンロード、デスクトップおよびモバイルのサポート、ならびに公式の Research カテゴリーへの配置が報告されている [@obsidianplugin]。GitHub リポジトリーでは 12,200件以上のスター、440件のフォーク、および広範なユーザーコミュニティーからの貢献が記録されている [@selfhostedlivesyncrepo]。これらの数値自体は研究上の直接的な影響を証明するものではないが、本ソフトウエアがコミュニティーに受容され、単一のプライベートなワークフローを超えて運用されている証拠を提供する。
本稿で説明したソフトウエアは Self-hosted LiveSync 1.0.23 [@selfhostedlivesync] であり、MIT ライセンスの下でリリースされ、Commonlib 0.1.21 [@commonlib021] に固定されている。プラグイン、再利用可能なテストハーネス [@fancykit]、および以前の Commonlib 0.1.19 のスナップショット [@commonlib] は Zenodo に恒久的にアーカイブされており、プラットホーム非依存のロジックが独立したテストと再利用を可能にしている。
# AI Usage Disclosure
2026年7月から9月にかけて、コード探索、テストおよびベンチマークの足場作り、CI およびドキュメントの編集、原稿の推敲および校正、レビュー、引用の検証、ならびに結果の要約に GPT-5 を使用した OpenAI Codex が利用された。また、Codex を通じて GPT-6 も 9月の原稿レビューおよび改訂を支援した。本原稿の準備において、その他の生成 AI ツールは使用されていない。GitHub Copilot(モデルおよびバージョンは未記録)は、本リリースに含まれるコミットの実装、テスト、およびドキュメント作成を支援した。Google GeminiGemini Flash バージョン 3.5 から 3.8)は、リソースチェックおよび関連するコードベースの検証に使用された。人間の著者自身がすべての支援出力をレビュー、編集、および検証し、主要な設計判断を行い、関連する検証コマンドおよびベンチマークコマンドを実行した。著者は、提出された資料の正確性、独創性、ライセンス、および倫理的コンプライアンスについて引き続き全責任を負う。
# Acknowledgements
著者は、プロジェクトの貢献者、ユーザー、ならびに PouchDB、CouchDB、および Trystero のアップストリームメンテナーに感謝の意を表する。本プロジェクトは、GitHub Sponsors を通じたコミュニティーの支援、JetBrains からの開発ツールライセンス、および OpenAI の Codex for Open Source プログラムによる支援を受けている。
# References
+97
View File
@@ -0,0 +1,97 @@
---
title: 'Self-hosted LiveSync: Inspectable and recoverable replication for local-first Obsidian vaults'
tags:
- local-first software
- synchronisation
- CouchDB
- PouchDB
- WebRTC
- Obsidian
- TypeScript
authors:
- name: 'vorotamoroz'
affiliation: 1
corresponding: true
affiliations:
- name: 'Independent Researcher'
index: 1
date: 5 September 2026
bibliography: paper.bib
---
# Summary
Self-hosted LiveSync is an open-source synchronisation plug-in for Obsidian [@obsidian], a note-taking application that stores documents as local Markdown files. It replicates a user's vault—a directory containing notes and attachments—across desktop and mobile devices using user-controlled storage or direct peer-to-peer connections.
The plug-in allows users to continue editing offline and synchronise upon reconnection, even when conflicting edits arise—such as when two disconnected devices modify the same note concurrently. Rather than forcing immediate reconciliation or unconditionally overwriting competing changes, the system supports automatic merging of non-overlapping edits and preserves competing versions for deferred review, depending on file formats and configured policies. Built-in inspection tools help users investigate conflicts or missing content and recover files when surviving copies exist.
The software serves researchers, engineers, and practitioners who require continued note-taking across multiple devices while controlling their data storage.
# Statement of Need
Research and engineering workflows depend on long-lived notes, observations, design decisions, and supporting files. The author's work required managing the software installed on each device, keeping files on infrastructure under personal control, and using server software with an established operational record. These constraints motivated a synchronisation engine running directly inside Obsidian across desktop and mobile platforms without external client daemons.
Conflicts arising from concurrent edits on disconnected devices are recognised only after devices exchange updates. An edit or deletion may be unintended, concurrent modifications may diverge, or external tools may update files independently of database events. Overwriting with a single version without retaining competing revisions risks irreversibly discarding information before users can evaluate the divergence.
During fieldwork and mobile operations, researchers and practitioners often need to continue recording observations and transferring notes between devices before reviewing competing edits. Self-hosted LiveSync supports this separation of recording and reconciliation: replication proceeds while concurrent branches remain unresolved, protecting competing edits until they can be reviewed or resolved according to configured policies.
# State of the Field
Local-first software combines local availability with multi-device synchronisation and collaboration while avoiding dependence on a hosted service as the sole owner of user data [@kleppmann2019localfirst]. Within the Obsidian ecosystem, Obsidian Sync provides an integrated hosted service [@obsidiansync]; Obsidian Git provides version-control-oriented push and pull workflows [@obsidiangit]; Syncthing operates at the filesystem layer [@syncthing]; and Remotely Save connects Obsidian to several cloud and self-hosted storage APIs [@remotelysave].
These approaches differ in how they represent concurrent changes. Syncthing propagates conflict copies as ordinary files [@syncthingsync], while Git can fetch divergent histories into separate tracking branches before merging them [@gitfetch], an approach automated on desktop and mobile by Obsidian Git [@obsidiangit]. Conflict-free replicated data types (CRDTs) can also expose alternatives: Automerge retains concurrent assignments to an object property for inspection [@automergeconflicts]. Self-hosted LiveSync retains competing file versions as leaves—the current versions of divergent branches—in the metadata document's revision tree until resolved by configured policies or explicit user action.
These existing tools address distinct operational needs: hosted services prioritise turnkey convenience, external file synchronisers manage arbitrary filesystem trees, and version-control tools introduce explicit commit workflows. In the author's environment, however, managed devices prohibited background client daemons, while retaining competing revisions alongside their associated file updates required integrating replication directly with local file operations. Self-hosted LiveSync was therefore implemented as an Obsidian plug-in running entirely within the application runtime, backed by a decoupled, platform-independent engine to maintain uniform revision semantics across backends.
Self-hosted LiveSync does not introduce a new database replication algorithm; rather, its contribution lies in applying revision-aware database semantics to an externally editable file vault. Content-addressed chunks, device-local branch provenance, and built-in recovery tools support continued editing while conflict review is deferred, protecting divergent work without altering standard note-taking workflows.
# Software Design
The shared replication and conflict-handling services are published as `@vrtmrz/livesync-commonlib` [@commonlib021] and used by the Obsidian plug-in, command-line interface (CLI), web application, and web peer.
## Revision-aware Vault representation
Each Vault file is represented in local PouchDB [@pouchdb] by a metadata document containing its path, size, modification time, and references to separate chunk documents. Chunks are content-addressed and can therefore be reused across revisions and files with identical content regions. When concurrent updates occur across devices, they form multiple competing leaves around the metadata document. PouchDB selects a deterministic winner for default retrieval, but this choice is an internal tie-breaker rather than evidence that the winner is newer, safer, or the version represented by a particular device's Vault.
When concurrent updates diverge into branches $\alpha$ and $\beta$, both leaves replicate to other devices before resolution. Automatic three-way merging applies to Markdown (`.md`), Canvas (`.canvas`), and JSON (`.json`) files when both leaves and their nearest available shared ancestor are readable; other formats are excluded. In Markdown, concurrent insertions at the same offset concatenate sequentially by modification time. Because CouchDB replication transfers ancestry identifiers without ancestor content [@couchdbreplication], missing ancestral history or conflicting edits defer automatic merging. When automatic merging is disabled or inapplicable and both versions are readable, JSON and differing binary files resolve by modification time as a compatibility fallback, even when 'Always overwrite with a newer file' is disabled; enabling that option extends modification-time resolution to text conflicts. Otherwise, competing text versions remain for manual resolution, and can be compared via a two-way diff when both leaves are readable.
## Device-local branch provenance
While the database can retain competing branches concurrently, a local Vault can instantiate only a single concrete file at any given path. To resolve which branch a local file represents, the plug-in stores an exact database revision and observed local modification time in a device-local key-value store. This revision serves as the path's **branch anchor**, updated after a successful database-to-Vault reflection or Vault-to-database write.
During active conflicts, local edits or logical deletions become children of the anchored revision, advancing that branch while keeping competing leaves intact. Cross-path renames store the target before logically deleting only the anchored source branch. If provenance is unavailable, the plug-in binds a file to an existing revision only when its bytes match exactly one available revision body; otherwise, it retains the conflict for manual resolution rather than guessing from paths or timestamps. Without active conflicts, ordinary writes simply advance the database revision.
The built-in conflict inspector examines the current winner, every conflict leaf, and the nearest available shared ancestor. It reports missing chunks and file/database differences, permitting operations on an explicitly selected current leaf. Mutating operations recheck the revision beforehand, preventing a stale inspection from deleting or extending a superseded branch.
## Transport-independent replication
Using CouchDB's revision model as a baseline, Self-hosted LiveSync decouples database representation from network transport, replicating file-metadata revision identifiers and competing leaves intact across backends. CouchDB [@couchdb] provides native revision-aware replication. S3-compatible storage journals metadata leaf revisions and ancestry identifiers without synthesising new revisions, while chunk documents are stored as new local revisions with content-derived identifiers. The WebRTC peer-to-peer (P2P) adapter [@webrtc] uses Trystero [@trystero] DataChannels and an RPC-based replication shim to batch document requests while preserving identical revision semantics directly between peers. In CouchDB and journal transfers, Web Streams pipeline data to limit the amount of data buffered in memory during transfer.
Transports combine flexibly: while P2P requires concurrent online presence, pairing it with CouchDB or object storage bridges offline intervals. All transports support end-to-end content encryption and path obfuscation. P2P encrypts session descriptions during connection negotiation, though signalling relays and network services can still observe connection timing and network addresses.
## Retention and recovery
Because alternative branches share unchanged chunks, retaining competing leaves incurs storage and transfer costs primarily for new chunks and revision metadata. Remote database rebuilds reclaim space, while an explicitly initiated beta garbage-collection workflow provides in-place CouchDB cleanup by protecting chunks reachable from the current winner, every conflict leaf, and the available ancestry needed to inspect active conflicts. Because superseded chunks may be collected, historical revisions are not an unconditional backup.
When chunks are missing, unreadable current revisions remain in the tree rather than being automatically discarded during conflict processing. Because missing chunks may still exist on other devices, users can defer recovery until they reconnect and synchronise. The conflict inspector identifies affected revisions and facilitates retrieval retries and explicit recovery actions. Recovery ultimately requires surviving content on a device, in remote storage, or in a backup.
# Research Impact Statement
Self-hosted LiveSync originated in the author's multi-platform software engineering workflows, capturing screenshots and recording observations across multiple devices, including when a primary device was unavailable. Today, the same workflow supports the author's patent prior-art investigations, synchronising notes and reflections made while reading prior patent literature. Retaining competing revisions allows divergent observations to be compared after the fact rather than overwritten immediately.
Unit and integration tests cover revision ancestry, chunk reachability, unavailable content, and host composition. CLI and real-Obsidian scenarios exercise conflict propagation and resolution, including edits, logical deletions, and renames while competing leaves remain active. A three-node P2P scenario verifies that unresolved leaves move between devices before resolution. Reusable headless test infrastructure is archived independently [@fancykit]. Deterministic fixtures compare P2P and CouchDB paths over identical generated data; controlled local measurements do not establish universal performance.
As of 2 September 2026, the Obsidian plug-in directory reported more than 900,000 downloads, desktop and mobile support, and placement in its Research category [@obsidianplugin]. The GitHub repository recorded over 12,200 stars, 440 forks, and contributions from a broad user community [@selfhostedlivesyncrepo]. These figures demonstrate community adoption rather than direct research impact, but they provide evidence that the software operates beyond a single private workflow.
The software described here is Self-hosted LiveSync 1.0.23 [@selfhostedlivesync], released under the MIT licence and pinned to Commonlib 0.1.21 [@commonlib021]. Zenodo archives the plug-in, the reusable test harness [@fancykit], and an earlier Commonlib 0.1.19 snapshot [@commonlib]. Platform-independent logic supports independent testing and reuse.
# AI Usage Disclosure
OpenAI Codex using GPT-5 was used from July to September 2026 for code navigation, test and benchmark scaffolding, CI and documentation edits, manuscript editing, proofreading, review, citation verification, and result summarisation. GPT-6 assisted with September manuscript review and revision through Codex. No other generative AI tools prepared the manuscript. GitHub Copilot assisted with commits in this release, and Google Gemini (Flash versions 3.5 to 3.8) supported codebase verification. The human author validated all assisted outputs, made core design decisions, ran verification commands, and remains responsible for the accuracy, originality, licensing, and ethical compliance of the submitted materials.
# Acknowledgements
The author acknowledges project contributors, users, and upstream maintainers of PouchDB, CouchDB, and Trystero. The project has received community support through GitHub Sponsors, development-tool licensing from JetBrains, and support through OpenAI's Codex for Open Source programme.
# References
+13 -12
View File
@@ -22,33 +22,34 @@ import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/comp
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import { AbstractModule } from "./modules/AbstractModule";
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
import { ModuleLiveSyncMain } from "./modules/main/ModuleLiveSyncMain";
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { ModuleBasicMenu } from "./modules/essential/ModuleBasicMenu";
import { usePrepareDatabaseForUse } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/prepareDatabaseForUse";
import type { Constructor } from "@vrtmrz/livesync-commonlib/compat/common/utils.type";
import { useReplicationScheduling, type ReplicationSchedulingControl } from "./serviceFeatures/replicationScheduling";
import { createCentralReplicatorProviderDefinitions } from "./common/replicatorProviders";
import { useReplicationFeature } from "./serviceFeatures/replication";
import { useConflictResolutionFeature } from "./serviceFeatures/conflictResolution";
import { useBasicCommandsFeature } from "./serviceFeatures/basicCommands";
/** Focused views returned by serviceFeatures which the host may consume during composition. */
export interface LiveSyncCoreFeatureViews {
readonly replicationScheduling: ReplicationSchedulingControl;
}
export interface StartupDatabaseOptions {
readonly ignoreSuspending?: boolean;
readonly continueOnFileFailure?: boolean;
}
type CompatibilityReplicatorView = ReplicatorInstance & Partial<LiveSyncAbstractReplicator>;
export class LiveSyncBaseCore<
T extends ServiceContext = ServiceContext,
TCommands extends IMinimumLiveSyncCommands = IMinimumLiveSyncCommands,
>
implements
LiveSyncLocalDBEnv,
LiveSyncCouchDBReplicatorEnv,
HasSettings<ObsidianLiveSyncSettings>
implements LiveSyncLocalDBEnv, LiveSyncCouchDBReplicatorEnv, HasSettings<ObsidianLiveSyncSettings>
{
addOns = [] as TCommands[];
@@ -82,7 +83,8 @@ export class LiveSyncBaseCore<
) => ServiceModules,
extraModuleInitialiser: (core: LiveSyncBaseCore<T, TCommands>) => AbstractModule[],
addOnsInitialiser: (core: LiveSyncBaseCore<T, TCommands>) => TCommands[],
featuresInitialiser: (core: LiveSyncBaseCore<T, TCommands>, coreFeatureViews: LiveSyncCoreFeatureViews) => void
featuresInitialiser: (core: LiveSyncBaseCore<T, TCommands>, coreFeatureViews: LiveSyncCoreFeatureViews) => void,
readonly startupDatabaseOptions: StartupDatabaseOptions = {}
) {
this._services = serviceHub;
this.registerReplicatorProviders();
@@ -95,9 +97,10 @@ export class LiveSyncBaseCore<
for (const addOn of addOns) {
this._registerAddOn(addOn);
}
// Register host features and add-ons before replication, then bind
// Compose late core features after host features and add-ons, then bind
// legacy modules so lifecycle handlers observe the required order.
useReplicationFeature(this);
useBasicCommandsFeature(this);
this.bindModuleFunctions();
}
/**
@@ -157,10 +160,7 @@ export class LiveSyncBaseCore<
public registerModules(extraModules: AbstractModule[] = []) {
this._registerModule(new ModuleLiveSyncMain(this));
this._registerModule(new ModuleConflictChecker(this));
this._registerModule(new ModuleConflictResolver(this));
this._registerModule(new ModuleResolvingMismatchedTweaks(this));
this._registerModule(new ModuleBasicMenu(this));
for (const module of extraModules) {
this._registerModule(module);
@@ -290,6 +290,7 @@ export class LiveSyncBaseCore<
* (Please refer `serviceFeatures` for more details)
*/
initialiseServiceFeatures(): LiveSyncCoreFeatureViews {
useConflictResolutionFeature(this);
useTargetFilters(this);
// enable target filter feature.
usePrepareDatabaseForUse(this);
@@ -1,105 +1,67 @@
<script lang="ts">
import { onMount } from "svelte";
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { P2PReplicatorPaneHost } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost";
import TurnConfiguration from "@/features/P2PSync/TurnConfiguration.svelte";
import { validateManagedTurnSettings } from "@/integrations/turnSettings";
interface Props {
host: P2PReplicatorPaneHost;
}
let { host }: Props = $props();
let { host }: { host: P2PReplicatorPaneHost } = $props();
const currentSettings = () => host.services.setting.currentSettings() as P2PSyncSetting;
const initialSettings = currentSettings();
let savedTurnServers = $state(initialSettings.P2P_turnServers);
let savedTurnUsername = $state(initialSettings.P2P_turnUsername);
let savedTurnCredential = $state(initialSettings.P2P_turnCredential);
let turnServers = $state(initialSettings.P2P_turnServers);
let turnUsername = $state(initialSettings.P2P_turnUsername);
let turnCredential = $state(initialSettings.P2P_turnCredential);
const isTurnServersModified = $derived(turnServers !== savedTurnServers);
const isTurnUsernameModified = $derived(turnUsername !== savedTurnUsername);
const isTurnCredentialModified = $derived(turnCredential !== savedTurnCredential);
const isModified = $derived(
isTurnServersModified || isTurnUsernameModified || isTurnCredentialModified
);
function turnSettings(settings: P2PSyncSetting) {
return {
P2P_roomID: settings.P2P_roomID,
P2P_turnServers: settings.P2P_turnServers,
P2P_turnUsername: settings.P2P_turnUsername,
P2P_turnCredential: settings.P2P_turnCredential,
P2P_managedType: settings.P2P_managedType,
P2P_managedId: settings.P2P_managedId,
P2P_managedToken: settings.P2P_managedToken,
};
}
let draft = $state(turnSettings(currentSettings()));
let saved = $state(JSON.stringify(turnSettings(currentSettings())));
const isModified = $derived(JSON.stringify(draft) !== saved);
const sourceError = $derived(validateManagedTurnSettings(draft));
const sourceNeedsRoom = $derived(!!draft.P2P_managedType && (draft.P2P_roomID ?? "").trim() === "");
function loadSettings(settings: P2PSyncSetting): void {
savedTurnServers = settings.P2P_turnServers;
savedTurnUsername = settings.P2P_turnUsername;
savedTurnCredential = settings.P2P_turnCredential;
turnServers = savedTurnServers;
turnUsername = savedTurnUsername;
turnCredential = savedTurnCredential;
const next = turnSettings(settings);
draft = next;
saved = JSON.stringify(next);
}
onMount(() =>
host.services.context.events.onEvent("setting-saved", (settings) => {
loadSettings(settings as P2PSyncSetting);
})
);
onMount(() => host.services.context.events.onEvent("setting-saved", () => loadSettings(currentSettings())));
async function save(): Promise<void> {
await host.services.setting.applyPartial(
{
P2P_turnServers: turnServers,
P2P_turnUsername: turnUsername,
P2P_turnCredential: turnCredential,
},
true
);
if (sourceError || sourceNeedsRoom) return;
const values = $state.snapshot(draft);
await host.services.setting.updateSettings((settings) => {
const next = { ...settings, ...values, remoteConfigurations: { ...settings.remoteConfigurations } };
const profileId = settings.P2P_ActiveRemoteConfigurationId ||
(settings.remoteType === REMOTE_P2P ? settings.activeConfigurationId : "");
const selected = next.remoteConfigurations[profileId];
if (selected?.uri.startsWith("sls+p2p://")) {
upsertRemoteConfigurationInPlace(next, "p2p", { id: profileId, activateForP2P: true });
} else if (values.P2P_managedType) {
upsertRemoteConfigurationInPlace(next, "p2p", { activateForP2P: true });
}
return next;
}, true);
loadSettings(currentSettings());
}
function revert(): void {
turnServers = savedTurnServers;
turnUsername = savedTurnUsername;
turnCredential = savedTurnCredential;
}
</script>
<section class="browser-p2p-transport-settings">
<details>
<summary>Optional TURN server settings</summary>
<p>
Configure TURN only when a direct peer-to-peer connection cannot be established.
</p>
<label class:is-dirty={isTurnServersModified}>
<span>TURN Server URLs (comma-separated)</span>
<input
type="text"
placeholder="turn:turn.example.com:3478"
bind:value={turnServers}
autocomplete="off"
spellcheck="false"
autocorrect="off"
/>
</label>
<label class:is-dirty={isTurnUsernameModified}>
<span>TURN Username</span>
<input
type="text"
placeholder="Enter TURN username"
bind:value={turnUsername}
autocomplete="off"
/>
</label>
<label class:is-dirty={isTurnCredentialModified}>
<span>TURN Credential</span>
<input
type="password"
placeholder="Enter TURN credential"
bind:value={turnCredential}
autocomplete="new-password"
/>
</label>
<p>Configure TURN only when a direct peer-to-peer connection cannot be established.</p>
<TurnConfiguration bind:settings={draft} />
<div class="actions">
<button type="button" class="button mod-cta" disabled={!isModified} onclick={save}>
<button type="button" class="button mod-cta" disabled={!isModified || !!sourceError || sourceNeedsRoom} onclick={save}>
Save TURN settings
</button>
<button type="button" class="button" disabled={!isModified} onclick={revert}>
<button type="button" class="button" disabled={!isModified} onclick={() => loadSettings(currentSettings())}>
Revert TURN settings
</button>
</div>
@@ -107,27 +69,7 @@
</section>
<style>
.browser-p2p-transport-settings {
margin-bottom: 1rem;
}
p {
margin: 0.75rem 0;
}
label {
display: grid;
gap: 0.25rem;
margin: 0.75rem 0;
}
label.is-dirty {
background-color: var(--background-modifier-error);
}
input {
box-sizing: border-box;
width: 100%;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.browser-p2p-transport-settings { margin-bottom: 1rem; }
p { margin: 0.75rem 0; }
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
</style>
+5 -3
View File
@@ -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
@@ -92,10 +92,9 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
}
async getFiles(): Promise<NodeFile[]> {
if (this.fileCache.size === 0) {
await this.scanDirectory();
}
return Array.from(this.fileCache.values());
const files = new Map<string, NodeFile>();
await this.scanDirectoryInto("", files);
return Array.from(files.values());
}
async renameFile(file: NodeFile, newPath: string): Promise<NodeFile> {
@@ -147,6 +146,10 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
* Helper method to recursively scan directory and populate file cache
*/
async scanDirectory(relativePath: string = ""): Promise<void> {
await this.scanDirectoryInto(relativePath, this.fileCache);
}
private async scanDirectoryInto(relativePath: string, files: Map<string, NodeFile>): Promise<void> {
const fullPath = this.resolvePath(relativePath);
try {
const directoryStat = await this.storage.stat(relativePath);
@@ -160,10 +163,10 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
path: entryPath as FilePath,
stat,
};
this.fileCache.set(entryPath, file);
files.set(entryPath, file);
}
for (const entryPath of entries.folders) {
await this.scanDirectory(entryPath);
await this.scanDirectoryInto(entryPath, files);
}
} catch (error) {
// Directory doesn't exist or is not readable
@@ -0,0 +1,118 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter";
describe("NodeFileSystemAdapter file enumeration", () => {
const tempDirs: string[] = [];
const paths = ["a.md", "folder/b.md", "folder/sub/c.md"];
async function createVault() {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-cli-enumeration-"));
tempDirs.push(directory);
for (const file of paths) {
await fs.mkdir(path.dirname(path.join(directory, file)), { recursive: true });
await fs.writeFile(path.join(directory, file), `content of ${file}`);
}
return { directory, adapter: new NodeFileSystemAdapter(directory) };
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
});
it("lists every file when one file was refreshed before the first enumeration", async () => {
const { adapter } = await createVault();
expect(await adapter.refreshFile("folder/b.md")).not.toBeNull();
expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual(paths);
});
it("lists every file after a path lookup without any replication", async () => {
const { adapter } = await createVault();
expect((await adapter.getAbstractFileByPath("folder/b.md"))?.path).toBe("folder/b.md");
expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual(paths);
});
it("lists every file on the first enumeration without a prior path lookup", async () => {
const { adapter } = await createVault();
expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual(paths);
});
it("excludes a deleted file after its cache entry is refreshed", async () => {
const { directory, adapter } = await createVault();
await adapter.getFiles();
await fs.rm(path.join(directory, "folder/b.md"));
expect(await adapter.refreshFile("folder/b.md")).toBeNull();
expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual(["a.md", "folder/sub/c.md"]);
});
it("reflects files added and deleted between enumerations", async () => {
const { directory, adapter } = await createVault();
expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual(paths);
await fs.rm(path.join(directory, "folder/b.md"));
const updatedContent = "updated content of a.md";
await fs.writeFile(path.join(directory, "a.md"), updatedContent);
await fs.writeFile(path.join(directory, "later.md"), "content of later.md");
const files = await adapter.getFiles();
expect(files.map((file) => file.path).sort()).toEqual(["a.md", "folder/sub/c.md", "later.md"]);
expect(files.find((file) => file.path === "a.md")?.stat.size).toBe(updatedContent.length);
});
it("returns complete listings from simultaneous calls", async () => {
const { adapter } = await createVault();
const originalStat = adapter.storage.stat.bind(adapter.storage);
let releaseFolderStat!: () => void;
const folderStatReleased = new Promise<void>((resolve) => {
releaseFolderStat = resolve;
});
let folderStatStarted!: () => void;
const folderStatStartedPromise = new Promise<void>((resolve) => {
folderStatStarted = resolve;
});
let pauseFolderStat = true;
const statSpy = vi.spyOn(adapter.storage, "stat").mockImplementation(async (relativePath) => {
const stat = await originalStat(relativePath);
if (pauseFolderStat && relativePath === "folder") {
pauseFolderStat = false;
folderStatStarted();
await folderStatReleased;
}
return stat;
});
const firstListing = adapter.getFiles();
let listings: Awaited<ReturnType<typeof adapter.getFiles>>[] | undefined;
try {
await folderStatStartedPromise;
const secondListing = adapter.getFiles();
const secondFiles = await secondListing;
releaseFolderStat();
const firstFiles = await firstListing;
listings = [secondFiles, firstFiles];
} finally {
releaseFolderStat();
statSpy.mockRestore();
}
if (!listings) throw new Error("Expected both concurrent listings to complete");
expect(listings.map((files) => files.map((file) => file.path).sort())).toEqual([paths, paths]);
});
it("returns an empty listing for an empty vault", async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-cli-enumeration-empty-"));
tempDirs.push(directory);
const adapter = new NodeFileSystemAdapter(directory);
await expect(adapter.getFiles()).resolves.toEqual([]);
});
});
@@ -4,7 +4,7 @@ import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
import { runCommand } from "./runCommand";
import type { CLIOptions } from "./types";
// Mock performFullScan so daemon tests don't require a real CouchDB connection.
// Track explicit scans: database preparation owns the daemon startup scan.
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner", () => ({
performFullScan: vi.fn(async () => true),
}));
@@ -102,6 +102,7 @@ function createDaemonContext(core: ReturnType<typeof createCoreMock>) {
describe("daemon command", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.mocked(offlineScanner.performFullScan).mockClear();
vi.useFakeTimers();
});
@@ -109,27 +110,16 @@ describe("daemon command", () => {
vi.useRealTimers();
});
it("calls performFullScan during startup", async () => {
it("does not repeat the startup scan after initial replication", async () => {
const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
await runCommand(makeDaemonOptions(), createDaemonContext(core));
expect(await runCommand(makeDaemonOptions(), createDaemonContext(core))).toBe(true);
expect(offlineScanner.performFullScan).toHaveBeenCalledTimes(1);
});
it("returns false when performFullScan fails", async () => {
const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(false);
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
expect(result).toBe(false);
expect(offlineScanner.performFullScan).not.toHaveBeenCalled();
});
it("polling mode: calls setTimeout when interval option is set", async () => {
const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const context = createDaemonContext(core);
@@ -143,7 +133,6 @@ describe("daemon command", () => {
it("polling mode: applies settings with suspendFileWatching=false before setting interval", async () => {
const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
await runCommand(makeDaemonOptions(10), createDaemonContext(core));
@@ -156,7 +145,6 @@ describe("daemon command", () => {
it("liveSync mode: calls applyPartial and applySettings", async () => {
const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
await runCommand(makeDaemonOptions(), createDaemonContext(core));
@@ -176,7 +164,6 @@ describe("daemon command", () => {
liveSync: false,
syncOnStart: false,
}));
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
@@ -194,7 +181,6 @@ describe("daemon command", () => {
liveSync: true,
syncOnStart: false,
}));
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
await runCommand(makeDaemonOptions(), createDaemonContext(core));
@@ -205,22 +191,21 @@ describe("daemon command", () => {
expect(warningCalls.length).toBe(0);
});
it("calls replicate before performFullScan", async () => {
it("completes initial replication before restoring automatic synchronisation", async () => {
const core = createCoreMock();
const callOrder: string[] = [];
core.services.replication.replicateUnattended = vi.fn(async () => {
callOrder.push("replicate");
return { status: "completed" as const };
});
vi.mocked(offlineScanner.performFullScan).mockImplementation(async () => {
callOrder.push("performFullScan");
return true;
core.services.control.applySettings.mockImplementation(async () => {
callOrder.push("restoreSettings");
});
const context = createDaemonContext(core);
await runCommand(makeDaemonOptions(), context);
expect(callOrder).toEqual(["replicate", "performFullScan"]);
expect(callOrder).toEqual(["replicate", "restoreSettings"]);
expect(core.services.replication.replicateUnattended).toHaveBeenCalledWith({
trigger: "daemon",
interaction: NO_INTERACTION,
@@ -234,12 +219,11 @@ describe("daemon command", () => {
status: "failed" as const,
error: new Error("initial replication failed"),
}));
vi.mocked(offlineScanner.performFullScan).mockClear();
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
expect(result).toBe(false);
// performFullScan should NOT have been called
expect(core.services.control.applySettings).not.toHaveBeenCalled();
expect(offlineScanner.performFullScan).not.toHaveBeenCalled();
expect(core.services.replication.replicateUnattended).toHaveBeenCalledWith({
trigger: "daemon",
@@ -249,7 +233,6 @@ describe("daemon command", () => {
it("polling mode: registers onUnload handler that clears timeout", async () => {
const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
await runCommand(makeDaemonOptions(10), createDaemonContext(core));
@@ -265,7 +248,6 @@ describe("daemon command", () => {
it("polling backoff: interval escalates on failure, caps at 300000ms, then halves on recovery", async () => {
const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
// startup replicate (call 1) succeeds; poll calls 27 fail; call 8 succeeds.
let callCount = 0;
@@ -320,7 +302,6 @@ describe("daemon command", () => {
it("polling error handling: replicate rejection is caught and written to standard error", async () => {
const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
// Make replicate succeed on the initial call (startup), then fail on the poll.
let callCount = 0;
+6 -19
View File
@@ -15,8 +15,6 @@ 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 { 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";
import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput";
@@ -56,9 +54,9 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
// accept whatever configuration the remote has.
await core.services.setting.applyPartial({ disableCheckingConfigMismatch: true }, true);
// 1. Replicate the configured remote into the local database so the
// mirror scan has content to work with.
log("Replicating from remote...");
// Database preparation has already reconciled the local database and Vault.
// Replicate before restoring automatic synchronisation.
log("Replicating with remote...");
const replResult = await core.services.replication.replicateUnattended({
trigger: "daemon",
interaction: NO_INTERACTION,
@@ -70,17 +68,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
replicationScheduling.markInitialOneShotSatisfied();
log("Initial replication complete");
// 2. Mirror scan to reconcile PouchDB ↔ local filesystem.
const errorManager = new UnresolvedErrorManager(core.services.appLifecycle, core.services.context.events);
log("Running mirror scan...");
const scanOk = await performFullScan(core, log, errorManager, false, true);
if (!scanOk) {
writeStderrLine(standardIo, "[Daemon] Mirror scan failed, cannot continue");
return false;
}
log("Mirror scan complete");
// 3. Re-enable sync.
// Re-enable sync.
const restoreSyncSettings = async () => {
await core.services.setting.applyPartial(
{
@@ -527,9 +515,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
if (options.command === "mirror") {
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);
// Database preparation has already completed the mirror scan.
return true;
}
if (options.command === "remote-add") {
@@ -419,6 +419,30 @@ describe("runCommand abnormal cases", () => {
expect(appliedSettings.useIndexedDBAdapter).toBe(false);
});
it("setup imports managed TURN through the existing encrypted URI", async () => {
const core = createCoreMock();
const profiles = {
turn: { id: "turn", name: "TURN", isEncrypted: false,
uri: "sls+p2p://room?managedType=CF&managedId=turn-key&token=private-token" },
};
const passphrase = "setup-passphrase";
const setupURI = await processSetting.encodeSettingsToSetupURI(
{
...DEFAULT_SETTINGS,
remoteConfigurations: profiles,
},
passphrase
);
expect(setupURI.startsWith(configURIBase)).toBe(true);
expect(setupURI).not.toContain("private-token");
core.services.context.standardIo.prompt.mockResolvedValue(passphrase);
await runCommand(makeOptions("setup", [setupURI]), { ...context, core });
expect(core.services.setting.applyExternalSettings).toHaveBeenCalledWith(
expect.objectContaining({ remoteConfigurations: profiles }),
true
);
});
it("setup rejects encoded URI when passphrase is wrong", async () => {
const core = createCoreMock();
const setupURI = await createSetupURI("correct-passphrase");
+195
View File
@@ -0,0 +1,195 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import * as chokidar from "chokidar";
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
import { ServiceFileAccessCLI } from "./serviceModules/ServiceFileAccessImpl";
import { runCommand } from "./commands/runCommand";
import { createDefaultCliSettings } from "./cliSettingsDefaults";
import { main, type CliCommandRunner } from "./main";
vi.mock("chokidar", { spy: true });
function createStandardIoMock() {
return {
readStdin: vi.fn(async () => ""),
prompt: vi.fn(async () => ""),
writeStdout: vi.fn(),
writeStderr: vi.fn(),
};
}
describe("CLI database preparation", () => {
const originalArgv = process.argv.slice();
const originalExitCode = process.exitCode;
let directory: string;
let vaultPath: string;
let settingsPath: string;
let signalHandlers: Map<"SIGINT" | "SIGTERM", Set<NodeJS.SignalsListener>>;
let standardIo: ReturnType<typeof createStandardIoMock>;
beforeEach(async () => {
vi.mocked(chokidar.watch).mockClear();
directory = await mkdtemp(join(tmpdir(), "livesync-cli-bootstrap-"));
vaultPath = join(directory, "vault");
settingsPath = join(directory, "settings.json");
await mkdir(join(vaultPath, "notes"), { recursive: true });
await writeFile(join(vaultPath, "notes/local.md"), "local content");
await writeFile(settingsPath, JSON.stringify({ ...createDefaultCliSettings(), isConfigured: true }));
standardIo = createStandardIoMock();
signalHandlers = new Map(
(["SIGINT", "SIGTERM"] as const).map((signal) => [signal, new Set(process.listeners(signal))])
);
process.exitCode = undefined;
vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`__EXIT__:${code ?? 0}`);
});
});
afterEach(async () => {
for (const [signal, originalHandlers] of signalHandlers) {
for (const handler of process.listeners(signal)) {
if (!originalHandlers.has(handler)) process.removeListener(signal, handler);
}
}
process.argv = originalArgv.slice();
process.exitCode = originalExitCode;
vi.restoreAllMocks();
await rm(directory, { recursive: true, force: true });
});
async function start(command: "daemon" | "mirror" | "ls", runner: CliCommandRunner, exitCode = 1) {
process.argv = ["node", "livesync-cli", directory, "--vault", vaultPath, "--settings", settingsPath, command];
// Daemon probes return false so the real core unloads without keeping a daemon alive.
await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`);
}
it.each([
{ command: "daemon" as const, suspendFileWatching: false },
{ command: "mirror" as const, suspendFileWatching: false },
{ command: "mirror" as const, suspendFileWatching: true },
])(
"prepares the Vault before $command (watching suspended: $suspendFileWatching)",
async ({ command, suspendFileWatching }) => {
await writeFile(
settingsPath,
JSON.stringify({ ...createDefaultCliSettings(), isConfigured: true, suspendFileWatching })
);
await mkdir(join(vaultPath, ".livesync"));
await writeFile(join(vaultPath, ".livesync/ignore"), "*.tmp\n");
await writeFile(join(vaultPath, "notes/ignored.tmp"), "ignored");
const storedPaths: string[] = [];
let content: string | undefined;
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
for await (const doc of core.services.database.localDatabase.findAllNormalDocs()) {
storedPaths.push(doc.path);
}
const file = await core.serviceModules.databaseFileAccess.fetch("notes/local.md" as FilePathWithPrefix);
content = file ? await file.body.text() : undefined;
return false;
});
await start(command, runner);
expect(runner).toHaveBeenCalledOnce();
expect(storedPaths).toEqual(["notes/local.md"]);
expect(content).toBe("local content");
expect(await readFile(join(vaultPath, "notes/local.md"), "utf-8")).toBe("local content");
}
);
it("runs the mirror scan once and exits without starting file watching", async () => {
const enumerate = vi.spyOn(ServiceFileAccessCLI.prototype, "getFiles");
const watch = vi.mocked(chokidar.watch);
const runner = vi.fn<CliCommandRunner>(runCommand);
await start("mirror", runner, 0);
expect(runner).toHaveBeenCalledOnce();
expect(enumerate).toHaveBeenCalledOnce();
expect(watch).not.toHaveBeenCalled();
});
it.each([
{ command: "daemon" as const, commandRuns: true },
{ command: "mirror" as const, commandRuns: false },
])("handles an individual file failure during $command preparation", async ({ command, commandRuns }) => {
const store = vi
.spyOn(ServiceFileHandler.prototype, "storeFileToDB")
.mockRejectedValue(new Error("file failed"));
const unload = vi.spyOn(ControlService.prototype, "onUnload");
const runner = vi.fn<CliCommandRunner>(async () => false);
await start(command, runner);
expect(store).toHaveBeenCalledOnce();
expect(runner).toHaveBeenCalledTimes(commandRuns ? 1 : 0);
expect(unload).toHaveBeenCalledOnce();
expect(await readFile(join(vaultPath, "notes/local.md"), "utf-8")).toBe("local content");
});
it("does not import vault files for standalone database commands", async () => {
const storedPaths: string[] = [];
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
for await (const doc of core.services.database.localDatabase.findAllNormalDocs()) {
storedPaths.push(doc.path);
}
return false;
});
await start("ls", runner);
expect(runner).toHaveBeenCalledOnce();
expect(storedPaths).toEqual([]);
});
it("unloads without starting the command when database preparation fails", async () => {
vi.spyOn(ControlService.prototype, "onReady").mockResolvedValue(false);
const unload = vi.spyOn(ControlService.prototype, "onUnload");
const runner = vi.fn<CliCommandRunner>(async () => false);
const settingsBefore = await readFile(settingsPath, "utf-8");
await start("daemon", runner);
expect(runner).not.toHaveBeenCalled();
expect(unload).toHaveBeenCalledOnce();
expect(unload.mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(process.exit).mock.invocationCallOrder[0]);
expect(process.exit).toHaveBeenCalledWith(1);
expect(await readFile(settingsPath, "utf-8")).toBe(settingsBefore);
});
it("stops the daemon when the startup scanner refuses a suspended Vault scan", async () => {
await writeFile(
settingsPath,
JSON.stringify({ ...createDefaultCliSettings(), isConfigured: true, suspendFileWatching: true })
);
const unload = vi.spyOn(ControlService.prototype, "onUnload");
const runner = vi.fn<CliCommandRunner>(async () => false);
await start("daemon", runner);
expect(runner).not.toHaveBeenCalled();
expect(unload).toHaveBeenCalledOnce();
expect(process.exit).toHaveBeenCalledWith(1);
});
it("unloads when database preparation throws", async () => {
const ready = vi.spyOn(ControlService.prototype, "onReady").mockRejectedValue(new Error("scan failed"));
const unload = vi.spyOn(ControlService.prototype, "onUnload");
const runner = vi.fn<CliCommandRunner>(async () => false);
try {
await start("daemon", runner);
expect(runner).not.toHaveBeenCalled();
expect(unload).toHaveBeenCalledOnce();
expect(standardIo.writeStderr.mock.calls.flat().join("")).toContain("scan failed");
} finally {
const control = ready.mock.contexts[0];
if (unload.mock.calls.length === 0 && control instanceof ControlService) await control.onUnload();
}
});
});
+54 -14
View File
@@ -1,6 +1,7 @@
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { LiveSyncBaseCore, type StartupDatabaseOptions } from "@/LiveSyncBaseCore";
import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules";
import {
LOG_LEVEL_VERBOSE,
@@ -24,6 +25,7 @@ import { getPathFromUXFileInfo } from "@vrtmrz/livesync-commonlib/compat/common/
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { IgnoreRules } from "./serviceModules/IgnoreRules";
import { useP2PReplicatorFeature, type UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/p2p";
import { useOfflineScanner } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import type { ReplicationSchedulingControl } from "@/serviceFeatures/replicationScheduling";
import { createNodeStandardIo, fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
@@ -41,6 +43,27 @@ import {
} from "./settingsPersistence";
const SETTINGS_FILE = ".livesync/settings.json";
interface CLIVaultSyncMode {
readonly watchFiles: boolean;
readonly reflectReplicationResults: boolean;
readonly startupDatabaseOptions: StartupDatabaseOptions;
}
// Commands which synchronise a physical Vault with the local database.
const VAULT_SYNC_MODES: Readonly<Partial<Record<CLICommand, CLIVaultSyncMode>>> = {
daemon: {
watchFiles: true,
reflectReplicationResults: true,
startupDatabaseOptions: { ignoreSuspending: false, continueOnFileFailure: true },
},
mirror: {
watchFiles: false,
reflectReplicationResults: false,
startupDatabaseOptions: { ignoreSuspending: true, continueOnFileFailure: false },
},
};
ensureGlobalNodeLocalStorage();
defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG;
@@ -296,6 +319,7 @@ export async function main(
commandRunner: CliCommandRunner = runCommand
) {
const options = parseArgs(standardIo);
const vaultSyncMode = VAULT_SYNC_MODES[options.command];
if (options.interval && options.command !== "daemon") {
writeStderrLine(
standardIo,
@@ -357,9 +381,6 @@ export async function main(
// Resolve vault path: mirror positional argument takes priority,
// then --vault flag, otherwise fall back to databasePath.
// For daemon mode, enable chokidar file watching so the _changes feed picks up events.
// mirror runs a single full scan and doesn't need continuous watching.
const watchEnabled = options.command === "daemon";
const vaultPath =
options.command === "mirror" && options.commandArgs[0]
? path.resolve(options.commandArgs[0])
@@ -385,7 +406,7 @@ export async function main(
infoLog(`Settings: ${settingsPath}`);
infoLog("");
let ignoreRules: IgnoreRules | undefined;
if (options.command === "daemon" || options.command === "mirror") {
if (vaultSyncMode) {
ignoreRules = new IgnoreRules(vaultPath, (message, detail) => {
if (detail === undefined) {
writeStderrLine(standardIo, message);
@@ -426,9 +447,8 @@ export async function main(
}
writeStderrLine(standardIo, prefix, message);
}, true);
// Prevent replication result from being processed automatically in non-daemon commands.
// In daemon mode the default handler must run so changes are applied to the filesystem.
if (options.command !== "daemon") {
// Only modes which reflect replication results use the default filesystem handler.
if (!vaultSyncMode?.reflectReplicationResults) {
serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => {
writeStderrLine(
standardIo,
@@ -489,14 +509,25 @@ export async function main(
const core = new LiveSyncBaseCore(
serviceHubInstance,
(core: LiveSyncBaseCore<NodeServiceContext, never>, serviceHub: InjectableServiceHub<NodeServiceContext>) => {
return initialiseServiceModulesCLI(vaultPath, core, serviceHub, ignoreRules, watchEnabled);
return initialiseServiceModulesCLI(
vaultPath,
core,
serviceHub,
ignoreRules,
vaultSyncMode?.watchFiles ?? false
);
},
(core) => [],
() => [], // No add-ons
(core, coreFeatureViews) => {
replicationScheduling = coreFeatureViews.replicationScheduling;
if (vaultSyncMode) {
useOfflineScanner(core);
}
// Register P2P replicator feature.
p2pReplicator = useP2PReplicatorFeature(core);
p2pReplicator = useP2PReplicatorFeature(core, undefined, undefined, {
prepareP2PSettings: useP2PSettingsPreparation(core.services.API.webCompatFetch.bind(core.services.API)),
});
// Add target filter to prevent internal files are handled
core.services.vault.isTargetFile.addHandler(async (target) => {
const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target));
@@ -512,7 +543,7 @@ export async function main(
return await Promise.resolve(true);
}, -1 /* highest priority */);
// Apply user-defined ignore rules for daemon mode (lower priority, runs after dotfile check).
// Apply user-defined ignore rules after the dotfile check.
if (ignoreRules) {
const rules = ignoreRules;
core.services.vault.isTargetFile.addHandler(async (target) => {
@@ -524,7 +555,8 @@ export async function main(
return true;
}, 0);
}
}
},
vaultSyncMode?.startupDatabaseOptions
);
if (!replicationScheduling) {
throw new Error("Replication scheduling was not provided during core feature composition.");
@@ -577,7 +609,7 @@ export async function main(
: originalSettingsText;
// Capture sync settings before suspendAllSync() clobbers them.
// Used by daemon mode to restore the correct sync behaviour after the mirror scan.
// Used by daemon mode to restore sync behaviour after initial replication.
const settingsBeforeSuspend = cloneSettings(core.services.setting.currentSettings());
const durableSettingsBeforeSuspend = cloneSettings(settingsBeforeSuspend);
applyStoredSetting(durableSettingsBeforeSuspend, settingsAfterLoadText, "useIndexedDBAdapter");
@@ -592,7 +624,15 @@ export async function main(
};
await core.services.setting.suspendAllSync();
const settingsAfterSuspend = cloneSettings(core.services.setting.currentSettings());
await core.services.control.onReady();
let readyResult = false;
try {
readyResult = await core.services.control.onReady();
} finally {
if (!readyResult) await core.services.control.onUnload();
}
if (!readyResult) {
throw new Error("Failed to initialise LiveSync.");
}
const settingsBeforeCommand = cloneSettings(core.services.setting.currentSettings());
const transientSettingKeys = changedSettingKeys(settingsBeforeSuspend, settingsAfterSuspend);
for (const key of CLI_RUNTIME_ONLY_SETTING_KEYS) {
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "1.0.24-cli",
"version": "1.0.29-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
@@ -12,7 +12,7 @@
"buildRun": "npm run build && npm run cli --",
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
"check": "tsc -p tsconfig.json",
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts",
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
@@ -37,7 +37,7 @@
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
"octagonal-wheels": "^0.1.53",
"octagonal-wheels": "^0.1.54",
"pouchdb-adapter-http": "^9.0.0",
"pouchdb-adapter-leveldb": "^9.0.0",
"pouchdb-core": "^9.0.0",
@@ -51,7 +51,7 @@
"werift": "^0.24.4"
},
"devDependencies": {
"typescript": "5.9.3",
"typescript": "6.0.3",
"vite": "^8.0.16",
"vitest": "^4.1.8"
}
+27 -10
View File
@@ -307,10 +307,19 @@ cli_test_wait_for_minio_bucket() {
local delay_sec=2
local i
for ((i = 1; i <= retries; i++)); do
if docker run --rm --network host --entrypoint=/bin/sh minio/mc -c "mc alias set myminio $minio_endpoint $minio_access_key $minio_secret_key >/dev/null 2>&1 && mc ls myminio/$minio_bucket >/dev/null 2>&1"; then
if docker run --rm --network host --entrypoint=/bin/sh \
rustfs/rc:v0.1.35@sha256:adb45b56539006120f1d790bcc17ee5f9b4d93c1d7e71ed0a24f10267f9d6914 \
-c 'set -e
rc alias set myminio "$1" "$2" "$3" >/dev/null 2>&1
rc ls "myminio/$4" >/dev/null 2>&1
' sh "$minio_endpoint" "$minio_access_key" "$minio_secret_key" "$minio_bucket"; then
return 0
fi
bucketName="$minio_bucket" bash "$CLI_DIR/util/minio-init.sh" >/dev/null 2>&1 || true
minioEndpoint="$minio_endpoint" \
accessKey="$minio_access_key" \
secretKey="$minio_secret_key" \
bucketName="$minio_bucket" \
bash "$CLI_DIR/util/minio-init.sh" >/dev/null 2>&1 || true
sleep "$delay_sec"
done
return 1
@@ -323,26 +332,34 @@ cli_test_start_minio() {
local minio_bucket="$4"
local minio_init_ok=0
echo "[INFO] stopping leftover MinIO container if present"
echo "[INFO] stopping leftover RustFS container if present"
cli_test_stop_minio
echo "[INFO] starting MinIO test container"
bucketName="$minio_bucket" bash "$CLI_DIR/util/minio-start.sh"
echo "[INFO] starting RustFS test container"
minioEndpoint="$minio_endpoint" \
accessKey="$minio_access_key" \
secretKey="$minio_secret_key" \
bucketName="$minio_bucket" \
bash "$CLI_DIR/util/minio-start.sh"
echo "[INFO] initialising MinIO test bucket: $minio_bucket"
echo "[INFO] initialising RustFS test bucket: $minio_bucket"
for _ in 1 2 3 4 5; do
if bucketName="$minio_bucket" bash "$CLI_DIR/util/minio-init.sh"; then
if minioEndpoint="$minio_endpoint" \
accessKey="$minio_access_key" \
secretKey="$minio_secret_key" \
bucketName="$minio_bucket" \
bash "$CLI_DIR/util/minio-init.sh"; then
minio_init_ok=1
break
fi
sleep 2
done
if [[ "$minio_init_ok" != "1" ]]; then
echo "[FAIL] could not initialise MinIO bucket after retries: $minio_bucket" >&2
echo "[FAIL] could not initialise RustFS bucket after retries: $minio_bucket" >&2
exit 1
fi
if ! cli_test_wait_for_minio_bucket "$minio_endpoint" "$minio_access_key" "$minio_secret_key" "$minio_bucket"; then
echo "[FAIL] MinIO bucket not ready: $minio_bucket" >&2
echo "[FAIL] RustFS bucket not ready: $minio_bucket" >&2
exit 1
fi
}
@@ -359,4 +376,4 @@ display_test_info(){
if [[ "${LIVESYNC_TEST_DOCKER:-0}" == "1" ]]; then
# shellcheck source=/dev/null
source "$(dirname "${BASH_SOURCE[0]}")/test-helpers-docker.sh"
fi
fi
+1
View File
@@ -5,6 +5,7 @@
"test:p2p:compose": "deno run -A --no-check run-compose-p2p.ts",
"test:local": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts test-mirror.ts test-daemon.ts",
"test:daemon": "deno test --env-file=.test.env -A --no-check test-daemon.ts",
"test:daemon-startup": "deno test --env-file=.test.env -A --no-check test-daemon-startup.ts",
"test:decoupled-vault": "deno test --env-file=.test.env -A --no-check test-decoupled-vault.ts",
"test:remote-commands": "deno test --env-file=.test.env -A --no-check test-remote-commands.ts",
"test:settings-writeback": "deno test -A --no-check test-settings-writeback.ts",
+37 -23
View File
@@ -190,7 +190,7 @@ async function dockerOrFail(...args: string[]): Promise<string> {
async function stopAndRemoveContainer(container: string): Promise<void> {
await docker("stop", container).catch(() => {});
await docker("rm", container).catch(() => {});
await docker("rm", "-v", container).catch(() => {});
}
async function cleanupTrackedContainers(reason: string): Promise<void> {
@@ -327,8 +327,22 @@ const COUCHDB_CONTAINER = "couchdb-test";
const COUCHDB_IMAGE = "couchdb:3.5.0";
const MINIO_CONTAINER = "minio-test";
const MINIO_IMAGE = "minio/minio";
const MINIO_MC_IMAGE = "minio/mc";
// RustFS provides the S3 backend for the existing MINIO test mode.
const S3_IMAGE = "rustfs/rustfs:1.0.0-rc.6@sha256:97171b3d72cd47dc81000f92ea84de25608bfc35a94c965501afaeb5d99f6035";
const S3_CLIENT_IMAGE = "rustfs/rc:v0.1.35@sha256:adb45b56539006120f1d790bcc17ee5f9b4d93c1d7e71ed0a24f10267f9d6914";
const S3_BUCKET_CORS = `<CORSConfiguration>
<CORSRule>
<AllowedOrigin>*</AllowedOrigin>
<AllowedMethod>GET</AllowedMethod>
<AllowedMethod>PUT</AllowedMethod>
<AllowedMethod>POST</AllowedMethod>
<AllowedMethod>DELETE</AllowedMethod>
<AllowedMethod>HEAD</AllowedMethod>
<AllowedHeader>*</AllowedHeader>
<AllowedHeader>authorization</AllowedHeader>
<ExposeHeader>ETag</ExposeHeader>
</CORSRule>
</CORSConfiguration>`;
export async function stopCouchdb(): Promise<void> {
await stopAndRemoveContainer(COUCHDB_CONTAINER);
@@ -454,7 +468,7 @@ export async function updateCouchdbDoc(
}
// ---------------------------------------------------------------------------
// MinIO
// S3 (RustFS)
// ---------------------------------------------------------------------------
function shQuote(value: string): string {
@@ -473,9 +487,10 @@ async function initMinioBucket(
bucket: string
): Promise<boolean> {
const cmd =
`mc alias set myminio ${shQuote(minioEndpoint)} ${shQuote(accessKey)} ${shQuote(secretKey)} >/dev/null 2>&1 && ` +
`mc mb --ignore-existing myminio/${shQuote(bucket)} >/dev/null 2>&1`;
const r = await docker("run", "--rm", "--network", "host", "--entrypoint", "/bin/sh", MINIO_MC_IMAGE, "-c", cmd);
`rc alias set myminio ${shQuote(minioEndpoint)} ${shQuote(accessKey)} ${shQuote(secretKey)} >/dev/null 2>&1 && ` +
`rc mb --ignore-existing myminio/${shQuote(bucket)} >/dev/null 2>&1 && ` +
`printf %s ${shQuote(S3_BUCKET_CORS)} | rc cors set myminio/${shQuote(bucket)} - >/dev/null 2>&1`;
const r = await docker("run", "--rm", "--network", "host", "--entrypoint", "/bin/sh", S3_CLIENT_IMAGE, "-c", cmd);
return r.code === 0;
}
@@ -487,8 +502,8 @@ async function waitForMinioBucket(
): Promise<void> {
for (let i = 0; i < 30; i++) {
const checkCmd =
`mc alias set myminio ${shQuote(minioEndpoint)} ${shQuote(accessKey)} ${shQuote(secretKey)} >/dev/null 2>&1 && ` +
`mc ls myminio/${shQuote(bucket)} >/dev/null 2>&1`;
`rc alias set myminio ${shQuote(minioEndpoint)} ${shQuote(accessKey)} ${shQuote(secretKey)} >/dev/null 2>&1 && ` +
`rc ls myminio/${shQuote(bucket)} >/dev/null 2>&1`;
const check = await docker(
"run",
"--rm",
@@ -498,7 +513,7 @@ async function waitForMinioBucket(
"host",
"--entrypoint",
"/bin/sh",
MINIO_MC_IMAGE,
S3_CLIENT_IMAGE,
"-c",
checkCmd
);
@@ -508,7 +523,7 @@ async function waitForMinioBucket(
await initMinioBucket(minioEndpoint, accessKey, secretKey, bucket);
await sleep(2000);
}
throw new Error(`MinIO bucket not ready: ${bucket}`);
throw new Error(`S3 bucket not ready: ${bucket}`);
}
export async function startMinio(
@@ -517,10 +532,10 @@ export async function startMinio(
secretKey: string,
bucket: string
): Promise<void> {
console.log("[INFO] stopping leftover MinIO container if present");
console.log("[INFO] stopping leftover S3 test container if present");
await stopMinio().catch(() => {});
console.log("[INFO] starting MinIO test container");
console.log("[INFO] starting RustFS test container");
await dockerOrFail(
"run",
"-d",
@@ -532,20 +547,19 @@ export async function startMinio(
"-p",
"9001:9001",
"-e",
`MINIO_ROOT_USER=${accessKey}`,
`RUSTFS_ACCESS_KEY=${accessKey}`,
"-e",
`MINIO_ROOT_PASSWORD=${secretKey}`,
`RUSTFS_SECRET_KEY=${secretKey}`,
"-e",
`MINIO_SERVER_URL=${minioEndpoint}`,
MINIO_IMAGE,
"server",
"/data",
"--console-address",
":9001"
"RUSTFS_CONSOLE_ENABLE=true",
"-e",
"RUSTFS_CORS_ALLOWED_ORIGINS=*",
S3_IMAGE,
"/data"
);
trackContainer(MINIO_CONTAINER);
console.log(`[INFO] initialising MinIO test bucket: ${bucket}`);
console.log(`[INFO] initialising S3 test bucket: ${bucket}`);
let initialised = false;
for (let i = 0; i < 5; i++) {
if (await initMinioBucket(minioEndpoint, accessKey, secretKey, bucket)) {
@@ -555,7 +569,7 @@ export async function startMinio(
await sleep(2000);
}
if (!initialised) {
throw new Error(`Could not initialise MinIO bucket after retries: ${bucket}`);
throw new Error(`Could not initialise S3 bucket after retries: ${bucket}`);
}
await waitForMinioBucket(minioEndpoint, accessKey, secretKey, bucket);
+1
View File
@@ -4,6 +4,7 @@ const TASKS = [
"test:setup-put-cat",
"test:mirror",
"test:daemon",
"test:daemon-startup",
"test:push-pull",
"test:decoupled-vault",
"test:sync-two-local",
@@ -0,0 +1,152 @@
import { assertEquals } from "@std/assert";
import { join } from "@std/path";
import { TempDir } from "./helpers/temp.ts";
import { runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts";
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
import { startCliInBackground, type BackgroundCliProcess } from "./helpers/backgroundCli.ts";
import { startCouchdb, stopCouchdb } from "./helpers/docker.ts";
function envOrDefault(keys: string[], fallback: string): string {
for (const key of keys) {
const value = Deno.env.get(key)?.trim();
if (value) return value;
}
return fallback;
}
function waitForTick(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 100));
}
async function waitForText(filePath: string, expected: string, timeoutMs = 45_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
let actual = "";
while (Date.now() < deadline) {
try {
actual = await Deno.readTextFile(filePath);
if (actual === expected) return;
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
}
await waitForTick();
}
throw new Error(
`Timed out waiting for ${filePath} to contain ${JSON.stringify(expected)}; actual=${JSON.stringify(actual)}`
);
}
async function waitForMissing(filePath: string, timeoutMs = 45_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await Deno.stat(filePath);
} catch (error) {
if (error instanceof Deno.errors.NotFound) return;
throw error;
}
await waitForTick();
}
throw new Error(`Timed out waiting for ${filePath} to be removed`);
}
async function stopDaemon(daemon: BackgroundCliProcess | undefined): Promise<void> {
if (!daemon) return;
await daemon.stop().catch(() => {});
}
Deno.test("daemon: startup scan uploads, reconciles, and reflects CouchDB files", async () => {
await using workDir = await TempDir.create("livesync-cli-daemon-startup");
const couchdbUri = envOrDefault(["COUCHDB_URI", "hostname"], "http://127.0.0.1:5989").replace(/\/$/, "");
const couchdbUser = envOrDefault(["COUCHDB_USER", "username"], "admin");
const couchdbPassword = envOrDefault(["COUCHDB_PASSWORD", "password"], "testpassword");
const dbPrefix = envOrDefault(["COUCHDB_DBNAME", "dbname"], "livesync-test-db-ci");
const dbname = `${dbPrefix}-daemon-startup-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`.toLowerCase();
const databaseA = workDir.join("database-a");
const databaseB = workDir.join("database-b");
const databaseC = workDir.join("database-c");
const vaultA = workDir.join("vault-a");
const vaultB = workDir.join("vault-b");
const vaultC = workDir.join("vault-c");
const settingsA = workDir.join("settings-a.json");
const settingsB = workDir.join("settings-b.json");
const settingsC = workDir.join("settings-c.json");
await Promise.all([
Deno.mkdir(databaseA, { recursive: true }),
Deno.mkdir(databaseB, { recursive: true }),
Deno.mkdir(databaseC, { recursive: true }),
Deno.mkdir(vaultA, { recursive: true }),
Deno.mkdir(vaultB, { recursive: true }),
Deno.mkdir(vaultC, { recursive: true }),
]);
const startupPath = "notes/present-before-start.md";
const deletePath = "notes/deleted-while-stopped.md";
const remoteOnlyPath = "notes/remote-only.md";
const startupFileA = join(vaultA, startupPath);
const deleteFileA = join(vaultA, deletePath);
const startupFileB = join(vaultB, startupPath);
const deleteFileB = join(vaultB, deletePath);
const remoteOnlyFileB = join(vaultB, remoteOnlyPath);
await Deno.mkdir(join(vaultA, "notes"), { recursive: true });
await Deno.writeTextFile(startupFileA, "created before daemon startup\n");
const initialTime = new Date(Date.now() - 10_000);
await Deno.utime(startupFileA, initialTime, initialTime);
await Deno.writeTextFile(deleteFileA, "delete this after the first run\n");
let daemonA: BackgroundCliProcess | undefined;
let daemonB: BackgroundCliProcess | undefined;
try {
await startCouchdb(couchdbUri, couchdbUser, couchdbPassword, dbname);
for (const settings of [settingsA, settingsB, settingsC]) {
await initSettingsFile(settings);
await applyCouchdbSettings(settings, couchdbUri, couchdbUser, couchdbPassword, dbname, true);
}
// A pre-existing local file must be uploaded by the daemon's startup scan.
daemonA = startCliInBackground(databaseA, "--vault", vaultA, "--settings", settingsA, "daemon");
await daemonA.waitUntilContains("[Daemon] Initial replication complete", 45_000);
// A separate daemon proves that the first startup replication reached CouchDB
// and that remote files are reflected into its filesystem.
daemonB = startCliInBackground(databaseB, "--vault", vaultB, "--settings", settingsB, "daemon");
await daemonB.waitUntilContains("[Daemon] Initial replication complete", 45_000);
await waitForText(startupFileB, "created before daemon startup\n");
await waitForText(deleteFileB, "delete this after the first run\n");
// Changes made while A is stopped must be found by its next startup scan.
assertEquals(await daemonA.stop(), 0, daemonA.combined);
daemonA = undefined;
await Deno.writeTextFile(startupFileA, "edited while daemon was stopped\n");
await Deno.remove(deleteFileA);
daemonA = startCliInBackground(databaseA, "--vault", vaultA, "--settings", settingsA, "daemon");
await daemonA.waitUntilContains("[Daemon] Initial replication complete", 45_000);
await waitForText(startupFileB, "edited while daemon was stopped\n");
await waitForMissing(deleteFileB);
// Seed a file into a third local database without creating it in vault C.
// After C's finite sync, it exists only remotely from B's point of view.
await runCliWithInputOrFail(
"created in a different local database\n",
databaseC,
"--vault",
vaultC,
"--settings",
settingsC,
"put",
remoteOnlyPath
);
await runCliOrFail(databaseC, "--vault", vaultC, "--settings", settingsC, "sync");
await waitForText(remoteOnlyFileB, "created in a different local database\n");
assertEquals(await Deno.readTextFile(startupFileB), "edited while daemon was stopped\n");
assertEquals((await Deno.stat(remoteOnlyFileB)).isFile, true);
} finally {
await stopDaemon(daemonB);
await stopDaemon(daemonA);
await stopCouchdb().catch(() => {});
}
});
@@ -60,6 +60,32 @@ export async function runScenario(remoteType: RemoteType, encrypt: boolean): Pro
}
try {
if (remoteType === "MINIO") {
// The shared S3 fixture also serves the browser and real Obsidian tests.
const origin = "app://obsidian.md";
const requestedHeaders = ["authorization", "content-type", "x-amz-date", "x-amz-content-sha256"];
const preflight = await fetch(`${minioEndpoint}/${minioBucket}`, {
method: "OPTIONS",
headers: {
Origin: origin,
"Access-Control-Request-Method": "PUT",
"Access-Control-Request-Headers": requestedHeaders.join(","),
},
});
await preflight.body?.cancel();
assert(preflight.ok, "The S3 fixture must accept browser preflight requests");
const allowedOrigin = preflight.headers.get("access-control-allow-origin");
assert(allowedOrigin === "*" || allowedOrigin === origin, "The S3 fixture must allow the Obsidian origin");
const allowedHeaders = (preflight.headers.get("access-control-allow-headers") ?? "")
.toLowerCase()
.split(",")
.map((header) => header.trim());
assert(allowedHeaders.includes("authorization"), "S3 CORS must explicitly allow the Authorization header");
assert(
preflight.headers.get("access-control-allow-methods")?.split(/,\s*/).includes("PUT"),
"S3 CORS must allow browser uploads"
);
}
await initSettingsFile(settingsA);
await initSettingsFile(settingsB);
await applyRemoteSyncSettings(settingsA, {
+4 -3
View File
@@ -99,11 +99,12 @@ This file corresponds to settings helpers in `test-helpers.sh`.
### `helpers/docker.ts`
- Starts, stops, and initialises CouchDB directly from Deno.
- Starts, stops, and initialises CouchDB and RustFS directly from Deno.
- Configures CouchDB via `fetch + retry`.
- Initialises S3 buckets using the RustFS `rc` client, including CORS for signed browser requests.
- Starts and stops the P2P relay through the same Docker runner.
Both CouchDB and P2P relay flows are bash-independent.
These flows do not require Bash on the host. The S3 matrix tasks, environment variables, and container name retain their existing `minio` names for compatibility; RustFS provides the test backend. The RustFS server and `rc` client images are pinned by version and digest.
### `helpers/backgroundCli.ts`
@@ -328,7 +329,7 @@ The GitHub Actions workflow `.github/workflows/cli-deno-tests.yml` runs automati
## Current limitations
- MinIO startup and matrix coverage are ported. Current limits are elsewhere, not setup URI generation.
- S3 startup and matrix coverage use RustFS. Current limits are elsewhere, not setup URI generation.
---
+21 -44
View File
@@ -1,47 +1,24 @@
#!/bin/bash
set -e
cat >/tmp/mybucket-rw.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetBucketLocation","s3:ListBucket"],
"Resource": ["arn:aws:s3:::$bucketName"]
},
{
"Effect": "Allow",
"Action": ["s3:GetObject","s3:PutObject","s3:DeleteObject"],
"Resource": ["arn:aws:s3:::$bucketName/*"]
}
]
}
EOF
# echo "<CORSConfiguration>
# <CORSRule>
# <AllowedOrigin>http://localhost:63315</AllowedOrigin>
# <AllowedOrigin>http://localhost:63316</AllowedOrigin>
# <AllowedOrigin>http://localhost</AllowedOrigin>
# <AllowedMethod>GET</AllowedMethod>
# <AllowedMethod>PUT</AllowedMethod>
# <AllowedMethod>POST</AllowedMethod>
# <AllowedMethod>DELETE</AllowedMethod>
# <AllowedMethod>HEAD</AllowedMethod>
# <AllowedHeader>*</AllowedHeader>
# </CORSRule>
# </CORSConfiguration>" > /tmp/cors.xml
# docker run --rm --network host -v /tmp/mybucket-rw.json:/tmp/mybucket-rw.json --entrypoint=/bin/sh minio/mc -c "
# mc alias set myminio $minioEndpoint $username $password
# mc mb --ignore-existing myminio/$bucketName
# mc admin policy create myminio my-custom-policy /tmp/mybucket-rw.json
# echo 'Creating service account for user $username with access key $accessKey'
# mc admin user svcacct add --access-key '$accessKey' --secret-key '$secretKey' myminio '$username'
# mc admin policy attach myminio my-custom-policy --user '$accessKey'
# echo 'Verifying policy and user creation:'
# mc admin user svcacct info myminio '$accessKey'
# "
docker run --rm --network host -v /tmp/mybucket-rw.json:/tmp/mybucket-rw.json --entrypoint=/bin/sh minio/mc -c "
mc alias set myminio $minioEndpoint $accessKey $secretKey
mc mb --ignore-existing myminio/$bucketName
"
docker run --rm --network host --entrypoint=/bin/sh \
rustfs/rc:v0.1.35@sha256:adb45b56539006120f1d790bcc17ee5f9b4d93c1d7e71ed0a24f10267f9d6914 \
-c 'set -e
rc alias set myminio "$1" "$2" "$3"
rc mb --ignore-existing "myminio/$4"
rc cors set "myminio/$4" - <<CORS
<CORSConfiguration>
<CORSRule>
<AllowedOrigin>*</AllowedOrigin>
<AllowedMethod>GET</AllowedMethod>
<AllowedMethod>PUT</AllowedMethod>
<AllowedMethod>POST</AllowedMethod>
<AllowedMethod>DELETE</AllowedMethod>
<AllowedMethod>HEAD</AllowedMethod>
<AllowedHeader>*</AllowedHeader>
<AllowedHeader>authorization</AllowedHeader>
<ExposeHeader>ETag</ExposeHeader>
</CORSRule>
</CORSConfiguration>
CORS
' sh "$minioEndpoint" "$accessKey" "$secretKey" "$bucketName"
+7 -1
View File
@@ -1,2 +1,8 @@
#!/bin/bash
docker run -d --name minio-test -p 9000:9000 -p 9001:9001 -e MINIO_ROOT_USER=$accessKey -e MINIO_ROOT_PASSWORD=$secretKey -e MINIO_SERVER_URL=$minioEndpoint minio/minio server /data --console-address ':9001'
docker run -d --name minio-test \
-p 9000:9000 -p 9001:9001 \
-e "RUSTFS_ACCESS_KEY=$accessKey" \
-e "RUSTFS_SECRET_KEY=$secretKey" \
-e "RUSTFS_CONSOLE_ENABLE=true" \
-e 'RUSTFS_CORS_ALLOWED_ORIGINS=*' \
rustfs/rustfs:1.0.0-rc.6@sha256:97171b3d72cd47dc81000f92ea84de25608bfc35a94c965501afaeb5d99f6035 /data
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/bash
docker stop minio-test
docker rm minio-test
docker rm -v minio-test
+4 -1
View File
@@ -1,3 +1,4 @@
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
/** Browser runtime for Self-hosted LiveSync over the File System Access API. */
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
@@ -217,7 +218,9 @@ export class WebAppRuntime {
useRedFlagFeatures(core);
useCheckRemoteSize(core);
useRemoteConfiguration(core);
this.p2p = useP2PReplicatorFeature(core);
this.p2p = useP2PReplicatorFeature(core, undefined, undefined, {
prepareP2PSettings: useP2PSettingsPreparation(core.services.API.webCompatFetch.bind(core.services.API)),
});
this.paneHost = {
services: core.services,
p2p: this.p2p,
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "1.0.24-webapp",
"version": "1.0.29-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
@@ -15,12 +15,12 @@
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webapp/browser-smoke.test.ts"
},
"dependencies": {
"octagonal-wheels": "^0.1.53"
"octagonal-wheels": "^0.1.54"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"svelte": "5.56.3",
"typescript": "5.9.3",
"typescript": "6.0.3",
"vite": "^8.0.16"
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "1.0.24-webpeer",
"version": "1.0.29-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
@@ -15,7 +15,7 @@
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webpeer/browser-smoke.test.ts"
},
"dependencies": {
"octagonal-wheels": "^0.1.53"
"octagonal-wheels": "^0.1.54"
},
"devDependencies": {
"eslint-plugin-svelte": "^3.19.0",
@@ -23,7 +23,7 @@
"@tsconfig/svelte": "^5.0.8",
"svelte": "5.56.3",
"svelte-check": "^4.6.0",
"typescript": "5.9.3",
"typescript": "6.0.3",
"vite": "^8.0.16"
}
}
+3 -3
View File
@@ -1,3 +1,4 @@
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
import { type P2PSyncSetting, SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
@@ -70,9 +71,8 @@ export class WebPeerRuntime {
isScheduled: () => this.restartScheduled,
},
});
this.p2p = useP2PReplicatorFeature({
services: this.services,
serviceModules: {},
this.p2p = useP2PReplicatorFeature({ services: this.services, serviceModules: {} }, undefined, undefined, {
prepareP2PSettings: useP2PSettingsPreparation(this.services.API.webCompatFetch.bind(this.services.API)),
});
this.p2pLogCollector = new P2PLogCollector(this.events);
this.paneHost = {
@@ -7,6 +7,26 @@
* remove it from this map in the same change.
*/
export const liveSyncProvisionalEnglishMessages = {
"Configure TURN when a direct connection cannot be established or when you select TURN relay only.":
"Configure TURN when a direct connection cannot be established or when you select TURN relay only.",
"TURN configuration": "TURN configuration",
Manual: "Manual",
"Managed (Cloudflare)": "Managed (Cloudflare)",
"TURN Key ID": "TURN Key ID",
"TURN Key API Token": "TURN Key API Token",
"Unsupported TURN configuration": "Unsupported TURN configuration",
"The API token is saved with this profile and included in Setup URI and QR code sharing. Temporary TURN credentials are kept in memory only.":
"The API token is saved with this profile and included in Setup URI and QR code sharing. Temporary TURN credentials are kept in memory only.",
"TURN relay only requires a TURN server or a configured credential source under Advanced Settings.":
"TURN relay only requires a TURN server or a configured credential source under Advanced Settings.",
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic.":
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic.",
"Enter a TURN Key ID.": "Enter a TURN Key ID.",
"TURN Key ID contains unsupported characters.": "TURN Key ID contains unsupported characters.",
"Enter a TURN Key API Token.": "Enter a TURN Key API Token.",
"TURN Key API Token must use Bearer token syntax.": "TURN Key API Token must use Bearer token syntax.",
"The selected TURN configuration is not supported.": "The selected TURN configuration is not supported.",
"Setup Complete: Preparing to Fetch from Another Device": "Setup Complete: Preparing to Fetch from Another Device",
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.":
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.",
@@ -28,8 +48,8 @@ export const liveSyncProvisionalEnglishMessages = {
"The project's public signalling relay is a best-effort convenience operated by the project author. It does not store Vault contents, but signalling metadata may be visible to the relay. Availability and log retention are not guaranteed. You can replace it with your own Nostr-compatible relay.",
"Learn more about P2P connections": "Learn more about P2P connections",
"Learn more about signalling and TURN": "Learn more about signalling and TURN",
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust.":
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust.",
"WebRTC encrypts data between your devices, including when it passes through TURN. The TURN provider cannot read the transferred data. It can see network addresses and traffic volume.":
"WebRTC encrypts data between your devices, including when it passes through TURN. The TURN provider cannot read the transferred data. It can see network addresses and traffic volume.",
"Connection compatibility": "Connection compatibility",
"P2P message size": "P2P message size",
Standard: "Standard",
@@ -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",
+3
View File
@@ -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",
+5
View File
@@ -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
+26
View File
@@ -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);
}
+46
View File
@@ -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 },
]);
});
});
+2
View File
@@ -1,3 +1,4 @@
import { redactTurnSettingsForReport } from "./turnSettingsPrivacy";
import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/settings";
import { generateCredentialObject } from "@vrtmrz/livesync-commonlib/compat/replication/httplib";
@@ -67,6 +68,7 @@ export async function generateReport(settings: ObsidianLiveSyncSettings, core: L
delete pluginConfig[key as keyof ObsidianLiveSyncSettings];
}
redactTurnSettingsForReport(pluginConfig);
pluginConfig.couchDB_DBNAME = REDACTED;
pluginConfig.couchDB_PASSWORD = REDACTED;
const scheme = pluginConfig.couchDB_URI.startsWith("http:")
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/settings";
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { generateReport } from "./reportTool";
vi.mock("./utils", () => ({ requestToCouchDBWithCredentials: vi.fn() }));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({
compatGlobal: { origin: "test", navigator: { userAgent: "test" } },
}));
describe("TURN credentials in diagnostic reports", () => {
it("redacts provider tokens in all profiles and runtime credentials", async () => {
const token = "private+token/with=symbols";
const provider = { P2P_managedType: "CF", P2P_managedId: "private-key", P2P_managedToken: token };
const settings = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_P2P,
...provider,
P2P_iceServers: [{ urls: "turn:example.test", username: "issued-user", credential: "issued-password" }],
P2P_iceServersExpiresAt: 123456789,
remoteConfigurations: {
inactive: {
id: "inactive",
name: "Inactive TURN",
isEncrypted: false,
uri: `sls+p2p://room?managedType=CF&managedId=private-key&token=${encodeURIComponent(token)}`,
},
},
};
const core = { services: { vault: { isStorageInsensitive: () => false } } } as unknown as LiveSyncBaseCore;
const report = await generateReport(settings, core);
const text = JSON.stringify(report);
expect(text).not.toContain(token);
expect(text).not.toContain(encodeURIComponent(token));
expect(text).not.toContain("private-key");
expect(report.pluginConfig.remoteConfigurations.inactive.uri).toBe("sls+p2p://");
expect(settings.P2P_managedToken).toBe(token);
expect(text).not.toMatch(/issued-user|issued-password|P2P_iceServers/);
});
});
+17
View File
@@ -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");
+63
View File
@@ -0,0 +1,63 @@
import {
hasManagedP2PTurnConfiguration,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { pickP2PSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { CLOUDFLARE_TURN_TYPE } from "@/integrations/cloudflare/settings";
/** Include inactive profiles when deciding whether Markdown would disclose provider settings. */
export function hasManagedTurnSettings(settings: Partial<ObsidianLiveSyncSettings>): boolean {
return (
hasManagedP2PTurnConfiguration(settings) ||
Object.values(settings.remoteConfigurations ?? {}).some(({ uri }) => {
if (!uri.startsWith("sls+p2p://")) return false;
const queryStart = uri.indexOf("?");
return (
queryStart >= 0 && new URLSearchParams(uri.slice(queryStart + 1).split("#", 1)[0]).has("managedType")
);
})
);
}
/** Reports retain a recognised provider label and omit issued credentials. */
export function redactTurnSettingsForReport(settings: Partial<ObsidianLiveSyncSettings>): void {
if (settings.P2P_managedType) {
settings.P2P_managedType =
settings.P2P_managedType === CLOUDFLARE_TURN_TYPE ? CLOUDFLARE_TURN_TYPE : "redacted";
}
if (settings.P2P_managedId !== undefined) settings.P2P_managedId = "redacted";
if (settings.P2P_managedToken !== undefined) settings.P2P_managedToken = "redacted";
delete settings.P2P_iceServers;
delete settings.P2P_iceServersExpiresAt;
}
/** Managed connection profiles are shared through Setup URIs and QR codes. */
export function omitManagedTurnProfilesFromMarkdown(settings: Partial<ObsidianLiveSyncSettings>): void {
delete settings.P2P_iceServers;
delete settings.P2P_iceServersExpiresAt;
if (!hasManagedTurnSettings(settings)) return;
delete settings.P2P_managedType;
delete settings.P2P_managedId;
delete settings.P2P_managedToken;
delete settings.remoteConfigurations;
delete settings.activeConfigurationId;
delete settings.P2P_ActiveRemoteConfigurationId;
}
/** Preserve the complete connection when Markdown omits its profile group. */
export function preserveManagedTurnProfilesOnMarkdownImport(
incoming: Partial<ObsidianLiveSyncSettings>,
current: ObsidianLiveSyncSettings,
merged: ObsidianLiveSyncSettings
): void {
if (
!hasManagedTurnSettings(current) ||
incoming.remoteConfigurations !== undefined ||
incoming.P2P_managedType !== undefined
)
return;
merged.remoteConfigurations = structuredClone(current.remoteConfigurations);
merged.activeConfigurationId = current.activeConfigurationId;
merged.P2P_ActiveRemoteConfigurationId = current.P2P_ActiveRemoteConfigurationId;
Object.assign(merged, pickP2PSyncSettings(current));
}
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_SETTINGS,
REMOTE_P2P,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
SettingService,
type SettingServiceDependencies,
} from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import {
hasManagedTurnSettings,
omitManagedTurnProfilesFromMarkdown,
preserveManagedTurnProfilesOnMarkdownImport,
redactTurnSettingsForReport,
} from "./turnSettingsPrivacy";
class MemorySettingService extends SettingService {
readonly items = new Map<string, string>();
saved?: ObsidianLiveSyncSettings;
protected setItem(key: string, value: string) {
this.items.set(key, value);
}
protected getItem(key: string) {
return this.items.get(key) ?? "";
}
protected deleteItem(key: string) {
this.items.delete(key);
}
protected saveData(settings: ObsidianLiveSyncSettings) {
this.saved = structuredClone(settings);
return Promise.resolve();
}
protected loadData() {
return Promise.resolve(this.saved);
}
}
function configuredSettings() {
return {
...DEFAULT_SETTINGS,
P2P_managedType: "CF",
P2P_managedId: "private-key-id",
P2P_managedToken: "private-token",
remoteConfigurations: {
managed: {
id: "managed",
name: "Managed TURN",
isEncrypted: false,
uri: "sls+p2p://room?managedType=CF&managedId=private-key-id&token=private-token",
},
},
activeConfigurationId: "central",
P2P_ActiveRemoteConfigurationId: "managed",
};
}
describe("managed TURN settings privacy", () => {
it("preserves the active managed room through Markdown import, save, and reload", async () => {
const current = {
...configuredSettings(),
remoteType: REMOTE_P2P,
activeConfigurationId: "managed",
P2P_roomID: "local-room",
P2P_relays: "wss://local-relay.example.test",
P2P_passphrase: "local-passphrase",
};
const originalURI = ConnectionStringParser.serialize({ type: "p2p", settings: current });
current.remoteConfigurations.managed.uri = originalURI;
const service = new MemorySettingService(new ServiceContext(), {
APIService: {
getSystemVaultName: () => "test-vault",
getAppID: () => "test-app",
addLog: () => undefined,
confirm: { askString: async () => "" },
} as unknown as SettingServiceDependencies["APIService"],
});
service.settings = structuredClone(current);
const incoming: Partial<ObsidianLiveSyncSettings> = {
P2P_roomID: "imported-room",
P2P_relays: "wss://imported-relay.example.test",
P2P_passphrase: "imported-passphrase",
};
const merged = { ...structuredClone(DEFAULT_SETTINGS), ...incoming };
preserveManagedTurnProfilesOnMarkdownImport(incoming, current, merged);
await service.applyExternalSettings(merged, true);
const saved = service.saved!.remoteConfigurations.managed;
const uri = saved.isEncrypted ? await service.decryptConfigurationItem(saved.uri, "*") : saved.uri;
expect(uri).toBe(originalURI);
expect(service.settings.P2P_roomID).toBe("local-room");
await service.loadSettings();
expect(service.settings.P2P_roomID).toBe("local-room");
});
it("redacts provider fields and issued credentials, including unknown integrations", () => {
const settings = configuredSettings();
settings.P2P_managedType = "private-token";
redactTurnSettingsForReport(settings);
expect([settings.P2P_managedType, settings.P2P_managedId, settings.P2P_managedToken]).toEqual([
"redacted",
"redacted",
"redacted",
]);
});
it("omits the whole managed profile group from Markdown, including inactive sources", () => {
const settings = configuredSettings();
settings.P2P_managedType = "";
expect(hasManagedTurnSettings(settings)).toBe(true);
omitManagedTurnProfilesFromMarkdown(settings);
expect(JSON.stringify(settings)).not.toMatch(/private-token|private-key-id|sls\+p2p/);
expect(settings).not.toHaveProperty("remoteConfigurations");
expect(settings).not.toHaveProperty("activeConfigurationId");
expect(settings).not.toHaveProperty("P2P_ActiveRemoteConfigurationId");
});
it("preserves existing profiles and both selections when Markdown omits the group", () => {
const current = configuredSettings();
const incoming = { ...DEFAULT_SETTINGS };
delete (incoming as Partial<typeof incoming>).remoteConfigurations;
delete (incoming as Partial<typeof incoming>).P2P_managedType;
const merged = { ...DEFAULT_SETTINGS, ...incoming };
preserveManagedTurnProfilesOnMarkdownImport(incoming, current, merged);
expect(merged.remoteConfigurations).toEqual(current.remoteConfigurations);
expect(merged.remoteConfigurations).not.toBe(current.remoteConfigurations);
expect(merged.P2P_managedToken).toEqual(current.P2P_managedToken);
expect(merged.activeConfigurationId).toBe("central");
expect(merged.P2P_ActiveRemoteConfigurationId).toBe("managed");
});
it("retains the manual-only Markdown contract", () => {
const settings = { ...DEFAULT_SETTINGS };
const before = structuredClone(settings);
omitManagedTurnProfilesFromMarkdown(settings);
expect(settings).toEqual(before);
});
});
@@ -0,0 +1,69 @@
<script lang="ts">
import type { P2PConnectionInfo } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { CLOUDFLARE_TURN_TYPE } from "@/integrations/cloudflare/settings";
import { validateManagedTurnSettings } from "@/integrations/turnSettings";
import { translateLiveSyncMessage as translate, translateIfAvailable } from "@/common/translation";
type TurnSettings = Pick<P2PConnectionInfo, "P2P_turnServers" | "P2P_turnUsername" | "P2P_turnCredential" | "P2P_managedType" | "P2P_managedId" | "P2P_managedToken">;
let { settings = $bindable() }: { settings: TurnSettings } = $props();
const managedType = $derived(settings.P2P_managedType ?? "");
const error = $derived(validateManagedTurnSettings(settings));
function selectProvider(type: string) {
settings.P2P_managedType = type || undefined;
settings.P2P_managedId = type ? "" : undefined;
settings.P2P_managedToken = type ? "" : undefined;
}
</script>
<div class="turn-configuration">
<label>
<span>{translate("TURN configuration")}</span>
<select aria-label={translate("TURN configuration")} name="p2p-turn-source" value={managedType} onchange={(event) => selectProvider(event.currentTarget.value)}>
<option value="">{translate("Manual")}</option>
<option value={CLOUDFLARE_TURN_TYPE}>{translate("Managed (Cloudflare)")}</option>
{#if managedType !== "" && managedType !== CLOUDFLARE_TURN_TYPE}
<option value={managedType} disabled>{translate("Unsupported TURN configuration")}</option>
{/if}
</select>
</label>
{#if managedType === ""}
<label>
<span>{translate("TURN Server URLs (comma-separated)")}</span>
<textarea name="p2p-turn-servers" rows="3" placeholder="turn:turn.example.com:3478"
bind:value={settings.P2P_turnServers} autocapitalize="off" spellcheck="false"></textarea>
</label>
<label>
<span>{translate("TURN Username")}</span>
<input type="text" name="p2p-turn-username" placeholder={translate("Enter TURN username")} bind:value={settings.P2P_turnUsername}
autocomplete="off" autocapitalize="off" spellcheck="false" />
</label>
<label>
<span>{translate("TURN Credential")}</span>
<input type="password" name="p2p-turn-credential" placeholder={translate("Enter TURN credential")} bind:value={settings.P2P_turnCredential}
autocomplete="new-password" />
</label>
{:else if managedType === CLOUDFLARE_TURN_TYPE}
<label>
<span>{translate("TURN Key ID")}</span>
<input type="text" name="p2p-turn-turnKeyId" bind:value={settings.P2P_managedId}
autocomplete="off" autocapitalize="off" spellcheck="false" />
</label>
<label>
<span>{translate("TURN Key API Token")}</span>
<input type="password" name="p2p-turn-apiToken" bind:value={settings.P2P_managedToken}
autocomplete="new-password" autocapitalize="off" spellcheck="false" />
</label>
<p>{translate("The API token is saved with this profile and included in Setup URI and QR code sharing. Temporary TURN credentials are kept in memory only.")}</p>
{/if}
{#if error}
<p role="status" class="turn-error">{translateIfAvailable(error)}</p>
{/if}
</div>
<style>
label { display: grid; gap: 0.25rem; margin: 0.75rem 0; }
input, textarea, select { box-sizing: border-box; width: 100%; }
p { font-size: var(--font-ui-small, 0.9rem); }
.turn-error { color: var(--text-error, #b33); }
</style>
+49
View File
@@ -0,0 +1,49 @@
/** The provider identifier persisted in a P2P profile for Cloudflare TURN. */
export const CLOUDFLARE_TURN_TYPE = "CF" as const;
/** The lifetime requested from Cloudflare for each issued credential set. */
export const CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS = 86_400 as const;
/** The Cloudflare TURN credential-generation endpoint. */
export const CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT = "https://rtc.live.cloudflare.com/v1/turn/keys" as const;
/** A Cloudflare TURN configuration. */
export interface CloudflareTurnConfiguration {
readonly turnKeyId: string;
readonly apiToken: string;
}
// TURN Key IDs are inserted into one fixed URL path. Keep the accepted set
// deliberately narrower than URI escaping so a configuration cannot alter
// the request path or add a query string.
const TURN_KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,255}$/;
// RFC 6750's b64token grammar, including optional trailing padding. This
// also excludes whitespace and control characters from the Authorization
// header without exposing the token in a validation message.
const BEARER_TOKEN_PATTERN = /^[A-Za-z0-9._~+/-]+={0,2}$/;
const MAX_BEARER_TOKEN_LENGTH = 4_096;
/**
* Returns a safe validation message for a Cloudflare TURN configuration.
* The result never includes the supplied Key ID or API token.
*/
export function validateCloudflareTurnConfiguration(value: CloudflareTurnConfiguration): string | undefined {
const turnKeyId = value.turnKeyId;
if (typeof turnKeyId !== "string" || turnKeyId.length === 0) {
return "Enter a TURN Key ID.";
}
if (!TURN_KEY_ID_PATTERN.test(turnKeyId)) {
return "TURN Key ID contains unsupported characters.";
}
const apiToken = value.apiToken;
if (typeof apiToken !== "string" || apiToken.length === 0) {
return "Enter a TURN Key API Token.";
}
if (apiToken.length > MAX_BEARER_TOKEN_LENGTH || !BEARER_TOKEN_PATTERN.test(apiToken)) {
return "TURN Key API Token must use Bearer token syntax.";
}
return undefined;
}
@@ -0,0 +1,363 @@
import {
CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT,
CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS,
type CloudflareTurnConfiguration,
validateCloudflareTurnConfiguration,
} from "./settings";
/** Fetch-compatible function supplied by the host composition. */
export type CloudflareTurnFetch = (input: string | Request, init?: RequestInit) => Promise<Response>;
export interface CloudflareTurnDependencies {
readonly fetch: CloudflareTurnFetch;
readonly now?: () => number;
readonly requestDeadlineMs?: number;
}
export const CLOUDFLARE_TURN_REQUEST_DEADLINE_MS = 15_000 as const;
export const CLOUDFLARE_TURN_MAX_RESPONSE_BYTES = 32 * 1024;
export const CLOUDFLARE_TURN_MAX_ICE_SERVER_ENTRIES = 16 as const;
export const CLOUDFLARE_TURN_MAX_ICE_SERVER_URLS = 32 as const;
export const CLOUDFLARE_TURN_MIN_REMAINING_LIFETIME_MS = 30_000 as const;
type TurnFailureCode = "configuration" | "authentication" | "unavailable" | "invalid-response";
const FAILURE_MESSAGES: Record<TurnFailureCode, string> = {
configuration: "The Cloudflare TURN configuration is invalid.",
authentication: "The Cloudflare TURN credential request was not authorised.",
unavailable: "The Cloudflare TURN service is unavailable.",
"invalid-response": "The Cloudflare TURN service returned an invalid response.",
};
function credentialFailure(code: TurnFailureCode, retryable: boolean): Error {
return Object.assign(new Error(FAILURE_MESSAGES[code]), { code, retryable });
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function abortError(): Error {
try {
return new DOMException("The operation was aborted.", "AbortError");
} catch {
const error = new Error("The operation was aborted.");
error.name = "AbortError";
return error;
}
}
function throwIfAborted(signal: AbortSignal): void {
if (signal.aborted) {
throw abortError();
}
}
function isControlCharacter(value: string): boolean {
return Array.from(value).some((character) => {
const code = character.charCodeAt(0);
return code <= 0x1f || code === 0x7f;
});
}
function isPort(value: string): boolean {
if (!/^\d{1,5}$/.test(value)) return false;
const port = Number(value);
return port >= 1 && port <= 65_535;
}
function isHost(value: string): boolean {
return value.length > 0 && /^[A-Za-z0-9._-]+$/.test(value);
}
/**
* Validates the URL forms accepted by WebRTC's ICE server configuration.
* TURN URLs may carry only the standard transport query parameter; userinfo,
* paths, fragments, and arbitrary query values are not accepted.
*/
export function isSupportedIceServerUrl(value: string): boolean {
if (value.length === 0 || value.length > 2_048 || isControlCharacter(value)) return false;
const schemeMatch = /^(stun|stuns|turn|turns):(.+)$/i.exec(value);
if (!schemeMatch) return false;
const remainder = schemeMatch[2];
const queryIndex = remainder.indexOf("?");
const authority = queryIndex >= 0 ? remainder.slice(0, queryIndex) : remainder;
const query = queryIndex >= 0 ? remainder.slice(queryIndex + 1) : "";
if (authority.length === 0 || authority.includes("/") || authority.includes("#") || authority.includes("@")) {
return false;
}
if (authority.includes("%")) return false;
if (authority.startsWith("[")) {
const closingBracket = authority.indexOf("]");
if (closingBracket < 0) return false;
const host = authority.slice(1, closingBracket);
if (!/^[0-9A-Fa-f:.]+$/.test(host) || !host.includes(":")) return false;
const suffix = authority.slice(closingBracket + 1);
if (suffix !== "" && (!suffix.startsWith(":") || !isPort(suffix.slice(1)))) return false;
} else {
const colonIndex = authority.lastIndexOf(":");
const host = colonIndex >= 0 ? authority.slice(0, colonIndex) : authority;
if (!isHost(host) || (colonIndex >= 0 && !isPort(authority.slice(colonIndex + 1)))) return false;
// IPv6 literals must use brackets so a colon cannot be interpreted as
// an ambiguous port separator.
if (colonIndex >= 0 && host.includes(":")) return false;
}
if (query.length === 0) return true;
const queryParts = query.split("&");
return queryParts.length === 1 && /^transport=(udp|tcp)$/i.test(queryParts[0]);
}
function isTurnUrl(value: string): boolean {
return /^(turn|turns):/i.test(value);
}
function isCredential(value: unknown): value is string {
return typeof value === "string" && value.length > 0 && value.length <= 4_096 && !isControlCharacter(value);
}
function normaliseIceServers(value: unknown): readonly RTCIceServer[] {
if (!isRecord(value) || !Array.isArray(value.iceServers)) {
throw credentialFailure("invalid-response", false);
}
if (value.iceServers.length === 0 || value.iceServers.length > CLOUDFLARE_TURN_MAX_ICE_SERVER_ENTRIES) {
throw credentialFailure("invalid-response", false);
}
const servers: RTCIceServer[] = [];
let urlCount = 0;
let hasTurnServer = false;
for (const candidate of value.iceServers) {
if (!isRecord(candidate)) throw credentialFailure("invalid-response", false);
const rawUrls = candidate.urls;
const urls =
typeof rawUrls === "string"
? [rawUrls]
: Array.isArray(rawUrls) && rawUrls.every((url): url is string => typeof url === "string")
? [...rawUrls]
: undefined;
if (!urls || urls.length === 0) throw credentialFailure("invalid-response", false);
urlCount += urls.length;
if (urlCount > CLOUDFLARE_TURN_MAX_ICE_SERVER_URLS || urls.some((url) => !isSupportedIceServerUrl(url))) {
throw credentialFailure("invalid-response", false);
}
const turnEntry = urls.some(isTurnUrl);
hasTurnServer ||= turnEntry;
const normalised: RTCIceServer = { urls };
if (turnEntry) {
if (!isCredential(candidate.username) || !isCredential(candidate.credential)) {
throw credentialFailure("invalid-response", false);
}
normalised.username = candidate.username;
normalised.credential = candidate.credential;
}
servers.push(normalised);
}
if (!hasTurnServer) throw credentialFailure("invalid-response", false);
return Object.freeze(servers);
}
class BoundedResponseError extends Error {
constructor(readonly kind: "too-large" | "invalid-length" | "read-failed") {
super(kind);
}
}
async function readResponseBody(response: Response): Promise<string> {
const contentLength = response.headers.get("content-length");
if (contentLength !== null) {
const declaredLength = Number(contentLength);
if (!Number.isFinite(declaredLength) || declaredLength < 0) {
throw new BoundedResponseError("invalid-length");
}
if (declaredLength > CLOUDFLARE_TURN_MAX_RESPONSE_BYTES) {
throw new BoundedResponseError("too-large");
}
}
if (!response.body) {
try {
const text = await response.text();
if (new TextEncoder().encode(text).byteLength > CLOUDFLARE_TURN_MAX_RESPONSE_BYTES) {
throw new BoundedResponseError("too-large");
}
return text;
} catch (error) {
if (error instanceof BoundedResponseError) throw error;
throw new BoundedResponseError("read-failed");
}
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
while (true) {
const result = await reader.read();
if (result.done) break;
totalBytes += result.value.byteLength;
if (totalBytes > CLOUDFLARE_TURN_MAX_RESPONSE_BYTES) {
try {
await reader.cancel();
} catch {
// The response is already invalid because it exceeded the
// bound; cancellation failure must not change the safe
// classification or expose a host-specific error.
}
throw new BoundedResponseError("too-large");
}
chunks.push(result.value);
}
} catch (error) {
if (error instanceof BoundedResponseError) throw error;
throw new BoundedResponseError("read-failed");
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(bytes);
}
function classifyHttpFailure(status: number): Error {
if (status === 401 || status === 403) {
return credentialFailure("authentication", false);
}
if (status === 408 || status === 429 || status >= 500) {
return credentialFailure("unavailable", true);
}
return credentialFailure("unavailable", false);
}
function parseResponseBody(body: string): readonly RTCIceServer[] {
let value: unknown;
try {
value = JSON.parse(body) as unknown;
} catch {
throw credentialFailure("invalid-response", false);
}
return normaliseIceServers(value);
}
/** Acquire one temporary ICE configuration for a new room connection. */
export async function acquireCloudflareTurnCredentials(
configuration: CloudflareTurnConfiguration,
dependencies: CloudflareTurnDependencies,
signal: AbortSignal
): Promise<{ iceServers: readonly RTCIceServer[]; expiresAt: number }> {
if (validateCloudflareTurnConfiguration(configuration)) throw credentialFailure("configuration", false);
const now = dependencies.now ?? Date.now;
const requestDeadlineMs = dependencies.requestDeadlineMs ?? CLOUDFLARE_TURN_REQUEST_DEADLINE_MS;
throwIfAborted(signal);
const requestStartedAt = now();
if (!Number.isFinite(requestStartedAt)) {
throw credentialFailure("unavailable", true);
}
const requestController = new AbortController();
let cancelledByCaller = false;
let rejectCaller: ((reason?: unknown) => void) | undefined;
const callerAbort = new Promise<never>((_resolve, reject) => {
rejectCaller = reject;
});
let timedOut = false;
const onAbort = () => {
cancelledByCaller = true;
requestController.abort();
rejectCaller?.(abortError());
};
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) {
signal.removeEventListener("abort", onAbort);
requestController.abort();
throw abortError();
}
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<never>((_resolve, reject) => {
timeoutId = globalThis.setTimeout(() => {
timedOut = true;
requestController.abort();
reject(credentialFailure("unavailable", true));
}, requestDeadlineMs);
});
const cleanup = () => {
if (timeoutId !== undefined) globalThis.clearTimeout(timeoutId);
signal.removeEventListener("abort", onAbort);
};
const endpoint = `${CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT}/${configuration.turnKeyId}/credentials/generate-ice-servers`;
let response: Response;
try {
response = await Promise.race([
dependencies.fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${configuration.apiToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ ttl: CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS }),
signal: requestController.signal,
redirect: "error",
credentials: "omit",
cache: "no-store",
}),
callerAbort,
deadline,
]);
} catch {
cleanup();
if (cancelledByCaller || signal.aborted) throw abortError();
if (timedOut) throw credentialFailure("unavailable", true);
throw credentialFailure("unavailable", true);
}
if (cancelledByCaller || signal.aborted) {
cleanup();
throw abortError();
}
if (timedOut || requestController.signal.aborted) {
cleanup();
throw credentialFailure("unavailable", true);
}
if (response.status !== 201) {
cleanup();
throw classifyHttpFailure(response.status);
}
let body: string;
try {
body = await Promise.race([readResponseBody(response), callerAbort, deadline]);
} catch (error) {
cleanup();
if (cancelledByCaller || signal.aborted) throw abortError();
if (timedOut) throw credentialFailure("unavailable", true);
if (error instanceof BoundedResponseError && error.kind === "read-failed") {
throw credentialFailure("unavailable", true);
}
throw credentialFailure("invalid-response", false);
}
try {
throwIfAborted(signal);
const iceServers = parseResponseBody(body);
const expiresAt = requestStartedAt + CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS * 1_000;
if (!Number.isFinite(expiresAt) || expiresAt <= now() + CLOUDFLARE_TURN_MIN_REMAINING_LIFETIME_MS) {
throw credentialFailure("invalid-response", false);
}
return { iceServers, expiresAt };
} finally {
cleanup();
}
}
@@ -0,0 +1,179 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
CLOUDFLARE_TURN_MAX_RESPONSE_BYTES,
CLOUDFLARE_TURN_REQUEST_DEADLINE_MS,
acquireCloudflareTurnCredentials,
} from "./turnCredentials";
import {
CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT,
CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS,
validateCloudflareTurnConfiguration,
} from "./settings";
const configuration = {
turnKeyId: "key-123",
apiToken: "token_abc-123",
} as const;
function response(body: unknown, status = 201): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function validBody() {
return {
iceServers: [
{
urls: ["turn:relay.example.test:3478?transport=udp", "turns:relay.example.test:5349"],
username: "turn-user",
credential: "turn-password",
},
{ urls: "stun:stun.example.test:3478" },
],
};
}
afterEach(() => {
vi.useRealTimers();
});
describe("Cloudflare TURN credentials", () => {
it("requests the fixed endpoint with the bearer token and TTL", async () => {
const now = 1_000_000;
let requestUrl: string | Request | undefined;
let requestInit: RequestInit | undefined;
const fetch = vi.fn(async (input: string | Request, init?: RequestInit) => {
requestUrl = input;
requestInit = init;
return response(validBody());
});
const dependencies = { fetch, now: () => now };
const result = await acquireCloudflareTurnCredentials(
configuration,
dependencies,
new AbortController().signal
);
expect(requestUrl).toBe(`${CLOUDFLARE_TURN_CREDENTIAL_ENDPOINT}/key-123/credentials/generate-ice-servers`);
expect(requestInit).toMatchObject({
method: "POST",
redirect: "error",
credentials: "omit",
cache: "no-store",
body: JSON.stringify({ ttl: CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS }),
});
expect(new Headers(requestInit?.headers).get("authorization")).toBe("Bearer token_abc-123");
expect(new Headers(requestInit?.headers).get("content-type")).toBe("application/json");
expect(requestInit?.signal).toBeInstanceOf(AbortSignal);
expect(result.iceServers).toHaveLength(2);
expect(result.expiresAt).toBe(now + CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS * 1_000);
});
it("rejects malformed, oversized, and STUN-only responses without exposing secrets", async () => {
const cases: Array<{ body: unknown; expectedCode: string }> = [
{ body: { iceServers: [] }, expectedCode: "invalid-response" },
{ body: { iceServers: [{ urls: "turn:relay.example.test:3478" }] }, expectedCode: "invalid-response" },
{ body: { iceServers: [{ urls: "stun:stun.example.test:3478" }] }, expectedCode: "invalid-response" },
];
for (const testCase of cases) {
const dependencies = {
fetch: vi.fn(async () => response(testCase.body)),
now: () => 1_000_000,
};
const error = await acquireCloudflareTurnCredentials(
configuration,
dependencies,
new AbortController().signal
).catch((reason: unknown) => reason);
expect(error).toMatchObject({ code: testCase.expectedCode });
expect(String(error)).not.toContain(configuration.apiToken);
expect(String(error)).not.toContain(configuration.turnKeyId);
}
const oversized = "x".repeat(CLOUDFLARE_TURN_MAX_RESPONSE_BYTES + 1);
const dependencies = {
fetch: vi.fn(async () => new Response(oversized, { status: 201 })),
now: () => 1_000_000,
};
const error = await acquireCloudflareTurnCredentials(
configuration,
dependencies,
new AbortController().signal
).catch((reason: unknown) => reason);
expect(error).toMatchObject({ code: "invalid-response" });
});
it("classifies authentication and transient provider failures", async () => {
const authDependencies = {
fetch: vi.fn(async () => response({}, 401)),
};
await expect(
acquireCloudflareTurnCredentials(configuration, authDependencies, new AbortController().signal)
).rejects.toMatchObject({
code: "authentication",
retryable: false,
});
const transientDependencies = {
fetch: vi.fn(async () => response({}, 503)),
};
await expect(
acquireCloudflareTurnCredentials(configuration, transientDependencies, new AbortController().signal)
).rejects.toMatchObject({
code: "unavailable",
retryable: true,
});
});
it("propagates caller cancellation and turns a deadline into an unavailable failure", async () => {
const controller = new AbortController();
const fetch = vi.fn((_input: string | Request, init?: RequestInit) => {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), {
once: true,
});
});
});
const dependencies = { fetch };
const cancelled = acquireCloudflareTurnCredentials(configuration, dependencies, controller.signal);
controller.abort();
await expect(cancelled).rejects.toMatchObject({ name: "AbortError" });
vi.useFakeTimers();
const timedDependencies = { fetch };
const timed = acquireCloudflareTurnCredentials(configuration, timedDependencies, new AbortController().signal);
const assertion = expect(timed).rejects.toMatchObject({ code: "unavailable", retryable: true });
await vi.advanceTimersByTimeAsync(CLOUDFLARE_TURN_REQUEST_DEADLINE_MS);
await assertion;
});
it("rejects an issuance which has no usable remaining lifetime", async () => {
let now = 1_000_000;
const dependencies = {
fetch: vi.fn(async () => {
now += CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS * 1_000;
return response(validBody());
}),
now: () => now,
};
await expect(
acquireCloudflareTurnCredentials(configuration, dependencies, new AbortController().signal)
).rejects.toMatchObject({
code: "invalid-response",
});
});
});
describe("Cloudflare TURN input validation", () => {
it("rejects unsafe key IDs and malformed bearer credentials", () => {
expect(
validateCloudflareTurnConfiguration({ turnKeyId: "key/id", apiToken: configuration.apiToken })
).toContain("unsupported characters");
expect(validateCloudflareTurnConfiguration({ ...configuration, apiToken: "token with spaces" })).toContain(
"Bearer token syntax"
);
});
});
+14
View File
@@ -0,0 +1,14 @@
import type { P2PConnectionInfo } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { CLOUDFLARE_TURN_TYPE, validateCloudflareTurnConfiguration } from "./cloudflare/settings";
/** Validate provider inputs without requesting credentials. */
export function validateManagedTurnSettings(settings: Partial<P2PConnectionInfo>): string | undefined {
if (settings.P2P_managedType === undefined || settings.P2P_managedType === "") return undefined;
if (settings.P2P_managedType !== CLOUDFLARE_TURN_TYPE) {
return "The selected TURN configuration is not supported.";
}
return validateCloudflareTurnConfiguration({
turnKeyId: settings.P2P_managedId ?? "",
apiToken: settings.P2P_managedToken ?? "",
});
}
+19 -10
View File
@@ -1,3 +1,4 @@
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
import { getLanguage, Notice, Plugin, type App, type PluginManifest } from "./deps";
import { setGetLanguage } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
setGetLanguage(getLanguage);
@@ -6,7 +7,6 @@ import { HiddenFileSync } from "./features/HiddenFileSync/CmdHiddenFileSync.ts";
import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts";
// import { ModuleDev } from "./modules/extras/ModuleDev.ts";
import { ModuleInteractiveConflictResolver } from "./modules/features/ModuleInteractiveConflictResolver.ts";
import { ModuleLog } from "./modules/features/ModuleLog.ts";
import { ModuleObsidianEvents } from "./modules/essentialObsidian/ModuleObsidianEvents.ts";
import { ModuleObsidianSettingDialogue } from "./modules/features/ModuleObsidianSettingTab.ts";
@@ -26,10 +26,8 @@ import type { ServiceModules } from "./types.ts";
import { setNoticeClass } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/wrapper";
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import { LiveSyncBaseCore } from "./LiveSyncBaseCore.ts";
import { ModuleObsidianMenu } from "./modules/essentialObsidian/ModuleObsidianMenu.ts";
import { ModuleObsidianSettingsAsMarkdown } from "./modules/features/ModuleObsidianSettingAsMarkdown.ts";
import { SetupManager } from "./modules/features/SetupManager.ts";
import { ModuleMigration } from "./modules/essential/ModuleMigration.ts";
import { enableI18nFeature } from "./serviceFeatures/onLayoutReady/enablei18n.ts";
import { useOfflineScanner } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
@@ -38,7 +36,10 @@ import { useRedFlagFeatures } from "./serviceFeatures/redFlag.ts";
import { useSetupProtocolFeature } from "./serviceFeatures/setupObsidian/setupProtocol.ts";
import { useSetupQRCodeFeature } from "@/serviceFeatures/setupObsidian/qrCode";
import { useSetupURIFeature } from "@/serviceFeatures/setupObsidian/setupUri";
import { useSetupManagerHandlersFeature } from "./serviceFeatures/setupObsidian/setupManagerHandlers.ts";
import {
showOnboardingInvitation,
useSetupManagerHandlersFeature,
} from "./serviceFeatures/setupObsidian/setupManagerHandlers.ts";
import { useP2PReplicatorCommands, useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/p2p";
import { useP2PReplicatorUI } from "./serviceFeatures/useP2PReplicatorUI.ts";
import { useReviewHarness } from "./serviceFeatures/useReviewHarness.ts";
@@ -46,6 +47,10 @@ import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync
import { useCompatibilityReview } from "./serviceFeatures/compatibilityReview.ts";
import { createObsidianCompatibilityReviewUi } from "./serviceFeatures/compatibilityReviewObsidian.ts";
import { createFileReflectionProvenance } from "./serviceModules/FileReflectionProvenance.ts";
import { useInteractiveConflictResolutionFeature } from "./serviceFeatures/interactiveConflictResolution";
import { ConflictResolveModal } from "./modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
import { useObsidianReplicationRibbonFeature } from "./serviceFeatures/obsidianReplicationRibbon.ts";
import { useStartupLifecycleFeature } from "./serviceFeatures/startupLifecycle";
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
export default class ObsidianLiveSyncPlugin extends Plugin {
core: LiveSyncCore;
@@ -145,7 +150,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
setNoticeClass(Notice);
const serviceHub = new ObsidianServiceHub(this);
let waitForCompatibilityReview = (): Promise<void> => Promise.resolve();
this.core = new LiveSyncBaseCore(
serviceHub,
@@ -156,15 +160,12 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
const extraModules = [
new ModuleObsidianEvents(this, core),
new ModuleObsidianSettingDialogue(this, core),
new ModuleObsidianMenu(core),
new ModuleObsidianSettingsAsMarkdown(core),
new ModuleLog(this, core),
new ModuleObsidianDocumentHistory(this, core),
new ModuleInteractiveConflictResolver(this, core),
new ModuleObsidianGlobalHistory(this, core),
// new ModuleDev(this, core),
new SetupManager(core), // this should be moved to core?
new ModuleMigration(core, () => waitForCompatibilityReview()),
];
return extraModules;
},
@@ -182,11 +183,13 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
const replicator = useP2PReplicatorFeature(
core,
(_compatibilityReplicator, p2p) => createInteractiveP2PReplication(p2p),
createOpenRebuildUI(this.app)
createOpenRebuildUI(this.app),
{ prepareP2PSettings: useP2PSettingsPreparation(core.services.API.webCompatFetch.bind(core.services.API)) }
);
setupManager.registerP2PSetupConnectionProbe(replicator.connectionProbe);
useP2PReplicatorCommands(core, replicator);
useP2PReplicatorUI(core, core, replicator, createInteractiveP2PReplication(replicator));
useObsidianReplicationRibbonFeature(core);
useRemoteConfiguration(core);
useSetupProtocolFeature(core, setupManager);
@@ -196,11 +199,17 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
useOfflineScanner(core);
useRedFlagFeatures(core);
useCheckRemoteSize(core);
useInteractiveConflictResolutionFeature(core, (filename, conflictCheckResult) => {
return new ConflictResolveModal(this.app, filename, conflictCheckResult);
});
const compatibilityReview = useCompatibilityReview(
core,
createObsidianCompatibilityReviewUi(core.confirm)
);
waitForCompatibilityReview = () => compatibilityReview.openReview();
useStartupLifecycleFeature(core, {
inviteToOnboarding: () => showOnboardingInvitation(core, setupManager),
waitForCompatibilityReview: () => compatibilityReview.openReview(),
});
useReviewHarness(core, this, compatibilityReview);
}
);
@@ -1,82 +0,0 @@
import { AbstractModule } from "@/modules/AbstractModule.ts";
import { LOG_LEVEL_NOTICE, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
import { sendValue } from "octagonal-wheels/messagepassing/signal";
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import type { LiveSyncCore } from "@/main.ts";
export class ModuleConflictChecker extends AbstractModule {
async _queueConflictCheckIfOpen(file: FilePathWithPrefix): Promise<void> {
const path = file;
if (this.settings.checkConflictOnlyOnOpen) {
const af = this.services.vault.getActiveFilePath();
if (af && af != path) {
this._log(`${file} is conflicted, merging process has been postponed.`, LOG_LEVEL_NOTICE);
return;
}
}
await this.services.conflict.queueCheckFor(path);
}
async _queueConflictCheck(file: FilePathWithPrefix): Promise<void> {
const optionalConflictResult = await this.services.conflict.getOptionalConflictCheckMethod(file);
if (optionalConflictResult == true) {
// The conflict has been resolved by another process.
return;
} else if (optionalConflictResult === "newer") {
// The conflict should be resolved by the newer entry.
await this.services.conflict.resolveByNewest(file);
} else {
this.conflictCheckQueue.enqueue(file);
}
}
_waitForAllConflictProcessed(): Promise<boolean> {
return this.conflictResolveQueue.waitForAllProcessed();
}
// TODO-> Move to ModuleConflictResolver?
conflictResolveQueue = new QueueProcessor(
async (filenames: FilePathWithPrefix[]) => {
const filename = filenames[0];
return await this.services.conflict.resolve(filename);
},
{
suspended: false,
batchSize: 1,
// No need to limit concurrency to `1` here, subsequent process will handle it,
// And, some cases, we do not need to synchronised. (e.g., auto-merge available).
// Therefore, limiting global concurrency is performed on resolver with the UI.
concurrentLimit: 10,
delay: 0,
keepResultUntilDownstreamConnected: false,
}
).replaceEnqueueProcessor((queue, newEntity) => {
const filename = newEntity;
sendValue("cancel-resolve-conflict:" + filename, true);
const newQueue = [...queue].filter((e) => e != newEntity);
return [...newQueue, newEntity];
});
conflictCheckQueue = // First process - Check is the file actually need resolve -
new QueueProcessor(
(files: FilePathWithPrefix[]) => {
const filename = files[0];
return Promise.resolve([filename]);
},
{
suspended: false,
batchSize: 1,
concurrentLimit: 10,
delay: 0,
keepResultUntilDownstreamConnected: true,
pipeTo: this.conflictResolveQueue,
totalRemainingReactiveSource: this.services.conflict.conflictProcessQueueCount,
}
);
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
services.conflict.queueCheckForIfOpen.setHandler(this._queueConflictCheckIfOpen.bind(this));
services.conflict.queueCheckFor.setHandler(this._queueConflictCheck.bind(this));
services.conflict.ensureAllProcessed.setHandler(this._waitForAllConflictProcessed.bind(this));
}
}
@@ -1,240 +0,0 @@
import { serialized } from "octagonal-wheels/concurrency/lock";
import { AbstractModule } from "@/modules/AbstractModule.ts";
import {
AUTO_MERGED,
CANCELLED,
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
MISSING_OR_ERROR,
NOT_CONFLICTED,
type diff_check_result,
type FilePathWithPrefix,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { isCustomisationSyncMetadata, isPluginMetadata } from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
import { TARGET_IS_NEW } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols";
import { compareMTime, displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import diff_match_patch from "diff-match-patch";
import { stripAllPrefixes, isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import type { LiveSyncCore } from "@/main.ts";
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
export class ModuleConflictResolver extends AbstractModule {
private async _resolveConflictByDeletingRev(
path: FilePathWithPrefix,
deleteRevision: string,
subTitle = "",
showNotice = true
): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> {
const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`;
if (!(await this.core.fileHandler.deleteRevisionFromDB(path, deleteRevision))) {
this._log(
`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`,
LOG_LEVEL_NOTICE
);
return MISSING_OR_ERROR;
}
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, path);
this._log(
`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`,
LOG_LEVEL_INFO
);
if ((await this.core.databaseFileAccess.getConflictedRevs(path)).length != 0) {
this._log(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO);
return AUTO_MERGED;
}
if (isPluginMetadata(path) || isCustomisationSyncMetadata(path)) {
this._log(`${title} ${path} is a plugin metadata file, no need to write to storage`, LOG_LEVEL_INFO);
return AUTO_MERGED;
}
// If no conflicts were found, write the resolved content to the storage.
if (!(await this.core.fileHandler.dbToStorage(path, stripAllPrefixes(path), true))) {
this._log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
const level = subTitle.indexOf("same") !== -1 || !showNotice ? LOG_LEVEL_INFO : LOG_LEVEL_NOTICE;
this._log(`${path} has been merged automatically`, level);
return AUTO_MERGED;
}
async checkConflictAndPerformAutoMerge(path: FilePathWithPrefix): Promise<diff_check_result> {
//
const ret = await this.localDatabase.tryAutoMerge(path, !this.settings.disableMarkdownAutoMerge);
if ("ok" in ret) {
return ret.ok;
}
if ("result" in ret) {
const p = ret.result;
// Merged content is coming.
// 1. Store the merged content to the storage
if (!(await this.core.databaseFileAccess.storeContent(path, p))) {
this._log(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
return await this.services.conflict.resolveByDeletingRevision(path, ret.conflictedRev, "Sensible");
}
const { rightRev, leftLeaf, rightLeaf } = ret;
// should be one or more conflicts;
if (leftLeaf == false) {
// what's going on..
this._log(`could not get current revisions:${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
if (rightLeaf == false) {
// A locally unreadable conflict leaf may still be recoverable from another
// replica or backup. Keep it visible for explicit repair instead of treating
// missing chunks as evidence that the branch is obsolete.
this._log(`could not read conflicted revision ${rightRev}:${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
const isSame = leftLeaf.data == rightLeaf.data && leftLeaf.deleted == rightLeaf.deleted;
const isBinary = !isPlainText(path);
const alwaysNewer = this.settings.resolveConflictsByNewerFile;
if (isSame || isBinary || alwaysNewer) {
const result = compareMTime(leftLeaf.mtime, rightLeaf.mtime);
let loser = leftLeaf;
// if (lMtime > rMtime) {
if (result != TARGET_IS_NEW) {
loser = rightLeaf;
}
const subTitle = [
`${isSame ? "same" : ""}`,
`${isBinary ? "binary" : ""}`,
`${alwaysNewer ? "alwaysNewer" : ""}`,
]
.filter((e) => e.trim())
.join(",");
return await this.services.conflict.resolveByDeletingRevision(path, loser.rev, subTitle);
}
// make diff.
const dmp = new diff_match_patch();
const diff = dmp.diff_main(leftLeaf.data, rightLeaf.data);
dmp.diff_cleanupSemantic(diff);
this._log(`conflict(s) found:${path}`);
return {
left: leftLeaf,
right: rightLeaf,
diff: diff,
};
}
private async _resolveConflict(filename: FilePathWithPrefix): Promise<void> {
// const filename = filenames[0];
return await serialized(`conflict-resolve:${filename}`, async () => {
const conflictCheckResult = await this.checkConflictAndPerformAutoMerge(filename);
if (conflictCheckResult === NOT_CONFLICTED) {
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
this._log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
return;
}
if (conflictCheckResult === MISSING_OR_ERROR || conflictCheckResult === CANCELLED) {
// nothing to do.
this._log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
return;
}
if (conflictCheckResult === AUTO_MERGED) {
//auto resolved, but need check again;
if (this.settings.syncAfterMerge && !this.services.appLifecycle.isSuspended()) {
//Wait for the running replication, if not running replication, run it once.
await this.services.replication.replicateUnattendedByEvent({
trigger: "merge",
interaction: NO_INTERACTION,
});
}
this._log("[conflict] Automatically merged, but we have to check it again");
await this.services.conflict.queueCheckFor(filename);
return;
}
if (this.settings.showMergeDialogOnlyOnActive) {
const af = this.services.vault.getActiveFilePath();
if (af && af != filename) {
this._log(
`[conflict] ${filename} is conflicted. Merging process has been postponed to the file have got opened.`,
LOG_LEVEL_NOTICE
);
return;
}
}
this._log("[conflict] Manual merge required!");
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
await this.services.conflict.resolveByUserInteraction(filename, conflictCheckResult);
});
}
private async _anyResolveConflictByNewest(filename: FilePathWithPrefix, showNotice = true): Promise<boolean> {
const currentRev = await this.core.databaseFileAccess.fetchEntryMeta(filename, undefined, true);
if (currentRev == false) {
this._log(`Could not get current revision of ${filename}`);
return Promise.resolve(false);
}
const revs = await this.core.databaseFileAccess.getConflictedRevs(filename);
if (revs.length == 0) {
return Promise.resolve(true);
}
const mTimeAndRev = (
[
[currentRev.mtime, currentRev._rev],
...(await Promise.all(
revs.map(async (rev) => {
const leaf = await this.core.databaseFileAccess.fetchEntryMeta(filename, rev);
if (leaf == false) {
return [0, rev];
}
return [leaf.mtime, rev];
})
)),
] as [number, string][]
).sort((a, b) => {
const diff = b[0] - a[0];
if (diff == 0) {
return a[1].localeCompare(b[1], "en", { numeric: true });
}
return diff;
});
// console.warn(mTimeAndRev);
this._log(
`Resolving conflict by newest: ${filename} (Newest: ${new Date(mTimeAndRev[0][0]).toLocaleString()}) (${mTimeAndRev.length} revisions exists)`
);
for (let i = 1; i < mTimeAndRev.length; i++) {
this._log(
`conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}`
);
await this._resolveConflictByDeletingRev(filename, mTimeAndRev[i][1], "NEWEST", showNotice);
}
return true;
}
private async _resolveAllConflictedFilesByNewerOnes() {
this._log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE);
const files = await this.core.storageAccess.getFileNames();
let i = 0;
for (const file of files) {
i++;
if (i % 10 === 0)
this._log(
`Check and Processing ${i} / ${files.length}`,
LOG_LEVEL_NOTICE,
"resolveAllConflictedFilesByNewerOnes"
);
await this._anyResolveConflictByNewest(file, false);
}
this._log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
}
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
services.conflict.resolveByDeletingRevision.setHandler(this._resolveConflictByDeletingRev.bind(this));
services.conflict.resolve.setHandler(this._resolveConflict.bind(this));
services.conflict.resolveByNewest.setHandler(this._anyResolveConflictByNewest.bind(this));
services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(
this._resolveAllConflictedFilesByNewerOnes.bind(this)
);
}
}
@@ -1,268 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
AUTO_MERGED,
DEFAULT_SETTINGS,
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
MISSING_OR_ERROR,
type FilePathWithPrefix,
type MetaEntry,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ModuleConflictResolver } from "./ModuleConflictResolver";
function createModule(files: FilePathWithPrefix[] = []) {
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
const tryAutoMerge = vi.fn();
const queueCheckFor = vi.fn(async () => undefined);
const resolveByUserInteraction = vi.fn(async () => false);
const core = {
_services: {
API: {
addLog: vi.fn(),
addCommand: vi.fn(),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
},
setting: {
saveSettingData: vi.fn(async () => undefined),
},
conflict: {
resolveByNewest: vi.fn(async () => true),
resolveByDeletingRevision,
resolveByUserInteraction,
queueCheckFor,
},
appLifecycle: {
isSuspended: vi.fn(() => false),
},
replication: {
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
},
vault: {
getActiveFilePath: vi.fn(() => undefined),
},
},
settings: DEFAULT_SETTINGS,
fileHandler: {
deleteRevisionFromDB: vi.fn(async () => true),
dbToStorage: vi.fn(async () => true),
},
databaseFileAccess: {
getConflictedRevs: vi.fn(async () => []),
storeContent: vi.fn(async () => true),
},
localDatabase: {
tryAutoMerge,
},
storageAccess: {
getFileNames: vi.fn(async () => files),
},
} as any;
Object.defineProperty(core, "services", { get: () => core._services });
const module = new ModuleConflictResolver(core);
module._log = vi.fn();
return { module, queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction, tryAutoMerge };
}
describe("ModuleConflictResolver bulk newest resolution", () => {
it("retains the success notice for a non-bulk newest resolution", async () => {
const { module } = createModule();
const path = "example.md" as FilePathWithPrefix;
module.core.databaseFileAccess.fetchEntryMeta = vi.fn(
async (_path: unknown, rev?: string): Promise<MetaEntry> =>
({
_id: "doc-id",
_rev: rev ?? "2-current",
path,
ctime: 1,
mtime: rev ? 1 : 2,
size: 0,
children: [],
type: "plain",
eden: {},
}) as unknown as MetaEntry
);
module.core.databaseFileAccess.getConflictedRevs = vi
.fn()
.mockResolvedValueOnce(["1-old"])
.mockResolvedValue([]);
await (module as any)._anyResolveConflictByNewest(path);
expect(module._log).toHaveBeenLastCalledWith(`${path} has been merged automatically`, LOG_LEVEL_NOTICE);
});
it("logs a successful bulk newest resolution without displaying a notice", async () => {
const { module } = createModule();
const path = "example.md" as FilePathWithPrefix;
module.core.databaseFileAccess.fetchEntryMeta = vi.fn(
async (_path: unknown, rev?: string): Promise<MetaEntry> =>
({
_id: "doc-id",
_rev: rev ?? "2-current",
path,
ctime: 1,
mtime: rev ? 1 : 2,
size: 0,
children: [],
type: "plain",
eden: {},
}) as unknown as MetaEntry
);
module.core.databaseFileAccess.getConflictedRevs = vi
.fn()
.mockResolvedValueOnce(["1-old"])
.mockResolvedValue([]);
await (module as any)._anyResolveConflictByNewest(path, false);
expect(module._log).toHaveBeenLastCalledWith(`${path} has been merged automatically`, LOG_LEVEL_INFO);
});
it("updates notice-level progress once every ten checked files", async () => {
const files = Array.from({ length: 11 }, (_, index) => `note-${index}.md` as FilePathWithPrefix);
const { module } = createModule(files);
const resolveByNewest = vi.spyOn(module as any, "_anyResolveConflictByNewest").mockResolvedValue(true);
await (module as any)._resolveAllConflictedFilesByNewerOnes();
expect(resolveByNewest).toHaveBeenCalledTimes(11);
expect(resolveByNewest).toHaveBeenCalledWith(files[0], false);
expect(module._log).toHaveBeenCalledWith(
"Check and Processing 10 / 11",
LOG_LEVEL_NOTICE,
"resolveAllConflictedFilesByNewerOnes"
);
expect(module._log).toHaveBeenCalledTimes(3);
});
});
describe("ModuleConflictResolver independent same-path creation", () => {
const path = "independently-created.md" as FilePathWithPrefix;
function leaf(rev: string, data: string, mtime: number) {
return {
rev,
data,
mtime,
ctime: mtime,
deleted: false,
} as any;
}
it("collapses one duplicate revision when independently created files have identical content", async () => {
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
const leftLeaf = leaf("1-left", "Same content\n", 1000);
const rightLeaf = leaf("1-right", "Same content\n", 2000);
tryAutoMerge.mockResolvedValue({
leftRev: leftLeaf.rev,
rightRev: rightLeaf.rev,
leftLeaf,
rightLeaf,
});
const result = await module.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(AUTO_MERGED);
expect(resolveByDeletingRevision).toHaveBeenCalledOnce();
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-left", "same");
});
it("returns a manual diff when independently created files have different content", async () => {
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
const leftLeaf = leaf("1-left", "Left content\n", 1000);
const rightLeaf = leaf("1-right", "Right content\n", 2000);
tryAutoMerge.mockResolvedValue({
leftRev: leftLeaf.rev,
rightRev: rightLeaf.rev,
leftLeaf,
rightLeaf,
});
const result = await module.checkConflictAndPerformAutoMerge(path);
expect(result).toMatchObject({ left: leftLeaf, right: rightLeaf });
expect(result).toHaveProperty("diff");
expect(resolveByDeletingRevision).not.toHaveBeenCalled();
});
});
describe("ModuleConflictResolver sensible merge hand-off", () => {
it("keeps an unreadable non-winner revision unresolved", async () => {
const path = "missing-conflict-body.md" as FilePathWithPrefix;
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
tryAutoMerge.mockResolvedValue({
leftRev: "3-current",
rightRev: "2-unreadable",
leftLeaf: {
rev: "3-current",
data: "Readable current body\n",
ctime: 1,
mtime: 3,
deleted: false,
},
rightLeaf: false,
});
const result = await module.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(MISSING_OR_ERROR);
expect(resolveByDeletingRevision).not.toHaveBeenCalled();
});
it("stores the merged body and removes the resolved conflict leaf", async () => {
const path = "sensible.md" as FilePathWithPrefix;
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
tryAutoMerge.mockResolvedValue({
result: "Title\nLeft changed\nRight changed\n",
conflictedRev: "2-right",
});
const result = await module.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(AUTO_MERGED);
expect(module.core.databaseFileAccess.storeContent).toHaveBeenCalledWith(
path,
"Title\nLeft changed\nRight changed\n"
);
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "Sensible");
});
it("commits a sensible pair before rechecking the remaining manual pair", async () => {
const path = "three-versions.md" as FilePathWithPrefix;
const { module, queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction, tryAutoMerge } =
createModule();
const remainingManualPair = {
leftRev: "3-merged",
rightRev: "2-third",
leftLeaf: { rev: "3-merged", data: "Merged\n", ctime: 1, mtime: 3 },
rightLeaf: { rev: "2-third", data: "Overlapping\n", ctime: 1, mtime: 2 },
};
tryAutoMerge
.mockResolvedValueOnce({
result: "Merged\n",
conflictedRev: "2-second",
})
.mockResolvedValueOnce(remainingManualPair);
await (module as any)._resolveConflict(path);
expect(module.core.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "Merged\n");
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-second", "Sensible");
expect(queueCheckFor).toHaveBeenCalledWith(path);
expect(resolveByUserInteraction).not.toHaveBeenCalled();
await (module as any)._resolveConflict(path);
expect(tryAutoMerge).toHaveBeenCalledTimes(2);
expect(resolveByUserInteraction).toHaveBeenCalledWith(
path,
expect.objectContaining({
left: remainingManualPair.leftLeaf,
right: remainingManualPair.rightLeaf,
})
);
});
});
@@ -1,19 +1,16 @@
import { Logger, LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
import { extractObject } from "octagonal-wheels/object";
import {
TweakValuesShouldMatchedTemplate,
TweakValuesTemplate,
IncompatibleChanges,
configurationNames,
statusDisplay,
type TweakValues,
type ObsidianLiveSyncSettings,
type RemoteDBSettings,
IncompatibleChangesInSpecificPattern,
CompatibleButLossyChanges,
type RemotePreferredTweakResult,
RemotePreferredTweakStatuses,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { assessTweakCompatibility, type TweakAssessment } from "@vrtmrz/livesync-commonlib/settings";
import { escapeMarkdownValue } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { AbstractModule } from "@/modules/AbstractModule.ts";
import { $msg, translateIfAvailable } from "@/common/translation";
@@ -59,14 +56,49 @@ function valueToString(value: string | number | boolean | object | undefined): s
return `${value}`;
}
export class ModuleResolvingMismatchedTweaks extends AbstractModule {
private _collectMismatchedTweakKeys(current: TweakValues, preferred: Partial<TweakValues>) {
const items = Object.keys(
TweakValuesShouldMatchedTemplate
) as (keyof typeof TweakValuesShouldMatchedTemplate)[];
return items.filter((key) => current[key] !== preferred[key]);
}
function definedTweaks(values: TweakValues): TweakValues {
return Object.fromEntries(
Object.entries(values).filter(([key, value]) => key in TweakValuesTemplate && value !== undefined)
);
}
function settingsAfterAdoption(assessment: TweakAssessment, direction: "adoptPreferred" | "adoptCurrent"): TweakValues {
const comparedKeys = new Set<string>(assessment.entries.map((entry) => entry.key));
const source = direction === "adoptPreferred" ? assessment.preferredValues : assessment.currentValues;
const target = direction === "adoptPreferred" ? assessment.currentValues : assessment.preferredValues;
const recommendations = Object.fromEntries(
Object.entries(source).filter(([key, value]) => !comparedKeys.has(key) && value !== undefined)
);
return {
...definedTweaks(target),
...recommendations,
...assessment[direction].changes,
};
}
function mismatchTable(assessment: TweakAssessment, direction?: "adoptPreferred" | "adoptCurrent"): string {
const reasons = direction
? assessment[direction].reasons
: [...assessment.adoptPreferred.reasons, ...assessment.adoptCurrent.reasons];
const consequenceKeys = new Set(reasons.map((reason) => reason.key));
const rows = assessment.entries
.filter((entry) => entry.relation === "different" || consequenceKeys.has(entry.key))
.map((entry) =>
$msg("TweakMismatchResolve.Table.Row", {
name: localisedConfName(entry.key),
self: valueToString(escapeMarkdownValue(entry.current.effectiveValue)),
remote: valueToString(escapeMarkdownValue(entry.preferred.effectiveValue)),
})
);
return $msg("TweakMismatchResolve.Table", { rows: rows.join("\n") });
}
/** Kept only while resolving a decision; this can contain credentials and must never be logged. */
function resolutionSettingsSignature(settings: ObsidianLiveSyncSettings): string {
return JSON.stringify({ ...settings, autoAcceptCompatibleTweak: settings.autoAcceptCompatibleTweak ?? true });
}
export class ModuleResolvingMismatchedTweaks extends AbstractModule {
private _selectNewerTweakSide(current: TweakValues, preferred: Partial<TweakValues>): "REMOTE" | "CURRENT" {
Logger(`Modified: ${current.tweakModified} (current) vs ${preferred.tweakModified} (preferred)`);
const currentModified = current.tweakModified;
@@ -83,15 +115,9 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
}
private async _shouldAutoAcceptCompatibleLossy(
current: TweakValues,
preferred: Partial<TweakValues>,
mismatchedKeys: (keyof typeof TweakValuesShouldMatchedTemplate)[]
assessment: TweakAssessment
): Promise<"REMOTE" | "CURRENT" | undefined> {
if (mismatchedKeys.length === 0) return undefined;
const hasOnlyCompatibleLossyMismatches = mismatchedKeys.every(
(key) => CompatibleButLossyChanges.indexOf(key) !== -1
);
if (!hasOnlyCompatibleLossyMismatches) return undefined;
if (!assessment.onlyCompatibleLossyDifferences) return undefined;
let autoAcceptCompatibleTweak = this.settings.autoAcceptCompatibleTweak;
if (this.settings.autoAcceptCompatibleTweak === undefined) {
@@ -104,7 +130,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
}
if (autoAcceptCompatibleTweak !== true) return undefined;
return this._selectNewerTweakSide(current, preferred);
return this._selectNewerTweakSide(assessment.currentValues, assessment.preferredValues);
}
/**
@@ -138,6 +164,14 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
) {
return false;
}
const isCurrent = await this.services.replicator.runWithActiveReplicatorContext(
(activeContext) => activeContext === failure.context
);
if (!isCurrent || resolutionSettingsSignature(failure.setting) !== resolutionSettingsSignature(this.settings)) {
return true;
}
const assessment =
recovery.tweakAssessment ?? assessTweakCompatibility(failure.setting, recovery.preferredTweakValue);
const ret = await this.services.tweakValue.askResolvingMismatched(
{ ...recovery.preferredTweakValue },
async (setting) => {
@@ -149,138 +183,119 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
updated = true;
});
return updated;
}
},
assessment
);
if (ret == "OK") return false;
if (ret == "CHECKAGAIN") return "CHECKAGAIN";
if (ret == "IGNORE") return true;
}
async _checkAndAskResolvingMismatchedTweaks(preferred: TweakValues): Promise<[TweakValues | boolean, boolean]> {
const mine = extractObject(TweakValuesTemplate, this.settings) as TweakValues;
const mismatchedKeys = this._collectMismatchedTweakKeys(mine, preferred);
const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(mine, preferred, mismatchedKeys);
if (autoAcceptSide === "REMOTE") {
return [{ ...mine, ...preferred }, false];
}
if (autoAcceptSide === "CURRENT") {
return [true, false];
}
const items = Object.entries(TweakValuesShouldMatchedTemplate);
let rebuildRequired = false;
let rebuildRecommended = false;
// Making tables:
// let table = `| Value name | This device | Configured | \n` + `|: --- |: --- :|: ---- :| \n`;
const tableRows = [];
// const items = [mine,preferred]
for (const v of items) {
const key = v[0] as keyof typeof TweakValuesShouldMatchedTemplate;
const valueMine = escapeMarkdownValue(mine[key]);
const valuePreferred = escapeMarkdownValue(preferred[key]);
if (valueMine == valuePreferred) continue;
if (IncompatibleChanges.indexOf(key) !== -1) {
rebuildRequired = true;
}
for (const pattern of IncompatibleChangesInSpecificPattern) {
if (pattern.key !== key) continue;
// if from value supplied, check if current value have been violated : in other words, if the current value is the same as the from value, it should require a rebuild.
const isFromConditionMet = "from" in pattern ? pattern.from === mine[key] : false;
// and, if to value supplied, same as above.
const isToConditionMet = "to" in pattern ? pattern.to === preferred[key] : false;
// if either of them is true, it should require a rebuild, if the pattern is not a recommendation.
if (isFromConditionMet || isToConditionMet) {
if (pattern.isRecommendation) {
rebuildRecommended = true;
} else {
rebuildRequired = true;
}
}
}
if (CompatibleButLossyChanges.indexOf(key) !== -1) {
rebuildRecommended = true;
}
// table += `| ${confName(key)} | ${valueMine} | ${valuePreferred} | \n`;
tableRows.push(
$msg("TweakMismatchResolve.Table.Row", {
name: localisedConfName(key),
self: valueToString(valueMine),
remote: valueToString(valuePreferred),
})
);
}
async _checkAndAskResolvingMismatchedTweaks(
preferred: TweakValues,
assessment = assessTweakCompatibility(this.settings, preferred)
): Promise<[TweakValues | boolean, boolean]> {
if (assessment.alignment === "matched") return [false, false];
const acceptedSettings = settingsAfterAdoption(assessment, "adoptPreferred");
const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(assessment);
if (autoAcceptSide === "REMOTE") return [acceptedSettings, false];
if (autoAcceptSide === "CURRENT") return [true, false];
const localImpact = assessment.adoptPreferred.reconstruction;
const remoteImpact = assessment.adoptCurrent.reconstruction;
const requiresRebuild = localImpact === "required" || remoteImpact === "required";
const recommendsRebuild = localImpact === "recommended" || remoteImpact === "recommended";
const additionalMessage =
rebuildRequired && this.core.settings.isConfigured
requiresRebuild && this.settings.isConfigured
? $msg("TweakMismatchResolve.Message.WarningIncompatibleRebuildRequired")
: "";
const additionalMessage2 =
rebuildRecommended && this.core.settings.isConfigured
recommendsRebuild && this.settings.isConfigured
? $msg("TweakMismatchResolve.Message.WarningIncompatibleRebuildRecommended")
: "";
const table = $msg("TweakMismatchResolve.Table", { rows: tableRows.join("\n") });
const message = $msg("TweakMismatchResolve.Message.MainTweakResolving", {
table: table,
additionalMessage: [additionalMessage, additionalMessage2].filter((v) => v).join("\n"),
table: mismatchTable(assessment),
additionalMessage: [additionalMessage, additionalMessage2].filter(Boolean).join("\n"),
});
const CHOICE_USE_REMOTE = $msg("TweakMismatchResolve.Action.UseRemote");
const CHOICE_USE_REMOTE_WITH_REBUILD = $msg("TweakMismatchResolve.Action.UseRemoteWithRebuild");
const CHOICE_USE_REMOTE_PREVENT_REBUILD = $msg("TweakMismatchResolve.Action.UseRemoteAcceptIncompatible");
const CHOICE_USE_MINE = $msg("TweakMismatchResolve.Action.UseMine");
const CHOICE_USE_MINE_WITH_REBUILD = $msg("TweakMismatchResolve.Action.UseMineWithRebuild");
const CHOICE_USE_MINE_PREVENT_REBUILD = $msg("TweakMismatchResolve.Action.UseMineAcceptIncompatible");
const CHOICE_DISMISS = $msg("TweakMismatchResolve.Action.Dismiss");
const CHOICE_AND_VALUES = [] as [string, [result: TweakValues | boolean, rebuild: boolean]][];
if (rebuildRequired) {
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_WITH_REBUILD, [preferred, true]]);
CHOICE_AND_VALUES.push([CHOICE_USE_MINE_WITH_REBUILD, [true, true]]);
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_PREVENT_REBUILD, [preferred, false]]);
CHOICE_AND_VALUES.push([CHOICE_USE_MINE_PREVENT_REBUILD, [true, false]]);
} else if (rebuildRecommended) {
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE, [preferred, false]]);
CHOICE_AND_VALUES.push([CHOICE_USE_MINE, [true, false]]);
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_WITH_REBUILD, [preferred, true]]);
CHOICE_AND_VALUES.push([CHOICE_USE_MINE_WITH_REBUILD, [true, true]]);
} else {
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE, [preferred, false]]);
CHOICE_AND_VALUES.push([CHOICE_USE_MINE, [true, false]]);
const choices: Record<string, [TweakValues | boolean, boolean]> = {};
const remoteChoices = {
ordinary: $msg("TweakMismatchResolve.Action.UseRemote"),
rebuild: $msg("TweakMismatchResolve.Action.UseRemoteWithRebuild"),
accept: $msg("TweakMismatchResolve.Action.UseRemoteAcceptIncompatible"),
};
const localChoices = {
ordinary: $msg("TweakMismatchResolve.Action.UseMine"),
rebuild: $msg("TweakMismatchResolve.Action.UseMineWithRebuild"),
accept: $msg("TweakMismatchResolve.Action.UseMineAcceptIncompatible"),
};
// Each direction owns its consequence; a rebuild on one side does not require one on the other.
choices[localImpact === "required" ? remoteChoices.rebuild : remoteChoices.ordinary] = [
acceptedSettings,
localImpact === "required",
];
choices[remoteImpact === "required" ? localChoices.rebuild : localChoices.ordinary] = [
true,
remoteImpact === "required",
];
if (localImpact !== "none") {
choices[localImpact === "required" ? remoteChoices.accept : remoteChoices.rebuild] = [
acceptedSettings,
localImpact !== "required",
];
}
CHOICE_AND_VALUES.push([CHOICE_DISMISS, [false, false]]);
const CHOICES = Object.fromEntries(CHOICE_AND_VALUES) as Record<
string,
[TweakValues | boolean, performRebuild: boolean]
>;
const retKey = await this.core.confirm.askSelectStringDialogue(message, Object.keys(CHOICES), {
if (remoteImpact !== "none") {
choices[remoteImpact === "required" ? localChoices.accept : localChoices.rebuild] = [
true,
remoteImpact !== "required",
];
}
const dismiss = $msg("TweakMismatchResolve.Action.Dismiss");
choices[dismiss] = [false, false];
const retKey = await this.core.confirm.askSelectStringDialogue(message, Object.keys(choices), {
title: $msg("TweakMismatchResolve.Title.TweakResolving"),
timeout: 60,
defaultAction: CHOICE_DISMISS,
defaultAction: dismiss,
});
if (!retKey) return [false, false];
return CHOICES[retKey];
return (retKey && choices[retKey]) || [false, false];
}
async _askResolvingMismatchedTweaks(
preferredSource: TweakValues,
updatePreferredRemote?: (setting: ObsidianLiveSyncSettings) => Promise<boolean>
updatePreferredRemote?: (setting: ObsidianLiveSyncSettings) => Promise<boolean>,
assessment = assessTweakCompatibility(this.settings, preferredSource)
): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> {
const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(preferredSource);
const signature = resolutionSettingsSignature(this.settings);
const publication = await this.services.replicator.acquireActiveReplicatorContext();
if (resolutionSettingsSignature(this.settings) !== signature) return "IGNORE";
const currentTweaks = JSON.stringify(extractObject(TweakValuesTemplate, this.settings));
if (JSON.stringify(extractObject(TweakValuesTemplate, assessment.currentValues)) !== currentTweaks) {
return "IGNORE";
}
const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(
preferredSource,
assessment
);
if (!conf) return "IGNORE";
const currentPublication = await this.services.replicator.acquireActiveReplicatorContext();
if (currentPublication !== publication || resolutionSettingsSignature(this.settings) !== signature) {
return "IGNORE";
}
const updateRemote = async () => {
if (updatePreferredRemote) return await updatePreferredRemote(this.settings);
const updateRemote = async (tweaks: TweakValues) => {
const setting = {
...this.settings,
...definedTweaks(assessment.preferredValues),
...definedTweaks(tweaks),
};
if (updatePreferredRemote) return await updatePreferredRemote(setting);
const candidate = this.core.replicator;
if (typeof candidate.setPreferredRemoteTweakSettings !== "function") return false;
await candidate.setPreferredRemoteTweakSettings(this.settings);
await candidate.setPreferredRemoteTweakSettings(setting);
return true;
};
if (conf === true) {
if (!(await updateRemote())) return "IGNORE";
if (!(await updateRemote(settingsAfterAdoption(assessment, "adoptCurrent")))) return "IGNORE";
if (rebuildRequired) {
await this.core.rebuilder.$rebuildRemote();
}
@@ -288,16 +303,15 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
return "CHECKAGAIN";
}
if (conf) {
// ReplicationService retains the current settings object while it performs the immediate
// CHECKAGAIN retry. Update that object in place so the retry observes the accepted values.
Object.assign(this.settings, extractObject(TweakValuesTemplate, conf));
// Keep existing consumers' settings reference stable, and never erase a value omitted by an older peer.
Object.assign(this.settings, definedTweaks(conf));
await this.services.setting.saveSettingData();
if (!rebuildRequired) {
// The failed replication has settled before mismatch resolution runs. Reinitialise the
// chunk-generation managers now so hash and splitter changes take effect before retrying.
await this.localDatabase.managers.reinitialise();
}
if (!(await updateRemote())) return "IGNORE";
if (!(await updateRemote(this.settings))) return "IGNORE";
if (rebuildRequired) {
await this.core.rebuilder.$fetchLocal();
}
@@ -333,7 +347,9 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
if (trialSetting.remoteType === REMOTE_P2P) {
return { result: false, requireFetch: false };
}
const signature = JSON.stringify(trialSetting);
const preferred = await this.services.tweakValue.fetchRemotePreferred(trialSetting);
if (JSON.stringify(trialSetting) !== signature) return { result: false, requireFetch: false };
if (preferred.status === RemotePreferredTweakStatuses.AVAILABLE) {
return await this.services.tweakValue.askUseRemoteConfiguration(trialSetting, preferred.values);
}
@@ -344,101 +360,48 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
trialSetting: RemoteDBSettings,
preferred: TweakValues
): Promise<{ result: false | TweakValues; requireFetch: boolean }> {
const localTweaks = extractObject(TweakValuesTemplate, this.settings) as TweakValues;
const mismatchedKeys = this._collectMismatchedTweakKeys(localTweaks, preferred);
const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(localTweaks, preferred, mismatchedKeys);
if (autoAcceptSide === "REMOTE") {
return { result: { ...trialSetting, ...preferred }, requireFetch: false };
}
if (autoAcceptSide === "CURRENT") {
return { result: false, requireFetch: false };
}
const items = Object.entries(TweakValuesShouldMatchedTemplate);
let rebuildRequired = false;
let rebuildRecommended = false;
// Making tables:
// let table = `| Value name | This device | On Remote | \n` + `|: --- |: ---- :|: ---- :| \n`;
let differenceCount = 0;
const tableRows = [] as string[];
// const items = [mine,preferred]
for (const v of items) {
const key = v[0] as keyof typeof TweakValuesShouldMatchedTemplate;
const remoteValueForDisplay = escapeMarkdownValue(valueToString(preferred[key]));
const currentValueForDisplay = escapeMarkdownValue(valueToString((trialSetting as TweakValues)?.[key]));
if ((trialSetting as TweakValues)?.[key] !== preferred[key]) {
if (IncompatibleChanges.indexOf(key) !== -1) {
rebuildRequired = true;
}
for (const pattern of IncompatibleChangesInSpecificPattern) {
if (pattern.key !== key) continue;
// if from value supplied, check if current value have been violated : in other words, if the current value is the same as the from value, it should require a rebuild.
const isFromConditionMet =
"from" in pattern ? pattern.from === (trialSetting as TweakValues)?.[key] : false;
// and, if to value supplied, same as above.
const isToConditionMet = "to" in pattern ? pattern.to === preferred[key] : false;
// if either of them is true, it should require a rebuild, if the pattern is not a recommendation.
if (isFromConditionMet || isToConditionMet) {
if (pattern.isRecommendation) {
rebuildRecommended = true;
} else {
rebuildRequired = true;
}
}
}
if (CompatibleButLossyChanges.indexOf(key) !== -1) {
rebuildRecommended = true;
}
} else {
continue;
}
tableRows.push(
$msg("TweakMismatchResolve.Table.Row", {
name: localisedConfName(key),
self: currentValueForDisplay,
remote: remoteValueForDisplay,
})
);
differenceCount++;
}
if (differenceCount === 0) {
const trialSignature = JSON.stringify(trialSetting);
const currentSignature = resolutionSettingsSignature(this.settings);
const assessment = assessTweakCompatibility(trialSetting, preferred);
if (assessment.alignment === "matched") {
this._log("The settings in the remote database are the same as the local database.", LOG_LEVEL_NOTICE);
return { result: false, requireFetch: false };
}
const publication = await this.services.replicator.acquireActiveReplicatorContext();
const settingsStillCurrent = () =>
JSON.stringify(trialSetting) === trialSignature &&
resolutionSettingsSignature(this.settings) === currentSignature;
if (!settingsStillCurrent()) return { result: false, requireFetch: false };
const stillCurrent = async () =>
(await this.services.replicator.acquireActiveReplicatorContext()) === publication && settingsStillCurrent();
const acceptedSettings = { ...trialSetting, ...settingsAfterAdoption(assessment, "adoptPreferred") };
const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(assessment);
if (!(await stillCurrent())) return { result: false, requireFetch: false };
if (autoAcceptSide === "REMOTE") return { result: acceptedSettings, requireFetch: false };
if (autoAcceptSide === "CURRENT") return { result: false, requireFetch: false };
const impact = assessment.adoptPreferred.reconstruction;
const additionalMessage =
rebuildRequired && this.core.settings.isConfigured
impact === "required" && this.settings.isConfigured
? $msg("TweakMismatchResolve.Message.UseRemote.WarningRebuildRequired")
: "";
const additionalMessage2 =
rebuildRecommended && this.core.settings.isConfigured
impact === "recommended" && this.settings.isConfigured
? $msg("TweakMismatchResolve.Message.UseRemote.WarningRebuildRecommended")
: "";
const table = $msg("TweakMismatchResolve.Table", { rows: tableRows.join("\n") });
const message = $msg("TweakMismatchResolve.Message.Main", {
table: table,
additionalMessage: [additionalMessage, additionalMessage2].filter((v) => v).join("\n"),
table: mismatchTable(assessment, "adoptPreferred"),
additionalMessage: [additionalMessage, additionalMessage2].filter(Boolean).join("\n"),
});
const CHOICE_USE_REMOTE = $msg("TweakMismatchResolve.Action.UseConfigured");
const CHOICE_DISMISS = $msg("TweakMismatchResolve.Action.Dismiss");
// const CHOICE_AND_VALUES = [
// [CHOICE_USE_REMOTE, preferred],
// [CHOICE_DISMISS, false]]
const CHOICES = [CHOICE_USE_REMOTE, CHOICE_DISMISS];
const retKey = await this.core.confirm.askSelectStringDialogue(message, CHOICES, {
const useRemote = $msg("TweakMismatchResolve.Action.UseConfigured");
const dismiss = $msg("TweakMismatchResolve.Action.Dismiss");
const retKey = await this.core.confirm.askSelectStringDialogue(message, [useRemote, dismiss], {
title: $msg("TweakMismatchResolve.Title.UseRemoteConfig"),
timeout: 0,
defaultAction: CHOICE_DISMISS,
defaultAction: dismiss,
});
if (!retKey) return { result: false, requireFetch: false };
if (retKey === CHOICE_DISMISS) return { result: false, requireFetch: false };
if (retKey === CHOICE_USE_REMOTE) {
return { result: { ...trialSetting, ...preferred }, requireFetch: rebuildRequired };
}
return { result: false, requireFetch: false };
if (retKey !== useRemote || !(await stillCurrent())) return { result: false, requireFetch: false };
return { result: acceptedSettings, requireFetch: impact === "required" };
}
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
@@ -2,9 +2,12 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
TweakValuesTemplate,
type RemoteDBSettings,
type TweakValues,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { extractObject } from "octagonal-wheels/object";
import { assessTweakCompatibility } from "@vrtmrz/livesync-commonlib/settings";
import { ModuleResolvingMismatchedTweaks } from "./ModuleResolveMismatchedTweaks";
import { setLang } from "@/common/translation";
import {
@@ -14,10 +17,16 @@ import {
type ReplicationAttemptFailure,
} from "@vrtmrz/livesync-commonlib/replication";
const BASE_TWEAKS = {
...extractObject(TweakValuesTemplate, DEFAULT_SETTINGS),
handleFilenameCaseSensitive: false,
};
function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
const askSelectStringDialogue = vi.fn(async (..._args: unknown[]): Promise<string | undefined> => undefined);
const applyPartial = vi.fn(async (_partial: Record<string, unknown>): Promise<void> => undefined);
const reinitialise = vi.fn(async () => undefined);
const publication = {};
const core = {
_services: {
API: {
@@ -31,6 +40,9 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
saveSettingData: vi.fn(async () => undefined),
applyPartial,
},
replicator: {
acquireActiveReplicatorContext: vi.fn(async () => publication),
},
},
localDatabase: {
managers: {
@@ -39,6 +51,7 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
},
settings: {
...DEFAULT_SETTINGS,
handleFilenameCaseSensitive: false,
remoteType: REMOTE_COUCHDB,
...settingsOverride,
},
@@ -61,14 +74,170 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
}
describe("ModuleResolvingMismatchedTweaks", () => {
it("compatibility: offers ordinary application for a missing legacy filename-case setting", async () => {
const { module, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: false,
customChunkSize: 60,
usePluginSyncV2: true,
handleFilenameCaseSensitive: false,
});
const preferred: TweakValues = {
...DEFAULT_SETTINGS,
customChunkSize: 0,
usePluginSyncV2: false,
};
delete preferred.handleFilenameCaseSensitive;
await module._checkAndAskResolvingMismatchedTweaks(preferred);
expect(askSelectStringDialogue.mock.calls[0][1]).toContain("Apply settings to this device");
expect(askSelectStringDialogue.mock.calls[0][0]).not.toContain("Handle files as Case-Sensitive");
});
it("compares the trial configuration when deciding whether to accept compatible remote values", async () => {
const { module, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: true,
hashAlg: "xxhash32",
tweakModified: 300,
});
const trial = {
...DEFAULT_SETTINGS,
hashAlg: "xxhash64",
tweakModified: 100,
} as RemoteDBSettings;
const preferred = { ...trial, hashAlg: "xxhash32", tweakModified: 200 } as TweakValues;
const result = await module._askUseRemoteConfiguration(trial, preferred);
expect(result).toEqual({ result: { ...trial, ...preferred }, requireFetch: false });
expect(askSelectStringDialogue).not.toHaveBeenCalled();
});
it("discards remote profile adoption if the active publication changed while awaiting it", async () => {
const { module, core, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: false,
usePluginSyncV2: true,
});
let publication = {};
core._services.replicator.acquireActiveReplicatorContext.mockImplementation(async () => publication);
askSelectStringDialogue.mockImplementation(async () => {
publication = {};
return "Use configured settings";
});
const trial = { ...core.settings } as RemoteDBSettings;
const preferred = { ...trial, usePluginSyncV2: false };
const result = await module._askUseRemoteConfiguration(trial, preferred);
expect(askSelectStringDialogue).toHaveBeenCalled();
expect(result).toEqual({ result: false, requireFetch: false });
});
it("discards a decision if the connection settings changed while awaiting it", async () => {
const { module, core, reinitialise } = createModule({ hashAlg: "xxhash64" });
const preferred = { ...DEFAULT_SETTINGS, hashAlg: "xxhash32" } as TweakValues;
core._services.tweakValue = {
checkAndAskResolvingMismatched: vi.fn(async () => {
core.settings.couchDB_DBNAME = "another-database";
return [preferred, false];
}),
};
const updatePreferredRemote = vi.fn(async () => true);
const result = await module._askResolvingMismatchedTweaks(preferred, updatePreferredRemote);
expect(result).toBe("IGNORE");
expect(core.settings.hashAlg).toBe("xxhash64");
expect(core._services.setting.saveSettingData).not.toHaveBeenCalled();
expect(reinitialise).not.toHaveBeenCalled();
expect(updatePreferredRemote).not.toHaveBeenCalled();
});
it("discards a decision if its active publication was replaced while awaiting it", async () => {
const { module, core, reinitialise } = createModule({ hashAlg: "xxhash64" });
const preferred = { ...BASE_TWEAKS, hashAlg: "xxhash32" } as TweakValues;
core._services.tweakValue = {
checkAndAskResolvingMismatched: vi.fn(async () => [preferred, false]),
};
core._services.replicator.acquireActiveReplicatorContext.mockResolvedValueOnce({}).mockResolvedValueOnce({});
const updatePreferredRemote = vi.fn(async () => true);
await expect(module._askResolvingMismatchedTweaks(preferred, updatePreferredRemote)).resolves.toBe("IGNORE");
expect(core._services.setting.saveSettingData).not.toHaveBeenCalled();
expect(reinitialise).not.toHaveBeenCalled();
expect(updatePreferredRemote).not.toHaveBeenCalled();
});
it("uses each direction's assessed reconstruction consequence in the available choices", async () => {
const { module, core, askSelectStringDialogue } = createModule({ autoAcceptCompatibleTweak: false });
const preferred = { ...BASE_TWEAKS, encrypt: true };
const assessment = assessTweakCompatibility(core.settings, preferred);
const directionalAssessment = {
...assessment,
adoptCurrent: { ...assessment.adoptCurrent, reconstruction: "none" as const },
};
askSelectStringDialogue.mockResolvedValueOnce("Update remote database settings");
const result = await module._checkAndAskResolvingMismatchedTweaks(preferred, directionalAssessment);
expect(result).toEqual([true, false]);
expect(askSelectStringDialogue.mock.calls[0][1]).toContain("Apply settings to this device, and fetch again");
expect(askSelectStringDialogue.mock.calls[0][1]).not.toContain("Apply settings to this device");
});
it("keeps explicitly chosen Fetch failures from becoming a successful retry", async () => {
const { module, core } = createModule({ hashAlg: "xxhash64" });
const preferred = { ...BASE_TWEAKS, hashAlg: "xxhash32" } as TweakValues;
core._services.tweakValue = {
checkAndAskResolvingMismatched: vi.fn(async () => [preferred, true]),
};
const failure = new Error("Fetch failed");
core.rebuilder = {
$fetchLocal: vi.fn(async () => {
throw failure;
}),
};
await expect(module._askResolvingMismatchedTweaks(preferred, async () => true)).rejects.toBe(failure);
});
it("does not erase an explicit local setting when accepting a partial remote configuration", async () => {
const { module, core } = createModule({ handleFilenameCaseSensitive: false });
core._services.tweakValue = {
checkAndAskResolvingMismatched: vi.fn(async () => [{ customChunkSize: 30 }, false]),
};
await expect(module._askResolvingMismatchedTweaks({ customChunkSize: 30 }, async () => true)).resolves.toBe(
"CHECKAGAIN"
);
expect(core.settings.handleFilenameCaseSensitive).toBe(false);
expect(core.settings.customChunkSize).toBe(30);
});
it("preserves a remote recommendation which this device has not advertised", async () => {
const { module, core } = createModule({ hashAlg: "xxhash64" });
delete core.settings.readChunksOnline;
const preferred = { ...BASE_TWEAKS, hashAlg: "xxhash32", readChunksOnline: false } as TweakValues;
core._services.tweakValue = {
checkAndAskResolvingMismatched: vi.fn(async () => [true, false]),
};
const updateRemote = vi.fn(async () => true);
await expect(module._askResolvingMismatchedTweaks(preferred, updateRemote)).resolves.toBe("CHECKAGAIN");
expect(updateRemote).toHaveBeenCalledWith(
expect.objectContaining({ hashAlg: "xxhash64", readChunksOnline: false })
);
});
it("uses the failed attempt hint and writes only through that exact active publication", async () => {
const { module, core } = createModule();
const attemptPreferred = {
...(DEFAULT_SETTINGS as unknown as TweakValues),
...BASE_TWEAKS,
customChunkSize: 60,
};
const replacementPreferred = {
...(DEFAULT_SETTINGS as unknown as TweakValues),
...BASE_TWEAKS,
customChunkSize: 99,
};
let updatePreferredRemote: ((setting: typeof core.settings) => Promise<boolean>) | undefined;
@@ -118,7 +287,11 @@ describe("ModuleResolvingMismatchedTweaks", () => {
await expect(module._anyAfterConnectCheckFailed(request)).resolves.toBe(true);
expect(askResolvingMismatched).toHaveBeenCalledWith(attemptPreferred, expect.any(Function));
expect(askResolvingMismatched).toHaveBeenCalledWith(
attemptPreferred,
expect.any(Function),
expect.objectContaining({ alignment: "mismatched" })
);
const effectiveSetting = { ...core.settings, customChunkSize: 64 };
await expect(updatePreferredRemote?.(effectiveSetting)).resolves.toBe(true);
expect(failedSetPreferred).toHaveBeenCalledWith(effectiveSetting);
@@ -193,7 +366,7 @@ describe("ModuleResolvingMismatchedTweaks", () => {
const initialSettings = core.settings;
const preferred = {
...(DEFAULT_SETTINGS as unknown as TweakValues),
...BASE_TWEAKS,
hashAlg: "xxhash32",
tweakModified: 200,
} as Partial<TweakValues>;
@@ -217,7 +390,7 @@ describe("ModuleResolvingMismatchedTweaks", () => {
});
const preferred = {
...(DEFAULT_SETTINGS as unknown as TweakValues),
...BASE_TWEAKS,
hashAlg: "xxhash32",
tweakModified: 200,
} as Partial<TweakValues>;
@@ -239,7 +412,7 @@ describe("ModuleResolvingMismatchedTweaks", () => {
tweakModified: currentModified,
});
const preferred = {
...(DEFAULT_SETTINGS as unknown as TweakValues),
...BASE_TWEAKS,
hashAlg: "xxhash32",
tweakModified: preferredModified,
} as Partial<TweakValues>;
@@ -260,7 +433,7 @@ describe("ModuleResolvingMismatchedTweaks", () => {
});
const preferred = {
...(DEFAULT_SETTINGS as unknown as TweakValues),
...BASE_TWEAKS,
hashAlg: "xxhash32",
encrypt: true,
tweakModified: 200,
@@ -281,7 +454,7 @@ describe("ModuleResolvingMismatchedTweaks", () => {
askSelectStringDialogue.mockResolvedValueOnce("Apply settings to this device, and fetch again");
const preferred = {
...(DEFAULT_SETTINGS as unknown as TweakValues),
...BASE_TWEAKS,
hashAlg: "xxhash32",
} as TweakValues;
@@ -325,7 +498,7 @@ describe("ModuleResolvingMismatchedTweaks", () => {
});
const initialSettings = core.settings;
const preferred = {
...(DEFAULT_SETTINGS as unknown as TweakValues),
...BASE_TWEAKS,
hashAlg: "xxhash32",
tweakModified: 200,
} as TweakValues;
@@ -372,7 +545,7 @@ describe("ModuleResolvingMismatchedTweaks setting labels", () => {
tweakModified: 100,
});
const preferred = {
...(DEFAULT_SETTINGS as unknown as TweakValues),
...BASE_TWEAKS,
hashAlg: "xxhash32",
encrypt: true,
tweakModified: 200,
-107
View File
@@ -1,107 +0,0 @@
import type { LiveSyncCore } from "@/main";
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
import { fireAndForget } from "octagonal-wheels/promises";
import { AbstractModule } from "@/modules/AbstractModule";
import { $msg } from "@/common/translation";
import { copyFileDatabaseInfo } from "@/serviceFeatures/fileDatabaseInfo";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
// Separated Module for basic menu commands, which are not related to obsidian specific features. It is expected to be used in other platforms with minimal changes.
// However, it is odd that it has here at all; it really ought to be in each respective feature. It will likely be moved eventually. Until now, addCommand pointed to Obsidian's version.
export class ModuleBasicMenu extends AbstractModule {
_everyOnloadStart(): Promise<boolean> {
this.addCommand({
id: "livesync-replicate",
name: $msg("Sync now"),
callback: async () => {
await this.services.replication.replicateUserInitiated({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
},
});
this.addCommand({
id: "livesync-dump",
name: $msg("Copy database information for the active file"),
checkCallback: (checking) => {
const file = this.services.vault.getActiveFilePath();
if (!file) return false;
if (!checking) {
fireAndForget(() => copyFileDatabaseInfo(this.core, file));
}
return true;
},
});
this.addCommand({
id: "livesync-toggle",
name: "Toggle LiveSync",
callback: async () => {
if (this.settings.liveSync) {
this.settings.liveSync = false;
this._log("LiveSync Disabled.", LOG_LEVEL_NOTICE);
} else {
this.settings.liveSync = true;
this._log("LiveSync Enabled.", LOG_LEVEL_NOTICE);
}
await this.services.control.applySettings();
await this.services.setting.saveSettingData();
},
});
this.addCommand({
id: "livesync-suspendall",
name: "Toggle All Sync.",
callback: async () => {
if (this.services.appLifecycle.isSuspended()) {
this.services.appLifecycle.setSuspended(false);
this._log("Self-hosted LiveSync resumed", LOG_LEVEL_NOTICE);
} else {
this.services.appLifecycle.setSuspended(true);
this._log("Self-hosted LiveSync suspended", LOG_LEVEL_NOTICE);
}
await this.services.control.applySettings();
await this.services.setting.saveSettingData();
},
});
this.addCommand({
id: "livesync-scan-files",
name: "Scan storage and database again",
checkCallback: (checking) => {
if (!this.settings.useAdvancedMode) return false;
if (!checking) {
fireAndForget(() => this.services.vault.scanVault(true));
}
return true;
},
});
this.addCommand({
id: "livesync-runbatch",
name: $msg("Apply pending changes now"),
callback: async () => {
await this.services.fileProcessing.commitPendingFileEvents();
},
});
// TODO, Replicator is possibly one of features. It should be moved to features.
this.addCommand({
id: "livesync-abortsync",
name: "Abort synchronization immediately",
checkCallback: (checking) => {
if (!this.settings.useAdvancedMode) return false;
if (!checking) {
fireAndForget(() => this.services.replication.stopActiveTransfer());
}
return true;
},
});
return Promise.resolve(true);
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
}
}
@@ -1,201 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { Command } from "@/deps";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
import { ModuleBasicMenu } from "./ModuleBasicMenu";
type RegisteredCommand = Command & {
checkCallback?: (checking: boolean) => boolean | void;
};
function createFixture() {
const commands: RegisteredCommand[] = [];
const settings = {
liveSync: false,
useAdvancedMode: false,
enableDebugTools: false,
};
const services = {
API: {
addLog: vi.fn(),
addCommand: vi.fn((command: RegisteredCommand) => {
commands.push(command);
return command;
}),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
},
replication: {
replicateUserInitiated: vi.fn(async () => ({ status: "completed" as const })),
stopActiveTransfer: vi.fn(async () => ({ status: "completed" as const })),
},
vault: {
getActiveFilePath: vi.fn((): string | null => "note.md"),
scanVault: vi.fn(async () => undefined),
},
control: {
applySettings: vi.fn(async () => undefined),
},
setting: {
saveSettingData: vi.fn(async () => undefined),
},
appLifecycle: {
isSuspended: vi.fn(() => false),
setSuspended: vi.fn(),
},
fileProcessing: {
commitPendingFileEvents: vi.fn(async () => true),
},
UI: {
promptCopyToClipboard: vi.fn(async (_title: string, _value: string) => true),
},
path: {
path2id: vi.fn(async () => "f:note"),
},
};
const core = {
settings,
_services: services,
services,
localDatabase: {
getDBEntry: vi.fn(async () => false),
localDatabase: {
get: vi.fn(async () => ({
_id: "f:note",
_rev: "2-current",
_conflicts: [],
path: "note.md",
ctime: 100,
mtime: 200,
size: 12,
type: "plain",
children: ["h:private-chunk-id"],
eden: {},
})),
},
getDBEntryMeta: vi.fn(async () => ({
_id: "f:note",
_rev: "2-current",
_conflicts: [],
path: "note.md",
ctime: 100,
mtime: 200,
size: 12,
type: "plain",
datatype: "plain",
data: "",
children: ["h:private-chunk-id"],
eden: {},
})),
allDocsRaw: vi.fn(async () => ({
rows: [{ id: "h:private-chunk-id", key: "h:private-chunk-id", value: { rev: "1-chunk" } }],
})),
},
storageAccess: {
isExistsIncludeHidden: vi.fn(async () => true),
statHidden: vi.fn(async () => ({ ctime: 100, mtime: 200, size: 12, type: "file" })),
},
replicator: {
terminateSync: vi.fn(),
},
};
const module = new ModuleBasicMenu(core as never);
return {
commands,
core,
module,
services,
settings,
getCommand(id: string) {
const command = commands.find((candidate) => candidate.id === id);
expect(command, `command ${id}`).toBeDefined();
return command!;
},
};
}
describe("ModuleBasicMenu command palette", () => {
it("uses clear user-facing names without changing the established command IDs", async () => {
const fixture = createFixture();
await fixture.module._everyOnloadStart();
expect(fixture.getCommand("livesync-replicate").name).toBe("Sync now");
expect(fixture.getCommand("livesync-runbatch").name).toBe("Apply pending changes now");
});
it("keeps Sync now progress quiet while retaining failure-recovery authority", async () => {
const fixture = createFixture();
await fixture.module._everyOnloadStart();
await fixture.getCommand("livesync-replicate").callback?.();
expect(fixture.services.replication.replicateUserInitiated).toHaveBeenCalledWith({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
});
it("keeps maintenance commands out of the normal palette", async () => {
const fixture = createFixture();
await fixture.module._everyOnloadStart();
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(false);
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(false);
fixture.settings.useAdvancedMode = true;
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(true);
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(true);
});
it("routes an explicit stop through the active provider capability", async () => {
const fixture = createFixture();
fixture.settings.useAdvancedMode = true;
await fixture.module._everyOnloadStart();
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(false)).toBe(true);
await vi.waitFor(() => {
expect(fixture.services.replication.stopActiveTransfer).toHaveBeenCalledOnce();
});
expect(fixture.core.replicator.terminateSync).not.toHaveBeenCalled();
});
it("keeps active-file database information available and opens it in a copy dialogue", async () => {
const fixture = createFixture();
await fixture.module._everyOnloadStart();
const command = fixture.getCommand("livesync-dump");
expect(command.name).toBe("Copy database information for the active file");
expect(command.checkCallback?.(true)).toBe(true);
command.checkCallback?.(false);
await vi.waitFor(() => {
expect(fixture.services.UI.promptCopyToClipboard).toHaveBeenCalledOnce();
});
const [title, report] = fixture.services.UI.promptCopyToClipboard.mock.calls[0];
expect(title).toBe("Database information for note.md");
expect(report).toContain("note.md");
expect(report).toContain("2-current");
expect(report).toContain("h:private-chunk-id");
expect(report).toContain("1-chunk");
expect(fixture.core.localDatabase.getDBEntry).not.toHaveBeenCalled();
});
it("hides the active-file database report when no file is active", async () => {
const fixture = createFixture();
fixture.services.vault.getActiveFilePath.mockReturnValue(null);
await fixture.module._everyOnloadStart();
expect(fixture.getCommand("livesync-dump").checkCallback?.(true)).toBe(false);
});
});
-346
View File
@@ -1,346 +0,0 @@
import {
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
Logger,
} from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { EVENT_REQUEST_RUN_DOCTOR, EVENT_REQUEST_RUN_FIX_INCOMPLETE, eventHub } from "@/common/events.ts";
import { AbstractModule } from "@/modules/AbstractModule.ts";
import { $msg } from "@/common/translation";
import { performDoctorConsultation, RebuildOptions } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
import { isValidPath } from "@/common/utils.ts";
import { isMetaEntry } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
isDeletedEntry,
isDocContentSame,
isLoadedEntry,
readAsBlob,
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { countCompromisedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
import type { LiveSyncCore } from "@/main.ts";
import { SetupManager } from "@/modules/features/SetupManager.ts";
import { showOnboardingInvitation } from "@/serviceFeatures/setupObsidian/setupManagerHandlers.ts";
import {
runConfiguredStartupLifecycle,
runStartupEntryLifecycle,
} from "@/serviceFeatures/configuredStartupLifecycle.ts";
import { disableLegacyBulkChunkPreSend } from "@/common/compatibilitySettings.ts";
type ErrorInfo = {
path: string;
recordedSize: number;
actualSize: number;
storageSize: number;
contentMatched: boolean;
isConflicted?: boolean;
};
const INCOMPLETE_DOCUMENT_NOTICE_GROUP = "startup-integrity-check";
interface CompromisedChunkCounter {
countCompromisedChunks(): Promise<number | boolean>;
}
function hasCompromisedChunkCounter(value: object | undefined): value is CompromisedChunkCounter {
return (
value !== undefined && "countCompromisedChunks" in value && typeof value.countCompromisedChunks === "function"
);
}
export class ModuleMigration extends AbstractModule<LiveSyncCore> {
constructor(
core: LiveSyncCore,
private readonly waitForCompatibilityReview: () => Promise<void> = () => Promise.resolve()
) {
super(core);
}
async migrateUsingDoctor(skipRebuild: boolean = false, activateReason = "updated", forceRescan = false) {
const { shouldRebuild, shouldRebuildLocal, isModified, settings } = await performDoctorConsultation(
{
confirm: this.core.confirm,
translate: this.services.context.translate,
},
this.settings,
{
localRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
remoteRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
activateReason,
forceRescan,
}
);
if (isModified) {
this.settings = settings;
await this.saveSettings();
}
if (!skipRebuild) {
if (shouldRebuild) {
await this.core.rebuilder.scheduleRebuild();
this.services.appLifecycle.performRestart();
return false;
} else if (shouldRebuildLocal) {
await this.core.rebuilder.scheduleFetch();
this.services.appLifecycle.performRestart();
return false;
}
}
return true;
}
async migrateDisableBulkSend() {
if (disableLegacyBulkChunkPreSend(this.settings)) {
this._log($msg("moduleMigration.logBulkSendCorrupted"), LOG_LEVEL_NOTICE);
await this.saveSettings();
}
}
initialMessage() {
const manager = this.core.getModule(SetupManager);
showOnboardingInvitation(this.core, manager);
}
async hasIncompleteDocs(force: boolean = false): Promise<boolean> {
const incompleteDocsChecked = (await this.core.kvDB.get<boolean>("checkIncompleteDocs")) || false;
if (incompleteDocsChecked && !force) {
this._log("Incomplete docs check already done, skipping.", LOG_LEVEL_VERBOSE);
return Promise.resolve(true);
}
const noticeGroups = this.core.services.context.noticeGroups;
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "checking", {
message: "Checking for incomplete documents...",
});
this._log("Checking for incomplete documents...", LOG_LEVEL_VERBOSE);
try {
const errorFiles = [] as ErrorInfo[];
for await (const metaDoc of this.localDatabase.findAllNormalDocs({ conflicts: true })) {
const path = this.getPath(metaDoc);
if (!isValidPath(path)) {
continue;
}
if (!(await this.services.vault.isTargetFile(path))) {
continue;
}
if (!isMetaEntry(metaDoc)) {
continue;
}
const doc = await this.localDatabase.getDBEntryFromMeta(metaDoc);
if (!doc || !isLoadedEntry(doc)) {
continue;
}
if (isDeletedEntry(doc)) {
continue;
}
const isConflicted = metaDoc?._conflicts && metaDoc._conflicts.length > 0;
let storageFileContent;
try {
storageFileContent = await this.core.storageAccess.readHiddenFileBinary(path);
} catch (e) {
Logger(`Failed to read file ${path}: Possibly unprocessed or missing`);
Logger(e, LOG_LEVEL_VERBOSE);
continue;
}
// const storageFileBlob = createBlob(storageFileContent);
const sizeOnStorage = storageFileContent.byteLength;
const recordedSize = doc.size;
const docBlob = readAsBlob(doc);
const actualSize = docBlob.size;
if (
recordedSize !== actualSize ||
sizeOnStorage !== actualSize ||
sizeOnStorage !== recordedSize ||
isConflicted
) {
const contentMatched = await isDocContentSame(doc.data, storageFileContent);
errorFiles.push({
path,
recordedSize,
actualSize,
storageSize: sizeOnStorage,
contentMatched,
isConflicted,
});
Logger(
`Size mismatch for ${path}: ${recordedSize} (DB Recorded) , ${actualSize} (DB Stored) , ${sizeOnStorage} (Storage Stored), ${contentMatched ? "Content Matched" : "Content Mismatched"} ${isConflicted ? "Conflicted" : "Not Conflicted"}`
);
}
}
if (errorFiles.length == 0) {
Logger("No size mismatches found", LOG_LEVEL_INFO);
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
message: "No size mismatches found",
});
await this.core.kvDB.set("checkIncompleteDocs", true);
return Promise.resolve(true);
}
Logger(`Found ${errorFiles.length} size mismatches`, LOG_LEVEL_INFO);
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
message: `Found ${errorFiles.length} size mismatches`,
});
// We have to repair them following rules and situations:
// A. DB Recorded != DB Stored
// A.1. DB Recorded == Storage Stored
// Possibly recoverable from storage. Just overwrite the DB content with storage content.
// A.2. Neither
// Probably it cannot be resolved on this device. Even if the storage content is larger than DB Recorded, it possibly corrupted.
// We do not fix it automatically. Leave it as is. Possibly other device can do this.
// B. DB Recorded == DB Stored , < Storage Stored
// Very fragile, if DB Recorded size is less than Storage Stored size, we possibly repair the content (The issue was `unexpectedly shortened file`).
// We do not fix it automatically, but it will be automatically overwritten in other process.
// C. DB Recorded == DB Stored , > Storage Stored
// Probably restored by the user by resolving A or B on other device, We should overwrite the storage
// Also do not fix it automatically. It should be overwritten by replication.
const recoverable = errorFiles.filter((e) => {
return e.recordedSize === e.storageSize && !e.isConflicted;
});
const unrecoverable = errorFiles.filter((e) => {
return e.recordedSize !== e.storageSize || e.isConflicted;
});
const fileInfo = (e: (typeof errorFiles)[0]) => {
return `${e.path} (M: ${e.recordedSize}, A: ${e.actualSize}, S: ${e.storageSize}) ${e.isConflicted ? "(Conflicted)" : ""}`;
};
const messageUnrecoverable =
unrecoverable.length > 0
? $msg("moduleMigration.fix0256.messageUnrecoverable", {
filesNotRecoverable: unrecoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
})
: "";
const message = $msg("moduleMigration.fix0256.message", {
files: recoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
messageUnrecoverable,
});
const CHECK_IT_LATER = $msg("moduleMigration.fix0256.buttons.checkItLater");
const FIX = $msg("moduleMigration.fix0256.buttons.fix");
const DISMISS = $msg("moduleMigration.fix0256.buttons.DismissForever");
const ret = await this.core.confirm.askSelectStringDialogue(message, [CHECK_IT_LATER, FIX, DISMISS], {
title: $msg("moduleMigration.fix0256.title"),
defaultAction: CHECK_IT_LATER,
});
if (ret == FIX) {
for (const file of recoverable) {
// Overwrite the database with the files on the storage
const stubFile = await this.core.storageAccess.getFileStub(file.path);
if (stubFile == null) {
Logger(`Could not find stub file for ${file.path}`, LOG_LEVEL_NOTICE);
continue;
}
stubFile.stat.mtime = Date.now();
const result = await this.core.fileHandler.storeFileToDB(stubFile, true, false);
if (result) {
Logger(`Successfully restored ${file.path} from storage`);
} else {
Logger(`Failed to restore ${file.path} from storage`, LOG_LEVEL_NOTICE);
}
}
} else if (ret === DISMISS) {
// User chose to dismiss the issue
await this.core.kvDB.set("checkIncompleteDocs", true);
}
return Promise.resolve(true);
} catch (error) {
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
message: "The incomplete document check could not be completed.",
});
throw error;
} finally {
noticeGroups.finish(INCOMPLETE_DOCUMENT_NOTICE_GROUP);
}
}
async hasCompromisedChunks(): Promise<boolean> {
Logger(`Checking for compromised chunks...`, LOG_LEVEL_VERBOSE);
if (!this.settings.encrypt) {
// If not encrypted, we do not need to check for compromised chunks.
return true;
}
// Check local database for compromised chunks
const localCompromised = await countCompromisedChunks(this.localDatabase.localDatabase);
const remote = this.services.replicator.getActiveReplicator();
const remoteCompromised =
this.services.API.isOnline && hasCompromisedChunkCounter(remote)
? await remote.countCompromisedChunks()
: 0;
if (localCompromised === false) {
Logger(`Failed to count compromised chunks in local database`, LOG_LEVEL_NOTICE);
return false;
}
if (remoteCompromised === false) {
Logger(`Failed to count compromised chunks in remote database`, LOG_LEVEL_NOTICE);
return false;
}
if (remoteCompromised === 0 && localCompromised === 0) {
return true;
}
Logger(
`Found compromised chunks : ${localCompromised} in local, ${remoteCompromised} in remote`,
LOG_LEVEL_NOTICE
);
const title = $msg("moduleMigration.insecureChunkExist.title");
const msg = $msg("moduleMigration.insecureChunkExist.message");
const REBUILD = $msg("moduleMigration.insecureChunkExist.buttons.rebuild");
const FETCH = $msg("moduleMigration.insecureChunkExist.buttons.fetch");
const DISMISS = $msg("moduleMigration.insecureChunkExist.buttons.later");
const buttons = [REBUILD, FETCH, DISMISS];
if (remoteCompromised != 0) {
buttons.splice(buttons.indexOf(FETCH), 1);
}
const result = await this.core.confirm.askSelectStringDialogue(msg, buttons, {
title,
defaultAction: DISMISS,
timeout: 0,
});
if (result === REBUILD) {
// Rebuild the database
await this.core.rebuilder.scheduleRebuild();
this.services.appLifecycle.performRestart();
return false;
} else if (result === FETCH) {
// Fetch the latest data from remote
await this.core.rebuilder.scheduleFetch();
this.services.appLifecycle.performRestart();
return false;
} else {
// User chose to dismiss the issue
this._log($msg("moduleMigration.insecureChunkExist.laterMessage"), LOG_LEVEL_NOTICE);
}
return true;
}
async _everyOnFirstInitialize(): Promise<boolean> {
return await runConfiguredStartupLifecycle({
databaseReady: this.localDatabase.isReady,
reportDatabaseNotReady: () => this._log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE),
hasCompromisedChunks: () => this.hasCompromisedChunks(),
hasIncompleteDocuments: () => this.hasIncompleteDocs(),
waitForCompatibilityReview: () => this.waitForCompatibilityReview(),
runDoctor: () => this.migrateUsingDoctor(false),
migrateBulkSend: () => this.migrateDisableBulkSend(),
});
}
_everyOnLayoutReady(): Promise<boolean> {
const shouldInitialiseDatabase = runStartupEntryLifecycle({
configured: this.settings.isConfigured === true,
inviteToOnboarding: () => this.initialMessage(),
});
if (!shouldInitialiseDatabase) return Promise.resolve(false);
eventHub.onEvent(EVENT_REQUEST_RUN_DOCTOR, async (reason) => {
await this.migrateUsingDoctor(false, reason, true);
});
eventHub.onEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE, async () => {
await this.hasIncompleteDocs(true);
});
return Promise.resolve(true);
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
super.onBindFunction(core, services);
services.appLifecycle.onLayoutReady.addHandler(this._everyOnLayoutReady.bind(this));
services.appLifecycle.onFirstInitialise.addHandler(this._everyOnFirstInitialize.bind(this));
}
}
@@ -1,107 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/modules/features/SetupManager.ts", () => ({
SetupManager: class SetupManager {},
}));
vi.mock("@/deps.ts", () => ({}));
vi.mock("@/common/utils.ts", () => ({
isValidPath: () => true,
}));
import { ModuleMigration } from "./ModuleMigration.ts";
async function* noDocuments() {
return;
}
async function* failedDocumentScan() {
throw new Error("scan failed");
}
function createMigration(
findAllNormalDocs: typeof noDocuments | typeof failedDocumentScan = noDocuments,
settings = { sendChunksBulk: false, sendChunksBulkMaxSize: 1 }
) {
const noticeGroups = {
setItem: vi.fn(),
finish: vi.fn(() => true),
};
const services = {
API: {
addLog: vi.fn(),
addCommand: vi.fn(),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
},
context: { noticeGroups },
setting: { saveSettingData: vi.fn(async () => undefined) },
vault: { isTargetFile: vi.fn(async () => true) },
path: { getPath: vi.fn() },
};
const core = {
_services: services,
services,
kvDB: {
get: vi.fn(async () => false),
set: vi.fn(async () => undefined),
},
localDatabase: { findAllNormalDocs },
storageAccess: {},
settings,
};
return {
migration: new ModuleMigration(core as never),
noticeGroups,
saveSettingData: services.setting.saveSettingData,
};
}
describe("ModuleMigration obsolete-setting migration", () => {
it("persists the removal of an enabled automatic bulk chunk pre-send setting", async () => {
const settings = { sendChunksBulk: true, sendChunksBulkMaxSize: 16 };
const { migration, saveSettingData } = createMigration(noDocuments, settings);
await migration.migrateDisableBulkSend();
expect(settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 1 });
expect(saveSettingData).toHaveBeenCalledOnce();
});
it("does not persist an already disabled automatic bulk chunk pre-send setting", async () => {
const settings = { sendChunksBulk: false, sendChunksBulkMaxSize: 16 };
const { migration, saveSettingData } = createMigration(noDocuments, settings);
await migration.migrateDisableBulkSend();
expect(settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 16 });
expect(saveSettingData).not.toHaveBeenCalled();
});
});
describe("ModuleMigration incomplete-document notice", () => {
it("keeps the check and its result in one persistent named group", async () => {
const { migration, noticeGroups } = createMigration();
await expect(migration.hasIncompleteDocs()).resolves.toBe(true);
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(1, "startup-integrity-check", "checking", {
message: "Checking for incomplete documents...",
});
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(2, "startup-integrity-check", "result", {
message: "No size mismatches found",
});
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
});
it("finishes the group with a failure result when the scan throws", async () => {
const { migration, noticeGroups } = createMigration(failedDocumentScan);
await expect(migration.hasIncompleteDocs()).rejects.toThrow("scan failed");
expect(noticeGroups.setItem).toHaveBeenLastCalledWith("startup-integrity-check", "result", {
message: "The incomplete document check could not be completed.",
});
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
});
});
@@ -1,7 +1,7 @@
// This file is based on a file that was published by the @remotely-save, under the Apache 2 License.
// I would love to express my deepest gratitude to the original authors for their hard work and dedication. Without their contributions, this project would not have been possible.
// This file was originally based on code published by @remotely-save under the Apache License 2.0.
// I would like to express my gratitude to the original authors for their work.
//
// Original Implementation is here: https://github.com/remotely-save/remotely-save/blob/28b99557a864ef59c19d2ad96101196e401718f0/src/remoteForS3.ts
// Original implementation: https://github.com/remotely-save/remotely-save/blob/28b99557a864ef59c19d2ad96101196e401718f0/src/remoteForS3.ts
import { FetchHttpHandler, type FetchHttpHandlerOptions } from "@smithy/fetch-http-handler";
import { HttpRequest, HttpResponse } from "@smithy/protocol-http";
@@ -102,6 +102,7 @@ export class ObsHttpHandler extends FetchHttpHandler {
method: method,
url: url,
contentType: contentType,
throw: false,
};
const raceOfPromises = [
@@ -1,9 +1,10 @@
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { HttpRequest } from "@smithy/protocol-http";
import { beforeEach, describe, expect, it, vi } from "vitest";
const requestUrlMock = vi.hoisted(() =>
vi.fn<
(param: { body?: string | ArrayBuffer }) => Promise<{
(param: { body?: string | ArrayBuffer; throw?: boolean }) => Promise<{
headers: Record<string, string>;
status: number;
arrayBuffer: ArrayBuffer;
@@ -28,6 +29,42 @@ function requestWithBody(body: unknown) {
});
}
function mockS3ErrorResponse(status: number, code?: string) {
requestUrlMock.mockImplementation(async (param) => {
if (param.throw !== false) {
throw new Error(`Request failed, status ${status}`);
}
return {
headers: { "content-type": "application/xml" },
status,
arrayBuffer: new TextEncoder().encode(code ? `<Error><Code>${code}</Code></Error>` : "").buffer,
};
});
}
function createS3Client() {
return new S3Client({
region: "us-east-1",
credentials: {
accessKeyId: "access-key",
secretAccessKey: "secret-key",
},
endpoint: "https://objects.example.com",
forcePathStyle: true,
maxAttempts: 1,
requestHandler: new ObsHttpHandler(),
});
}
function getMissingObject(client: S3Client) {
return client.send(
new GetObjectCommand({
Bucket: "bucket",
Key: "missing.json",
})
);
}
describe("ObsHttpHandler request bodies", () => {
beforeEach(() => {
requestUrlMock.mockReset();
@@ -58,3 +95,56 @@ describe("ObsHttpHandler request bodies", () => {
expect(requestUrlMock).not.toHaveBeenCalled();
});
});
describe("ObsHttpHandler response handling", () => {
beforeEach(() => {
requestUrlMock.mockReset();
});
it("returns an HTTP error response to the Smithy client", async () => {
mockS3ErrorResponse(404, "NoSuchKey");
const request = new HttpRequest({
protocol: "https:",
hostname: "objects.example.com",
method: "GET",
path: "/bucket/missing.json",
headers: {},
});
const result = await new ObsHttpHandler().handle(request);
expect(requestUrlMock).toHaveBeenCalledWith(expect.objectContaining({ throw: false }));
expect(result.response.statusCode).toBe(404);
});
it.each([
{ code: "NoSuchKey", name: "NoSuchKey" },
{ code: undefined, name: "NotFound" },
])("lets the S3 client classify a missing object as $name", async ({ code, name }) => {
mockS3ErrorResponse(404, code);
await expect(getMissingObject(createS3Client())).rejects.toMatchObject({
name,
$metadata: { httpStatusCode: 404 },
});
});
it.each([
{ status: 403, code: "AccessDenied" },
{ status: 500, code: "InternalError" },
])("keeps an S3 $status response distinct from a missing object", async ({ status, code }) => {
mockS3ErrorResponse(status, code);
await expect(getMissingObject(createS3Client())).rejects.toMatchObject({
name: code,
$metadata: { httpStatusCode: status },
});
});
it("preserves a transport failure", async () => {
const failure = new Error("network failed");
requestUrlMock.mockRejectedValue(failure);
await expect(new ObsHttpHandler().handle(requestWithBody(new ArrayBuffer(0)))).rejects.toBe(failure);
});
});
@@ -1,41 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({ addIcon: vi.fn() }));
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
import { ModuleObsidianMenu } from "./ModuleObsidianMenu";
describe("ModuleObsidianMenu ribbon", () => {
it("retains visible progress and full interaction authority", async () => {
let runRibbonAction: (() => Promise<void>) | undefined;
const addClass = vi.fn();
const replicateUserInitiated = vi.fn(async () => ({ status: "completed" as const }));
const services = {
API: {
addLog: vi.fn(),
addCommand: vi.fn(),
registerWindow: vi.fn(),
registerProtocolHandler: vi.fn(),
addRibbonIcon: vi.fn((_icon: string, _title: string, callback: () => Promise<void>) => {
runRibbonAction = callback;
return { addClass };
}),
},
replication: { replicateUserInitiated },
};
const module = new ModuleObsidianMenu({ _services: services, services } as never);
await module._everyOnloadStart();
await runRibbonAction?.();
expect(replicateUserInitiated).toHaveBeenCalledWith({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
expect(addClass).toHaveBeenCalledWith("livesync-ribbon-replicate");
});
});
@@ -6,12 +6,11 @@ import {
type diff_result,
type FilePathWithPrefix,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED, eventHub } from "@/common/events.ts";
import { promiseWithResolvers } from "octagonal-wheels/promises";
import { POSTPONED, type MergeDialogResult } from "@/serviceFeatures/interactiveConflictResolution/types";
export const POSTPONED = Symbol("postponed");
export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string;
export { POSTPONED, type MergeDialogResult };
export type ConflictResolveModalOptions = {
readOnly?: boolean;
@@ -35,7 +34,7 @@ export class ConflictResolveModal extends Modal {
readOnly: boolean = false;
localName: string = "Base";
remoteName: string = "Conflicted";
offEvent?: ReturnType<typeof eventHub.onEvent>;
private eventSubscriptions?: AbortController;
currentDiffIndex = -1;
diffView!: HTMLDivElement;
diffNavIndicator!: HTMLSpanElement;
@@ -112,20 +111,31 @@ export class ConflictResolveModal extends Modal {
override onOpen() {
const { contentEl } = this;
if (this.offEvent) {
this.offEvent();
}
this.eventSubscriptions?.abort();
const eventSubscriptions = new AbortController();
this.eventSubscriptions = eventSubscriptions;
eventHub.onceEvent(
EVENT_PLUGIN_UNLOADED,
() => {
this.sendResponse(CANCELLED);
},
{ signal: eventSubscriptions.signal }
);
if (!this.readOnly) {
// Cancel an older dialogue for this path before subscribing this
// instance. Emitting after subscription would close the replacement
// itself; the instance-owned result promise then completes the older
// caller even when it only begins waiting after this event.
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, this.filename);
this.offEvent = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => {
if (path === this.filename) {
this.sendResponse(CANCELLED);
}
});
eventHub.onEvent(
EVENT_CONFLICT_CANCELLED,
(path) => {
if (path === this.filename) {
this.sendResponse(CANCELLED);
}
},
{ signal: eventSubscriptions.signal }
);
}
this.titleEl.setText(this.title);
contentEl.empty();
@@ -216,9 +226,8 @@ export class ConflictResolveModal extends Modal {
override onClose() {
const { contentEl } = this;
contentEl.empty();
if (this.offEvent) {
this.offEvent();
}
this.eventSubscriptions?.abort();
this.eventSubscriptions = undefined;
if (this.consumed) {
return;
}
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { POSTPONED, ConflictResolveModal } from "./ConflictResolveModal.ts";
import { CANCELLED, type diff_result, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED, eventHub } from "@/common/events.ts";
vi.mock("@/deps.ts", () => ({
App: class App {},
@@ -24,12 +25,7 @@ vi.mock("@/deps.ts", () => ({
};
element.createDiv = vi.fn(() => this.createElement());
element.createEl = vi.fn((_tag: string, _options?: unknown, callback?: (child: unknown) => void) => {
if (
_tag === "button" &&
typeof _options === "object" &&
_options !== null &&
"text" in _options
) {
if (_tag === "button" && typeof _options === "object" && _options !== null && "text" in _options) {
this.createdButtons.push(String((_options as { text: unknown }).text));
}
const child = this.createElement();
@@ -93,23 +89,50 @@ describe("ConflictResolveModal result lifecycle", () => {
expect(replacementState).toBe("still-open");
});
it("closes for an external resolution of the same file and ignores other files", async () => {
const filename = "resolved-elsewhere.md" as FilePathWithPrefix;
const modal = new ConflictResolveModal({} as never, filename, conflict);
modal.onOpen();
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, "other.md" as FilePathWithPrefix);
const stateAfterOtherFile = await Promise.race([
modal.waitForResult(),
new Promise<"still-open">((resolve) => setTimeout(() => resolve("still-open"), 25)),
]);
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
await expect(modal.waitForResult()).resolves.toBe(CANCELLED);
expect(stateAfterOtherFile).toBe("still-open");
});
it("closes and completes its result when the plug-in unloads", async () => {
const modal = new ConflictResolveModal(
{} as never,
"open-during-unload.md" as FilePathWithPrefix,
conflict
);
modal.onOpen();
eventHub.emitEvent(EVENT_PLUGIN_UNLOADED);
const result = await Promise.race([
modal.waitForResult(),
new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 25)),
]);
modal.sendResponse(CANCELLED);
expect(result).toBe(CANCELLED);
});
it("renders a read-only comparison with no resolution actions", () => {
const ReadOnlyModal = ConflictResolveModal as unknown as new (
...args: unknown[]
) => ConflictResolveModal & { createdButtons: string[] };
const modal = new ReadOnlyModal(
{},
"repair-preview.md",
conflict,
false,
undefined,
{
readOnly: true,
title: "Vault and database revision",
localName: "Vault file",
remoteName: "Database revision",
}
);
const modal = new ReadOnlyModal({}, "repair-preview.md", conflict, false, undefined, {
readOnly: true,
title: "Vault and database revision",
localName: "Vault file",
remoteName: "Database revision",
});
modal.onOpen();
@@ -124,9 +147,7 @@ describe("ConflictResolveModal result lifecycle", () => {
it("does not cancel an active conflict dialogue when a read-only comparison opens for the same file", async () => {
const filename = "repair-alongside-conflict.md" as FilePathWithPrefix;
const previous = new ConflictResolveModal({} as never, filename, conflict);
const ReadOnlyModal = ConflictResolveModal as unknown as new (
...args: unknown[]
) => ConflictResolveModal;
const ReadOnlyModal = ConflictResolveModal as unknown as new (...args: unknown[]) => ConflictResolveModal;
const comparison = new ReadOnlyModal({}, filename, conflict, false, undefined, {
readOnly: true,
});
@@ -1,276 +0,0 @@
import {
CANCELLED,
LEAVE_TO_SUBSEQUENT,
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
MISSING_OR_ERROR,
type DocumentID,
type FilePathWithPrefix,
type diff_result,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ConflictResolveModal, POSTPONED } from "./InteractiveConflictResolving/ConflictResolveModal.ts";
import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts";
import { displayRev } from "@/common/utils.ts";
import { fireAndForget } from "octagonal-wheels/promises";
import { serialized } from "octagonal-wheels/concurrency/lock";
import type { LiveSyncCore } from "@/main.ts";
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR, eventHub } from "@/common/events.ts";
import { $msg } from "@/common/translation.ts";
import type { Editor, MarkdownFileInfo, MarkdownView } from "@/deps.ts";
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
private postponedConflictEpisodes = new Set<FilePathWithPrefix>();
private async getConflictVersionCount(filename: FilePathWithPrefix): Promise<number | undefined> {
try {
const conflictCount = (await this.core.databaseFileAccess.getConflictedRevs(filename)).length;
return conflictCount === 0 ? 0 : conflictCount + 1;
} catch (error) {
this._log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
this._log(error, LOG_LEVEL_VERBOSE);
return undefined;
}
}
private async getActiveConflictMessages(): Promise<string[]> {
const filename = this.services.vault.getActiveFilePath();
if (!filename) return [];
const versionCount = await this.getConflictVersionCount(filename);
if (versionCount === 0) {
this.postponedConflictEpisodes.delete(filename);
return [];
}
if (versionCount !== undefined && versionCount >= 3) {
return [
$msg("This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", {
COUNT: `${versionCount}`,
}),
];
}
if (versionCount === 2 || this.postponedConflictEpisodes.has(filename)) {
return [$msg("This file has unresolved conflicts.")];
}
return [];
}
private async refreshConflictState(filename: FilePathWithPrefix): Promise<void> {
if ((await this.getConflictVersionCount(filename)) === 0) {
this.postponedConflictEpisodes.delete(filename);
}
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
}
private async requestConflictResolution(filename: FilePathWithPrefix): Promise<void> {
this.postponedConflictEpisodes.delete(filename);
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
await this.services.conflict.queueCheckFor(filename);
await this.services.conflict.ensureAllProcessed();
}
_everyOnloadStart(): Promise<boolean> {
this.addCommand({
id: "livesync-checkdoc-conflicted",
name: "Resolve if conflicted.",
editorCallback: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => {
const file = view.file;
if (!file) return;
void this.requestConflictResolution(file.path as FilePathWithPrefix);
},
});
this.addCommand({
id: "livesync-conflictcheck",
name: "Pick a file to resolve conflict",
callback: async () => {
await this.pickFileForResolve();
},
});
this.addCommand({
id: "livesync-all-conflictcheck",
name: "Resolve all conflicted files",
callback: async () => {
await this.allConflictCheck();
},
});
return Promise.resolve(true);
}
async _anyResolveConflictByUI(filename: FilePathWithPrefix, conflictCheckResult: diff_result): Promise<boolean> {
// UI for resolving conflicts should one-by-one.
return await serialized(`conflict-resolve-ui`, async () => {
if (this.postponedConflictEpisodes.has(filename)) {
this._log(`Merge: Postponed ${filename}`, LOG_LEVEL_VERBOSE);
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
return false;
}
this._log("Merge:open conflict dialog", LOG_LEVEL_VERBOSE);
const dialog = new ConflictResolveModal(this.app, filename, conflictCheckResult);
dialog.open();
const selected = await dialog.waitForResult();
if (selected === POSTPONED) {
this.postponedConflictEpisodes.add(filename);
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
this._log(`Merge: Postponed ${filename}`, LOG_LEVEL_INFO);
return false;
}
if (selected === CANCELLED) {
// Cancelled by UI, or another conflict.
this._log(`Merge: Cancelled ${filename}`, LOG_LEVEL_INFO);
return false;
}
const testDoc = await this.localDatabase.getDBEntry(filename, { conflicts: true }, false, true, true);
if (testDoc === false) {
this._log(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE);
return false;
}
if (!testDoc._conflicts || testDoc._conflicts.length === 0) {
this._log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE);
await this.refreshConflictState(filename);
return false;
}
if (
testDoc._rev !== conflictCheckResult.left.rev ||
!testDoc._conflicts.includes(conflictCheckResult.right.rev)
) {
this._log(
`Merge: The compared revisions changed while the dialogue was open: ${filename}`,
LOG_LEVEL_INFO
);
await this.refreshConflictState(filename);
await this.services.conflict.queueCheckFor(filename);
return false;
}
const toDelete = selected;
// const toKeep = conflictCheckResult.left.rev != toDelete ? conflictCheckResult.left.rev : conflictCheckResult.right.rev;
if (toDelete === LEAVE_TO_SUBSEQUENT) {
// Concatenate both conflicted revisions.
// Create a new file by concatenating both conflicted revisions.
const p = conflictCheckResult.diff.map((e) => e[1]).join("");
const delRev = conflictCheckResult.right.rev;
if (!(await this.core.databaseFileAccess.storeContent(filename, p))) {
this._log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE);
return false;
}
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
if (
(await this.services.conflict.resolveByDeletingRevision(filename, delRev, "UI Concatenated")) ==
MISSING_OR_ERROR
) {
this._log(
`Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`,
LOG_LEVEL_NOTICE
);
return false;
}
} else if (
typeof toDelete === "string" &&
(toDelete === conflictCheckResult.left.rev || toDelete === conflictCheckResult.right.rev)
) {
// Select one of the conflicted revision to delete.
if (
(await this.services.conflict.resolveByDeletingRevision(filename, toDelete, "UI Selected")) ==
MISSING_OR_ERROR
) {
this._log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE);
return false;
}
} else {
this._log(`Merge: Something went wrong: ${filename}, (${String(toDelete)})`, LOG_LEVEL_NOTICE);
return false;
}
// In here, some merge has been processed.
// So we have to run replication if configured.
// TODO: Make this is as a event request
if (this.settings.syncAfterMerge && !this.services.appLifecycle.isSuspended()) {
await this.services.replication.replicateUnattendedByEvent({
trigger: "merge",
interaction: NO_INTERACTION,
});
}
// And, check it again.
await this.services.conflict.queueCheckFor(filename);
return false;
});
}
async allConflictCheck() {
let notifyIfEmpty = true;
while (await this.pickFileForResolve(notifyIfEmpty)) {
notifyIfEmpty = false;
}
}
async pickFileForResolve(notifyIfEmpty = true) {
const notes: { id: DocumentID; path: FilePathWithPrefix; dispPath: string; mtime: number }[] = [];
for await (const doc of this.localDatabase.findAllDocs({ conflicts: true })) {
if (!("_conflicts" in doc)) continue;
notes.push({
id: doc._id,
path: this.getPath(doc),
dispPath: this.getPathWithoutPrefix(doc),
mtime: doc.mtime,
});
}
notes.sort((a, b) => b.mtime - a.mtime);
const notesList = notes.map((e) => e.dispPath);
if (notesList.length == 0) {
if (notifyIfEmpty) {
this._log("There are no conflicted documents", LOG_LEVEL_NOTICE);
}
return false;
}
const target = await this.core.confirm.askSelectString("File to resolve conflict", notesList);
if (target) {
const targetItem = notes.find((e) => e.dispPath == target)!;
await this.requestConflictResolution(targetItem.path);
return true;
}
return false;
}
async _allScanStat(): Promise<boolean> {
const notes: { path: string; mtime: number }[] = [];
this._log(`Checking conflicted files`, LOG_LEVEL_VERBOSE);
try {
for await (const doc of this.localDatabase.findAllDocs({ conflicts: true })) {
if (!("_conflicts" in doc)) continue;
notes.push({ path: this.getPath(doc), mtime: doc.mtime });
}
if (notes.length > 0) {
this.core.confirm.askInPopup(
`conflicting-detected-on-safety`,
`Some files have been left conflicted! Press {HERE} to resolve them, or you can do it later by "Pick a file to resolve conflict`,
(anchor) => {
anchor.text = "HERE";
anchor.addEventListener("click", () => {
fireAndForget(() => this.allConflictCheck());
});
}
);
this._log(
`Some files have been left conflicted! Please resolve them by "Pick a file to resolve conflict". The list is written in the log.`,
LOG_LEVEL_VERBOSE
);
for (const note of notes) {
this._log(`Conflicted: ${note.path}`);
}
} else {
this._log(`There are no conflicting files`, LOG_LEVEL_VERBOSE);
}
} catch (e) {
this._log(`Error while scanning conflicted files...`, LOG_LEVEL_NOTICE);
this._log(e, LOG_LEVEL_VERBOSE);
return false;
}
return true;
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.appLifecycle.onScanningStartupIssues.addHandler(this._allScanStat.bind(this));
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
services.appLifecycle.getUnresolvedMessages.addHandler(this.getActiveConflictMessages.bind(this));
services.conflict.resolveByUserInteraction.addHandler(this._anyResolveConflictByUI.bind(this));
eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (filename) => {
fireAndForget(() => this.refreshConflictState(filename));
});
}
}

Some files were not shown because too many files have changed in this diff Show More