Compare commits

...
Author SHA1 Message Date
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
44 changed files with 1701 additions and 627 deletions
+4 -4
View File
@@ -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
+10 -7
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'
+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
+5
View File
@@ -133,6 +133,11 @@ The [Project glossary](docs/glossary.md#developer-and-design-terms) defines the
stable developer and design vocabulary used in this section. The guidance
below describes how those boundaries are applied.
For file-event admission versus physical Vault writes, see
[File events and storage writes](docs/tech_info.md#file-events-and-storage-writes)
and its linked Commonlib contract. Keep regression coverage for those two
directions separate when changing deletion handling.
### Service composition and legacy Modules
The application is composed from Services, ServiceModules, serviceFeatures, add-ons, and a legacy Module layer:
+82
View File
@@ -2,6 +2,88 @@
This document contains earlier published releases from the 1.0 line of the [current Self-hosted LiveSync release history](../../updates.md). Beta and release-candidate builds published before 1.0.0 are recorded in the [1.0 preview history](1.0-previews.md). Earlier release lines continue in the [0.25 history](0.25.md) and the [legacy history](legacy.md).
## 1.0.21
26th August, 2026
It is becoming more 'ordinary' with each release, but please let me know if anything has become less convenient.
### Interface and translation
#### Fixed
- Remote Configuration section headings no longer overlap their contents when scrolling on mobile. Action buttons in Remote Configuration, Maintenance, and Patches now remain inside the settings pane on narrow screens.
## 1.0.20
~~1.0.19~~ was cancelled because prerelease validation exposed an incorrect warning at start-up.
25th August, 2026
I know this is the second time I have said it, but I had grown quite fond of the settings screen. It seems, however, that a simpler, healthier life is called for.
### Interface and translation
#### Fixed
- Compatibility pause warnings now direct you to the dedicated compatibility review instead of the Change Log.
- The Obsidian 1.13 settings page now waits for saved settings before choosing its initial layout. This prevents a spurious missing-replicator warning at start-up, keeps configured devices on the Synchronisation-first layout even when automatic synchronisation triggers are disabled, and keeps Quick Setup first on unconfigured devices.
#### Improved
- Settings page names, controls in General Settings, Quick Setup actions, and Advanced controls now use Obsidian 1.13's native settings interface and global search, while retaining their familiar icons. The landing page keeps Remote Configuration and Sync Settings together, places Appearance, Logging, and Extra menus under General Settings, and groups maintenance, optional features, advanced settings, and help by purpose. Earlier supported Obsidian versions continue to use the pane-based interface.
- Settings changes which require database initialisation now use a focused Setup Manager dialogue to choose between existing synchronisation data and the files in the current Vault. The selected reset or rebuild is reserved before the settings are saved, while cancelling offers a separate, explicit settings-only fallback.
## 1.0.18
24th August, 2026
### Synchronisation and storage
#### Fixed
- Reset and rebuild workflows now use the local database selected by their updated settings, preventing stale data from reopening after a **Database Suffix** change. If database initialisation does not complete, the workflow remains paused instead of continuing with incomplete state.
#### Improved
- Rebuilds now recheck restored file events against the current Vault, use current file contents, and finish processing them before the plug-in reports readiness.
## 1.0.17
23rd August, 2026
### Interface and translation
#### Fixed
- Settings generated from the settings manifest, Setup Wizard configuration summaries, and warnings about externally changed settings now honour **Display language** when a translation is available, instead of remaining in English (PR #1123). Thank you to @nimula for the contribution!
### Peer-to-peer synchronisation
#### Improved
- P2P connection profiles now provide four **P2P message size** presets and a **Connection path** choice between **Automatic** and **TURN relay only**. Smaller messages can improve compatibility on paths which fragment or drop larger WebRTC messages, while relay-only routing requires a configured TURN server. P2P connection strings and encrypted Setup URIs preserve both choices.
- Thank you to @andrewschreiber for the detailed fragmentation diagnosis and working 800-byte threshold in vrtmrz/livesync-commonlib#97, which informed this compatibility design.
- An optional self-hosted Coturn Compose starter is now available for P2P deployments that need a TURN relay. It uses a pinned upstream image and documents its network, credential, security, and verification boundaries.
## 1.0.16
19th August, 2026
### Conflict handling and recovery
#### Fixed
- **Back to this revision** in Document History now restores the selected content as a new non-deleted successor revision before reflecting it to the Vault. A readable revision restored after a logical deletion therefore remains restored through later synchronisation instead of being overwritten by the deletion.
- If the file changes while restoration is in progress, the operation stops instead of extending a stale revision. Existing conflicts remain available through **Inspect conflicts and file/database differences**.
### Synchronisation and storage
#### Improved
- One-shot CouchDB synchronisation now releases stalled web-compatible connection checks before replication starts, so a later synchronisation can make a fresh attempt (Commonlib 0.1.16).
- The 60-second safeguard applies only to pre-replication checks. It does not limit ordinary synchronisation, and the **Use Internal API** path is unchanged.
## 1.0.15
15th August, 2026
+20
View File
@@ -11,6 +11,26 @@
Note: The figure is drawn as single-directional, between two devices for demonstration purposes. Everything actually occurs bi-directionally between many devices at the same time.
## File events and storage writes
File events describe changes observed in the Vault. Commonlib filters and
serialises those events before updating file Metadata in the local database.
A queued `DELETE` therefore requests a database change; it is not itself an
instruction to delete the physical file. A rename out of the selected files
can also become a database deletion while the destination remains on disk.
The opposite direction starts with database Metadata. Replicated changes and
full scans can call the database-to-storage handler, which writes or removes
Vault files subject to its conflict and content-preservation rules. Preventing
a stale file event from deleting Metadata and applying a valid replicated
deletion are separate decisions.
Commonlib's [Storage events and database-to-storage reflection](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/storage-events-and-reflection.md)
documents the event boundary, the deletion revalidation introduced in
Commonlib 0.1.23, and its limits. In particular, deletion protection does not
promise full support for external folder case changes or convergence of path
spelling.
## Current technical references
- [Database Data Structures](datastructure.md) describes current Metadata and
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "1.0.24",
"version": "1.0.27",
"minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz",
+170 -442
View File
@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
"version": "1.0.24",
"version": "1.0.27",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "1.0.24",
"version": "1.0.27",
"license": "MIT",
"workspaces": [
"src/apps/cli",
@@ -23,11 +23,10 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.22",
"@vrtmrz/livesync-commonlib": "0.1.23",
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
"fflate": "^0.8.2",
"idb": "^8.0.3",
"markdown-it": "^14.2.0",
"minimatch": "^10.2.5",
@@ -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.22",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.22.tgz",
"integrity": "sha512-8TsFo6xgEO/uZzkQ4TE3yydUyK8pCbuMm0C4DC/8KhG8z06N6hQQmwR7bV+a3Zgt9A5tXPImTFJWTMUIxJYV2g==",
"version": "0.1.23",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.23.tgz",
"integrity": "sha512-hsaz2N04qNqM9HL0B+d5G/do1T0fe6Y4gVK3IueXvEnM6HM+3Jp17mdjbLn93FUOxkUVMcn4M+zIwPuppryVbw==",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.808.0",
@@ -7214,9 +7161,9 @@
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
@@ -11521,9 +11468,9 @@
}
},
"node_modules/svelte-preprocess": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-6.0.3.tgz",
"integrity": "sha512-PLG2k05qHdhmRG7zR/dyo5qKvakhm8IJ+hD2eFRQmMLHp7X3eJnjeupUtvuRpbNiF31RjVw45W+abDwHEmP5OA==",
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-6.0.5.tgz",
"integrity": "sha512-sgwew5yV/2eMeQobIWgAxCNarKwiTUDIc3siAUbq3sp0G6ONtzk0W+wJihMdqjbYb3iGU3ubpGv0usnnuXT3qg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -11541,7 +11488,7 @@
"stylus": ">=0.55",
"sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.100 || ^5.0.0",
"typescript": "^5.0.0"
"typescript": "^5.0.0 || ^6.0.0"
},
"peerDependenciesMeta": {
"@babel/core": {
@@ -11993,9 +11940,9 @@
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -12007,16 +11954,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz",
"integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==",
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.69.0.tgz",
"integrity": "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.1",
"@typescript-eslint/utils": "8.61.1"
"@typescript-eslint/eslint-plugin": "8.69.0",
"@typescript-eslint/parser": "8.69.0",
"@typescript-eslint/typescript-estree": "8.69.0",
"@typescript-eslint/utils": "8.69.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -12030,225 +11977,6 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz",
"integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/type-utils": "8.61.1",
"@typescript-eslint/utils": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.1",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.61.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz",
"integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.1",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/project-service": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz",
"integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.61.1",
"@typescript-eslint/types": "^8.61.1",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz",
"integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz",
"integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz",
"integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.1",
"@typescript-eslint/utils": "8.61.1",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/types": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz",
"integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz",
"integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.61.1",
"@typescript-eslint/tsconfig-utils": "8.61.1",
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.1",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz",
"integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.61.1",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/typescript-eslint/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/typescript-eslint/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
@@ -12937,11 +12665,11 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
"version": "1.0.24-cli",
"version": "1.0.27-cli",
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
"octagonal-wheels": "^0.1.53",
"octagonal-wheels": "^0.1.54",
"pouchdb-adapter-http": "^9.0.0",
"pouchdb-adapter-leveldb": "^9.0.0",
"pouchdb-core": "^9.0.0",
@@ -12955,28 +12683,28 @@
"werift": "^0.24.4"
},
"devDependencies": {
"typescript": "5.9.3",
"typescript": "6.0.3",
"vite": "^8.0.16",
"vitest": "^4.1.8"
}
},
"src/apps/webapp": {
"name": "livesync-webapp",
"version": "1.0.24-webapp",
"version": "1.0.27-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.53"
"octagonal-wheels": "^0.1.54"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"svelte": "5.56.3",
"typescript": "5.9.3",
"typescript": "6.0.3",
"vite": "^8.0.16"
}
},
"src/apps/webpeer": {
"version": "1.0.24-webpeer",
"version": "1.0.27-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.53"
"octagonal-wheels": "^0.1.54"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
@@ -12984,7 +12712,7 @@
"eslint-plugin-svelte": "^3.19.0",
"svelte": "5.56.3",
"svelte-check": "^4.6.0",
"typescript": "5.9.3",
"typescript": "6.0.3",
"vite": "^8.0.16"
}
}
+17 -9
View File
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "1.0.24",
"version": "1.0.27",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js",
"type": "module",
@@ -30,6 +30,7 @@
"i18n:json2yaml": "tsx _tools/json2yaml.ts",
"i18n:yaml2json": "tsx _tools/yaml2json.ts",
"test:unit": "vitest run --config vitest.config.unit.ts",
"test:release-process": "vitest run --config vitest.config.unit.ts utils/release-process.unit.spec.ts",
"build:browser-apps": "npm run build --workspace livesync-webapp --workspace webpeer",
"test:browser-apps": "npm run test:browser --workspace livesync-webapp && npm run test:browser --workspace webpeer",
"test:browser-apps:pages": "deno test -A --no-check --frozen --config test/browser-apps/deno.json --lock test/browser-apps/deno.lock test/browser-apps/pages/browser-smoke.test.ts",
@@ -46,7 +47,7 @@
"test:e2e:cli:p2p": "npm run test:e2e:p2p --workspace self-hosted-livesync-cli",
"test:e2e:cli:all": "npm run test:e2e:all --workspace self-hosted-livesync-cli",
"test:integration": "npx dotenv-cli -e .env -e .test.env -- vitest run --config vitest.config.integration.ts",
"test:unit:coverage": "vitest run --config vitest.config.unit.ts --coverage",
"test:unit:coverage": "vitest run --config vitest.config.unit.ts --coverage --exclude utils/release-process.unit.spec.ts",
"test:e2e:obsidian:install-appimage": "tsx test/e2e-obsidian/scripts/install-appimage.ts",
"test:e2e:obsidian:runner": "vitest run --config vitest.config.e2e-runner.ts",
"test:e2e:obsidian:discover": "tsx test/e2e-obsidian/scripts/discover.ts",
@@ -69,10 +70,12 @@
"test:e2e:obsidian:cli-to-obsidian-sync": "tsx test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts",
"test:e2e:obsidian:minio-upload": "tsx test/e2e-obsidian/scripts/minio-upload.ts",
"test:e2e:obsidian:object-storage-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts",
"test:e2e:obsidian:object-storage-custom-http-handler-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts --custom-http-handler",
"test:e2e:obsidian:p2p-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts",
"pretest:e2e:obsidian:p2p-connection-check": "npm run build && npm run build --workspace webpeer",
"test:e2e:obsidian:p2p-connection-check": "tsx test/e2e-obsidian/scripts/p2p-connection-check.ts",
"test:e2e:obsidian:p2p-connection-check:services": "npm run test:e2e:obsidian:p2p-connection-check -- --manage-p2p",
"test:e2e:obsidian:partial-startup-file-failure": "tsx test/e2e-obsidian/scripts/partial-startup-file-failure.ts",
"test:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts",
"test:e2e:obsidian:setup-uri-workflow": "tsx test/e2e-obsidian/scripts/setup-uri-workflow.ts",
"test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts",
@@ -126,7 +129,7 @@
"@types/pouchdb-mapreduce": "^6.1.10",
"@types/pouchdb-replication": "^6.4.7",
"@types/transform-pouch": "^1.0.6",
"@typescript-eslint/parser": "8.56.1",
"@typescript-eslint/parser": "8.69.0",
"@vitest/coverage-v8": "^4.1.8",
"@vrtmrz/obsidian-test-session": "0.2.6",
"dotenv-cli": "^11.0.0",
@@ -156,13 +159,13 @@
"svelte": "5.56.3",
"svelte-check": "^4.6.0",
"svelte-eslint-parser": "^1.8.0",
"svelte-preprocess": "^6.0.3",
"svelte-preprocess": "6.0.5",
"terser": "^5.39.0",
"tinyglobby": "^0.2.15",
"transform-pouch": "^2.0.0",
"tsx": "^4.21.0",
"typescript": "5.9.3",
"typescript-eslint": "^8.61.0",
"typescript": "6.0.3",
"typescript-eslint": "8.69.0",
"vite": "^8.0.16",
"vitest": "^4.1.8",
"yaml": "^2.8.2"
@@ -177,11 +180,10 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.22",
"@vrtmrz/livesync-commonlib": "0.1.23",
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
"fflate": "^0.8.2",
"idb": "^8.0.3",
"markdown-it": "^14.2.0",
"minimatch": "^10.2.5",
@@ -202,5 +204,11 @@
"src/apps/cli",
"src/apps/webpeer",
"src/apps/webapp"
]
],
"allowScripts": {
"esbuild@0.28.1": true,
"leveldown@5.6.0": true,
"leveldown@6.1.1": true,
"svelte-preprocess": false
}
}
+28
View File
@@ -0,0 +1,28 @@
# Self-hosted LiveSync technical paper manuscript
This directory contains a technical manuscript prepared in the format of the Journal of Open Source Software (JOSS) to document the design intent, architecture, and workflow context of Self-hosted LiveSync.
This document is not currently published as a formal journal paper; rather, it serves as an architectural overview explaining the project's background and replication model (describing Self-hosted LiveSync 1.0.23 pinned to Commonlib 0.1.21). If you reference or utilise Self-hosted LiveSync in academic research, laboratory workflows, or technical publications, citing the software via [CITATION.cff](../CITATION.cff) or this manuscript is greatly appreciated.
## Contents
- [paper.md](paper.md): English manuscript.
- [paper.ja.md](paper.ja.md): Japanese reference translation.
- [paper.bib](paper.bib): Shared bibliography.
## Citing Self-hosted LiveSync
Please refer to the repository's [CITATION.cff](../CITATION.cff) file or the metadata recorded in [paper.bib](paper.bib) if you wish to cite this software in your research papers, technical reports, or presentations.
## Feedback and Contributions
For corrections, suggestions, or questions regarding the manuscript, please open an [issue](https://github.com/vrtmrz/obsidian-livesync/issues) or submit a pull request.
---
## 本原稿について
本ディレクトリーには、Journal of Open Source 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
+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
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "1.0.24-cli",
"version": "1.0.27-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
@@ -37,7 +37,7 @@
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
"octagonal-wheels": "^0.1.53",
"octagonal-wheels": "^0.1.54",
"pouchdb-adapter-http": "^9.0.0",
"pouchdb-adapter-leveldb": "^9.0.0",
"pouchdb-core": "^9.0.0",
@@ -51,7 +51,7 @@
"werift": "^0.24.4"
},
"devDependencies": {
"typescript": "5.9.3",
"typescript": "6.0.3",
"vite": "^8.0.16",
"vitest": "^4.1.8"
}
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "1.0.24-webapp",
"version": "1.0.27-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
@@ -15,12 +15,12 @@
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webapp/browser-smoke.test.ts"
},
"dependencies": {
"octagonal-wheels": "^0.1.53"
"octagonal-wheels": "^0.1.54"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"svelte": "5.56.3",
"typescript": "5.9.3",
"typescript": "6.0.3",
"vite": "^8.0.16"
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "1.0.24-webpeer",
"version": "1.0.27-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
@@ -15,7 +15,7 @@
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webpeer/browser-smoke.test.ts"
},
"dependencies": {
"octagonal-wheels": "^0.1.53"
"octagonal-wheels": "^0.1.54"
},
"devDependencies": {
"eslint-plugin-svelte": "^3.19.0",
@@ -23,7 +23,7 @@
"@tsconfig/svelte": "^5.0.8",
"svelte": "5.56.3",
"svelte-check": "^4.6.0",
"typescript": "5.9.3",
"typescript": "6.0.3",
"vite": "^8.0.16"
}
}
+1 -1
View File
@@ -4213,7 +4213,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
"zh-tw": "正在等待就緒⋯",
},
"moduleLog.pathComponentTooLong": {
def: "A file or folder name exceeds ${maxBytes} UTF-8 bytes and may not work on some Android and Linux file systems: ${components}",
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",
+1 -1
View File
@@ -483,7 +483,7 @@
"moduleLiveSyncMain.optionResumeAndRestart": "Resume and restart Obsidian",
"moduleLiveSyncMain.titleScramEnabled": "Scram Enabled",
"moduleLocalDatabase.logWaitingForReady": "Waiting for ready...",
"moduleLog.pathComponentTooLong": "A file or folder name exceeds ${maxBytes} UTF-8 bytes and may not work on some Android and Linux file systems: ${components}",
"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",
+2 -2
View File
@@ -733,8 +733,8 @@ moduleLocalDatabase:
logWaitingForReady: Waiting for ready...
moduleLog:
pathComponentTooLong: >-
A file or folder name exceeds ${maxBytes} UTF-8 bytes and may not work on
some Android and Linux file systems: ${components}
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:
+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");
@@ -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);
});
});
-4
View File
@@ -299,13 +299,9 @@ export class ModuleLog extends AbstractObsidianModule {
}
const oversizedPathComponents = findPathComponentsExceedingUtf8Limit(thisFile.path);
if (oversizedPathComponents.length > 0) {
const components = oversizedPathComponents
.map(({ component, utf8Bytes }) => `${component} (${utf8Bytes} bytes)`)
.join(", ");
reasonWarn.push(
$msg("moduleLog.pathComponentTooLong", {
maxBytes: `${ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY}`,
components,
})
);
}
@@ -25,6 +25,7 @@ import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
import type PouchDB from "pouchdb-core";
import { promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
import { $msg } from "@/common/translation";
const KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT = "replicationResultProcessorSnapshot";
const REPROCESS_BATCH_SIZE = 100;
@@ -79,6 +80,14 @@ export class ReplicateResultProcessor {
private logError(e: unknown) {
Logger(e, LOG_LEVEL_VERBOSE);
}
private reportVaultReflectionFailure(entry: MetaEntry, cause?: unknown) {
this.log(
`Live replication could not reflect ${this.getPath(entry)} from the local database to the Vault; this path remains eligible for a later Vault scan.`,
LOG_LEVEL_VERBOSE
);
if (cause !== undefined) this.logError(cause);
Logger($msg("Ui.Common.SomeFilesCouldNotBeSynchronised"), LOG_LEVEL_NOTICE);
}
constructor(private readonly context: ReplicateResultProcessorContext) {}
private get localDatabase() {
@@ -510,8 +519,16 @@ export class ReplicateResultProcessor {
this.log(`Processed by other processor: ${docNote}`, LOG_LEVEL_DEBUG);
} else if (this.services.vault.isValidPath(this.getPath(doc))) {
// Apply to storage if the path is valid
await this.applyToStorage(doc as MetaEntry);
this.log(`Processed: ${docNote}`, LOG_LEVEL_DEBUG);
try {
const reflected = await this.applyToStorage(doc as MetaEntry);
if (!reflected) {
this.reportVaultReflectionFailure(doc as MetaEntry);
return;
}
this.log(`Processed: ${docNote}`, LOG_LEVEL_DEBUG);
} catch (error) {
this.reportVaultReflectionFailure(doc as MetaEntry, error);
}
} else {
// Should process, but have an invalid path
this.log(`Unprocessed (Invalid path): ${docNote}`, LOG_LEVEL_VERBOSE);
@@ -525,9 +542,10 @@ export class ReplicateResultProcessor {
* @returns
*/
protected applyToStorage(entry: MetaEntry) {
return this.withCounting(async () => {
await this.services.replication.processSynchroniseResult(entry);
}, this.services.replication.storageApplyingCount);
return this.withCounting(
() => this.services.replication.processSynchroniseResult(entry),
this.services.replication.storageApplyingCount
);
}
/**
@@ -2,6 +2,13 @@ import { promiseWithResolvers } from "octagonal-wheels/promises";
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
import { describe, expect, it, vi } from "vitest";
import { VER, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
defaultLogger,
LOG_LEVEL_DEBUG,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
setGlobalLogFunction,
} from "octagonal-wheels/common/logger";
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
function note(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
@@ -21,12 +28,12 @@ function note(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
type SetupOptions = {
applicationReady?: boolean;
processSynchroniseResult?: (entry: unknown) => Promise<void>;
processSynchroniseResult?: (entry: unknown) => Promise<boolean>;
setSnapshot?: (key: string, value: unknown) => Promise<unknown>;
};
function setup(options: SetupOptions = {}) {
const processSynchroniseResult = vi.fn(options.processSynchroniseResult ?? (async () => undefined));
const processSynchroniseResult = vi.fn(options.processSynchroniseResult ?? (async () => true));
const setSnapshot = vi.fn(options.setSnapshot ?? (async () => undefined));
const runBoundedLocalApplicationActivity = vi.fn(async (task: () => Promise<void>) => await task());
const onCloseActiveReplication = vi.fn(async () => true);
@@ -120,7 +127,7 @@ describe("ReplicateResultProcessor", () => {
});
it("keeps one local application activity until every replicated document has been applied", async () => {
const applying = promiseWithResolvers<void>();
const applying = promiseWithResolvers<boolean>();
let activityFinished = false;
const { processor, processSynchroniseResult, runBoundedLocalApplicationActivity } = setup({
processSynchroniseResult: async () => applying.promise,
@@ -139,7 +146,7 @@ describe("ReplicateResultProcessor", () => {
});
expect(activityFinished).toBe(false);
applying.resolve();
applying.resolve(true);
await vi.waitFor(() => expect(activityFinished).toBe(true));
});
@@ -160,7 +167,7 @@ describe("ReplicateResultProcessor", () => {
});
it("releases and reacquires local application activity around processing suspension", async () => {
const applying = promiseWithResolvers<void>();
const applying = promiseWithResolvers<boolean>();
let completedActivities = 0;
const { processor, processSynchroniseResult, runBoundedLocalApplicationActivity } = setup({
processSynchroniseResult: async () => applying.promise,
@@ -178,7 +185,47 @@ describe("ReplicateResultProcessor", () => {
processor.resume();
await vi.waitFor(() => expect(runBoundedLocalApplicationActivity).toHaveBeenCalledTimes(2));
applying.resolve();
applying.resolve(true);
await vi.waitFor(() => expect(completedActivities).toBe(2));
});
it.each([
["returns false", async () => false, undefined],
["throws", async () => Promise.reject(new Error("File name too long")), "File name too long"],
])("reports when Vault reflection %s", async (_description, processSynchroniseResult, errorMessage) => {
const log = vi.fn((_message: unknown, _level?: number) => undefined);
setGlobalLogFunction(log);
try {
const { processor } = setup({ processSynchroniseResult });
processor.enqueueAll([note("unreflectable")]);
await vi.waitFor(() =>
expect(log).toHaveBeenCalledWith(
"Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.",
LOG_LEVEL_NOTICE,
undefined
)
);
expect(log).toHaveBeenCalledWith(
"[ReplicateResultProcessor] Live replication could not reflect unreflectable.md from the local database to the Vault; this path remains eligible for a later Vault scan.",
LOG_LEVEL_VERBOSE,
undefined
);
if (errorMessage !== undefined) {
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ message: errorMessage }),
LOG_LEVEL_VERBOSE,
undefined
);
}
expect(log).not.toHaveBeenCalledWith(
expect.stringContaining("Processed: unreflectable.md"),
LOG_LEVEL_DEBUG,
undefined
);
} finally {
setGlobalLogFunction(defaultLogger);
}
});
});
+8 -1
View File
@@ -166,9 +166,15 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
`test:e2e:obsidian:startup-scan` starts from a CouchDB fixture using current settings with its device-local compatibility marker already acknowledged, stops Obsidian, writes a note directly into the Vault, restarts the same isolated Vault and profile without rewriting its plug-in data, and verifies from CouchDB that the start-up scan picked up the offline file. Onboarding remains covered by `onboarding-invitation`; this scenario owns the ordinary configured restart and start-up scan.
`test:e2e:obsidian:partial-startup-file-failure` is a focused Linux release-acceptance scenario for an ordinary configured restart. It stores one valid database-only note and one database-only note whose path component is 258 UTF-8 bytes, then restarts the same isolated Vault and profile. On a Linux test Vault which enforces the conventional 255-byte component limit, the scenario requires the valid file to be reflected, the application to become ready, the partial-failure Notice to appear, and the failed path to remain readable and eligible for a later scan with its exact path in the verbose log. It remains outside `local-suite` because the failure fixture is deliberately platform-specific.
`test:e2e:obsidian:setup-uri-workflow` runs the repository's public Commonlib-backed CouchDB provisioning and Setup URI tools against the local CouchDB fixture. It configures a new, empty Vault in the first real Obsidian session through the visible onboarding wizard and uses Rebuild. After that device is working, it generates a new Setup URI through the registered command; the second real Obsidian Vault uses that URI for Fetch instead of reusing the initial Setup URI produced by the provisioning tool. The workflow verifies ordinary notes from the first device to the second and back again, independently enables Hidden File Sync on each device, and verifies a snippet. The retained Setup URI screenshots show only encrypted URIs and visually masked Setup URI passphrases; plaintext credentials are not captured. Files prefixed with `guide-` capture the relevant dialogue, settings panel, or workspace leaf without transient Notices. Public documentation copies selected images only after visual inspection; the E2E run does not overwrite repository documentation assets.
`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, ordinary rename, a case-only file name change within the same directory, deletion, and a separate encrypted round-trip with Path Obfuscation enabled. Its target-filter scenario confirms that one Vault receives and checkpoints a remote document without reflecting it, restarts with the same profile and filter, and then reflects the stored document after the filter is broadened through the settings service. Directory case changes deliberately remain outside this scenario because they require directory-aware rename handling. The optional Markdown conflict check can be enabled with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`. It creates divergent revisions in two separate Vaults, performs a conservative merge on one Vault, edits that result again, and requires the other Vault to replace its known deleted losing revision without recreating the conflict. The separate `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` check keeps four conflicts active while one Vault edits, deletes, performs a case-only rename, and performs a cross-path rename. It asserts that each operation extends the revision displayed on that device, replicates the exact resulting revision tree, and preserves the other conflict branch. During focused development, `E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS=true` runs that self-contained scope without the ordinary, target-filter, or encrypted scenarios. Both conflict checks remain outside the default local suite.
`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, ordinary rename, a case-only file name change within the same directory, deletion, and a separate encrypted round-trip with Path Obfuscation enabled. Its target-filter scenario confirms that one Vault receives and checkpoints a remote document without reflecting it, restarts with the same profile and filter, and then reflects the stored document after the filter is broadened through the settings service. Directory case changes deliberately remain outside the ordinary workflow because they require directory-aware rename handling.
During focused development, `E2E_OBSIDIAN_ONLY_PARENT_CASE_DELETION=true` runs an Issue #1168 check which renames `parent/test3` to `parent/Test3` through external `node:fs/promises.rename` while Vault A is open, and verifies that the note content, Metadata, and Chunk references are not logically deleted locally, remotely, or after restart. It accepts either case spelling on Vault B, so it does not provide directory rename support or exact case convergence between devices. The natural Obsidian event sequence and resulting database state are evidence for the selected build; an existing-version reproduction result must be reported separately from fixed-version safety evidence.
The optional Markdown conflict check can be enabled with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`. It creates divergent revisions in two separate Vaults, performs a conservative merge on one Vault, edits that result again, and requires the other Vault to replace its known deleted losing revision without recreating the conflict. The separate `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` check keeps four conflicts active while one Vault edits, deletes, performs a case-only rename, and performs a cross-path rename. It asserts that each operation extends the revision displayed on that device, replicates the exact resulting revision tree, and preserves the other conflict branch. During focused development, `E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS=true` runs that self-contained scope without the ordinary, target-filter, or encrypted scenarios. Both conflict checks remain outside the default local suite.
`test:e2e:obsidian:security-seed-reconnect` is a focused CouchDB release-acceptance workflow. Device A first recognises an initial remote Security Seed, stops automatic replication while remaining open, and creates an unsent note. The runner replaces only the Security Seed in the managed remote synchronisation-parameter fixture. Device A must retain its deliberately stale cached value until the next one-shot synchronisation, refresh it before sending, and upload an HKDF-encrypted payload which uses the replacement value. A fresh device B must decrypt that note and send an encrypted note back; the original device A then receives the return journey with its Vault and isolated profile preserved. Desktop Obsidian may enforce a single application instance, so the two device sessions run sequentially after the same-process stale-cache assertion has completed.
@@ -256,6 +262,7 @@ Useful environment variables:
- `E2E_OBSIDIAN_FILE_TIMEOUT_MS`: timeout for waiting until a note created through Obsidian's vault API is reflected to disk.
- `E2E_OBSIDIAN_CORE_READY_TIMEOUT_MS`: timeout for waiting until Self-hosted LiveSync reports that its core lifecycle and local database are ready.
- `E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS`: timeout for waiting until a file appears in Self-hosted LiveSync's local database.
- `E2E_OBSIDIAN_ONLY_PARENT_CASE_DELETION=true`: run only the focused external parent-directory case-rename protection check in `two-vault-sync`.
- `E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS`: timeout for waiting until CouchDB contains uploaded E2E documents.
- `E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS`: timeout for an observed remote activity to enter or leave its status boundary; default is 30 seconds.
- `E2E_OBSIDIAN_DIAGNOSTICS_DIR`: directory for screenshots and status snapshots, including the Security Seed reconnect stages; default is `/tmp/obsidian-livesync-e2e`.
+2
View File
@@ -19,6 +19,7 @@ export type SetupState = {
endpoint: string;
bucket: string;
bucketPrefix: string;
useCustomRequestHandler: boolean;
p2pEnabled: boolean;
p2pRelays: string;
p2pRoomId: string;
@@ -354,6 +355,7 @@ export async function readSetupState(cliBinary: string, environment: NodeJS.Proc
"endpoint:settings.endpoint||'',",
"bucket:settings.bucket||'',",
"bucketPrefix:settings.bucketPrefix||'',",
"useCustomRequestHandler:settings.useCustomRequestHandler===true,",
"p2pEnabled:settings.P2P_Enabled===true,",
"p2pRelays:settings.P2P_relays||'',",
"p2pRoomId:settings.P2P_roomID||'',",
+4
View File
@@ -35,6 +35,10 @@ const testSteps: Step[] = [
name: "Object Storage Setup URI workflow",
args: ["run", "test:e2e:obsidian:object-storage-setup-uri-workflow"],
},
{
name: "Object Storage Custom HTTP Handler Setup URI workflow",
args: ["run", "test:e2e:obsidian:object-storage-custom-http-handler-setup-uri-workflow"],
},
{ name: "P2P Setup URI workflow", args: ["run", "test:e2e:obsidian:p2p-setup-uri-workflow"] },
{ name: "startup scan", args: ["run", "test:e2e:obsidian:startup-scan"] },
{ name: "provisioned Setup URI workflow", args: ["run", "test:e2e:obsidian:setup-uri-workflow"] },
@@ -45,7 +45,10 @@ import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
const execFileAsync = promisify(execFile);
const captures: SetupCaptureNames = { scenario: "object-storage-setup-uri", guide: "object-storage-setup" };
const useCustomRequestHandler = process.argv.includes("--custom-http-handler");
const captures: SetupCaptureNames = useCustomRequestHandler
? { scenario: "object-storage-custom-http-handler-setup-uri", guide: "object-storage-custom-http-handler-setup" }
: { scenario: "object-storage-setup-uri", guide: "object-storage-setup" };
const noteFromFirst = "E2E/object-storage/from-first.md";
const noteFromSecond = "E2E/object-storage/from-second.md";
const firstContent =
@@ -94,7 +97,8 @@ async function runDeno(script: string, environment: NodeJS.ProcessEnv): Promise<
async function generateBootstrapSetupURI(
objectStorage: ObjectStorageConfig,
bucketPrefix: string
bucketPrefix: string,
useCustomRequestHandler: boolean
): Promise<SetupArtifact> {
const setupPassphrase = randomBytes(24).toString("base64url");
const output = await runDeno("utils/setup/generate_setup_uri.ts", {
@@ -107,6 +111,7 @@ async function generateBootstrapSetupURI(
region: objectStorage.region,
force_path_style: String(objectStorage.forcePathStyle),
bucket_prefix: bucketPrefix,
...(useCustomRequestHandler ? { use_custom_request_handler: "true" } : {}),
passphrase: randomBytes(24).toString("base64url"),
uri_passphrase: setupPassphrase,
});
@@ -251,7 +256,7 @@ async function main(): Promise<void> {
const objectStorage = await loadObjectStorageConfig();
const bucketPrefix = makeUniqueBucketPrefix("setup-uri-workflow");
const bootstrapArtifact = await generateBootstrapSetupURI(objectStorage, bucketPrefix);
const bootstrapArtifact = await generateBootstrapSetupURI(objectStorage, bucketPrefix, useCustomRequestHandler);
const vaultA = await createTemporaryVault();
const vaultB = await createTemporaryVault();
const [portA, portB] = sessionPorts();
@@ -285,6 +290,11 @@ async function main(): Promise<void> {
bucketPrefix,
"The first device did not activate the unique bucket prefix."
);
assertEqual(
firstState.useCustomRequestHandler,
useCustomRequestHandler,
"The first device did not preserve the expected Custom HTTP Handler setting."
);
await writeNote(context.cliBinary, sessionA.cliEnv, noteFromFirst, firstContent);
await pushLocalChanges(context.cliBinary, sessionA.cliEnv);
@@ -330,6 +340,11 @@ async function main(): Promise<void> {
bucketPrefix,
"The second device did not import the unique bucket prefix."
);
assertEqual(
secondState.useCustomRequestHandler,
useCustomRequestHandler,
"The second device did not import the expected Custom HTTP Handler setting."
);
await pushLocalChanges(context.cliBinary, sessionB.cliEnv);
await waitForPathContent(vaultB, noteFromFirst, firstContent);
screenshots.push(
@@ -337,7 +352,7 @@ async function main(): Promise<void> {
portB,
noteFromFirst,
"Object Storage from the first device",
"guide-object-storage-setup-first-to-second.png"
`guide-${captures.guide}-first-to-second.png`
)
);
@@ -357,12 +372,14 @@ async function main(): Promise<void> {
portA,
noteFromSecond,
"Object Storage from the second device",
"guide-object-storage-setup-second-to-first.png"
`guide-${captures.guide}-second-to-first.png`
)
);
console.log(
`Object Storage Setup URI and two-device roundtrip succeeded. Screenshots: ${screenshots.join(", ")}`
`Object Storage Setup URI and two-device roundtrip succeeded with the ${
useCustomRequestHandler ? "Custom HTTP Handler" : "default HTTP handler"
}. Screenshots: ${screenshots.join(", ")}`
);
} finally {
await stopSessions(context).catch((error: unknown) => {
@@ -0,0 +1,301 @@
/**
* Proves that one file which cannot be reflected during an ordinary start-up
* does not keep the entire configured application unready.
*
* The fixture relies on the conventional Linux 255-byte path component
* limit. It stores one ordinary note and one note with a 258-byte component in
* the local database, then restarts the same real Obsidian Vault and profile.
*/
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import {
assertCouchDbReachable,
createCouchDbDatabase,
deleteCouchDbDatabase,
loadCouchDbConfig,
makeUniqueDatabaseName,
} from "../runner/couchdb.ts";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
const validPath = "E2E/partial-startup-valid.md";
const oversizedComponent = `${"界".repeat(85)}.md`;
const failedPath = `E2E/${oversizedComponent}`;
const validContent = `# Partial start-up\n\n${"V".repeat(4096)}\n`;
const failedContent = `# Retry this file\n\n${"R".repeat(4096)}\n`;
const partialFailureNotice =
"Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.";
const failedPathLog =
`Offline scan failed to synchronise ${failedPath} between storage and the local database; ` +
"this path remains eligible for a later scan.";
const assertionTimeoutMs = Number(process.env.E2E_OBSIDIAN_CORE_READY_TIMEOUT_MS ?? 20000);
type SeededEntry = {
id: string;
path: string;
revision: string;
children: string[];
};
type FailedPathState = {
appReady: boolean;
databaseReady: boolean;
fileExists: boolean;
entryReadable: boolean;
metadataRevision?: string;
provenance: { revision: string; observedStorageMtime?: number } | null;
logText: string;
};
type RetryState = Omit<FailedPathState, "databaseReady" | "logText"> & {
scanResult: string | false;
};
async function seedDatabaseOnlyEntries(cliBinary: string, env: NodeJS.ProcessEnv): Promise<SeededEntry[]> {
return await evalObsidianJson<SeededEntry[]>(
cliBinary,
[
"(async()=>{",
`const fixtures=${JSON.stringify([
{ path: validPath, content: validContent },
{ path: failedPath, content: failedContent },
])};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const seeded=[];",
"for(const {path,content} of fixtures){",
" if(app.vault.getAbstractFileByPath(path)!==null){",
" throw new Error(`Database-only fixture already exists in the Vault: ${path}`);",
" }",
" const blob=new Blob([content],{type:'text/plain'});",
" const id=await core.services.path.path2id(path);",
" const now=Date.now();",
" const result=await core.localDatabase.putDBEntry({",
" _id:id,path,data:blob,ctime:now,mtime:now,",
" size:(await blob.arrayBuffer()).byteLength,children:[],",
" datatype:'plain',type:'plain',eden:{},",
" });",
" if(!result?.ok) throw new Error(`Could not seed database-only fixture: ${path}`);",
" const metadata=await core.localDatabase.getDBEntryMeta(path,undefined,true);",
" if(!metadata) throw new Error(`Could not reload seeded Metadata: ${path}`);",
" seeded.push({id,path,revision:result.rev,children:metadata.children??[]});",
"}",
"return JSON.stringify(seeded);",
"})()",
].join(""),
env
);
}
async function observePartialFailureNotice(remoteDebuggingPort: number): Promise<void> {
await withObsidianPage(remoteDebuggingPort, async (page) => {
await page
.locator(".notice")
.filter({ hasText: partialFailureNotice })
.first()
.waitFor({ state: "visible", timeout: assertionTimeoutMs });
});
}
async function inspectFailedPathState(cliBinary: string, env: NodeJS.ProcessEnv): Promise<FailedPathState> {
return await evalObsidianJson<FailedPathState>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(failedPath)};`,
`const expectedLog=${JSON.stringify(failedPathLog)};`,
`const timeoutMs=${JSON.stringify(assertionTimeoutMs)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const metadata=await core.localDatabase.getDBEntryMeta(path,undefined,true);",
"const entry=await core.localDatabase.getDBEntry(path,undefined,false,true,true);",
"const provenanceStore=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');",
"const provenance=(await provenanceStore.get(path))??null;",
"await core.services.API.showWindow('log-log');",
"const deadline=Date.now()+timeoutMs;",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"let logText='';",
"while(Date.now()<deadline){",
" logText=Array.from(document.querySelectorAll('.logpane .log pre'))",
" .map((element)=>element.textContent??'').join('\\n');",
" if(logText.includes(expectedLog)) break;",
" await sleep(100);",
"}",
"for(const leaf of app.workspace.getLeavesOfType('log-log')) leaf.detach();",
"return JSON.stringify({",
" appReady:core.services.appLifecycle.isReady(),",
" databaseReady:core.services.database.isDatabaseReady(),",
" fileExists:app.vault.getAbstractFileByPath(path)!==null,",
" entryReadable:entry!==false,",
" metadataRevision:metadata?._rev,",
" provenance,",
" logText,",
"});",
"})()",
].join(""),
env
);
}
async function retryFailedPath(cliBinary: string, env: NodeJS.ProcessEnv): Promise<RetryState> {
return await evalObsidianJson<RetryState>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(failedPath)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const scanResult=await core.services.vault.scanVault(false,false,true);",
"const metadata=await core.localDatabase.getDBEntryMeta(path,undefined,true);",
"const entry=await core.localDatabase.getDBEntry(path,undefined,false,true,true);",
"const provenanceStore=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');",
"return JSON.stringify({",
" scanResult,",
" appReady:core.services.appLifecycle.isReady(),",
" fileExists:app.vault.getAbstractFileByPath(path)!==null,",
" entryReadable:entry!==false,",
" metadataRevision:metadata?._rev,",
" provenance:(await provenanceStore.get(path))??null,",
"});",
"})()",
].join(""),
env
);
}
async function main(): Promise<void> {
if (process.platform !== "linux") {
throw new Error("The partial start-up file-failure scenario currently requires a Linux test Vault.");
}
assertEqual(
Buffer.byteLength(oversizedComponent, "utf8"),
258,
"The failing path component no longer exercises the intended UTF-8 byte boundary."
);
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) {
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
}
const couchDb = await loadCouchDbConfig();
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "partial-startup-file-failure");
const couchDbSettings = {
uri: couchDb.uri,
username: couchDb.username,
password: couchDb.password,
dbName,
};
const vault = await createTemporaryVault("obsidian-livesync-partial-startup-");
let session: ObsidianLiveSyncSession | undefined;
try {
await assertCouchDbReachable(couchDb);
await createCouchDbDatabase(couchDb, dbName);
console.log(`Using Obsidian executable: ${binary}`);
console.log(`Temporary vault: ${vault.path}`);
console.log(`Temporary CouchDB database: ${dbName}`);
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eCouchDbPluginData(couchDbSettings, {
showVerboseLog: true,
lessInformationInLog: false,
}),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await prepareRemote(cli.binary, session.cliEnv);
const seeded = await seedDatabaseOnlyEntries(cli.binary, session.cliEnv);
const validSeed = seeded.find((entry) => entry.path === validPath);
const failedSeed = seeded.find((entry) => entry.path === failedPath);
if (!validSeed || !failedSeed) throw new Error("The database-only start-up fixtures were incomplete.");
if (validSeed.children.length === 0 || failedSeed.children.length === 0) {
throw new Error("The database-only fixtures did not create independently stored chunks.");
}
await session.app.stop();
session = undefined;
let partialNoticeObserved = false;
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
pluginStartup: "natural",
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
lifecycle: {
afterPluginLoad: async ({ remoteDebuggingPort }) => {
await observePartialFailureNotice(remoteDebuggingPort);
partialNoticeObserved = true;
},
},
});
const readiness = await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
assertEqual(readiness.configured, true, "Self-hosted LiveSync lost its configuration on restart.");
assertEqual(partialNoticeObserved, true, "The partial start-up failure Notice was not observed.");
assertEqual(
await readFile(join(vault.path, validPath), "utf8"),
validContent,
"The valid database-only file was not reflected during the same start-up scan."
);
const state = await inspectFailedPathState(cli.binary, session.cliEnv);
assertEqual(state.databaseReady, true, "The local database did not remain ready after one file failed.");
assertEqual(state.appReady, true, "One file failure kept the application unready.");
assertEqual(state.fileExists, false, "The overlong path was unexpectedly reflected to the Linux Vault.");
assertEqual(state.entryReadable, true, "The failed database entry was no longer readable.");
assertEqual(state.metadataRevision, failedSeed.revision, "The failed database entry revision changed.");
assertEqual(state.provenance, null, "A failed reflection was recorded as successful provenance.");
assertEqual(
state.logText.includes(failedPathLog),
true,
"The verbose log did not identify the path which failed during the start-up scan."
);
const retry = await retryFailedPath(cli.binary, session.cliEnv);
assertEqual(
retry.scanResult,
"completed-with-file-failures",
"A later scan did not retry and report the same individual file failure."
);
assertEqual(retry.appReady, true, "Retrying the failed path cleared application readiness.");
assertEqual(retry.fileExists, false, "The overlong path was unexpectedly reflected during retry.");
assertEqual(retry.entryReadable, true, "Retrying removed the failed database entry.");
assertEqual(retry.metadataRevision, failedSeed.revision, "Retrying changed the failed database revision.");
assertEqual(retry.provenance, null, "Retrying recorded a failed reflection as successful provenance.");
console.log(`Ordinary start-up remained ready, reflected ${validPath}, and retained ${failedPath} for retry.`);
} finally {
if (session) {
await session.app.stop();
}
await vault.dispose();
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+2
View File
@@ -21,7 +21,9 @@ const focusedScenarios = new Set([
"cli-to-obsidian-sync",
"minio-upload",
"object-storage-setup-uri-workflow",
"object-storage-custom-http-handler-setup-uri-workflow",
"p2p-setup-uri-workflow",
"partial-startup-file-failure",
"startup-scan",
"setup-uri-workflow",
"two-vault-sync",
+395 -4
View File
@@ -1,10 +1,11 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, readFile, rename as renameFilesystemPath, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
createCouchDbDatabase,
deleteCouchDbDatabase,
fetchAllCouchDbDocs,
loadCouchDbConfig,
makeUniqueDatabaseName,
waitForCouchDbDocs,
@@ -27,6 +28,7 @@ import {
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
import { captureObsidianPage } from "../runner/ui.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000";
@@ -47,6 +49,11 @@ const conflictRenameFromPath = "E2E/two-vault/conflict-operations/rename-source.
const conflictRenameToPath = "E2E/two-vault/conflict-operations/renamed/rename-target.md";
const targetMismatchPath = "E2E/two-vault/target-mismatch.md";
const encryptedPath = "E2E/two-vault/encrypted.md";
const parentCaseRenameFromDirectoryPath = "E2E/two-vault/parent/test3";
const parentCaseRenameToDirectoryPath = "E2E/two-vault/parent/Test3";
const parentCaseRenameFromPath = `${parentCaseRenameFromDirectoryPath}/note.md`;
const parentCaseRenameToPath = `${parentCaseRenameToDirectoryPath}/note.md`;
const parentCaseEventObserverKey = "__livesyncE2eParentCaseEventObserver";
type RunnerContext = {
binary: string;
@@ -68,6 +75,34 @@ type FileConflictState = {
}[];
};
type ParentCaseVaultEvent = {
type: "create" | "delete" | "rename";
path: string;
oldPath: string | null;
};
type ParentCaseMetadataState = {
id: string;
found: boolean;
rev: string | null;
path: string | null;
deleted: boolean;
children: string[];
contentMatches: boolean;
childrenMatch: boolean;
chunksPresent: boolean;
chunkReferenceCount: number;
availableChunkCount: number;
};
type ParentCaseRemoteMetadataState = {
id: string;
rev: string | null;
path: string | null;
deleted: boolean;
children: string[];
};
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
const fullPath = join(vaultPath, path);
await mkdir(dirname(fullPath), { recursive: true });
@@ -94,6 +129,57 @@ async function pathExists(vaultPath: string, path: string): Promise<boolean> {
}
}
async function installParentCaseEventObserver(
cliBinary: string,
env: NodeJS.ProcessEnv,
observedPaths: readonly string[]
): Promise<string> {
return await evalObsidianJson<string>(
cliBinary,
[
"(async()=>{",
`const key=${JSON.stringify(parentCaseEventObserverKey)};`,
`const observedPaths=${JSON.stringify(observedPaths)};`,
"const previous=globalThis[key];",
"if(previous){for(const ref of previous.refs??[]) app.vault.offref(ref);}",
"const events=[];",
"const record=(type,file,oldPath)=>{",
" const path=typeof file?.path==='string'?file.path:'';",
" const previousPath=typeof oldPath==='string'?oldPath:null;",
" if(!observedPaths.includes(path)&&(!previousPath||!observedPaths.includes(previousPath))) return;",
" globalThis[key].lastEventAt=Date.now();",
" if(events.length<32) events.push({type,path,oldPath:previousPath});",
"};",
"const refs=[",
" app.vault.on('create',(file)=>record('create',file)),",
" app.vault.on('delete',(file)=>record('delete',file)),",
" app.vault.on('rename',(file,oldPath)=>record('rename',file,oldPath)),",
"];",
"globalThis[key]={events,refs,lastEventAt:Date.now()};",
"return JSON.stringify(app.plugins.plugins['obsidian-livesync'].core.services.API.getAppVersion());",
"})()",
].join(""),
env
);
}
async function takeParentCaseEventEvidence(cliBinary: string, env: NodeJS.ProcessEnv): Promise<ParentCaseVaultEvent[]> {
return await evalObsidianJson<ParentCaseVaultEvent[]>(
cliBinary,
[
"(async()=>{",
`const key=${JSON.stringify(parentCaseEventObserverKey)};`,
"const observer=globalThis[key];",
"if(!observer) return JSON.stringify([]);",
"const events=Array.isArray(observer.events)?observer.events.slice(0,32):[];",
"try{for(const ref of observer.refs??[]) app.vault.offref(ref);}finally{delete globalThis[key];}",
"return JSON.stringify(events);",
"})()",
].join(""),
env
);
}
async function stopTrackedSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
if (!context.activeSessions.has(session)) return;
await session.app.stop();
@@ -141,6 +227,165 @@ async function waitForPathDeleted(
throw new Error(`Timed out waiting for deleted file: ${join(vaultPath, path)}`);
}
async function waitForExactObsidianPath(
cliBinary: string,
env: NodeJS.ProcessEnv,
path: string,
oldPath: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000)
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const expectedPath=${JSON.stringify(path)};`,
`const oldPath=${JSON.stringify(oldPath)};`,
`const observerKey=${JSON.stringify(parentCaseEventObserverKey)};`,
`const timeoutMs=${JSON.stringify(timeoutMs)};`,
"const deadline=Date.now()+timeoutMs;",
"let observedPath=null;",
"while(Date.now()<deadline){",
" const files=app.vault.getFiles();",
" const file=files.find((candidate)=>candidate.path===expectedPath);",
" observedPath=typeof file?.path==='string'?file.path:null;",
" const observer=globalThis[observerKey];",
" if(observedPath===expectedPath&&!files.some((candidate)=>candidate.path===oldPath)&&observer?.events.length>0&&Date.now()-observer.lastEventAt>=500) return JSON.stringify({path:observedPath});",
" await new Promise((resolve)=>setTimeout(resolve,100));",
"}",
"throw new Error(`Timed out waiting for Obsidian to recognise the exact path: ${JSON.stringify({expectedPath,observedPath})}`);",
"})()",
].join(""),
env
);
}
async function waitForEitherPathContent(
vaultPath: string,
paths: readonly string[],
expectedContent: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000)
): Promise<{ path: string }> {
const deadline = Date.now() + timeoutMs;
let lastPath: string | null = null;
let contentMatched = false;
while (Date.now() < deadline) {
for (const path of paths) {
if (!(await pathExists(vaultPath, path))) continue;
lastPath = path;
contentMatched = (await readVaultFile(vaultPath, path)) === expectedContent;
if (contentMatched) return { path };
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(
`Timed out waiting for content at either case variant: ${JSON.stringify({
paths,
lastPath,
contentMatched,
})}`
);
}
async function waitForParentCaseMetadata(
cliBinary: string,
env: NodeJS.ProcessEnv,
id: string,
expectedPath: string,
expectedContent: string,
expectedChildren: readonly string[],
expectedRevision?: string
): Promise<ParentCaseMetadataState> {
const timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000);
return await evalObsidianJson<ParentCaseMetadataState>(
cliBinary,
[
"(async()=>{",
`const id=${JSON.stringify(id)};`,
`const expectedPath=${JSON.stringify(expectedPath)};`,
`const expectedContent=${JSON.stringify(expectedContent)};`,
`const expectedChildren=${JSON.stringify(expectedChildren)};`,
`const expectedRevision=${JSON.stringify(expectedRevision ?? null)};`,
`const timeoutMs=${JSON.stringify(timeoutMs)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const deadline=Date.now()+timeoutMs;",
"let state={id,found:false,rev:null,path:null,deleted:false,children:[],contentMatches:false,childrenMatch:false,chunksPresent:false,chunkReferenceCount:0,availableChunkCount:0};",
"while(Date.now()<deadline){",
" await core.services.fileProcessing.commitPendingFileEvents();",
" const raw=await core.localDatabase.getRaw(id,{revs_info:true}).catch(()=>null);",
" const row=((await core.localDatabase.allDocsRaw({keys:[id],include_docs:true})).rows??[])[0];",
" const rawDoc=raw??row?.doc??null;",
" const deleted=Boolean(raw?.deleted||raw?._deleted||row?.value?.deleted||row?.doc?.deleted||row?.doc?._deleted);",
" const children=Array.isArray(rawDoc?.children)?rawDoc.children:[];",
" const rev=rawDoc?._rev??row?.value?.rev??null;",
" if(deleted) throw new Error(`Parent case rename marked Metadata as deleted (deleted or _deleted): ${JSON.stringify({id,rev,path:rawDoc?.path??null})}`);",
" if(rawDoc){",
" const loaded=await core.localDatabase.getDBEntry(expectedPath,{rev},false,true,true).catch(()=>false);",
" const content=loaded===false?'':Array.isArray(loaded.data)?loaded.data.join(''):typeof loaded.data==='string'?loaded.data:'';",
" const chunkRows=children.length===0?{rows:[]}:await core.localDatabase.allDocsRaw({keys:children,include_docs:true});",
" const availableChunkCount=chunkRows.rows.filter((chunkRow)=>Boolean(chunkRow.doc)&&!Boolean(chunkRow.value?.deleted)&&!Boolean(chunkRow.doc?.deleted)&&!Boolean(chunkRow.doc?._deleted)).length;",
" state={id,found:true,rev,path:rawDoc?.path??null,deleted:false,children,contentMatches:content===expectedContent,childrenMatch:children.length===expectedChildren.length&&children.every((child,index)=>child===expectedChildren[index]),chunksPresent:availableChunkCount===children.length&&children.length===expectedChildren.length,chunkReferenceCount:children.length,availableChunkCount};",
" if(state.contentMatches&&state.childrenMatch&&state.chunksPresent&&(!expectedRevision||state.rev===expectedRevision)) return JSON.stringify(state);",
" }",
" await new Promise((resolve)=>setTimeout(resolve,250));",
"}",
"throw new Error(`Timed out waiting for parent case Metadata and Chunks: ${JSON.stringify(state)}`);",
"})()",
].join(""),
env
);
}
async function waitForParentCaseRemoteMetadata(
context: RunnerContext,
entry: LocalDatabaseEntry
): Promise<ParentCaseRemoteMetadataState> {
const timeoutMs = Number(process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ?? 15000);
const deadline = Date.now() + timeoutMs;
let lastState: ParentCaseRemoteMetadataState | null = null;
while (Date.now() < deadline) {
const response = await fetchAllCouchDbDocs(context.couchDb, context.dbName);
const row = response.rows.find((candidate) => candidate.id === entry.id);
const doc = row?.doc;
const deleted = Boolean(row?.value.deleted || doc?.deleted || doc?._deleted);
lastState = {
id: entry.id,
rev: row?.value.rev ?? doc?._rev ?? null,
path: doc?.path ?? null,
deleted,
children: Array.isArray(doc?.children) ? doc.children : [],
};
if (deleted) {
throw new Error(
`Parent case rename uploaded deleted remote Metadata: ${JSON.stringify({
id: entry.id,
rev: lastState.rev,
path: lastState.path,
})}`
);
}
if (
doc &&
lastState.children.length === entry.children.length &&
lastState.children.every(
(child, index) =>
child === entry.children[index] &&
response.rows.some(
(chunk) =>
chunk.id === child &&
chunk.doc &&
!chunk.value.deleted &&
!chunk.doc.deleted &&
!chunk.doc._deleted
)
)
) {
return lastState;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for non-deleted remote Metadata: ${JSON.stringify(lastState)}`);
}
async function writeNoteViaObsidian(cliBinary: string, env: NodeJS.ProcessEnv, path: string, content: string) {
await evalObsidianJson<unknown>(
cliBinary,
@@ -559,6 +804,145 @@ async function runCaseOnlyRename(
console.log("Two-vault case-only note rename round-tripped without a tombstone.");
}
async function runParentCaseDeletionProtection(
context: RunnerContext,
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<void> {
const fileContent = "# Parent case rename\n\nThe document must remain live after an external parent rename.\n";
const parentCaseOverrides = {
handleFilenameCaseSensitive: false,
batchSave: false,
};
const observedPaths = [
parentCaseRenameFromDirectoryPath,
parentCaseRenameToDirectoryPath,
parentCaseRenameFromPath,
parentCaseRenameToPath,
];
let session: ObsidianLiveSyncSession | undefined;
let observerInstalled = false;
let obsidianVersion: string | undefined;
let observedEvents: ParentCaseVaultEvent[] = [];
let localMetadataEvidence: ParentCaseMetadataState | undefined;
let remoteMetadataEvidence: ParentCaseRemoteMetadataState | undefined;
let restartedMetadataEvidence: ParentCaseMetadataState | undefined;
try {
session = await startConfiguredSession(context, vaultA, parentCaseOverrides);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, parentCaseRenameFromPath, fileContent);
const initialEntry = await uploadNote(context, session, parentCaseRenameFromPath);
if (initialEntry.children.length === 0) {
throw new Error(`Parent case fixture did not retain a Chunk reference: ${initialEntry.id}`);
}
await stopTrackedSession(context, session);
session = undefined;
session = await startConfiguredSession(context, vaultB, parentCaseOverrides);
await syncAndApply(context, session);
await waitForPathContent(vaultB.path, parentCaseRenameFromPath, (content) => content === fileContent);
await stopTrackedSession(context, session);
session = undefined;
session = await startConfiguredSession(context, vaultA, parentCaseOverrides);
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, parentCaseRenameFromPath);
obsidianVersion = await installParentCaseEventObserver(context.cliBinary, session.cliEnv, observedPaths);
observerInstalled = true;
await renameFilesystemPath(
join(vaultA.path, parentCaseRenameFromDirectoryPath),
join(vaultA.path, parentCaseRenameToDirectoryPath)
);
await waitForExactObsidianPath(
context.cliBinary,
session.cliEnv,
parentCaseRenameToPath,
parentCaseRenameFromPath
);
localMetadataEvidence = await waitForParentCaseMetadata(
context.cliBinary,
session.cliEnv,
initialEntry.id,
parentCaseRenameToPath,
fileContent,
initialEntry.children
);
await pushLocalChanges(context.cliBinary, session.cliEnv);
const remoteMetadata = await waitForParentCaseRemoteMetadata(context, initialEntry);
remoteMetadataEvidence = remoteMetadata;
observedEvents = await takeParentCaseEventEvidence(context.cliBinary, session.cliEnv);
observerInstalled = false;
await stopTrackedSession(context, session);
session = undefined;
session = await startConfiguredSession(context, vaultB, parentCaseOverrides);
await syncAndApply(context, session);
await waitForParentCaseMetadata(
context.cliBinary,
session.cliEnv,
initialEntry.id,
parentCaseRenameToPath,
fileContent,
initialEntry.children,
remoteMetadata.rev ?? undefined
);
await waitForEitherPathContent(vaultB.path, [parentCaseRenameFromPath, parentCaseRenameToPath], fileContent);
await stopTrackedSession(context, session);
session = undefined;
session = await startConfiguredSession(context, vaultA, parentCaseOverrides);
await syncAndApply(context, session);
await waitForEitherPathContent(vaultA.path, [parentCaseRenameFromPath, parentCaseRenameToPath], fileContent);
restartedMetadataEvidence = await waitForParentCaseMetadata(
context.cliBinary,
session.cliEnv,
initialEntry.id,
parentCaseRenameToPath,
fileContent,
initialEntry.children,
remoteMetadata.rev ?? undefined
);
await stopTrackedSession(context, session);
session = undefined;
} catch (error) {
if (session) {
await captureObsidianPage(session.remoteDebuggingPort, "parent-case-deletion-failure.png", async () => {})
.then((path) => console.error(`Parent case failure screenshot: ${path}`))
.catch((captureError: unknown) => {
console.warn(captureError instanceof Error ? captureError.message : captureError);
});
}
throw error;
} finally {
if (observerInstalled && session) {
try {
observedEvents = await takeParentCaseEventEvidence(context.cliBinary, session.cliEnv);
} catch (error) {
console.warn(
`Could not collect parent case rename event evidence: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
try {
if (session) await stopTrackedSession(context, session);
} finally {
console.log(
`Parent case rename evidence: ${JSON.stringify({
obsidianVersion,
events: observedEvents,
localMetadata: localMetadataEvidence ?? null,
remoteMetadata: remoteMetadataEvidence ?? null,
restartedMetadata: restartedMetadataEvidence ?? null,
})}`
);
}
}
console.log("External parent case rename preserved the note Metadata, Chunks, and content.");
}
async function runEncryptedRoundTrip(
context: RunnerContext,
vaultA: TemporaryVault,
@@ -1002,8 +1386,12 @@ async function main(): Promise<void> {
console.log(`Temporary CouchDB database: ${dbName}`);
console.log(`Temporary encrypted CouchDB database: ${encryptedDbName}`);
const onlyParentCaseDeletion = process.env.E2E_OBSIDIAN_ONLY_PARENT_CASE_DELETION === "true";
if (onlyParentCaseDeletion) {
await runParentCaseDeletionProtection(context, vaultA, vaultB);
}
const onlyConflictOperations = process.env.E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS === "true";
if (!onlyConflictOperations) {
if (!onlyParentCaseDeletion && !onlyConflictOperations) {
await runCreateUpdateDelete(context, vaultA, vaultB);
await runRename(context, vaultA, vaultB);
await runCaseOnlyRename(context, vaultA, vaultB);
@@ -1011,10 +1399,13 @@ async function main(): Promise<void> {
await runMarkdownAutoMerge(context, vaultA, vaultB);
}
}
if (onlyConflictOperations || process.env.E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS === "true") {
if (
!onlyParentCaseDeletion &&
(onlyConflictOperations || process.env.E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS === "true")
) {
await runConflictTimeStorageOperations(context, vaultA, vaultB);
}
if (!onlyConflictOperations) {
if (!onlyParentCaseDeletion && !onlyConflictOperations) {
await runTargetMismatch(context, vaultA, vaultB);
await runEncryptedRoundTrip(encryptedContext, encryptedVaultA, encryptedVaultB);
}
+33 -89
View File
@@ -12,22 +12,48 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
## Unreleased
## 1.0.27
7th September, 2026
For now, I am addressing the issues I can resolve first. I hope this helps.
### Synchronisation and storage
#### Fixed
- Conflict resolution dialogues now close when the same file is resolved elsewhere or the plug-in unloads. Requests for different files are shown one at a time, while a newer request for the same file replaces the stale dialogue.
- An individual file-processing failure during ordinary start-up no longer keeps the entire application unready. A start-up notice asks the user to check the affected files and generate a report for details; each path is recorded in verbose logs and remains eligible for retry, while explicit Fetch and Rebuild operations retain strict completion.
- Replication readiness diagnostics now state that application initialisation is incomplete instead of reporting only 'Not ready'. Database-preparation failures show a short notice, with the failed stage available in verbose logs.
- First-time Object Storage setup now completes when **Use Custom HTTP Handler** is enabled for an empty remote, including a new Cloudflare R2 bucket. LiveSync can now create the remote state required to begin synchronisation. (#1166)
## 1.0.26
~~1.0.25~~ was cancelled because pre-release validation found that LiveSync could appear to finish synchronising even though Android had not written a received file to the Vault; the warning appeared only after restart.
6th September, 2026
### Synchronisation and storage
#### Fixed
- Files inside a folder are no longer silently removed from synchronisation when an external tool changes only the letter case of that folder while Obsidian is running. This prevents the stale deletion from reaching other devices or later removing the local file. Moving files into ignored or otherwise excluded locations retains the existing behaviour, and the folder-name case itself may still differ between devices. (#1168)
- A problem processing one file during ordinary start-up no longer prevents every other file from synchronising. LiveSync warns about the affected files and can retry them later; Fetch and Rebuild still stop if they cannot finish safely. (#1164)
- When LiveSync cannot finish preparing this device for synchronisation, it now says that synchronisation is unavailable and directs you to generate a report, instead of remaining at 'Not ready'. (#1164)
#### Improved
- Start-up now keeps unconfigured Vaults on the onboarding path without running configured-only checks or accepting Config Doctor and incomplete-document repair requests. Returning a configured Vault to an unconfigured state also retires those requests for the current plug-in process, so completing setup admits them only after the requested restart.
- The active-file warning now identifies file or folder names longer than 255 UTF-8 bytes as an Android and Linux compatibility risk, without rejecting or changing the path.
- When LiveSync cannot write a received file to the Vault, it now warns immediately instead of appearing to have synchronised it successfully. The generated report identifies the affected path, and a later scan can try it again.
### Testing
### Conflict handling and recovery
- Start-up migrations, integrity checks, Config Doctor, basic commands, and the Obsidian replication ribbon now have focused regression tests for their service composition. Real Obsidian checks cover unconfigured onboarding, configured start-up scanning, Config Doctor detection and layout, command registration, and the established ribbon icon.
#### Improved
- Conflict resolution dialogues now close when the same file is resolved elsewhere or when the plug-in unloads. Requests for different files are shown one at a time, while a newer request for the same file replaces the older one.
### Setup and compatibility
#### Improved
- Unconfigured Vaults now stay focused on setup instead of running Config Doctor or incomplete-document checks before they can be used. Returning a configured Vault to an unconfigured state also stops those checks until the requested restart. (#1161)
- When the active file contains a file or folder name longer than 255 UTF-8 bytes, LiveSync now explains that the path may not work on some Android and Linux file systems. It does not rename or reject the file. (#1164)
## 1.0.24
@@ -98,85 +124,3 @@ I am sorry to make this release while several pull requests are still awaiting m
#### 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.
+3 -1
View File
@@ -36,5 +36,7 @@
"1.0.21": "1.7.2",
"1.0.22": "1.7.2",
"1.0.23": "1.7.2",
"1.0.24": "1.7.2"
"1.0.24": "1.7.2",
"1.0.26": "1.7.2",
"1.0.27": "1.7.2"
}
+2 -6
View File
@@ -89,6 +89,7 @@ export default defineConfig({
treeShaking: true,
}),
svelte({
configFile: false,
preprocess: sveltePreprocess(),
compilerOptions: { css: "injected", preserveComments: false },
}),
@@ -99,12 +100,7 @@ export default defineConfig({
src: path.resolve(__dirname, "./src"),
},
},
esbuild: {
define: define,
target: "es2018",
platform: "browser",
},
// define,
define,
server: {
headers: {
"Service-Worker-Allowed": "/",
+5
View File
@@ -7,6 +7,10 @@ import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "./vitest.config.common";
const importOnlyFiles = ["**/encryption/encryptHKDF.ts"];
const obsidianRuntimeOnlyFiles = [
"src/deps.ts",
"src/serviceModules/FileSystemAdapters/ObsidianTypeGuardAdapter.ts",
];
export default mergeConfig(
viteConfig,
defineConfig({
@@ -32,6 +36,7 @@ export default mergeConfig(
"src/apps/**/testdeno/**",
"**/*_obsolete.ts",
...importOnlyFiles,
...obsidianRuntimeOnlyFiles,
],
provider: "v8",
reporter: ["text", "json", "html", ["text", { file: "coverage-text.txt" }]],