mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-30 23:37:08 +00:00
Compare commits
150
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a23b7a6b0 | ||
|
|
23dd4ab87e | ||
|
|
6364988453 | ||
|
|
76318944a4 | ||
|
|
704e141fd9 | ||
|
|
69cd6250d2 | ||
|
|
72f033fca4 | ||
|
|
a5756503a2 | ||
|
|
7300db08d6 | ||
|
|
db70b4c2b6 | ||
|
|
f7206b1a6e | ||
|
|
fc160ee060 | ||
|
|
22b0cc133a | ||
|
|
948f961caa | ||
|
|
ad83ae858d | ||
|
|
cc000f2cdf | ||
|
|
d24bda88be | ||
|
|
4dcc783a71 | ||
|
|
be03c25904 | ||
|
|
7cf4ec49ed | ||
|
|
8e5b058eef | ||
|
|
56444bb98b | ||
|
|
7aa41baf08 | ||
|
|
011a8405b5 | ||
|
|
f5f7aab11f | ||
|
|
9854319c96 | ||
|
|
4ab0689c7f | ||
|
|
edeac6f7e2 | ||
|
|
783fbb8f23 | ||
|
|
8644af6128 | ||
|
|
4ff5b4dfe8 | ||
|
|
0b1f5ca719 | ||
|
|
9de4f2952d | ||
|
|
330f2acd42 | ||
|
|
1a84b08de5 | ||
|
|
ce7988fe7c | ||
|
|
537e0e42c3 | ||
|
|
a5e1acb960 | ||
|
|
b8191e548d | ||
|
|
1c3feb526c | ||
|
|
aebf4874b3 | ||
|
|
3ea8eac212 | ||
|
|
f6eefed97c | ||
|
|
131ffeb0ef | ||
|
|
070ce0e307 | ||
|
|
8adc88b8cb | ||
|
|
dd11a753d5 | ||
|
|
47759f6205 | ||
|
|
d8f7762d01 | ||
|
|
ebaf89f822 | ||
|
|
06b9f32bdf | ||
|
|
32e827692f | ||
|
|
cdf6935042 | ||
|
|
665a5b5b61 | ||
|
|
83faec9280 | ||
|
|
d0bdae2676 | ||
|
|
cfbf94a38a | ||
|
|
35e170463e | ||
|
|
694371898a | ||
|
|
3f1d4efd86 | ||
|
|
d27a5b3725 | ||
|
|
cbd42bdecb | ||
|
|
e20386b8e2 | ||
|
|
f09b4c779e | ||
|
|
552d286392 | ||
|
|
22ae1b4a6e | ||
|
|
d1ae42a134 | ||
|
|
c7443ee728 | ||
|
|
f8ee3c8662 | ||
|
|
fbe868092a | ||
|
|
693cd77576 | ||
|
|
98ea2b516a | ||
|
|
4d9e24d8ed | ||
|
|
c6a964548a | ||
|
|
d69cff0bc6 | ||
|
|
f02470ec5c | ||
|
|
a0684c4b3a | ||
|
|
dc5274df27 | ||
|
|
a6c93c358e | ||
|
|
d9df2a859f | ||
|
|
b7c2512da6 | ||
|
|
97b0ec25c7 | ||
|
|
bc41355a74 | ||
|
|
3d69142a52 | ||
|
|
2403a66441 | ||
|
|
54e20dede3 | ||
|
|
277091c1e4 | ||
|
|
e294747557 | ||
|
|
3062d45dc5 | ||
|
|
8e14bc35d2 | ||
|
|
a07082d7bc | ||
|
|
0b31b84598 | ||
|
|
3a4f84443c | ||
|
|
a5e7ec6546 | ||
|
|
80fc493d0e | ||
|
|
7cff820aa6 | ||
|
|
5fdac724cf | ||
|
|
74fdec7f96 | ||
|
|
de03534d2f | ||
|
|
16c7cc1b0f | ||
|
|
0aec7b8cca | ||
|
|
040ce87b2f | ||
|
|
a4194a8978 | ||
|
|
1e9ab39adc | ||
|
|
0a1e1f3625 | ||
|
|
b939687cb3 | ||
|
|
df8c79538e | ||
|
|
3973290485 | ||
|
|
510db277cd | ||
|
|
b3bf717947 | ||
|
|
93f0f78494 | ||
|
|
11cb09d49a | ||
|
|
e0160f15f3 | ||
|
|
a3aa79ead9 | ||
|
|
db8dac2db4 | ||
|
|
c933674a0d | ||
|
|
eda78dee36 | ||
|
|
67c8424231 | ||
|
|
1735bc4d66 | ||
|
|
d11a92498d | ||
|
|
950623f5d6 | ||
|
|
bb72b6b317 | ||
|
|
769b7ff4b6 | ||
|
|
1bb50c1580 | ||
|
|
d17330f3c7 | ||
|
|
bd45924649 | ||
|
|
b1ad3e0653 | ||
|
|
6b94f0ce47 | ||
|
|
08b55b7677 | ||
|
|
21d904cfd6 | ||
|
|
00de35e5d4 | ||
|
|
f2976bc89a | ||
|
|
b65deede79 | ||
|
|
9203bdd40e | ||
|
|
fcd30d07be | ||
|
|
cfb75a05db | ||
|
|
5b19f4415d | ||
|
|
047429033f | ||
|
|
9765569bb6 | ||
|
|
c5835a9da6 | ||
|
|
8311060f77 | ||
|
|
13d9624407 | ||
|
|
76560e3bf2 | ||
|
|
1e190d042c | ||
|
|
40215032dd | ||
|
|
fd9a9175dd | ||
|
|
cf5181bb28 | ||
|
|
34ae802796 | ||
|
|
a3a09df3c8 | ||
|
|
4393a49cba |
@@ -8,7 +8,7 @@ assignees: ''
|
||||
---
|
||||
|
||||
Thank you for taking the time to report this issue!
|
||||
Before filling in this form, please read: [How to report an issue](../docs/to_issue_reporting.md).
|
||||
Before filling in this form, please read [How to report an issue](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/to_issue_reporting.md).
|
||||
|
||||
Issues with sufficient information will be prioritised.
|
||||
|
||||
@@ -49,13 +49,14 @@ To get it: open the command palette → "Show debug info".
|
||||
</details>
|
||||
|
||||
### LiveSync version
|
||||
The hatch report (below) includes version information. If you cannot provide the report, please fill in the version here.
|
||||
The full LiveSync report below includes version information. If you cannot provide the report, please fill in the version here.
|
||||
|
||||
- Self-hosted LiveSync version: <!-- e.g. 0.23.0 — find it in Obsidian Settings → Community Plugins -->
|
||||
- Self-hosted LiveSync version: <!-- Find it in Obsidian Settings → Community plugins. -->
|
||||
|
||||
### Report and Logs from LiveSync
|
||||
Perform a `Generate full report for opening the issue with debug info` command and provide the generated report. This contains detailed information and recent 1000 log lines, which is very helpful for debugging. **PLEASE AMEND THE REPORT TO REMOVE ANY SENSITIVE INFORMATION BEFORE PASTING.**
|
||||
If too large to paste here, upload to [Gist](https://gist.github.com/) and share the link.
|
||||
Run `Generate full report for opening the issue with debug info` and provide the generated report. It contains detailed information and up to 1,000 recent log lines. Review the complete output, and remove credentials, private remote details, Vault names, file paths, file contents, and other private information before sharing it.
|
||||
|
||||
If the report is too large to paste here, upload the redacted report to [Gist](https://gist.github.com/) and share the link.
|
||||
|
||||
<details>
|
||||
<summary>Report and Logs (primary)</summary>
|
||||
|
||||
@@ -68,6 +68,13 @@ Each workflow establishes ordinary note synchronisation on the first device, gen
|
||||
> CouchDB can also be run on a Raspberry Pi (please be mindful of your server's security).
|
||||
|
||||
|
||||
### Third-party managed CouchDB hosting
|
||||
|
||||
> [!NOTE]
|
||||
> The following is a third-party hosting option proposed by Zenith Hosting. It is not an official Self-hosted LiveSync service, and it is neither endorsed nor recommended by this project.
|
||||
|
||||
If you would rather not set up and maintain a server yourself, Zenith Hosting offers a managed CouchDB server which this plug-in can be configured to connect to: [Zenith Hosting](https://zenith.hosting/host/obsidian-livesync). As with any hosted service, your data will reside on a server operated by a third party, so please consider whether that is acceptable for your vault before using it.
|
||||
|
||||
## Information in the Status Bar
|
||||
|
||||
Synchronisation status is shown in the status bar with the following icons.
|
||||
|
||||
+2
-1
@@ -33,7 +33,8 @@
|
||||
const id = params.get('id');
|
||||
const total = parseInt(params.get('n') || '0');
|
||||
const index = parseInt(params.get('i') || '-1');
|
||||
const data = params.get('d');
|
||||
// Keep the chunk percent-encoded so URI delimiters remain part of the settings payload.
|
||||
const data = hash.match(/(?:^|&)d=([^&]*)/)?.[1];
|
||||
|
||||
const app = document.getElementById('app');
|
||||
|
||||
|
||||
@@ -27,6 +27,21 @@ npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
#### Community Review dependency installation
|
||||
|
||||
Community Review installs dependencies independently before applying type-aware source rules. A successful installation with the npm version bundled with the repository's current Node.js CI does not prove that the lockfile is accepted by the scanner's npm version.
|
||||
|
||||
After changing `package.json`, a workspace manifest, or `package-lock.json`, verify both installation paths:
|
||||
|
||||
```bash
|
||||
npm ci --ignore-scripts
|
||||
npx --yes npm@10.9.2 ci --ignore-scripts
|
||||
```
|
||||
|
||||
The npm 10.9.2 command is the current project-side compatibility check for the Community Review installation path. Update this check when the scanner runtime changes.
|
||||
|
||||
If Community Review reports widespread TypeScript `error` types across unrelated external packages, confirm that dependency installation completed successfully before changing source imports, declarations, or lint rules. An installation failure can make every unresolved external type appear as downstream unsafe-type findings.
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
@@ -114,34 +129,36 @@ Changes spanning both repositories must first produce a packed Commonlib artefac
|
||||
|
||||
## Architecture
|
||||
|
||||
### Module System
|
||||
### Service composition and legacy modules
|
||||
|
||||
The plugin uses a dynamic module system to reduce coupling and improve maintainability:
|
||||
The application is composed from Services, ServiceModules, serviceFeatures, and a legacy module layer:
|
||||
|
||||
- **Service Hub**: Central registry for services using dependency injection
|
||||
- Services are registered, and accessed via `this.services` (in most modules)
|
||||
- **Module Loading**: All modules extend `AbstractModule` or `AbstractObsidianModule` (which extends `AbstractModule`). These modules are loaded in main.ts and some modules.
|
||||
- **Module Categories** (by directory):
|
||||
- `core/` - Platform-independent core functionality
|
||||
- `coreObsidian/` - Obsidian-specific core (e.g., `ModuleFileAccessObsidian`)
|
||||
- `essential/` - Required modules (e.g., `ModuleMigration`, `ModuleKeyValueDB`)
|
||||
- `features/` - Optional features (e.g., `ModuleLog`, `ModuleObsidianSettings`)
|
||||
- `extras/` - Development/testing tools (e.g., `ModuleDev`, ~~`ModuleIntegratedTest`~~)
|
||||
- **Services**: Core services (e.g., `database`, `replicator`, `storageAccess`) are registered in `ServiceHub` and accessed by modules. They provide an extension point for add new behaviour without modifying existing code.
|
||||
- For example, checks before the replication can be added to the `replication.onBeforeReplicate` handler, and the handlers can be return `false` to prevent replication-starting. `vault.isTargetFile` also can be used to prevent processing specific files.
|
||||
- **ServiceModule**: A new type of module that directly depends on services.
|
||||
- **Service Hub**: the long-lived registry of service contracts. A simple extension, such as a check before replication, belongs in an existing Service handler.
|
||||
- **ServiceModule**: a host-created, long-lived stateful or resource-owning capability shared through the typed `ServiceModules` record. Current examples include storage access, file handling, and database rebuilding.
|
||||
- **serviceFeature**: a typed composition function which accepts only its declared Services and ServiceModules. It registers lifecycle handlers, commands, UI bindings, or other host glue, and may return a focused view. It is not a runtime registry entry.
|
||||
- **AbstractModule** and **AbstractObsidianModule**: the legacy application module layer. Existing modules are loaded by the application and bound after the Service graph has been composed; this broad core access is not the preferred dependency boundary for new orchestration.
|
||||
|
||||
#### Note on Module vs Service
|
||||
The normal composition order is the Service Hub, replicator-provider registration, ServiceModules, serviceFeatures, add-ons, and finally legacy module binding. A serviceFeature may therefore consume an already constructed ServiceModule. Preferring a serviceFeature for new composition is a dependency-boundary rule, not an initialisation-order rule.
|
||||
|
||||
After v0.25.44 refactoring, the Service will henceforth, as a rule, cease to use setHandler, that is to say, simple lazy binding. - They will be implemented directly in the service. - However, not everything will be middlewarised. Modules that maintain state or make decisions based on the results of multiple handlers are permitted.
|
||||
Mutable state is permitted in a serviceFeature. State alone is not a reason to create a class, a ServiceModule, or retain an AbstractModule. Prefer one private context, with module-level functions which receive that context, when identity and polymorphism are not part of the contract. Separate the state, transitions, and invariants from the surrounding function which registers lifecycle handlers and connects downstream effects. Give the stateful boundary narrow collaborators rather than `LiveSyncBaseCore`.
|
||||
|
||||
Hence, the new feature should be implemented as follows:
|
||||
Use a class when stable object identity, replaceable implementations, or an explicit external-resource lifecycle such as serialised ownership, `dispose()`, or `abort()` is part of the contract. Use a ServiceModule when that operational capability or resource lifecycle must also be shared explicitly by several consumers. Do not introduce a class merely to group dependencies or make private functions callable.
|
||||
|
||||
- If it is a simple extension point (e.g., adding a check before replication), it should be implemented as a handler in the service (e.g., `replication.onBeforeReplicate`).
|
||||
- If it requires maintaining state or making decisions based on multiple handlers, it should be implemented as a serviceModule dependent on the relevant services explicitly.
|
||||
- If you have to implement a new feature without much modification, you can extent existing modules, but it is recommended to implement a new module or serviceModule for better maintainability.
|
||||
- Refactoring existing modules to services is also always welcome!
|
||||
- Please write tests for new features, you will notice that the simple handler approach is quite testable.
|
||||
Several narrow views over one lifetime do not require several state owners or a public façade class. One private context may back all of those views, provided that the context remains private and each consumer receives only its declared contract. Keep actual resource owners separate when identity, serialised replacement, abort, retirement, or disposal order is part of their behaviour.
|
||||
|
||||
When a core-owned serviceFeature returns a view needed by one host-specific consumer, pass that view through host composition instead of storing it as a public `LiveSyncBaseCore` property or promoting it to a ServiceModule. The receiving host should inject the view into the narrow command or application context which uses it.
|
||||
|
||||
Commonlib's `targetFilter.ts` and `prepareDatabaseForUse.ts` demonstrate the intended split: focused factories or operations own their private state and behaviour, while the corresponding `use...` function composes dependencies and registers handlers. The P2P composition follows the same direction at a larger scale by separating durable policy and room-session ownership from host lifecycle and UI wiring. Existing modules do not apply this boundary consistently; improve the affected boundary when changing their behaviour rather than performing an unrelated mechanical conversion.
|
||||
|
||||
Use interaction-based, London School unit tests for the composition boundary. Verify collaborator calls, ordering, failure short-circuiting, and handler registration, then test the focused state owner for its transitions and invariants. If a test needs a broad core fixture, a large class mock, deep mock chains, or unrelated Services, treat that friction as a design-review signal and consider a private context with narrower functions before adding more test machinery.
|
||||
|
||||
Legacy modules remain grouped by directory:
|
||||
|
||||
- `core/` contains platform-independent core behaviour;
|
||||
- `coreObsidian/` contains Obsidian-specific core behaviour;
|
||||
- `essential/` contains required modules;
|
||||
- `features/` contains optional features; and
|
||||
- `extras/` contains development and testing tools.
|
||||
|
||||
### Key Architectural Components
|
||||
|
||||
@@ -150,7 +167,7 @@ Hence, the new feature should be implemented as follows:
|
||||
- **Service Hub** (`src/modules/services/`): Central service registry using dependency injection
|
||||
- **Common Library** (`@vrtmrz/livesync-commonlib`): Platform-independent synchronisation logic, shared with the CLI, WebApp, WebPeer, and external tools
|
||||
|
||||
Commonlib owns the P2P replicator and Trystero transport lifecycle. Host commands, event handlers, and views must retain the Commonlib service-feature result and resolve its current `replicator` at the point of use. They must not snapshot an instance which can be replaced when settings or the local database change, close Trystero-owned raw peers, or install another Trystero transport generation at the application root.
|
||||
Commonlib owns one stable `LiveSyncP2PService`, its `P2PRoomSessionOwner`, and the replaceable Trystero room session. Host commands, event handlers, and views consume the focused transport, connection-probe admission, directory, peer-admission, transfer, change-relay, configuration, and diagnostic views returned by the service feature. They must not retain the deprecated compatibility Replicator as an ordinary service locator, close Trystero-owned raw peers, or install another Trystero transport generation at the application root. The exact as-built ownership and shutdown boundaries are recorded in Commonlib's `docs/p2p-transport-lifecycle.md` design document.
|
||||
|
||||
### Conflict Merge Policy
|
||||
|
||||
@@ -230,6 +247,12 @@ export class ModuleExample extends AbstractObsidianModule {
|
||||
|
||||
- Settings are defined by Commonlib (`ObsidianLiveSyncSettings`)
|
||||
- Configuration metadata is supplied by the Commonlib settings exports
|
||||
- Obsidian may request declarative definitions immediately from
|
||||
`Plugin.addSettingTab()`. Register a settings tab which reads persisted values
|
||||
from the sequential `onSettingLoaded` lifecycle, seed its editing snapshot
|
||||
before registration, and keep definition construction independent of local
|
||||
database and replicator readiness. See
|
||||
[the declarative settings adapter ADR](docs/adr/2026_08_declarative_settings_adapter.md).
|
||||
- Use `this.services.setting.saveSettingData()` instead of using plugin methods directly
|
||||
|
||||
### Database Operations
|
||||
@@ -261,6 +284,7 @@ export class ModuleExample extends AbstractObsidianModule {
|
||||
- Avoid listing purely internal refactors, maintenance chores, generated-file changes, and dependency updates unless they affect users; group and label them when they are included.
|
||||
- When preparing a release, replace `## Unreleased` with the target version heading (for example, `## 0.25.81`) and add a fresh empty `## Unreleased` section above it for the next cycle.
|
||||
- Review and polish the released section in the release PR before tagging, because the content is embedded into the plug-in and may be reused as the GitHub Release notes.
|
||||
- Keep approximately the five most recent published plug-in versions in the embedded `updates.md`. Move older published sections unchanged into the appropriate release-line archive under `docs/releases/`, and update the history references when rotating them.
|
||||
|
||||
## Release Workflow
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
A fully self-hosted CouchDB stack for the [obsidian-livesync](https://github.com/vrtmrz/obsidian-livesync) plugin.
|
||||
**No fly.io. No IBM Cloudant. No cloud accounts required for basic use.**
|
||||
|
||||
The optional [Coturn Compose starter](coturn/README.md) is a separate Linux-only service for P2P connectivity. It is not part of the CouchDB stack below.
|
||||
|
||||
> ✅ **Tested on Docker Desktop for Windows (Docker 29.2, Compose v5, WSL2 backend)** — full init, CORS, auth, and idempotent restart verified.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Public DNS name used as the TURN authentication realm.
|
||||
TURN_REALM=
|
||||
|
||||
# Public IPv4 address advertised by Coturn. If Coturn is behind NAT, forward
|
||||
# port 3478 (TCP and UDP) and the UDP relay range to this host.
|
||||
TURN_EXTERNAL_IP=
|
||||
|
||||
# Static long-term credential used by the LiveSync P2P profile. Use a simple
|
||||
# username without a colon and a high-entropy password, such as hexadecimal.
|
||||
TURN_USERNAME=
|
||||
TURN_PASSWORD=
|
||||
@@ -0,0 +1 @@
|
||||
/.env
|
||||
@@ -0,0 +1,81 @@
|
||||
# Coturn starter for LiveSync P2P
|
||||
|
||||
This optional Compose project runs a small, static-credential TURN service for LiveSync P2P. It uses the upstream `coturn/coturn` image directly; the repository does not maintain a separate Coturn Dockerfile.
|
||||
|
||||
The starter is deliberately limited to a Linux server with a public IPv4 address, TURN over UDP and TCP on port 3478, and UDP relay ports 49160–49200. It does not configure TLS, automatic certificate renewal, monitoring, quotas, or a managed credential endpoint.
|
||||
|
||||
## Before starting
|
||||
|
||||
Prepare:
|
||||
|
||||
- a Linux host with Docker Engine and the Compose plug-in;
|
||||
- a public IPv4 address, either on the host or forwarded to it;
|
||||
- a DNS name such as `turn.example.com`;
|
||||
- firewall and NAT rules for TCP and UDP port 3478, and UDP ports 49160–49200; and
|
||||
- enough bandwidth for every relayed P2P transfer.
|
||||
|
||||
Docker host networking is intentional. Coturn's upstream image recommends it because forwarding a large relay port range through Docker performs poorly. This starter therefore does not support Docker Desktop.
|
||||
|
||||
## Configure and start
|
||||
|
||||
From this directory:
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
```
|
||||
|
||||
Set every value in `.env`. Generate a high-entropy password, for example:
|
||||
|
||||
```sh
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Use a simple username without a colon. The static username and password are passed to Coturn as process arguments. They are visible to a local Docker administrator, who already controls the host. The `.env` file is excluded from Git and should remain private. The resolved output of `docker compose config` also contains the credential, so do not publish it.
|
||||
|
||||
Validate the resolved configuration, then start it:
|
||||
|
||||
```sh
|
||||
docker compose config
|
||||
docker compose up -d
|
||||
docker compose logs -f coturn
|
||||
```
|
||||
|
||||
The pinned image version is deliberate. Review the upstream Coturn release notes and update the pin explicitly rather than following `latest` automatically.
|
||||
|
||||
## Configure LiveSync
|
||||
|
||||
Enter both client paths in the P2P profile's TURN server list:
|
||||
|
||||
```text
|
||||
turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?transport=tcp
|
||||
```
|
||||
|
||||
Use `TURN_USERNAME` and `TURN_PASSWORD` as the TURN username and credential. Keep the normal `Automatic` ICE policy unless a future LiveSync release offers `TURN relay only` and the direct path needs to be excluded deliberately.
|
||||
|
||||
Both synchronising devices must be able to reach the server. Prove an explicit two-way `Replicate now` round trip on the intended networks before relying on the configuration.
|
||||
|
||||
The repository check can validate Compose expansion and local TURN allocations. It cannot prove the public firewall, NAT, carrier, or client path for a particular deployment. Validate both UDP and TCP from outside the server network.
|
||||
|
||||
## TLS and port 443 are advanced extensions
|
||||
|
||||
This starter does not recommend putting Coturn on port 443. Coturn cannot bind to the same IP address and TCP port as Caddy or another HTTPS entry point. In particular, it conflicts with the bundled CouchDB Caddy profile when both use the same host address.
|
||||
|
||||
If a restrictive network requires TURN over TLS on port 443, prefer a separate TURN host or a separate public IP address. An outbound tunnel used for CouchDB may also leave the host's public port 443 available for Coturn, provided that TURN uses a separate DNS record which resolves directly to that host. The tunnel itself does not carry TURN traffic.
|
||||
|
||||
A single public IP can technically be shared when one layer-4 TLS router owns port 443 and routes separate CouchDB and TURN hostnames by Server Name Indication (SNI). This adds another certificate and connection-routing boundary, depends on every intended TURN client supplying usable SNI, and is outside this starter. The standard Caddy image used by the bundled CouchDB profile does not provide that layer-4 routing.
|
||||
|
||||
TURN over TLS is not HTTP. An ordinary HTTP reverse proxy or Cloudflare Tunnel route is not a substitute for a TURN listener. Follow Coturn's upstream configuration guidance for `tls-listening-port`, `cert`, and `pkey`, arrange renewal and restart behaviour, and test the resulting `turns:` URL from outside the server network.
|
||||
|
||||
This starter disables Coturn's TLS listener and does not add a TURN-over-DTLS path, so it cannot appear to provide a secure TURN port without those operator-owned prerequisites. This does not disable the end-to-end DTLS encryption used by the WebRTC peer connection carried through TURN.
|
||||
|
||||
## Security and operations
|
||||
|
||||
- Rotate the static credential if the Setup URI, `.env` file, or credential is exposed.
|
||||
- Treat TURN as an internet-facing bandwidth service and monitor traffic and logs.
|
||||
- Add appropriate allocation and bandwidth quotas for a shared or public deployment.
|
||||
- Keep the private-address restrictions unless the TURN server is intentionally permitted to relay to those networks.
|
||||
- Keep independent Vault backups. TURN improves connection reachability; it does not store a backup of Vault data.
|
||||
- A TURN operator can observe endpoint addresses, timing, and traffic volume even though LiveSync content remains end-to-end encrypted.
|
||||
|
||||
The authoritative image and configuration references are the [Coturn Docker image guide](https://github.com/coturn/coturn/blob/master/docker/coturn/README.md) and [Coturn server documentation](https://github.com/coturn/coturn/blob/master/README.turnserver).
|
||||
@@ -0,0 +1,33 @@
|
||||
name: livesync-coturn
|
||||
|
||||
services:
|
||||
coturn:
|
||||
image: coturn/coturn:4.17.2
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
# Compose has already interpolated the environment values. Invoke Coturn
|
||||
# directly so that the image's shell entrypoint does not expand them again.
|
||||
entrypoint:
|
||||
- turnserver
|
||||
command:
|
||||
- "-n"
|
||||
- "--log-file=stdout"
|
||||
- "--pidfile=/tmp/turnserver.pid"
|
||||
- "--listening-ip=0.0.0.0"
|
||||
- "--listening-port=3478"
|
||||
- "--min-port=49160"
|
||||
- "--max-port=49200"
|
||||
- "--external-ip=${TURN_EXTERNAL_IP:?Set TURN_EXTERNAL_IP in docker/coturn/.env}"
|
||||
- "--realm=${TURN_REALM:?Set TURN_REALM in docker/coturn/.env}"
|
||||
- "--user=${TURN_USERNAME:?Set TURN_USERNAME in docker/coturn/.env}:${TURN_PASSWORD:?Set TURN_PASSWORD in docker/coturn/.env}"
|
||||
- "--fingerprint"
|
||||
- "--lt-cred-mech"
|
||||
- "--stale-nonce=600"
|
||||
- "--unauthorized-ratelimit"
|
||||
- "--no-multicast-peers"
|
||||
- "--denied-peer-ip=10.0.0.0-10.255.255.255"
|
||||
- "--denied-peer-ip=100.64.0.0-100.127.255.255"
|
||||
- "--denied-peer-ip=169.254.0.0-169.254.255.255"
|
||||
- "--denied-peer-ip=172.16.0.0-172.31.255.255"
|
||||
- "--denied-peer-ip=192.168.0.0-192.168.255.255"
|
||||
- "--no-tls"
|
||||
@@ -267,7 +267,7 @@ The Community directory scanner preview completes with no source-code errors. Th
|
||||
|
||||
The remaining source warnings belong to application code. Browser dialogue visibility now uses DOM state instead of inline static styling, so the earlier styling warning is absent. Direct diagnostic output was resolved at its existing ownership boundaries: Webapp components use an injected log function backed by `BrowserAPIService`, WebPeer retains output in its Svelte log store, Obsidian modules use the established Logger path, and duplicate console emission was removed from `ModuleLog`. The later Webapp and WebPeer recomposition around maintained Context and serviceFeature APIs should preserve these explicit output paths.
|
||||
|
||||
The final Community lint inventory for this boundary has no errors and 126 warnings: 67 sentence-case findings, 58 deprecated-API findings, and one declarative setting-definition suggestion. The sentence-case strings and deprecated interfaces are retained deliberately to avoid an unrelated localisation and host-lifecycle change. Declarative definitions would migrate the complete Obsidian setting tab into the 1.13 settings-search model; that is a separate visible UI project after LiveSync 1.0, not a hidden package-boundary release gate. Revisit each category through focused UI and compatibility work rather than suppressing the rules or treating the warning count as zero.
|
||||
The final Community lint inventory for this boundary has no errors and 126 warnings: 67 sentence-case findings, 58 deprecated-API findings, and one declarative setting-definition suggestion. The sentence-case strings and deprecated interfaces are retained deliberately to avoid an unrelated localisation and host-lifecycle change. Declarative definitions would migrate the complete Obsidian setting tab into the 1.13 settings-search model; that is a separate visible UI project after LiveSync 1.0, not a hidden package-boundary release gate. Revisit each category through focused UI and compatibility work rather than suppressing the rules or treating the warning count as zero. The declarative-settings suggestion was subsequently addressed by [Adapt Standard Settings to Obsidian's Declarative API](2026_08_declarative_settings_adapter.md); the figures above remain the historical package-boundary inventory.
|
||||
|
||||
WebPeer's production build still reports that Vite externalises the guarded Node `crypto` fallback reached through a compatibility path. Browser execution selects `globalThis.crypto`, and the focused root, `context`, and `browser` bundle checks do not include the Node fallback, so this is not a leak in the reviewed public browser entries. Removing the compatibility-build warning requires a focused crypto-capability contract or a platform-specific implementation split and remains part of compatibility-surface narrowing.
|
||||
|
||||
@@ -275,7 +275,7 @@ The dependency preview also reports `uuid`, but the installed and locked graph r
|
||||
|
||||
The 1.0 dependency review found newly disclosed parser and denial-of-service advisories with compatible fixes. All locked `brace-expansion` generations now use their patched releases, including the production generation reached by Commonlib path matching and the CLI's user-configured ignore patterns. The development-only ESLint and Istanbul `js-yaml` generations likewise use patched releases. The production Markdown parser uses the patched `linkify-it` release to avoid quadratic processing of maliciously structured `mailto:` links, while the development-only JSON Schema toolchain uses the patched `fast-uri` release for unambiguous hostname parsing. A clean install and both complete and production-only `npm audit` checks no longer report these packages.
|
||||
|
||||
The remaining audit report is the existing `werift` and `werift-ice` dependency on `ip`, for which npm offers no patched version. The advisory concerns `ip.isPublic()` misclassifying unusual loopback representations. The locked werift implementation uses `ip` for address encoding, decoding, format detection, and loopback filtering, but does not call `isPublic()` or `isPrivate()`. LiveSync reaches werift only through the Node CLI's injected `RTCPeerConnection`; the Obsidian plug-in and browser applications use their platform WebRTC implementation, and the plug-in artefact does not contain werift. The package-level finding is therefore accepted for the 1.0 integration preview as a non-reachable advisory in the reviewed call path, not as a general waiver. Revisit it when werift or `ip` publishes a replacement, or before any change which delegates address trust, routing, or URL access decisions to that dependency.
|
||||
The initial 1.0 dependency review also found `werift` and `werift-ice` pulling in `ip`, for which npm offers no patched version. The advisory concerns `ip.isPublic()` misclassifying unusual loopback representations. LiveSync reached werift only through the Node CLI's injected `RTCPeerConnection`, and the reviewed call path did not use `isPublic()` or `isPrivate()`. This temporary acceptance ended when werift removed the dependency: the CLI now uses `werift` 0.24.4 or later, the installed production graph contains no `ip` package, and `npm audit --omit=dev` reports no vulnerabilities.
|
||||
|
||||
The local real-Obsidian suite verifies the actual loaded `ObsidianServiceContext`, all 18 services, Vault reflection, CouchDB and Object Storage transfer, remote-activity accounting, CLI-to-Obsidian encrypted synchronisation, startup scanning, two-Vault create, update, delete, ordinary rename, case-only rename, target mismatch, Hidden File Sync, Customisation Sync, setting Markdown export, and two-device CouchDB, Object Storage, and P2P Setup URI workflows. These checks establish observable results and host composition rather than relying on declaration compatibility alone.
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Version 1.0 needs to distinguish supported opt-in features from previews and fro
|
||||
- `xxhash64` is the current hash contract. Other hash algorithms remain available for existing databases and edge-case recovery, not as experimental alternatives for new Vaults.
|
||||
- Eden chunks remain accepted at runtime and in transported settings, but are not offered in the settings interface.
|
||||
- `doNotUseFixedRevisionForChunks` remains an inert compatibility input. Chunk revisions are always content-derived.
|
||||
- The deprecated cleaned-database reconciliation callback remains internal while an old IndexedDB client may still encounter that remote state. It is not a user-selectable maintenance action and is omitted from the settings reference.
|
||||
- The cleaned-database compatibility path remains internal while an old IndexedDB client may still encounter that remote state. It is not a user-selectable maintenance action and is omitted from the settings reference.
|
||||
|
||||
### Already removed
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
# Architectural Decision Record: CouchDB Remote Connection Ownership
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. The first implementation is deliberately limited to the
|
||||
abort-capable CouchDB connectivity preflight used by one-shot replication.
|
||||
|
||||
## Context
|
||||
|
||||
Commonlib opens a remote CouchDB database through `RemoteService.connect()`.
|
||||
Before this decision, a successful call returned only a PouchDB handle and an
|
||||
information snapshot:
|
||||
|
||||
```typescript
|
||||
{
|
||||
db: PouchDB.Database<EntryDoc>;
|
||||
info: PouchDB.Core.DatabaseInfo;
|
||||
}
|
||||
```
|
||||
|
||||
The value did not state who must close the handle or how requests which outlive
|
||||
the current operation are cancelled. Callers consequently accumulated their
|
||||
own `try`/`finally` blocks and closing helpers. Commonlib PR 112 made finite
|
||||
handle clean-up substantially safer, but closing a raw PouchDB handle still did
|
||||
not define ownership of its outstanding HTTP work.
|
||||
|
||||
A remote PouchDB HTTP handle is not a dedicated socket. `db.close()` emits
|
||||
PouchDB's normal `closed` event and closes the logical handle, but the HTTP
|
||||
adapter does not establish that a pending browser fetch or response-body read
|
||||
has stopped. `RemoteService.performFetch()` also records a physical request as
|
||||
complete once response headers have arrived, while PouchDB may still be reading
|
||||
and parsing the body. Neither the request counters nor raw `db.close()` can
|
||||
therefore prove that the transport work has settled.
|
||||
|
||||
One-shot CouchDB replication performs the following work before it creates the
|
||||
replication controller:
|
||||
|
||||
1. prepare the encryption security seed;
|
||||
2. construct the remote PouchDB handle and read database information;
|
||||
3. check and, where required, migrate the remote database version;
|
||||
4. read and update compatibility Metadata, including the milestone document;
|
||||
and
|
||||
5. create the PouchDB replication operation.
|
||||
|
||||
The controller owned by `processSync()` begins only at step 5. A request which
|
||||
never settles during steps 2 to 4 is outside that cancellation scope. The
|
||||
shared one-shot result remains pending as well, so later triggers join the same
|
||||
pending operation rather than beginning a fresh attempt.
|
||||
|
||||
Self-hosted LiveSync issue 1116 provides evidence of this failure shape. On one
|
||||
Linux and Electron combination, a one-shot attempt remained pending while
|
||||
writing the remote milestone document. Bypassing that write allowed the
|
||||
attempt to reach later replication requests. A local real-Obsidian exercise
|
||||
reproduced the preceding Fast Fetch state but not the indefinite write. The
|
||||
evidence does not establish a milestone-specific defect, a CouchDB defect, a
|
||||
browser connection-pool defect, or a lock cycle. It does establish that the
|
||||
connectivity preflight lacks an owner which can terminate its abort-capable
|
||||
transport work.
|
||||
|
||||
No code-level circular wait has been identified. `shareRunningResult()` shares
|
||||
a logical promise, the remote-activity counters observe work, and the global
|
||||
replication concurrency controller is entered only after the preflight. Adding
|
||||
a semaphore or another connection lock would let a stalled request retain the
|
||||
permit; it would not make that request settle.
|
||||
|
||||
## Decision
|
||||
|
||||
### Extend the existing connection result
|
||||
|
||||
Commonlib will define the owned connection as the existing flat result with one
|
||||
additional operation:
|
||||
|
||||
```typescript
|
||||
interface RemoteConnectionOpenOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly allowNativeFallback?: boolean;
|
||||
}
|
||||
|
||||
interface OwnedCouchDBConnection<T extends object> {
|
||||
readonly db: PouchDB.Database<T>;
|
||||
readonly info: PouchDB.Core.DatabaseInfo;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface CouchDBReplicationConnection extends OwnedCouchDBConnection<EntryDoc> {
|
||||
readonly syncOptionBase: PouchDB.Replication.SyncOptions;
|
||||
readonly syncOption: PouchDB.Replication.SyncOptions;
|
||||
}
|
||||
```
|
||||
|
||||
`RemoteService.connect()` remains the entry point. It returns
|
||||
`OwnedCouchDBConnection` directly; there is no nested resource wrapper, separate
|
||||
lease API, public connection signal, or public `abort()` operation. The
|
||||
connection and checked replication types are owned and documented by
|
||||
Commonlib. Compatibility checks enrich the same connection with replication
|
||||
options instead of creating another lifetime object.
|
||||
|
||||
The following properties are part of the contract:
|
||||
|
||||
- `close()` is idempotent;
|
||||
- `close()` first cancels abort-capable requests scoped to the connection, then
|
||||
calls PouchDB's ordinary `db.close()`;
|
||||
- PouchDB retains its normal `closed` event behaviour;
|
||||
- the optional input signal cancels the same internal request scope;
|
||||
- skipping the information request retains the established placeholder in
|
||||
`info` for source and behaviour compatibility;
|
||||
- a close failure is reported diagnostically but does not replace the primary
|
||||
replication result; and
|
||||
- ownership of the connection does not imply ownership of a dedicated physical
|
||||
HTTP socket.
|
||||
|
||||
The public connection does not expose its internal signal because no caller
|
||||
needs to make a second shutdown decision. Owners either cancel through the
|
||||
input signal or finish through `close()`. This leaves one operation responsible
|
||||
for final clean-up.
|
||||
|
||||
Replacing the existing error-string union with a typed connection-failure
|
||||
result remains desirable, but it is outside this change. Mixing that migration
|
||||
into the first implementation would enlarge the consumer and user-message
|
||||
surface without improving cancellation.
|
||||
|
||||
### Bind cancellation at the custom fetch boundary
|
||||
|
||||
`RemoteService.connect()` creates its internal request scope before PouchDB is
|
||||
constructed, so the initial `db.info()` request is covered. The custom PouchDB
|
||||
fetch implementation combines, without replacing, both applicable signals:
|
||||
|
||||
- the signal supplied by PouchDB for the individual request; and
|
||||
- the signal for the owned connection, which follows its optional owner signal.
|
||||
|
||||
The combined signal remains applicable while the response body is consumed.
|
||||
Receiving response headers is not the end of cancellation ownership.
|
||||
|
||||
Cancellation is not a CORS failure. If the connection scope has been aborted,
|
||||
the request must not enter the diagnostic fallback from the web-compatible
|
||||
fetch path to the native request API. A bounded owner can also disable that
|
||||
fallback explicitly.
|
||||
|
||||
The web-compatible fetch path honours `AbortSignal`. Obsidian's current native
|
||||
`requestUrl` adapter does not expose physical cancellation through
|
||||
`RequestInit.signal`. The implementation must not use `Promise.race()` to
|
||||
declare a native remote write cancelled while it may still complete. A bounded
|
||||
guarantee therefore applies only where the selected request path remains
|
||||
abort-capable.
|
||||
|
||||
### Transfer the same connection between owners
|
||||
|
||||
At every point, exactly one operation is responsible for calling `close()`:
|
||||
|
||||
1. `RemoteService.connect()` owns the connection until it returns successfully;
|
||||
2. the connectivity preflight owns it while checking version and compatibility;
|
||||
3. a successful preflight transfers the same connection to the one-shot or
|
||||
continuous replication operation; and
|
||||
4. the final replication owner closes it after replication settles or is
|
||||
terminated.
|
||||
|
||||
A failed factory or preflight closes the connection in its own failure path. A
|
||||
successful transfer clears the former owner's deadline before replication
|
||||
continues. Borrowing `connection.db` does not transfer close responsibility.
|
||||
|
||||
`shareRunningResult()` owns no connection. It may share the result of an
|
||||
operation which owns one, but that operation must settle and close the
|
||||
connection before the shared entry can be released for a later attempt.
|
||||
|
||||
## Limited Introduction
|
||||
|
||||
The first bounded consumer is the CouchDB connectivity preflight reached from
|
||||
one-shot replication. Its boundary includes:
|
||||
|
||||
- PouchDB construction and the initial `db.info()` request;
|
||||
- the database-version check and migration negotiation; and
|
||||
- compatibility and milestone reads and writes before replication starts.
|
||||
|
||||
The preflight receives an internal 60-second wall-clock deadline. This is a
|
||||
last-resort safety fuse for an owner which would otherwise remain pending
|
||||
indefinitely. It is not the expected completion time, a service-level target, a
|
||||
per-request inactivity timeout, a user setting, a limit on replication
|
||||
duration, or the `useTimeouts` changes-feed setting. Tests inject a shorter
|
||||
deadline.
|
||||
|
||||
When that deadline expires on the web-compatible path:
|
||||
|
||||
1. the owner signal aborts the connection's request scope;
|
||||
2. the preflight closes the connection;
|
||||
3. no PouchDB replication operation is created;
|
||||
4. the shared one-shot result settles as failed; and
|
||||
5. a later trigger may create a fresh connection and attempt.
|
||||
|
||||
On success, the deadline is cleared before the connection is transferred to
|
||||
replication, so an old timer cannot interrupt a healthy long-running transfer.
|
||||
|
||||
The explicitly selected native Request API retains its previous unbounded
|
||||
behaviour because its host adapter cannot honour transport cancellation. The
|
||||
security-seed preparation which precedes the shared one-shot operation is also
|
||||
outside this first boundary. Fast Fetch, setup probes, maintenance commands,
|
||||
status inspection, and other direct CouchDB consumers retain their existing
|
||||
deadline and retry policies. They receive the additive `close()` contract but
|
||||
are not silently given this one-shot deadline.
|
||||
|
||||
The first implementation does not add automatic retry. Retrying before the old
|
||||
request is known to be cancelled could duplicate remote writes or consume more
|
||||
connections without changing the failing condition.
|
||||
|
||||
## Ownership
|
||||
|
||||
The legacy `_ensureConnection()` method retains its raw PouchDB return type for
|
||||
source compatibility. New Commonlib paths use an internal owned-connection
|
||||
helper and do not discard the lifecycle object.
|
||||
|
||||
Commonlib owns:
|
||||
|
||||
- `OwnedCouchDBConnection`, `RemoteConnectionOpenOptions`, and
|
||||
`CouchDBReplicationConnection`;
|
||||
- composition of PouchDB request and connection cancellation signals;
|
||||
- the PouchDB custom-fetch integration;
|
||||
- idempotent connection close behaviour;
|
||||
- ownership transfer within the CouchDB replicator; and
|
||||
- timeout classification at the connectivity-preflight boundary.
|
||||
|
||||
Self-hosted LiveSync owns:
|
||||
|
||||
- the concrete Obsidian fetch adapters and their declared capabilities;
|
||||
- user-facing logs or notices for a timed-out attempt;
|
||||
- integration of an immutable Commonlib release; and
|
||||
- real-Obsidian validation of the affected consumer path.
|
||||
|
||||
No host may claim physical cancellation unless its injected fetch
|
||||
implementation honours the supplied signal.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
This decision does not:
|
||||
|
||||
- identify the exact environmental cause reported in issue 1116;
|
||||
- guarantee that a one-shot attempt succeeds;
|
||||
- special-case the milestone document or its URL;
|
||||
- impose a global connection limit or connection semaphore;
|
||||
- add an automatic retry, fallback, or remote reconciliation policy;
|
||||
- apply a deadline to ordinary replication, continuous changes feeds, Fast
|
||||
Fetch, rebuilds, or bulk transfers;
|
||||
- change CouchDB documents, checkpoints, encryption, the security seed, or
|
||||
compatibility Metadata;
|
||||
- make `close()` equivalent to closing a browser socket;
|
||||
- detach a potentially mutating native request and report it as cancelled; or
|
||||
- migrate every direct PouchDB borrower to the one-shot deadline policy.
|
||||
|
||||
## Alternatives Rejected
|
||||
|
||||
### Time out only the milestone write
|
||||
|
||||
The observed write is where one report stopped, not an established ownership
|
||||
boundary. Another environment could stop at `db.info()`, version Metadata,
|
||||
response-body parsing, or an adjacent compatibility request.
|
||||
|
||||
### Race the preflight without cancelling its transport
|
||||
|
||||
This would release `shareRunningResult()` while the old request remained able
|
||||
to complete. It is especially unsafe for a remote `PUT`, because a later
|
||||
attempt could begin after the first had been reported as failed.
|
||||
|
||||
### Add a global semaphore or lower the connection count
|
||||
|
||||
No connection-limit failure has been demonstrated. A stalled owner would
|
||||
retain its permit indefinitely and turn an unexplained request into an explicit
|
||||
queue deadlock.
|
||||
|
||||
### Add a separate lease wrapper
|
||||
|
||||
A nested `{ connection: { db, close }, info }` result would make ownership
|
||||
visible, but it would duplicate the existing connection shape, force callers
|
||||
through another projection, and expose lifetime operations which have no
|
||||
consumer. Adding `close()` to the existing value preserves source compatibility
|
||||
and keeps the PouchDB handle, information snapshot, and lifetime together.
|
||||
|
||||
### Share one global remote PouchDB handle
|
||||
|
||||
A singleton would couple setup, maintenance, one-shot, and continuous
|
||||
lifecycles, make credential and setting changes harder to isolate, and turn one
|
||||
stalled request into a process-wide resource.
|
||||
|
||||
## Verification
|
||||
|
||||
The regression tests were changed before the implementation. Against the old
|
||||
flat result they demonstrated that:
|
||||
|
||||
- an owner signal left an in-flight request pending;
|
||||
- the result had no `close()` operation capable of interrupting a body read;
|
||||
- a bounded web-compatible request could enter the native fallback; and
|
||||
- the one-shot path still depended on the discarded nested connection API.
|
||||
|
||||
After the change, focused Commonlib tests verify that:
|
||||
|
||||
- an owner signal settles a pending request;
|
||||
- `close()` interrupts a response body read before closing PouchDB;
|
||||
- repeated `close()` calls close the handle once;
|
||||
- an aborted or bounded request does not enter the non-abortable native adapter;
|
||||
- deadline expiry closes the same flat connection and releases the shared
|
||||
one-shot attempt;
|
||||
- a later invocation begins after that release;
|
||||
- a successful preflight clears its deadline and transfers ownership; and
|
||||
- close failures are logged without replacing timeout or replication results.
|
||||
|
||||
Type checking and package-boundary checks verify the Commonlib-owned
|
||||
declarations and compatibility export. Self-hosted LiveSync must then validate
|
||||
the exact packed Commonlib artefact with its focused consumer tests and an
|
||||
ordinary real-Obsidian and CouchDB smoke test. The reporter's environment
|
||||
remains the validation boundary for the original platform-specific symptom.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A successful remote connection has one explicit close operation without a
|
||||
parallel lease abstraction.
|
||||
- Existing `{ db, info }` consumers remain source-compatible and may adopt
|
||||
`close()` without changing their projections.
|
||||
- One-shot connectivity can become a bounded failure on an abort-capable
|
||||
transport instead of retaining the shared operation indefinitely.
|
||||
- Native `requestUrl` cancellation remains an acknowledged gap rather than a
|
||||
falsely satisfied contract.
|
||||
- Other finite consumers can adopt owner signals and explicit `close()` one at
|
||||
a time, with tests for their own side effects and retry policies.
|
||||
|
||||
## References
|
||||
|
||||
- [Bounded Remote Activity](2026_07_bounded_remote_activity.md)
|
||||
- [Fast Fetch Persistence and Completion Semantics](2026_08_fast_fetch_persistence_and_completion.md)
|
||||
- Commonlib PR 112, which closes finite remote PouchDB handles after their
|
||||
logical owners settle
|
||||
- Self-hosted LiveSync issue 1116, which reports a one-shot compatibility write
|
||||
remaining pending on one Linux and Electron environment
|
||||
@@ -0,0 +1,731 @@
|
||||
---
|
||||
date: 2026-08-25
|
||||
commonlib-version: "0.1.19"
|
||||
self-hosted-livesync-version: "1.0.20"
|
||||
status: accepted
|
||||
---
|
||||
|
||||
# Architectural Decision Record: Adapt Standard Settings to Obsidian's Declarative API
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and implemented through Stage C1 and two bounded Stage C2
|
||||
landing-page improvements. The shared specification remains deliberately
|
||||
limited to one-key, immediately persisted controls. Complex pages retain their
|
||||
existing renderers instead of being forced through a general abstraction.
|
||||
Settings pending application which require database initialisation now delegate
|
||||
their decision, scheduling, and restart boundary to `SetupManager` and
|
||||
`Rebuilder`. Setting-tab registration and definition construction also follow
|
||||
the persisted-settings lifecycle rather than transient runtime readiness.
|
||||
|
||||
## Context
|
||||
|
||||
Obsidian 1.13 introduced declarative plug-in settings through
|
||||
`PluginSettingTab.getSettingDefinitions()`. Declarative definitions are used for
|
||||
native rendering, validation, navigation, and global settings search. When the
|
||||
method returns a non-empty array, Obsidian does not call the existing
|
||||
`display()` implementation.
|
||||
|
||||
Obsidian may call `getSettingDefinitions()` as soon as a tab is passed to
|
||||
`Plugin.addSettingTab()`. Registering the tab during initialisation therefore
|
||||
allowed definition construction to observe constructor defaults before
|
||||
persisted settings had loaded. The former landing-page predicate also inspected
|
||||
the active replicator, although the local database and replicator are created
|
||||
only after the settings-loaded lifecycle. On start-up this ordering could emit
|
||||
a spurious missing-replicator warning and produce a landing-page order from
|
||||
transient state.
|
||||
|
||||
Self-hosted LiveSync still supports Obsidian versions before 1.13 through its
|
||||
`minAppVersion` of 1.7.2. It must therefore retain an imperative `display()`
|
||||
fallback unless the minimum supported Obsidian version is raised separately.
|
||||
Maintaining an unrelated declarative definition and imperative implementation
|
||||
for each setting would allow the two interfaces to drift.
|
||||
|
||||
The current `LiveSyncSetting` AutoWire implementation combines several
|
||||
responsibilities:
|
||||
|
||||
- Commonlib setting metadata supplies setting names, descriptions, maturity, and
|
||||
configuration level;
|
||||
- pane functions decide page and group membership, control type, options, and
|
||||
conditional visibility;
|
||||
- `LiveSyncSetting` creates and updates Obsidian DOM components;
|
||||
- `ObsidianLiveSyncSettingTab` owns an editing buffer, dirty state, local and
|
||||
persisted settings, and save operations; and
|
||||
- selected controls add staged Apply behaviour, derived values, or effects
|
||||
which run after a successful save.
|
||||
|
||||
These responsibilities are not all declarative setting data. In particular,
|
||||
Remote Configuration, Hatch, Maintenance, Help, and Selector contain dynamic
|
||||
lists, Svelte components, diagnostic results, multi-step actions, and
|
||||
destructive confirmations. Encoding those interactions in a general settings
|
||||
DSL would increase the abstraction before a second renderer had proved which
|
||||
parts are genuinely shared.
|
||||
|
||||
Commonlib's `SettingInformation` setting metadata contains more entries than
|
||||
the current settings interface exposes. Generating definitions from all of it
|
||||
would therefore make compatibility or internal settings searchable merely
|
||||
because they have labels. Page membership must remain an explicit LiveSync
|
||||
decision.
|
||||
|
||||
The existing legacy settings wizard also changes DOM classes and selects panes
|
||||
through `enableMinimalSetup()`. The maintained onboarding path now uses
|
||||
`SetupManager`. The current call graph has no caller for
|
||||
`askAgainForSetupURI()`: it is the only emitter of
|
||||
`EVENT_REQUEST_OPEN_SETTING_WIZARD`, and that event's only handler calls
|
||||
`enableMinimalSetup()`. The old route is therefore obsolete rather than a
|
||||
second onboarding interface which the declarative renderer must preserve.
|
||||
Historically, it was the second prompt after a user reported having no Setup
|
||||
URI. It offered the in-settings wizard, P2P setup, manual settings, or a reminder
|
||||
at the next launch, then stopped initialisation while the selected interface
|
||||
took over. `SetupManager` now owns that decision and continuation.
|
||||
|
||||
## Decision
|
||||
|
||||
### Retire the obsolete in-settings wizard first
|
||||
|
||||
The old in-settings wizard will be removed as a focused prerequisite. This is
|
||||
cleanup of an unreachable interface, not part of the declarative settings
|
||||
model. The cleanup will remove:
|
||||
|
||||
- `askAgainForSetupURI()`, `EVENT_REQUEST_OPEN_SETTING_WIZARD`, its handler, and
|
||||
`enableMinimalSetup()`;
|
||||
- the `inWizard` completion branch in Sync Settings;
|
||||
- the `isWizard`, `wizardHidden`, and `wizardOnly` styling contract;
|
||||
- the General-page `Next` control and the already commented Remote
|
||||
Configuration `Next` control;
|
||||
- the now-unnecessary `wizardHidden` argument on the old pane builder; and
|
||||
- message keys whose final production consumer is the removed route, followed
|
||||
by the normal catalogue regeneration.
|
||||
|
||||
This cleanup does not affect `SetupManager`, Setup URI onboarding, QR-code
|
||||
navigation, document-history navigation, or any other control which happens to
|
||||
use the word 'Next'. The existing onboarding and ordinary settings E2E paths
|
||||
must pass before the declarative work begins.
|
||||
|
||||
The English quick-setup documentation already describes the maintained
|
||||
onboarding. Older localised quick-setup pages which still describe the removed
|
||||
interface are documentation maintenance rather than a prerequisite for this
|
||||
runtime cleanup.
|
||||
|
||||
### Use one explicit page catalogue
|
||||
|
||||
LiveSync will define one ordered page catalogue. It will be the sole source for
|
||||
page identity, name, configuration level, visibility, and content ownership.
|
||||
Each entry keeps the existing pane renderer for the legacy path and selects one
|
||||
of two native content forms:
|
||||
|
||||
```typescript
|
||||
type SettingsPageEntry = {
|
||||
id: string;
|
||||
name: () => string;
|
||||
icon: string;
|
||||
order: number;
|
||||
level?: ConfigLevel;
|
||||
content: "native" | "custom";
|
||||
legacy: PaneRenderer;
|
||||
};
|
||||
```
|
||||
|
||||
In Stage C1, `native` identifies the Advanced proof page, whose definitions are
|
||||
supplied by the adapter, and `custom` selects the shared lazy custom-page
|
||||
factory. The catalogue will gain a per-page native factory only when a second
|
||||
native page requires one; Stage C1 does not introduce that abstraction in
|
||||
advance.
|
||||
|
||||
A native `items` page may mix groups of `SettingSpec` controls with Obsidian's
|
||||
direct action, render, list, and nested-page definitions. A native custom
|
||||
`SettingPage` is the final escape hatch when the page cannot yet be divided
|
||||
safely. The imperative and declarative interfaces consume the same catalogue:
|
||||
|
||||
| Catalogue content | Obsidian before 1.13 | Obsidian 1.13 and later |
|
||||
| ------------------------------------- | -------------------------------- | ------------------------------------------ |
|
||||
| Standard `SettingSpec` | Render through `LiveSyncSetting` | Convert to a control definition |
|
||||
| Native group, action, or rendered row | Use the existing pane renderer | Use `SettingDefinitionPage.items` |
|
||||
| Full custom page | Use the existing pane renderer | Open a lazily created custom `SettingPage` |
|
||||
|
||||
This makes page names and visibility consistent without requiring every page
|
||||
to migrate at once. Page names must be unique because Obsidian uses them for
|
||||
nested navigation.
|
||||
|
||||
`SettingDefinitionPage` does not expose a separate icon field. The declarative
|
||||
renderer therefore prefixes each native page name with the emoji already held
|
||||
by the catalogue, while the imperative renderer continues to pass the same
|
||||
emoji to its existing menu button. This preserves the established visual
|
||||
identity without adding host-DOM manipulation.
|
||||
|
||||
`SettingDefinitionGroup` likewise exposes only a string heading. Root groups
|
||||
therefore have semantic identifiers whose catalogue entries keep their emoji
|
||||
and late-translated names separate. The adapter combines those fields only
|
||||
when constructing the Obsidian definition, so callers select a group by its
|
||||
identifier instead of repeating presentation strings.
|
||||
|
||||
### Compose the native landing page around common tasks
|
||||
|
||||
The declarative root is a composition of native groups and catalogue pages,
|
||||
not a second flat copy of the legacy tab menu. General Settings contains the
|
||||
native Appearance, Logging, and Extra menus child pages. Their standard
|
||||
`SettingSpec` controls remain searchable without crowding the root. The small
|
||||
Quick Setup actions are native action rows on the root. The old Setup child
|
||||
page is not retained: its feature-level controls move to Extra menus, its full
|
||||
reset moves to Maintenance, and its online guidance becomes Help and
|
||||
troubleshooting. The pane-based interface exposes Quick Setup as a pane and
|
||||
renders the same controls within General Settings.
|
||||
|
||||
Remote Configuration and Sync Settings remain catalogue pages. Obsidian's
|
||||
native group contract permits navigable pages as group items, so both pages are
|
||||
placed inside an explicit Synchronisation group. This keeps them near the top
|
||||
for narrow mobile displays while preventing the unheaded page entries from
|
||||
appearing to continue the preceding Quick Setup group. The root order reflects
|
||||
the current task:
|
||||
|
||||
| Configuration state | First root sections |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| Unconfigured | Quick Setup, Synchronisation (Remote Configuration and Sync Settings), then General |
|
||||
| Configured | Synchronisation (Remote Configuration and Sync Settings), General, Set up other devices, then Quick Setup |
|
||||
|
||||
Set up other devices is hidden until the plug-in is configured. The remaining
|
||||
destinations are grouped explicitly:
|
||||
|
||||
| Group | Pages |
|
||||
| ------------------------ | ---------------------------------------- |
|
||||
| Maintenance and recovery | Maintenance and Hatch |
|
||||
| Extra features | Selector and Customisation sync |
|
||||
| Advanced settings | Advanced, Power users, and Patches |
|
||||
| Help and information | Help and troubleshooting, and Change Log |
|
||||
|
||||
This prevents Obsidian from presenting them as one undifferentiated 'Detailed
|
||||
settings' continuation. Changing a setting which can move or reveal a page
|
||||
requests a catalogue refresh after persistence. External setting reloads use
|
||||
the same boundary. Constructing the definitions still performs no persistence,
|
||||
service, file, database, or network operation.
|
||||
|
||||
The imperative renderer uses the same stable distinction for its default-page
|
||||
selection: Quick Setup for an unconfigured installation and General for a
|
||||
configured installation. The landing composition is therefore a native 1.13
|
||||
improvement rather than a separate interpretation of synchronisation state on
|
||||
earlier supported Obsidian versions.
|
||||
|
||||
The custom `SettingPage` adapter class will be constructed lazily from the
|
||||
1.13-or-later path. `SettingPage` may remain a normal runtime import because the
|
||||
bundle reads Obsidian exports through its namespace object, but the import must
|
||||
not be subclassed or instantiated while the module is loading. The factory
|
||||
will first use `requireApiVersion("1.13.0")`, then verify that `SettingPage` is
|
||||
available. Older supported Obsidian versions therefore continue to call the
|
||||
imperative `display()` fallback without requiring a dynamic import or a
|
||||
polyfill for host behaviour which does not exist in those versions.
|
||||
|
||||
The adapter sets `title` from the catalogue and renders pane content into the
|
||||
host-provided `containerEl`. Its `hide()` boundary will unload the page-owned
|
||||
`Component`, unmount Svelte and markdown content, and remove page-owned update
|
||||
handlers. The parent tab's `hide()` remains a final cleanup boundary because
|
||||
Obsidian does not guarantee a page-level `hide()` call when the host window is
|
||||
destroyed.
|
||||
|
||||
Custom pages receive only the current page's `containerEl` and the existing
|
||||
`addPanel` helper. They do not recreate the old top-level tab menu inside each
|
||||
native page.
|
||||
|
||||
### Prefer native groups and searchable rows to full custom pages
|
||||
|
||||
An existing `addPanel` section maps naturally to a
|
||||
`SettingDefinitionGroup`. Within that group:
|
||||
|
||||
- ordinary value controls use `SettingSpec`;
|
||||
- a simple button operation may use `SettingDefinitionAction` directly;
|
||||
- a Svelte control or a specialised Obsidian row uses
|
||||
`SettingDefinitionRender` and returns its cleanup callback; and
|
||||
- a truly dynamic collection may use `SettingDefinitionList` when its existing
|
||||
behaviour already matches the list contract.
|
||||
|
||||
These Obsidian-specific definitions are written directly in the page adapter.
|
||||
They are not added to the shared `SettingSpec` vocabulary. This keeps the shared
|
||||
model small while allowing page, panel, and row names, descriptions, and aliases
|
||||
to participate in settings search.
|
||||
|
||||
Search indexes the metadata on a definition; it does not infer searchable
|
||||
entries from arbitrary DOM created inside a `render` callback. A whole panel
|
||||
wrapped in one rendered row therefore provides panel-level search only.
|
||||
Individual controls or actions require individual standard, action, or render
|
||||
definitions when control-level search is worthwhile.
|
||||
|
||||
A full custom `SettingPage` remains acceptable for a workflow which cannot yet
|
||||
be split without nesting several existing setting rows inside one synthetic
|
||||
row. It is a compatibility escape hatch, not the default representation for
|
||||
every complex pane.
|
||||
|
||||
### Keep `SettingSpec` intentionally small
|
||||
|
||||
`SettingSpec` describes only controls which have all of the following
|
||||
properties:
|
||||
|
||||
- one explicit persisted setting key, excluding keys from `OnDialogSettings`;
|
||||
- one standard toggle, number, or dropdown control in the first proof page;
|
||||
- a value which is read from the current editing buffer;
|
||||
- a change which can be persisted immediately through the existing
|
||||
`saveSettings([key])` path; and
|
||||
- no additional operation which must run after saving.
|
||||
|
||||
A representative, key-safe shape is:
|
||||
|
||||
```typescript
|
||||
type PersistedBooleanSettingKey = Exclude<AllBooleanItemKey, keyof OnDialogSettings>;
|
||||
type PersistedStringSettingKey = Exclude<AllStringItemKey, keyof OnDialogSettings>;
|
||||
type PersistedNumericSettingKey = Exclude<AllNumericItemKey, keyof OnDialogSettings>;
|
||||
type PersistedSettingKey = PersistedBooleanSettingKey | PersistedStringSettingKey | PersistedNumericSettingKey;
|
||||
|
||||
type SettingSpecBase<K extends PersistedSettingKey, C> = {
|
||||
key: K;
|
||||
control: C;
|
||||
visible?: () => boolean;
|
||||
disabled?: () => boolean;
|
||||
aliases?: string[];
|
||||
};
|
||||
|
||||
type SettingSpec =
|
||||
| SettingSpecBase<PersistedBooleanSettingKey, { type: "toggle"; defaultValue?: boolean }>
|
||||
| SettingSpecBase<
|
||||
PersistedNumericSettingKey,
|
||||
{
|
||||
type: "number";
|
||||
min?: number;
|
||||
max?: number;
|
||||
allowZero?: boolean;
|
||||
}
|
||||
>
|
||||
| SettingSpecBase<
|
||||
PersistedStringSettingKey,
|
||||
{
|
||||
type: "dropdown";
|
||||
options: () => Record<string, string>;
|
||||
}
|
||||
>;
|
||||
```
|
||||
|
||||
The initial union contains only the three control types used by the Advanced
|
||||
proof page. Text and textarea controls will be added when a migrated page
|
||||
provides a concrete use for them. Number validation is derived from `min`,
|
||||
`max`, and `allowZero`, so the native and imperative renderers enforce the same
|
||||
constraints without introducing an arbitrary validation language.
|
||||
|
||||
Names, descriptions, maturity labels, and placeholders come from the translated
|
||||
Commonlib setting metadata by default. The native renderer appends the existing
|
||||
maturity marker to `name`, and maps the description and supported placeholder
|
||||
directly. Configuration level remains a page and renderer concern: the legacy
|
||||
renderer retains its existing DOM classes, while the native page catalogue owns
|
||||
page-level visibility. A mixed-level native group must provide an explicit
|
||||
visibility predicate at that boundary rather than inferring one in the pure
|
||||
control converter. The specification may override a label only where the
|
||||
current interface already uses a deliberate product-specific label. Options
|
||||
remain LiveSync owned because they can depend on the active remote, platform,
|
||||
or language. A control which needs the current obsolete-row styling remains
|
||||
custom because the native definition does not provide an equivalent per-row
|
||||
class contract.
|
||||
|
||||
The catalogue explicitly lists each exposed key. It does not enumerate
|
||||
`SettingInformation` automatically.
|
||||
|
||||
The following behaviours are outside the standard specification and remain a
|
||||
custom row or custom page:
|
||||
|
||||
- `holdValue` and Apply buttons;
|
||||
- `invert` bindings;
|
||||
- password inputs;
|
||||
- a control which maps one displayed value to several stored keys, such as
|
||||
`syncMode`;
|
||||
- a control whose save effect cannot remain an explicit tab-owned handler;
|
||||
- button clusters, dynamic lists, Svelte components, rich diagnostic output,
|
||||
or destructive actions; and
|
||||
- styling which exists only to support the old wizard or tab menu.
|
||||
|
||||
This is a migration boundary, not a permanent prohibition. A second concrete
|
||||
use may justify a focused extension, but the first implementation will not add
|
||||
a generic action language, transaction language, or lifecycle hook system.
|
||||
|
||||
### Retain the existing editing and persistence owner
|
||||
|
||||
`ObsidianLiveSyncSettingTab` remains the owner of editing values and saves. The
|
||||
first implementation will expose a small adapter over its existing methods
|
||||
rather than move settings persistence into a new service.
|
||||
|
||||
For declarative controls:
|
||||
|
||||
- `getControlValue(key)` reads `editingSettings[key]`;
|
||||
- `setControlValue(key, value)` resolves an explicitly registered standard
|
||||
specification, updates the editing value, and calls `saveSettings([key])`;
|
||||
- successful saves continue to pass through `saveLocalSetting()` or
|
||||
`services.setting.saveSettingData()` as appropriate; and
|
||||
- the tab calls `refreshDomState()` after a value changes when another
|
||||
definition's `visible` or `disabled` predicate can depend on it.
|
||||
|
||||
An unknown key is an implementation error. The adapter must not fall through
|
||||
to `plugin.settings`, because LiveSync does not use that conventional storage
|
||||
shape.
|
||||
|
||||
Specification construction and `getSettingDefinitions()` must remain cheap
|
||||
and side-effect free. Obsidian calls the method during search indexing and
|
||||
again on updates; it must perform no file, database, network, or settings
|
||||
write.
|
||||
|
||||
### Register the setting tab after persisted settings load
|
||||
|
||||
The settings module registers its `PluginSettingTab` from the sequential
|
||||
`onSettingLoaded` lifecycle, not from `onInitialise`. Immediately before
|
||||
registration, it seeds the tab's editing and initial snapshots through
|
||||
`reloadAllSettings(true)`. Skipping the update request is intentional because
|
||||
the tab is not yet owned by Obsidian; `addSettingTab()` may request definitions
|
||||
immediately after this seeding step.
|
||||
|
||||
This lifecycle still precedes local database opening and replicator activation.
|
||||
Definition construction must therefore depend only on the seeded setting
|
||||
snapshot, static catalogue data, and translations. In particular, root-page
|
||||
ordering is based on the persisted `isConfigured` value. It must not inspect
|
||||
automatic synchronisation triggers, the active replicator, replication status,
|
||||
database readiness, files, or the network. Runtime operations remain explicit
|
||||
actions which run after the user selects them.
|
||||
|
||||
### Give imperative pages an explicit lifetime and refresh boundary
|
||||
|
||||
The present `display()` renders every pane together, so arrays of
|
||||
`settingComponents`, controlled DOM updates, and `onSavedHandlers` can be
|
||||
cleared and rebuilt as one unit. Native page navigation mounts one custom page
|
||||
at a time. Reusing those arrays without a page boundary would leak updates from
|
||||
a hidden page or remove effects which a staged edit still needs.
|
||||
|
||||
Each imperative render will therefore receive a small page scope containing:
|
||||
|
||||
- its `Component` lifetime;
|
||||
- its `LiveSyncSetting` instances;
|
||||
- its controlled DOM update functions; and
|
||||
- its explicit cleanup callbacks for Svelte, markdown, and other mounted
|
||||
content.
|
||||
|
||||
The legacy `display()` fallback uses one scope for the complete old tab. A
|
||||
custom declarative page creates one scope when opened and disposes it when
|
||||
hidden. This scope is renderer state and is not part of `SettingSpec`.
|
||||
Pane-construction callbacks which are queued by the existing helpers run only
|
||||
whilst the scope which requested them remains current. Closing or replacing a
|
||||
page therefore cannot attach delayed controls or cleanup callbacks to its
|
||||
successor.
|
||||
|
||||
Saved-setting effects remain owned by the tab session, not by a DOM page. The
|
||||
existing handlers are unique by setting key, so `addOnSaved()` will replace the
|
||||
handler for that key instead of appending duplicate closures when a page is
|
||||
reopened. A later migration may declare those effects in a separate catalogue,
|
||||
but they will not be added to the standard control specification merely to
|
||||
support page navigation.
|
||||
|
||||
Direct calls to `this.display()` from pane code and the tab's own reload path
|
||||
will be replaced by an explicit refresh request with one of two scopes:
|
||||
|
||||
- `page` re-renders the active custom page, or the legacy tab; and
|
||||
- `catalogue` calls the declarative tab's `update()` so translated page names,
|
||||
page visibility, and search definitions are rebuilt, or re-renders the
|
||||
legacy tab.
|
||||
|
||||
Changing the display language or the Advanced, Power User, or Edge Case mode
|
||||
uses a catalogue refresh. Dynamic Selector rows, Maintenance status, and
|
||||
Hidden File Sync status use a page refresh. This keeps the renderer choice out
|
||||
of pane actions and prevents a direct `display()` call from replacing native
|
||||
declarative navigation.
|
||||
|
||||
### Preserve imperative rendering as a renderer
|
||||
|
||||
The existing AutoWire calls are not the shared model. Instead,
|
||||
`LiveSyncSetting` becomes the legacy renderer for `SettingSpec` where a pane
|
||||
has migrated. It continues to own DOM classes, dirty-value decoration, and
|
||||
component updates for older Obsidian versions.
|
||||
|
||||
Unmigrated pane functions continue to call `LiveSyncSetting` directly inside a
|
||||
custom page. This allows incremental migration without first rewriting their
|
||||
behaviour.
|
||||
|
||||
The initial implementation must not modify Commonlib's setting metadata.
|
||||
Commonlib owns setting identity and shared labels; LiveSync owns page placement,
|
||||
Obsidian controls, persistence routing, and side effects.
|
||||
|
||||
### Use Advanced as the first proof page
|
||||
|
||||
The Advanced page is the first page whose groups contain only standard
|
||||
`SettingSpec` controls. It provides a useful proof without introducing
|
||||
unrelated workflows:
|
||||
|
||||
- number, dropdown, and toggle controls;
|
||||
- translated Commonlib labels;
|
||||
- minimum-value validation and a default value;
|
||||
- CouchDB-dependent visibility; and
|
||||
- configuration-level page visibility.
|
||||
|
||||
It has no current `onSaved` handler, Svelte component, staged Apply group, or
|
||||
destructive action. General was not the first proof because changing the
|
||||
display language re-renders the interface and other controls emit status events
|
||||
after saving. After the standard binding was proven, these effects remained
|
||||
explicit, tab-owned saved handlers while their one-key controls adopted
|
||||
`SettingSpec`.
|
||||
|
||||
The first native activation does not also divide other pages into searchable
|
||||
rows. It exposes their established pane renderers as custom pages, limited to
|
||||
page-level search. A later, optional migration can replace an individual custom
|
||||
page with standard, action, or rendered rows without changing the page
|
||||
catalogue.
|
||||
|
||||
## Implementation Stages and Checkpoint
|
||||
|
||||
### Stage A: remove the old wizard
|
||||
|
||||
Complete the focused prerequisite described above. This removes a DOM contract
|
||||
which would otherwise distort both renderers.
|
||||
|
||||
### Stage B: prove the shared standard-control model
|
||||
|
||||
The first declarative-settings change remains deliberately small. It will:
|
||||
|
||||
1. add the minimal `SettingSpec` type and pure conversion functions;
|
||||
2. describe only the Advanced controls as specifications;
|
||||
3. render those specifications through the existing `LiveSyncSetting` path;
|
||||
4. prove conversion to Obsidian setting definitions with focused tests; and
|
||||
5. retain the current `display()` behaviour, page menu, persistence owner, and
|
||||
`minAppVersion`, without returning non-empty setting definitions.
|
||||
|
||||
Stage B does not enable the native declarative renderer. It proves that the
|
||||
shared model can express a real page without first taking ownership of every
|
||||
page's lifetime. Returning an empty definition array merely to silence review
|
||||
output is not an outcome of this stage.
|
||||
|
||||
### Stage C1: activate the native catalogue
|
||||
|
||||
Activation is a separate checkpoint because it is the first cross-cutting
|
||||
change. It will add the page catalogue, custom `SettingPage` adapter, scoped
|
||||
imperative lifetime, renderer-neutral refresh operation, declarative control
|
||||
read and write overrides, and non-empty definitions on Obsidian 1.13 or later.
|
||||
|
||||
The non-empty definition array replaces `display()` completely. Partial
|
||||
activation is therefore not safe: all 12 existing pages must enter the native
|
||||
catalogue together. Advanced is the only page represented by native groups in
|
||||
this stage. The other 11 pages use their existing pane renderers inside lazy
|
||||
custom pages. Obsidian versions before 1.13 retain the complete imperative
|
||||
renderer and its menu.
|
||||
|
||||
The existing rebuild-required action remains available while navigating native
|
||||
pages. Custom pages render the established action at their page boundary, and
|
||||
the Advanced definition includes an equivalent action item whose visibility is
|
||||
derived from the same dirty-state predicate. Both forms call the existing
|
||||
`confirmRebuild()` owner rather than introducing another apply workflow.
|
||||
|
||||
This stage necessarily touches direct `display()` callers, saved-handler
|
||||
ownership, and cleanup for Svelte and markdown content. It does not expand
|
||||
`SettingSpec` to absorb those concerns merely to make activation appear
|
||||
smaller.
|
||||
|
||||
### Stage C2: improve search coverage selectively
|
||||
|
||||
After activation, an individual custom page may be replaced with native groups,
|
||||
actions, and rendered rows where the existing panel boundary maps cleanly to
|
||||
Obsidian's definitions. The bounded improvement converts the General and
|
||||
Logging controls and the simple Quick Setup actions, then organises Appearance,
|
||||
Logging, and Extra menus as child pages of General Settings. It also removes
|
||||
the now-misleading Setup child page and assigns its remaining responsibilities
|
||||
to their existing owners: Extra menus, Maintenance, and Help and
|
||||
troubleshooting. Further conversions remain optional follow-up work rather than
|
||||
a condition of Stage C1. Complex workflows may remain custom pages indefinitely.
|
||||
|
||||
Stage C2 must not introduce a general action or lifecycle language. Each page
|
||||
conversion should be justified by useful settings-search coverage and retain
|
||||
the catalogue, persistence owner, and refresh boundaries established by Stage
|
||||
C1.
|
||||
|
||||
### Centralise initialisation for settings pending application
|
||||
|
||||
Settings which cannot take effect safely through immediate persistence remain
|
||||
in the settings tab's editing buffer. Their Apply action delegates to a focused
|
||||
`SetupManager` dialogue which asks whether the next start should use existing
|
||||
synchronisation data or the files in the current Vault. `SetupManager` reports
|
||||
user cancellation and validation or reservation failure distinctly instead of
|
||||
collapsing them into a boolean setup outcome. A settings-persistence exception
|
||||
still propagates after `Rebuilder` removes the reserved flag.
|
||||
|
||||
`Rebuilder.scheduleFetch()` and `Rebuilder.scheduleRebuild()` are the only
|
||||
owners of the corresponding flag files. They reserve the next-start operation
|
||||
before the callback persists the edited settings, remove the flag if that
|
||||
callback fails, and request the restart only after preparation succeeds. The
|
||||
settings tab must not write those flag files or request the restart directly.
|
||||
|
||||
A user cancellation returns to a separate confirmation which offers either to
|
||||
keep editing or to apply the settings without initialisation. This preserves
|
||||
the former advanced fallback without presenting it as an equal data-source
|
||||
choice. A validation or flag-reservation failure is not a cancellation and
|
||||
must not offer that bypass. The pending action is present on the native root
|
||||
settings page as well as within custom and native child pages.
|
||||
|
||||
## Verification
|
||||
|
||||
Stage A runs the maintained onboarding E2E scenario and an ordinary settings
|
||||
navigation scenario. A source check confirms that no old wizard event, state,
|
||||
class, or message consumer remains.
|
||||
|
||||
Stage B focused unit tests verify:
|
||||
|
||||
- only explicitly listed Advanced controls become specifications;
|
||||
- synthetic `OnDialogSettings` keys cannot be standard specifications;
|
||||
- control type, options, defaults, validation, metadata, and visibility map
|
||||
consistently to the legacy and native representations; and
|
||||
- rendering the Advanced specifications through `LiveSyncSetting` preserves
|
||||
the current save behaviour.
|
||||
|
||||
Stage C1 and the landing-page focused unit tests verify:
|
||||
|
||||
- the page catalogue contains all 13 pane-based destinations with stable, unique
|
||||
identifiers and names;
|
||||
- Appearance, Logging, Extra menus, and Advanced are native-items child pages,
|
||||
and ten child pages retain custom factories;
|
||||
- configured and unconfigured installations use their specified landing-page
|
||||
order regardless of transient replication status;
|
||||
- definition construction does not request the active replicator before the
|
||||
database is ready;
|
||||
- the settings tab is registered only after persisted settings load, and its
|
||||
editing snapshot is seeded before registration without requesting a render;
|
||||
- Remote Configuration and Sync Settings remain native navigable pages inside
|
||||
the separate Synchronisation group;
|
||||
- maintenance, extra features, advanced settings, and help have explicit page
|
||||
groups, and the old Setup child page is absent;
|
||||
- all eight General and Logging controls are registered once, with their
|
||||
existing conditional visibility;
|
||||
- the three Extra menus controls are registered once and refresh page
|
||||
visibility after persistence;
|
||||
- every standard setting key is registered once;
|
||||
- a setting pending application exposes its Apply action on the native root
|
||||
page;
|
||||
- cancelling the initialisation choice preserves the editing buffer, while the
|
||||
separately confirmed settings-only path persists it;
|
||||
- Fetch and Rebuild reserve their flag through `Rebuilder` before pending
|
||||
settings are persisted, and a reservation failure leaves them unapplied;
|
||||
- reads use the editing buffer;
|
||||
- writes use `saveSettings([key])` and never `plugin.settings`;
|
||||
- custom pages dispose their page-owned resources and do not duplicate saved
|
||||
handlers when reopened; and
|
||||
- importing and opening the imperative fallback does not evaluate or require
|
||||
`SettingPage` on Obsidian before 1.13.
|
||||
|
||||
Real-Obsidian verification on 1.13 or later confirms:
|
||||
|
||||
- the common landing controls and actions render before native page navigation;
|
||||
- the Quick Setup action opens the maintained onboarding dialogue;
|
||||
- Remote Configuration remains visible without initial scrolling in mobile
|
||||
test mode;
|
||||
- native page navigation opens every remaining child page;
|
||||
- Advanced controls appear in global settings search;
|
||||
- Advanced values persist and are restored after reopening settings;
|
||||
- CouchDB-dependent controls and Advanced-mode visibility update correctly;
|
||||
- a representative custom page, including its cleanup, still works;
|
||||
- page and catalogue refreshes preserve native navigation and the
|
||||
rebuild-required action; and
|
||||
- no duplicate save, update handler, or saved-setting effect occurs after
|
||||
leaving and reopening a page.
|
||||
|
||||
The maintained real-Obsidian settings scenario also changes a setting which
|
||||
remains pending until initialisation, captures the source-choice and
|
||||
settings-only fallback dialogues, and confirms that keeping the setting
|
||||
pending does not persist it. It mounts the P2P variant directly to confirm that
|
||||
it offers a source device and local Vault preparation without presenting a
|
||||
central-server overwrite operation.
|
||||
|
||||
Because the manifest continues to support earlier Obsidian versions, a
|
||||
pre-1.13 real-runtime smoke test must confirm that the imperative fallback
|
||||
still opens, navigates, saves one Advanced value, and opens one custom page. If
|
||||
the maintained E2E runner cannot install that runtime, the exact manual version
|
||||
and procedure must be recorded before the implementation is merged.
|
||||
|
||||
The current real-Obsidian runner defaults to Obsidian 1.12.7, so it owns the
|
||||
fallback smoke path. The declarative path uses a separately installed
|
||||
1.13-or-later AppImage selected through `OBSIDIAN_BINARY` and `OBSIDIAN_CLI`.
|
||||
`E2E_OBSIDIAN_SETTINGS_ONLY=true` limits that run to the settings contract so
|
||||
the same scenario can validate a second Obsidian runtime without repeating its
|
||||
unrelated compatibility-review and mobile-layout coverage.
|
||||
|
||||
Existing E2E scenarios must use one shared settings-page navigation helper.
|
||||
That helper uses the current `.sls-setting-menu-btn` contract on the legacy
|
||||
runtime and accessible native page names on 1.13 or later. Individual scenarios
|
||||
must not duplicate version checks or retain selectors for a menu which the
|
||||
declarative renderer does not create.
|
||||
|
||||
The initial Stage C1 implementation was exercised against the official Obsidian
|
||||
1.13.4 arm64 AppImage with SHA-256
|
||||
`20d0b13c6d40bb3d7e73d9b4be6d2e21dfcc145b2106a747d0c1b81e651dabfe`.
|
||||
That run opened all 12 pages from the native page catalogue, found the Advanced
|
||||
control through global settings search, persisted a numeric value on Enter,
|
||||
and restored it after the settings dialogue was closed and reopened. The
|
||||
complete default scenario also passed on Obsidian 1.12.7, including
|
||||
compatibility review, mobile layout, imperative page navigation, and immediate
|
||||
persistence of the same Advanced value. The shared E2E navigator owns both the
|
||||
separate settings renderer used by Obsidian 1.13 and the legacy
|
||||
`.sls-setting-menu-btn` interface.
|
||||
|
||||
Before the start-up lifecycle correction, the Stage C2 landing composition was
|
||||
exercised on Obsidian 1.13.4 with a configured installation whose automatic
|
||||
synchronisation triggers were disabled. Under the former predicate, the real
|
||||
interface rendered Quick Setup, a separate Synchronisation group containing
|
||||
Remote Configuration and Sync Settings, and a General Settings group containing
|
||||
Appearance, Logging, and Extra menus in that order. It opened all 14 nested
|
||||
settings pages, found the Advanced control through global settings search, and
|
||||
restored its saved value after reopening settings. In mobile test mode, Remote
|
||||
Configuration remained inside the initial viewport below the two Quick Setup
|
||||
actions and the Synchronisation heading. The complete scenario also passed with
|
||||
the same bundle on Obsidian 1.12.7, confirming that the imperative fallback
|
||||
retained its navigation and save behaviour.
|
||||
|
||||
The start-up lifecycle correction was subsequently exercised with the same
|
||||
official Obsidian 1.13.4 build. The settings scenario captured and verified the
|
||||
exact configured and unconfigured root-group orders, including Set up other
|
||||
devices before Quick Setup for a configured installation. The same bundle
|
||||
opened General Settings by default through the imperative fallback on Obsidian
|
||||
1.12.7. Focused unit tests own the earlier lifecycle boundary: persisted
|
||||
settings are copied before registration, and definition construction does not
|
||||
request an active replicator.
|
||||
|
||||
## Expansion Checkpoints
|
||||
|
||||
Review the scope with the maintainer before any implementation adds one of the
|
||||
following:
|
||||
|
||||
- a generic representation of actions, confirmations, rebuilds, or service
|
||||
lifecycles;
|
||||
- staged multi-setting transactions in `SettingSpec`;
|
||||
- a replacement for the current onboarding workflow;
|
||||
- a Commonlib setting metadata contract change;
|
||||
- a minimum Obsidian version increase; or
|
||||
- further conversion of Remote Configuration, Hatch, Maintenance, Help, or the
|
||||
Svelte-based Selector controls.
|
||||
|
||||
These may become worthwhile after the first proof, but none is required to
|
||||
establish a shared standard-control model and native settings search.
|
||||
|
||||
## Alternatives Rejected
|
||||
|
||||
### Return an empty definition array
|
||||
|
||||
This can satisfy a syntactic lint check while retaining `display()`, but it
|
||||
does not add native settings search or prove a migration path.
|
||||
|
||||
### Generate every setting from Commonlib setting metadata
|
||||
|
||||
Metadata does not define current page membership, control type, options,
|
||||
visibility, save policy, or whether a compatibility key should be exposed.
|
||||
Automatic generation would expose settings which the current interface omits.
|
||||
|
||||
### Teach `LiveSyncSetting` to run against a simulated DOM
|
||||
|
||||
The existing class is a renderer with direct component and element access.
|
||||
Making it emulate declarative output would preserve its mixed responsibilities
|
||||
and make the new API depend on implementation details of the old one.
|
||||
|
||||
### Model every pane before adopting the API
|
||||
|
||||
This would require a general action and lifecycle language for approximately
|
||||
50 buttons, several dynamic lists, five Svelte-based regular-expression
|
||||
controls, and multiple recovery workflows. The resulting framework would be
|
||||
larger than the standard-control problem it is intended to solve.
|
||||
|
||||
## References
|
||||
|
||||
- [Migrate to declarative settings](https://docs.obsidian.md/plugins/guides/migrate-declarative-settings)
|
||||
- Obsidian `PluginSettingTab`, `SettingDefinitionItem`, and `SettingPage` type
|
||||
declarations from the dependency version locked by this repository
|
||||
@@ -42,10 +42,20 @@ probe. CouchDB's API documentation says that `limit=0` has the same effect as
|
||||
no result rows and leave the complete count in `pending`. Fast Fetch therefore
|
||||
uses an explicit one-row normal probe and includes no document bodies.
|
||||
|
||||
A continuous feed with a heartbeat remains open at the current tail. It closes
|
||||
with a `{ "last_seq": ... }` line only after its finite `limit` has been met.
|
||||
Consequently, a limit larger than the workload already known to be available
|
||||
can leave initialisation waiting for future writes.
|
||||
Finite continuous-feed completion differs across supported CouchDB releases.
|
||||
CouchDB 3.5.0 was observed to close a heartbeat-enabled feed with a
|
||||
`{ "last_seq": ... }` line when its finite `limit` is met. CouchDB 3.2 instead
|
||||
continues to wait for database updates after the limit has been consumed. With
|
||||
a heartbeat configured, each wait emits another heartbeat and the page can
|
||||
remain open indefinitely, even after every requested row has arrived.
|
||||
|
||||
On CouchDB 3.2, an explicit `timeout` without a heartbeat has different
|
||||
semantics from a total request deadline. Shard-result waits may emit blank
|
||||
keep-alive lines and continue processing. Once the currently available changes
|
||||
have been exhausted and the feed is waiting for another database update, the
|
||||
timeout stops that wait and returns the feed-level `last_seq`. The timeout can
|
||||
therefore terminate a finite page without limiting the duration of an active
|
||||
page transfer.
|
||||
|
||||
The CouchDB sequence token is opaque and must be handled using CouchDB's
|
||||
sequence semantics, without parsing, ordering, or comparison. On clustered
|
||||
@@ -73,8 +83,16 @@ request starts again from that same cursor.
|
||||
The number currently available is `results.length + pending`. If it is zero,
|
||||
Fast Fetch is caught up and completes without opening another stream. Otherwise,
|
||||
the next continuous request uses the smaller of that count and 10,000 as its
|
||||
finite `limit`. This prevents a heartbeat-enabled request from waiting for
|
||||
future changes merely to fill an oversized page.
|
||||
finite `limit`.
|
||||
|
||||
Each finite page omits `heartbeat` and sets `timeout=1000`. This lets CouchDB
|
||||
3.2 return the page's terminator one second after it exhausts the currently
|
||||
available changes, rather than keeping the request open for future writes. The
|
||||
client immediately reconnects from that terminator while another normal probe
|
||||
reports available work. This bounded cycle also preserves the intent of the
|
||||
earlier iOS and iPadOS heartbeat workaround: Fast Fetch no longer depends on a
|
||||
silent continuous request eventually closing at CouchDB's default 60-second
|
||||
timeout.
|
||||
|
||||
The probe and page are separate HTTP requests, not a transactional snapshot.
|
||||
New writes, replica selection, or administrative changes may alter the rows
|
||||
@@ -252,6 +270,8 @@ writer. Verify that:
|
||||
`pending` is zero;
|
||||
- every probe uses `limit=1`, excludes document bodies, and is repeated from the
|
||||
previous page's opaque terminator;
|
||||
- each bounded page omits `heartbeat`, uses `timeout=1000`, and can complete
|
||||
under CouchDB 3.2 after its current rows have been delivered;
|
||||
- deletion and document-less rows consume a page slot;
|
||||
- a row count cannot complete a page without its `last_seq` terminator;
|
||||
- a page terminator cannot advance the checkpoint before its batch is durable;
|
||||
@@ -286,11 +306,14 @@ existing setup sequence and cleanup.
|
||||
|
||||
Commonlib's CouchDB integration test remains responsible for the real HTTP
|
||||
changes feed, opaque sequence tokens, deletion rows, and local batch
|
||||
persistence. It should use a two-shard database, include a data set large enough
|
||||
to cross a local batch boundary, and confirm that the final checkpoint can be
|
||||
passed back to CouchDB as `since` with no result rows or pending changes. The
|
||||
test must not compare that token's representation with a separately requested
|
||||
target or changes-row token.
|
||||
persistence. It should use the maintained CI CouchDB release, a two-shard
|
||||
database, and a data set large enough to cross a local batch boundary, and
|
||||
confirm that the final checkpoint can be passed back to CouchDB as `since` with
|
||||
no result rows or pending changes. The test must not compare that token's
|
||||
representation with a separately requested target or changes-row token. The
|
||||
focused regression test covers CouchDB 3.2's page-tail behaviour; compatibility
|
||||
with a real CouchDB 3.2 server can be confirmed manually without expanding the
|
||||
permanent CI matrix.
|
||||
|
||||
LiveSync's real Obsidian Setup URI workflow remains responsible for the actual
|
||||
Fast Fetch selection, E2EE passphrase, Vault reflection, ordinary file round
|
||||
@@ -310,6 +333,9 @@ This follows [Real Obsidian E2E](2026_06_real_obsidian_e2e.md).
|
||||
or local persistence failures which cannot repair themselves.
|
||||
- Progress totals remain approximate and may grow when a later probe observes
|
||||
new work, without affecting correctness.
|
||||
- A completed page can spend up to one second waiting for its terminator before
|
||||
Fast Fetch probes and reconnects. Active page transfer is not constrained to
|
||||
one second.
|
||||
- The implementation requires coordinated changes in Commonlib and LiveSync.
|
||||
Commonlib remains the authoritative package for streaming and rebuilder
|
||||
behaviour; LiveSync consumes an immutable Commonlib release and owns its setup
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Architectural Decision Record: Fast Fetch Transport Eligibility
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Fast Fetch accelerates Fast Setup (Simple Fetch) by reading bounded pages from
|
||||
CouchDB's continuous changes feed. It consumes each response incrementally,
|
||||
persists documents while the page is still arriving, and cancels the underlying
|
||||
request when the page completes or fails.
|
||||
|
||||
The CouchDB setting `useRequestAPI`, labelled 'Use Internal API', routes ordinary
|
||||
replication through Obsidian's `requestUrl` API to avoid browser CORS
|
||||
restrictions. This API exposes a completed response as text, JSON, or an
|
||||
`ArrayBuffer`; it does not expose the network response progressively or accept
|
||||
the Fetch API's `AbortSignal`. Wrapping its result in a `Response` does not
|
||||
restore those transport properties.
|
||||
|
||||
Custom headers can cause a browser preflight, and an authenticating proxy may
|
||||
reject that preflight before the requested header values are sent. Custom
|
||||
headers do not, however, make Fast Fetch intrinsically incompatible. A server
|
||||
with correctly configured CORS can accept the same headers through the ordinary
|
||||
Fetch API and retain streaming behaviour.
|
||||
|
||||
Ordinary PouchDB replication has a different response contract. Standard Fetch
|
||||
uses finite batches, while LiveSync uses long-poll responses whose change
|
||||
payload is bounded by the replication batch size. Both can process each response
|
||||
after it has completed and do not depend on progressively reading a
|
||||
document-bearing continuous feed.
|
||||
|
||||
## Decision
|
||||
|
||||
Fast Fetch requires a Fetch-compatible transport which exposes the response
|
||||
body progressively and honours request cancellation.
|
||||
|
||||
When `useRequestAPI` is enabled for a CouchDB remote, Fast Fetch falls back to
|
||||
Standard Fetch before entering the Fast Fetch activity or resetting the local
|
||||
database through the Fast Fetch path. The presence of custom headers alone does
|
||||
not disable Fast Fetch. Once Standard Fetch resets the local database, it
|
||||
invalidates any retained Fast Fetch checkpoint for that database.
|
||||
|
||||
Commonlib's Rebuilder owns this eligibility decision because it owns both Fast
|
||||
Fetch and the existing Standard Fetch fallback. The streaming implementation
|
||||
does not receive Obsidian's buffered request adapter, and LiveSync does not add
|
||||
proxy-specific or Cloudflare-specific policy.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Initial retrieval through Standard Fetch may be slower and issue more HTTP
|
||||
requests because PouchDB uses the configured batch size, document retrieval,
|
||||
and checkpoint operations. The decision does not assume that `requestUrl` is
|
||||
faster; its benefit here is compatibility with connections which browser CORS
|
||||
would otherwise reject.
|
||||
- LiveSync remains supported with `useRequestAPI`. Its HTTP adapter uses
|
||||
long-poll responses whose change payload is bounded by the replication batch
|
||||
size, rather than the document-bearing stream required by Fast Fetch.
|
||||
- A user whose server accepts the configured custom headers through correct
|
||||
CORS handling can leave `useRequestAPI` disabled and continue to use Fast
|
||||
Fetch.
|
||||
- The decision can be revisited if Obsidian provides a progressively readable,
|
||||
cancellable internal request API, or if a separately designed buffered
|
||||
transport establishes explicit payload bounds and equivalent cancellation
|
||||
semantics.
|
||||
|
||||
## Verification
|
||||
|
||||
Commonlib unit tests verify that `useRequestAPI` selects only the existing
|
||||
Standard Fetch activity, does not invoke Streaming Fetch, and invalidates any
|
||||
retained Fast Fetch checkpoint after the local database is reset. Existing
|
||||
tests continue to verify that custom headers are passed to Fast Fetch when
|
||||
`useRequestAPI` is disabled.
|
||||
|
||||
## References
|
||||
|
||||
- [Fast Fetch Persistence and Completion Semantics](2026_08_fast_fetch_persistence_and_completion.md)
|
||||
- [Apache CouchDB changes-feed API](https://docs.couchdb.org/en/stable/api/database/changes.html)
|
||||
@@ -0,0 +1,179 @@
|
||||
# Architectural Decision Record: P2P Transport Compatibility Controls
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — the user-facing controls will be introduced in stages. This record defines their boundaries before Commonlib settings and LiveSync interfaces are changed.
|
||||
|
||||
## Context
|
||||
|
||||
WebRTC connectivity depends on both devices, their browsers or embedded WebViews, NAT behaviour, carrier networks, VPNs, firewalls, and the path between them. A configuration which works on desktop Wi-Fi may fail on a mobile carrier, and moving the same devices through a mesh VPN may change the result without changing LiveSync.
|
||||
|
||||
The current P2P transport has several relevant properties:
|
||||
|
||||
- Trystero supplies the Nostr signalling strategy and the browser-owned WebRTC connection.
|
||||
- Commonlib limits one RPC wire payload to 15,360 bytes so it remains below Trystero's own action-chunk boundary.
|
||||
- Trystero supplies ordinary STUN servers and accepts an optional TURN server list with one username and credential.
|
||||
- ICE chooses a direct, server-reflexive, or TURN-relayed path automatically.
|
||||
- Commonlib can collect raw WebRTC statistics, but LiveSync does not yet present the selected candidate route in a concise diagnostic result.
|
||||
|
||||
Issue reports suggest that reducing the application payload may improve some mobile and constrained-network paths. A VPN such as Tailscale may also turn an unreliable route into a reliable one. These observations are consistent with NAT, path-MTU, fragmentation, or intermediary behaviour, but they do not prove one universal cause. Browser WebRTC implementations retain responsibility for SCTP, DTLS, ICE, packetisation, congestion control, and retransmission.
|
||||
|
||||
One low-level number cannot represent all of these concerns. Users need a small set of meaningful compatibility choices, while transport-internal controls which cannot be selected safely should remain implementation details.
|
||||
|
||||
## Decision
|
||||
|
||||
### Message-size presets
|
||||
|
||||
LiveSync will expose a `P2P message size` choice with four presets:
|
||||
|
||||
| Label | Maximum RPC wire payload | Intended use |
|
||||
| ----------------------- | -----------------------: | ------------------------------------------------------------------------------- |
|
||||
| `Standard` | 15,360 bytes | Existing default and best throughput. |
|
||||
| `Reduced` | 2,048 bytes | First compatibility step for an unreliable path. |
|
||||
| `Conservative` | 1,024 bytes | Stronger compatibility at greater framing and processing cost. |
|
||||
| `Maximum compatibility` | 800 bytes | Most conservative offered value for paths suspected of dropping larger packets. |
|
||||
|
||||
This value limits Commonlib RPC wire payloads before Trystero applies its own framing. It is not a LiveSync file Chunk size, an IP MTU, an SCTP fragment size, or a guarantee that lower layers will avoid fragmentation. The smaller presets reduce the amount presented to the transport at once and trade throughput for compatibility.
|
||||
|
||||
The bound applies to outgoing messages. A device which only lowers its own value still receives messages produced under the sender's value. The selected preset therefore belongs to the P2P profile and is included in an encrypted Setup URI for additional devices. A device which was configured earlier must be changed separately; the interface and troubleshooting guidance must state that the same conservative preset should be selected on every participating device. An absent key preserves the current 15,360-byte default.
|
||||
|
||||
Automatic negotiation or fallback between presets is deferred. A failed ordered data channel may require connection replacement before a smaller retry can prove anything, and changing transport parameters during a replication session would broaden the lifecycle contract considerably. The first implementation remains explicit, stable for one room lifetime, and inspectable.
|
||||
|
||||
### Connection path
|
||||
|
||||
LiveSync will expose a separate `Connection path` choice:
|
||||
|
||||
- `Automatic` retains normal ICE selection and is the default.
|
||||
- `TURN relay only` supplies `iceTransportPolicy: 'relay'` and prevents direct or server-reflexive candidates from being selected.
|
||||
|
||||
`TURN relay only` is enabled only when at least one syntactically valid `turn:` or `turns:` URL is configured. If the last valid TURN URL is removed while relay-only mode is selected, the dialogue restores `Automatic` and displays a concise explanation.
|
||||
|
||||
The route policy is an ordinary P2P profile property. It is retained in P2P connection strings and encrypted Setup URIs so that an imported compatibility profile has reproducible transport behaviour.
|
||||
|
||||
Multiple P2P profiles may intentionally use the same Group ID, passphrase, and relay list while selecting different compatibility settings. For example, one profile may use `Standard` and `Automatic`, while another uses `Maximum compatibility` and `TURN relay only`. Only the selected P2P profile joins the group, so each device can select the profile appropriate to its current network without a separate device-local override system.
|
||||
|
||||
No `Direct only` choice will be added. `Automatic` already prefers viable non-relayed candidates, and preventing TURN fallback would mainly create another failure mode.
|
||||
|
||||
### TURN server presentation
|
||||
|
||||
The first settings revision retains the existing storage and dialogue contract of one comma-separated TURN URL list, one username, and one credential. The connection-path choice is presented separately under `Connection compatibility`, while the TURN values remain under `Advanced Settings`.
|
||||
|
||||
A future interface may present the existing comma-separated value as ordered `turn:` and `turns:` URL rows without changing its serialised representation. A structured list of multiple credential profiles is deferred until a provider or self-hosted use case requires different credentials in the same P2P profile.
|
||||
|
||||
Static long-term credentials are the supported first stage. Managed providers may return short-lived credentials, but LiveSync must not store a provider API token or a Coturn shared authentication secret. A future managed-credential design needs a separately trusted HTTPS endpoint, expiry handling, refresh behaviour, failure reporting, and a clear Setup URI policy. It is not represented as another static password field.
|
||||
|
||||
### TURN allocation check and route diagnostics
|
||||
|
||||
A future `Test TURN server` action should create a disposable WebRTC check with `iceTransportPolicy: 'relay'`, request candidate gathering, and require at least one relay candidate. It must not read a Vault, join a LiveSync P2P room, or claim that document synchronisation has succeeded.
|
||||
|
||||
Where the browser exposes the evidence, the result should report:
|
||||
|
||||
- whether a relay candidate was gathered;
|
||||
- the TURN URL used for that candidate;
|
||||
- UDP, TCP, or TLS transport; and
|
||||
- a bounded failure or inconclusive result.
|
||||
|
||||
Ordinary P2P diagnostics should later summarise the selected candidate pair as direct, server-reflexive, or relayed, with its transport. Raw `getStats()` output remains supporting evidence rather than the primary interface.
|
||||
|
||||
### Placement and defaults
|
||||
|
||||
These controls belong inside `P2P Configuration` under a `Connection compatibility` section. They do not require the repository-wide Advanced, Power User, or Edge Case modes. P2P itself remains a supported opt-in feature.
|
||||
|
||||
Existing profiles retain the following defaults:
|
||||
|
||||
- `P2P message size`: `Standard`;
|
||||
- `Connection path`: `Automatic`; and
|
||||
- TURN credentials and URLs: unchanged.
|
||||
|
||||
Settings which replace a room continue to use the established P2P room and transport lifecycle. No new reconnect interval, handshake timeout, keepalive interval, trickle-ICE, candidate-pool, data-channel reliability, or backpressure setting is exposed.
|
||||
|
||||
## Self-hosted TURN example
|
||||
|
||||
The repository supplies an optional Coturn Compose example under `docker/coturn/`. It uses a versioned upstream `coturn/coturn` image rather than maintaining another LiveSync Dockerfile.
|
||||
|
||||
The example deliberately covers one small static-credential deployment:
|
||||
|
||||
- Linux host networking, which avoids Docker's large port-range forwarding cost;
|
||||
- TURN over UDP and TCP on port 3478;
|
||||
- a bounded UDP relay port range;
|
||||
- explicit long-term credentials;
|
||||
- an explicit public IPv4 address;
|
||||
- no TLS or DTLS in the starter configuration; and
|
||||
- restrictions which prevent relaying to common private IPv4 ranges.
|
||||
|
||||
The starter does not recommend `turns:` on port 443. It conflicts with an HTTPS entry point which already owns the same IP address and TCP port, including the bundled CouchDB Caddy profile. When a restrictive network requires this path, the preferred deployment uses a separate TURN host or public IP address.
|
||||
|
||||
An outbound tunnel used for CouchDB may leave the host's public port 443 available when TURN uses a separate DNS record which resolves directly to that host, but the tunnel itself cannot carry TURN traffic. A layer-4 TLS router can also own the shared port and select separate CouchDB and TURN backends by SNI. That alternative adds certificate and routing responsibilities, depends on the intended TURN clients supplying usable SNI, and is outside the supplied Compose example. The standard Caddy image used by the CouchDB profile does not provide that layer-4 routing.
|
||||
|
||||
TURN over TLS is not HTTP and must reach Coturn directly or through a compatible layer-4 proxy. Supporting it also adds private-key, renewal, privileged-port, and real-network verification responsibilities.
|
||||
|
||||
The Compose example is not a hosted service supplied by the project, an availability guarantee, or a substitute for firewall and abuse controls. Operators remain responsible for DNS, certificates when enabled, port forwarding, bandwidth, quotas, monitoring, software updates, credential rotation, and legal or provider constraints.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
TURN relays the already encrypted WebRTC connection. A TURN operator cannot read LiveSync's end-to-end encrypted Vault contents, but can observe endpoint addresses, timing, traffic volume, and service credentials.
|
||||
|
||||
Static credentials allow use of the operator's bandwidth until they are changed. They should be unique, high entropy, and limited to the intended deployment. Setup URIs are encrypted but still contain the P2P connection profile; they and their separate passphrases must be protected.
|
||||
|
||||
The Coturn Docker example uses environment interpolation for its static credential. A local Docker administrator can inspect the resulting container arguments and already has equivalent control of that host. The `.env` file remains untracked and should be readable only by the operator.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
### Expose a free-form byte field
|
||||
|
||||
Most users cannot infer a safe application payload from a network MTU, and an arbitrary value makes reports difficult to compare. Four named presets provide a bounded troubleshooting ladder.
|
||||
|
||||
### Apply the smaller payload only on the affected mobile device
|
||||
|
||||
The bound controls outgoing messages. This would leave larger messages from another sender unchanged and could fail during the direction which matters most for an initial fetch.
|
||||
|
||||
### Force TURN whenever a TURN server is configured
|
||||
|
||||
TURN is normally a fallback. Forcing it by default adds latency and bandwidth cost, and exposes more connection metadata even when a direct path works.
|
||||
|
||||
### Store the connection path in a device-local overlay
|
||||
|
||||
A second layer of device-specific profile overrides would make imported profile behaviour less reproducible and add another identity, mapping, and lifecycle contract. Separate named P2P profiles already let each device select an explicit transport policy, including when those profiles share the same Group ID and credentials.
|
||||
|
||||
### Automatically decrease the payload after a transfer failure
|
||||
|
||||
A transfer failure does not identify message size as the cause. Reusing a possibly wedged ordered channel would also make the retry inconclusive, while rebuilding the connection expands the lifecycle and user-notification design.
|
||||
|
||||
### Add browser-specific defaults
|
||||
|
||||
Safari, mobile Safari, Chrome, and Chrome on Android use different platform WebRTC implementations and lifecycle policies, but the failing route also depends on both networks and the remote peer. There is not enough stable evidence for a browser-name heuristic. Explicit cross-platform presets are more predictable.
|
||||
|
||||
### Build and maintain a LiveSync Coturn image
|
||||
|
||||
The upstream project already publishes a multi-platform image and documents its configuration contract. A local Dockerfile would duplicate security updates and release work without adding a LiveSync-specific server component.
|
||||
|
||||
### Bundle a shared port-443 router
|
||||
|
||||
A layer-4 TLS router could share one public address between distinct CouchDB and TURN hostnames by inspecting SNI. Bundling that topology would replace the current Caddy ownership of port 443, add another certificate and routing lifecycle, and rely on the intended TURN clients presenting usable SNI. A separate TURN host or public IP address keeps those failure and ownership boundaries explicit.
|
||||
|
||||
## Verification
|
||||
|
||||
The first implementation stage must add focused tests before production changes:
|
||||
|
||||
- settings-schema defaults for absent keys;
|
||||
- P2P connection-string and Setup URI round trips which retain both transport compatibility settings;
|
||||
- compatibility parsing and serialisation of the existing TURN URL string;
|
||||
- mapping each message-size preset to the exact Commonlib wire bound;
|
||||
- mapping relay-only mode to `iceTransportPolicy: 'relay'`;
|
||||
- rejection or automatic reset of relay-only mode without a valid TURN URL;
|
||||
- room replacement after either effective transport setting changes; and
|
||||
- the real Obsidian dialogue, profile, and connection-string round trip.
|
||||
|
||||
The future TURN allocation action requires its own focused tests using injected WebRTC boundaries, followed by a real transport test only for the device- or network-owned behaviour which deterministic injection cannot prove.
|
||||
|
||||
The Coturn example is checked independently with `docker compose config`. Runtime verification uses a real Coturn allocation from outside the server network and confirms both UDP and TCP client paths before it is presented as a known-working deployment.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Users gain a small compatibility ladder without learning WebRTC internals.
|
||||
- A conservative message size affects throughput wherever it is selected or imported, and must be applied to every participating device to protect all transfer directions.
|
||||
- Profiles may intentionally share the same P2P group identity while offering different transport compatibility choices; the selected profile determines the active connection behaviour.
|
||||
- TURN can be forced for diagnosis or hostile networks without making relay use the global default.
|
||||
- Static and managed TURN credentials have separate, explicit responsibility boundaries.
|
||||
- Browser-specific heuristics, automatic payload fallback, and low-level transport knobs remain out of scope.
|
||||
- A reproducible self-hosted starter is available without making LiveSync responsible for a separate TURN image.
|
||||
@@ -0,0 +1,715 @@
|
||||
---
|
||||
date: 2026-08-27
|
||||
commonlib-version: "0.1.19"
|
||||
self-hosted-livesync-version: "1.0.21"
|
||||
status: proposed
|
||||
series: replicator-capabilities-and-lifecycle
|
||||
part: 1 of 3
|
||||
---
|
||||
|
||||
# Architectural Decision Record: Replicator Capabilities and Lifecycle Orchestration — Part 1: Core Contract
|
||||
|
||||
Series navigation: this is Part 1 of 3. Continue with [Part 2: P2P service and
|
||||
session lifecycle](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md),
|
||||
then [Part 3: migration plan and verification](2026_08_replicator_capabilities_03_migration_plan.md).
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. This record defines the provider, capability, lifecycle, interaction,
|
||||
ownership, and probe boundaries required by current Self-hosted LiveSync
|
||||
consumers. It is the generic part of the series; the P2P-specific ownership
|
||||
rules live in Part 2, and implementation sequencing lives in Part 3.
|
||||
|
||||
The accepted P2P room and transport lifecycle record remains authoritative for
|
||||
the current P2P implementation until Stage 3 in Part 3 is complete. The
|
||||
supersession boundary for that record is stated in Part 2 and is not repeated
|
||||
here.
|
||||
|
||||
## Context
|
||||
|
||||
Self-hosted LiveSync currently represents CouchDB, Object Storage, and P2P with
|
||||
one `LiveSyncAbstractReplicator` base class. That base class requires finite
|
||||
and continuous replication, full upload and download, remote creation and
|
||||
reset, lock administration, preferred-tweak Metadata, on-demand Chunk reads,
|
||||
remote status, integrity inspection, and connected-device inspection.
|
||||
|
||||
These operations do not share one support boundary:
|
||||
|
||||
- CouchDB supports unattended OneShot Sync, Continuous replication,
|
||||
central-remote administration, and CouchDB-specific inspection and
|
||||
maintenance.
|
||||
- Object Storage supports finite journal synchronisation, central reset and
|
||||
lock Metadata, full upload and download, and storage-size inspection. It has
|
||||
no continuous changes feed, CouchDB Chunk source, CouchDB integrity
|
||||
inspection, or CouchDB device registry.
|
||||
- P2P supports peer-targeted finite transfer and an independently owned room,
|
||||
signalling, watch, and broadcast lifecycle. It has no central database to
|
||||
create, reset, lock, inspect for size, or upload during first-device setup.
|
||||
It also has peer-driven AutoSync and AutoWatch paths when the room is open.
|
||||
Those paths are detailed in Part 2.
|
||||
|
||||
The abstract class makes absent capabilities look like operations. Current
|
||||
implementations express absence through thrown errors, `false`, empty arrays,
|
||||
zero counts, silent success, and a dummy all-zero Security Seed. Callers
|
||||
cannot tell whether an operation was performed, was inapplicable, or could not
|
||||
be performed.
|
||||
|
||||
A neutral value is correct only when it is the documented identity for every
|
||||
caller. For example, Object Storage has no remote-Chunk role, whereas a
|
||||
supported CouchDB Chunk request may legitimately return an empty array. A
|
||||
zero integrity count cannot safely stand for an inspection which did not run.
|
||||
|
||||
Issue 1140 exposes the lifecycle consequence. In version 1.0.21,
|
||||
`ModuleReplicatorCouchDB` owns the application resume callback and excludes
|
||||
Object Storage and P2P by `remoteType`. Object Storage accepts `syncOnStart`,
|
||||
but no journal synchronisation starts at resume. Periodic, event-driven, and
|
||||
manual calls later reach the active Replicator successfully.
|
||||
|
||||
The construction boundary is also overloaded. `getNewReplicator()` is an
|
||||
order-dependent first-result handler which ignores false results and catches
|
||||
handler errors. It is used for active Replicator acquisition, trial settings,
|
||||
and temporary command instances. P2P construction can replace and close a
|
||||
service-owned current transport, so an apparently temporary request can
|
||||
disturb an active or adjunct transport.
|
||||
|
||||
Fast Setup is a separate boundary. Streaming Fetch is a CouchDB-specific
|
||||
initial transfer which uses CouchDB HTTP settings and a Security Seed supplier;
|
||||
it does not need a full Replicator or an owned PouchDB connection. It must not
|
||||
be made a generic Replicator capability merely because the current code obtains
|
||||
one as a supplier.
|
||||
|
||||
Finally, current operation results can hide failure. Object Storage can discard
|
||||
a failed or stopped journal result and report success. A headless P2P path can
|
||||
complete but return `undefined`, which the caller interprets as failure. Remote
|
||||
mutations can be caught or ignored before Rebuilder continuation, and an
|
||||
offline integrity inspection can be represented as zero. The contract must
|
||||
make those outcomes truthful.
|
||||
|
||||
## Decision drivers
|
||||
|
||||
The design must:
|
||||
|
||||
1. cover every operation used by the plug-in, CLI, WebApp, WebPeer, Setup,
|
||||
Rebuilder, and maintenance flows;
|
||||
2. keep lifecycle policy independent of provider class names;
|
||||
3. make an omitted support decision a compile-time error when a current
|
||||
provider or capability is added;
|
||||
4. carry trigger and interaction policy through `ReplicationService`, so an
|
||||
automatic trigger cannot open a dialogue;
|
||||
5. distinguish capability absence, unavailable observation, and an observed
|
||||
empty value where that difference affects safety;
|
||||
6. retain simple neutral results where they are safe identities for every
|
||||
caller;
|
||||
7. give active Replicator, flow-specific probe, and transport resources one
|
||||
explicit owner and disposal boundary; and
|
||||
8. report replication, central-remote mutation, and maintenance outcomes
|
||||
truthfully while preserving source compatibility during migration.
|
||||
|
||||
## Decision
|
||||
|
||||
### Use precise ownership terms
|
||||
|
||||
- A **provider definition** is the host-composed, exhaustive declaration for
|
||||
one current remote kind.
|
||||
- The **active Replicator** is the selected main-remote handle owned by
|
||||
`ReplicatorService`.
|
||||
- A **probe** is a short-lived, flow-specific validation resource whose caller
|
||||
owns disposal.
|
||||
- The **P2P service** is the stable Commonlib implementation which supplies
|
||||
narrow P2P contract views. Its room-session ownership is defined in Part 2.
|
||||
- A **room session** and **session epoch** are P2P terms defined in Part 2; an
|
||||
epoch is an internal fence, not a public capability or logical room name.
|
||||
|
||||
An active Replicator is a handle, not necessarily the owner of every transport
|
||||
which it uses. In particular, the active P2P Replicator is a non-owning adapter
|
||||
over the P2P service. Its ownership consequences are specified once in Part 2.
|
||||
|
||||
### Separate policy, provider selection, and the active Replicator
|
||||
|
||||
Application lifecycle policy decides **when** synchronisation is requested.
|
||||
The selected provider and its active Replicator decide **how**, and whether,
|
||||
that request can be performed. A provider module must not subscribe to the
|
||||
application resume lifecycle merely because it can construct a transport.
|
||||
|
||||
Self-hosted LiveSync will compose one LiveSync-owned serviceFeature as the
|
||||
replication scheduling boundary. The serviceFeature creates one private
|
||||
scheduling context and passes it to module-level transition functions. The
|
||||
context contains only scheduling state and narrow collaborators; the functions
|
||||
implement external-poller ownership, Continuous ownership of recurring work,
|
||||
the daemon's satisfied initial OneShot marker, resume coalescing, and
|
||||
periodic-timer reconciliation. They do not register handlers or acquire
|
||||
`LiveSyncBaseCore`.
|
||||
|
||||
The context receives narrow collaborators for readiness and suspension queries,
|
||||
current settings, `ReplicationService`, periodic-timer control, and diagnostic
|
||||
logging. The surrounding serviceFeature owns the context lifetime, lifecycle
|
||||
registration, and adaptation from those Services. It returns a focused control
|
||||
view containing only the daemon operations to select external polling and mark
|
||||
the initial OneShot as satisfied. Core construction passes a frozen bundle of
|
||||
built-in feature views to host composition, without retaining those views as
|
||||
public `LiveSyncBaseCore` properties. The CLI injects the scheduling view into
|
||||
its command context; other hosts may ignore it. No host may expose the context's
|
||||
mutable state or recover it from a core-keyed global or `WeakMap`.
|
||||
|
||||
This boundary is not a ServiceModule merely because it owns state. It neither
|
||||
owns a shared external resource nor supplies a general operational capability
|
||||
to several unrelated consumers. If a future consumer needs a stable shared
|
||||
scheduling capability beyond the focused CLI view, that ownership decision
|
||||
must be reviewed explicitly rather than widening the returned view implicitly.
|
||||
|
||||
The scheduling functions use persisted settings, `ReplicationService`, and
|
||||
the active support declaration. They do not branch on `remoteType` or use
|
||||
`instanceof` as a capability test. Commonlib owns the trigger-aware replication
|
||||
contract; the host owns application lifecycle wiring.
|
||||
|
||||
The existing `onResumed` event remains the eligible-resume boundary after
|
||||
initial readiness, settings application, and visibility recovery. It is not
|
||||
redefined as a once-per-process event. The context-backed functions coalesce
|
||||
duplicate work within one lifecycle generation and preserve readiness and
|
||||
suspension gates:
|
||||
|
||||
- configured Continuous replication starts only through an active Continuous
|
||||
role;
|
||||
- otherwise, configured `syncOnStart` runs through an unattended OneShot role
|
||||
when that role is supported;
|
||||
- an unsupported configured policy produces an explicit unsupported or
|
||||
not-implemented result; and
|
||||
- automatic start-up, periodic, file-event, and merge triggers never open a
|
||||
dialogue. A target-requiring operation is available to an explicit user
|
||||
action or to a flow with a configured target.
|
||||
|
||||
`ReplicationService` remains responsible for readiness checks, bounded finite
|
||||
activity, failure processing, and replication timing. It exposes distinct
|
||||
user-initiated and unattended entry points, or a typed request which carries
|
||||
interaction authority. The scheduling functions never call a concrete
|
||||
Replicator's `openReplication()` directly.
|
||||
|
||||
`P2P_AutoStart` remains a separate P2P room policy. It is not central
|
||||
Continuous replication and is not `syncOnStart`; its service lifecycle is
|
||||
specified in Part 2. Reopening after `EVENT_DATABASE_REBUILT` is a
|
||||
flow-authorised continuation requested by the Rebuilder, not evidence that
|
||||
AutoStart is enabled.
|
||||
|
||||
Correctness must not depend on the registration order of equal-priority resume
|
||||
handlers. P2P AutoStart records persistent room demand, while an unattended P2P
|
||||
OneShot records finite room demand. `P2PRoomSessionOwner` serialises both and
|
||||
retains the room while either demand remains. Provider-specific P2P lifecycle
|
||||
wiring and host replication scheduling may therefore run in either order.
|
||||
Focused owner tests cover AutoStart-before-OneShot and OneShot-before-AutoStart;
|
||||
the host feature-binding test must not encode their current registration order
|
||||
as a scheduling prerequisite.
|
||||
|
||||
The CLI daemon owns its initial finite convergence before its mirror scan.
|
||||
Restored settings mark that convergence as satisfied for the current lifecycle
|
||||
generation, so `syncOnStart` does not repeat it. In `--interval` mode, the
|
||||
daemon poller is the sole recurring remote-poll scheduler. In changes-feed mode,
|
||||
the scheduling functions start one configured Continuous session when
|
||||
supported; otherwise they may enable the configured generic periodic timer.
|
||||
Continuous has precedence when both are configured.
|
||||
|
||||
The resume function starts work synchronously far enough to reserve
|
||||
Continuous ownership, then lets the lifecycle handler settle without awaiting
|
||||
network completion. Concurrent resume notifications share one internal
|
||||
operation. Periodic reconciliation therefore observes the reservation before
|
||||
it can enable a competing timer. A failed operation is logged and releases the
|
||||
coalescing slot so a later resume can retry.
|
||||
|
||||
Coalescing applies only within one observed lifecycle generation. If the
|
||||
application suspends and resumes while an earlier operation is still settling,
|
||||
the context retains the newer generation and runs it after the earlier
|
||||
operation releases the slot. A result from the obsolete generation cannot
|
||||
change recurring-work ownership or initiate a OneShot fallback for the newer
|
||||
generation.
|
||||
|
||||
Disabling an interval does not retract a callback which the runtime has already
|
||||
queued. Each Periodic callback therefore rechecks lifecycle eligibility,
|
||||
readiness, suspension, configuration, external-poller ownership, and
|
||||
Continuous ownership immediately before it requests replication.
|
||||
|
||||
### Use a fixed current-provider definition
|
||||
|
||||
Commonlib defines the canonical current remote kinds, provider contract,
|
||||
capability catalogue, support-decision type, and typed provider builder. Each
|
||||
host composition explicitly declares the provider kinds which it includes and
|
||||
supplies an exhaustive definition table for that set. The current catalogue is
|
||||
CouchDB, Object Storage, and P2P; it is not a public third-party registration
|
||||
API.
|
||||
|
||||
CouchDB is part of every current host composition. Object Storage and P2P are
|
||||
compile-time composition choices and may be included or omitted without
|
||||
changing the generic scheduling feature. Adding another current provider
|
||||
requires a Commonlib kind and support declaration, host composition,
|
||||
Setup/profile schema handling, and provider-specific tests. It does not require
|
||||
a runtime plug-in registry or behaviour for unknown provider kinds.
|
||||
|
||||
Each provider definition supplies:
|
||||
|
||||
- canonical kind and diagnostic name;
|
||||
- active Replicator construction;
|
||||
- a configuration predicate and private configuration identity;
|
||||
- explicit user-initiated and unattended OneShot runners;
|
||||
- readiness requirements, an explicit Continuous support decision, and a
|
||||
transfer-stop runner;
|
||||
- the exhaustive current remote-resource catalogue; and
|
||||
- an optional cohesive central-remote administration runner.
|
||||
|
||||
The host composes CouchDB and Object Storage definitions directly. A module is
|
||||
retained only when it owns separate state or behaviour; a factory-registration-
|
||||
only module instance is not required. The stateful P2P service is composed
|
||||
independently and may be an adjunct beside a different selected main provider.
|
||||
|
||||
The definition table uses `satisfies` against required record keys. Adding a
|
||||
current provider or catalogue entry without a support decision is a compile-time
|
||||
error. A typed builder correlates each `supported` decision with its required
|
||||
role and rejects a role for an absent capability.
|
||||
|
||||
Support has three stable states:
|
||||
|
||||
```typescript
|
||||
type CapabilitySupport =
|
||||
| { readonly kind: "supported" }
|
||||
| { readonly kind: "not-implemented"; readonly reason: CapabilityReason }
|
||||
| { readonly kind: "not-applicable"; readonly reason: CapabilityReason };
|
||||
```
|
||||
|
||||
`not-implemented` means that the provider model exists but the current
|
||||
implementation does not supply it. `not-applicable` means that the model does
|
||||
not exist for that provider, such as central database locking for P2P. Reasons
|
||||
are stable codes, not arbitrary user-facing strings.
|
||||
|
||||
Historical empty `remoteType` is resolved explicitly as CouchDB at the
|
||||
persistence/profile boundary. Capability selection then uses that canonical
|
||||
kind; it never infers CouchDB by truthiness or by a negative test such as
|
||||
'neither Object Storage nor P2P'.
|
||||
|
||||
The private configuration identity covers every effective setting which binds
|
||||
the active adapter. The active publication is retained only while both the
|
||||
provider and identity are unchanged. Every changed identity follows the same
|
||||
serialised replacement transition; there is no same-instance rebind branch.
|
||||
|
||||
### Keep the active contract small and compose the differing roles
|
||||
|
||||
The active object implements only the lifecycle and transport primitives which
|
||||
are real for CouchDB, Object Storage Journal, and P2P:
|
||||
|
||||
```typescript
|
||||
interface ReplicatorInstance {
|
||||
initializeDatabaseForReplication(): Promise<boolean>;
|
||||
openReplication(
|
||||
setting: RemoteDBSettings,
|
||||
keepAlive: boolean,
|
||||
showResult: boolean,
|
||||
ignoreCleanLock: boolean
|
||||
): Promise<void | boolean>;
|
||||
terminateSync(): void | Promise<void>;
|
||||
closeReplication(): void | Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
Provider runners compose the differences in interaction authority, readiness,
|
||||
typed settlement, explicit Continuous support or inapplicability, and P2P room
|
||||
demand. Short-lived connection, preferred-tweak, Security Seed, and
|
||||
synchronisation-information operations remain caller-owned resources. Central
|
||||
administration remains one optional cohesive runner. None of those differences
|
||||
widens `ReplicatorInstance`.
|
||||
|
||||
The LiveSync central-remote administration composition shares only local-identity
|
||||
preparation, mutation ordering, milestone interpretation, and result
|
||||
settlement. Its CouchDB and Object Storage adapters retain their own milestone
|
||||
readers and connection or client ownership. The fixed provider definition has
|
||||
already selected the adapter, so those readers validate only the additional
|
||||
operations which they use; they do not rediscover capability support through a
|
||||
concrete-class `instanceof` test.
|
||||
|
||||
The central OneShot adapters likewise require only the local structural
|
||||
`openOneShotReplicationWithOutcome()` operation. Concrete constructors remain
|
||||
at the host-composition boundary, but constructor identity is not a capability
|
||||
test. A structurally incomplete active instance settles as a failed outcome
|
||||
rather than falling back to the legacy `openReplication()` operation.
|
||||
|
||||
Directional Fetch and Rebuild, Streaming Fetch, CouchDB on-demand Chunk reads,
|
||||
remote-size inspection, compromised-Chunk inspection, Garbage Collection,
|
||||
compaction, and journal checkpoint maintenance are workflow or
|
||||
provider-specific concerns. They do not become exhaustive provider
|
||||
capabilities merely because an application flow branches by topology.
|
||||
|
||||
Local node identity initialisation remains at the established Replicator and
|
||||
local-database initialisation boundary for this change. A later physical
|
||||
database-lifetime review may move it only after establishing a concrete owner
|
||||
and migration benefit. Replication statistics remain a `ReplicatorService`
|
||||
telemetry sink.
|
||||
|
||||
### Make unattended work explicit and truthful
|
||||
|
||||
User-initiated and unattended OneShot Sync are separate roles. Interaction
|
||||
authority is an upper bound on local interaction, not an instruction to display
|
||||
a dialogue. An operation may apply a stricter veto, but cannot request an
|
||||
interaction which its caller did not permit. Remote refusal and incoming-peer
|
||||
consent remain independent decisions.
|
||||
|
||||
```typescript
|
||||
type UnattendedTrigger = "resume" | "periodic" | "database-event" | "editor-save" | "file-open" | "merge" | "daemon";
|
||||
|
||||
interface InteractionPermissions {
|
||||
readonly peerSelection: boolean;
|
||||
readonly localPeerAdmission: boolean;
|
||||
readonly configurationExchange: boolean;
|
||||
readonly failureRecovery: boolean;
|
||||
}
|
||||
|
||||
type PermittedInteractionPermissions =
|
||||
| (InteractionPermissions & { readonly peerSelection: true })
|
||||
| (InteractionPermissions & { readonly localPeerAdmission: true })
|
||||
| (InteractionPermissions & { readonly configurationExchange: true })
|
||||
| (InteractionPermissions & { readonly failureRecovery: true });
|
||||
|
||||
type InteractionAuthority =
|
||||
| typeof NO_INTERACTION
|
||||
| { readonly kind: "permitted"; readonly permissions: PermittedInteractionPermissions };
|
||||
|
||||
const NO_INTERACTION = { kind: "forbidden" } as const;
|
||||
|
||||
interface UserInitiatedOneShotRequest {
|
||||
readonly trigger: "manual";
|
||||
readonly interaction: InteractionAuthority;
|
||||
}
|
||||
|
||||
interface UserInitiatedOneShot {
|
||||
run(request: UserInitiatedOneShotRequest): Promise<ReplicationOutcome>;
|
||||
}
|
||||
|
||||
interface UnattendedOneShot {
|
||||
run(request: {
|
||||
readonly trigger: UnattendedTrigger;
|
||||
readonly interaction: typeof NO_INTERACTION;
|
||||
}): Promise<ReplicationOutcome>;
|
||||
}
|
||||
```
|
||||
|
||||
`NO_INTERACTION` is the only all-false authority. Shared immutable authority
|
||||
values are reused by hosts, avoiding a permission allocation per operation.
|
||||
Operation-specific requests, including P2P configuration exchange, carry the
|
||||
same upper bound and expose only relevant permissions. An unattended path may
|
||||
use persisted or automatic acceptance policy, but cannot obtain local
|
||||
interaction authority implicitly.
|
||||
|
||||
The same authority bounds presentation. An unattended P2P path may retain an
|
||||
informational diagnostic, but no-target, authentication, tweak-mismatch, and
|
||||
overlapping-transfer settlements must not promote themselves to a Notice.
|
||||
User-initiated paths retain their existing Notice-level presentation. This is
|
||||
a caller-authority rule, not a new process-wide presentation framework.
|
||||
|
||||
Outcomes do not collapse failure into `void` or `boolean`:
|
||||
|
||||
```typescript
|
||||
const REPLICATION_COMPLETED = { status: "completed" } as const;
|
||||
const REPLICATION_CANCELLED = { status: "cancelled" } as const;
|
||||
|
||||
type ReplicationOutcome =
|
||||
| typeof REPLICATION_COMPLETED
|
||||
| typeof REPLICATION_CANCELLED
|
||||
| { readonly status: "blocked"; readonly reason: ReplicationBlockReason }
|
||||
| { readonly status: "partial"; readonly detail: PartialReplicationDetail }
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly error: unknown;
|
||||
readonly recoveryHint?: CentralCompatibilityRecoveryHint;
|
||||
};
|
||||
```
|
||||
|
||||
Completed and cancelled values are shared singletons or literals. Blocked,
|
||||
partial, and failed results may carry diagnostic detail. Central provider
|
||||
initialisation, reset, lock, unlock, and resolution settle only after their
|
||||
defined remote write succeeds; a Rebuilder must not continue after an ignored
|
||||
mutation failure.
|
||||
|
||||
CouchDB and Object Storage Journal record one immutable central-compatibility
|
||||
decision inside the finite attempt which owns the connection or borrowed
|
||||
client. Only a rejection from that exact attempt becomes a recovery hint. A
|
||||
transport failure before assessment, or after an accepted assessment, cannot
|
||||
reuse mutable mismatch or lock state from an earlier attempt. P2P produces no
|
||||
central-compatibility decision. The recovery field is already specific to this
|
||||
contract, so its value carries the stable rejection reason and any preferred
|
||||
tweak value without a redundant kind discriminator.
|
||||
|
||||
`cancelled` means that the requested finite operation did not reach its normal
|
||||
completion boundary. It does not promise rollback. A provider may retain
|
||||
documents and checkpoints from batches which had already settled before the
|
||||
cancellation signal was observed, and a later operation resumes from that
|
||||
durable state.
|
||||
|
||||
### Preserve uncertainty at the boundary which owns the decision
|
||||
|
||||
Capability availability and operation results are separate. A supported
|
||||
operation may fail, while an inapplicable operation must not be called or
|
||||
reported as a network attempt. A workflow which legitimately has no step for
|
||||
its topology may complete that branch without claiming that a provider ran an
|
||||
operation.
|
||||
|
||||
Keep uncertainty where it changes a safety decision. In particular, an Object
|
||||
Storage read distinguishes `available`, `not-found`, and `unavailable` before a
|
||||
caller decides whether creation is permitted. Only explicit `not-found` may
|
||||
create Journal synchronisation parameters; an unavailable read cannot be
|
||||
converted to a missing value or a new Security Seed.
|
||||
|
||||
This principle does not require one generic `RemoteObservation<T>` type or an
|
||||
active-read catalogue. Existing provider-specific inspection and maintenance
|
||||
methods may retain their current compatibility surface until their real
|
||||
consumers are migrated. When a later bounded migration needs to distinguish an
|
||||
observed zero or empty result from an unavailable inspection, that consumer
|
||||
owns the smallest explicit result type required by the decision.
|
||||
|
||||
### Give active ownership and probes explicit boundaries
|
||||
|
||||
`ReplicatorService` is the sole owner of the active Replicator. Replacement and
|
||||
disposal use one explicit quiescing transition under its transition lock:
|
||||
|
||||
```text
|
||||
active -> quiescing -> closed -> replacement published
|
||||
```
|
||||
|
||||
The transition removes the old publication from `current`, rejects later
|
||||
admission, requests the provider's supported transfer cancellation, and drains
|
||||
work which was already admitted. Only then does it close the old Replicator and
|
||||
publish another active context. Acquisitions ordered after the
|
||||
transition receive only the replacement. A cancellation failure does not
|
||||
permit the physical close boundary to be skipped. If admitted work cannot
|
||||
settle, no replacement is published. If physical close fails, the quiescing
|
||||
publication remains fenced so a later transition can retry that close before
|
||||
constructing a replacement.
|
||||
|
||||
The publication object itself is the private generation identity; no separate
|
||||
generation number or public lease is required. Its reservation count is not the
|
||||
service-wide bounded-activity count or finite-replication count: those counts
|
||||
remain status and quiescence signals and can include trials, local work, and an
|
||||
outer Rebuilder flow which itself initiates a lifecycle transition. A switch
|
||||
which waited for either global count could therefore wait for the operation
|
||||
which is awaiting that same switch.
|
||||
|
||||
Production consumers cannot synchronously inspect an unreserved active
|
||||
context. They must acquire it or run work inside the admitted callback boundary.
|
||||
The synchronous `inspectActiveReplicatorContext()` view is protected and exists
|
||||
only for lifecycle diagnostics and focused tests.
|
||||
|
||||
The minimum consumer surface is a callback boundary,
|
||||
`runWithActiveReplicatorContext(callback)`, rather than an exposed lease or
|
||||
release token. Admission is ordered with lifecycle transitions, the callback
|
||||
receives one exact context, and private release runs in `finally` without
|
||||
entering the transition queue.
|
||||
|
||||
The callback must not initiate or await settings realisation, database reset or
|
||||
replacement, active Replicator retirement, or another operation which queues
|
||||
the same lifecycle transition. Such recovery or reconfiguration is staged
|
||||
after the reserved dispatch settles. A process-wide re-entrancy flag would
|
||||
both reject unrelated asynchronous work and miss re-entry after an `await`, so
|
||||
the contract is documented and tested at the owning workflows rather than
|
||||
claimed as a reliable runtime detector.
|
||||
|
||||
Failure presentation and recovery start only after the finite reservation has
|
||||
settled. A later remote mutation re-enters through the callback boundary and
|
||||
requires reference equality with the failed context. It cannot apply the
|
||||
decision produced by one publication to its replacement.
|
||||
|
||||
An edited-settings trial is different: it owns an independent Replicator and
|
||||
connection, never borrows the active publication, and disposes both resources.
|
||||
An owned Security Seed resource also forces a fresh provider read for its
|
||||
settings snapshot. Reusing a process-cached synchronisation parameter would
|
||||
turn an observation made for an earlier flow into current trial evidence.
|
||||
|
||||
Application suspension is a reversible host pause, not an ownership
|
||||
transition. `ReplicatorService` orders the provider's transfer-stop request but
|
||||
retains the active publication, accepts later work, and does not drain or close
|
||||
the Replicator. Provider-specific transport lifecycle, including P2P room and
|
||||
relay handling, remains independently owned.
|
||||
|
||||
Plug-in unload is terminal and reuses the same quiescing retirement as disposal;
|
||||
it does not add another public state or unload capability. The lifecycle handler
|
||||
fences admission, requests transfer cancellation, drains admitted work, and
|
||||
closes the Replicator before `ControlService` closes the local database. This
|
||||
ordering matters even when disabling the plug-in leaves the JavaScript process
|
||||
alive.
|
||||
|
||||
The generic stop role is an idempotent request to stop a provider transfer after
|
||||
transport work begins. It does not promise cancellation of readiness checks,
|
||||
Security Seed acquisition, or external storage calls which do not consume a
|
||||
cancellation signal. P2P implements this role through its room-session owner:
|
||||
the request aborts the current finite-operation scopes without closing the room
|
||||
or disabling later transfers. Its RPC request, incoming `reqSync`, and
|
||||
replication batch loop consume the same effective signal. An already-started
|
||||
atomic database operation may settle before cancellation completes, but no new
|
||||
batch is started afterwards.
|
||||
|
||||
`getNewReplicator()` is not a general temporary-instance API. CouchDB and
|
||||
Object Storage Setup and settings flows request narrow connection or
|
||||
preferred-tweak probes. A resource-returning factory returns an owned resource
|
||||
with idempotent asynchronous `dispose()`. Trial settings are passed to the
|
||||
probe itself and cannot silently read active settings. P2P Setup instead uses
|
||||
the stable service's connection-probe admission described in Part 2. Only its
|
||||
idle continuation constructs and disposes a short-lived raw signalling trial.
|
||||
Neither form can replace the active Replicator or the P2P service.
|
||||
|
||||
Streaming Fetch receives an owned Security Seed resource bound to its settings
|
||||
snapshot. The current compatibility implementation may construct an
|
||||
unpublished Replicator internally, but the resource owns and disposes it and
|
||||
cannot replace the active publication.
|
||||
|
||||
### Keep initialisation workflows explicit
|
||||
|
||||
- Fetch, Rebuild, overwrite, and first-device setup remain application
|
||||
workflows. Their direction, reset, lock, peer selection, local-database work,
|
||||
and convergence passes are not one Replicator capability.
|
||||
- CouchDB and Object Storage use their established workflow-local directional
|
||||
adapters and central administration where required.
|
||||
- A P2P first device prepares only its local state. An additional P2P device
|
||||
selects a peer and uses the real finite download path; P2P does not emulate a
|
||||
central reset, lock, milestone, or upload.
|
||||
- CouchDB Streaming Fetch remains a separate initial-transfer service and uses
|
||||
only its owned Security Seed dependency.
|
||||
|
||||
The `EVENT_DATABASE_REBUILT` continuation remains separately authorised and
|
||||
does not imply `syncOnStart` or P2P AutoStart. Complete Rebuilder and
|
||||
maintenance-facade migration is a later bounded change rather than a condition
|
||||
for the active Replicator core.
|
||||
|
||||
## Target capability matrix for current providers
|
||||
|
||||
`S` means supported, `NI` means not implemented, and `NA` means not applicable.
|
||||
Configuration and reachability are request preconditions or outcomes, not
|
||||
support states.
|
||||
|
||||
| Active provider role | CouchDB | Object Storage | P2P |
|
||||
| -------------------------------------- | ------- | -------------- | --- |
|
||||
| User-initiated OneShot Sync | S | S | S |
|
||||
| Unattended OneShot Sync without UI | S | S | S |
|
||||
| Ordinary long-lived Continuous session | S | NA | NA |
|
||||
| Request to stop active transfer | S | S | S |
|
||||
|
||||
Every provider definition records the Continuous row explicitly. CouchDB
|
||||
supplies its runner, while Object Storage and P2P declare the role not
|
||||
applicable; omission is not a fourth support state.
|
||||
|
||||
The current definition also declares the finite resources and the one optional
|
||||
central facility which have real consumers:
|
||||
|
||||
| Provider-owned facility | CouchDB | Object Storage | P2P |
|
||||
| --------------------------------------------- | ------- | -------------- | ------ |
|
||||
| Connection probe | S | S | NA |
|
||||
| Preferred-tweak probe | S | S | NA |
|
||||
| Security Seed resource | S | S | NA |
|
||||
| Synchronisation-information resource | S | NA | NA |
|
||||
| Cohesive central-remote administration runner | S | S | absent |
|
||||
|
||||
`remoteResources` is exhaustive over its four stable machine keys, so adding a
|
||||
resource requires an explicit decision from every composed provider. The
|
||||
central-remote administration field is optional because there is no corresponding
|
||||
P2P facility. Actions within that runner are the current central protocol, not
|
||||
an exhaustive capability table imposed on every Replicator.
|
||||
|
||||
The public contract is named `CentralRemoteAdministration*` because every
|
||||
current action, observation, failure, and postcondition belongs to that central
|
||||
milestone protocol. Established CLI command names remain unchanged.
|
||||
|
||||
The P2P Setup signalling check is not the P2P entry in the provider-owned
|
||||
connection-probe row. P2P has no central connection resource; its
|
||||
`P2PConnectionProbeAdmission` is a focused view of the independently composed
|
||||
P2P service. It compares requested relays with the binding held by the existing
|
||||
room-session owner as specified in Part 2; it adds neither a process-global
|
||||
lease nor a second owner.
|
||||
|
||||
The following concerns deliberately stay outside this capability matrix:
|
||||
|
||||
| Concern | Current owner |
|
||||
| ---------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Directional Fetch and Rebuild | Application workflow over provider-specific adapters |
|
||||
| Streaming Fetch | CouchDB initial-transfer workflow plus an owned Security Seed resource |
|
||||
| On-demand remote Chunks and compromised-Chunk inspection | CouchDB compatibility or maintenance consumers |
|
||||
| Remote size, Garbage Collection, compaction, and device registry | Provider-specific inspection and maintenance flows |
|
||||
| P2P room, relay, peer selection, admission, watch, and broadcast | Stable P2P service and room-session owner in Part 2 |
|
||||
|
||||
P2P unattended OneShot means a role exists which uses configured target names
|
||||
without opening a dialogue. Peer-room, watch, acceptance, and broadcast roles
|
||||
are provider-specific facets, not the ordinary Continuous role; their trigger
|
||||
matrix and de-duplication rules are owned by Part 2.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
### Move the resume handler and retain a `remoteType` switch
|
||||
|
||||
This would fix issue 1140 narrowly, but would leave future providers and
|
||||
triggers subject to the same omission. It would not stop automatic P2P calls
|
||||
from reaching an interactive method, and capability semantics would remain
|
||||
implicit.
|
||||
|
||||
### Use only optional methods or Boolean support flags
|
||||
|
||||
Optional runtime roles are useful, but they do not force a support decision when
|
||||
a provider or catalogue entry is added. Exhaustive support metadata plus a
|
||||
typed builder keeps runtime interfaces small while requiring the decision at
|
||||
compile time.
|
||||
|
||||
### Use only a provider-discriminated union
|
||||
|
||||
A provider union is appropriate for provider-specific maintenance after
|
||||
explicit narrowing. It cannot model adjunct P2P beside another selected main
|
||||
provider and is not a generic feature test.
|
||||
|
||||
### Tag every value and hot-path return
|
||||
|
||||
Uniform wrappers would add allocation and noise to Chunk, document,
|
||||
changes-feed, and queue paths without improving semantics where an identity is
|
||||
safe. Tags remain for low-frequency observations and outcomes which affect
|
||||
control flow.
|
||||
|
||||
### Retain `getNewReplicator()` as the trial and command factory
|
||||
|
||||
The handler is order-dependent, can suppress construction errors, and can
|
||||
replace a feature-owned P2P transport. Flow-specific probes and the stable P2P
|
||||
service make ownership explicit.
|
||||
|
||||
### Treat neutral compatibility results as supported operations
|
||||
|
||||
Dummy zero counts, empty Security Seeds, false values, and silent mutations
|
||||
lose distinctions required for safety and recovery. A neutral value remains
|
||||
only when every caller proves it to be the operation's identity.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `syncOnStart` becomes an application policy for every unattended finite
|
||||
provider which supports it, rather than a CouchDB module behaviour.
|
||||
- Adding a current provider or capability requires an explicit compile-time
|
||||
support decision.
|
||||
- Unsupported central operations are no longer represented as successful P2P
|
||||
no-ops or transport errors.
|
||||
- Safe identities remain simple, while safety-sensitive observations are
|
||||
explicit.
|
||||
- Setup and trial configuration cannot replace an active or adjunct transport.
|
||||
- Central mutations and integrity checks cannot silently turn failure or
|
||||
unavailability into success.
|
||||
- P2P remains first-class without being mislabelled as central Continuous
|
||||
replication.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not introduce third-party remote-provider registration.
|
||||
- Do not redesign every replication result or user-facing message in one
|
||||
change.
|
||||
- Do not make Streaming Fetch a generic Replicator capability.
|
||||
- Do not make P2P pretend to own a central remote database.
|
||||
- Do not infer support from `remoteType`, constructor identity, a falsy result,
|
||||
or a neutral value.
|
||||
- Do not redefine `syncOnStart` as a once-per-process setting.
|
||||
- Do not change established profile persistence or Setup flag-file restart
|
||||
ordering as part of this capability split.
|
||||
|
||||
## References
|
||||
|
||||
- [Part 2: P2P service and session lifecycle](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md)
|
||||
- [Part 3: migration plan and verification](2026_08_replicator_capabilities_03_migration_plan.md)
|
||||
- [Bounded Remote Activity](2026_07_bounded_remote_activity.md)
|
||||
- [Make Onboarding Profile-Aware](2026_07_multiple_remote_onboarding.md)
|
||||
- [P2P Room and Transport Lifecycle](2026_07_p2p_transport_lifecycle.md)
|
||||
- [P2P Transport Compatibility Controls](2026_08_p2p_transport_compatibility.md)
|
||||
- [CouchDB Remote Connection Ownership](2026_08_couchdb_remote_connection_ownership.md)
|
||||
- [Package the Common Library Behind Explicit Host Boundaries](2026_07_common_library_package_boundary.md)
|
||||
- [Self-hosted LiveSync issue 1140](https://github.com/vrtmrz/obsidian-livesync/issues/1140)
|
||||
- [Self-hosted LiveSync issue 1147](https://github.com/vrtmrz/obsidian-livesync/issues/1147)
|
||||
@@ -0,0 +1,384 @@
|
||||
---
|
||||
date: 2026-08-27
|
||||
commonlib-version: "0.1.19"
|
||||
self-hosted-livesync-version: "1.0.21"
|
||||
status: proposed
|
||||
series: replicator-capabilities-and-lifecycle
|
||||
part: 2 of 3
|
||||
---
|
||||
|
||||
# Architectural Decision Record: Replicator Capabilities and Lifecycle Orchestration — Part 2: P2P Service and Session Lifecycle
|
||||
|
||||
Series navigation: this is Part 2 of 3. Read [Part 1: core contract](2026_08_replicator_capabilities_01_core_contract.md)
|
||||
first, then continue with [Part 3: migration plan and verification](2026_08_replicator_capabilities_03_migration_plan.md).
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. This record defines the P2P service owner, room-session boundary,
|
||||
narrow contract views, automation demands, replacement fencing, and trigger
|
||||
semantics. Generic provider and capability rules are owned by Part 1; the
|
||||
implementation and verification order is owned by Part 3.
|
||||
|
||||
The implemented state is recorded separately in Commonlib's
|
||||
`docs/p2p-transport-lifecycle.md` design document. It supersedes the
|
||||
replaceable LiveSync P2P Replicator and current-result ownership described by
|
||||
the accepted [P2P Room and Transport Lifecycle](2026_07_p2p_transport_lifecycle.md)
|
||||
record. The accepted record's decisions about serialised room operations,
|
||||
`room.leave()`, Trystero-owned physical peers, and relay reconnection remain in
|
||||
force. This ADR remains the decision and migration target; the Commonlib
|
||||
design document records the names and ownership boundaries which actually
|
||||
landed.
|
||||
|
||||
## Scope and context
|
||||
|
||||
The Commonlib P2P feature currently spans `TrysteroReplicatorP2PServer`,
|
||||
`TrysteroReplicator`, and the LiveSync-specific `LiveSyncTrysteroReplicator`.
|
||||
The current result resolves a replaceable Replicator while the room, signalling,
|
||||
watch, broadcast, diagnostics, platform-event subscriptions, and database
|
||||
feeds have longer-lived relationships. Installing that replaceable object as
|
||||
the active Replicator makes ordinary active-handle disposal capable of closing
|
||||
an adjunct or policy-owned room.
|
||||
|
||||
The same room membership serves several independent behaviours:
|
||||
|
||||
- P2P AutoStart opens the room and signalling service;
|
||||
- AutoSync reacts to an advertised and accepted peer with one finite transfer;
|
||||
- AutoWatch follows later changes broadcast by a selected peer;
|
||||
- AutoBroadcast publishes local database changes;
|
||||
- explicit commands pull, push, or synchronise against a selected peer;
|
||||
- incoming `reqSync` requests pull from an accepted peer; and
|
||||
- diagnostics and platform events observe the transport.
|
||||
|
||||
These behaviours must share one room owner, but they must not share one
|
||||
implicit policy. A setting change may replace the room session while another
|
||||
provider remains the selected main remote. A setup probe must not close that
|
||||
session. A finite operation may need a room even when AutoStart is disabled.
|
||||
|
||||
## Decision
|
||||
|
||||
### One stable P2P service owns each room session
|
||||
|
||||
The host composes one stable P2P service independently of the selected main
|
||||
provider. It may be present as an adjunct beside CouchDB or Object Storage, or
|
||||
its non-owning adapter may serve as the selected main P2P Replicator. The host
|
||||
owns the service lifetime; the service owns all LiveSync-specific room
|
||||
resources.
|
||||
|
||||
A **P2P room session** is one active room membership and every resource whose
|
||||
validity depends on that membership:
|
||||
|
||||
- the Trystero room, RPC actions, and `RpcRoom`;
|
||||
- its internal session epoch, session controller, and finite-operation
|
||||
registry;
|
||||
- advertisement state and temporary peer decisions;
|
||||
- peer-bound RPC clients and remote database proxies;
|
||||
- connection, diagnostic, and platform-event subscriptions; and
|
||||
- RPC publication, the watch set, database and broadcast change feeds, and
|
||||
in-flight finite-transfer de-duplication bound to the local database.
|
||||
|
||||
A **session epoch** is the internal identity and fence of one room-session
|
||||
object. It is not a public capability, a persisted profile identifier, or a
|
||||
synonym for the logical room. Peer callbacks and finite-operation tokens carry
|
||||
that identity and cannot be routed into a replacement session.
|
||||
|
||||
Closing or replacing a session fences its epoch, stops new operations, settles
|
||||
or fails in-flight work, closes RPC and client resources, and leaves the room
|
||||
in the transport-owned order. Persisted peer acceptance decisions survive;
|
||||
temporary decisions, advertisements, clients, listeners, and feeds do not.
|
||||
The underlying WebRTC peer remains under Trystero's shared-peer ownership as
|
||||
specified by the accepted lifecycle record.
|
||||
|
||||
### Expose narrow contract views
|
||||
|
||||
The service does not expose a room session, raw host, or concrete Replicator to
|
||||
ordinary consumers. It supplies these views over the same owner:
|
||||
|
||||
1. `P2PTransportLifecycle` observes room state and accepts explicit,
|
||||
user-owned connect or disconnect requests.
|
||||
2. `P2PConnectionProbeAdmission` arbitrates a complete Setup signalling check
|
||||
against the current relay binding without exposing or replacing the room.
|
||||
3. `P2PPeerDirectory` supplies peer snapshots and peer arrival or departure.
|
||||
4. `P2PPeerAdmission` evaluates incoming peers and administers temporary or
|
||||
persisted acceptance decisions.
|
||||
5. `P2PTargetedTransfer` performs pull, requested push, and bidirectional
|
||||
finite synchronisation against an explicit peer, and executes the persisted
|
||||
configured-target set without interactive peer selection.
|
||||
6. `P2PChangeRelay` administers peer watch and local-change broadcast.
|
||||
7. `P2PConfigurationExchange` performs peer configuration exchange under its
|
||||
declared interaction authority.
|
||||
8. `P2PDiagnostics` supplies status and RTC diagnostics without exposing raw
|
||||
room or peer connections.
|
||||
|
||||
These are stable service-level contract views, not independent wrapper or state
|
||||
owners. One implementation may satisfy several views.
|
||||
Advertisement and admission state remain under one peer-access owner, while
|
||||
pull, push, and bidirectional transfer remain under one transfer owner. A
|
||||
consumer which needs more than one view receives those views explicitly; it
|
||||
does not receive a general P2P context or service locator.
|
||||
|
||||
The views resolve the current published session at invocation. Finite transfer
|
||||
work which carries an old epoch is rejected or cancelled rather than dispatched
|
||||
into a replacement. A configuration or diagnostic request which was already
|
||||
admitted may settle against its originating session, while persisted peer
|
||||
admission decisions intentionally settle independently of room replacement.
|
||||
These operations are outside active-transfer cancellation and cannot publish
|
||||
the former session's peer callbacks or status into its replacement. The epoch
|
||||
remains internal. The active P2P Replicator is a non-owning adapter over the
|
||||
views, and its disposal cannot implicitly leave the service-owned room.
|
||||
|
||||
### Make explicit disconnect a veto, not another policy demand
|
||||
|
||||
`P2PTransportLifecycle` distinguishes user intent from automation:
|
||||
|
||||
- explicit connect resumes relay reconnection and establishes a user-owned
|
||||
room demand;
|
||||
- explicit disconnect is the sole force-close path, retires the current
|
||||
session, invalidates all demands under the retirement contract, pauses relay
|
||||
reconnection, and establishes a service-lifetime veto against AutoStart; and
|
||||
- only a later explicit connect clears that veto.
|
||||
|
||||
Automated policies and finite operations acquire or release only their own
|
||||
demands. They cannot close a room held by another demand and cannot override an
|
||||
explicit disconnect veto. This is a lifecycle veto, not the opposite of
|
||||
`InteractionAuthority`: local interaction authority is the upper bound used by
|
||||
operations, as specified in Part 1. An operation may impose a stricter veto,
|
||||
but an unattended operation cannot gain permission to open a dialogue.
|
||||
|
||||
`EVENT_DATABASE_REBUILT` is a separately authorised continuation of the owning
|
||||
Rebuilder workflow, not an AutoStart demand. After the replacement database is
|
||||
ready, that continuation may request a room independently of the AutoStart
|
||||
veto. It does not clear the veto for later automatic-start events.
|
||||
|
||||
### Separate automation policy from room ownership
|
||||
|
||||
P2P automation is a composed service feature. It owns `P2P_AutoStart`,
|
||||
AutoSync, AutoWatch, and AutoBroadcast policy, including delayed work and
|
||||
automatic-trigger coalescing. It consumes the transport, peer, admission,
|
||||
transfer, and change-relay views but does not own their mutable state.
|
||||
|
||||
`P2PChangeRelay` owns the actual watch set and database changes feed.
|
||||
`P2PTargetedTransfer` owns explicit finite-transfer and configured-target
|
||||
execution. The stable automation coordinator owns baseline de-duplication
|
||||
shared by AutoSync and configured-target requests. Incoming-peer consent is
|
||||
distinct from a local caller's authority to select a peer or open a dialogue.
|
||||
Persisted or automatic acceptance may authorise an unattended path; it cannot
|
||||
create local interaction authority implicitly.
|
||||
|
||||
Every finite operation which needs a room obtains an internal demand from the
|
||||
room-session owner. Once a session admits the operation, that session owns its
|
||||
operation controller and settlement. The operation consumes an effective
|
||||
signal composed from the room session, its operation controller, and any
|
||||
narrower caller or incoming-RPC signal. Neither the adapter nor the UI consumer
|
||||
owns this bookkeeping.
|
||||
|
||||
Acquiring another demand does not open another session. Releasing it never
|
||||
closes a session still required by AutoStart, another finite operation, or
|
||||
another host consumer. A request to stop active transfer aborts the registered
|
||||
finite-operation controllers but does not abort the room-session controller;
|
||||
the room remains usable and later transfers obtain fresh operation controllers.
|
||||
Retiring the room session aborts its session controller, which cancels every
|
||||
remaining child operation. Demand and controller bookkeeping is internal to the
|
||||
service and is not a general consumer contract; it cannot turn a finite transfer
|
||||
into persistent transport policy.
|
||||
|
||||
Cancellation is cooperative and does not roll back durable work. Pull,
|
||||
requested push, and bidirectional synchronisation propagate the effective
|
||||
signal through the initiating RPC, incoming `reqSync`, the reverse database RPC
|
||||
calls, and the replication batch loop. An atomic PouchDB read or write which has
|
||||
already begun may settle. If a batch write has begun, the operation processes
|
||||
its successful writes and records the batch checkpoint only after every
|
||||
required revision has settled successfully. Cancellation before the write does
|
||||
not advance that checkpoint. The operation then reports a cancelled result and
|
||||
does not start another batch. Session retirement awaits that settlement before
|
||||
releasing RPC and room resources.
|
||||
|
||||
### Reconcile session settings atomically
|
||||
|
||||
The effective P2P session binding is derived from the selected profile, all
|
||||
settings which affect transport or session-bound automation, and the current
|
||||
local database identity. It is not a new persisted profile identifier or a
|
||||
device-local override. The service reconciles this binding independently of
|
||||
the selected main provider, so an adjunct room can be replaced while CouchDB
|
||||
or Object Storage remains active.
|
||||
|
||||
A change to any binding input retires the whole room session and opens a
|
||||
replacement when policy still requires one. Replacing a policy-only setting
|
||||
may cause a temporary disconnect, but it preserves one atomic listener and
|
||||
policy boundary: advertisements and temporary peer decisions are reacquired,
|
||||
while persisted peer decisions survive. AutoStart reconnects when it remains
|
||||
enabled and no explicit-disconnect veto is active. No old listener, credential,
|
||||
client, or policy demand remains reachable after replacement.
|
||||
|
||||
The candidate captures its settings, device identity, and local database object
|
||||
when it is constructed. The owner re-reads the effective binding after the room
|
||||
has opened and publishes the candidate only when it still matches. A setting or
|
||||
database change during open therefore retires the stale candidate rather than
|
||||
making it current.
|
||||
|
||||
Reconciliation is serialised with room lifecycle operations:
|
||||
|
||||
1. fence new session work;
|
||||
2. abort the old session's finite-operation scopes and await their cooperative
|
||||
settlement;
|
||||
3. close and leave the old room in transport-owned order;
|
||||
4. open and validate the candidate session; and
|
||||
5. publish the replacement only after it has opened successfully.
|
||||
|
||||
The service never exposes a partly initialised candidate or treats a cancellation
|
||||
request as rollback. If bounded settlement or candidate opening fails,
|
||||
reconciliation reports the failure and publishes no mixed old and new session.
|
||||
A candidate-open failure leaves one observable disconnected state with policy
|
||||
demands unsatisfied; it does not revive the fenced session or start an unbounded
|
||||
retry loop. A later lifecycle trigger or explicit connect may retry.
|
||||
|
||||
### Order local database replacement across both owners
|
||||
|
||||
The database lifecycle transition owns ordering above `ReplicatorService` and
|
||||
the P2P service:
|
||||
|
||||
1. fence acquisition of active Replicator and P2P room work;
|
||||
2. retire the active adapter;
|
||||
3. settle and retire P2P database-bound feeds and publication;
|
||||
4. publish the replacement local database identity only after both boundaries
|
||||
have settled;
|
||||
5. rebind or replace the active provider;
|
||||
6. reconcile P2P against the new database identity; and
|
||||
7. let the Rebuilder request its separately authorised reopen.
|
||||
|
||||
Each service serialises its own resources. The database transition owns the
|
||||
cross-service ordering rather than introducing one transport-wide lock. Reset
|
||||
preparation and explicit database close both await service-owned room
|
||||
retirement before database managers are torn down and the old physical handle
|
||||
is destroyed or closed. Explicit close settles every registered cleanup handler
|
||||
sequentially, even when an earlier cleanup fails; its aggregate result is
|
||||
diagnostic rather than a close veto.
|
||||
|
||||
### De-duplicate by logical lifecycle, not by room epoch
|
||||
|
||||
Completed AutoSync baselines are scoped by normalised peer name and application
|
||||
lifecycle generation. In-flight baseline promises are indexed by normalised
|
||||
peer name until they settle. Trigger provenance and session epoch are not part
|
||||
of either lookup, so a transport-only reconnect cannot repeat an in-flight or
|
||||
completed baseline transfer.
|
||||
|
||||
Both the in-flight baseline promise and completed baseline history belong to
|
||||
the stable automation coordinator and survive transport-only or policy-only
|
||||
session replacement. The originating room session still owns cancellation and
|
||||
settlement of the actual transfer. A lifecycle or logical-identity change clears
|
||||
completed history, but does not clear an in-flight promise. A request for the
|
||||
same normalised peer name may therefore share that promise until it settles.
|
||||
Work from an older automation generation may settle, but cannot publish
|
||||
completion into the current generation. Only completed per-peer baselines are
|
||||
recorded; partial requests record completed peers only, while blocked,
|
||||
cancelled, failed, and incomplete peers remain eligible for a later bounded
|
||||
retry.
|
||||
|
||||
The automation owner clears settled records when the logical peer namespace or
|
||||
local database identity changes. It retains them across transport-only and
|
||||
policy-only replacement. Watch may follow a later advertised change, but does
|
||||
not immediately repeat a completed baseline transfer.
|
||||
|
||||
### Define the P2P trigger matrix
|
||||
|
||||
| Trigger | Required preconditions | Effect |
|
||||
| ----------------------- | --------------------------------------------------------------------- | -------------------------------------------- |
|
||||
| `P2P_AutoStart` | P2P enabled, active lifecycle generation, and no user disconnect veto | Open room; do not itself transfer files |
|
||||
| `P2P_AutoSyncPeers` | Open room, matching advertisement, and accepted peer policy | Run one bidirectional finite synchronisation |
|
||||
| `P2P_AutoWatchPeers` | Open room, matching accepted advertisement, and remote broadcasting | Pull later announced updates |
|
||||
| `P2P_AutoBroadcast` | Open room and local broadcasting enabled | Announce later local database changes |
|
||||
| `P2P_SyncOnReplication` | Configured names and advertisements received within a bounded wait | Run target-aware unattended OneShot Sync |
|
||||
| Explicit peer command | Supplied peer target and accepted connection | Run user-owned finite synchronisation |
|
||||
| Incoming `reqSync` | Accepted peer and ordinary readiness | Pull from requesting peer |
|
||||
|
||||
`P2P_AutoStart` is a transport policy, not central Continuous replication and
|
||||
not `syncOnStart`. AutoSync, AutoWatch, and accepted incoming requests remain
|
||||
unattended when their persisted policies permit them. A configured-target
|
||||
request waits for advertisement for a bounded period; it does not inspect a
|
||||
possibly stale snapshot immediately after opening. Missing, undiscovered,
|
||||
unaccepted, or partly successful targets are explicit operation results. An
|
||||
unknown peer never opens an acceptance dialogue on an unattended path.
|
||||
|
||||
Delayed opens belong to the lifecycle generation which scheduled them.
|
||||
Suspension cancels them or makes them harmless, and the callback rechecks
|
||||
current settings and suspension state before opening. Ordinary automation uses
|
||||
the trigger-aware readiness policy rather than bypassing readiness, pending-file
|
||||
settlement, clean-up, or version gates. Fetch and Rebuild retain their
|
||||
separately authorised bypasses.
|
||||
|
||||
### Arbitrate Setup signalling checks through the room owner
|
||||
|
||||
The current Setup check establishes only whether its signalling transport can
|
||||
be opened. It does not validate peer discovery, room credentials against
|
||||
another device, or a TURN or WebRTC data path. The host therefore receives a
|
||||
`P2PConnectionProbeAdmission` view over the existing room-session owner instead
|
||||
of constructing an uncoordinated second transport.
|
||||
|
||||
The admission receives the requested relay settings and a continuation which
|
||||
owns one short-lived raw signalling trial. The room owner serialises the whole
|
||||
decision on its existing lifecycle queue:
|
||||
|
||||
- a serving room whose active relay set covers every requested relay returns
|
||||
`observed-active` without entering the continuation;
|
||||
- a serving room which does not cover the requested relay set returns the
|
||||
stable `active-p2p-relay-binding-conflict` decision code without entering the
|
||||
continuation; and
|
||||
- an idle owner runs the continuation and does not settle admission until the
|
||||
caller has disposed the raw Replicator and its temporary database.
|
||||
|
||||
Relay admission uses the same split-and-trim projection as transport setup,
|
||||
then compares de-duplicated sets. It does not infer URI equivalence which the
|
||||
Trystero relay key does not implement. The continuation must not await another
|
||||
lifecycle transition on the same service while it holds this serialisation
|
||||
boundary.
|
||||
|
||||
This view adds neither a second room owner nor a process-global relay lease.
|
||||
It does not change raw `TrysteroReplicator.dispose()` semantics, pause or close
|
||||
an active relay, or silently retire the active service to make an incompatible
|
||||
trial possible. A future check which genuinely needs peer-, room-, TURN-, or
|
||||
WebRTC-level evidence requires its own bounded contract rather than widening
|
||||
this signalling-only result implicitly.
|
||||
|
||||
### Keep provider composition explicit
|
||||
|
||||
The stable P2P service can be composed even when P2P is not the selected main
|
||||
provider, while the generic provider table in Part 1 can include or omit the
|
||||
P2P provider at compile time. No runtime unknown-provider registry is implied.
|
||||
When P2P is selected as main, its active adapter delegates to this same service
|
||||
and does not publish a second P2P lifecycle owner.
|
||||
|
||||
## Consequences
|
||||
|
||||
- P2P room, signalling-check admission, peer, watch, acceptance, transfer,
|
||||
configuration, and diagnostics have one explicit owner and focused
|
||||
contracts.
|
||||
- Disposing an active adapter cannot close a policy-owned or adjunct room.
|
||||
- Explicit user disconnect has a clear veto boundary and cannot be undone by
|
||||
AutoStart or a finite operation.
|
||||
- Finite operations can request a room without changing persistent room policy.
|
||||
- Settings and local database replacement cannot publish mixed-session state.
|
||||
- Reconnects do not repeat completed baseline transfers merely because the room
|
||||
epoch changed.
|
||||
- Setup can observe a compatible active signalling binding or run an idle
|
||||
trial without mutating the active transport; an incompatible active binding
|
||||
is reported explicitly.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not make the P2P service a general service locator.
|
||||
- Do not expose room, raw host, peer connection, or concrete Replicator state
|
||||
to ordinary consumers.
|
||||
- Do not make P2P own a central remote database.
|
||||
- Do not reinterpret P2P AutoStart as `syncOnStart` or central Continuous
|
||||
replication.
|
||||
- Do not replace the accepted Trystero physical-peer and relay ownership
|
||||
decisions.
|
||||
- Do not add unbounded retry loops or pretend that a missing lower-level stop
|
||||
operation is end-to-end cancellation.
|
||||
|
||||
## References
|
||||
|
||||
- [Part 1: core contract](2026_08_replicator_capabilities_01_core_contract.md)
|
||||
- [Part 3: migration plan and verification](2026_08_replicator_capabilities_03_migration_plan.md)
|
||||
- [P2P Room and Transport Lifecycle](2026_07_p2p_transport_lifecycle.md)
|
||||
- [P2P Transport Compatibility Controls](2026_08_p2p_transport_compatibility.md)
|
||||
- [Bounded Remote Activity](2026_07_bounded_remote_activity.md)
|
||||
@@ -0,0 +1,576 @@
|
||||
---
|
||||
date: 2026-08-27
|
||||
commonlib-version: "0.1.19"
|
||||
self-hosted-livesync-version: "1.0.21"
|
||||
status: proposed
|
||||
series: replicator-capabilities-and-lifecycle
|
||||
part: 3 of 3
|
||||
---
|
||||
|
||||
# Architectural Decision Record: Replicator Capabilities and Lifecycle Orchestration — Part 3: Migration Plan and Verification
|
||||
|
||||
Series navigation: this is Part 3 of 3. Start with [Part 1: core contract](2026_08_replicator_capabilities_01_core_contract.md),
|
||||
then [Part 2: P2P service and session lifecycle](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md).
|
||||
This part owns implementation sequencing and verification; it does not add
|
||||
another runtime contract.
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. The stages below are an implementation and verification order, not
|
||||
independently releasable states. Commonlib and Self-hosted LiveSync must not
|
||||
publish temporary support boundaries described by an incomplete stage. A
|
||||
release follows only after the target matrix, ownership boundaries, and the
|
||||
contracted production-consumer migrations in Parts 1 and 2 are complete.
|
||||
|
||||
## Migration rules
|
||||
|
||||
Each Commonlib change first runs its focused unit and type-contract tests,
|
||||
builds and validates the packed artefact, installs that exact artefact in
|
||||
Self-hosted LiveSync, and runs focused downstream tests before either
|
||||
repository advances. Compatibility methods remain until every production
|
||||
consumer has migrated.
|
||||
|
||||
The smallest vertical contract which fixes issue 1140 takes priority over
|
||||
unrelated probe, Fast Fetch, maintenance, integrity, and facade work. A stage
|
||||
must not claim completion when it only changes a type declaration while a
|
||||
production caller still uses the old ownership or interaction path.
|
||||
|
||||
## Stage 1: reproduce the current boundary failures
|
||||
|
||||
Add the smallest regression which configures an Object Storage active provider,
|
||||
enables `syncOnStart`, invokes the resume lifecycle after readiness, and
|
||||
expects one unattended finite synchronisation. Run it against 1.0.21 and
|
||||
confirm the expected failure: the CouchDB-owned lifecycle handler excludes
|
||||
Object Storage. Cover both an ordinary Object Storage profile and a migrated
|
||||
profile which retains `liveSync: true`; unsupported Continuous must not suppress
|
||||
the supported `syncOnStart` OneShot policy.
|
||||
|
||||
Add passing characterisation tests which inventory automatic P2P periodic,
|
||||
database-save, editor-save, file-open, and merge triggers, together with P2P
|
||||
AutoSync, AutoWatch, incoming-request, and nested finite-activity paths. These
|
||||
tests record current ownership and dialogue behaviour so that later stages do
|
||||
not accidentally remove a valid automatic path.
|
||||
|
||||
## Stage 2: fix issue 1140 through the minimum vertical contract
|
||||
|
||||
Add the fixed provider definitions and the minimum user-initiated, unattended,
|
||||
and Continuous roles to Commonlib. Give `ReplicationService` typed entry points
|
||||
which carry trigger and interaction policy. Immediately before changing
|
||||
Object Storage handling, add a failing test in which a stopped or failed
|
||||
journal transfer must not produce a completed outcome; then propagate the
|
||||
actual journal result.
|
||||
|
||||
Add the LiveSync-owned replication scheduling serviceFeature, remove the resume
|
||||
handler from `ModuleReplicatorCouchDB`, and route CouchDB Continuous and OneShot
|
||||
Sync plus Object Storage `syncOnStart` through `ReplicationService`. Its private
|
||||
context owns scheduling state, module-level functions implement transitions,
|
||||
and the serviceFeature owns lifecycle and settings-handler registration.
|
||||
Migrate every automatic caller to
|
||||
the unattended entry point in this stage, so periodic and event calls cannot
|
||||
fall back to an interactive P2P role. Migrate manual commands to the
|
||||
user-initiated entry point.
|
||||
|
||||
Replace the factory-registration-only responsibilities of
|
||||
`ModuleReplicatorCouchDB` and `ModuleReplicatorMinIO` with composed provider
|
||||
definitions. Retain a module only for separately identified stateful
|
||||
behaviour; do not retain an instance merely to add a construction handler.
|
||||
|
||||
Serialise active initialisation, replacement, and disposal. Publish the active
|
||||
provider and Replicator as one context after initialisation, clear that context
|
||||
before retiring the old adapter, and keep each typed dispatch on one context
|
||||
snapshot. This is the minimum publication fence for this stage. Waiting for
|
||||
in-flight adapter work and making acquisitions wait for replacement settlement
|
||||
remain part of the later active-construction migration.
|
||||
|
||||
The scheduling context coalesces its network work internally, but an
|
||||
`onResumed` handler settles once that work has been scheduled. It does not hold
|
||||
later resume consumers until a OneShot transfer or Continuous start has
|
||||
settled. Pass one private context with narrow replication, settings, lifecycle,
|
||||
timer, and logging collaborators to module-level functions. Return only the
|
||||
daemon-facing control view, pass it to host composition, and inject it into the
|
||||
CLI command context. Do not retain the view as a public `LiveSyncBaseCore`
|
||||
property or retain scheduling state in a core-keyed `WeakMap`.
|
||||
|
||||
At this boundary, existing P2P AutoSync, AutoWatch, and incoming-request
|
||||
entry points receive the same non-interactive readiness and accepted-peer gate.
|
||||
The no-interaction authority reaches counterpart RPC authorisation and
|
||||
broadcast progress notifications. An unknown peer is blocked rather than
|
||||
prompting.
|
||||
|
||||
Add focused Commonlib owner tests which start an unattended finite room demand
|
||||
before AutoStart demand and after AutoStart demand. Both orders retain one room
|
||||
until every remaining demand has settled. The LiveSync feature-binding test
|
||||
must not rely on the current registration order of equal-priority resume
|
||||
handlers.
|
||||
|
||||
Until Stage 4 supplies target-aware unattended P2P, each host composition
|
||||
declares generic `P2P_SyncOnReplication` as `not-implemented`. Its automatic
|
||||
request settles without UI with an explicit blocked result. Existing AutoSync,
|
||||
AutoWatch, and accepted incoming-request paths continue with the Stage 2 gate.
|
||||
This is a temporary migration state, not the target matrix in Part 1.
|
||||
|
||||
Apply and test the CLI scheduling precedence defined in Part 1, so the daemon
|
||||
and scheduling context cannot schedule duplicate initial or recurring work.
|
||||
Replace `ModuleReplicationLifecycle` and the replication-specific
|
||||
`ModulePeriodicProcess` wiring only after equivalent context and feature-
|
||||
binding tests pass. Reuse the existing timer implementation behind a narrow
|
||||
timer port; changing other periodic feature owners is outside this stage.
|
||||
|
||||
## Stage 3: make P2P transport ownership truthful
|
||||
|
||||
Introduce the stable P2P service and its narrow contract views around the
|
||||
existing implementation while preserving transfer semantics and changing the
|
||||
necessary ownership and lifecycle behaviour. Make the active P2P Replicator a
|
||||
non-owning adapter. Give the service exclusive ownership of room sessions,
|
||||
session-epoch fencing, effective session binding, room open/close/replacement,
|
||||
and settings reconciliation. Consolidate overlapping resume and
|
||||
settings-event handlers.
|
||||
|
||||
Migrate Obsidian panes and commands to lifecycle, peer, admission, transfer,
|
||||
change-relay, configuration-exchange, and diagnostic views as required. Migrate
|
||||
CLI and WebPeer away from concrete-class checks and raw host or room access.
|
||||
Preserve RTC diagnostics through `P2PDiagnostics`, rather than retaining
|
||||
`rawHost`. The compatibility facade may delegate during this stage, but new
|
||||
consumers cannot receive it.
|
||||
|
||||
Separate active-adapter release, room-session leave, and the stop request.
|
||||
Implement the lower-level cooperative cancellation path before declaring the
|
||||
P2P stop role supported: caller abort through RPC request cancellation,
|
||||
incoming-handler signal propagation, signal-bound reverse database RPC calls,
|
||||
and safe batch-boundary termination in `replicateShim`. Add room-session and
|
||||
operation controllers beside internal session-demand ownership for finite
|
||||
operations and policy-held AutoStart, without exposing that bookkeeping as a
|
||||
general consumer API.
|
||||
|
||||
Add ownership regressions immediately before implementation:
|
||||
|
||||
- replacing or disposing the active main adapter does not close a policy-owned
|
||||
or adjunct room;
|
||||
- a disposed session fences late callbacks and clients;
|
||||
- a P2P setting change replaces an adjunct room while another provider remains
|
||||
the active main remote;
|
||||
- persisted peer decisions survive replacement, while temporary decisions and
|
||||
advertisements do not;
|
||||
- local database replacement retires database-bound feeds and publication
|
||||
before manager teardown;
|
||||
- explicit database close settles every dependent cleanup owner before closing
|
||||
the physical handle, even when an earlier cleanup reports failure;
|
||||
- repeated replacement does not retain platform-event subscriptions;
|
||||
- an active-transfer stop aborts finite operations without closing the room,
|
||||
while a later operation can use the same room;
|
||||
- room retirement aborts both locally initiated and incoming `reqSync` work,
|
||||
waits for an already-started atomic database operation to settle, and starts
|
||||
no later batch;
|
||||
- RPC cancellation, timeout, peer departure, and room close abort a
|
||||
cancellation-aware handler rather than only discarding its eventual result;
|
||||
- the inbound request context exists before request admission begins, so a
|
||||
cancellation received while admission waits cannot be lost;
|
||||
- cancellation retains already-settled documents and checkpoints and reports
|
||||
`cancelled`, rather than claiming rollback or completion;
|
||||
- a per-document batch-write failure does not advance the replication
|
||||
checkpoint past the failed revision;
|
||||
- explicit disconnect suppresses AutoStart and relay reconnection until
|
||||
explicit connect, while a separately authorised rebuild continuation can
|
||||
reopen the room without clearing that automatic-start veto;
|
||||
- a candidate whose settings, device identity, or database binding changes
|
||||
while it opens is retired instead of published; and
|
||||
- database replacement fences both active-provider and P2P work before
|
||||
publishing the new database identity, while a failed candidate leaves one
|
||||
observable disconnected state without reviving the fenced session.
|
||||
|
||||
When this stage lands, add a supersession note to the accepted P2P lifecycle
|
||||
record and update `devs.md` from the replaceable concrete Replicator getter to
|
||||
the stable contract views. Preserve the accepted Trystero peer and relay
|
||||
ownership rules rather than rewriting their historical verification.
|
||||
|
||||
## Stage 4: add target-aware unattended P2P orchestration
|
||||
|
||||
Before implementation, add failing regressions for the headless result-loss
|
||||
path, delayed advertisement, an unaccepted peer, overlapping configured-target
|
||||
and AutoSync requests, suspension before delayed open, and a finite-operation
|
||||
demand beside policy-owned AutoStart demand.
|
||||
|
||||
Implement target-aware unattended P2P work without UI. Add bounded
|
||||
advertisement waiting, peer-acceptance outcomes, finite-operation demands beside
|
||||
policy demands, lifecycle-generation cancellation for delayed opens, and
|
||||
de-duplication across AutoSync and configured-target baseline requests. Keep
|
||||
AutoWatch as the relay for later changes rather than treating it as another
|
||||
baseline transfer.
|
||||
Route P2P automation through trigger-aware readiness without central-remote
|
||||
Security Seed preflight. Add the missing finite-activity boundary to direct
|
||||
shared-pane synchronisation.
|
||||
|
||||
Keep the detailed wait, session-demand, de-duplication, and session-epoch state
|
||||
machine in Part 2 rather than expanding the generic provider contract. If
|
||||
implementation evidence requires a refinement, amend Part 2 before completing
|
||||
this stage. After Stage 3 is complete, Part 2 supersedes the
|
||||
replaceable-Replicator and current-result ownership portions of the accepted
|
||||
July 2026 record; its Trystero peer and relay decisions remain unchanged.
|
||||
|
||||
Commonlib's `docs/p2p-transport-lifecycle.md` design document records the
|
||||
implemented Stage 3 and Stage 4 ownership, demand, automation, replacement,
|
||||
and shutdown behaviour. This document remains the migration and verification
|
||||
sequence rather than a second description of the implemented state.
|
||||
|
||||
## Stage 5: separate active construction and flow-specific probes
|
||||
|
||||
Make active creation a private, exhaustive `ReplicatorService` operation with
|
||||
one configuration identity and replacement policy. Migrate every non-active caller
|
||||
before restricting `getNewReplicator()`: CouchDB connection and passphrase
|
||||
checks, Object Storage connection and preferred-tweak trials, P2P Setup
|
||||
signalling trials, CLI commands, and other host compositions. Prove that every probe leaves
|
||||
the active main Replicator and adjunct P2P transport unchanged.
|
||||
|
||||
Migrate Streaming Fetch to the owned CouchDB initial-transfer dependencies
|
||||
defined in Part 1. Add replacement-fence, late-settlement,
|
||||
configuration-identity, cache-invalidation, and probe-disposal tests before
|
||||
making active construction private.
|
||||
|
||||
### Implementation position after Stage 5
|
||||
|
||||
The provider-defined active-construction path is now private to
|
||||
`ReplicatorService`. The public `getNewReplicator` handler remains as a
|
||||
compatibility surface, but current Self-hosted LiveSync production code no
|
||||
longer calls it. Its removal belongs to Stage 7 after any external compatibility
|
||||
decision has been made.
|
||||
|
||||
The current host composition has migrated CouchDB and Object Storage connection
|
||||
checks, passphrase inspection, preferred-tweak reads, CLI remote status and
|
||||
administration, P2P Setup, and Streaming Fetch Security Seed access away from
|
||||
active construction and towards owned resources or focused services. Active
|
||||
replacement is serialised, context acquisition waits for a queued replacement,
|
||||
late candidates are fenced, and short-lived resources are disposed.
|
||||
|
||||
Stage 5 made the P2P Setup trial separately owned, but did not yet arbitrate it
|
||||
against the active relay binding held by the stable P2P service. The Stage 7
|
||||
review identified and completed that remaining owner boundary; it did not
|
||||
reopen the active-construction contract.
|
||||
|
||||
This position completes the Stage 5 construction and probe boundary. It is not
|
||||
itself a release decision: the active-publication and truthful-attempt work in
|
||||
Stage 6 remains required. Complete retirement of the compatibility facade is
|
||||
not a prerequisite for issue 1140.
|
||||
|
||||
## Stage 6: harden the active lifecycle and exact attempt outcome
|
||||
|
||||
Replace the active dependency on `LiveSyncAbstractReplicator` with the small
|
||||
`ReplicatorInstance` contract. Retain a publication only while its provider and
|
||||
private configuration identity are unchanged. Every changed identity fences
|
||||
new admission, requests supported transfer cancellation, drains admitted work,
|
||||
closes the old instance, and only then constructs and publishes a replacement.
|
||||
|
||||
Reserve the exact publication around typed finite dispatch and the currently
|
||||
required workflow-local directional attempts. Release before recovery or a
|
||||
dialogue. CouchDB and Journal record an immutable compatibility decision inside
|
||||
the attempt which owns the connection or borrowed client, and a later mutation
|
||||
must re-admit that failed context. Retry and Continuous paths reassess on each
|
||||
new CouchDB connection without adding another logical connection.
|
||||
|
||||
Preserve Journal storage read states through `getSyncParameters()`. Only an
|
||||
explicit `not-found` result permits `SyncParamsHandler` to create and upload new
|
||||
synchronisation parameters. An unavailable read becomes a fetch failure and
|
||||
must not regenerate the shared Security Seed. This fixes issue 1147 within the
|
||||
owned-observation boundary rather than adding another Replicator capability.
|
||||
|
||||
### Contracted Stage 6 implementation position
|
||||
|
||||
The retained implementation is deliberately smaller than the earlier complete
|
||||
consumer-migration proposal:
|
||||
|
||||
- `ReplicatorInstance` contains only initialisation, `openReplication`,
|
||||
transfer termination, and close;
|
||||
- provider definitions retain user-initiated and unattended OneShot runners,
|
||||
an explicit Continuous support decision, readiness, transfer stop, the four
|
||||
owned remote resources, and one optional cohesive central-remote administration
|
||||
runner;
|
||||
- every changed configuration identity replaces the active instance; there is
|
||||
no same-instance rebind policy;
|
||||
- typed finite dispatch reserves the exact readiness-tested publication and
|
||||
releases it before failure recovery;
|
||||
- CouchDB and Journal carry only a rejected attempt-local compatibility
|
||||
decision into the failure outcome, so a transport failure cannot reuse old
|
||||
mutable fields;
|
||||
- mismatch updates, unlock, and cleaned-remote reconciliation re-admit the
|
||||
failed publication before remote mutation;
|
||||
- P2P uses a narrow non-owning active adapter, takes the same typed finite path,
|
||||
supports its real download workflow, and exposes no central facility;
|
||||
- ordinary central preparation and Streaming Fetch use owned Security Seed
|
||||
resources which dispose their unpublished compatibility instances;
|
||||
- CLI synchronisation diagnoses lock and clean rejection from the exact outcome.
|
||||
Established successful output and exit behaviour are preserved. Typed
|
||||
remote-administration verification failures return non-zero by default;
|
||||
`--compat-remote-admin-exit-zero` restores the former zero result only for
|
||||
returned verification failures, while thrown mutation failures remain
|
||||
non-zero; and
|
||||
- Journal unavailable sync-parameter reads cannot enter the create-and-upload
|
||||
branch required only by explicit absence.
|
||||
|
||||
The active path no longer depends on the giant facade. Existing maintenance,
|
||||
remote-size, on-demand Chunk, migration-inspection, and compatibility consumers
|
||||
may still use focused structural checks or the legacy facade. Their complete
|
||||
migration, local-node-identity redesign, generic milestone extraction, and
|
||||
in-process local-database reset redesign are deferred unless a separate bounded
|
||||
change proves that they are required.
|
||||
|
||||
Each production correction has a focused regression. The verification section
|
||||
distinguishes local source and packed-consumer evidence from registry,
|
||||
real-runtime, and release validation.
|
||||
|
||||
### Active-publication quiescing boundary
|
||||
|
||||
Commonlib implements the publication-scoped callback reservation defined in
|
||||
Part 1 for finite provider dispatch, central-remote administration, exact
|
||||
recovery mutation, and the workflow-local directional attempts which use the
|
||||
active instance. Invocation queues its admission decision in the same order as
|
||||
replacement and disposal. The publication object is its private generation
|
||||
identity. Once admitted, release is idempotent, private, and independent of
|
||||
that queue so quiescing cannot prevent the operation which allows its own drain
|
||||
to settle.
|
||||
|
||||
Every switch follows the same order: fence admission, request supported
|
||||
transfer cancellation, drain admitted callbacks, close, then publish another
|
||||
context. An unchanged provider and configuration identity keeps the existing
|
||||
publication.
|
||||
|
||||
The first implementation regressions cover:
|
||||
|
||||
- replacement waiting for an admitted exact-context task while ignoring an
|
||||
unrelated bounded activity;
|
||||
- context acquisition waiting for a queued replacement rather than returning a
|
||||
stale or intermediate publication;
|
||||
- rejecting central-remote administration releasing its reservation before
|
||||
replacement continues;
|
||||
- finite failure recovery starting only after publication release;
|
||||
- directional workflow retry releasing and then re-admitting the same context;
|
||||
and
|
||||
- terminal unload draining admitted work before Replicator and local-database
|
||||
close, while reversible suspension retains the publication.
|
||||
|
||||
Keep readiness, failure presentation, dialogue, independently owned trial
|
||||
resources, P2P room-session demand, and Continuous replication outside this
|
||||
reservation. The callback TSDoc forbids awaiting a lifecycle transition which
|
||||
would wait for the same admission to settle.
|
||||
|
||||
## Stage 7: perform a bounded structural review
|
||||
|
||||
Confirm that the contracted core is minimum and robust before a commit or
|
||||
release decision. The active path must stay independent of
|
||||
`LiveSyncAbstractReplicator`, but complete migration of every compatibility
|
||||
consumer is separate work. Remove a dummy or abstract requirement only when
|
||||
its current callers have a truthful alternative; do not turn facade retirement
|
||||
into a condition for issue 1140.
|
||||
|
||||
The retained compatibility surface includes the broad
|
||||
`LiveSyncBaseCore.replicator` view and concrete central adapters required by
|
||||
maintenance and migration code. It is explicitly a partial compatibility view,
|
||||
not the active provider contract. P2P's active adapter does not inherit it.
|
||||
|
||||
CouchDB and Object Storage Journal both have a real central milestone document,
|
||||
but that fact alone does not justify a generic store. Extract a focused contract
|
||||
only when a second current caller demonstrates the same ownership and mutation
|
||||
semantics. P2P has no central milestone document and must not receive a stub or
|
||||
synthetic implementation.
|
||||
|
||||
The final structural review retains the following minimum boundaries:
|
||||
|
||||
- central provider definitions remain outside `LiveSyncBaseCore`; its thin
|
||||
registration method remains as the construction-order composition boundary;
|
||||
- P2P registration remains with the feature which owns the stable P2P service;
|
||||
- `ReplicatorService` and `ReplicationService` remain cohesive services. Their
|
||||
complex state and policy already reside in `ActiveReplicatorState`,
|
||||
`TypedReplicationCoordinator`, the readiness evaluator,
|
||||
`RemoteResourceResolver`, and `CentralRemoteAdministrationCoordinator`, so
|
||||
another split would not improve the current test seams;
|
||||
- every provider explicitly declares Continuous support or inapplicability;
|
||||
- `IReplicatorService` exposes only acquired or admitted active-context access.
|
||||
A protected synchronous inspector remains for focused lifecycle tests;
|
||||
- central-compatibility statuses, recorders, and projection helpers remain
|
||||
package-internal. Only stable rejection reason codes and public recovery
|
||||
value types remain package-index exports; and
|
||||
- redundant recovery-kind data, unused active-state identity matching, and the
|
||||
externally visible legacy administration lookup helper are removed.
|
||||
|
||||
LiveSync implements its optional central-remote administration facility through one
|
||||
shared protocol executor and provider-specific milestone readers. Provider
|
||||
composition selects the reader; a reader validates only the structural
|
||||
operations required for its CouchDB connection or Journal client before any
|
||||
remote mutation. It does not impose concrete-class identity on the generic
|
||||
runner contract.
|
||||
|
||||
The unpublished public contract, provider field, coordinator, and service
|
||||
operation use the `CentralRemoteAdministration*` name because their complete
|
||||
protocol is central-milestone-specific. The central OneShot adapters use a
|
||||
separate local structural operation instead of concrete-class identity. Stable
|
||||
capability-kind comparisons use `CAPABILITY_SUPPORT_KINDS`, and unavailable
|
||||
capabilities settle once without a redundant post-narrowing check.
|
||||
|
||||
Provider-specific configuration identities remain explicit projections of the
|
||||
settings which bind each adapter. They are not replaced with a generic identity
|
||||
builder: the current URL normalisation and setting lists are clearer at the
|
||||
provider boundary, and changing the shared header parser is separate work.
|
||||
|
||||
The review records, but does not prejudge, whether maintained Rebuild and Fetch
|
||||
workflows still require an in-process local-database reset. Prefer a Flag File and restart
|
||||
boundary if it can preserve user intent, CLI behaviour, failure recovery, and
|
||||
the maintained test workflows without losing a supported continuation path.
|
||||
Until that evidence exists, retain the current reset contract and its explicit
|
||||
Replicator-retirement ordering rather than assuming that every workflow has
|
||||
already moved to a restart.
|
||||
|
||||
It also records consumers which retain `LiveSyncLocalDB`, its physical PouchDB
|
||||
handle, or its managers. If broad retention still leaks ownership after the
|
||||
consumer migrations, keep the physical handle and teardown authority in
|
||||
`LiveSyncLocalDB`, and expose only the smallest read-only view or
|
||||
generation-bound operation contract required by each consumer. Do not cache a
|
||||
detached handle snapshot across reset. Preserve the current single active
|
||||
database contract, and add another abstraction only where the inventory shows
|
||||
a concrete lifetime or testability benefit. These recorded questions do not
|
||||
widen Stage 6 or make an anticipatory database abstraction part of this change.
|
||||
|
||||
### Contracted Stage 7 review position
|
||||
|
||||
The structural review retains the contracted service boundaries. The active
|
||||
path remains independent of `LiveSyncAbstractReplicator`; the partial
|
||||
compatibility facade, focused maintenance consumers, and in-process database
|
||||
reset remain bounded deferred work. `ReplicatorService` and
|
||||
`ReplicationService` continue to delegate their complex state and sequencing
|
||||
to the focused collaborators listed above, so another physical service split
|
||||
would add indirection without removing a current responsibility or improving a
|
||||
current test seam.
|
||||
|
||||
The review did identify five bounded behavioural corrections inside existing
|
||||
owners:
|
||||
|
||||
- Security Seed resources force a fresh provider read for their settings
|
||||
snapshot instead of accepting process-cached synchronisation parameters as
|
||||
current evidence;
|
||||
- local-database close, reset, failed-initialisation rollback, and
|
||||
physical-database close clean-up share and await the existing
|
||||
active-Replicator retirement for one physical database lifetime;
|
||||
- disposing an unused Journal resource does not report that replication
|
||||
closed, while a Replicator lifecycle message is emitted only when a real
|
||||
active publication is retired and no longer mislabels unload as database
|
||||
reset;
|
||||
- unattended P2P no-target, authentication, tweak-mismatch, and
|
||||
overlapping-transfer settlements retain informational diagnostics without
|
||||
creating Notice-level presentation; and
|
||||
- P2P Setup receives the stable service's `P2PConnectionProbeAdmission` view.
|
||||
Compatible active relay bindings are observed, an active binding which does
|
||||
not cover the requested relay set is blocked with a stable decision code,
|
||||
and only an idle owner runs and awaits the caller-owned raw trial.
|
||||
|
||||
The last correction uses `P2PRoomSessionOwner`'s existing lifecycle queue and
|
||||
adds no room owner, global relay lease, reference count, or raw transport
|
||||
disposal policy. Every maintained production opening of the P2P Setup dialogue
|
||||
passes through one host-owned `SetupManager` seam, which injects the admission
|
||||
view explicitly. The decision code remains separate from the LiveSync-owned
|
||||
English presentation message.
|
||||
|
||||
These corrections close ownership and presentation gaps found by the review;
|
||||
they do not add another generic capability, remote resource, probe framework,
|
||||
or provider role. The target matrices in Part 1 therefore remain unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
### Commonlib unit and type-contract tests
|
||||
|
||||
Cover:
|
||||
|
||||
- exhaustive host-composed definitions for CouchDB, Object Storage, and P2P;
|
||||
- the four-method active `ReplicatorInstance` contract and provider-owned
|
||||
runtime roles;
|
||||
- user-initiated, unattended, blocked, partial, cancelled, and failed OneShot
|
||||
outcomes;
|
||||
- truthful Object Storage stop or transfer failure and headless P2P outcomes;
|
||||
- active, quiescing, disposed, and replacement-published states, including
|
||||
rejection of new work during retirement, acquisition waiting, and late
|
||||
candidate settlement;
|
||||
- unchanged-identity retention, changed-identity replacement, idempotent
|
||||
reservation release, and close ordering;
|
||||
- probes which cannot replace the active Replicator or P2P service, including
|
||||
P2P Setup observation and blocking without trial construction, and idle
|
||||
admission which awaits complete disposal of the caller-owned trial;
|
||||
- the deliberately narrow active-transfer stop request, including work it
|
||||
does not claim to cancel;
|
||||
- CouchDB compatibility and transfer using the same owned OneShot connection;
|
||||
- Journal compatibility and transfer using one settings-bound borrowed client;
|
||||
- attempt-local accepted, rejected, and not-assessed decisions, including retry
|
||||
and Continuous reassessment on newly opened CouchDB connections;
|
||||
- Journal synchronisation-parameter reads distinguishing explicit absence from
|
||||
unavailability and never writing after the latter;
|
||||
- cohesive central administration and its truthful mutation settlement;
|
||||
- the narrow non-owning P2P active adapter, including download without a
|
||||
synthetic upload or central facility;
|
||||
- local-database retirement sharing the active owner boundary across reset and
|
||||
close paths; and
|
||||
- caller-authority preservation for unattended P2P presentation and truthful
|
||||
Journal and active-lifecycle closure diagnostics.
|
||||
|
||||
### Self-hosted LiveSync unit tests
|
||||
|
||||
Cover:
|
||||
|
||||
- Object Storage `syncOnStart` through resume, including a migrated profile
|
||||
which retains `liveSync: true`;
|
||||
- existing CouchDB Continuous and OneShot paths;
|
||||
- same-generation resume coalescing, a fresh attempt after a later lifecycle
|
||||
generation, and rejection of an obsolete generation's OneShot fallback;
|
||||
- a queued Periodic callback rechecking lifecycle and recurring-work ownership
|
||||
after its interval has been disabled;
|
||||
- the daemon's satisfied initial OneShot marker being consumed even when a
|
||||
Continuous start throws, so a later resume may retry normally;
|
||||
- periodic, database-save, editor-save, file-open, merge, and daemon triggers
|
||||
remaining free of dialogues;
|
||||
- manual P2P and configured peer-targeted flows remaining available;
|
||||
- P2P AutoStart cancellation across suspension, bounded advertisement waiting,
|
||||
accepted peers without unattended dialogues, remote-broadcast prerequisites,
|
||||
session-demand reference counts, and overlapping peer policies;
|
||||
- the focused P2P service views sharing one room owner without exposing a raw
|
||||
host, room, or concrete Replicator, including connection-probe admission;
|
||||
- session replacement fencing callbacks, clients, temporary decisions,
|
||||
advertisements, and database-bound feeds while retaining persisted decisions;
|
||||
- counterpart RPC authorisation and broadcast progress preserving no-dialogue
|
||||
authority;
|
||||
- Setup and settings validation through owned resources or owner-arbitrated
|
||||
P2P admission;
|
||||
- exact failed-context mismatch, unlock, and cleaned-remote recovery, including
|
||||
rejection after active replacement;
|
||||
- CLI lock diagnostics from the exact finite outcome rather than mutable fields
|
||||
on a later active Replicator;
|
||||
- CLI process exit codes for successful administration, returned verification
|
||||
failure with and without `--compat-remote-admin-exit-zero`, and thrown
|
||||
mutation failure;
|
||||
- first-device and additional-device initialisation for each current provider;
|
||||
- P2P as the main remote and as an adjunct transport; and
|
||||
- CLI, WebApp, and WebPeer composition against the same contracts, including
|
||||
daemon scheduling after settings restoration and P2P AutoStart reconciliation
|
||||
after a settings change.
|
||||
|
||||
### Focused integration and real-Obsidian verification
|
||||
|
||||
Cover an Object Storage change arriving immediately after start-up without
|
||||
waiting for the periodic interval, ordinary CouchDB start-up, and P2P start-up
|
||||
without an unexpected selection dialogue or transport replacement. P2P
|
||||
validation also covers an accepted configured peer advertising after room open,
|
||||
a watched peer whose remote side broadcasts, and suspension before a delayed
|
||||
AutoStart callback. A focused P2P Setup check must also cover an active room
|
||||
with the same relay set, an attempted additional relay which is blocked without
|
||||
closing that room, and an idle trial which releases its short-lived resources.
|
||||
Object Storage validation must also confirm that a temporarily unavailable
|
||||
synchronisation-parameter read does not upload a new Security Seed.
|
||||
|
||||
No temporary stage is a release candidate. Release readiness requires the
|
||||
target capability matrix, the contracted core and in-scope consumer migration,
|
||||
focused downstream checks with the exact packed Commonlib artefact, and the
|
||||
real-runtime checks appropriate to the changed boundary. Complete legacy-facade
|
||||
retirement remains a separately reviewed compatibility change.
|
||||
|
||||
## References
|
||||
|
||||
- [Part 1: core contract](2026_08_replicator_capabilities_01_core_contract.md)
|
||||
- [Part 2: P2P service and session lifecycle](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md)
|
||||
- [P2P Room and Transport Lifecycle](2026_07_p2p_transport_lifecycle.md)
|
||||
- [P2P Transport Compatibility Controls](2026_08_p2p_transport_compatibility.md)
|
||||
- [Bounded Remote Activity](2026_07_bounded_remote_activity.md)
|
||||
- [Self-hosted LiveSync issue 1140](https://github.com/vrtmrz/obsidian-livesync/issues/1140)
|
||||
- [Self-hosted LiveSync issue 1147](https://github.com/vrtmrz/obsidian-livesync/issues/1147)
|
||||
@@ -141,6 +141,10 @@ This field stores an array of Chunk Document IDs.
|
||||
|
||||
\_id is generated based on the path of the Obsidian note.
|
||||
|
||||
The validation and explicit repair contract for normal-file Metadata whose
|
||||
actual ID does not match the ID derived from its stored path is defined in
|
||||
[Normal-file Metadata Document ID Validation and Repair](design_docs/metadata_document_id_validation_and_repair.md).
|
||||
|
||||
- If the path starts with `_`, it is converted to `/_` for convenience.
|
||||
- If Case Sensitive is disabled, it is converted to lowercase.
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
# Document History Revision Restoration
|
||||
|
||||
## Status
|
||||
|
||||
Accepted for a limited implementation.
|
||||
|
||||
## Problem and scope
|
||||
|
||||
This document uses the independent revision properties defined under
|
||||
[Revision](../terms.md#revision) and the general state model in
|
||||
[Conflict resolution and revision provenance](../specs_conflict_resolution.md).
|
||||
|
||||
Document History can reconstruct an available historical revision from its
|
||||
Chunks and write that content to the Vault through **Back to this revision**.
|
||||
The current action writes directly through the storage adapter. It does not
|
||||
create a new Metadata revision, clear a logical deletion through a successor,
|
||||
or record which database revision produced the restored Vault file.
|
||||
|
||||
This leaves a logically deleted Metadata document at `deleted: true` after the
|
||||
file has returned to the Vault. A later ordinary Vault save may create a
|
||||
non-deleted successor, but restoration must not depend on an unrelated later
|
||||
file event.
|
||||
|
||||
The same action does not inspect or preserve revision-tree intent explicitly
|
||||
when conflicts exist. Document History currently displays the ancestry of the
|
||||
PouchDB winner. It does not display the complete revision tree or the ancestry
|
||||
of every conflict leaf.
|
||||
|
||||
This design covers restoration of one readable historical revision of a normal
|
||||
Vault file. It creates a new non-deleted successor revision, reflects that exact
|
||||
revision to the Vault, and leaves every other conflict branch available for the
|
||||
existing conflict workflow.
|
||||
|
||||
## Evidence
|
||||
|
||||
A real-Obsidian exercise created a Markdown document, removed it through the
|
||||
Vault API, and waited for LiveSync to store a logical-deletion successor. The
|
||||
Metadata retained all referenced Chunks, and Document History could reconstruct
|
||||
the deleted content.
|
||||
|
||||
Selecting **Back to this revision** restored the file and its exact bytes to the
|
||||
Vault. The local database nevertheless retained the same deleted current
|
||||
revision after file processing had settled. An additional ordinary Vault save
|
||||
then created a new non-deleted successor. This demonstrates that content
|
||||
reconstruction works and that the missing operation is the database-aware
|
||||
restoration step.
|
||||
|
||||
## Revision-tree decision
|
||||
|
||||
The selected historical revision is the content source. It is not necessarily
|
||||
a current leaf and is not used as the parent of the new write.
|
||||
|
||||
At the time of the restoration operation, LiveSync reads the current PouchDB
|
||||
winner. The new revision is written as a child of that exact winner revision
|
||||
and contains the selected historical content with no logical-deletion marker.
|
||||
|
||||
For an unconflicted logical deletion:
|
||||
|
||||
```text
|
||||
A -- D (logically deleted winner) -- R (non-deleted restored successor)
|
||||
```
|
||||
|
||||
For an existing conflict:
|
||||
|
||||
```text
|
||||
A -- W (winner) -- R (restored successor)
|
||||
\
|
||||
C (existing conflict remains current)
|
||||
```
|
||||
|
||||
Advancing the winner branch is the ordinary meaning of reverting its content.
|
||||
The previous winner remains in revision history, while every other conflict
|
||||
leaf remains available for conflict resolution. Restoration does not
|
||||
manufacture an additional independent branch merely to retain the previous
|
||||
winner as another current conflict.
|
||||
|
||||
The existing **Inspect conflicts and file/database differences** workflow owns
|
||||
subsequent comparison and resolution of the restored revision and the remaining
|
||||
conflict leaves. Document History supplies content which may no longer be a
|
||||
current leaf; the Inspector operates on the current revision tree after that
|
||||
content has been restored. Neither interface replaces the other.
|
||||
|
||||
## Persistence and reflection order
|
||||
|
||||
Restoration performs these steps in order:
|
||||
|
||||
1. read and reconstruct the exact selected historical revision;
|
||||
2. read the current winner and use its exact revision as the write base;
|
||||
3. create Chunks and conditionally write a new non-deleted Metadata revision
|
||||
containing the selected content below that exact winner;
|
||||
4. obtain the exact created revision from the database write;
|
||||
5. reflect that exact revision to the Vault; and
|
||||
6. record the reflected revision as the device-local file provenance.
|
||||
|
||||
The database write precedes Vault reflection. If the database write fails, the
|
||||
Vault remains unchanged. If the new revision is stored but Vault reflection
|
||||
fails, the restored revision remains in database history and the operation
|
||||
reports that persistence completed without successful reflection. It does not
|
||||
remove the new revision in an attempted rollback. Concurrent database activity
|
||||
may subsequently change whether that stored revision is a current leaf or the
|
||||
winner.
|
||||
|
||||
The new Metadata keeps the current document's creation time, records the
|
||||
restoration as a new modification, and derives its size and type from the
|
||||
reconstructed bytes. Reusing the historical modification time would make an
|
||||
explicit present-day restoration appear older than concurrent changes and
|
||||
would interact poorly with modification-time policies.
|
||||
|
||||
### Restoration state transition
|
||||
|
||||
The selected historical revision `H` supplies content, the winner `W` supplies
|
||||
the conditional write base, and `R` is the non-deleted restored revision. These
|
||||
are operation roles; winner, Vault-matching, and displayed remain independent
|
||||
properties.
|
||||
|
||||
| Stage | Content source | Winner | Vault relationship |
|
||||
| ---------------------------- | -------------- | -------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| Before restoration | `H` | current winner `W` | unchanged |
|
||||
| After the conditional write | `H` | `R` immediately after writing | Vault state and displayed provenance remain unchanged |
|
||||
| After exact Vault reflection | `H` | normally `R`; concurrent activity may differ | `R` is Vault-matching and displayed |
|
||||
| After a reflection failure | `H` | depends on subsequent database activity | Vault state and displayed provenance remain unchanged; `R` is stored |
|
||||
|
||||
## Conflicts and concurrent changes
|
||||
|
||||
Existing conflict leaves are never deleted by restoration. When conflicts
|
||||
remain after the new revision is written, LiveSync keeps the ordinary conflict
|
||||
indicator and conflict-resolution workflow available. The interface explains
|
||||
that restoration advances the currently shown branch and does not resolve the
|
||||
other versions.
|
||||
|
||||
Document History does not perform a separate comparison with the winner which
|
||||
was current when the dialogue opened. The conditional exact-base write uses an
|
||||
ordinary PouchDB new edit. If that base is no longer a writable current leaf,
|
||||
PouchDB rejects the write and the dialogue reports that the revision tree
|
||||
changed and the operation should be retried. If the base remains current while
|
||||
another conflict leaf appears, the write may succeed and both leaves remain
|
||||
available.
|
||||
|
||||
Commonlib's existing `storeWithBaseRevision` operation cannot provide this
|
||||
condition. Its force-write behaviour intentionally uses `new_edits: false` so
|
||||
that conflict-preservation workflows can create a branch from a supplied
|
||||
ancestor. The restoration path therefore uses a separate
|
||||
`storeWithLiveBaseRevision` operation. That operation writes below the supplied
|
||||
current leaf with ordinary PouchDB revision checking and never falls back to
|
||||
the force path.
|
||||
|
||||
The action must not silently fall back to an unbased write after an exact-base
|
||||
failure.
|
||||
|
||||
## History presentation boundary
|
||||
|
||||
The current slider continues to represent the available ancestry of the
|
||||
PouchDB winner. Building a complete branch-aware history viewer would require
|
||||
loading the ancestry of every current leaf, joining shared ancestors,
|
||||
representing missing or compacted revisions, and adding an explicit
|
||||
branch-selection interface. That work is not required to restore the history
|
||||
currently shown.
|
||||
|
||||
When the document has conflicts, the dialogue may state that restoration will
|
||||
create a new revision on the winner branch which is current when the action
|
||||
runs, and leave the other versions unresolved. Detailed branch comparison
|
||||
remains in the Inspector.
|
||||
|
||||
## Ownership
|
||||
|
||||
LiveSync owns:
|
||||
|
||||
- selection and reconstruction of the historical revision;
|
||||
- the Document History user interaction and result messages;
|
||||
- orchestration of the exact-base write and exact-revision reflection; and
|
||||
- presentation of any remaining conflict state.
|
||||
|
||||
Commonlib owns:
|
||||
|
||||
- Chunk creation and Metadata persistence;
|
||||
- `storeWithLiveBaseRevision`, which writes content as a normal child of an
|
||||
exact current leaf and returns the created revision;
|
||||
- rejecting an unavailable or stale base revision;
|
||||
- reflecting an exact current leaf revision to storage; and
|
||||
- recording device-local file-reflection provenance.
|
||||
|
||||
The implementation retains `storeWithBaseRevision` for the conflict workflows
|
||||
which deliberately create branches. The new conditional operation is narrower
|
||||
and is not a replacement for that existing behaviour.
|
||||
|
||||
## Non-goals
|
||||
|
||||
This change does not:
|
||||
|
||||
- identify the origin of malformed or doubled Metadata paths;
|
||||
- turn Document History into a complete revision-tree viewer;
|
||||
- select, merge, or discard existing conflict leaves;
|
||||
- restore unavailable content whose Chunks cannot be reconstructed;
|
||||
- mutate an old revision or clear its deletion marker in place;
|
||||
- rebuild a local or remote database; or
|
||||
- change automatic conflict-resolution policy.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused tests cover:
|
||||
|
||||
- restoring readable content as a non-deleted child of a deleted winner;
|
||||
- returning and reflecting the exact created revision;
|
||||
- retaining every existing conflict leaf while advancing the winner branch;
|
||||
- refusing an exact-base write when the base is no longer a current leaf;
|
||||
- retaining the existing force-write behaviour for callers which deliberately
|
||||
create a conflict branch;
|
||||
- leaving the Vault unchanged when database persistence fails; and
|
||||
- retaining the stored revision when subsequent Vault reflection fails.
|
||||
|
||||
The real-Obsidian regression exercise removes the additional normal Vault save
|
||||
from the earlier reproduction. **Back to this revision** must itself produce a
|
||||
new non-deleted successor revision, restore the exact content to the Vault, and
|
||||
reopen Document History at that successor.
|
||||
@@ -0,0 +1,187 @@
|
||||
# Normal-file Metadata Document ID Validation and Repair
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Problem and scope
|
||||
|
||||
A normal-file Metadata document is addressed by an ID derived from its recorded
|
||||
Vault-relative path. Historical data can contain a readable Metadata document
|
||||
whose actual local database ID no longer matches that derivation. An ordinary
|
||||
path-based read then looks up a different ID. It may reach a separate,
|
||||
consistently addressed Metadata document, or it may find no document at all;
|
||||
it cannot reach the mismatched document which the Offline Scanner enumerated.
|
||||
|
||||
The mismatch can repeatedly produce failed reflection, and an offline-deletion
|
||||
decision can be made before that failure. The scanner must therefore recognise
|
||||
the mismatch before any file reflection, database deletion, expired-history
|
||||
cleanup, or last-seen update.
|
||||
|
||||
This design covers ordinary Vault files. Hidden File Sync, Customisation Sync,
|
||||
and the obsolete plug-in storage namespace retain their feature-specific
|
||||
processing. A disagreement between the document-ID namespace and recorded-path
|
||||
namespace is reported, but is not repaired by this workflow.
|
||||
|
||||
## Evidence and cause boundary
|
||||
|
||||
The reported data included readable paths which could be enumerated from
|
||||
Metadata but could not be fetched again through the path-derived lookup. The
|
||||
Vault also had a history of case changes in folder names. This is consistent
|
||||
with an ID/path mismatch, but it does not prove whether a historical rename,
|
||||
interrupted migration, or earlier path-setting change created it.
|
||||
|
||||
The repair workflow must not infer that the current path is authoritative merely
|
||||
because it is readable. It is available only when the local evidence is
|
||||
unambiguous and current.
|
||||
|
||||
## Identity invariant
|
||||
|
||||
For normal-file Metadata:
|
||||
|
||||
actualDocumentId === path2id(declaredPath)
|
||||
|
||||
The active path service owns the derivation. In particular,
|
||||
handleFilenameCaseSensitive, usePathObfuscation, and the path-obfuscation
|
||||
passphrase can change the expected ID. The E2EE Security Seed and Chunk settings
|
||||
do not directly participate in this ID.
|
||||
|
||||
Inspection and repair use the current local path service. They do not query the
|
||||
remote or decide whether this device's settings should become authoritative.
|
||||
Commonlib recalculates the expected ID during its pre-mutation inspection, so a
|
||||
local ID-derivation setting change makes an earlier approval stale. An
|
||||
intentional whole-database change to ID-derivation settings requires the
|
||||
established rebuild workflow, not this one-entry repair.
|
||||
|
||||
## Offline Scanner decision
|
||||
|
||||
The Offline Scanner validates each decoded Metadata document while its actual ID
|
||||
is still available. It does this before target-file policy and path-keyed pair
|
||||
construction.
|
||||
|
||||
- Consistent normal-file Metadata continues through the existing scan.
|
||||
- Consistent special-namespace Metadata remains owned by its feature.
|
||||
- An ID/path or namespace mismatch is left unchanged and does not enter pair
|
||||
processing.
|
||||
- If consistently addressed Metadata is selected for the same case-normalised
|
||||
path, that Metadata and its storage file continue through the established
|
||||
path-based scan. A stale enumerated document must not suppress this flow.
|
||||
- If no consistently addressed Metadata is selected for that logical path, its
|
||||
storage entry is also withheld. No storage write, database deletion, or
|
||||
last-seen update is performed for that withheld path.
|
||||
- Expired logical deletion history with an inconsistent identity is left
|
||||
unchanged.
|
||||
|
||||
The ordinary scan still returns its established Boolean execution result. A
|
||||
recognised mismatch is left unchanged and omitted before file-pair processing.
|
||||
It therefore does not add a `FilePairProcessResult`, change the ordinary Boolean
|
||||
scan contract, or change Fast Setup or CLI completion policy. Detailed
|
||||
inspection remains separate.
|
||||
|
||||
## Inspection decision
|
||||
|
||||
`inspectMetadataDocumentIdentities` is read-only and enumerates the local
|
||||
database by actual document ID. This is necessary because inspection through a
|
||||
path-derived lookup cannot discover the mismatched source.
|
||||
|
||||
The existing **Inspect conflicts and file/database differences** interface shows
|
||||
one card for each mismatch. It excludes that card's path from ordinary
|
||||
path-based repair only when no consistently addressed Metadata document can be
|
||||
resolved for the same logical path. A stale entry does not hide the normal
|
||||
inspection of a resolvable entry. Multiple affected files are presented
|
||||
separately; there is no batch repair.
|
||||
|
||||
A one-entry repair is offered only when all of these checks pass:
|
||||
|
||||
- the mismatch is within the normal-file namespace;
|
||||
- the source is the current winner and has no conflict leaves;
|
||||
- the recorded path is valid and selected by current synchronisation policy;
|
||||
- one case-normalised path maps to one source under the active filename setting;
|
||||
- only one mismatched source expects the target ID; and
|
||||
- the target ID is absent, or contains an exact structural copy left by an
|
||||
earlier attempt.
|
||||
|
||||
An exact structural copy has the same path, timestamps, size, type, Chunk
|
||||
references, Eden data, and logical-deletion state. Inspection does not fetch
|
||||
Chunk content or query the remote. Missing content remains the responsibility
|
||||
of the existing file and Chunk repair tools.
|
||||
|
||||
## Repair decision
|
||||
|
||||
The user explicitly confirms one actual ID, expected ID, and source revision.
|
||||
Commonlib then:
|
||||
|
||||
1. acquires the existing ordered document locks for the source and target IDs;
|
||||
2. reruns the complete inspection and rejects stale or unsafe input;
|
||||
3. reads the exact approved source revision;
|
||||
4. removes the path from the Offline Scanner's durable last-seen map;
|
||||
5. writes the expected target ID when it is absent;
|
||||
6. reads the target back and verifies the exact structural copy;
|
||||
7. writes a deletion revision for the source against the approved source
|
||||
revision; and
|
||||
8. returns control to LiveSync, which requests an ordinary Vault scan.
|
||||
|
||||
The repair result and the follow-up scan result remain separate. If the scan is
|
||||
suspended, returns false, or raises an error after the source has been removed,
|
||||
LiveSync reports that the identity repair completed and directs the operator to
|
||||
run the ordinary scan separately. It does not describe the completed mutation
|
||||
as a failed or rolled-back repair.
|
||||
|
||||
The target is always verified before the source is removed. If target creation
|
||||
fails, the source remains. If source removal fails, the exact target remains and
|
||||
the same one-entry action can finish the operation after a new inspection. This
|
||||
retry property is an implementation safety guarantee, not a separate public
|
||||
repair mode.
|
||||
|
||||
The target receives new CouchDB revision ancestry because ancestry cannot move
|
||||
between document IDs. Users are told to back up the device, pause editing and
|
||||
synchronisation on other devices, allow the change to replicate, and inspect
|
||||
again.
|
||||
|
||||
## Case handling
|
||||
|
||||
The active handleFilenameCaseSensitive setting defines whether path claims are
|
||||
folded before ambiguity is assessed. When case-insensitive handling is active,
|
||||
a consistently addressed entry may continue through the existing path-based
|
||||
flow even if a stale case variant is also reported. The stale entry is not
|
||||
automatically selected or removed unless the one-entry repair preconditions
|
||||
hold. When case-sensitive handling is active, intentional variants remain
|
||||
distinct.
|
||||
|
||||
This workflow does not rename Vault files or folders, infer a preferred folder
|
||||
name from one device, or coordinate a repair across devices. The repair changes
|
||||
one local database and relies on ordinary replication afterwards. Other devices
|
||||
must remain paused until that result has replicated and a new inspection is
|
||||
clean.
|
||||
|
||||
For widespread cross-device naming differences, the operator must choose an
|
||||
authoritative Vault, stop every participating device, correct its storage names
|
||||
outside Obsidian, rebuild the central remote from that Vault, and reset the
|
||||
other devices from the verified remote. During Fast Setup on an empty Vault,
|
||||
there are no storage names to correct: the scanner reflects every consistently
|
||||
addressable Metadata entry and reports only the references which remain
|
||||
unresolved.
|
||||
|
||||
## Non-goals
|
||||
|
||||
This change does not:
|
||||
|
||||
- repair several entries automatically or in a batch;
|
||||
- choose between competing case variants;
|
||||
- rename storage files or folders;
|
||||
- coordinate a distributed repair across devices;
|
||||
- migrate an entire database after path-obfuscation or case-setting changes;
|
||||
- repair special-namespace Metadata;
|
||||
- reconstruct unavailable Chunk content;
|
||||
- query or modify the remote directly; or
|
||||
- change Fast Setup, daemon, or CLI completion policy.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused Commonlib tests cover unresolved-identity exclusion before pair
|
||||
construction, continued processing of a resolvable same-path entry, expired
|
||||
logical-deletion retention, namespace routing, read-only actual-ID inspection,
|
||||
repair preconditions, target-first ordering, stale approval, exact-target retry,
|
||||
source preservation on failure, and last-seen clearing. LiveSync tests cover
|
||||
selective presentation-path withholding, separate confirmation, cancellation,
|
||||
and the ordinary scan request after a completed repair.
|
||||
+11
@@ -44,6 +44,17 @@ Both settings contain server addresses, but they are not interchangeable.
|
||||
|
||||
A TURN provider cannot read LiveSync's encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust. The project does not operate an official TURN service.
|
||||
|
||||
## Connection compatibility profiles
|
||||
|
||||
`P2P Configuration` includes a separate `Connection compatibility` section. Its defaults preserve the existing transport behaviour:
|
||||
|
||||
- **P2P message size** defaults to **Standard**. **Reduced**, **Conservative**, and **Maximum compatibility** progressively limit outgoing P2P messages when a network path appears to drop larger WebRTC messages. This is not a Vault Chunk size or an IP MTU. Smaller values add framing and processing overhead.
|
||||
- **Connection path** defaults to **Automatic**, which lets WebRTC select a viable direct or TURN-relayed path. **TURN relay only** forces the encrypted connection through TURN and is available only when the profile contains at least one valid `turn:` or `turns:` URL.
|
||||
|
||||
The sending device controls its outgoing message size. Select the same conservative preset on every device which may send across the constrained path. Existing devices do not receive the choice retrospectively merely because another device changed it.
|
||||
|
||||
Both compatibility choices belong to the saved P2P profile and are retained in P2P connection strings and encrypted Setup URIs. Separate profiles may use the same Group ID, passphrase, and relay list while selecting different compatibility choices. Only the selected P2P profile joins the group.
|
||||
|
||||
## P2P Status
|
||||
|
||||
The **P2P Status** pane is the current Obsidian interface for P2P connections.
|
||||
|
||||
+22
-3
@@ -36,7 +36,7 @@ The flag deliberately enables file logging, which may affect performance. Remove
|
||||
|
||||
Use this workflow when one file, or a small number of known files, has conflicts, missing chunks, or a difference between the current Vault file and the local LiveSync database. The inspection is device-local: it does not query a remote database or prove that another device has the same chunks.
|
||||
|
||||
The `Hatch` recovery controls are ordered by escalation. Running **Recreate chunks for current Vault files** again with unchanged chunk settings and file contents produces the same chunks, and does not alter the revision tree. **Inspect conflicts and file/database differences** then provides actions for exact revisions. **Resolve All conflicted files by the newer one** is last because it applies a modification-time policy in bulk and logically deletes every other live version.
|
||||
The `Hatch` recovery controls are ordered by escalation. Running **Recreate chunks for current Vault files** again with unchanged chunk settings and file contents produces the same chunks, and does not alter the revision tree. **Inspect conflicts and file/database differences** then provides actions for exact revisions. **Resolve All conflicted files by the newer one** is last because it applies a modification-time policy in bulk and logically deletes every other current version.
|
||||
|
||||
1. Stop editing the affected file, pause replication on the participating devices, and keep a separate copy of every readable version.
|
||||
2. If another device or backup has the intended content, preserve that copy before changing any revision.
|
||||
@@ -52,7 +52,26 @@ The `Hatch` recovery controls are ordered by escalation. Running **Recreate chun
|
||||
- **Apply logical deletion to Vault**, **Discard this branch**, and **Discard unreadable revision** are destructive decisions. Use them only after preserving every version which may still be needed.
|
||||
7. Synchronise the healthy source if chunks were restored, scan again, and confirm that the expected conflict or difference has disappeared before resuming ordinary editing.
|
||||
|
||||
An absent Vault file and a logical-deletion winner already agree and do not require a repair card unless another live branch remains. If the scan reports many unrelated files, or the local database itself is incomplete or corrupt, stop the per-file workflow and use [Reset synchronisation on this device](#reset-synchronisation-on-this-device) from a trusted remote. If the central remote must instead be reconstructed from an authoritative Vault, use [Overwrite server data with this device's files](#overwrite-server-data-with-this-devices-files).
|
||||
An absent Vault file and a logical-deletion winner already agree and do not require a repair card unless another conflict branch remains. If the scan reports many unrelated files, or the local database itself is incomplete or corrupt, stop the per-file workflow and use [Reset synchronisation on this device](#reset-synchronisation-on-this-device) from a trusted remote. If the central remote must instead be reconstructed from an authoritative Vault, use [Overwrite server data with this device's files](#overwrite-server-data-with-this-devices-files).
|
||||
|
||||
Metadata document-ID mismatches use a separate action in the same Inspector. Follow [Repair a Metadata document ID mismatch](#repair-a-metadata-document-id-mismatch) rather than applying a file revision by path.
|
||||
|
||||
## Repair a Metadata document ID mismatch
|
||||
|
||||
Use this workflow when **Inspect conflicts and file/database differences** reports `Metadata entry requires review and was left unchanged`. The Inspector found local Metadata whose stored document ID no longer represents its recorded path. It leaves the entry unchanged, while any consistently addressed Metadata for the same logical path remains available to ordinary inspection and Vault reflection. This inspection does not query the remote.
|
||||
|
||||
1. Back up this device. If other devices share the database, stop editing and pause synchronisation on them.
|
||||
2. Confirm that the current file-name case and path obfuscation settings are intended for this database. If either setting was deliberately changed for the whole database, stop this workflow and use Rebuild instead.
|
||||
3. Open **Self-hosted LiveSync settings** → **Hatch** → **Inspect conflicts and file/database differences**, then select **Begin inspection**.
|
||||
4. Find the affected Metadata card and review its recorded path, stored document ID, expected document ID, and source revision.
|
||||
5. Continue only when the card says `Repair is available for this entry.` Open its wrench menu and select **Repair this Metadata document ID**. If the action is unavailable, do not force an ID: the entry is ambiguous, conflicted, deleted, outside the normal-file namespace, or otherwise unsafe for one-entry repair.
|
||||
6. Review the warning and select **Repair Metadata ID**. LiveSync rechecks the source revision and expected ID, writes and verifies the target, then removes the obsolete ID.
|
||||
7. Wait for the ordinary Vault scan to complete. If LiveSync reports that the repair completed but the scan did not run, keep synchronisation paused, resolve the reported scan condition, then run the **Scan storage and database again** command.
|
||||
8. Allow this device to upload the repair. Resume the other devices one at a time, then run the inspection again and confirm that the Metadata card no longer appears and the Vault file has the intended content.
|
||||
|
||||
This action changes one local database entry. It does not rename Vault files or folders, repair several entries at once, coordinate other devices, or preserve CouchDB revision ancestry across the two document IDs.
|
||||
|
||||
If many entries reflect folder-name differences across devices, stop every device, choose the authoritative Vault, close Obsidian, correct the actual storage names with operating-system tools, then rebuild the central remote from that Vault and reset the other devices. During Fast Setup on an empty Vault, there are no storage names to correct: allow consistently addressable Metadata to be reflected, then inspect any remaining unresolved references.
|
||||
|
||||
## Reset synchronisation on this device
|
||||
|
||||
@@ -93,7 +112,7 @@ Garbage Collection removes unreferenced chunks while preserving the current data
|
||||
- all relevant devices have synchronised; and
|
||||
- the remaining historical and deletion state is understood.
|
||||
|
||||
Deleted documents, tombstones, live conflicts, and retained metadata are not free. Live conflict branches keep the chunks needed for review, while an ordinary superseded linear revision does not protect its former chunks. Garbage Collection can therefore make old content unreadable and cannot promise the smallest possible remote. Review the [Garbage Collection V3 specification](specs_garbage_collection.md) before using it.
|
||||
Deleted documents, tombstones, unresolved conflicts, and retained metadata are not free. Conflict branches keep the chunks needed for review, while an ordinary superseded linear revision does not protect its former chunks. Garbage Collection can therefore make old content unreadable and cannot promise the smallest possible remote. Review the [Garbage Collection V3 specification](specs_garbage_collection.md) before using it.
|
||||
|
||||
Rebuild is a different operation. It reconstructs the database from a chosen authoritative state and is the more certain way to remove unwanted history or repair a damaged remote, but it is also more disruptive and can discard changes which exist only elsewhere.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 1.0 preview release history
|
||||
|
||||
This document records the opt-in beta and release-candidate builds published before 1.0.0. Most users upgrading from 0.25.83 only need the consolidated [1.0.0 release notes](../../updates.md).
|
||||
This document records the opt-in beta and release-candidate builds published before 1.0.0. Most users upgrading from 0.25.83 only need the consolidated [1.0.0 release notes](1.0.md#100).
|
||||
|
||||
The prepared `1.0.0-rc.0` tag was not published as a plug-in release and is therefore omitted.
|
||||
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
# 1.0 release history
|
||||
|
||||
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.15
|
||||
|
||||
15th August, 2026
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Improved
|
||||
|
||||
- Start-up offline scanning is now faster, especially for larger Vaults using path obfuscation (Commonlib 0.1.15).
|
||||
|
||||
### Interface and translation
|
||||
|
||||
#### Improved
|
||||
|
||||
- The Traditional Chinese translation catalogue has been completed and polished for broader coverage and more natural, consistent terminology (PR #1106). Thank you to @nimula for the contribution!
|
||||
|
||||
## 1.0.14
|
||||
|
||||
14th August, 2026
|
||||
|
||||
Thank you for your patience. At last, it looks as though we can clear some of the Community Review warnings.
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Fixed
|
||||
|
||||
- CouchDB operations which run to completion now close their temporary remote database connections after use across both Commonlib and LiveSync, including Setup Wizard and settings probes, command-line milestone verification, database maintenance, Security Seed refreshes, status queries, and retry and error paths (Commonlib PR #112).
|
||||
- This covers every temporary connection currently identified as a possible contributor to the long-running resource growth tracked in #1034. Validation over extended sessions is continuing.
|
||||
- Thank you to @apple-ouyang for the contribution!
|
||||
|
||||
## 1.0.13
|
||||
|
||||
13th August, 2026
|
||||
|
||||
### Conflict handling and recovery
|
||||
|
||||
#### Improved
|
||||
|
||||
- **Inspect conflicts and file/database differences** now reports local Metadata whose stored document ID does not match the ID derived from its recorded path. Ordinary scans leave unresolved entries and their corresponding Vault paths unchanged, while allowing consistently addressed Metadata for the same logical path to proceed normally.
|
||||
- When the current winner has no conflict leaves and has an unambiguous target, its wrench menu can repair that one local Metadata document after separate confirmation. The target is written and verified before the mismatched source ID is removed; ambiguous or otherwise unsafe entries remain read-only.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Fast Fetch now writes deletion tombstones to the local database without attempting to decrypt them. A tombstone has no encrypted payload, and decryption previously aborted the whole fetch at the first deleted document. New devices could not complete their initial sync on vaults that contain old deletions (Commonlib PR #108).
|
||||
- Thank you to @KennethLloyd for the contribution!
|
||||
|
||||
## 1.0.12
|
||||
|
||||
11th August, 2026
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Fixed
|
||||
|
||||
- One-shot CouchDB replication now closes its temporary remote database after each run and before retrying, preventing inactive PouchDB instances from accumulating during long-running periodic synchronisation (Commonlib PR #75). Thank you to @apple-ouyang for the contribution!
|
||||
- Start-up and recovery scans now keep failed database-to-Vault writes retryable instead of recording them as successful and later mistaking the still-missing file for a local deletion (Commonlib PR #106).
|
||||
- Files over the size limit or in conflict remain deliberately skipped, while actual write failures are reported to Fast Setup, CLI mirror, and daemon callers.
|
||||
|
||||
## 1.0.11
|
||||
|
||||
9th August, 2026
|
||||
|
||||
### Setup and compatibility
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Fast Setup now uses Standard Fetch when CouchDB's 'Use Internal API' setting is enabled, avoiding a streaming request path which Obsidian's buffered API cannot support (#1020).
|
||||
- Custom headers alone continue to use Fast Fetch when browser CORS permits them; Standard Fetch clears any obsolete Fast Fetch checkpoint after resetting the local database.
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Fractional file timestamps no longer cause affected mobile clients to crash after synchronisation (#1087, PR #1039). Thank you to @andrewleech for the contribution!
|
||||
- Timestamps are now normalised in the command-line tool and before Obsidian's native file-system writes.
|
||||
|
||||
## 1.0.10
|
||||
|
||||
9th August, 2026
|
||||
|
||||
### Setup and compatibility
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Fast Setup now sends configured CouchDB custom headers with every changes-feed request, allowing reverse proxies such as Cloudflare Access to authenticate initial setup in the same way as ordinary synchronisation ([Commonlib PR #82](https://github.com/vrtmrz/livesync-commonlib/pull/82)). Thank you to @nimula for the contribution!
|
||||
|
||||
## 1.0.9
|
||||
|
||||
8th August, 2026
|
||||
|
||||
For the first time in a while, I published a release that could not be promoted to a stable release. Sorry about that! I am glad that we caught it while it was still a pre-release.
|
||||
|
||||
### Setup and compatibility
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Multi-part settings QR codes now preserve special characters in passwords, passphrases, and other settings (PR #1083). Thank you to @calvinbui for the improvement!
|
||||
- Fast Setup now sizes each finite CouchDB changes page from a one-row status probe, counts the returned result together with `pending`, and resumes from the page's opaque `last_seq` without comparing token representations. Each page uses a one-second idle timeout instead of a heartbeat, allowing CouchDB 3.2 to return its terminator after the currently available rows have been persisted.
|
||||
|
||||
## 1.0.8
|
||||
|
||||
8th August, 2026
|
||||
|
||||
This version was published for pre-release validation only and was not promoted to a stable release.
|
||||
|
||||
### Setup and compatibility
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Fast Setup now sizes each finite CouchDB changes page from a one-row status probe, counts the returned result together with `pending`, and resumes from the page's opaque `last_seq` without comparing token representations. Heartbeat-enabled feeds no longer wait for future writes after the currently available rows have been persisted (#1065).
|
||||
- Cancelling remote selection during a scheduled Fetch now removes the Fetch flag before restarting with file and database reflection paused, preventing the same selection dialogue from reopening on every start-up.
|
||||
|
||||
## 1.0.7
|
||||
|
||||
8th August, 2026
|
||||
|
||||
### Setup and compatibility
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Fast Setup now completes only after the captured CouchDB changes target has been persisted. Decryption, protocol, and local write failures stop the operation without finalising an incomplete database, while transient interruptions resume from the last durable checkpoint (#1065).
|
||||
|
||||
## 1.0.6
|
||||
|
||||
6th August, 2026
|
||||
|
||||
I know that onboarding, and other parts which feel unclear or confusing, still need improvement. Please do report any such cases.
|
||||
|
||||
### Setup and compatibility
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Initial setup now distinguishes an empty remote with no saved synchronisation settings from a failed remote read. New remotes can use this device's settings without an unnecessary retry; Fetch pauses on unreadable settings, while Rebuild can explicitly continue with this device's settings. Cancelling preserves the selected automatic synchronisation mode and restarts with Vault and database reflection paused (#1064). Thank you to @mateus2k2 for the follow-up report!
|
||||
|
||||
## 1.0.5
|
||||
|
||||
5th August, 2026
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Improved
|
||||
|
||||
- Added settings to control whether finite synchronisation operations keep the screen awake. Desktop devices now allow automatic sleep by default, while mobile devices retain screen-awake protection unless the general option is enabled (#1073).
|
||||
|
||||
### Interface and translation
|
||||
|
||||
#### Improved
|
||||
|
||||
- Korean translations now cover the complete current catalogue across Setup, P2P, remote configuration, diagnostics, and maintenance (PR #1075). Thank you to @motolies for the improvement!
|
||||
|
||||
#### Fixed
|
||||
|
||||
- The in-editor LiveSync status on iOS now remains below the view-header controls instead of overlapping them (PR #1067). Thank you to @Hsiii for the improvement!
|
||||
|
||||
## 1.0.4
|
||||
|
||||
5th August, 2026
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Testing or saving a fresh remote configuration no longer tries to access the local database while constructing a replicator, avoiding 'Local database is not ready yet' failures before local database initialisation (#1064).
|
||||
|
||||
### Command-line tool
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Successful setup and remote-configuration commands now retain their settings changes. Other commands leave the settings file unchanged unless `--write-settings` is supplied, and temporary CLI suspension values are never written (#1070).
|
||||
|
||||
## 1.0.3
|
||||
|
||||
3rd August, 2026
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Fixed
|
||||
|
||||
- File consistency checks no longer read older revisions after the current Vault content matches known synchronised history, avoiding unnecessary 'Missing document content' warnings from obsolete unreadable revisions.
|
||||
- Remote chunk fetching now retains chunks which were returned successfully when another chunk in the same request is unavailable, so the available content can still be processed (#771).
|
||||
|
||||
### Interface
|
||||
|
||||
#### Fixed
|
||||
|
||||
- The Remediation setting now displays its configured modification-time limit without raising a `HierarchyRequestError`.
|
||||
|
||||
## 1.0.2
|
||||
|
||||
31st July, 2026
|
||||
|
||||
I am aware that some of the Community Directory review checks have become a little more sensitive again. I will watch them for a little longer, then consider the most appropriate way to adapt.
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Improved
|
||||
|
||||
- Downloaded document batches retain best-effort screen-awake and lifecycle protection until every queued file has been applied to local storage, without extending the remote-activity indicator (#1031, PR #1032). Thank you to @apple-ouyang for the improvement!
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Leading UTF-8 byte order marks are preserved during Vault ingestion, keeping stored content sizes consistent with file metadata and preventing persistent three-byte integrity mismatches (#1056, PR #1058).
|
||||
|
||||
### Interface and translation
|
||||
|
||||
#### Improved
|
||||
|
||||
- Document History now provides previous and next revision controls, reports the current revision position, and disables navigation at the oldest and newest boundaries without changing search-result navigation (#990, PR #1009). Thank you to @SeleiXi for the improvement!
|
||||
- Remaining user-visible text in Setup, P2P, Customisation Sync, Global History, JSON conflict handling, and remote configuration now uses the translation catalogue (PR #1015). Thank you to @zeedif for the improvement!
|
||||
- Korean translations have broader coverage and corrections for placeholders, punctuation, and established terminology (PR #1055). Thank you to @motolies for the improvement!
|
||||
- Spanish translation coverage has been expanded across settings, Setup, P2P, maintenance, and newly catalogued interface text (PR #1059). Thank you to @zeedif for the improvement!
|
||||
|
||||
### Command-line tool
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Large-buffer base64 encoding under Node.js now uses the published `octagonal-wheels` fallback when `FileReader` is unavailable, including correctly handling sliced binary views (#1036, PR #1060; [Fancy Kit PR #44](https://github.com/vrtmrz/fancy-kit/pull/44)).
|
||||
|
||||
## 1.0.1
|
||||
|
||||
29th July, 2026
|
||||
|
||||
I am taking this opportunity to update the experimental features as well.
|
||||
|
||||
This maintenance release mainly improves the robustness and maintainability of the experimental WebApp, WebPeer, and shared dialogue composition. Most plug-in users can skip it. I have reviewed the changes through CI and a real Obsidian instance, and I will validate the exact published build before merging the release commit.
|
||||
|
||||
### Interface
|
||||
|
||||
#### Improved
|
||||
|
||||
- Removed a custom positioning workaround from the onboarding Notice so that it follows Obsidian's standard placement and dismissal behaviour.
|
||||
- WebApp now points users to **Scan local files** when automatic file observation is unavailable, instead of relying on a fixed browser-version recommendation.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
27th July, 2026
|
||||
|
||||
The work towards 1.0 has become so substantial that I have written [an article about it](https://fancy-syncing.vrtmrz.net/blog/0036-livesync-1_0_0-en.html) (linked again here).
|
||||
|
||||
### Setup and compatibility
|
||||
|
||||
#### Improved
|
||||
|
||||
- An unconfigured Vault now waits for the user to start setup. Onboarding is offered through a persistent Notice and remains available from **Self-hosted LiveSync settings** → **Setup**.
|
||||
- Setup now creates named CouchDB, Object Storage, and P2P connections. Setup URIs preserve their connection names and selections, and reserve Fetch or Rebuild before the ordinary start-up scan begins.
|
||||
- Manual CouchDB setup distinguishes creating the first database from connecting another device. Onboarding requires a successful connection, while Settings can explicitly save an unverified connection and offers each server-setting correction separately.
|
||||
- Compatible differences limited to the chunk hash algorithm, chunk size, or splitter version are aligned automatically by default. Existing chunks remain readable, an explicit opt-out remains available, and differences involving incompatible settings still require review.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Existing Vaults retain their effective legacy settings, including the case-insensitive file-name fallback used when an older release had no explicit case setting.
|
||||
|
||||
#### Security
|
||||
|
||||
- Fly.io setup generates CouchDB and Vault encryption secrets with cryptographically secure randomness.
|
||||
- Dependency updates address excessive CPU use from crafted path patterns and `mailto:` links.
|
||||
|
||||
### Conflict handling and recovery
|
||||
|
||||
#### Improved
|
||||
|
||||
- **Not now** postpones repeated automatic merge dialogues while retaining the unresolved-conflict warning. Three or more live revisions are reviewed one reproducible pair at a time, completed pairs remain resolved across restart, and explicit commands can reopen a postponed conflict.
|
||||
- **Inspect conflicts and file/database differences** compares the Vault with the database winner and every live conflict revision. Compact indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflicts remain.
|
||||
- Each reported file and live revision has a compact wrench menu for comparison, applying an exact readable revision, recording an exact byte match, storing the Vault content as a child of a selected branch, retrying missing chunks without changing the tree, or explicitly discarding one selected live branch.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Automatic text and structured-data merge now uses the nearest revision actually shared by both branches. A resolution received from another device no longer recreates the same conflict merely because the Vault still contains the exact content of the removed branch.
|
||||
- Edits, logical deletions, and renames made while a file remains conflicted extend the revision displayed on that device. When the relationship cannot be proved, LiveSync preserves the branches for review.
|
||||
- Unreadable live revisions are preserved during automatic handling. An absent Vault file and a winning logical deletion are treated as agreement unless another live branch still requires attention.
|
||||
- Garbage Collection V3 is limited to CouchDB and now protects every live conflict branch, required shared ancestry, and shared chunks. It stops when device progress cannot be verified and reports compaction failure without a contradictory success message.
|
||||
|
||||
### P2P and optional synchronisation features
|
||||
|
||||
#### Improved
|
||||
|
||||
- P2P and Hidden File Sync remain supported opt-in features. Customisation Sync remains a supported Advanced workflow, while Data Compression remains available but disabled by default.
|
||||
- P2P controls remain outside the ordinary CouchDB experience until P2P is configured. The current status pane distinguishes announcing changes, following a peer, and persistent per-device actions.
|
||||
- P2P setup and guidance now distinguish the required signalling relay from optional TURN and describe the replaceable public relay's privacy and availability limits.
|
||||
- Enabling Hidden File Sync opens one progress Notice before saving the setting and reuses it until the initial scan has finished instead of stacking phase, reload, and restart messages.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- First-device P2P setup can complete its signalling test without another peer online. Fetch on an additional device still requires an available source peer and a completed P2P Rebuild.
|
||||
- P2P relay connections now close and are recreated reliably after settings changes and database resets.
|
||||
|
||||
### Interface, translation, and operations
|
||||
|
||||
#### Improved
|
||||
|
||||
- Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands retain their identifiers so that existing hotkeys continue to work.
|
||||
- Setup and review dialogue text can be selected for copying or translation.
|
||||
- Remote-size warnings use persistent clickable Notices. Initial uploads and Rebuild no longer ask to send every chunk in advance; ordinary replication completes the transfer.
|
||||
- Obsolete controls for the plug-in trash setting and fixed chunk revisions were removed. The Change Log remains available but no longer opens automatically or tracks an unread count.
|
||||
- Self-hosted LiveSync now owns its translation catalogue. Commonlib supplies canonical English to other consumers, while translation contributions can be made in the main Self-hosted LiveSync repository.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Applying an available interface translation no longer holds start-up behind an unsolicited dialogue; a persistent Notice opens the existing details on demand.
|
||||
- Action buttons are arranged for narrow mobile screens, long dialogues keep their controls reachable, and persistent Notices no longer cover close controls.
|
||||
|
||||
### Storage and file selection
|
||||
|
||||
#### Fixed
|
||||
|
||||
- The optional Custom HTTP Handler used by Object Storage sends the correct byte range from binary request bodies and reports unsupported body types instead of silently sending an empty request.
|
||||
- Broadening selectors, ignore rules, size or modification-time limits, or file-name case handling now rechecks previously received files without requiring another remote update.
|
||||
- Start-up and full-inspection scans omit built-in legacy LiveSync log files and recovery flag files before comparing Vault and local-database state. Existing ignored database records remain untouched, and user-configured ignore behaviour is unchanged.
|
||||
|
||||
### Command-line tool
|
||||
|
||||
#### Fixed
|
||||
|
||||
- CLI Setup URI validation now uses the supported Commonlib ESM package interface.
|
||||
- The non-root Docker image no longer depends on permissions inherited from the source checkout.
|
||||
|
||||
#### Security
|
||||
|
||||
- The CLI rejects detected path traversal and symbolic-link components before Vault operations.
|
||||
|
||||
### Validation
|
||||
|
||||
#### Testing
|
||||
|
||||
- Expanded automated Real Obsidian coverage for upgrades, two-device synchronisation, CouchDB, Object Storage, P2P, Hidden File Sync, mobile dialogues, conflict and revision recovery, failure diagnostics, and strict clean-up.
|
||||
- Real CouchDB integration coverage verifies logical deletion, shared and conflict chunk retention, compaction, downstream replication, and recreation of content-addressed chunks.
|
||||
- An encrypted Real Obsidian reconnect scenario replaces the remote Security Seed while one client retains the previous value, verifies that synchronisation adopts the replacement without restoring the old value, and proves a bidirectional encrypted round-trip.
|
||||
- The plug-in code in this release was installed through BRAT and validated on macOS, iOS, and Android, including upgrade from 0.25.83, bidirectional synchronisation, P2P setup, conflict handling, recovery controls, mobile layouts, and start-up with existing configurations.
|
||||
- Native and non-root Docker CLI scenarios cover setup, write, read, list, information, deletion, conflict resolution, and revision retrieval with the packaged Commonlib dependency.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Legacy release history
|
||||
|
||||
This history covers releases before 0.25. Later releases are recorded in the [0.25 history](0.25.md) and the [current release history](../../updates.md).
|
||||
This history covers releases before 0.25. Later releases are recorded in the [0.25 history](0.25.md), the [earlier 1.0 history](1.0.md), and the [current release history](../../updates.md).
|
||||
|
||||
## 0.24
|
||||
|
||||
|
||||
+81
-21
@@ -4,6 +4,19 @@ NOTE: This document not completed. I'll improve this doc in a while. but your co
|
||||
|
||||
There are many settings in Self-hosted LiveSync. This document describes each setting in detail (not how-to). Configuration and settings are divided into several categories and indicated by icons. The icon is as follows:
|
||||
|
||||
On Obsidian 1.13 or later, the root settings page is organised by task. On an unconfigured installation, **Quick Setup** appears first, followed by **Synchronisation** and **General Settings**. Once this plug-in has been configured, **Synchronisation** and **General Settings** appear first, followed by **Set up other devices** and **Quick Setup**. Earlier supported Obsidian versions retain a pane-based interface with the same controls; they open **Quick Setup** when unconfigured and **General Settings** when configured.
|
||||
|
||||
| Icon | Root group | Contents or availability |
|
||||
| :--: | ------------------------ | ------------------------------------------------------------- |
|
||||
| 🧙♂️ | Quick Setup | Setup URI, onboarding, and enable actions |
|
||||
| 🔄 | Synchronisation | Remote Configuration and Sync Settings |
|
||||
| ⚙️ | General Settings | Appearance, Logging, and Extra menus |
|
||||
| 📲 | Set up other devices | Copy a Setup URI or show its QR code after configuration |
|
||||
| 🛠️ | Maintenance and recovery | Maintenance and Hatch |
|
||||
| 🧩 | Extra features | Selector and Customisation sync when advanced features appear |
|
||||
| 🔧 | Advanced settings | Advanced, Power users, and Patches when their modes appear |
|
||||
| ℹ️ | Help and information | Help and troubleshooting, and Change Log |
|
||||
|
||||
## Feature maturity for 1.0
|
||||
|
||||
The following status applies to optional and compatibility features in the 1.0 line:
|
||||
@@ -15,10 +28,32 @@ The following status applies to optional and compatibility features in the 1.0 l
|
||||
| Beta or experimental | JWT authentication, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 for CouchDB | Retained for explicit testing and specialised use. They remain disabled by default and are not part of the minimum supported setup. |
|
||||
| Compatibility only | V1 dynamic iteration counts, the old IndexedDB adapter, non-current hash algorithms, Eden chunks, and the stored `doNotUseFixedRevisionForChunks` key | Existing settings and data remain readable. New Vaults use the current defaults, and compatibility controls are shown only where a migration or recovery path still needs them. |
|
||||
|
||||
### Apply changes which require initialisation
|
||||
|
||||
Some compatibility settings are not saved immediately. They remain pending
|
||||
until **Apply** is selected. The Apply action remains visible on the root
|
||||
settings page and on the relevant child page. The following dialogue asks which
|
||||
existing data should be used after restarting:
|
||||
|
||||
- **Reset Synchronisation on This Device** reconstructs this device's local
|
||||
database from the configured remote. For P2P, an online source device is
|
||||
selected after restart.
|
||||
- **Overwrite Server Data with This Device's Files** reconstructs the local and
|
||||
remote databases from this Vault. The P2P equivalent prepares only this
|
||||
device from its current Vault files.
|
||||
- **Review another way to apply these settings** returns to a separate choice
|
||||
between keeping the changes pending and applying them without initialisation.
|
||||
Applying them alone is an advanced compatibility fallback and can make the
|
||||
device incompatible with its existing synchronisation data.
|
||||
|
||||
LiveSync reserves the selected next-start operation before saving the pending
|
||||
settings. If validation or that reservation fails, the settings remain
|
||||
unapplied and the settings-only fallback is not offered.
|
||||
|
||||
| Icon | Description |
|
||||
| :--: | ------------------------------------------------------------------ |
|
||||
| 💬 | [0. Change Log](#0-change-log) |
|
||||
| 🧙♂️ | [1. Setup](#1-setup) |
|
||||
| 🧙♂️ | [1. Quick Setup and Extra menus](#1-quick-setup-and-extra-menus) |
|
||||
| ⚙️ | [2. General Settings](#2-general-settings) |
|
||||
| 🛰️ | [3. Remote Configuration](#3-remote-configuration) |
|
||||
| 🔄 | [4. Sync Settings](#4-sync-settings) |
|
||||
@@ -34,17 +69,19 @@ The following status applies to optional and compatibility features in the 1.0 l
|
||||
|
||||
This pane always shows the current release history. It does not track whether a particular plug-in version has been read and does not open automatically after an ordinary update.
|
||||
|
||||
Internal database or settings compatibility reviews use a separate safety dialogue, not this pane. The dialogue explains why remote synchronisation has been paused and preserves the automatic synchronisation choices which were configured before the update. A configured Vault which was copied, restored, or opened in a new Obsidian profile can require this review because its device-local acknowledgement is not part of the Vault data. An empty local database is not accepted as evidence that it is safe to continue. An existing unconfigured Vault remains in onboarding without this synchronisation warning; its missing acknowledgement is not filled in automatically, so it is evaluated if the Vault is configured later. Closing the dialogue keeps synchronisation paused. When the detected state can be handled by the running version, the explicit resume action records the current internal database version and restores the configured behaviour. A persistent Notice and the `Review why synchronisation is paused` command reopen the review. An older installation cannot dismiss a pause caused by a newer database or settings version.
|
||||
Internal database or settings compatibility reviews use a separate safety dialogue, not this pane. After the Obsidian layout is ready, a pending review opens as **Synchronisation paused for compatibility review**. The dialogue explains why remote synchronisation has been paused and preserves the automatic synchronisation choices which were configured before the update. Closing it or selecting **Keep synchronisation paused** leaves synchronisation paused. Use the persistent Notice's **Review why** link, or run the `Review why synchronisation is paused` command, to reopen it. Opening **Change Log** does not acknowledge the review.
|
||||
|
||||
## 1. Setup
|
||||
A configured Vault which was copied, restored, or opened in a new Obsidian profile can require this review because its device-local acknowledgement is not part of the Vault data. An empty local database is not accepted as evidence that it is safe to continue. An existing unconfigured Vault remains in onboarding without this synchronisation warning; its missing acknowledgement is not filled in automatically, so it is evaluated if the Vault is configured later. When the detected state can be handled by the running version, **Resume synchronisation** records the current internal database version and restores the configured behaviour. An older installation cannot dismiss a pause caused by a newer database or settings version.
|
||||
|
||||
This pane is used for setting up Self-hosted LiveSync. There are several options to set up Self-hosted LiveSync.
|
||||
## 1. Quick Setup and Extra menus
|
||||
|
||||
An unconfigured installation does not open the onboarding dialogue automatically or scan the Vault into the local database. A long-lived Notice offers the onboarding action. If the Notice is dismissed, open **Self-hosted LiveSync settings** → **Setup** → **Rerun Onboarding Wizard**.
|
||||
Quick Setup contains the actions used to configure Self-hosted LiveSync. On Obsidian 1.13 or later these actions appear on the root settings page. In the pane-based interface, they remain available together on the **Quick Setup** pane.
|
||||
|
||||
An unconfigured installation does not open the onboarding dialogue automatically or scan the Vault into the local database. A long-lived Notice offers the onboarding action. If the Notice is dismissed, use **Rerun Onboarding Wizard** in the root **Quick Setup** group on Obsidian 1.13 or later. On earlier supported Obsidian versions, open **Self-hosted LiveSync settings** → **Quick Setup** → **Rerun Onboarding Wizard**.
|
||||
|
||||
Choose the new-device path when this device owns the files which should initialise synchronisation. Choose the existing-device path when it should receive an established remote state. The wizard reserves Rebuild or Fetch respectively before enabling the settings and requesting a restart, so the selected initialisation runs before the ordinary start-up scan.
|
||||
|
||||
### 1. Quick Setup
|
||||
### 1. Setup actions
|
||||
|
||||
Most preferred method to setup Self-hosted LiveSync. You can setup Self-hosted LiveSync with a few clicks.
|
||||
|
||||
@@ -64,22 +101,15 @@ Completing manual CouchDB, Object Storage, or P2P setup creates the correspondin
|
||||
|
||||
This button only appears when the setup was not completed. If you have completed the setup manually, you can enable LiveSync on this device by this button.
|
||||
|
||||
### 2. To setup other devices
|
||||
### 2. Set up other devices
|
||||
|
||||
#### Copy the current settings to a Setup URI
|
||||
|
||||
You can copy the current settings as a new setup URI. And this URI can be used to setup the other devices as [Use the copied setup URI](#use-the-copied-setup-uri).
|
||||
|
||||
### 3. Reset
|
||||
### 3. Extra menus
|
||||
|
||||
#### Discard existing settings and databases
|
||||
|
||||
Reset the Self-hosted LiveSync settings and databases.
|
||||
**Hazardous operation. Please be careful when using this.**
|
||||
|
||||
### 4. Enable extra and advanced features
|
||||
|
||||
To keep the set-up dialogue simple, some panes are hidden in default. You can enable them here.
|
||||
To keep the settings dialogue concise, some menus and features are hidden by default. On Obsidian 1.13 or later, enable them through **General Settings** → **Extra menus**. In the pane-based interface, the same controls appear in General Settings.
|
||||
|
||||
#### Enable advanced features
|
||||
|
||||
@@ -413,6 +443,14 @@ Setting key: P2P_relays
|
||||
|
||||
The Nostr-compatible WebSocket relay URL or URLs used for peer discovery and WebRTC connection negotiation. Multiple URLs can be separated by commas. A signalling relay does not store or transfer Vault contents. See [How peer-to-peer synchronisation works](p2p.md).
|
||||
|
||||
The P2P Setup connection test does not interrupt an active P2P room. When the
|
||||
active relay set already covers the requested URLs, the test observes that
|
||||
active signalling transport. If the test would add a relay while P2P is
|
||||
active, it asks you to use the active relay settings or disconnect P2P first.
|
||||
When P2P is idle, the test opens and disposes a short-lived signalling trial.
|
||||
This check does not prove peer discovery, room credentials against another
|
||||
device, or a TURN or WebRTC data path.
|
||||
|
||||
#### Group ID
|
||||
|
||||
Setting key: P2P_roomID
|
||||
@@ -465,6 +503,22 @@ Setting key: P2P_turnCredential
|
||||
|
||||
The password or credential for authentication with the TURN server.
|
||||
|
||||
#### P2P message size
|
||||
|
||||
Setting key: P2P_maxWirePayloadBytes
|
||||
|
||||
This profile setting limits each outgoing Commonlib RPC message before Trystero applies its own framing. It is not a Vault Chunk size, an IP MTU, or an SCTP fragment size. The available presets are **Standard** (15,360 bytes), **Reduced** (2,048 bytes), **Conservative** (1,024 bytes), and **Maximum compatibility** (800 bytes). Smaller values trade throughput for compatibility on paths which appear to drop larger WebRTC messages.
|
||||
|
||||
The sender controls the size of its outgoing messages. Select the same conservative preset on every device which may send across the constrained path. Existing profiles without this key use **Standard**. P2P connection strings and encrypted Setup URIs retain the selected preset.
|
||||
|
||||
#### Connection path
|
||||
|
||||
Setting key: P2P_connectionPath
|
||||
|
||||
**Automatic** lets WebRTC select a viable direct or TURN-relayed path and is the default. **TURN relay only** forces `iceTransportPolicy: 'relay'` and is available only when the profile contains at least one valid `turn:` or `turns:` URL. Removing the last valid TURN URL while relay-only mode is selected restores **Automatic** and displays a Notice.
|
||||
|
||||
This choice belongs to the P2P profile and is retained in P2P connection strings and encrypted Setup URIs. Separate profiles may use the same Group ID and credentials with different compatibility choices; only the selected P2P profile is active.
|
||||
|
||||
## 4. Sync Settings
|
||||
|
||||
### 1. Synchronisation Preset
|
||||
@@ -573,7 +627,7 @@ Should we keep folders that do not have any files inside?
|
||||
|
||||
### 5. Conflict resolution (Advanced)
|
||||
|
||||
Conflict resolution preserves unknown local content and automatically merges only when the available revision history supplies a safe shared base. See [Conflict resolution and revision provenance](specs_conflict_resolution.md) for the revision-tree rules, stale and concurrent resolutions, binary-file limitation, and the device-local provenance used for operations while a conflict is live.
|
||||
Conflict resolution preserves unknown local content and automatically merges only when the available revision history supplies a safe shared base. See [Conflict resolution and revision provenance](specs_conflict_resolution.md) for the revision-tree rules, stale and concurrent resolutions, binary-file limitation, and the device-local provenance used while a conflict remains unresolved.
|
||||
|
||||
#### (BETA) Always overwrite with a newer file
|
||||
|
||||
@@ -745,9 +799,11 @@ Recreate chunks from files currently present in the Vault. This can repair missi
|
||||
|
||||
#### Inspect conflicts and file/database differences
|
||||
|
||||
Compare each Vault file with every current live revision in the local database. Each winner and conflict revision is shown separately with its exact revision identifier, local chunk availability, and relationship to the current Vault file. Unavailable shared ancestors are reported separately because they prevent conservative three-way merging but are not live revisions which can be discarded.
|
||||
Compare each Vault file with every current leaf revision in the local database. Each winner and conflict revision is shown separately with its exact revision identifier, local chunk availability, and relationship to the current Vault file. Unavailable shared ancestors are reported separately because they prevent conservative three-way merging but are not current leaves which can be discarded.
|
||||
|
||||
Select **Begin inspection** to run the inspection. Each reported file and live revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history.
|
||||
Select **Begin inspection** to run the inspection. Each reported file and current leaf revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history.
|
||||
|
||||
The same inspection also reports local Metadata whose stored document ID does not agree with its recorded path. A stale entry does not suppress ordinary inspection when consistently addressed Metadata can still be resolved for that logical path; otherwise, the unresolved path is excluded from ordinary file-repair actions. When the current winner has no conflict leaves and has an unambiguous target, its wrench menu offers a separately confirmed, one-entry repair. The target is derived from the current local file-name case and path obfuscation settings, then written and verified before the obsolete ID is removed. Ambiguous, conflicted, deleted, excluded, or otherwise unsafe entries remain read-only. This action does not rename Vault files or folders. Follow [Repair a Metadata document ID mismatch](recovery.md#repair-a-metadata-document-id-mismatch) for the complete backup, repair, propagation, and verification procedure. For widespread naming differences across devices, use that guide to choose an authoritative Vault, correct its storage names while Obsidian is closed, rebuild the central remote, and reset the other devices.
|
||||
|
||||
#### Resolve All conflicted files by the newer one
|
||||
|
||||
@@ -1061,10 +1117,14 @@ Delete all data on the remote server.
|
||||
|
||||
### 6. Garbage Collection V3 (CouchDB only)
|
||||
|
||||
Garbage Collection V3 identifies chunk documents which are not reachable from any current file or live conflict branch, creates logical deletions for those chunks locally, propagates the deletions to CouchDB, and requests remote compaction.
|
||||
Garbage Collection V3 identifies Chunk documents which are not reachable from any current file or conflict branch, creates logical deletions for those Chunks locally, propagates the deletions to CouchDB, and requests remote compaction.
|
||||
|
||||
Use it only when the Vault, local database, and remote are healthy, and every relevant device has synchronised. It can make an ordinary superseded file revision unreadable when no live state still needs its chunks. It does not repair corruption or replace a deliberate rebuild. See the [Garbage Collection V3 specification](specs_garbage_collection.md).
|
||||
Use it only when the Vault, local database, and remote are healthy, and every relevant device has synchronised. It can make an ordinary superseded file revision unreadable when no current state still needs its Chunks. It does not repair corruption or replace a deliberate rebuild. See the [Garbage Collection V3 specification](specs_garbage_collection.md).
|
||||
|
||||
### 7. Reset
|
||||
|
||||
#### Discard existing settings and databases
|
||||
|
||||
Reset the Self-hosted LiveSync settings and local database. This is a hazardous operation; make a backup before using it.
|
||||
|
||||
#### Delete local database to reset or uninstall Self-hosted LiveSync
|
||||
|
||||
@@ -118,10 +118,12 @@ Please refer to the [official document](https://docs.couchdb.org/en/stable/insta
|
||||
|
||||
Deno 2 is required. Export the CouchDB connection and database details, then run the provisioning wrapper:
|
||||
|
||||
The `username` and `password` in this step are CouchDB administrator credentials. For Docker or Docker Compose, use the same values supplied as `COUCHDB_USER` and `COUCHDB_PASSWORD`. For a direct installation, use the administrator configured during CouchDB setup. The wrapper does not create a separate non-administrator synchronisation account.
|
||||
|
||||
```
|
||||
export hostname=http://localhost:5984
|
||||
export username=<INSERT USERNAME HERE>
|
||||
export password=<INSERT PASSWORD HERE>
|
||||
export username=<INSERT COUCHDB ADMINISTRATOR USERNAME HERE>
|
||||
export password=<INSERT COUCHDB ADMINISTRATOR PASSWORD HERE>
|
||||
export database=obsidiannotes
|
||||
curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | bash
|
||||
```
|
||||
@@ -135,7 +137,7 @@ The wrapper runs the exact registry-pinned Commonlib consumer. When `database` i
|
||||
|
||||
If you are using Docker Compose and the above command does not work or displays `ERROR: Hostname missing`, you can try running the following command, replacing the placeholders with your own values:
|
||||
```
|
||||
curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | hostname=http://<YOUR SERVER IP>:5984 username=<INSERT USERNAME HERE> password=<INSERT PASSWORD HERE> database=obsidiannotes bash
|
||||
curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | hostname=http://<YOUR SERVER IP>:5984 username=<INSERT COUCHDB ADMINISTRATOR USERNAME HERE> password=<INSERT COUCHDB ADMINISTRATOR PASSWORD HERE> database=obsidiannotes bash
|
||||
```
|
||||
|
||||
## 3. Expose CouchDB to the Internet
|
||||
@@ -170,10 +172,13 @@ Now `https://tiles-photograph-routine-groundwater.trycloudflare.com` is our serv
|
||||
> A generated Setup URI is the recommended path because it carries the current defaults for a new Vault and the selected remote profile. If a Setup URI cannot be generated, follow [Configure CouchDB manually on the first device](./quick_setup.md#configure-couchdb-manually-on-the-first-device), then generate a new Setup URI from that working device for every additional device.
|
||||
|
||||
### 1. Generate the setup URI on a desktop device or server
|
||||
|
||||
The `username` and `password` here are the credentials which Self-hosted LiveSync will store for routine access to this database. They may be the administrator credentials from step 2. If you have separately configured a CouchDB account with the required access to this database, use that account instead. Neither the provisioning wrapper nor the Setup URI generator creates that separate account.
|
||||
|
||||
```bash
|
||||
export hostname=https://tiles-photograph-routine-groundwater.trycloudflare.com
|
||||
export database=obsidiannotes
|
||||
export username=johndoe
|
||||
export username=<INSERT COUCHDB USERNAME FOR LIVESYNC>
|
||||
export password=<INSERT THE COUCHDB PASSWORD>
|
||||
export passphrase=<INSERT A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export uri_passphrase=<INSERT A SEPARATE SETUP URI PASSPHRASE> # Optional
|
||||
|
||||
@@ -4,7 +4,7 @@ This document describes the conflict-resolution and file-reflection guarantees u
|
||||
|
||||
## Revision-tree model
|
||||
|
||||
PouchDB stores a document as a revision tree. It selects one live leaf as the deterministic winner and reports the other live leaves as conflicts. That winner is not proof that its content is newer, safer, or the version currently shown in the Vault.
|
||||
PouchDB stores a document as a revision tree. It selects one current leaf as the deterministic winner and reports the other current leaves as conflicts. That winner is not proof that its content is newer, safer, or the version currently shown in the Vault.
|
||||
|
||||
For example:
|
||||
|
||||
@@ -14,9 +14,25 @@ A1
|
||||
└── B2 ── C2
|
||||
```
|
||||
|
||||
The two live leaves are `D1` and `C2`. Their nearest shared ancestor is `A1`; neither `B1` nor `B2` is shared. A conservative three-way merge therefore compares the changes from `A1` to each leaf. Matching generation numbers, or selecting the first older revision from one branch, does not prove shared ancestry.
|
||||
The two current leaves are `D1` and `C2`. Their nearest shared ancestor is `A1`; neither `B1` nor `B2` is shared. A conservative three-way merge therefore compares the changes from `A1` to each leaf. Matching generation numbers, or selecting the first older revision from one branch, does not prove shared ancestry.
|
||||
|
||||
Resolving a conflict writes the selected or merged result on one observed branch and deletes the other observed live leaf. A stale device may still have the deleted leaf's content in its Vault when it receives the resolution.
|
||||
Resolving a conflict writes the selected or merged result on one observed branch and deletes the other observed conflict leaf. A stale device may still have the deleted leaf's content in its Vault when it receives the resolution.
|
||||
|
||||
### Independent revision properties
|
||||
|
||||
The modifiers defined under [Revision](terms.md#revision) describe independent properties, rather than exclusive revision types. The winner is a database-tree role, Vault-matching describes current file-state equality, and displayed identifies the device-local branch recorded for the Vault. The same revision commonly has all three properties, but synchronisation, conflicts, local edits, and missing provenance can separate them.
|
||||
|
||||
| Situation | Winner | Vault-matching | Displayed |
|
||||
| ---------------------------------------------------- | ------------------ | --------------------------------------------------- | ---------------------------------------------------- |
|
||||
| A present Vault file is fully converged | `R` | `R` | `R` |
|
||||
| The Vault displays conflict leaf `C` | `W` | `C` | `C` |
|
||||
| The database advances before Vault reflection | new winner `W2` | previous revision `R`, while the Vault is unchanged | `R` |
|
||||
| A local edit of displayed revision `R` is pending | independent | none, or a coincidental content match | `R`, as the branch which the edit must extend |
|
||||
| Provenance is missing and exactly one revision fits | independent | `M` | none, then `M` after safe reconstruction |
|
||||
| Provenance is missing and several revisions fit | independent | every matching revision | none |
|
||||
| A logical-deletion winner agrees with an absent file | deleted winner `D` | `D`, and possibly other logical-deletion revisions | none; an absent file retains no displayed provenance |
|
||||
|
||||
At most one revision is the winner, more than one revision can be Vault-matching, and at most one revision can be displayed for a path on one device. A displayed revision may stop matching the Vault while a local edit is pending, but its branch identity remains authoritative until that edit is stored or the relationship is safely reconstructed.
|
||||
|
||||
## Implemented 1.0 guarantees
|
||||
|
||||
@@ -25,7 +41,7 @@ Resolving a conflict writes the selected or merged result on one observed branch
|
||||
- A receiving Vault file which exactly matches any available revision in the document tree is treated as previously synchronised content. This includes an ancestor below a deleted losing leaf.
|
||||
- A receiving Vault file whose bytes do not match any available revision is preserved as an unsynchronised local change.
|
||||
- File bytes, rather than path, size, modification time, or revision generation, determine whether content is known.
|
||||
- Three or more live versions are reviewed one pair at a time in a deterministic order, with each completed pair committed before the next live pair is read.
|
||||
- Three or more current versions are reviewed one pair at a time in a deterministic order, with each completed pair committed before the next pair is read.
|
||||
- Each device records the exact revision most recently reflected in each Vault file. An edit, deletion, or case-only rename made while a conflict is active extends that displayed branch rather than the deterministic database winner.
|
||||
- A cross-path rename stores the target before logically deleting only the displayed source branch.
|
||||
|
||||
@@ -50,32 +66,32 @@ The compatibility implementation currently selects the newer modification time f
|
||||
|
||||
A document revision can remain in the PouchDB tree while one or more chunks needed to reconstruct its content are unavailable. Missing content is not evidence that the revision is obsolete. LiveSync therefore leaves an unreadable winner or conflict revision in the tree instead of deleting it during automatic conflict processing.
|
||||
|
||||
**Hatch** → **Inspect conflicts and file/database differences** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. A logical-deletion winner and an absent Vault file already agree, so that state is not reported unless another live branch still requires attention. When the Vault already matches the winner but conflict branches remain, the card shows the compact status `✅ Vault matches winner · ⚠️ Conflicts: N`; matching the winner does not mean that the conflict has been resolved.
|
||||
**Hatch** → **Inspect conflicts and file/database differences** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. A logical-deletion winner and an absent Vault file already agree, so that state is not reported unless another conflict branch still requires attention. When the Vault already matches the winner but conflict branches remain, the card shows the compact status `✅ Vault matches winner · ⚠️ Conflicts: N`; matching the winner does not mean that the conflict has been resolved.
|
||||
|
||||
Each reported live revision has a compact wrench menu. The available actions depend on the exact revision and current Vault state:
|
||||
Each reported current leaf revision has a compact wrench menu. The available actions depend on the exact revision and current Vault state:
|
||||
|
||||
- **Compare with Vault** opens the existing difference dialogue in read-only mode for differing text files.
|
||||
- **Apply this revision to Vault** writes the selected readable revision, even when it is not the database winner. Replacing an existing file requires confirmation.
|
||||
- **Mark this revision as the Vault version** is offered when the bytes already match. It records exact device-local provenance without creating a child revision, and refuses the operation if the file changed after inspection.
|
||||
- **Store Vault file as a child of this revision** preserves the current Vault bytes on the explicitly selected live branch.
|
||||
- **Store Vault file as a child of this revision** preserves the current Vault bytes on the explicitly selected current branch.
|
||||
- **Apply logical deletion to Vault** removes an existing Vault file after confirmation. An absent file needs no retained deletion provenance.
|
||||
- **Retry reading revision** attempts the configured chunk-retrieval path again. It does not change the revision tree.
|
||||
- **Discard this branch** is available for each exact live revision while at least one other live branch remains. It requires confirmation and creates a logical deletion on only the selected branch without changing the current Vault file.
|
||||
- **Discard unreadable revision** remains available as a recovery action when an unreadable revision is the only live leaf. It requires confirmation because no other database branch remains.
|
||||
- **Discard this branch** is available for each exact current leaf while at least one other current leaf remains. It requires confirmation and creates a logical deletion on only the selected branch without changing the current Vault file.
|
||||
- **Discard unreadable revision** remains available as a recovery action when an unreadable revision is the only current leaf. It requires confirmation because no other database branch remains.
|
||||
|
||||
Every mutating action rechecks that the selected revision is still a current live leaf. If another operation resolved or replaced it, the action fails and the card is refreshed instead of extending an obsolete branch.
|
||||
Every mutating action rechecks that the selected revision is still a current leaf. If another operation resolved or replaced it, the action fails and the card is refreshed instead of extending an obsolete branch.
|
||||
|
||||
The card uses compact, mobile-friendly diagnostic rows with an emoji and a text label. `🧩 Missing chunks: N` identifies an unreadable revision. In the database row, `Δsize` is decoded size minus recorded size; in the Vault row, `Δsize vs DB` is Vault size minus decoded database size. `Δtime` is Vault modification time minus database modification time. The ordinary two-second comparison window still labels which side is newer. These values help diagnose a mismatch; path, size, and modification time do not prove revision identity or decide which content should win.
|
||||
|
||||
A shared ancestor is informational. An ancestor which is no longer a live revision cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable live revisions can still be selected manually.
|
||||
A shared ancestor is informational. An ancestor which is no longer a current leaf cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable current leaf revisions can still be selected manually.
|
||||
|
||||
Logical deletion does not recreate missing bytes, purge the document history, or prove that the deleted version was unimportant. Another replica or backup may still contain the missing chunks. Recover from that source before discarding a revision whenever possible.
|
||||
|
||||
**Recreate chunks for current Vault files** can recreate chunks only from files which are readable in the current Vault. It cannot reconstruct unique bytes from an unavailable historical or conflict revision.
|
||||
|
||||
Garbage Collection V3 treats every live conflict revision and its nearest available shared ancestor as reachable. Their locally available chunks are retained until the conflict is resolved. After resolution, chunks used only by the discarded branch or no-longer-needed merge ancestry can become eligible for collection. See the [Garbage Collection V3 specification](specs_garbage_collection.md).
|
||||
Garbage Collection V3 treats every conflict leaf and its nearest available shared ancestor as reachable. Their locally available chunks are retained until the conflict is resolved. After resolution, chunks used only by the discarded branch or no-longer-needed merge ancestry can become eligible for collection. See the [Garbage Collection V3 specification](specs_garbage_collection.md).
|
||||
|
||||
A generation-one revision has no parent. When its body is unavailable, LiveSync cannot preserve a changed Vault file as a sibling branch without inventing ancestry. It leaves the operation unresolved. Recover the missing chunks from another replica or backup, or explicitly discard that live revision. If the current Vault file is the intended replacement, it can be stored after the unreadable revision has been logically deleted.
|
||||
A generation-one revision has no parent. When its body is unavailable, LiveSync cannot preserve a changed Vault file as a sibling branch without inventing ancestry. It leaves the operation unresolved. Recover the missing chunks from another replica or backup, or explicitly discard that current leaf. If the current Vault file is the intended replacement, it can be stored after the unreadable revision has been logically deleted.
|
||||
|
||||
### Two devices independently create the same path
|
||||
|
||||
@@ -100,19 +116,19 @@ and otherwise asks the user.
|
||||
|
||||
## Stale and concurrent resolutions
|
||||
|
||||
A device can resolve only the leaves which it has observed. If another device has already extended a branch, later replication can reveal another live leaf and require another resolution. Two devices can also produce different resolutions concurrently, leaving multiple live leaves after their trees meet.
|
||||
A device can resolve only the leaves which it has observed. If another device has already extended a branch, later replication can reveal another current leaf and require another resolution. Two devices can also produce different resolutions concurrently, leaving multiple current leaves after their trees meet.
|
||||
|
||||
A higher revision generation or modification time does not make either result authoritative. The resolver must examine every current live leaf again until one result remains or user action is required. This is continued conflict processing, not a reset of the synchronisation checkpoint.
|
||||
A higher revision generation or modification time does not make either result authoritative. The resolver must examine every current leaf again until one result remains or user action is required. This is continued conflict processing, not a reset of the synchronisation checkpoint.
|
||||
|
||||
## More than two live versions
|
||||
## More than two current versions
|
||||
|
||||
When three or more versions remain, LiveSync compares the current PouchDB winner with one conflict leaf at a time. Commonlib orders the remaining candidates by revision generation ascending, original leaf modification time ascending, then the complete revision ID in code-unit lexical order. A missing or non-finite modification time is ordered before a finite value. Modification time makes pair selection reproducible here; it does not decide which content wins.
|
||||
|
||||
For each pair, LiveSync first collapses identical content, then attempts a conservative sensible merge, and finally asks the user when neither automatic action is safe. A completed action is written to the ordinary revision tree and its losing observed leaf is deleted before LiveSync reads the remaining live leaves again. There is no separate persistent merge accumulator.
|
||||
For each pair, LiveSync first collapses identical content, then attempts a conservative sensible merge, and finally asks the user when neither automatic action is safe. A completed action is written to the ordinary revision tree and its losing observed leaf is deleted before LiveSync reads the remaining current leaves again. There is no separate persistent merge accumulator.
|
||||
|
||||
**Concat both** writes the concatenated result as a new child of the displayed PouchDB winner, then deletes only the other leaf shown in that dialogue. With two live versions, that action resolves the conflict. With three or more, the new child remains live against every untouched leaf and becomes part of the next pairwise review; it does not create an unrelated root or consume an unseen branch.
|
||||
**Concat both** writes the concatenated result as a new child of the displayed PouchDB winner, then deletes only the other leaf shown in that dialogue. With two current versions, that action resolves the conflict. With three or more, the new child remains a current leaf against every untouched leaf and becomes part of the next pairwise review; it does not create an unrelated root or consume an unseen branch.
|
||||
|
||||
Consequently, choosing **Not now** or closing Obsidian cannot undo a completed pair. After restart, LiveSync reconstructs the next pair from the live tree. If replication changes either revision while a dialogue is open, LiveSync discards the stale selection, refreshes the live count, and rechecks the path rather than deleting a revision which was not the one shown.
|
||||
Consequently, choosing **Not now** or closing Obsidian cannot undo a completed pair. After restart, LiveSync reconstructs the next pair from the current revision tree. If replication changes either revision while a dialogue is open, LiveSync discards the stale selection, refreshes the current-version count, and rechecks the path rather than deleting a revision which was not the one shown.
|
||||
|
||||
## Device-local file provenance
|
||||
|
||||
@@ -133,7 +149,7 @@ When no record exists, LiveSync may reconstruct the displayed revision only if t
|
||||
## Operations while a conflict exists
|
||||
|
||||
- Editing a file writes a child of its recorded or uniquely reconstructed displayed revision.
|
||||
- Deleting a file writes a logical-deletion child of that revision. It uses LiveSync's `deleted` marker, rather than a PouchDB `_deleted` tombstone, so the deletion remains a live branch which can replicate and be resolved against the other branch.
|
||||
- Deleting a file writes a logical-deletion child of that revision. It uses LiveSync's `deleted` marker, rather than a PouchDB `_deleted` tombstone, so the deletion remains a current branch which can replicate and be resolved against the other branch.
|
||||
- A case-only rename writes the new path as a child in the same document tree.
|
||||
- A cross-path rename stores the target document first, then writes a logical-deletion child on the displayed source branch.
|
||||
|
||||
@@ -146,7 +162,7 @@ uninterrupted conflict episode in the current plug-in session. Ordinary file
|
||||
checks and replication do not reopen the dialogue while at least one conflict
|
||||
leaf remains. If the in-editor status display is enabled, the active file shows
|
||||
**This file has 3 unresolved versions. They will be reviewed one pair at a
|
||||
time.** for three or more live versions, using the current count, and **This
|
||||
time.** for three or more current versions, using the current count, and **This
|
||||
file has unresolved conflicts.** for two. Postponement therefore does not make
|
||||
the conflict invisible.
|
||||
|
||||
@@ -163,8 +179,8 @@ When synchronisation supplies a resolved document, the existing incoming-file
|
||||
processing event closes an open conflict dialogue for that path. The same event
|
||||
rechecks the local revision tree: if no conflict leaf remains, it ends any
|
||||
postponed episode and removes the active-file warning. If conflict leaves still
|
||||
exist, the stale dialogue closes and the warning changes to the current live
|
||||
version count. A postponed episode stays postponed; otherwise, subsequent
|
||||
exist, the stale dialogue closes and the warning changes to the current-version
|
||||
count. A postponed episode stays postponed; otherwise, subsequent
|
||||
conflict processing may open a fresh dialogue for the current revision tree.
|
||||
Each dialogue owns its completion result, so a prompt which is answered or
|
||||
closed immediately still completes the waiting conflict operation; the result
|
||||
@@ -193,7 +209,7 @@ A1
|
||||
└── B2 ── C2 ── D2 Android edit
|
||||
```
|
||||
|
||||
After synchronisation, both devices receive `C1` and `D2` as the live branches. The edit is not moved silently onto `C1`, and ordinary conflict resolution can compare the real descendants.
|
||||
After synchronisation, both devices receive `C1` and `D2` as the current branches. The edit is not moved silently onto `C1`, and ordinary conflict resolution can compare the real descendants.
|
||||
|
||||
### A user deletes the branch shown on one device
|
||||
|
||||
@@ -205,13 +221,13 @@ A1
|
||||
└── B2 ── C2 ── D2 (deleted: true)
|
||||
```
|
||||
|
||||
The deletion remains one side of the live conflict. The user can still choose between the content at `C1` and deleting the file. LiveSync does not delete `C1` merely because PouchDB selected it as the winner.
|
||||
The deletion remains one current leaf of the conflict. The user can still choose between the content at `C1` and deleting the file. LiveSync does not delete `C1` merely because PouchDB selected it as the winner.
|
||||
|
||||
### A user renames a conflicted file
|
||||
|
||||
If the user changes only the spelling case, such as `Note.md` to `note.md`, LiveSync keeps the rename in the same revision tree and extends the revision displayed on that device.
|
||||
|
||||
If the user renames `draft.md` to `published.md`, LiveSync stores `published.md` before it marks the displayed `draft.md` branch as logically deleted. If an interruption occurs between those operations, the recoverable result is a duplicate which can be reviewed, rather than loss of the only copy. Any other live branch of `draft.md` remains available for conflict resolution.
|
||||
If the user renames `draft.md` to `published.md`, LiveSync stores `published.md` before it marks the displayed `draft.md` branch as logically deleted. If an interruption occurs between those operations, the recoverable result is a duplicate which can be reviewed, rather than loss of the only copy. Any other conflict branch of `draft.md` remains available for conflict resolution.
|
||||
|
||||
### A remote resolution reaches a device which still shows the losing content
|
||||
|
||||
@@ -221,7 +237,7 @@ If the user edited the file on Mac before the resolution arrived, the bytes no l
|
||||
|
||||
### A three-version review is interrupted
|
||||
|
||||
Mac receives three live versions of `shared.md`. The active-file status reports three unresolved versions, and the first dialogue compares the deterministic winner with the first ordered conflict leaf. The user completes that pair, leaving two live versions, then chooses **Not now** on the next dialogue and closes Obsidian.
|
||||
Mac receives three current versions of `shared.md`. The active-file status reports three unresolved versions, and the first dialogue compares the deterministic winner with the first ordered conflict leaf. The user completes that pair, leaving two current versions, then chooses **Not now** on the next dialogue and closes Obsidian.
|
||||
|
||||
The first decision has already changed the ordinary revision tree. On restart, LiveSync reads the two surviving versions and presents only that remaining pair; it does not reconstruct the original three-version state. If another device resolves the remaining pair before or while the dialogue is open, the warning disappears and the stale dialogue closes.
|
||||
|
||||
@@ -251,8 +267,8 @@ Do not:
|
||||
|
||||
## Verification
|
||||
|
||||
Commonlib's real-PouchDB and injected-boundary unit tests cover unequal branch lengths, exact shared ancestry, deterministic ordering of multiple live leaves, a sensible stage followed by reconstruction of a manual pair, content below a deleted losing leaf, recorded and reconstructed branch identity, ambiguous matches, conflict-time editing, missing-body preservation when parent metadata is available, refusal to invent a parent for a generation-one revision, logical deletion, case-only rename, cross-path rename, and safe unproven fallbacks.
|
||||
Commonlib's real-PouchDB and injected-boundary unit tests cover unequal branch lengths, exact shared ancestry, deterministic ordering of multiple current leaves, a sensible stage followed by reconstruction of a manual pair, content below a deleted losing leaf, recorded and reconstructed branch identity, ambiguous matches, conflict-time editing, missing-body preservation when parent metadata is available, refusal to invent a parent for a generation-one revision, logical deletion, case-only rename, cross-path rename, and safe unproven fallbacks.
|
||||
|
||||
LiveSync's optional real-Obsidian two-Vault checks have two scopes. `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true` resolves and edits a Markdown conflict, propagates it to a Vault which still displays the deleted losing content, and requires one live result to remain. `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` edits, deletes, case-renames, and cross-path-renames files while conflicts remain active; it verifies the parent revision of each resulting branch, replicates those exact trees, and confirms that the other live branches remain intact.
|
||||
LiveSync's optional real-Obsidian two-Vault checks have two scopes. `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true` resolves and edits a Markdown conflict, propagates it to a Vault which still displays the deleted losing content, and requires one current result to remain. `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` edits, deletes, case-renames, and cross-path-renames files while conflicts remain active; it verifies the parent revision of each resulting branch, replicates those exact trees, and confirms that the other conflict branches remain intact.
|
||||
|
||||
The focused `test:e2e:obsidian:conflict-dialog-policy` scenario creates three live versions in one real Obsidian Vault. It verifies the count warning, commits a concatenated child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the isolated Obsidian profile, and confirms that only the live pair is reconstructed. It also verifies that an incoming resolution closes a stale dialogue, completes the waiting conflict operation, and clears the warning. The repair scenario removes a referenced local chunk, confirms that the exact unreadable live revision remains in the tree, and exercises explicit retry and discard controls without deleting another live revision.
|
||||
The focused `test:e2e:obsidian:conflict-dialog-policy` scenario creates three current versions in one real Obsidian Vault. It verifies the count warning, commits a concatenated child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the isolated Obsidian profile, and confirms that only the current pair is reconstructed. It also verifies that an incoming resolution closes a stale dialogue, completes the waiting conflict operation, and clears the warning. The repair scenario removes a referenced local chunk, confirms that the exact unreadable current leaf remains in the tree, and exercises explicit retry and discard controls without deleting another current leaf.
|
||||
|
||||
@@ -33,19 +33,19 @@ If the initial synchronisation, device inspection, or confirmation fails, the wo
|
||||
A chunk remains reachable when it is referenced by any of the following:
|
||||
|
||||
- the current database winner for a file;
|
||||
- any other live conflict revision for that file;
|
||||
- an available revision on either side of a live conflict which is required to describe the divergence; or
|
||||
- the nearest available revision shared by both live conflict branches.
|
||||
- any conflict leaf for that file;
|
||||
- an available revision on either side of a current conflict which is required to describe the divergence; or
|
||||
- the nearest available revision shared by both conflict branches.
|
||||
|
||||
Chunk identifiers are content-derived and shared between files. Reachability is therefore collected into one set across the database. A chunk used by two or more current files remains protected even when one file is updated or deleted.
|
||||
|
||||
An ordinary superseded linear revision does not protect its former chunks. Once no current file or live conflict branch references a chunk, it can be collected. After a conflict is resolved, chunks unique to the discarded branch and to no-longer-needed merge ancestry can also become eligible.
|
||||
An ordinary superseded linear revision does not protect its former chunks. Once no current file or conflict leaf references a chunk, it can be collected. After a conflict is resolved, chunks unique to the discarded branch and to no-longer-needed merge ancestry can also become eligible.
|
||||
|
||||
## Consequences
|
||||
|
||||
Garbage Collection deliberately trades historical recoverability for storage. A metadata revision may remain in the revision tree after a chunk which only that superseded revision used has been collected, so that historical body can become unreadable. Remote compaction can then discard old CouchDB revision bodies. Tombstones and retained metadata also consume storage, so the operation does not promise the smallest possible database.
|
||||
|
||||
Writing the same bytes again produces the same content-derived chunk identifier. If that chunk was collected previously, the normal chunk-writing path creates a new live revision for it, and ordinary replication can transfer it again. This does not recover an older file revision automatically; it only makes the newly written content available.
|
||||
Writing the same bytes again produces the same content-derived chunk identifier. If that chunk was collected previously, the normal chunk-writing path creates a new non-deleted revision of the Chunk document, and ordinary replication can transfer it again. This does not recover an older file revision automatically; it only makes the newly written content available.
|
||||
|
||||
Garbage Collection does not reconstruct a chunk which is already missing, determine whether an unreadable revision is important, or repair a damaged local database. Use **Inspect conflicts and file/database differences**, another healthy replica, or a backup for those cases. Use **Overwrite Server Data with This Device's Files** only when a chosen Vault is authoritative and a deliberate remote rebuild is required.
|
||||
|
||||
@@ -55,7 +55,7 @@ Commonlib tests use real in-memory PouchDB revision trees to verify:
|
||||
|
||||
- collection eligibility after a normal file update;
|
||||
- protection of chunks shared by multiple current files;
|
||||
- protection of all live conflict branches and their nearest available shared ancestor;
|
||||
- protection of all conflict branches and their nearest available shared ancestor;
|
||||
- eligibility of losing-branch and ancestor-only chunks after conflict resolution;
|
||||
- propagation of chunk deletion to another PouchDB database; and
|
||||
- recreation and propagation when the same content is written again.
|
||||
|
||||
+19
-1
@@ -43,7 +43,7 @@ All guidelines and conventions listed below are disclosed and maintained solely
|
||||
- Database Suffix (additionalSuffixOfDatabaseName)
|
||||
- A unique suffix appended to the database name to allow synchronising multiple vaults with the same name on the same remote server.
|
||||
- E2EE Algorithm
|
||||
- The cryptographic algorithm version used for end-to-end encryption. All devices in the synchronisation group must be configured with a compatible version (such as `V2` or `V1`).
|
||||
- The cryptographic algorithm version used for end-to-end encryption. All synchronising devices must be configured with a compatible version (such as `V2` or `V1`).
|
||||
- Eden (Eden Chunks)
|
||||
- A performance optimisation where newly created chunks are held within the document until they stabilise, before graduating to independent chunks.
|
||||
- Fast Setup (Simple Fetch)
|
||||
@@ -80,6 +80,22 @@ All guidelines and conventions listed below are disclosed and maintained solely
|
||||
- A recovery setting that restricts the propagation of changes from the database to local storage, ignoring any file events (such as accidental mass deletions) that occurred after a specified date and time.
|
||||
- Reset Synchronisation on This Device
|
||||
- A maintenance operation (formerly known as `Fetch everything`) that discards the local database and reconstructs it by downloading all data from the remote server.
|
||||
|
||||
#### Revision
|
||||
|
||||
A revision is a version of one PouchDB/CouchDB document. Concurrent changes can form a revision tree with more than one current branch.
|
||||
|
||||
Revision modifiers describe independent properties. More than one may apply to the same revision:
|
||||
|
||||
- **leaf**: Has no known child revision.
|
||||
- **winner**: Is the leaf selected by PouchDB/CouchDB as the current document.
|
||||
- **conflict**: Is another current leaf which was not selected as the winner.
|
||||
- **Vault-matching**: Represents the same file contents, or the same absent-file state, as the current Vault. More than one revision may match.
|
||||
- **displayed**: Is recorded by valid device-local file provenance as the branch represented in the Vault. A pending local edit may no longer match its bytes, but still extends this recorded branch.
|
||||
- **logically deleted**: Represents the absence of the file through a deletion marker. A logically deleted revision may also be a leaf, winner, conflict, or Vault-matching revision. An absent file retains no displayed provenance.
|
||||
|
||||
Avoid **live revision** in prose because it can ambiguously mean either a current leaf or a non-deleted revision. See [Independent revision properties](specs_conflict_resolution.md#independent-revision-properties) for the relationship between revision-tree roles, Vault state, and device-local provenance.
|
||||
|
||||
- Scram (Scram Switches)
|
||||
- Emergency controls in the settings that allow users to suspend file watching or database writes to prevent corruption.
|
||||
- Segmenter (Segmented-splitter)
|
||||
@@ -94,6 +110,8 @@ All guidelines and conventions listed below are disclosed and maintained solely
|
||||
- A data transfer method that downloads database documents as a continuous stream of events. It is significantly faster than traditional chunk-by-chunk HTTP requests and is used during Fast Setup to retrieve remote metadata quickly.
|
||||
- Sync Mode
|
||||
- The replication trigger mechanism. Users can select from `On Events` (synchronising on local file changes), `Periodic and Events` (synchronising at fixed intervals as well as on events), or `LiveSync` (continuous, real-time synchronisation).
|
||||
- Synchronising devices
|
||||
- Devices which participate in the same synchronisation for a Vault. The term describes membership rather than current activity, so it includes offline and idle devices.
|
||||
- TURN Server (WebRTC P2P)
|
||||
- A Traversal Using Relays around NAT server used as an optional fallback to relay encrypted WebRTC traffic when strict NAT or firewall rules block a direct peer connection. It is distinct from the signalling relay.
|
||||
- Update Thinning (Batch database update)
|
||||
|
||||
@@ -36,9 +36,20 @@ Try these in order:
|
||||
1. Put both devices on the same ordinary network and retry.
|
||||
2. Remove a VPN temporarily if it blocks peer traffic, or use a trusted VPN such as Tailscale when it provides a reachable path between the devices.
|
||||
3. In `P2P Configuration` -> `Advanced Settings`, configure a trusted TURN service.
|
||||
4. Under `Connection compatibility`, select `TURN relay only` to test the configured TURN path without direct ICE candidates.
|
||||
|
||||
TURN is a fallback for encrypted WebRTC traffic. It is different from the required signalling relay. The project does not operate an official TURN service. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume.
|
||||
|
||||
For a small self-hosted deployment, the repository includes an optional [Coturn Compose starter](../../docker/coturn/README.md). It uses static credentials and does not include TLS or a managed credential service; review its network and security boundaries before exposing it.
|
||||
|
||||
## A connection opens but a transfer stalls
|
||||
|
||||
If peers can connect but a transfer repeatedly stalls on one network path, try the `P2P message size` presets under `Connection compatibility`. Start with `Reduced`, then try `Conservative` and `Maximum compatibility` only if needed.
|
||||
|
||||
The preset limits outgoing messages, so select the same value on every device which may send across the affected path. Smaller values add overhead and do not prove that packet fragmentation was the cause. Return to `Standard` when the path works reliably without the compatibility setting.
|
||||
|
||||
Compatibility choices are saved with the P2P profile. You may keep separate standard and compatibility profiles with the same Group ID and credentials, then select the profile appropriate to the current network.
|
||||
|
||||
## A connected peer does not receive later edits
|
||||
|
||||
An open signalling connection does not automatically move every change.
|
||||
|
||||
+35
-115
@@ -1,145 +1,65 @@
|
||||
# How to report an issue
|
||||
|
||||
Thank you for helping improve Self-hosted LiveSync!
|
||||
Thank you for helping improve Self-hosted LiveSync. A concise report with the right evidence is more useful than trying several recovery operations before reporting the original symptom.
|
||||
|
||||
This document explains how to collect the information needed for an issue report. Issues with sufficient information will be prioritised.
|
||||
Use the [issue report template](https://github.com/vrtmrz/obsidian-livesync/issues/new?template=issue-report.md) for the report itself. Use [Troubleshooting](troubleshooting.md) to diagnose a symptom or choose a recovery action.
|
||||
|
||||
---
|
||||
## Preserve the original symptom
|
||||
|
||||
## Filled example
|
||||
Do not reset a database, rebuild a remote, change transport, or enable P2P merely to see whether the problem disappears. These actions can change the evidence and may make the original cause harder to identify.
|
||||
|
||||
Here is an example of a well-filled report for reference.
|
||||
If the problem may involve data loss, corruption, or unexpected deletion, preserve a copy of every readable affected file and stop editing it on other devices before changing settings.
|
||||
|
||||
### Abstract
|
||||
Include when the problem began, whether it followed an update or restart, how often it occurs, and which device and remote type were involved.
|
||||
|
||||
The synchronisation hung up immediately after connecting.
|
||||
## Required information
|
||||
|
||||
### Expected behaviour
|
||||
### Describe the behaviour
|
||||
|
||||
- Synchronisation ends with the message `Replication completed`
|
||||
- Everything synchronised
|
||||
Complete the issue template with:
|
||||
|
||||
### Actually happened
|
||||
- a one- or two-sentence summary;
|
||||
- the expected and actual behaviour;
|
||||
- repeatable steps, or the frequency and timing when reliable reproduction is not available; and
|
||||
- the role of each relevant device, such as the device where the change originated and the device where the failure appeared.
|
||||
|
||||
- Synchronisation was cancelled with the message `TypeError: Failed to fetch` (visible in the plug-in log around lines 10–12)
|
||||
- No files synchronised
|
||||
### Obsidian debug information
|
||||
|
||||
### Reproducing procedure
|
||||
Open the command palette with `Ctrl`+`P` or `Command`+`P`, run `Show debug info`, and include its output for each relevant device. The device where the problem appeared is required. Information from the other participating devices is particularly useful for synchronisation problems.
|
||||
|
||||
1. Configure LiveSync with the settings shown in the attached report.
|
||||
2. Click the sync button on the ribbon.
|
||||
3. Synchronisation begins.
|
||||
4. About two or three seconds later, the error `TypeError: Failed to fetch` appears.
|
||||
5. Replication stops. No files synchronised.
|
||||
### Full LiveSync report
|
||||
|
||||
### Obsidian debug info (Device 1 — Windows desktop)
|
||||
Run `Generate full report for opening the issue with debug info` on the device where the problem appeared. For a synchronisation problem, also collect a report from another participating device when its settings or logs are relevant. The command copies the current LiveSync settings summary and up to 1,000 recent log lines. It collects verbose log lines even when `Verbose Log` is disabled, so you do not need to enable that setting before reproducing the problem.
|
||||
|
||||
```
|
||||
SYSTEM INFO:
|
||||
Obsidian version: v1.2.8
|
||||
Installer version: v1.1.15
|
||||
Operating system: Windows 10 Pro 10.0.19044
|
||||
Login status: logged in
|
||||
Catalyst license: supporter
|
||||
Insider build toggle: off
|
||||
Community theme: Minimal v6.1.11
|
||||
Snippets enabled: 3
|
||||
Restricted mode: off
|
||||
Plugins installed: 35
|
||||
Plugins enabled: 11
|
||||
1: Self-hosted LiveSync v0.19.4
|
||||
...
|
||||
```
|
||||
The command automatically redacts known credential fields in the settings summary. It cannot guarantee that private text in log messages or unrecognised configuration fields is removed. Review the complete output before sharing it. Remove or replace:
|
||||
|
||||
### Report from LiveSync
|
||||
- usernames, passwords, passphrases, tokens, keys, and custom headers;
|
||||
- private server URLs, network addresses, database names, bucket names, room identifiers, and relay details;
|
||||
- Vault names, device names, and file paths; and
|
||||
- file contents or other private text which appears in a log message.
|
||||
|
||||
```
|
||||
----remote config----
|
||||
cors:
|
||||
credentials: "true"
|
||||
...
|
||||
---- Plug-in config ---
|
||||
couchDB_URI: self-hosted
|
||||
couchDB_USER: 𝑅𝐸𝐷𝐴𝐶𝑇𝐸𝐷
|
||||
...
|
||||
```
|
||||
Document and chunk identifiers can also be private metadata, but they may be necessary for diagnosing file reconstruction and chunk availability. Decide deliberately whether to share them. If you remove them, state that the report was redacted and that this may limit the diagnosis.
|
||||
|
||||
### Plug-in log
|
||||
For a large report, you may share a GitHub Gist after reviewing and redacting it. Deleting a Gist later cannot undo information which has already been disclosed.
|
||||
|
||||
```
|
||||
2023/5/24 10:50:33->HTTP:GET to:/ -> failed
|
||||
2023/5/24 10:50:33->TypeError:Failed to fetch
|
||||
2023/5/24 10:50:33->could not connect to https://example.com/ : your vault
|
||||
(TypeError:Failed to fetch)
|
||||
```
|
||||
## Additional evidence when relevant
|
||||
|
||||
---
|
||||
### A problem involving one file
|
||||
|
||||
## How to collect each piece of information
|
||||
Run `Copy database information for the active file`, or use **Hatch** → **Copy database information for a file** to select another file.
|
||||
|
||||
### Obsidian debug info
|
||||
This report describes only the local database on that device. It includes the Vault-relative path, document and chunk identifiers, local revisions, conflicts, and local chunk availability. It does not query the remote or include file contents. Review paths and identifiers as private metadata before sharing them.
|
||||
|
||||
Open the command palette (`Ctrl/Cmd + P`) and run **"Show debug info"**. Copy the output and paste it into the issue.
|
||||
### A problem which crosses a restart
|
||||
|
||||
If multiple devices are involved in the problem (e.g., sync between a phone and a desktop), please provide the debug info for each device. The device where the issue occurred is required; information from other devices is strongly recommended.
|
||||
Use `Write logs into the file` under **Hatch** only when the in-memory report cannot cover the restart. Persistent logging affects performance and can record private information. Disable it after reproducing the problem, review the log before sharing it, and remove the log file when it is no longer needed.
|
||||
|
||||
### Report from LiveSync (hatch report)
|
||||
### A connection, authentication, or CORS problem
|
||||
|
||||
1. Open LiveSync settings.
|
||||
2. Go to the **Hatch** pane.
|
||||
3. Press the **Make report** button.
|
||||
Include network evidence only when the ordinary LiveSync log cannot show the rejected response. Follow [Inspect a network failure](troubleshooting.md#inspect-a-network-failure), and remove request paths, remote addresses, authority and authorisation values, cookies, credentials, payload identifiers, and response secrets before sharing screenshots or copied data.
|
||||
|
||||
The report will be copied to your clipboard. It contains your LiveSync configuration and the remote server configuration, with credentials automatically redacted.
|
||||
## Sharing the report
|
||||
|
||||
**Tip:** For large reports, consider uploading to [GitHub Gist](https://gist.github.com/) and sharing the link instead of pasting directly into the issue. This makes it easier to manage, and if you accidentally leave sensitive data in, a Gist can be deleted.
|
||||
Paste reports into the matching collapsible sections in the issue template, or provide a link to an already-redacted Gist. A separate plug-in log is normally unnecessary because the full LiveSync report already contains the recent verbose log history.
|
||||
|
||||
If you paste directly, wrap it in a `<details>` tag to keep the issue readable:
|
||||
|
||||
```
|
||||
<details>
|
||||
<summary>Report from hatch</summary>
|
||||
|
||||
```
|
||||
----remote config----
|
||||
:
|
||||
```
|
||||
</details>
|
||||
```
|
||||
|
||||
### Plug-in log
|
||||
|
||||
The plug-in log is volatile by default (not saved to disk) and shown only in the log dialogue, which can be opened by tapping the **document box icon** in the ribbon.
|
||||
|
||||
#### Enable verbose log
|
||||
|
||||
Before reproducing the issue, enable **Verbose Log** in LiveSync's **General Settings** pane. Without this, many diagnostic messages will be suppressed.
|
||||
|
||||
#### Persist the log to a file (optional)
|
||||
|
||||
If you need to capture a log across a restart, enable **"Write logs into the file"** in General Settings. Note that log files may contain sensitive information — use this option only for troubleshooting, and disable it afterwards.
|
||||
|
||||
As with the hatch report, consider uploading large logs to [GitHub Gist](https://gist.github.com/).
|
||||
|
||||
### Network log (for connection-related issues only)
|
||||
|
||||
If the issue is related to network connectivity (e.g., cannot connect to the server, authentication errors), a network log captured from browser DevTools can be very helpful. You do not need to include this for non-connection issues.
|
||||
|
||||
#### Opening DevTools
|
||||
|
||||
| Platform | Shortcut |
|
||||
|----------|----------|
|
||||
| Windows / Linux | `Ctrl + Shift + I` |
|
||||
| macOS | `Cmd + Shift + I` |
|
||||
| Android | Use [Chrome remote debugging](https://developer.chrome.com/docs/devtools/remote-debugging/) |
|
||||
| iOS | Use [Safari Web Inspector](https://developer.apple.com/documentation/safari-developer-tools/inspecting-ios) on a Mac |
|
||||
|
||||
#### What to capture
|
||||
|
||||
1. Open the **Network** pane in DevTools.
|
||||
2. Reproduce the issue.
|
||||
3. Look for requests marked in red.
|
||||
4. Capture screenshots of the **Headers**, **Payload**, and **Response** tabs for those requests.
|
||||
|
||||
**Important — redact before sharing:**
|
||||
- Headers: conceal the request URL path, Remote Address, `authority`, and `authorisation` values.
|
||||
- Payload / Response: the `_id` field contains your file paths — redact if needed.
|
||||
If a maintainer asks for a more specialised diagnostic, collect only that additional evidence and review it again before publishing it.
|
||||
|
||||
+22
-4
@@ -47,6 +47,14 @@ Do not switch to P2P or reset the database as the first response. Check:
|
||||
|
||||
If the remote is healthy but one device's local database is not, use [Reset Synchronisation on This Device](recovery.md#reset-synchronisation-on-this-device) only after backing up unsynchronised local files.
|
||||
|
||||
## Synchronisation is paused for compatibility review
|
||||
|
||||
A compatibility review is separate from the Change Log. It can appear after an internal database or settings-format change, or when a configured Vault is copied, restored, or opened in a new Obsidian profile without its device-local acknowledgement.
|
||||
|
||||
The **Synchronisation paused for compatibility review** dialogue opens after the Obsidian layout is ready. If it has been closed, use the persistent Notice's **Review why** link, or run `Review why synchronisation is paused` from the command palette. Opening **Change Log** does not clear the pause.
|
||||
|
||||
Review the stated reason before continuing. When **Resume synchronisation** is available, first update every synchronising device, then use that action to record the current internal database version and restore the configured synchronisation behaviour. If the action is unavailable, the running installation is older than the recorded database or settings format. Update that installation instead of resetting the database merely to remove the warning.
|
||||
|
||||
## Files are missing or excluded
|
||||
|
||||
Check Obsidian's `Detect all file extensions`, LiveSync selectors, ignore files, file-size limits, modification-time limits, and Hidden File Sync rules. A filtered file is different from a file which reached the database but could not be reconstructed from its chunks.
|
||||
@@ -58,14 +66,22 @@ If the log reports missing chunks or a size mismatch:
|
||||
3. synchronise a device or restore a backup which still has the correct content;
|
||||
4. on that healthy device, run `Recreate chunks for current Vault files`, then synchronise;
|
||||
5. follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file); run `Inspect conflicts and file/database differences` from `Hatch`, then use each revision's wrench menu to review and act on that exact branch; and
|
||||
6. use `Discard this branch` only after confirming that the exact live branch is no longer wanted. Use the separate `Discard unreadable revision` recovery action only when an unreadable revision is the sole live leaf.
|
||||
6. use `Discard this branch` only after confirming that the exact current branch is no longer wanted. Use the separate `Discard unreadable revision` recovery action only when an unreadable revision is the sole current leaf.
|
||||
|
||||
The repair card uses compact diagnostic rows which remain readable in a narrow mobile settings pane. `🧩 Missing chunks: N` marks an unreadable revision. In the database row, `Δsize` means decoded size minus recorded size; `Δsize vs DB` means Vault size minus decoded database size; and `Δtime` means Vault modification time minus database modification time. These are diagnostic values, not a rule for deciding which revision is correct. `✅ Vault matches winner · ⚠️ Conflicts: N` means that the current Vault bytes agree with the database winner while other live branches still need a decision. Every mutating action rechecks that its selected revision is still live. Applying a logical deletion to an existing Vault file requires confirmation; a logical-deletion winner with no Vault file already agrees and is omitted.
|
||||
The repair card uses compact diagnostic rows which remain readable in a narrow mobile settings pane. `🧩 Missing chunks: N` marks an unreadable revision. In the database row, `Δsize` means decoded size minus recorded size; `Δsize vs DB` means Vault size minus decoded database size; and `Δtime` means Vault modification time minus database modification time. These are diagnostic values, not a rule for deciding which revision is correct. `✅ Vault matches winner · ⚠️ Conflicts: N` means that the current Vault bytes agree with the database winner while other conflict branches still need a decision. Every mutating action rechecks that its selected revision is still a current leaf. Applying a logical deletion to an existing Vault file requires confirmation; a logical-deletion winner with no Vault file already agrees and is omitted.
|
||||
|
||||
`Retry reading revision` does not change the revision tree. `Discard this branch` creates a logical deletion on one exact live revision while another live branch remains and leaves the current Vault file unchanged. If the discarded revision was recorded as the Vault's exact source, that stale device-local provenance is removed. `Discard unreadable revision` provides the corresponding explicit escape hatch for a sole unreadable live leaf. Neither action purges history or reconstructs missing content. An unavailable non-live ancestor cannot be deleted through this workflow; it disables conservative three-way merge but does not prevent explicit selection between readable live revisions.
|
||||
`Retry reading revision` does not change the revision tree. `Discard this branch` creates a logical deletion on one exact current leaf while another current leaf remains and leaves the current Vault file unchanged. If the discarded revision was recorded as the Vault's exact source, that stale device-local provenance is removed. `Discard unreadable revision` provides the corresponding explicit escape hatch for a sole unreadable current leaf. Neither action purges history or reconstructs missing content. An unavailable ancestor which is not a current leaf cannot be deleted through this workflow; it disables conservative three-way merge but does not prevent explicit selection between readable current leaves.
|
||||
|
||||
`Recreate chunks for current Vault files` uses current Vault content. It cannot recreate unique bytes which exist only in an unreadable historical or conflict revision.
|
||||
|
||||
## A Metadata entry requires review
|
||||
|
||||
When **Inspect conflicts and file/database differences** reports `Metadata entry requires review and was left unchanged`, the local database contains Metadata whose stored document ID does not agree with the ID derived from its recorded path. LiveSync withholds that entry from ordinary file reflection and deletion rather than guessing which identity is intended. The inspection is local and does not query the remote.
|
||||
|
||||
Do not change file-name case handling or path obfuscation merely to make the displayed IDs agree. Follow [Repair a Metadata document ID mismatch](recovery.md#repair-a-metadata-document-id-mismatch) when the card offers **Repair this Metadata document ID**. If no repair action is offered, the entry is ambiguous, conflicted, deleted, outside the normal-file namespace, or otherwise unsafe for one-entry repair. Preserve the evidence and use the wider recovery guidance instead of forcing a target ID.
|
||||
|
||||
If many entries reflect deliberate folder-name or ID-derivation differences across devices, choose an authoritative Vault and use the established Rebuild workflow. A one-entry repair is not a distributed rename or database migration.
|
||||
|
||||
## A configuration mismatch dialogue blocks synchronisation
|
||||
|
||||
Some settings must match across devices. LiveSync pauses synchronisation when the local and remote values differ rather than propagating an unexpected change silently.
|
||||
@@ -117,6 +133,8 @@ Enable Obsidian's `Detect all file extensions`, then check LiveSync selectors, i
|
||||
|
||||
## Collect a report
|
||||
|
||||
Follow [How to report an issue](to_issue_reporting.md) for the complete reporting checklist, including Obsidian debug information and the privacy review required before sharing evidence.
|
||||
|
||||
Run `Generate full report for opening the issue with debug info` to copy the current settings summary and recent verbose log lines. Remove credentials, remote URLs, Vault names, file contents, and other private information before sharing it.
|
||||
|
||||
When a problem concerns one file, run **Copy database information for the active file**, or use **Hatch** → **Copy database information for a file** to select another file. The report describes this device's local database view, including the Vault-relative path, document and chunk identifiers, local database revisions, conflicts, and local chunk availability. It does not query the remote server or include file contents. Treat paths and identifiers as private metadata before sharing.
|
||||
@@ -131,7 +149,7 @@ Browser security errors, particularly CORS failures, may reach the plug-in only
|
||||
|
||||
LiveSync stores file metadata, chunks, revision history, conflicts, deletions, and tombstones. Deleting or shortening a file therefore does not immediately remove every object which once represented it.
|
||||
|
||||
Garbage Collection V3 can remove unreferenced chunks from a healthy CouchDB setup, but it is appropriate only when the Vault and local database are healthy and all relevant devices have synchronised. Current files and live conflict branches protect their required chunks; an ordinary superseded revision does not. Tombstones and retained metadata are not free, so Garbage Collection does not guarantee a minimal database. Review the [Garbage Collection V3 specification](specs_garbage_collection.md) before using it.
|
||||
Garbage Collection V3 can remove unreferenced chunks from a healthy CouchDB setup, but it is appropriate only when the Vault and local database are healthy and all relevant devices have synchronised. Current files and conflict branches protect their required chunks; an ordinary superseded revision does not. Tombstones and retained metadata are not free, so Garbage Collection does not guarantee a minimal database. Review the [Garbage Collection V3 specification](specs_garbage_collection.md) before using it.
|
||||
|
||||
`Overwrite Server Data with This Device's Files` is a separate rebuild operation and is the more certain way to reconstruct a central remote from a chosen authoritative Vault. It is also destructive and may discard changes which exist only on another device. Review [Recovery and flag files](recovery.md#garbage-collection-is-not-rebuild) before choosing between them.
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.21",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
|
||||
Generated
+198
-309
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.21",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.21",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"src/apps/cli",
|
||||
@@ -23,8 +23,8 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.7",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.19",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -32,7 +32,7 @@
|
||||
"markdown-it": "^14.2.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"obsidian": "^1.13.1",
|
||||
"octagonal-wheels": "^0.1.52",
|
||||
"octagonal-wheels": "^0.1.53",
|
||||
"qrcode-generator": "^1.4.4",
|
||||
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
|
||||
},
|
||||
@@ -988,7 +988,6 @@
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -1193,15 +1192,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
@@ -1353,7 +1343,6 @@
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
@@ -1365,7 +1354,6 @@
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -2273,7 +2261,8 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@microsoft/eslint-plugin-sdl": {
|
||||
"version": "1.1.0",
|
||||
@@ -2323,12 +2312,6 @@
|
||||
"ret": "~0.1.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@minhducsun2002/leb128": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@minhducsun2002/leb128/-/leb128-1.0.0.tgz",
|
||||
"integrity": "sha512-eFrYUPDVHeuwWHluTG1kwNQUEUcFjVKYwPkU8z9DR1JH3AW7JtJsG9cRVGmwz809kKtGfwGJj58juCZxEvnI/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
@@ -2445,128 +2428,167 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-cms": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz",
|
||||
"integrity": "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==",
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz",
|
||||
"integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.1",
|
||||
"@peculiar/asn1-x509-attr": "^2.6.1",
|
||||
"asn1js": "^3.0.6",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"@peculiar/asn1-x509-attr": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-csr": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.1.tgz",
|
||||
"integrity": "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==",
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz",
|
||||
"integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.1",
|
||||
"asn1js": "^3.0.6",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz",
|
||||
"integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==",
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz",
|
||||
"integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.1",
|
||||
"asn1js": "^3.0.6",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pfx": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.1.tgz",
|
||||
"integrity": "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==",
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz",
|
||||
"integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.6.1",
|
||||
"@peculiar/asn1-pkcs8": "^2.6.1",
|
||||
"@peculiar/asn1-rsa": "^2.6.1",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"@peculiar/asn1-cms": "^2.9.4",
|
||||
"@peculiar/asn1-pkcs8": "^2.9.4",
|
||||
"@peculiar/asn1-rsa": "^2.9.4",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs8": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.1.tgz",
|
||||
"integrity": "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==",
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz",
|
||||
"integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.1",
|
||||
"asn1js": "^3.0.6",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs9": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.1.tgz",
|
||||
"integrity": "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==",
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz",
|
||||
"integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.6.1",
|
||||
"@peculiar/asn1-pfx": "^2.6.1",
|
||||
"@peculiar/asn1-pkcs8": "^2.6.1",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.1",
|
||||
"@peculiar/asn1-x509-attr": "^2.6.1",
|
||||
"asn1js": "^3.0.6",
|
||||
"@peculiar/asn1-cms": "^2.9.4",
|
||||
"@peculiar/asn1-pfx": "^2.9.4",
|
||||
"@peculiar/asn1-pkcs8": "^2.9.4",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"@peculiar/asn1-x509-attr": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-rsa": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz",
|
||||
"integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==",
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz",
|
||||
"integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.1",
|
||||
"asn1js": "^3.0.6",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz",
|
||||
"integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==",
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz",
|
||||
"integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asn1js": "^3.0.6",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz",
|
||||
"integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==",
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz",
|
||||
"integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509-attr": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.1.tgz",
|
||||
"integrity": "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==",
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz",
|
||||
"integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
|
||||
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.1",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
@@ -3003,11 +3025,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@shinyoshiaki/jspack": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@shinyoshiaki/jspack/-/jspack-0.0.6.tgz",
|
||||
"integrity": "sha512-SdsNhLjQh4onBlyPrn4ia1Pdx5bXT88G/LIEpOYAjx2u4xeY/m/HB5yHqlkJB1uQR3Zw4R3hBWLj46STRAN0rg=="
|
||||
},
|
||||
"node_modules/@smithy/abort-controller": {
|
||||
"version": "4.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz",
|
||||
@@ -3924,6 +3941,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dom-mediacapture-transform": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz",
|
||||
"integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/dom-webcodecs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dom-webcodecs": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz",
|
||||
"integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
@@ -4048,7 +4080,6 @@
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
@@ -4626,7 +4657,6 @@
|
||||
"integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.8",
|
||||
@@ -4775,9 +4805,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.7.tgz",
|
||||
"integrity": "sha512-xwRXuPqYmPbWlSaVrQqeVacZz4X5km3fgoUee6Odl8/C3dNx+pFndxoHvXIDo3HmbMKcz/JA09JTKvfxVqBwQQ==",
|
||||
"version": "0.1.19",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.19.tgz",
|
||||
"integrity": "sha512-sVQcVJaelm575xoopay1yUWsIlfh2Um6Oi70Jp4WLdO3vlPCoYUbCKH+zGDwU/r05BbB2xGwU+kgy6OjxyxGeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
@@ -4793,7 +4823,7 @@
|
||||
"idb": "^8.0.3",
|
||||
"markdown-it": "^14.2.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"octagonal-wheels": "^0.1.51",
|
||||
"octagonal-wheels": "^0.1.53",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-idb": "^9.0.0",
|
||||
"pouchdb-adapter-indexeddb": "^9.0.0",
|
||||
@@ -4838,9 +4868,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/obsidian-plugin-kit": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-plugin-kit/-/obsidian-plugin-kit-0.1.3.tgz",
|
||||
"integrity": "sha512-6fsKdhFZtBv6FXlZHtSmpqwROohFzDmres6q08nr2xYGVeh2ooBGU3zJS94WN/tOjDT+wa/Vr3yE42wmI0pIZA==",
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-plugin-kit/-/obsidian-plugin-kit-0.1.4.tgz",
|
||||
"integrity": "sha512-MxZgd7UOr8DXk0e+JsAXJmYj/8bfIoEliI4ruvp1Cu4U+mmVI/p63nZ5we3dDW+EaCgRAgF+ztuVDkcMUk1DwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vrtmrz/ui-interactions": "0.1.2"
|
||||
@@ -5138,7 +5168,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5156,12 +5185,6 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aes-js": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz",
|
||||
"integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -5606,13 +5629,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz",
|
||||
"integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==",
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.3",
|
||||
"pvutils": "^1.1.5",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -6030,7 +6053,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -6073,6 +6095,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
|
||||
"integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
@@ -6502,7 +6525,8 @@
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
@@ -6639,26 +6663,11 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "2.30.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
||||
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/date-fns"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -7344,7 +7353,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -7476,7 +7484,6 @@
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8441,6 +8448,7 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-fifo": {
|
||||
@@ -9404,12 +9412,6 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/int64-buffer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-1.1.0.tgz",
|
||||
"integrity": "sha512-94smTCQOvigN4d/2R/YDjz8YVG0Sufvv2aAh8P5m42gwhCsDAJqnbNOrxJsrADuAFAA69Q/ptGzxvNcNuIJcvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
@@ -9425,12 +9427,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz",
|
||||
"integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
@@ -10036,7 +10032,6 @@
|
||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
@@ -10927,6 +10922,7 @@
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
@@ -11095,6 +11091,24 @@
|
||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mediabunny": {
|
||||
"version": "1.55.2",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.55.2.tgz",
|
||||
"integrity": "sha512-EEx4O6qYddAdCyWPMZNDwI7uc5hewNHrPAf9jLcVhIbXoPsiqNQ+D9i1pfadmGkjN2V318jSrZljkpoziYm6Lg==",
|
||||
"license": "MPL-2.0",
|
||||
"workspaces": [
|
||||
".",
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@types/dom-mediacapture-transform": "^0.1.11",
|
||||
"@types/dom-webcodecs": "0.1.13"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/Vanilagy"
|
||||
}
|
||||
},
|
||||
"node_modules/memdown": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/memdown/-/memdown-1.4.1.tgz",
|
||||
@@ -11212,12 +11226,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/mp4box": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/mp4box/-/mp4box-0.5.4.tgz",
|
||||
"integrity": "sha512-GcCH0fySxBurJtvr0dfhz0IxHZjc1RP+F+I8xw+LIwkU1a+7HJx8NCDiww1I5u4Hz6g4eR1JlGADEGJ9r4lSfA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
@@ -11510,7 +11518,6 @@
|
||||
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.13.1.tgz",
|
||||
"integrity": "sha512-qtTEA2pmhJzhuhJqzbBFRYhpIOqvW+krDYjtFynv66KbxBbumHBlsJfWw3I4jtnK/6fZwbQhCrmmDdRwXmX56w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/codemirror": "5.60.8",
|
||||
"moment": "2.29.4"
|
||||
@@ -11532,9 +11539,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/octagonal-wheels": {
|
||||
"version": "0.1.52",
|
||||
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.52.tgz",
|
||||
"integrity": "sha512-9WJN2UveNh90Op1S07cIso1WyNrQbO/unibDLfUGnpomIcU4g6F+p8reZHW0Ed8sGzKF5qGnQp991Y9MB1TwNg==",
|
||||
"version": "0.1.53",
|
||||
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.53.tgz",
|
||||
"integrity": "sha512-4NJsb96Sk6rJXhrTyjAY5GRIoWMFJsFp56b5ba9fV8/87ys2HCjMo0hior4F8k4ma72pLTJT7i6DvuihHxSsMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"idb": "^8.0.3"
|
||||
@@ -11586,15 +11593,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/p-cancelable": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
@@ -12001,7 +11999,6 @@
|
||||
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.0"
|
||||
},
|
||||
@@ -12058,7 +12055,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -12582,9 +12578,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
|
||||
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz",
|
||||
"integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
@@ -12958,11 +12954,6 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rx.mini": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/rx.mini/-/rx.mini-1.4.0.tgz",
|
||||
"integrity": "sha512-8w5cSc1mwNja7fl465DXOkVvIOkpvh2GW4jo31nAIvX4WTXCsRnKJGUfiDBzWtYRInEcHAUYIZfzusjIrea8gA=="
|
||||
},
|
||||
"node_modules/sade": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||
@@ -13752,7 +13743,8 @@
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/sublevel-pouchdb": {
|
||||
"version": "9.0.0",
|
||||
@@ -13821,7 +13813,6 @@
|
||||
"integrity": "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -13887,6 +13878,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-check/node_modules/picomatch": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-eslint-parser": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.8.0.tgz",
|
||||
@@ -14060,7 +14066,6 @@
|
||||
"integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.15.0",
|
||||
@@ -14158,7 +14163,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -14457,7 +14461,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -14525,7 +14528,6 @@
|
||||
"integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.61.1",
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
@@ -14855,7 +14857,6 @@
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
@@ -14982,7 +14983,6 @@
|
||||
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.8",
|
||||
"@vitest/mocker": "4.1.8",
|
||||
@@ -15097,7 +15097,8 @@
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/wait-port": {
|
||||
"version": "1.1.0",
|
||||
@@ -15250,137 +15251,25 @@
|
||||
"link": true
|
||||
},
|
||||
"node_modules/werift": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/werift/-/werift-0.23.0.tgz",
|
||||
"integrity": "sha512-/WcIN5DHFG9Ri4anGOmIkp8gxBGFMWSIB/m4sfZ5CWlLfD3iMhiaAUuTBuc+KV3SY9NDmvmLtiN2uaM7k3lVzw==",
|
||||
"version": "0.24.4",
|
||||
"resolved": "https://registry.npmjs.org/werift/-/werift-0.24.4.tgz",
|
||||
"integrity": "sha512-NoROZ11L/ZqAB5ombCB4VBuVNdIZfwI493zlxlIX7BlwfWRy55eDJdtWMC2VX35yBXFaWwA8NtYbmJ8qWtVk9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fidm/x509": "^1.2.1",
|
||||
"@minhducsun2002/leb128": "^1.0.0",
|
||||
"@noble/curves": "^1.8.1",
|
||||
"@peculiar/x509": "^1.12.3",
|
||||
"@shinyoshiaki/binary-data": "^0.6.1",
|
||||
"@shinyoshiaki/jspack": "^0.0.6",
|
||||
"aes-js": "^3.1.2",
|
||||
"buffer": "^6.0.3",
|
||||
"debug": "4.4.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"int64-buffer": "1.1.0",
|
||||
"ip": "^2.0.1",
|
||||
"mp4box": "^0.5.3",
|
||||
"mediabunny": "^1.45.2",
|
||||
"multicast-dns": "^7.2.5",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"werift-common": "*",
|
||||
"werift-dtls": "*",
|
||||
"werift-ice": "*",
|
||||
"werift-rtp": "*",
|
||||
"werift-sctp": "*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/werift-common": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/werift-common/-/werift-common-0.0.3.tgz",
|
||||
"integrity": "sha512-ma3E4BqKTyZVLhrdfTVs2T1tg9seeUtKMRn5e64LwgrogWa62+3LAUoLBUSl1yPWhgSkXId7GmcHuWDen9IJeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shinyoshiaki/jspack": "^0.0.6",
|
||||
"debug": "^4.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/werift-dtls": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/werift-dtls/-/werift-dtls-0.5.7.tgz",
|
||||
"integrity": "sha512-z2fjbP7fFUFmu/Ky4bCKXzdgPTtmSY1DYi0TUf3GG2zJT4jMQ3TQmGY8y7BSSNGetvL4h3pRZ5un0EcSOWpPog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fidm/x509": "^1.2.1",
|
||||
"@noble/curves": "^1.3.0",
|
||||
"@peculiar/x509": "^1.9.2",
|
||||
"@shinyoshiaki/binary-data": "^0.6.1",
|
||||
"date-fns": "^2.29.3",
|
||||
"lodash": "^4.17.21",
|
||||
"rx.mini": "^1.2.2",
|
||||
"tweetnacl": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/werift-ice": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/werift-ice/-/werift-ice-0.2.2.tgz",
|
||||
"integrity": "sha512-td52pHp+JmFnUn5jfDr/SSNO0dMCbknhuPdN1tFp9cfRj5jaktN63qnAdUuZC20QCC3ETWdsOthcm+RalHpFCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shinyoshiaki/jspack": "^0.0.6",
|
||||
"buffer-crc32": "^1.0.0",
|
||||
"debug": "^4.3.4",
|
||||
"int64-buffer": "^1.0.1",
|
||||
"ip": "^2.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"multicast-dns": "^7.2.5",
|
||||
"p-cancelable": "^2.1.1",
|
||||
"rx.mini": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/werift-rtp": {
|
||||
"version": "0.8.8",
|
||||
"resolved": "https://registry.npmjs.org/werift-rtp/-/werift-rtp-0.8.8.tgz",
|
||||
"integrity": "sha512-GiYMSdvCyScQaw5bnEsraSoHUVZpjfokJAiLV4R1FsiB06t6XiebPYPpkqB9nYNNKiA8Z/cYWsym7wISq1sYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@minhducsun2002/leb128": "^1.0.0",
|
||||
"@shinyoshiaki/jspack": "^0.0.6",
|
||||
"aes-js": "^3.1.2",
|
||||
"buffer": "^6.0.3",
|
||||
"mp4box": "^0.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/werift-rtp/node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/werift-sctp": {
|
||||
"version": "0.0.11",
|
||||
"resolved": "https://registry.npmjs.org/werift-sctp/-/werift-sctp-0.0.11.tgz",
|
||||
"integrity": "sha512-7109yuI5U7NTEHjqjn0A8VeynytkgVaxM6lRr1Ziv0D8bPcaB8A7U/P88M7WaCpWDoELHoXiRUjQycMWStIgjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shinyoshiaki/jspack": "^0.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/werift/node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
@@ -15924,11 +15813,11 @@
|
||||
},
|
||||
"src/apps/cli": {
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"version": "1.0.8-cli",
|
||||
"version": "1.0.21-cli",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"octagonal-wheels": "^0.1.52",
|
||||
"octagonal-wheels": "^0.1.53",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||
"pouchdb-core": "^9.0.0",
|
||||
@@ -15939,7 +15828,7 @@
|
||||
"pouchdb-replication": "^9.0.0",
|
||||
"pouchdb-utils": "^9.0.0",
|
||||
"transform-pouch": "^2.0.0",
|
||||
"werift": "^0.23.0"
|
||||
"werift": "^0.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.9.3",
|
||||
@@ -15949,9 +15838,9 @@
|
||||
},
|
||||
"src/apps/webapp": {
|
||||
"name": "livesync-webapp",
|
||||
"version": "1.0.8-webapp",
|
||||
"version": "1.0.21-webapp",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
"octagonal-wheels": "^0.1.53"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
@@ -15961,9 +15850,9 @@
|
||||
}
|
||||
},
|
||||
"src/apps/webpeer": {
|
||||
"version": "1.0.8-webpeer",
|
||||
"version": "1.0.21-webpeer",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
"octagonal-wheels": "^0.1.53"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
|
||||
+5
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.21",
|
||||
"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",
|
||||
@@ -59,6 +59,7 @@
|
||||
"test:e2e:obsidian:conflict-dialog-policy": "tsx test/e2e-obsidian/scripts/conflict-dialog-policy.ts",
|
||||
"test:e2e:obsidian:revision-repair": "tsx test/e2e-obsidian/scripts/revision-repair.ts",
|
||||
"test:e2e:obsidian:document-history-nav": "tsx test/e2e-obsidian/scripts/document-history-nav.ts",
|
||||
"test:e2e:obsidian:document-history-restore": "tsx test/e2e-obsidian/scripts/document-history-restore.ts",
|
||||
"test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts",
|
||||
"test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts",
|
||||
"test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts",
|
||||
@@ -177,8 +178,8 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.7",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.19",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -186,7 +187,7 @@
|
||||
"markdown-it": "^14.2.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"obsidian": "^1.13.1",
|
||||
"octagonal-wheels": "^0.1.52",
|
||||
"octagonal-wheels": "^0.1.53",
|
||||
"qrcode-generator": "^1.4.4",
|
||||
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
|
||||
},
|
||||
|
||||
+39
-20
@@ -1,7 +1,11 @@
|
||||
import { LOG_LEVEL_INFO } from "octagonal-wheels/common/logger";
|
||||
import type PouchDB from "pouchdb-core";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
import type { HasSettings, ObsidianLiveSyncSettings, EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
type HasSettings,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type EntryDoc,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { __$checkInstanceBinding } from "@vrtmrz/livesync-commonlib/compat/dev/checks";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
|
||||
@@ -11,17 +15,13 @@ import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces
|
||||
import type { LiveSyncLocalDBEnv } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
|
||||
import type { LiveSyncCouchDBReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import type { CheckPointInfo } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncTypes";
|
||||
import type { LiveSyncJournalReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicatorEnv";
|
||||
import type { LiveSyncReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
||||
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
||||
import type { ReplicatorInstance } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { useTargetFilters } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/targetFilter";
|
||||
import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { AbstractModule } from "./modules/AbstractModule";
|
||||
import { ModulePeriodicProcess } from "./modules/core/ModulePeriodicProcess";
|
||||
import { ModuleReplicator } from "./modules/core/ModuleReplicator";
|
||||
import { ModuleReplicatorCouchDB } from "./modules/core/ModuleReplicatorCouchDB";
|
||||
import { ModuleReplicatorMinIO } from "./modules/core/ModuleReplicatorMinIO";
|
||||
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
|
||||
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
|
||||
@@ -30,6 +30,16 @@ import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interface
|
||||
import { ModuleBasicMenu } from "./modules/essential/ModuleBasicMenu";
|
||||
import { usePrepareDatabaseForUse } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/prepareDatabaseForUse";
|
||||
import type { Constructor } from "@vrtmrz/livesync-commonlib/compat/common/utils.type";
|
||||
import { useReplicationScheduling, type ReplicationSchedulingControl } from "./serviceFeatures/replicationScheduling";
|
||||
import { createCentralReplicatorProviderDefinitions } from "./common/replicatorProviders";
|
||||
import { useReplicationFeature } from "./serviceFeatures/replication";
|
||||
|
||||
/** Focused views returned by serviceFeatures which the host may consume during composition. */
|
||||
export interface LiveSyncCoreFeatureViews {
|
||||
readonly replicationScheduling: ReplicationSchedulingControl;
|
||||
}
|
||||
|
||||
type CompatibilityReplicatorView = ReplicatorInstance & Partial<LiveSyncAbstractReplicator>;
|
||||
|
||||
export class LiveSyncBaseCore<
|
||||
T extends ServiceContext = ServiceContext,
|
||||
@@ -37,8 +47,6 @@ export class LiveSyncBaseCore<
|
||||
>
|
||||
implements
|
||||
LiveSyncLocalDBEnv,
|
||||
LiveSyncReplicatorEnv,
|
||||
LiveSyncJournalReplicatorEnv,
|
||||
LiveSyncCouchDBReplicatorEnv,
|
||||
HasSettings<ObsidianLiveSyncSettings>
|
||||
{
|
||||
@@ -74,18 +82,22 @@ export class LiveSyncBaseCore<
|
||||
) => ServiceModules,
|
||||
extraModuleInitialiser: (core: LiveSyncBaseCore<T, TCommands>) => AbstractModule[],
|
||||
addOnsInitialiser: (core: LiveSyncBaseCore<T, TCommands>) => TCommands[],
|
||||
featuresInitialiser: (core: LiveSyncBaseCore<T, TCommands>) => void
|
||||
featuresInitialiser: (core: LiveSyncBaseCore<T, TCommands>, coreFeatureViews: LiveSyncCoreFeatureViews) => void
|
||||
) {
|
||||
this._services = serviceHub;
|
||||
this.registerReplicatorProviders();
|
||||
this._serviceModules = serviceModuleInitialiser(this, serviceHub);
|
||||
const extraModules = extraModuleInitialiser(this);
|
||||
this.registerModules(extraModules);
|
||||
this.initialiseServiceFeatures();
|
||||
featuresInitialiser(this);
|
||||
const coreFeatureViews = this.initialiseServiceFeatures();
|
||||
featuresInitialiser(this, coreFeatureViews);
|
||||
const addOns = addOnsInitialiser(this);
|
||||
for (const addOn of addOns) {
|
||||
this._registerAddOn(addOn);
|
||||
}
|
||||
// Preserve the former ModuleReplicator lifecycle-handler order:
|
||||
// host features and add-ons first, then replication, then legacy modules.
|
||||
useReplicationFeature(this);
|
||||
this.bindModuleFunctions();
|
||||
}
|
||||
/**
|
||||
@@ -136,14 +148,17 @@ export class LiveSyncBaseCore<
|
||||
this.modules.push(module);
|
||||
}
|
||||
|
||||
/** Compose the current central providers before any lifecycle event can acquire one. */
|
||||
private registerReplicatorProviders() {
|
||||
this.services.replicator.registerReplicatorProviderDefinitions(
|
||||
createCentralReplicatorProviderDefinitions(this)
|
||||
);
|
||||
}
|
||||
|
||||
public registerModules(extraModules: AbstractModule[] = []) {
|
||||
this._registerModule(new ModuleLiveSyncMain(this));
|
||||
this._registerModule(new ModuleConflictChecker(this));
|
||||
this._registerModule(new ModuleReplicatorMinIO(this));
|
||||
this._registerModule(new ModuleReplicatorCouchDB(this));
|
||||
this._registerModule(new ModuleReplicator(this));
|
||||
this._registerModule(new ModuleConflictResolver(this));
|
||||
this._registerModule(new ModulePeriodicProcess(this));
|
||||
this._registerModule(new ModuleResolvingMismatchedTweaks(this));
|
||||
this._registerModule(new ModuleBasicMenu(this));
|
||||
|
||||
@@ -223,10 +238,11 @@ export class LiveSyncBaseCore<
|
||||
}
|
||||
|
||||
/**
|
||||
* @obsolete Use services.replication.getActiveReplicator instead. Get the active replicator instance. Note that there can be multiple replicators, but only one can be active at a time.
|
||||
* @obsolete Use the provider context or a focused service operation instead.
|
||||
* Provider-specific members on this compatibility view are optional.
|
||||
*/
|
||||
get replicator() {
|
||||
return this.services.replicator.getActiveReplicator()!;
|
||||
get replicator(): CompatibilityReplicatorView {
|
||||
return this.services.replicator.getActiveReplicator() as CompatibilityReplicatorView;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,12 +289,15 @@ export class LiveSyncBaseCore<
|
||||
* Initialise ServiceFeatures.
|
||||
* (Please refer `serviceFeatures` for more details)
|
||||
*/
|
||||
initialiseServiceFeatures() {
|
||||
initialiseServiceFeatures(): LiveSyncCoreFeatureViews {
|
||||
useTargetFilters(this);
|
||||
// enable target filter feature.
|
||||
usePrepareDatabaseForUse(this);
|
||||
// Migration to multiple remote configurations
|
||||
useRemoteConfigurationMigration(this);
|
||||
return Object.freeze({
|
||||
replicationScheduling: useReplicationScheduling(this),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,9 @@ livesync-cli [database-path] [command] [args...]
|
||||
- `init-settings` writes its target file. `setup`, `remote-add`, `remote-rm`, `remote-set`, and `remote-activate` write their settings changes without this option.
|
||||
- All remaining commands leave the settings file unchanged by default.
|
||||
- Temporary values used to suspend synchronisation or select a remote for one command are never written.
|
||||
- `--compat-remote-admin-exit-zero`: Preserve the former zero exit code when `mark-resolved`, `lock-remote`, or `unlock-remote` returns a provider verification failure.
|
||||
- Without this option, those commands return a non-zero exit code when verification fails.
|
||||
- Invalid arguments, unknown remote IDs, and errors thrown while activating or mutating the remote remain errors with or without this option.
|
||||
|
||||
### Commands
|
||||
|
||||
@@ -96,6 +99,8 @@ livesync-cli [database-path] [command] [args...]
|
||||
- `remote-status [remote-id]`: Show remote database status.
|
||||
- `init-settings [file]`: Create a default settings file.
|
||||
|
||||
Remote-administration commands verify the resulting milestone state through the selected provider. The existing `[Verification]` lines remain suitable for scripts which inspect command output, while the default exit code now reflects whether that verification succeeded.
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
@@ -338,6 +343,8 @@ Options:
|
||||
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
|
||||
--vault <path>, -V <path> (daemon/mirror) Path to vault directory, decoupled from database-path
|
||||
--write-settings Write setting changes after a successful command
|
||||
--compat-remote-admin-exit-zero
|
||||
Preserve the former zero exit code when remote-administration verification fails
|
||||
--help, -h Show this help message
|
||||
|
||||
Commands:
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
|
||||
import {
|
||||
CENTRAL_REMOTE_ADMINISTRATION_ACTIONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
||||
isCentralRemoteAdministrationVerified,
|
||||
type CentralRemoteAdministrationAction,
|
||||
type CentralRemoteAdministrationResult,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { writeStderrLine } from "@/apps/cli/cliOutput";
|
||||
import type { CLICommand, CLICommandContext, CLIOptions } from "./types";
|
||||
|
||||
const CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND = Object.freeze({
|
||||
"mark-resolved": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
||||
"lock-remote": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||
"unlock-remote": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
||||
} as const satisfies Partial<Record<CLICommand, CentralRemoteAdministrationAction>>);
|
||||
|
||||
export type CentralRemoteAdministrationCommand = keyof typeof CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND;
|
||||
|
||||
/** Return whether a CLI command belongs to the central-remote administration category. */
|
||||
export function isCentralRemoteAdministrationCommand(
|
||||
command: CLICommand
|
||||
): command is CentralRemoteAdministrationCommand {
|
||||
return Object.prototype.hasOwnProperty.call(CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND, command);
|
||||
}
|
||||
|
||||
function detailMessage(detail: unknown): string {
|
||||
return detail instanceof Error ? detail.message : String(detail);
|
||||
}
|
||||
|
||||
function reportMilestoneObservation(
|
||||
standardIo: StandardIo,
|
||||
observation: Extract<
|
||||
CentralRemoteAdministrationResult["observation"],
|
||||
{ kind: typeof CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE }
|
||||
>
|
||||
): void {
|
||||
standardIo.writeStderr(`[Verification] Remote Database: ${observation.locked ? "LOCKED" : "UNLOCKED"}\n`);
|
||||
standardIo.writeStderr(
|
||||
`[Verification] Current Device Node ID (${observation.nodeId}): ${observation.accepted ? "ACCEPTED" : "NOT ACCEPTED"}\n`
|
||||
);
|
||||
}
|
||||
|
||||
/** Map typed provider observations to the CLI's established verification output. */
|
||||
function reportCentralRemoteAdministrationResult(
|
||||
standardIo: StandardIo,
|
||||
result: CentralRemoteAdministrationResult
|
||||
): void {
|
||||
if (result.observation?.kind === CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE) {
|
||||
reportMilestoneObservation(standardIo, result.observation);
|
||||
return;
|
||||
}
|
||||
if (isCentralRemoteAdministrationVerified(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (result.reason) {
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR:
|
||||
standardIo.writeStderr("[Verification] No active replicator found\n");
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED:
|
||||
standardIo.writeStderr(
|
||||
`[Verification] Failed to connect to the configured remote: ${detailMessage(result.detail)}\n`
|
||||
);
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND:
|
||||
standardIo.writeStderr("[Verification] Milestone document not found on remote.\n");
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED:
|
||||
standardIo.writeStderr(
|
||||
`[Verification] Failed to fetch milestone document: ${detailMessage(result.detail)}\n`
|
||||
);
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE:
|
||||
standardIo.writeStderr("[Verification] Failed to initialise the current device identity.\n");
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_IMPLEMENTED:
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_APPLICABLE:
|
||||
standardIo.writeStderr("[Verification] Remote administration is unavailable for this provider.\n");
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH:
|
||||
standardIo.writeStderr("[Verification] The requested remote state was not observed.\n");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one provider-owned mutation and map its typed verification to CLI exit policy.
|
||||
* Mutation exceptions deliberately escape this boundary.
|
||||
*/
|
||||
export async function runCentralRemoteAdministrationCommand(
|
||||
options: CLIOptions,
|
||||
context: CLICommandContext,
|
||||
command: CentralRemoteAdministrationCommand
|
||||
): Promise<boolean> {
|
||||
const id = options.commandArgs[0]?.trim();
|
||||
if (id) {
|
||||
let switched = false;
|
||||
await context.core.services.setting.updateSettings((currentSettings) => {
|
||||
const activated = activateRemoteConfiguration(currentSettings, id);
|
||||
if (activated) {
|
||||
switched = true;
|
||||
return activated;
|
||||
}
|
||||
return currentSettings;
|
||||
}, false);
|
||||
|
||||
if (!switched) {
|
||||
context.core.services.context.standardIo.writeStderr(
|
||||
`[Info] Failed to temporarily activate remote configuration: ${id}\n`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
await context.core.services.control.applySettings();
|
||||
}
|
||||
|
||||
writeStderrLine(context.core.services.context.standardIo, `[Command] ${command}${id ? ` ${id}` : ""}`);
|
||||
const action = CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND[command];
|
||||
const result = await context.core.services.replicator.runCentralRemoteAdministration({ action });
|
||||
reportCentralRemoteAdministrationResult(context.core.services.context.standardIo, result);
|
||||
return isCentralRemoteAdministrationVerified(result) || options.compatRemoteAdminExitZero === true;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { runCommand } from "./runCommand";
|
||||
import type { CLIOptions } from "./types";
|
||||
|
||||
@@ -38,7 +39,7 @@ function createCoreMock() {
|
||||
currentSettings: vi.fn(() => ({ liveSync: true, syncOnStart: false })),
|
||||
},
|
||||
replication: {
|
||||
replicate: vi.fn(async () => true),
|
||||
replicateUnattended: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
appLifecycle: {
|
||||
onUnload: {
|
||||
@@ -87,6 +88,17 @@ const baseContext = {
|
||||
},
|
||||
} as any;
|
||||
|
||||
function createDaemonContext(core: ReturnType<typeof createCoreMock>) {
|
||||
return {
|
||||
...baseContext,
|
||||
core,
|
||||
replicationScheduling: {
|
||||
setExternalPollingMode: vi.fn(),
|
||||
markInitialOneShotSatisfied: vi.fn(),
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("daemon command", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -101,7 +113,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(offlineScanner.performFullScan).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -110,7 +122,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(false);
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
@@ -120,9 +132,11 @@ describe("daemon command", () => {
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
const context = createDaemonContext(core);
|
||||
await runCommand(makeDaemonOptions(30), context);
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||
expect(context.replicationScheduling.setExternalPollingMode).toHaveBeenCalledWith(true);
|
||||
// Interval should be in milliseconds (30s → 30000ms)
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 30000);
|
||||
});
|
||||
@@ -131,7 +145,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(10), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(10), createDaemonContext(core));
|
||||
|
||||
expect(core.services.setting.applyPartial).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ suspendFileWatching: false }),
|
||||
@@ -144,7 +158,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(core.services.setting.applyPartial).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -164,7 +178,7 @@ describe("daemon command", () => {
|
||||
}));
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(result).toBe(true);
|
||||
const warningCalls = core.services.context.standardIo.writeStderr.mock.calls.filter(
|
||||
@@ -182,7 +196,7 @@ describe("daemon command", () => {
|
||||
}));
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
const warningCalls = core.services.context.standardIo.writeStderr.mock.calls.filter(
|
||||
([chunk]: [string | Uint8Array]) =>
|
||||
@@ -194,37 +208,50 @@ describe("daemon command", () => {
|
||||
it("calls replicate before performFullScan", async () => {
|
||||
const core = createCoreMock();
|
||||
const callOrder: string[] = [];
|
||||
core.services.replication.replicate = vi.fn(async () => {
|
||||
core.services.replication.replicateUnattended = vi.fn(async () => {
|
||||
callOrder.push("replicate");
|
||||
return true;
|
||||
return { status: "completed" as const };
|
||||
});
|
||||
vi.mocked(offlineScanner.performFullScan).mockImplementation(async () => {
|
||||
callOrder.push("performFullScan");
|
||||
return true;
|
||||
});
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const context = createDaemonContext(core);
|
||||
await runCommand(makeDaemonOptions(), context);
|
||||
|
||||
expect(callOrder).toEqual(["replicate", "performFullScan"]);
|
||||
expect(core.services.replication.replicateUnattended).toHaveBeenCalledWith({
|
||||
trigger: "daemon",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
expect(context.replicationScheduling.markInitialOneShotSatisfied).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns false when initial replication fails", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replication.replicate = vi.fn(async () => false);
|
||||
core.services.replication.replicateUnattended = vi.fn(async () => ({
|
||||
status: "failed" as const,
|
||||
error: new Error("initial replication failed"),
|
||||
}));
|
||||
vi.mocked(offlineScanner.performFullScan).mockClear();
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(result).toBe(false);
|
||||
// performFullScan should NOT have been called
|
||||
expect(offlineScanner.performFullScan).not.toHaveBeenCalled();
|
||||
expect(core.services.replication.replicateUnattended).toHaveBeenCalledWith({
|
||||
trigger: "daemon",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
});
|
||||
|
||||
it("polling mode: registers onUnload handler that clears timeout", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(10), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(10), createDaemonContext(core));
|
||||
|
||||
// onUnload handler should have been registered
|
||||
expect(core.services.appLifecycle.onUnload.addHandler).toHaveBeenCalledTimes(1);
|
||||
@@ -242,17 +269,17 @@ describe("daemon command", () => {
|
||||
|
||||
// startup replicate (call 1) succeeds; poll calls 2–7 fail; call 8 succeeds.
|
||||
let callCount = 0;
|
||||
core.services.replication.replicate = vi.fn(async () => {
|
||||
core.services.replication.replicateUnattended = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return true; // initial startup replicate
|
||||
if (callCount === 1) return { status: "completed" as const }; // initial startup replicate
|
||||
if (callCount <= 7) throw new Error("network failure");
|
||||
return true; // recovery
|
||||
return { status: "completed" as const }; // recovery
|
||||
});
|
||||
|
||||
const baseMs = 30 * 1000;
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(30), createDaemonContext(core));
|
||||
|
||||
// After runCommand returns the first setTimeout has been scheduled.
|
||||
// setTimeoutSpy.mock.calls[0] is the initial schedule (baseMs).
|
||||
@@ -297,14 +324,14 @@ describe("daemon command", () => {
|
||||
|
||||
// Make replicate succeed on the initial call (startup), then fail on the poll.
|
||||
let callCount = 0;
|
||||
core.services.replication.replicate = vi.fn(async () => {
|
||||
core.services.replication.replicateUnattended = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return true; // startup replicate
|
||||
if (callCount === 1) return { status: "completed" as const }; // startup replicate
|
||||
throw new Error("network failure");
|
||||
});
|
||||
|
||||
const intervalMs = 30 * 1000;
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(30), createDaemonContext(core));
|
||||
|
||||
// Advance time to trigger the first poll callback and flush its async work.
|
||||
await vi.advanceTimersByTimeAsync(intervalMs);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { P2P_DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
||||
import { getPeerConnectionStats } from "@vrtmrz/livesync-commonlib/compat/rpc/transports/DiagRTCPeerConnections.utils";
|
||||
import type { P2PPeerConnectionMetrics, P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { fsPromises } from "@vrtmrz/livesync-commonlib/node";
|
||||
|
||||
type CLIP2PPeer = {
|
||||
@@ -12,12 +11,7 @@ type CLIP2PPeer = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
type CandidateSummary = {
|
||||
id: string;
|
||||
candidateType: string;
|
||||
protocol: string;
|
||||
relayProtocol: string;
|
||||
};
|
||||
type CLIP2PService = Pick<P2PServiceViews, "transportLifecycle" | "peerDirectory" | "targetedTransfer" | "diagnostics">;
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => compatGlobal.setTimeout(resolve, ms));
|
||||
@@ -43,35 +37,35 @@ function validateP2PSettings(core: LiveSyncBaseCore<ServiceContext, never>) {
|
||||
settings.P2P_IsHeadless = true;
|
||||
}
|
||||
|
||||
async function createReplicator(core: LiveSyncBaseCore<ServiceContext, never>): Promise<LiveSyncTrysteroReplicator> {
|
||||
function requireP2PService(
|
||||
core: LiveSyncBaseCore<ServiceContext, never>,
|
||||
service: CLIP2PService | undefined
|
||||
): CLIP2PService {
|
||||
validateP2PSettings(core);
|
||||
const replicator = await core.services.replicator.getNewReplicator();
|
||||
if (!replicator) {
|
||||
throw new Error("Failed to create replicator instance. Ensure P2P is enabled in settings.");
|
||||
if (!service) {
|
||||
throw new Error("P2P service is not available. Ensure the P2P feature was composed for this CLI process.");
|
||||
}
|
||||
if (!(replicator instanceof LiveSyncTrysteroReplicator)) {
|
||||
throw new Error("Unexpected replicator type. Expected LiveSyncTrysteroReplicator.");
|
||||
}
|
||||
return replicator;
|
||||
return service;
|
||||
}
|
||||
|
||||
function getSortedPeers(replicator: LiveSyncTrysteroReplicator): CLIP2PPeer[] {
|
||||
return [...replicator.knownAdvertisements]
|
||||
function getSortedPeers(service: Pick<P2PServiceViews, "peerDirectory">): CLIP2PPeer[] {
|
||||
return [...service.peerDirectory.getPeers()]
|
||||
.map((peer) => ({ peerId: peer.peerId, name: peer.name }))
|
||||
.sort((a, b) => a.peerId.localeCompare(b.peerId));
|
||||
}
|
||||
|
||||
export async function collectPeers(
|
||||
core: LiveSyncBaseCore<ServiceContext, never>,
|
||||
p2pService: CLIP2PService | undefined,
|
||||
timeoutSec: number
|
||||
): Promise<CLIP2PPeer[]> {
|
||||
const replicator = await createReplicator(core);
|
||||
await replicator.open();
|
||||
const service = requireP2PService(core, p2pService);
|
||||
await service.transportLifecycle.connect();
|
||||
try {
|
||||
await delay(timeoutSec * 1000);
|
||||
return getSortedPeers(replicator);
|
||||
return getSortedPeers(service);
|
||||
} finally {
|
||||
await replicator.close();
|
||||
await service.transportLifecycle.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,32 +84,8 @@ function resolvePeer(peers: CLIP2PPeer[], peerToken: string): CLIP2PPeer | undef
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getReportValue<T extends string | number>(
|
||||
report: Record<string, unknown> | undefined,
|
||||
key: string
|
||||
): T | "unknown" {
|
||||
const value = report?.[key];
|
||||
return typeof value === "string" || typeof value === "number" ? (value as T) : "unknown";
|
||||
}
|
||||
|
||||
function summariseCandidate(reports: unknown[], candidateId: string): CandidateSummary | undefined {
|
||||
if (candidateId === "unknown") {
|
||||
return undefined;
|
||||
}
|
||||
const report = reports.map((r) => r as Record<string, unknown>).find((r) => r.id === candidateId);
|
||||
if (!report) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: candidateId,
|
||||
candidateType: getReportValue<string>(report, "candidateType"),
|
||||
protocol: getReportValue<string>(report, "protocol"),
|
||||
relayProtocol: getReportValue<string>(report, "relayProtocol"),
|
||||
};
|
||||
}
|
||||
|
||||
async function writePeerConnectionStatsIfRequested(
|
||||
replicator: LiveSyncTrysteroReplicator,
|
||||
service: Pick<P2PServiceViews, "diagnostics">,
|
||||
peer: CLIP2PPeer
|
||||
): Promise<void> {
|
||||
const outputPath = process.env.LIVESYNC_P2P_STATS_JSONL?.trim();
|
||||
@@ -123,21 +93,30 @@ async function writePeerConnectionStatsIfRequested(
|
||||
return;
|
||||
}
|
||||
|
||||
const peerConnection = replicator.rawHost?.room?.getPeers()[peer.peerId];
|
||||
const stats = peerConnection ? await getPeerConnectionStats(`cli-p2p-${peer.peerId}`, peerConnection) : undefined;
|
||||
const localCandidate = summariseCandidate(stats?.reports ?? [], stats?.localCandidateId ?? "unknown");
|
||||
const remoteCandidate = summariseCandidate(stats?.reports ?? [], stats?.remoteCandidateId ?? "unknown");
|
||||
const stats = await service.diagnostics.getPeerConnectionMetrics(peer.peerId);
|
||||
const payload = createPeerConnectionStatsPayload(peer, stats, new Date().toISOString());
|
||||
await fsPromises.appendFile(outputPath, `${JSON.stringify(payload)}\n`, "utf8");
|
||||
}
|
||||
|
||||
/** Build the stable JSONL record consumed by the P2P benchmark harnesses. */
|
||||
export function createPeerConnectionStatsPayload(
|
||||
peer: CLIP2PPeer,
|
||||
stats: P2PPeerConnectionMetrics | undefined,
|
||||
generatedAt: string
|
||||
) {
|
||||
const localCandidate = stats?.localCandidate;
|
||||
const remoteCandidate = stats?.remoteCandidate;
|
||||
const selectedPath =
|
||||
localCandidate && remoteCandidate
|
||||
? `${localCandidate.candidateType}<->${remoteCandidate.candidateType}`
|
||||
: "unknown";
|
||||
|
||||
const payload = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
generatedAt,
|
||||
command: "p2p-sync",
|
||||
peerId: peer.peerId,
|
||||
peerName: peer.name,
|
||||
candidatePathCollected: !!stats?.selectedPair,
|
||||
candidatePathCollected: stats?.selectedPairPresent ?? false,
|
||||
selectedPath,
|
||||
selectedPair: stats
|
||||
? {
|
||||
@@ -155,23 +134,24 @@ async function writePeerConnectionStatsIfRequested(
|
||||
localCandidate,
|
||||
remoteCandidate,
|
||||
};
|
||||
await fsPromises.appendFile(outputPath, `${JSON.stringify(payload)}\n`, "utf8");
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function syncWithPeer(
|
||||
core: LiveSyncBaseCore<ServiceContext, never>,
|
||||
p2pService: CLIP2PService | undefined,
|
||||
peerToken: string,
|
||||
timeoutSec: number
|
||||
): Promise<CLIP2PPeer> {
|
||||
const replicator = await createReplicator(core);
|
||||
await replicator.open();
|
||||
const service = requireP2PService(core, p2pService);
|
||||
await service.transportLifecycle.connect();
|
||||
try {
|
||||
const timeoutMs = timeoutSec * 1000;
|
||||
const start = Date.now();
|
||||
let targetPeer: CLIP2PPeer | undefined;
|
||||
|
||||
while (Date.now() - start <= timeoutMs) {
|
||||
const peers = getSortedPeers(replicator);
|
||||
const peers = getSortedPeers(service);
|
||||
targetPeer = resolvePeer(peers, peerToken);
|
||||
if (targetPeer) {
|
||||
break;
|
||||
@@ -183,11 +163,11 @@ export async function syncWithPeer(
|
||||
throw new Error(`Peer '${peerToken}' was not found within ${timeoutSec} seconds`);
|
||||
}
|
||||
|
||||
const pullResult = await replicator.replicateFrom(targetPeer.peerId, false);
|
||||
const pullResult = await service.targetedTransfer.pullFromPeer(targetPeer.peerId, { showNotice: false });
|
||||
if (pullResult && "error" in pullResult && pullResult.error) {
|
||||
throw pullResult.error instanceof Error ? pullResult.error : LiveSyncError.fromError(pullResult.error);
|
||||
}
|
||||
const pushResult = await replicator.requestSynchroniseToPeer(targetPeer.peerId);
|
||||
const pushResult = await service.targetedTransfer.requestPushToPeer(targetPeer.peerId);
|
||||
if (!pushResult || pushResult.ok !== true) {
|
||||
const err: unknown = pushResult && "error" in pushResult ? pushResult.error : undefined;
|
||||
throw err instanceof Error
|
||||
@@ -195,15 +175,18 @@ export async function syncWithPeer(
|
||||
: LiveSyncError.fromError(err ?? "P2P sync failed while requesting remote sync");
|
||||
}
|
||||
|
||||
await writePeerConnectionStatsIfRequested(replicator, targetPeer);
|
||||
await writePeerConnectionStatsIfRequested(service, targetPeer);
|
||||
return targetPeer;
|
||||
} finally {
|
||||
await replicator.close();
|
||||
await service.transportLifecycle.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export async function openP2PHost(core: LiveSyncBaseCore<ServiceContext, never>): Promise<LiveSyncTrysteroReplicator> {
|
||||
const replicator = await createReplicator(core);
|
||||
await replicator.open();
|
||||
return replicator;
|
||||
export async function openP2PHost(
|
||||
core: LiveSyncBaseCore<ServiceContext, never>,
|
||||
p2pService: CLIP2PService | undefined
|
||||
): Promise<CLIP2PService> {
|
||||
const service = requireP2PService(core, p2pService);
|
||||
await service.transportLifecycle.connect();
|
||||
return service;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseTimeoutSeconds } from "./p2p";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { collectPeers, createPeerConnectionStatsPayload, parseTimeoutSeconds, syncWithPeer } from "./p2p";
|
||||
|
||||
function createCore() {
|
||||
const settings = { P2P_Enabled: true, P2P_AppID: "app-id", P2P_IsHeadless: false };
|
||||
return {
|
||||
services: {
|
||||
setting: { currentSettings: () => settings },
|
||||
replicator: { getNewReplicator: vi.fn(() => Promise.reject(new Error("must not be called"))) },
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function createP2PService() {
|
||||
const connect = vi.fn(async () => undefined);
|
||||
const disconnect = vi.fn(async () => undefined);
|
||||
const pullFromPeer = vi.fn(async () => ({ ok: true }));
|
||||
const requestPushToPeer = vi.fn(async () => ({ ok: true }));
|
||||
return {
|
||||
service: {
|
||||
transportLifecycle: { isConnected: false, connect, disconnect },
|
||||
peerDirectory: {
|
||||
getPeers: () => [{ peerId: "peer-a", name: "Peer A", platform: "test" }],
|
||||
},
|
||||
targetedTransfer: {
|
||||
pullFromPeer,
|
||||
requestPushToPeer,
|
||||
synchroniseWithPeer: vi.fn(),
|
||||
},
|
||||
diagnostics: { requestStatus: vi.fn(), getPeerConnectionMetrics: vi.fn() },
|
||||
},
|
||||
connect,
|
||||
disconnect,
|
||||
pullFromPeer,
|
||||
requestPushToPeer,
|
||||
};
|
||||
}
|
||||
|
||||
describe("p2p command helpers", () => {
|
||||
it("accepts non-negative timeout", () => {
|
||||
@@ -15,4 +50,88 @@ describe("p2p command helpers", () => {
|
||||
"p2p-sync requires a non-negative timeout in seconds"
|
||||
);
|
||||
});
|
||||
|
||||
it("collects peers through service views without acquiring a concrete replicator", async () => {
|
||||
const { service, connect, disconnect } = createP2PService();
|
||||
|
||||
await expect(collectPeers(createCore(), service as never, 0)).resolves.toEqual([
|
||||
{ peerId: "peer-a", name: "Peer A" },
|
||||
]);
|
||||
expect(connect).toHaveBeenCalledOnce();
|
||||
expect(disconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("synchronises through the targeted-transfer view", async () => {
|
||||
const { service, pullFromPeer, requestPushToPeer } = createP2PService();
|
||||
|
||||
await expect(syncWithPeer(createCore(), service as never, "peer-a", 0)).resolves.toEqual({
|
||||
peerId: "peer-a",
|
||||
name: "Peer A",
|
||||
});
|
||||
expect(pullFromPeer).toHaveBeenCalledWith("peer-a", { showNotice: false });
|
||||
expect(requestPushToPeer).toHaveBeenCalledWith("peer-a");
|
||||
});
|
||||
|
||||
it("preserves the benchmark diagnostics JSONL contract", () => {
|
||||
expect(
|
||||
createPeerConnectionStatsPayload(
|
||||
{ peerId: "peer-a", name: "Peer A" },
|
||||
{
|
||||
selectedPairPresent: true,
|
||||
selectedPairId: "pair-1",
|
||||
state: "succeeded",
|
||||
currentRoundTripTime: 0.01,
|
||||
totalRoundTripTime: 0.1,
|
||||
requestsSent: 3,
|
||||
responsesReceived: 3,
|
||||
packetsDiscardedOnSend: 0,
|
||||
bytesSent: 100,
|
||||
bytesReceived: 200,
|
||||
localCandidate: {
|
||||
id: "local-1",
|
||||
candidateType: "host",
|
||||
protocol: "udp",
|
||||
relayProtocol: "unknown",
|
||||
},
|
||||
remoteCandidate: {
|
||||
id: "remote-1",
|
||||
candidateType: "relay",
|
||||
protocol: "udp",
|
||||
relayProtocol: "udp",
|
||||
},
|
||||
},
|
||||
"2026-08-27T00:00:00.000Z"
|
||||
)
|
||||
).toEqual({
|
||||
generatedAt: "2026-08-27T00:00:00.000Z",
|
||||
command: "p2p-sync",
|
||||
peerId: "peer-a",
|
||||
peerName: "Peer A",
|
||||
candidatePathCollected: true,
|
||||
selectedPath: "host<->relay",
|
||||
selectedPair: {
|
||||
id: "pair-1",
|
||||
state: "succeeded",
|
||||
currentRoundTripTime: 0.01,
|
||||
totalRoundTripTime: 0.1,
|
||||
requestsSent: 3,
|
||||
responsesReceived: 3,
|
||||
packetsDiscardedOnSend: 0,
|
||||
bytesSent: 100,
|
||||
bytesReceived: 200,
|
||||
},
|
||||
localCandidate: {
|
||||
id: "local-1",
|
||||
candidateType: "host",
|
||||
protocol: "udp",
|
||||
relayProtocol: "unknown",
|
||||
},
|
||||
remoteCandidate: {
|
||||
id: "remote-1",
|
||||
candidateType: "relay",
|
||||
protocol: "udp",
|
||||
relayProtocol: "udp",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,8 @@ import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/compat/AP
|
||||
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
MILESTONE_DOCID,
|
||||
type FilePathWithPrefix,
|
||||
type ObsidianLiveSyncSettings,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
type EntryMilestoneInfo,
|
||||
type EntryDoc,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
@@ -23,67 +19,26 @@ import { performFullScan } from "@vrtmrz/livesync-commonlib/compat/serviceFeatur
|
||||
import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import type { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput";
|
||||
import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
isReplicationCompleted,
|
||||
NO_INTERACTION,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
|
||||
import {
|
||||
isCentralRemoteAdministrationCommand,
|
||||
runCentralRemoteAdministrationCommand,
|
||||
} from "./centralRemoteAdministration";
|
||||
|
||||
function redactConnectionString(uri: string): string {
|
||||
return uri.replace(/\/\/([^@/]+)@/u, "//***@");
|
||||
}
|
||||
|
||||
async function verifyRemoteState(
|
||||
core: CLICommandContext["core"],
|
||||
settings: ObsidianLiveSyncSettings
|
||||
): Promise<boolean> {
|
||||
const { standardIo } = core.services.context;
|
||||
const replicator = core.services.replicator.getActiveReplicator();
|
||||
if (!replicator) {
|
||||
standardIo.writeStderr("[Verification] No active replicator found\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!replicator.nodeid) {
|
||||
await replicator.initializeDatabaseForReplication();
|
||||
}
|
||||
|
||||
try {
|
||||
let milestone: EntryMilestoneInfo | false | undefined = undefined;
|
||||
if (settings.remoteType === REMOTE_COUCHDB) {
|
||||
const dbRet = await (replicator as LiveSyncCouchDBReplicator).connectRemoteCouchDBWithSetting(
|
||||
settings,
|
||||
false,
|
||||
true
|
||||
);
|
||||
if (typeof dbRet === "string") {
|
||||
standardIo.writeStderr(`[Verification] Failed to connect to remote CouchDB: ${dbRet}\n`);
|
||||
return false;
|
||||
}
|
||||
milestone = await dbRet.db.get(MILESTONE_DOCID);
|
||||
} else if (settings.remoteType === REMOTE_MINIO) {
|
||||
milestone = await (replicator as LiveSyncJournalReplicator).client.downloadJson("_00000000-milestone.json");
|
||||
}
|
||||
|
||||
if (milestone) {
|
||||
const isLocked = !!milestone.locked;
|
||||
const isAccepted = !!milestone.accepted_nodes?.includes(replicator.nodeid);
|
||||
standardIo.writeStderr(`[Verification] Remote Database: ${isLocked ? "LOCKED" : "UNLOCKED"}\n`);
|
||||
standardIo.writeStderr(
|
||||
`[Verification] Current Device Node ID (${replicator.nodeid}): ${isAccepted ? "ACCEPTED" : "NOT ACCEPTED"}\n`
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
standardIo.writeStderr("[Verification] Milestone document not found on remote.\n");
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
standardIo.writeStderr(`[Verification] Failed to fetch milestone document: ${message}\n`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise<boolean> {
|
||||
const { databasePath, core, settingsPath } = context;
|
||||
const { databasePath, core, replicationScheduling, settingsPath } = context;
|
||||
const { standardIo } = core.services.context;
|
||||
const vaultPath = context.vaultPath || databasePath;
|
||||
|
||||
@@ -91,19 +46,28 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
if (options.command === "daemon") {
|
||||
const log = (msg: unknown) => writeStderrLine(standardIo, `[Daemon] ${String(msg)}`);
|
||||
|
||||
// The daemon owns its own recurring poller. Suppress the application
|
||||
// resume starter and generic periodic timer before restoring settings.
|
||||
replicationScheduling.setExternalPollingMode(!!options.interval);
|
||||
|
||||
// Skip the config mismatch dialog — the daemon cannot resolve it interactively
|
||||
// and the default "Dismiss" action would block replication. The daemon should
|
||||
// accept whatever configuration the remote has.
|
||||
await core.services.setting.applyPartial({ disableCheckingConfigMismatch: true }, true);
|
||||
|
||||
// 1. Replicate CouchDB → local PouchDB so the mirror scan has content to work with.
|
||||
log("Replicating from CouchDB...");
|
||||
const replResult = await core.services.replication.replicate(true);
|
||||
if (!replResult) {
|
||||
writeStderrLine(standardIo, "[Daemon] Initial CouchDB replication failed, cannot continue");
|
||||
// 1. Replicate the configured remote into the local database so the
|
||||
// mirror scan has content to work with.
|
||||
log("Replicating from remote...");
|
||||
const replResult = await core.services.replication.replicateUnattended({
|
||||
trigger: "daemon",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
if (!isReplicationCompleted(replResult)) {
|
||||
writeStderrLine(standardIo, "[Daemon] Initial replication failed, cannot continue");
|
||||
return false;
|
||||
}
|
||||
log("CouchDB replication complete");
|
||||
replicationScheduling.markInitialOneShotSatisfied();
|
||||
log("Initial replication complete");
|
||||
|
||||
// 2. Mirror scan to reconcile PouchDB ↔ local filesystem.
|
||||
const errorManager = new UnresolvedErrorManager(core.services.appLifecycle, core.services.context.events);
|
||||
@@ -125,8 +89,9 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
true
|
||||
);
|
||||
// applySettings fires the full lifecycle: onSuspending → onResumed.
|
||||
// ModuleReplicatorCouchDB starts continuous replication on onResumed
|
||||
// via fireAndForget.
|
||||
// The provider-independent scheduling feature owns any eligible
|
||||
// Continuous start; the daemon marker suppresses a duplicate
|
||||
// sync-on-start OneShot.
|
||||
await core.services.control.applySettings();
|
||||
// Lifecycle events (onSuspending) may re-enable suspension flags.
|
||||
// Clear them explicitly after the lifecycle completes. applyPartial
|
||||
@@ -149,7 +114,13 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
await core.services.replication.replicate(true);
|
||||
const result = await core.services.replication.replicateUnattended({
|
||||
trigger: "daemon",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
if (!isReplicationCompleted(result)) {
|
||||
throw new Error(`Daemon polling replication did not complete (${result.status}).`);
|
||||
}
|
||||
if (consecutiveFailures > 0) {
|
||||
consecutiveFailures--;
|
||||
currentIntervalMs = Math.max(currentIntervalMs / 2, baseIntervalMs);
|
||||
@@ -178,11 +149,11 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
log("LiveSync mode: restoring sync settings and starting _changes feed");
|
||||
log("LiveSync mode: restoring sync settings and starting continuous synchronisation where supported");
|
||||
await restoreSyncSettings();
|
||||
// The applySettings() lifecycle fires onResumed → ModuleReplicatorCouchDB which
|
||||
// starts continuous replication via fireAndForget(openReplication). Don't call
|
||||
// openReplication directly — it races with the handler and causes dedup/termination.
|
||||
// The applySettings() lifecycle fires onResumed → the provider-
|
||||
// independent scheduling feature, which starts Continuous when
|
||||
// supported. Do not call a concrete Replicator directly.
|
||||
log("LiveSync active");
|
||||
const currentSettings = core.services.setting.currentSettings();
|
||||
if (!currentSettings.liveSync && !currentSettings.syncOnStart) {
|
||||
@@ -200,13 +171,19 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
|
||||
if (options.command === "sync") {
|
||||
writeStdoutLine(standardIo, "[Command] sync");
|
||||
const result = await core.services.replication.replicate(true);
|
||||
if (!result) {
|
||||
const result = await core.services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
if (!isReplicationCompleted(result)) {
|
||||
// TODO: Standardise the logic for identifying the cause of replication
|
||||
// failure so that every reason (locked DB, version mismatch, network
|
||||
// error, etc.) is surfaced with a CLI-specific actionable message.
|
||||
const replicator = core.services.replicator.getActiveReplicator();
|
||||
if (replicator?.remoteLockedAndDeviceNotAccepted) {
|
||||
const recoveryHint = result.status === "failed" ? result.recoveryHint : undefined;
|
||||
if (
|
||||
recoveryHint?.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED ||
|
||||
recoveryHint?.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED
|
||||
) {
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`[Error] The remote database is locked and this device is not yet accepted.\n` +
|
||||
@@ -214,7 +191,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
);
|
||||
}
|
||||
}
|
||||
return !!result;
|
||||
return isReplicationCompleted(result);
|
||||
}
|
||||
|
||||
if (options.command === "p2p-peers") {
|
||||
@@ -223,7 +200,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
}
|
||||
const timeoutSec = parseTimeoutSeconds(options.commandArgs[0], "p2p-peers");
|
||||
writeStderrLine(standardIo, `[Command] p2p-peers timeout=${timeoutSec}s`);
|
||||
const peers = await collectPeers(core, timeoutSec);
|
||||
const peers = await collectPeers(core, context.p2pReplicator, timeoutSec);
|
||||
if (peers.length > 0) {
|
||||
standardIo.writeStdout(peers.map((peer) => `[peer]\t${peer.peerId}\t${peer.name}`).join("\n") + "\n");
|
||||
}
|
||||
@@ -240,14 +217,14 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
}
|
||||
const timeoutSec = parseTimeoutSeconds(options.commandArgs[1], "p2p-sync");
|
||||
writeStderrLine(standardIo, `[Command] p2p-sync peer=${peerToken} timeout=${timeoutSec}s`);
|
||||
const peer = await syncWithPeer(core, peerToken, timeoutSec);
|
||||
const peer = await syncWithPeer(core, context.p2pReplicator, peerToken, timeoutSec);
|
||||
writeStderrLine(standardIo, `[Done] P2P sync completed with ${peer.name} (${peer.peerId})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "p2p-host") {
|
||||
writeStderrLine(standardIo, "[Command] p2p-host");
|
||||
await openP2PHost(core);
|
||||
await openP2PHost(core, context.p2pReplicator);
|
||||
writeStderrLine(standardIo, "[Ready] P2P host is running. Press Ctrl+C to stop.");
|
||||
await new Promise(() => {});
|
||||
return true;
|
||||
@@ -753,88 +730,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "mark-resolved") {
|
||||
const id = options.commandArgs[0]?.trim();
|
||||
if (id) {
|
||||
let switched = false;
|
||||
await core.services.setting.updateSettings((currentSettings) => {
|
||||
const activated = activateRemoteConfiguration(currentSettings, id);
|
||||
if (activated) {
|
||||
switched = true;
|
||||
return activated;
|
||||
}
|
||||
return currentSettings;
|
||||
}, false);
|
||||
|
||||
if (!switched) {
|
||||
standardIo.writeStderr(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await core.services.control.applySettings();
|
||||
}
|
||||
|
||||
writeStderrLine(standardIo, `[Command] mark-resolved${id ? ` ${id}` : ""}`);
|
||||
await core.services.replication.markResolved();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
await verifyRemoteState(core, settings);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "unlock-remote") {
|
||||
const id = options.commandArgs[0]?.trim();
|
||||
if (id) {
|
||||
let switched = false;
|
||||
await core.services.setting.updateSettings((currentSettings) => {
|
||||
const activated = activateRemoteConfiguration(currentSettings, id);
|
||||
if (activated) {
|
||||
switched = true;
|
||||
return activated;
|
||||
}
|
||||
return currentSettings;
|
||||
}, false);
|
||||
|
||||
if (!switched) {
|
||||
standardIo.writeStderr(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await core.services.control.applySettings();
|
||||
}
|
||||
|
||||
writeStderrLine(standardIo, `[Command] unlock-remote${id ? ` ${id}` : ""}`);
|
||||
await core.services.replication.markUnlocked();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
await verifyRemoteState(core, settings);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "lock-remote") {
|
||||
const id = options.commandArgs[0]?.trim();
|
||||
if (id) {
|
||||
let switched = false;
|
||||
await core.services.setting.updateSettings((currentSettings) => {
|
||||
const activated = activateRemoteConfiguration(currentSettings, id);
|
||||
if (activated) {
|
||||
switched = true;
|
||||
return activated;
|
||||
}
|
||||
return currentSettings;
|
||||
}, false);
|
||||
|
||||
if (!switched) {
|
||||
standardIo.writeStderr(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await core.services.control.applySettings();
|
||||
}
|
||||
|
||||
writeStderrLine(standardIo, `[Command] lock-remote${id ? ` ${id}` : ""}`);
|
||||
await core.services.replication.markLocked();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
await verifyRemoteState(core, settings);
|
||||
return true;
|
||||
if (isCentralRemoteAdministrationCommand(options.command)) {
|
||||
return await runCentralRemoteAdministrationCommand(options, context, options.command);
|
||||
}
|
||||
|
||||
if (options.command === "remote-status") {
|
||||
@@ -859,13 +756,16 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
}
|
||||
|
||||
writeStderrLine(standardIo, `[Command] remote-status${id ? ` ${id}` : ""}`);
|
||||
const replicator = core.services.replicator.getActiveReplicator();
|
||||
if (!replicator) {
|
||||
standardIo.writeStderr("[Error] No active replicator found\n");
|
||||
const settings = core.services.setting.currentSettings();
|
||||
const resource = await core.services.replicator.createRemoteResource(
|
||||
REMOTE_RESOURCE_KINDS.CONNECTION,
|
||||
settings
|
||||
);
|
||||
if (!resource) {
|
||||
standardIo.writeStderr("[Error] Remote status is unavailable for the current provider\n");
|
||||
return false;
|
||||
}
|
||||
const settings = core.services.setting.currentSettings();
|
||||
const status = await replicator.getRemoteStatus(settings);
|
||||
const status = await withOwnedRemoteResource(resource, (ownedResource) => ownedResource.getStatus());
|
||||
if (status === false) {
|
||||
standardIo.writeStderr("[Error] Failed to fetch remote status\n");
|
||||
return false;
|
||||
|
||||
@@ -2,10 +2,25 @@ import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import * as processSetting from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { runCommand } from "./runCommand";
|
||||
import type { CLIOptions } from "./types";
|
||||
import {
|
||||
CENTRAL_REMOTE_ADMINISTRATION_ACTIONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
REPLICATION_COMPLETED,
|
||||
replicationFailed,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
function createStandardIoMock() {
|
||||
return {
|
||||
@@ -44,8 +59,26 @@ function createCoreMock() {
|
||||
markResolved: vi.fn(async () => {}),
|
||||
markUnlocked: vi.fn(async () => {}),
|
||||
markLocked: vi.fn(async () => {}),
|
||||
replicateUserInitiated: vi.fn(async () => REPLICATION_COMPLETED),
|
||||
},
|
||||
replicator: {
|
||||
runCentralRemoteAdministration: vi.fn(async ({ action }) => ({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED,
|
||||
observation: {
|
||||
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||
locked: action === CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||
accepted: true,
|
||||
nodeId: "test-node-id",
|
||||
},
|
||||
})),
|
||||
createRemoteResource: vi.fn(async () => ({
|
||||
check: vi.fn(async () => ({ ok: true as const })),
|
||||
getStatus: vi.fn(async () => ({
|
||||
db_name: "test-db",
|
||||
doc_count: 42,
|
||||
})),
|
||||
dispose: vi.fn(async () => undefined),
|
||||
})),
|
||||
getActiveReplicator: vi.fn(() => ({
|
||||
nodeid: "test-node-id",
|
||||
initializeDatabaseForReplication: vi.fn(async () => {}),
|
||||
@@ -93,6 +126,7 @@ function makeOptions(command: CLIOptions["command"], commandArgs: string[]): CLI
|
||||
databasePath: "/tmp/vault",
|
||||
verbose: false,
|
||||
force: false,
|
||||
compatRemoteAdminExitZero: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -231,6 +265,27 @@ describe("runCommand abnormal cases", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reports a lock from the exact sync outcome without inspecting a replacement Replicator", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replication.replicateUserInitiated.mockResolvedValue(
|
||||
replicationFailed(new Error("locked"), {
|
||||
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED,
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
runCommand(makeOptions("sync", []), {
|
||||
...context,
|
||||
core,
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining("remote database is locked")
|
||||
);
|
||||
expect(core.services.replicator.getActiveReplicator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pull returns false for non-existing path", async () => {
|
||||
const core = createCoreMock();
|
||||
core.serviceModules.fileHandler.dbToStorage.mockResolvedValue(false);
|
||||
@@ -706,6 +761,123 @@ describe("runCommand abnormal cases", () => {
|
||||
});
|
||||
|
||||
describe("mark-resolved and unlock-remote commands", () => {
|
||||
it("reports a connection failure without claiming that every central remote is CouchDB", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED,
|
||||
detail: new Error("remote unavailable"),
|
||||
});
|
||||
|
||||
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
const verificationOutput = core.services.context.standardIo.writeStderr.mock.calls
|
||||
.map(([chunk]: [string | Uint8Array]) =>
|
||||
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)
|
||||
)
|
||||
.join("");
|
||||
expect(verificationOutput).toContain(
|
||||
"[Verification] Failed to connect to the configured remote: remote unavailable\n"
|
||||
);
|
||||
expect(verificationOutput).not.toContain("CouchDB");
|
||||
});
|
||||
|
||||
it("fails by default when remote administration cannot verify its postcondition", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR,
|
||||
});
|
||||
|
||||
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the historical zero exit for returned verification failures only when requested", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR,
|
||||
});
|
||||
|
||||
const result = await runCommand(
|
||||
{ ...makeOptions("mark-resolved", []), compatRemoteAdminExitZero: true },
|
||||
{
|
||||
...context,
|
||||
core,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("does not hide a thrown remote mutation failure behind the compatibility option", async () => {
|
||||
const core = createCoreMock();
|
||||
const failure = new Error("mutation failed");
|
||||
core.services.replicator.runCentralRemoteAdministration.mockRejectedValueOnce(failure);
|
||||
|
||||
await expect(
|
||||
runCommand(
|
||||
{ ...makeOptions("mark-resolved", []), compatRemoteAdminExitZero: true },
|
||||
{
|
||||
...context,
|
||||
core,
|
||||
}
|
||||
)
|
||||
).rejects.toBe(failure);
|
||||
});
|
||||
|
||||
it("does not hide an unknown remote ID behind the compatibility option", async () => {
|
||||
const core = createCoreMock();
|
||||
|
||||
const result = await runCommand(
|
||||
{ ...makeOptions("mark-resolved", ["missing-remote"]), compatRemoteAdminExitZero: true },
|
||||
{
|
||||
...context,
|
||||
core,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails a lock command when the observed milestone remains unlocked", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH,
|
||||
observation: {
|
||||
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||
locked: false,
|
||||
accepted: true,
|
||||
nodeId: "test-node-id",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCommand(makeOptions("lock-remote", []), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
const verificationOutput = core.services.context.standardIo.writeStderr.mock.calls
|
||||
.map(([chunk]: [string | Uint8Array]) =>
|
||||
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)
|
||||
)
|
||||
.join("");
|
||||
expect(verificationOutput).toContain("[Verification] Remote Database: UNLOCKED\n");
|
||||
expect(verificationOutput).toContain("[Verification] Current Device Node ID (test-node-id): ACCEPTED\n");
|
||||
});
|
||||
|
||||
it("mark-resolved without args runs on active database", async () => {
|
||||
const core = createCoreMock();
|
||||
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||
@@ -713,8 +885,11 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markResolved).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
||||
});
|
||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||
expect(core.services.replication.markResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mark-resolved with remote-id temporarily activates it and runs markResolved", async () => {
|
||||
@@ -732,7 +907,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markResolved).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
||||
});
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
expect(settings.activeConfigurationId).toBe("r1");
|
||||
expect(core.services.setting.updateSettings).toHaveBeenCalledWith(expect.any(Function), false);
|
||||
@@ -745,7 +922,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markUnlocked).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
||||
});
|
||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -764,7 +943,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markUnlocked).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
||||
});
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
expect(settings.activeConfigurationId).toBe("r1");
|
||||
expect(core.services.setting.updateSettings).toHaveBeenCalledWith(expect.any(Function), false);
|
||||
@@ -777,7 +958,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markLocked).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||
});
|
||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -796,7 +979,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markLocked).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||
});
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
expect(settings.activeConfigurationId).toBe("r1");
|
||||
expect(core.services.setting.updateSettings).toHaveBeenCalledWith(expect.any(Function), false);
|
||||
@@ -804,6 +989,17 @@ describe("runCommand abnormal cases", () => {
|
||||
|
||||
it("remote-status without args outputs status of active remote configuration", async () => {
|
||||
const core = createCoreMock();
|
||||
const getStatus = vi.fn(async () => ({
|
||||
db_name: "test-db",
|
||||
doc_count: 42,
|
||||
}));
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const createRemoteResource = vi.fn(async () => ({
|
||||
check: vi.fn(),
|
||||
getStatus,
|
||||
dispose,
|
||||
}));
|
||||
core.services.replicator.createRemoteResource = createRemoteResource;
|
||||
const stdout = captureStdout(core);
|
||||
const result = await runCommand(makeOptions("remote-status", []), {
|
||||
...context,
|
||||
@@ -814,6 +1010,13 @@ describe("runCommand abnormal cases", () => {
|
||||
const parsedStatus = JSON.parse(fullOutput);
|
||||
expect(parsedStatus.db_name).toBe("test-db");
|
||||
expect(parsedStatus.doc_count).toBe(42);
|
||||
expect(createRemoteResource).toHaveBeenCalledWith(
|
||||
REMOTE_RESOURCE_KINDS.CONNECTION,
|
||||
core.services.setting.currentSettings()
|
||||
);
|
||||
expect(getStatus).toHaveBeenCalledOnce();
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
expect(core.services.replicator.getActiveReplicator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("remote-status with remote-id temporarily activates it and outputs status", async () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { NodeServiceContext } from "@/apps/cli/services/NodeServiceContext";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { ReplicationSchedulingControl } from "@/serviceFeatures/replicationScheduling";
|
||||
|
||||
export type CLICommand =
|
||||
| "daemon"
|
||||
@@ -41,6 +42,8 @@ export interface CLIOptions {
|
||||
debug?: boolean;
|
||||
force?: boolean;
|
||||
writeSettings?: boolean;
|
||||
/** Restore the former zero exit code after a returned remote-administration verification failure. */
|
||||
compatRemoteAdminExitZero?: boolean;
|
||||
command: CLICommand;
|
||||
commandArgs: string[];
|
||||
interval?: number;
|
||||
@@ -50,6 +53,8 @@ export interface CLICommandContext {
|
||||
databasePath: string;
|
||||
vaultPath: string;
|
||||
core: LiveSyncBaseCore<NodeServiceContext, never>;
|
||||
/** Host-composition view used only to coordinate daemon-owned recurring work. */
|
||||
replicationScheduling: ReplicationSchedulingControl;
|
||||
/** Current-result contract owned by the P2P service feature. */
|
||||
p2pReplicator?: UseP2PReplicatorResult;
|
||||
settingsPath: string;
|
||||
|
||||
+24
-5
@@ -23,8 +23,8 @@ import type { CLICommand, CLICommandContext, CLIOptions } from "./commands/types
|
||||
import { getPathFromUXFileInfo } from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
|
||||
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { IgnoreRules } from "./serviceModules/IgnoreRules";
|
||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import { useP2PReplicatorFeature, type UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { ReplicationSchedulingControl } from "@/serviceFeatures/replicationScheduling";
|
||||
import { createNodeStandardIo, fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { writeStderrLine, writeStdoutLine } from "./cliOutput";
|
||||
@@ -103,6 +103,8 @@ Options:
|
||||
(defaults to database-path; allows separate PouchDB and vault dirs)
|
||||
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
|
||||
--write-settings Write setting changes after a successful command
|
||||
--compat-remote-admin-exit-zero
|
||||
Preserve the former zero exit code when remote-administration verification fails
|
||||
|
||||
Examples:
|
||||
livesync-cli ./my-database Run daemon (LiveSync mode)
|
||||
@@ -153,6 +155,7 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
|
||||
let debug = false;
|
||||
let force = false;
|
||||
let writeSettings = false;
|
||||
let compatRemoteAdminExitZero = false;
|
||||
let interval: number | undefined;
|
||||
let command: CLICommand = "daemon";
|
||||
const commandArgs: string[] = [];
|
||||
@@ -212,6 +215,9 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
|
||||
case "--write-settings":
|
||||
writeSettings = true;
|
||||
break;
|
||||
case "--compat-remote-admin-exit-zero":
|
||||
compatRemoteAdminExitZero = true;
|
||||
break;
|
||||
default: {
|
||||
if (!databasePath) {
|
||||
if (command === "daemon" && isCLICommand(token)) {
|
||||
@@ -253,6 +259,7 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
|
||||
debug,
|
||||
force,
|
||||
writeSettings,
|
||||
compatRemoteAdminExitZero,
|
||||
command,
|
||||
commandArgs,
|
||||
interval,
|
||||
@@ -290,7 +297,10 @@ export async function main(
|
||||
) {
|
||||
const options = parseArgs(standardIo);
|
||||
if (options.interval && options.command !== "daemon") {
|
||||
writeStderrLine(standardIo, `Warning: --interval is only used in daemon mode, ignored for '${options.command}'`);
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`Warning: --interval is only used in daemon mode, ignored for '${options.command}'`
|
||||
);
|
||||
}
|
||||
const avoidStdoutNoise =
|
||||
options.command === "cat" ||
|
||||
@@ -420,7 +430,10 @@ export async function main(
|
||||
// In daemon mode the default handler must run so changes are applied to the filesystem.
|
||||
if (options.command !== "daemon") {
|
||||
serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => {
|
||||
writeStderrLine(standardIo, `[Info] Replication result received, but not processed automatically in CLI mode.`);
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`[Info] Replication result received, but not processed automatically in CLI mode.`
|
||||
);
|
||||
return await Promise.resolve(true);
|
||||
}, -100);
|
||||
}
|
||||
@@ -472,6 +485,7 @@ export async function main(
|
||||
|
||||
// Create LiveSync core
|
||||
let p2pReplicator: UseP2PReplicatorResult | undefined;
|
||||
let replicationScheduling: ReplicationSchedulingControl | undefined;
|
||||
const core = new LiveSyncBaseCore(
|
||||
serviceHubInstance,
|
||||
(core: LiveSyncBaseCore<NodeServiceContext, never>, serviceHub: InjectableServiceHub<NodeServiceContext>) => {
|
||||
@@ -479,7 +493,8 @@ export async function main(
|
||||
},
|
||||
(core) => [],
|
||||
() => [], // No add-ons
|
||||
(core) => {
|
||||
(core, coreFeatureViews) => {
|
||||
replicationScheduling = coreFeatureViews.replicationScheduling;
|
||||
// Register P2P replicator feature.
|
||||
p2pReplicator = useP2PReplicatorFeature(core);
|
||||
// Add target filter to prevent internal files are handled
|
||||
@@ -511,6 +526,9 @@ export async function main(
|
||||
}
|
||||
}
|
||||
);
|
||||
if (!replicationScheduling) {
|
||||
throw new Error("Replication scheduling was not provided during core feature composition.");
|
||||
}
|
||||
|
||||
// Setup signal handlers for graceful shutdown
|
||||
const shutdown = async (signal: string) => {
|
||||
@@ -617,6 +635,7 @@ export async function main(
|
||||
databasePath,
|
||||
vaultPath,
|
||||
core,
|
||||
replicationScheduling,
|
||||
p2pReplicator,
|
||||
settingsPath,
|
||||
originalSyncSettings,
|
||||
|
||||
@@ -69,6 +69,7 @@ describe("CLI parseArgs", () => {
|
||||
const combined = standardIo.writeStdout.mock.calls.flat().join("");
|
||||
expect(combined).toContain("Usage:");
|
||||
expect(combined).toContain("livesync-cli <database-path> [options] <command> [command-args]");
|
||||
expect(combined).toContain("--compat-remote-admin-exit-zero");
|
||||
});
|
||||
|
||||
it("parses p2p-peers command and timeout", () => {
|
||||
@@ -215,4 +216,13 @@ describe("CLI parseArgs", () => {
|
||||
expect(parsed.writeSettings).toBe(true);
|
||||
expect(parsed.commandArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses the remote-administration exit compatibility option globally", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "--compat-remote-admin-exit-zero", "mark-resolved"];
|
||||
const parsed = parseArgs();
|
||||
|
||||
expect(parsed.command).toBe("mark-resolved");
|
||||
expect(parsed.compatRemoteAdminExitZero).toBe(true);
|
||||
expect(parsed.commandArgs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,8 +121,10 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
||||
return {
|
||||
path: path.relative(this.basePath, filePath).replace(/\\/g, "/") as FilePath,
|
||||
stat: {
|
||||
ctime: stats?.ctimeMs ?? Date.now(),
|
||||
mtime: stats?.mtimeMs ?? Date.now(),
|
||||
// Floor to integer milliseconds; Linux fs.Stats.*Ms carry sub-millisecond
|
||||
// precision, and timestamps are stored as integer ms everywhere else.
|
||||
ctime: Math.floor(stats?.ctimeMs ?? Date.now()),
|
||||
mtime: Math.floor(stats?.mtimeMs ?? Date.now()),
|
||||
size: stats?.size ?? 0,
|
||||
type: "file",
|
||||
},
|
||||
|
||||
@@ -84,6 +84,27 @@ describe("CLIStorageEventManagerAdapter", () => {
|
||||
expect(created.stat?.size).toBe(42);
|
||||
});
|
||||
|
||||
it("floors sub-millisecond stat timestamps so mobile clients do not receive floats", async () => {
|
||||
const basePath = "/vault/base";
|
||||
const adapter = new CLIStorageEventManagerAdapter(basePath, undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
|
||||
const addCallback = mockWatcher.on.mock.calls.find(([event]) => event === "add")![1] as (
|
||||
filePath: string,
|
||||
stats: any
|
||||
) => void;
|
||||
|
||||
// Linux fs.Stats carry nanosecond-derived sub-millisecond precision.
|
||||
const floatStats = { ctimeMs: 1778511180024.462, mtimeMs: 1778511180999.913, size: 7 };
|
||||
addCallback(`${basePath}/note.md`, floatStats);
|
||||
|
||||
const created = (handlers.onCreate as ReturnType<typeof vi.fn>).mock.calls[0][0] as NodeFile;
|
||||
expect(created.stat?.ctime).toBe(1778511180024);
|
||||
expect(created.stat?.mtime).toBe(1778511180999);
|
||||
});
|
||||
|
||||
it("close() calls watcher.close()", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.8-cli",
|
||||
"version": "1.0.21-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.52",
|
||||
"octagonal-wheels": "^0.1.53",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||
"pouchdb-core": "^9.0.0",
|
||||
@@ -48,7 +48,7 @@
|
||||
"pouchdb-replication": "^9.0.0",
|
||||
"pouchdb-utils": "^9.0.0",
|
||||
"transform-pouch": "^2.0.0",
|
||||
"werift": "^0.23.0"
|
||||
"werift": "^0.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.9.3",
|
||||
|
||||
@@ -111,6 +111,7 @@ export function initialiseServiceModulesCLI(
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler: fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess: storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { CLICommandContext } from "@/apps/cli/commands/types";
|
||||
import { openP2PHost } from "@/apps/cli/commands/p2p";
|
||||
|
||||
@@ -15,32 +15,35 @@ function describeError(value: unknown): string {
|
||||
return value instanceof Error ? (value.stack ?? value.message) : String(value);
|
||||
}
|
||||
|
||||
async function waitForServing(replicator: LiveSyncTrysteroReplicator, timeoutMs: number): Promise<void> {
|
||||
type ProbeP2PService = Pick<P2PServiceViews, "transportLifecycle" | "peerDirectory" | "targetedTransfer">;
|
||||
|
||||
async function waitForServing(service: ProbeP2PService, timeoutMs: number): Promise<void> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started <= timeoutMs) {
|
||||
if (replicator.server?.isServing) return;
|
||||
if (service.transportLifecycle.isConnected) return;
|
||||
await delay(200);
|
||||
}
|
||||
throw new Error("The replacement P2P replicator did not start serving within the timeout");
|
||||
throw new Error("The stable P2P service did not start serving within the timeout");
|
||||
}
|
||||
|
||||
async function waitForPeer(
|
||||
replicator: LiveSyncTrysteroReplicator,
|
||||
service: ProbeP2PService,
|
||||
targetPeer: string,
|
||||
timeoutMs: number
|
||||
): Promise<{ peerId: string; name: string }> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started <= timeoutMs) {
|
||||
const peer = replicator.knownAdvertisements.find(
|
||||
(candidate) => candidate.name === targetPeer || candidate.peerId === targetPeer
|
||||
);
|
||||
const peer = service.peerDirectory
|
||||
.getPeers()
|
||||
.find((candidate) => candidate.name === targetPeer || candidate.peerId === targetPeer);
|
||||
if (peer) return peer;
|
||||
await delay(200);
|
||||
}
|
||||
const knownPeers = replicator.knownAdvertisements.map((peer) => `${peer.name} (${peer.peerId})`).join(", ");
|
||||
throw new Error(
|
||||
`Peer '${targetPeer}' was not discovered within the timeout. Known peers: ${knownPeers || "none"}`
|
||||
);
|
||||
const knownPeers = service.peerDirectory
|
||||
.getPeers()
|
||||
.map((peer) => `${peer.name} (${peer.peerId})`)
|
||||
.join(", ");
|
||||
throw new Error(`Peer '${targetPeer}' was not discovered within the timeout. Known peers: ${knownPeers || "none"}`);
|
||||
}
|
||||
|
||||
function assertPullSucceeded(result: unknown): void {
|
||||
@@ -50,15 +53,17 @@ function assertPullSucceeded(result: unknown): void {
|
||||
}
|
||||
|
||||
async function communicateWithPeer(
|
||||
replicator: LiveSyncTrysteroReplicator,
|
||||
service: ProbeP2PService,
|
||||
targetPeer: string,
|
||||
timeoutMs: number
|
||||
): Promise<{ peerId: string; name: string }> {
|
||||
await replicator.open();
|
||||
await waitForServing(replicator, timeoutMs);
|
||||
const peer = await waitForPeer(replicator, targetPeer, timeoutMs);
|
||||
assertPullSucceeded(await replicator.replicateFrom(peer.peerId, false));
|
||||
const pushResult = await replicator.requestSynchroniseToPeer(peer.peerId);
|
||||
if (!service.transportLifecycle.isConnected) {
|
||||
await service.transportLifecycle.connect();
|
||||
}
|
||||
await waitForServing(service, timeoutMs);
|
||||
const peer = await waitForPeer(service, targetPeer, timeoutMs);
|
||||
assertPullSucceeded(await service.targetedTransfer.pullFromPeer(peer.peerId, { showNotice: false }));
|
||||
const pushResult = await service.targetedTransfer.requestPushToPeer(peer.peerId);
|
||||
if (!pushResult || pushResult.ok !== true) {
|
||||
throw new Error(`P2P push failed: ${describeError(pushResult?.error)}`);
|
||||
}
|
||||
@@ -78,35 +83,39 @@ export async function runP2PReplicatorReplacementProbe(
|
||||
throw new Error("The CLI did not expose its P2P service-feature result to the integration probe");
|
||||
}
|
||||
|
||||
const firstReplicator = await openP2PHost(core);
|
||||
if (p2pReplicator.replicator !== firstReplicator) {
|
||||
throw new Error("The P2P service feature did not expose the newly created replicator");
|
||||
const initialActiveReplicator = core.services.replicator.getActiveReplicator();
|
||||
if (!initialActiveReplicator) {
|
||||
throw new Error("The CLI did not activate the initial P2P Replicator adapter");
|
||||
}
|
||||
const compatibilityFacade = p2pReplicator.replicator;
|
||||
const p2pService = await openP2PHost(core, p2pReplicator);
|
||||
|
||||
const firstPeer = await communicateWithPeer(firstReplicator, targetPeer, timeoutMs);
|
||||
const firstPeer = await communicateWithPeer(p2pService, targetPeer, timeoutMs);
|
||||
const initialised = await core.services.databaseEvents.initialiseDatabase(false, true, false);
|
||||
if (!initialised) {
|
||||
throw new Error("Database reinitialisation failed during the P2P replacement probe");
|
||||
}
|
||||
|
||||
const replacementReplicator = p2pReplicator.replicator;
|
||||
if (core.services.replicator.getActiveReplicator() !== replacementReplicator) {
|
||||
throw new Error("ReplicatorService did not activate the P2P service feature's replacement replicator");
|
||||
const replacementActiveReplicator = core.services.replicator.getActiveReplicator();
|
||||
if (!replacementActiveReplicator) {
|
||||
throw new Error("ReplicatorService did not activate a replacement P2P Replicator adapter");
|
||||
}
|
||||
if (replacementReplicator === firstReplicator) {
|
||||
throw new Error("Database reinitialisation retained the previous P2P replicator instance");
|
||||
if (replacementActiveReplicator === initialActiveReplicator) {
|
||||
throw new Error("Database reinitialisation retained the previous active P2P Replicator adapter");
|
||||
}
|
||||
if (firstReplicator.server !== undefined) {
|
||||
throw new Error("The previous P2P replicator remained open after replacement");
|
||||
if (p2pReplicator.replicator !== compatibilityFacade) {
|
||||
throw new Error("Database reinitialisation replaced the stable P2P service compatibility facade");
|
||||
}
|
||||
if (p2pService.transportLifecycle.isConnected) {
|
||||
throw new Error("Database reinitialisation left the database-bound P2P room open");
|
||||
}
|
||||
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.P2P_AutoStart = true;
|
||||
await core.services.control.applySettings();
|
||||
const resumedReplicator = p2pReplicator.replicator;
|
||||
await waitForServing(resumedReplicator, timeoutMs);
|
||||
if (firstReplicator.server !== undefined) {
|
||||
throw new Error("A setting event reopened the previous P2P replicator");
|
||||
await waitForServing(p2pService, timeoutMs);
|
||||
if (p2pReplicator.replicator !== compatibilityFacade) {
|
||||
throw new Error("A setting event replaced the stable P2P service compatibility facade");
|
||||
}
|
||||
|
||||
const encoded = new TextEncoder().encode(noteContent);
|
||||
@@ -118,7 +127,7 @@ export async function runP2PReplicatorReplacementProbe(
|
||||
});
|
||||
await core.serviceModules.fileHandler.storeFileToDB(notePath as FilePathWithPrefix, true);
|
||||
|
||||
const replacementPeer = await communicateWithPeer(resumedReplicator, targetPeer, timeoutMs);
|
||||
const replacementPeer = await communicateWithPeer(p2pService, targetPeer, timeoutMs);
|
||||
if (replacementPeer.name !== firstPeer.name) {
|
||||
throw new Error(
|
||||
`The replacement replicator reached '${replacementPeer.name}' instead of the original peer '${firstPeer.name}'`
|
||||
@@ -126,7 +135,7 @@ export async function runP2PReplicatorReplacementProbe(
|
||||
}
|
||||
|
||||
core.services.context.standardIo.writeStdout(
|
||||
`[Probe] P2P replicator replaced, old transport stayed closed, and ${notePath} was sent through the replacement.\n`
|
||||
`[Probe] The active P2P adapter was replaced, the stable service reopened, and ${notePath} was sent through it.\n`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"test:decoupled-vault": "deno test --env-file=.test.env -A --no-check test-decoupled-vault.ts",
|
||||
"test:remote-commands": "deno test --env-file=.test.env -A --no-check test-remote-commands.ts",
|
||||
"test:settings-writeback": "deno test -A --no-check test-settings-writeback.ts",
|
||||
"test:remote-administration-exit-codes": "deno test -A --no-check test-remote-administration-exit-codes.ts",
|
||||
"test:push-pull": "deno test --env-file=.test.env -A --no-check test-push-pull.ts",
|
||||
"test:setup-put-cat": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts",
|
||||
"test:mirror": "deno test --env-file=.test.env -A --no-check test-mirror.ts",
|
||||
|
||||
@@ -143,8 +143,12 @@ export async function createCompressionBenchmarkDataset(options: {
|
||||
);
|
||||
await copyRepositoryFile("json", "package.json", "package.json");
|
||||
await copyRepositoryFile("json", "manifest.json", "manifest.json");
|
||||
await copyRepositoryFile("ts", "src/modules/core/ModuleReplicator.ts", "ModuleReplicator.ts");
|
||||
await copyRepositoryFile("ts", "src/modules/core/ReplicateResultProcessor.ts", "ReplicateResultProcessor.ts");
|
||||
await copyRepositoryFile("ts", "src/serviceFeatures/replication/index.ts", "replicationFeature.ts");
|
||||
await copyRepositoryFile(
|
||||
"ts",
|
||||
"src/serviceFeatures/replication/ReplicateResultProcessor.ts",
|
||||
"ReplicateResultProcessor.ts"
|
||||
);
|
||||
|
||||
const markdownBytes = await Deno.readFile(join(repositoryRoot, "docs/settings.md"));
|
||||
const gzipPath = join(datasetRoot, "gz", "settings.md.gz");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const TASKS = [
|
||||
"test:settings-writeback",
|
||||
"test:remote-administration-exit-codes",
|
||||
"test:setup-put-cat",
|
||||
"test:mirror",
|
||||
"test:daemon",
|
||||
|
||||
@@ -79,7 +79,6 @@ Deno.test("benchmark cases record scope and limitations for paper use", () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("CouchDB latency proxy applies half the requested RTT in each direction", async () => {
|
||||
const backendPort = getFreePort();
|
||||
const proxyPort = getFreePort();
|
||||
@@ -156,8 +155,8 @@ Deno.test("compression benchmark dataset covers representative file kinds determ
|
||||
"images/quick-setup/guide-quick-setup-first-setup-uri.png",
|
||||
"package.json",
|
||||
"manifest.json",
|
||||
"src/modules/core/ModuleReplicator.ts",
|
||||
"src/modules/core/ReplicateResultProcessor.ts",
|
||||
"src/serviceFeatures/replication/index.ts",
|
||||
"src/serviceFeatures/replication/ReplicateResultProcessor.ts",
|
||||
];
|
||||
try {
|
||||
for (const [index, relativePath] of repositoryFiles.entries()) {
|
||||
|
||||
@@ -39,7 +39,7 @@ async function runReplacementProbe(
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("p2p lifecycle: replacement keeps real CLI communication on the current replicator", async () => {
|
||||
Deno.test("p2p lifecycle: active-adapter replacement keeps real CLI communication on the stable service", async () => {
|
||||
const relay = Deno.env.get("RELAY") ?? "ws://localhost:4000/";
|
||||
const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "20");
|
||||
const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "60");
|
||||
@@ -82,11 +82,8 @@ Deno.test("p2p lifecycle: replacement keeps real CLI communication on the curren
|
||||
try {
|
||||
await host.waitUntilContains("P2P host is running", 20000);
|
||||
const probe = await runReplacementProbe(probeVault, probeSettings, hostPeerName, probeTimeoutMs);
|
||||
assert(
|
||||
probe.code === 0,
|
||||
`P2P replacement probe failed\nstdout: ${probe.stdout}\nstderr: ${probe.stderr}`
|
||||
);
|
||||
assertStringIncludes(probe.stdout, "[Probe] P2P replicator replaced");
|
||||
assert(probe.code === 0, `P2P replacement probe failed\nstdout: ${probe.stdout}\nstderr: ${probe.stderr}`);
|
||||
assertStringIncludes(probe.stdout, "[Probe] The active P2P adapter was replaced");
|
||||
|
||||
const syncResult = await runCli(
|
||||
verifierVault,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { assertEquals, assertStringIncludes } from "@std/assert";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { runCli } from "./helpers/cli.ts";
|
||||
import { applyCouchdbSettings, applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
||||
|
||||
async function prepareFixture(prefix: string) {
|
||||
const workDir = await TempDir.create(prefix);
|
||||
const settingsFile = workDir.join("settings.json");
|
||||
const databaseDir = workDir.join("database");
|
||||
await Deno.mkdir(databaseDir, { recursive: true });
|
||||
await initSettingsFile(settingsFile);
|
||||
return { workDir, settingsFile, databaseDir };
|
||||
}
|
||||
|
||||
Deno.test("remote administration process exit policy distinguishes returned verification failure", async () => {
|
||||
const fixture = await prepareFixture("livesync-cli-remote-admin-exit");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
await applyP2pSettings(
|
||||
settingsFile,
|
||||
"remote-admin-exit-room",
|
||||
"remote-admin-exit-passphrase",
|
||||
"remote-admin-exit-tests",
|
||||
"ws://127.0.0.1:1/",
|
||||
"~.*",
|
||||
"none"
|
||||
);
|
||||
await applyP2pTestTweaks(settingsFile, "remote-admin-exit-device", "remote-admin-exit-passphrase");
|
||||
|
||||
const defaultFailure = await runCli(databaseDir, "--settings", settingsFile, "mark-resolved");
|
||||
assertEquals(defaultFailure.code, 1, defaultFailure.combined);
|
||||
assertStringIncludes(
|
||||
defaultFailure.combined,
|
||||
"[Verification] Remote administration is unavailable for this provider."
|
||||
);
|
||||
assertStringIncludes(defaultFailure.combined, "[Error] Command 'mark-resolved' failed");
|
||||
|
||||
const compatibilitySuccess = await runCli(
|
||||
databaseDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"--compat-remote-admin-exit-zero",
|
||||
"mark-resolved"
|
||||
);
|
||||
assertEquals(compatibilitySuccess.code, 0, compatibilitySuccess.combined);
|
||||
assertStringIncludes(
|
||||
compatibilitySuccess.combined,
|
||||
"[Verification] Remote administration is unavailable for this provider."
|
||||
);
|
||||
assertStringIncludes(compatibilitySuccess.combined, "[Done] Command 'mark-resolved' completed");
|
||||
});
|
||||
|
||||
Deno.test("remote administration compatibility does not hide a thrown mutation failure", async () => {
|
||||
const fixture = await prepareFixture("livesync-cli-remote-admin-mutation");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
await applyCouchdbSettings(
|
||||
settingsFile,
|
||||
"http://127.0.0.1:1/",
|
||||
"unreachable-user",
|
||||
"unreachable-password",
|
||||
"unreachable-database"
|
||||
);
|
||||
|
||||
const mutationFailure = await runCli(
|
||||
databaseDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"--compat-remote-admin-exit-zero",
|
||||
"mark-resolved"
|
||||
);
|
||||
assertEquals(mutationFailure.code, 1, mutationFailure.combined);
|
||||
assertStringIncludes(mutationFailure.combined, "[Command] mark-resolved");
|
||||
assertStringIncludes(mutationFailure.combined, "[Error] Failed to start:");
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.8-webapp",
|
||||
"version": "1.0.21-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
@@ -15,7 +15,7 @@
|
||||
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webapp/browser-smoke.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
"octagonal-wheels": "^0.1.53"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
|
||||
@@ -102,6 +102,7 @@ export function initialiseServiceModulesFSAPI(
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler: fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess: storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.8-webpeer",
|
||||
"version": "1.0.21-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.52"
|
||||
"octagonal-wheels": "^0.1.53"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-plugin-svelte": "^3.19.0",
|
||||
|
||||
@@ -53,7 +53,7 @@ export class P2PCheckSession {
|
||||
|
||||
try {
|
||||
await runtime.start();
|
||||
await runtime.currentReplicator.makeSureOpened();
|
||||
await runtime.p2p.transportLifecycle.connect();
|
||||
} catch (error) {
|
||||
await this.stop();
|
||||
throw error;
|
||||
|
||||
@@ -3,10 +3,9 @@ import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFu
|
||||
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import type { PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
|
||||
import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||
import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
|
||||
@@ -48,7 +47,7 @@ function removeFromList(item: string, list: string): string {
|
||||
export class WebPeerRuntime {
|
||||
readonly context: ServiceContext;
|
||||
readonly services: LiveSyncBrowserServiceHub<ServiceContext>;
|
||||
readonly p2p: UseP2PReplicatorResult;
|
||||
readonly p2p: P2PServiceViews;
|
||||
readonly p2pLogCollector: P2PLogCollector;
|
||||
readonly paneHost: P2PReplicatorPaneHost;
|
||||
|
||||
@@ -87,10 +86,6 @@ export class WebPeerRuntime {
|
||||
return this.context.events;
|
||||
}
|
||||
|
||||
get currentReplicator(): LiveSyncTrysteroReplicator {
|
||||
return this.p2p.replicator;
|
||||
}
|
||||
|
||||
get settings(): P2PSyncSetting {
|
||||
return this.services.setting.currentSettings();
|
||||
}
|
||||
@@ -119,9 +114,7 @@ export class WebPeerRuntime {
|
||||
}
|
||||
this.services.appLifecycle.markIsReady();
|
||||
this.events.emitEvent(EVENT_LAYOUT_READY);
|
||||
if (this.settings.P2P_AutoStart && this.settings.P2P_Enabled) {
|
||||
compatGlobal.setTimeout(() => void this.currentReplicator.open(), 100);
|
||||
}
|
||||
await this.services.appLifecycle.onResumed();
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -151,12 +144,12 @@ export class WebPeerRuntime {
|
||||
this.menu = new Menu()
|
||||
.addItem((item) =>
|
||||
item.setTitle("📥 Only fetch").onClick(async () => {
|
||||
await this.currentReplicator.replicateFrom(peer.peerId);
|
||||
await this.p2p.targetedTransfer.pullFromPeer(peer.peerId);
|
||||
})
|
||||
)
|
||||
.addItem((item) =>
|
||||
item.setTitle("📤 Only send").onClick(async () => {
|
||||
await this.currentReplicator.requestSynchroniseToPeer(peer.peerId);
|
||||
await this.p2p.targetedTransfer.requestPushToPeer(peer.peerId);
|
||||
})
|
||||
)
|
||||
.addSeparator()
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
MILESTONE_DOCID,
|
||||
type EntryMilestoneInfo,
|
||||
type RemoteDBSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import type { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import {
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
||||
applyCentralRemoteAdministrationMutation,
|
||||
milestoneSatisfiesCentralRemoteAdministration,
|
||||
centralRemoteAdministrationVerificationFailed,
|
||||
centralRemoteAdministrationVerified,
|
||||
supportedCapability,
|
||||
type MilestoneCentralRemoteAdministrationObservation,
|
||||
type CentralRemoteAdministrationFailureReason,
|
||||
type CentralRemoteAdministrationRequest,
|
||||
type CentralRemoteAdministrationReplicator,
|
||||
type CentralRemoteAdministrationResult,
|
||||
type CentralRemoteAdministrationRunner,
|
||||
type ReplicatorInstance,
|
||||
type SupportedCapability,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
const JOURNAL_MILESTONE_PATH = "_00000000-milestone.json";
|
||||
|
||||
type CentralMilestoneReadResult =
|
||||
| { readonly milestone: EntryMilestoneInfo | false | undefined }
|
||||
| { readonly failureReason: CentralRemoteAdministrationFailureReason; readonly detail?: unknown };
|
||||
|
||||
type PreparedCentralMilestoneReader = () => Promise<CentralMilestoneReadResult>;
|
||||
|
||||
type CentralMilestoneReaderPreparer = (
|
||||
replicator: CentralRemoteAdministrationReplicator,
|
||||
setting: RemoteDBSettings
|
||||
) => PreparedCentralMilestoneReader;
|
||||
|
||||
type CouchDBAdministrationReplicator = CentralRemoteAdministrationReplicator &
|
||||
Pick<LiveSyncCouchDBReplicator, "connectRemoteCouchDBWithSetting" | "isMobile">;
|
||||
|
||||
type JournalAdministrationClient = Pick<LiveSyncJournalReplicator["client"], "downloadJson">;
|
||||
|
||||
function isCentralRemoteAdministrationReplicator(
|
||||
replicator: ReplicatorInstance
|
||||
): replicator is CentralRemoteAdministrationReplicator {
|
||||
return (
|
||||
"nodeid" in replicator &&
|
||||
typeof replicator.nodeid === "string" &&
|
||||
"markRemoteResolved" in replicator &&
|
||||
typeof replicator.markRemoteResolved === "function" &&
|
||||
"markRemoteLocked" in replicator &&
|
||||
typeof replicator.markRemoteLocked === "function"
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureLocalNodeIdentity(
|
||||
replicator: CentralRemoteAdministrationReplicator
|
||||
): Promise<CentralRemoteAdministrationResult | undefined> {
|
||||
if (replicator.nodeid) {
|
||||
return undefined;
|
||||
}
|
||||
if ((await replicator.initializeDatabaseForReplication()) && replicator.nodeid) {
|
||||
return undefined;
|
||||
}
|
||||
return centralRemoteAdministrationVerificationFailed(
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
function observeMilestone(
|
||||
replicator: CentralRemoteAdministrationReplicator,
|
||||
milestone: EntryMilestoneInfo
|
||||
): MilestoneCentralRemoteAdministrationObservation {
|
||||
return {
|
||||
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||
locked: !!milestone.locked,
|
||||
accepted: !!milestone.accepted_nodes?.includes(replicator.nodeid),
|
||||
nodeId: replicator.nodeid,
|
||||
};
|
||||
}
|
||||
|
||||
function resultFromMilestone(
|
||||
replicator: CentralRemoteAdministrationReplicator,
|
||||
request: CentralRemoteAdministrationRequest,
|
||||
milestone: EntryMilestoneInfo | false | undefined
|
||||
): CentralRemoteAdministrationResult {
|
||||
if (!milestone) {
|
||||
return centralRemoteAdministrationVerificationFailed(
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND
|
||||
);
|
||||
}
|
||||
const observation = observeMilestone(replicator, milestone);
|
||||
return milestoneSatisfiesCentralRemoteAdministration(request.action, observation)
|
||||
? centralRemoteAdministrationVerified(observation)
|
||||
: centralRemoteAdministrationVerificationFailed(
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH,
|
||||
{
|
||||
observation,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply and verify the central milestone protocol without selecting a provider.
|
||||
*
|
||||
* The provider definition has already selected the reader preparer. Preparing
|
||||
* it before mutation rejects incomplete composition before a remote write and
|
||||
* binds any provider-owned client which must be used for postcondition reading.
|
||||
*/
|
||||
async function runCentralRemoteAdministration(
|
||||
replicator: CentralRemoteAdministrationReplicator,
|
||||
setting: RemoteDBSettings,
|
||||
request: CentralRemoteAdministrationRequest,
|
||||
prepareMilestoneReader: CentralMilestoneReaderPreparer
|
||||
): Promise<CentralRemoteAdministrationResult> {
|
||||
const identityFailure = await ensureLocalNodeIdentity(replicator);
|
||||
if (identityFailure) return identityFailure;
|
||||
|
||||
const readMilestone = prepareMilestoneReader(replicator, setting);
|
||||
await applyCentralRemoteAdministrationMutation(replicator, setting, request.action);
|
||||
|
||||
const readResult = await readMilestone();
|
||||
if ("failureReason" in readResult) {
|
||||
return centralRemoteAdministrationVerificationFailed(readResult.failureReason, { detail: readResult.detail });
|
||||
}
|
||||
return resultFromMilestone(replicator, request, readResult.milestone);
|
||||
}
|
||||
|
||||
function requireCouchDBAdministrationOperations(
|
||||
replicator: CentralRemoteAdministrationReplicator
|
||||
): asserts replicator is CouchDBAdministrationReplicator {
|
||||
if (
|
||||
!("connectRemoteCouchDBWithSetting" in replicator) ||
|
||||
typeof replicator.connectRemoteCouchDBWithSetting !== "function" ||
|
||||
!("isMobile" in replicator) ||
|
||||
typeof replicator.isMobile !== "function"
|
||||
) {
|
||||
throw new Error("The configured CouchDB administration adapter does not provide milestone access.");
|
||||
}
|
||||
}
|
||||
|
||||
function prepareCouchDBMilestoneReader(
|
||||
replicator: CentralRemoteAdministrationReplicator,
|
||||
setting: RemoteDBSettings
|
||||
): PreparedCentralMilestoneReader {
|
||||
requireCouchDBAdministrationOperations(replicator);
|
||||
|
||||
return async () => {
|
||||
let connection: Awaited<ReturnType<CouchDBAdministrationReplicator["connectRemoteCouchDBWithSetting"]>>;
|
||||
try {
|
||||
connection = await replicator.connectRemoteCouchDBWithSetting(setting, replicator.isMobile(), true);
|
||||
} catch (error) {
|
||||
return { failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED, detail: error };
|
||||
}
|
||||
if (typeof connection === "string") {
|
||||
return {
|
||||
failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED,
|
||||
detail: connection,
|
||||
};
|
||||
}
|
||||
|
||||
let milestone: EntryMilestoneInfo | undefined;
|
||||
let observationError: unknown;
|
||||
try {
|
||||
milestone = await connection.db.get<EntryMilestoneInfo>(MILESTONE_DOCID);
|
||||
} catch (error) {
|
||||
observationError = error;
|
||||
}
|
||||
try {
|
||||
await connection.close();
|
||||
} catch (error) {
|
||||
observationError ??= error;
|
||||
}
|
||||
if (observationError !== undefined) {
|
||||
return {
|
||||
failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED,
|
||||
detail: observationError,
|
||||
};
|
||||
}
|
||||
return { milestone };
|
||||
};
|
||||
}
|
||||
|
||||
function isJournalAdministrationClient(client: unknown): client is JournalAdministrationClient {
|
||||
return (
|
||||
typeof client === "object" &&
|
||||
client !== null &&
|
||||
"downloadJson" in client &&
|
||||
typeof client.downloadJson === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function requireJournalAdministrationClient(
|
||||
replicator: CentralRemoteAdministrationReplicator
|
||||
): JournalAdministrationClient {
|
||||
if (!("client" in replicator) || !isJournalAdministrationClient(replicator.client)) {
|
||||
throw new Error("The configured Object Storage administration adapter does not provide milestone access.");
|
||||
}
|
||||
return replicator.client;
|
||||
}
|
||||
|
||||
function prepareObjectStorageMilestoneReader(
|
||||
replicator: CentralRemoteAdministrationReplicator
|
||||
): PreparedCentralMilestoneReader {
|
||||
const client = requireJournalAdministrationClient(replicator);
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
return { milestone: await client.downloadJson<EntryMilestoneInfo>(JOURNAL_MILESTONE_PATH) };
|
||||
} catch (error) {
|
||||
return {
|
||||
failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED,
|
||||
detail: error,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const runCouchDBCentralRemoteAdministration: CentralRemoteAdministrationRunner = async (
|
||||
replicator,
|
||||
setting,
|
||||
request
|
||||
) => {
|
||||
if (!isCentralRemoteAdministrationReplicator(replicator)) {
|
||||
return centralRemoteAdministrationVerificationFailed(
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_APPLICABLE
|
||||
);
|
||||
}
|
||||
return await runCentralRemoteAdministration(replicator, setting, request, prepareCouchDBMilestoneReader);
|
||||
};
|
||||
|
||||
const runObjectStorageCentralRemoteAdministration: CentralRemoteAdministrationRunner = async (
|
||||
replicator,
|
||||
setting,
|
||||
request
|
||||
) => {
|
||||
if (!isCentralRemoteAdministrationReplicator(replicator)) {
|
||||
return centralRemoteAdministrationVerificationFailed(
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_APPLICABLE
|
||||
);
|
||||
}
|
||||
return await runCentralRemoteAdministration(replicator, setting, request, prepareObjectStorageMilestoneReader);
|
||||
};
|
||||
|
||||
/** CouchDB mutation and milestone postcondition verification capability. */
|
||||
export const COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY: SupportedCapability<CentralRemoteAdministrationRunner> =
|
||||
supportedCapability(runCouchDBCentralRemoteAdministration);
|
||||
|
||||
/** Object Storage mutation and milestone postcondition verification capability. */
|
||||
export const OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY: SupportedCapability<CentralRemoteAdministrationRunner> =
|
||||
supportedCapability(runObjectStorageCentralRemoteAdministration);
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
CENTRAL_REMOTE_ADMINISTRATION_ACTIONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
} from "./centralRemoteAdministration";
|
||||
|
||||
describe("central remote administration capabilities", () => {
|
||||
it("mutates CouchDB, verifies the requested postcondition, and closes only the owned connection", async () => {
|
||||
const rawDatabaseClose = vi.fn(async () => undefined);
|
||||
const close = vi.fn(async () => undefined);
|
||||
const database = {
|
||||
get: vi.fn(async () => ({ locked: true, accepted_nodes: ["node-1"] })),
|
||||
close: rawDatabaseClose,
|
||||
};
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
isMobile: vi.fn(() => false),
|
||||
markRemoteLocked: vi.fn(async () => undefined),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: database, close })),
|
||||
};
|
||||
const setting = { ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB };
|
||||
const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||
|
||||
await expect(
|
||||
capability.run(replicator as never, setting, { action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK })
|
||||
).resolves.toEqual({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED,
|
||||
observation: {
|
||||
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||
locked: true,
|
||||
accepted: true,
|
||||
nodeId: "node-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(replicator.markRemoteLocked).toHaveBeenCalledWith(setting, true, false);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(rawDatabaseClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a typed CouchDB failure when the observed milestone does not satisfy the action", async () => {
|
||||
const close = vi.fn(async () => undefined);
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
isMobile: vi.fn(() => false),
|
||||
markRemoteLocked: vi.fn(async () => undefined),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
connectRemoteCouchDBWithSetting: vi.fn(async () => ({
|
||||
db: { get: vi.fn(async () => ({ locked: false, accepted_nodes: ["node-1"] })) },
|
||||
close,
|
||||
})),
|
||||
};
|
||||
const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||
|
||||
const result = await capability.run(
|
||||
replicator as never,
|
||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB },
|
||||
{
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH,
|
||||
observation: { kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, locked: false },
|
||||
});
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not mutate when initialisation succeeds without publishing a local node identity", async () => {
|
||||
const replicator = {
|
||||
nodeid: "",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
isMobile: vi.fn(() => false),
|
||||
markRemoteLocked: vi.fn(async () => undefined),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
connectRemoteCouchDBWithSetting: vi.fn(async () => "must not connect"),
|
||||
};
|
||||
const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||
|
||||
await expect(
|
||||
capability.run(
|
||||
replicator as never,
|
||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB },
|
||||
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED }
|
||||
)
|
||||
).resolves.toEqual({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE,
|
||||
});
|
||||
expect(replicator.markRemoteResolved).not.toHaveBeenCalled();
|
||||
expect(replicator.connectRemoteCouchDBWithSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a CouchDB mutation exception to reject before verification", async () => {
|
||||
const failure = new Error("write failed");
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
isMobile: vi.fn(() => false),
|
||||
markRemoteLocked: vi.fn(async () => {
|
||||
throw failure;
|
||||
}),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
connectRemoteCouchDBWithSetting: vi.fn(),
|
||||
};
|
||||
const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||
|
||||
await expect(
|
||||
capability.run(
|
||||
replicator as never,
|
||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB },
|
||||
{
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
||||
}
|
||||
)
|
||||
).rejects.toBe(failure);
|
||||
expect(replicator.connectRemoteCouchDBWithSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mutates Object Storage and verifies its milestone postcondition", async () => {
|
||||
const downloadJson = vi.fn(async () => ({ locked: false, accepted_nodes: ["node-1"] }));
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
markRemoteLocked: vi.fn(async () => undefined),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
client: { downloadJson },
|
||||
};
|
||||
const setting = { ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO };
|
||||
const capability = OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||
|
||||
await expect(
|
||||
capability.run(replicator as never, setting, {
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED,
|
||||
observation: {
|
||||
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||
locked: false,
|
||||
accepted: true,
|
||||
nodeId: "node-1",
|
||||
},
|
||||
});
|
||||
expect(replicator.markRemoteResolved).toHaveBeenCalledWith(setting);
|
||||
expect(downloadJson).toHaveBeenCalledWith("_00000000-milestone.json");
|
||||
});
|
||||
|
||||
it("rejects an incomplete CouchDB milestone adapter before mutation", async () => {
|
||||
const markRemoteLocked = vi.fn(async () => undefined);
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
markRemoteLocked,
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
await expect(
|
||||
COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY.run(
|
||||
replicator as never,
|
||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB },
|
||||
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK }
|
||||
)
|
||||
).rejects.toThrow("The configured CouchDB administration adapter does not provide milestone access.");
|
||||
expect(markRemoteLocked).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an incomplete Object Storage milestone adapter before mutation", async () => {
|
||||
const markRemoteResolved = vi.fn(async () => undefined);
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
markRemoteLocked: vi.fn(async () => undefined),
|
||||
markRemoteResolved,
|
||||
client: {},
|
||||
};
|
||||
|
||||
await expect(
|
||||
OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY.run(
|
||||
replicator as never,
|
||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO },
|
||||
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED }
|
||||
)
|
||||
).rejects.toThrow("The configured Object Storage administration adapter does not provide milestone access.");
|
||||
expect(markRemoteResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
type LegacyLocalDatabaseSelection = {
|
||||
useIndexedDBAdapter: boolean;
|
||||
};
|
||||
|
||||
type LegacyBulkChunkPreSendSettings = {
|
||||
sendChunksBulk: boolean;
|
||||
sendChunksBulkMaxSize: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns whether persisted settings select the legacy PouchDB IndexedDB adapter.
|
||||
*
|
||||
* New local databases use IDB. Existing devices must retain this operative value until their local database has
|
||||
* been explicitly migrated, so compatibility code must not treat the setting as inert.
|
||||
*/
|
||||
export function usesLegacyIndexedDBAdapter(settings: LegacyLocalDatabaseSelection): boolean {
|
||||
return settings.useIndexedDBAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables the removed automatic bulk chunk pre-send option in persisted settings.
|
||||
*
|
||||
* The field remains readable only so older settings and Setup URIs can be migrated to the supported behaviour.
|
||||
*
|
||||
* @returns `true` when the legacy setting was enabled and has been changed.
|
||||
*/
|
||||
export function disableLegacyBulkChunkPreSend(settings: LegacyBulkChunkPreSendSettings): boolean {
|
||||
if (!settings.sendChunksBulk) return false;
|
||||
settings.sendChunksBulk = false;
|
||||
settings.sendChunksBulkMaxSize = 1;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { disableLegacyBulkChunkPreSend, usesLegacyIndexedDBAdapter } from "./compatibilitySettings.ts";
|
||||
|
||||
describe("compatibility settings", () => {
|
||||
it.each([true, false])("preserves the operative legacy adapter selection (%s)", (useIndexedDBAdapter) => {
|
||||
expect(usesLegacyIndexedDBAdapter({ useIndexedDBAdapter })).toBe(useIndexedDBAdapter);
|
||||
});
|
||||
|
||||
it("disables automatic bulk chunk pre-send and restores its inert size value", () => {
|
||||
const settings = { sendChunksBulk: true, sendChunksBulkMaxSize: 16 };
|
||||
|
||||
expect(disableLegacyBulkChunkPreSend(settings)).toBe(true);
|
||||
expect(settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 1 });
|
||||
});
|
||||
|
||||
it("leaves an already migrated bulk chunk setting unchanged", () => {
|
||||
const settings = { sendChunksBulk: false, sendChunksBulkMaxSize: 4 };
|
||||
|
||||
expect(disableLegacyBulkChunkPreSend(settings)).toBe(false);
|
||||
expect(settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 4 });
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ export const EVENT_FILE_SAVED = "file-saved";
|
||||
export const EVENT_LEAF_ACTIVE_CHANGED = "leaf-active-changed";
|
||||
|
||||
export const EVENT_REQUEST_OPEN_SETTINGS = "request-open-settings";
|
||||
export const EVENT_REQUEST_OPEN_SETTING_WIZARD = "request-open-setting-wizard";
|
||||
export const EVENT_REQUEST_OPEN_SETUP_URI = "request-open-setup-uri";
|
||||
export const EVENT_REQUEST_COPY_SETUP_URI = "request-copy-setup-uri";
|
||||
export const EVENT_REQUEST_SHOW_SETUP_QR = "request-show-setup-qr";
|
||||
@@ -30,7 +29,6 @@ declare global {
|
||||
[EVENT_PLUGIN_UNLOADED]: undefined;
|
||||
[EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG]: undefined;
|
||||
[EVENT_REQUEST_OPEN_SETTINGS]: undefined;
|
||||
[EVENT_REQUEST_OPEN_SETTING_WIZARD]: undefined;
|
||||
[EVENT_REQUEST_RELOAD_SETTING_TAB]: undefined;
|
||||
[EVENT_LEAF_ACTIVE_CHANGED]: undefined;
|
||||
[EVENT_REQUEST_OPEN_SETUP_URI]: undefined;
|
||||
|
||||
@@ -32,6 +32,20 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"Learn more about signalling and TURN": "Learn more about signalling and TURN",
|
||||
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust.":
|
||||
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust.",
|
||||
"Connection compatibility": "Connection compatibility",
|
||||
"P2P message size": "P2P message size",
|
||||
Standard: "Standard",
|
||||
Reduced: "Reduced",
|
||||
Conservative: "Conservative",
|
||||
"Maximum compatibility": "Maximum compatibility",
|
||||
"Smaller messages can improve compatibility on paths which fragment or drop larger WebRTC messages. This setting limits outgoing P2P messages, so use a compatible profile on each sending device when required.":
|
||||
"Smaller messages can improve compatibility on paths which fragment or drop larger WebRTC messages. This setting limits outgoing P2P messages, so use a compatible profile on each sending device when required.",
|
||||
"Connection path": "Connection path",
|
||||
"TURN relay only": "TURN relay only",
|
||||
"TURN relay only is available when at least one valid TURN server URL is configured under Advanced Settings.":
|
||||
"TURN relay only is available when at least one valid TURN server URL is configured under Advanced Settings.",
|
||||
"TURN relay only requires at least one valid TURN server URL. Connection path has been restored to Automatic.":
|
||||
"TURN relay only requires at least one valid TURN server URL. Connection path has been restored to Automatic.",
|
||||
"Announce changes": "Announce changes",
|
||||
"Announce changes automatically after connecting": "Announce changes automatically after connecting",
|
||||
"When enabled, this device notifies connected peers after a local change. The notification contains no Vault data; a peer which follows this device then fetches the change through the encrypted P2P connection.":
|
||||
@@ -150,9 +164,38 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.",
|
||||
"Resolve all conflicts by the newest version": "Resolve all conflicts by the newest version",
|
||||
"Inspect conflicts and file/database differences": "Inspect conflicts and file/database differences",
|
||||
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.":
|
||||
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.",
|
||||
"Scan Vault files and local-database Metadata for conflicts, missing chunks, identity mismatches, and differences. Each result provides actions for one exact entry or revision.":
|
||||
"Scan Vault files and local-database Metadata for conflicts, missing chunks, identity mismatches, and differences. Each result provides actions for one exact entry or revision.",
|
||||
"Begin inspection": "Begin inspection",
|
||||
"Metadata entry requires review and was left unchanged": "Metadata entry requires review and was left unchanged",
|
||||
"The stored document ID does not match the ID derived from its recorded path.":
|
||||
"The stored document ID does not match the ID derived from its recorded path.",
|
||||
"The stored document ID and recorded path are handled by different synchronisation features.":
|
||||
"The stored document ID and recorded path are handled by different synchronisation features.",
|
||||
"Stored document ID: ${ID}": "Stored document ID: ${ID}",
|
||||
"Expected document ID: ${ID}": "Expected document ID: ${ID}",
|
||||
"Source revision: ${REVISION}": "Source revision: ${REVISION}",
|
||||
"One-step repair is unavailable because this entry is ambiguous, no longer current, or unsafe to change.":
|
||||
"One-step repair is unavailable because this entry is ambiguous, no longer current, or unsafe to change.",
|
||||
"An exact target is already present; repair can remove the obsolete ID.":
|
||||
"An exact target is already present; repair can remove the obsolete ID.",
|
||||
"Repair is available for this entry.": "Repair is available for this entry.",
|
||||
"Repair this Metadata document ID": "Repair this Metadata document ID",
|
||||
"Repair Metadata ID": "Repair Metadata ID",
|
||||
"Keep unchanged": "Keep unchanged",
|
||||
"Repair Metadata document ID": "Repair Metadata document ID",
|
||||
"This moves one local Metadata entry to the ID derived from its recorded path.\n\n**File:** `${FILE}` \n**Source:** `${SOURCE}@${REVISION}` \n**Target:** `${TARGET}`\n\nThe target is verified before the source is removed. Its CouchDB revision ancestry cannot be preserved.\n\n> [!warning] Before repairing\n> - Back up this device.\n> - If file-name case or path obfuscation was intentionally changed for the whole database, use Rebuild instead.\n> - If other devices share this database, pause them, allow this device to upload the repair, then resume them one at a time.":
|
||||
"This moves one local Metadata entry to the ID derived from its recorded path.\n\n**File:** `${FILE}` \n**Source:** `${SOURCE}@${REVISION}` \n**Target:** `${TARGET}`\n\nThe target is verified before the source is removed. Its CouchDB revision ancestry cannot be preserved.\n\n> [!warning] Before repairing\n> - Back up this device.\n> - If file-name case or path obfuscation was intentionally changed for the whole database, use Rebuild instead.\n> - If other devices share this database, pause them, allow this device to upload the repair, then resume them one at a time.",
|
||||
"Metadata document ID repair and the ordinary Vault scan completed. Run this inspection again after synchronisation.":
|
||||
"Metadata document ID repair and the ordinary Vault scan completed. Run this inspection again after synchronisation.",
|
||||
"Metadata document ID repair completed, but the ordinary Vault scan did not run. Keep synchronisation paused, resolve the scan condition, then run 'Scan storage and database again'.":
|
||||
"Metadata document ID repair completed, but the ordinary Vault scan did not run. Keep synchronisation paused, resolve the scan condition, then run 'Scan storage and database again'.",
|
||||
"The inspected state changed. No repair was performed; run inspection again.":
|
||||
"The inspected state changed. No repair was performed; run inspection again.",
|
||||
"Repair stopped after creating the target. The source was retained. Run inspection again before retrying.":
|
||||
"Repair stopped after creating the target. The source was retained. Run inspection again before retrying.",
|
||||
"Repair failed before the source was removed. Run inspection again before retrying.":
|
||||
"Repair failed before the source was removed. Run inspection again before retrying.",
|
||||
"Connection settings": "Connection settings",
|
||||
"Saved connections": "Saved connections",
|
||||
} as const;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -112,8 +112,6 @@
|
||||
"Obsidian version": "Obsidian-Version",
|
||||
"obsidianLiveSyncSettingTab.btnApply": "Anwenden",
|
||||
"obsidianLiveSyncSettingTab.btnDisable": "Deaktivieren",
|
||||
"obsidianLiveSyncSettingTab.btnNext": "Weiter",
|
||||
"obsidianLiveSyncSettingTab.buttonNext": "Weiter",
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": "Standardsprache",
|
||||
"obsidianLiveSyncSettingTab.labelDisabled": "⏹️ : Deaktiviert",
|
||||
"obsidianLiveSyncSettingTab.labelEnabled": "🔁 : Aktiviert",
|
||||
@@ -124,9 +122,7 @@
|
||||
"obsidianLiveSyncSettingTab.msgConfigCheckFailed": "Die Konfigurationsprüfung ist fehlgeschlagen. Trotzdem fortfahren?",
|
||||
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "Wir empfehlen, Ende-zu-Ende-Verschlüsselung und Pfadverschleierung zu aktivieren. Möchten Sie wirklich ohne Verschlüsselung fortfahren?",
|
||||
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "Möchten Sie die Konfiguration vom Remote-Server abrufen?",
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "Alles fertig! Möchten Sie eine Setup-URI erzeugen, um andere Geräte einzurichten?",
|
||||
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "Ihre Verschlüsselungs-Passphrase könnte ungültig sein. Möchten Sie wirklich fortfahren?",
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": "Bitte wählen und übernehmen Sie eine beliebige Voreinstellung, um den Assistenten abzuschließen.",
|
||||
"obsidianLiveSyncSettingTab.nameDisableHiddenFileSync": "Synchronisation versteckter Dateien deaktivieren",
|
||||
"obsidianLiveSyncSettingTab.nameEnableHiddenFileSync": "Synchronisation versteckter Dateien aktivieren",
|
||||
"obsidianLiveSyncSettingTab.nameHiddenFileSynchronization": "Synchronisation versteckter Dateien",
|
||||
@@ -137,7 +133,6 @@
|
||||
"obsidianLiveSyncSettingTab.optionPeriodicWithBatch": "Periodisch mit Stapelverarbeitung",
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": "Darstellung",
|
||||
"obsidianLiveSyncSettingTab.titleConflictResolution": "Konfliktbehandlung",
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": "Glückwunsch!",
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": "CouchDB-Server",
|
||||
"obsidianLiveSyncSettingTab.titleDeletionPropagation": "Weitergabe von Löschungen",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": "Verschlüsselung ist nicht aktiviert",
|
||||
|
||||
@@ -478,7 +478,6 @@
|
||||
"moduleLiveSyncMain.titleScramEnabled": "Scram Enabled",
|
||||
"moduleLocalDatabase.logWaitingForReady": "Waiting for ready...",
|
||||
"moduleLog.showLog": "Show Log",
|
||||
"moduleMigration.docUri": "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use",
|
||||
"moduleMigration.fix0256.buttons.checkItLater": "Check it later",
|
||||
"moduleMigration.fix0256.buttons.DismissForever": "I have fixed it, and do not ask again",
|
||||
"moduleMigration.fix0256.buttons.fix": "Fix",
|
||||
@@ -500,26 +499,16 @@
|
||||
"moduleMigration.logRemoteTweakUnavailable": "Could not get remote tweak values",
|
||||
"moduleMigration.logSetupCancelled": "The setup has been cancelled, Self-hosted LiveSync waiting for your setup!",
|
||||
"moduleMigration.msgFetchRemoteAgain": "As you may already know, the self-hosted LiveSync has changed its default behaviour and database structure.\n\nAnd thankfully, with your time and efforts, the remote database appears to have already been migrated. Congratulations!\n\nHowever, we need a bit more. The configuration of this device is not compatible with the remote database. We will need to fetch the remote database again. Should we fetch from the remote again now?\n\n___Note: We cannot synchronise until the configuration has been changed and the database has been fetched again.___\n___Note2: The chunks are completely immutable, we can fetch only the metadata and difference.___",
|
||||
"moduleMigration.msgInitialSetup": "Your device has **not been set up yet**. Let me guide you through the setup process.\n\nPlease keep in mind that every dialogue content can be copied to the clipboard. If you need to refer to it later, you can paste it into a note in Obsidian. You can also translate it into your language using a translation tool.\n\nFirst, do you have **Setup URI**?\n\nNote: If you do not know what it is, please refer to the [documentation](${URI_DOC}).",
|
||||
"moduleMigration.msgRecommendSetupUri": "We strongly recommend that you generate a set-up URI and use it.\nIf you do not have knowledge about it, please refer to the [documentation](${URI_DOC}) (Sorry again, but it is important).\n\nHow do you want to set it up manually?",
|
||||
"moduleMigration.msgSinceV02321": "Since v0.23.21, the self-hosted LiveSync has changed the default behaviour and database structure. The following changes have been made:\n\n1. **Case sensitivity of filenames**\n The handling of filenames is now case-insensitive. This is a beneficial change for most platforms, other than Linux and iOS, which do not manage filename case sensitivity effectively.\n (On These, a warning will be displayed for files with the same name but different cases).\n\n2. **Revision handling of the chunks**\n Chunks are immutable, which allows their revisions to be fixed. This change will enhance the performance of file saving.\n\n___However, to enable either of these changes, both remote and local databases need to be rebuilt. This process takes a few minutes, and we recommend doing it when you have ample time.___\n\n- If you wish to maintain the previous behaviour, you can skip this process by using `${KEEP}`.\n- If you do not have enough time, please choose `${DISMISS}`. You will be prompted again later.\n- If you have rebuilt the database on another device, please select `${DISMISS}` and try synchronizing again. Since a difference has been detected, you will be prompted again.",
|
||||
"moduleMigration.optionAdjustRemote": "Adjust to remote",
|
||||
"moduleMigration.optionDecideLater": "Decide it later",
|
||||
"moduleMigration.optionEnableBoth": "Enable both",
|
||||
"moduleMigration.optionEnableFilenameCaseInsensitive": "Enable only #1",
|
||||
"moduleMigration.optionEnableFixedRevisionForChunks": "Enable only #2",
|
||||
"moduleMigration.optionHaveSetupUri": "Yes, I have",
|
||||
"moduleMigration.optionKeepPreviousBehaviour": "Keep previous behaviour",
|
||||
"moduleMigration.optionManualSetup": "Set it up all manually",
|
||||
"moduleMigration.optionNoAskAgain": "No, please ask again",
|
||||
"moduleMigration.optionNoSetupUri": "No, I do not have",
|
||||
"moduleMigration.optionRemindNextLaunch": "Remind me at the next launch",
|
||||
"moduleMigration.optionSetupViaP2P": "Use %{short_p2p_sync} to set up",
|
||||
"moduleMigration.optionSetupWizard": "Take me into the setup wizard",
|
||||
"moduleMigration.optionYesFetchAgain": "Yes, fetch again",
|
||||
"moduleMigration.titleCaseSensitivity": "Case Sensitivity",
|
||||
"moduleMigration.titleRecommendSetupUri": "Recommendation to use Setup URI",
|
||||
"moduleMigration.titleWelcome": "Welcome to Self-hosted LiveSync",
|
||||
"moduleObsidianMenu.replicate": "Replicate",
|
||||
"More actions": "More actions",
|
||||
"Mostly Complete: Decision Required": "Mostly Complete: Decision Required",
|
||||
@@ -564,12 +553,10 @@
|
||||
"obsidianLiveSyncSettingTab.btnEnable": "Enable",
|
||||
"obsidianLiveSyncSettingTab.btnFix": "Fix",
|
||||
"obsidianLiveSyncSettingTab.btnGotItAndUpdated": "I got it and updated.",
|
||||
"obsidianLiveSyncSettingTab.btnNext": "Next",
|
||||
"obsidianLiveSyncSettingTab.btnStart": "Start",
|
||||
"obsidianLiveSyncSettingTab.btnTest": "Test",
|
||||
"obsidianLiveSyncSettingTab.btnUse": "Use",
|
||||
"obsidianLiveSyncSettingTab.buttonFetch": "Fetch",
|
||||
"obsidianLiveSyncSettingTab.buttonNext": "Next",
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": "Default",
|
||||
"obsidianLiveSyncSettingTab.descConnectSetupURI": "This is the recommended method to set up Self-hosted LiveSync with a Setup URI.",
|
||||
"obsidianLiveSyncSettingTab.descCopySetupURI": "Perfect for setting up a new device!",
|
||||
@@ -633,7 +620,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgEnableCorsChttpd": "Set chttpd.enable_cors",
|
||||
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "We recommend enabling End-To-End Encryption, and Path Obfuscation. Are you sure you want to continue without encryption?",
|
||||
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "Do you want to fetch the config from the remote server?",
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "All done! Do you want to generate a setup URI to set up other devices?",
|
||||
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": "If the server configuration is not persistent (e.g., running on docker), the values here may change. Once you are able to connect, please update the settings in the server's local.ini.",
|
||||
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "Your encryption passphrase might be invalid. Are you sure you want to continue?",
|
||||
"obsidianLiveSyncSettingTab.msgNewVersionNote": "Here due to an upgrade notification? Please review the version history. If you're satisfied, click the button. A new update will prompt this again.",
|
||||
@@ -643,7 +629,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgObjectStorageWarning": "WARNING: This feature is a Work In Progress, so please keep in mind the following:\n- Append only architecture. A rebuild is required to shrink the storage.\n- A bit fragile.\n- When first syncing, all history will be transferred from the remote. Be mindful of data caps and slow speeds.\n- Only differences are synced live.\n\nIf you run into any issues, or have ideas about this feature, please create a issue on GitHub.\nI appreciate you for your great dedication.",
|
||||
"obsidianLiveSyncSettingTab.msgOriginCheck": "Origin check: ${org}",
|
||||
"obsidianLiveSyncSettingTab.msgRebuildRequired": "Rebuilding Databases are required to apply the changes.. Please select the method to apply the changes.\n\n<details>\n<summary>Legends</summary>\n\n| Symbol | Meaning |\n|: ------ :| ------- |\n| ⇔ | Up to Date |\n| ⇄ | Synchronise to balance |\n| ⇐,⇒ | Transfer to overwrite |\n| ⇠,⇢ | Transfer to overwrite from other side |\n\n</details>\n\n## ${OPTION_REBUILD_BOTH}\nAt a glance: 📄 ⇒¹ 💻 ⇒² 🛰️ ⇢ⁿ 💻 ⇄ⁿ⁺¹ 📄\nReconstruct both the local and remote databases using existing files from this device.\nThis causes a lockout other devices, and they need to perform fetching.\n## ${OPTION_FETCH}\nAt a glance: 📄 ⇄² 💻 ⇐¹ 🛰️ ⇔ 💻 ⇔ 📄\nInitialise the local database and reconstruct it using data fetched from the remote database.\nThis case includes the case which you have rebuilt the remote database.\n## ${OPTION_ONLY_SETTING}\nStore only the settings. **Caution: This may lead to data corruption**; database reconstruction is generally necessary.",
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": "Please select and apply any preset item to complete the wizard.",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsCredentials": "Set cors.credentials",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsOrigins": "Set cors.origins",
|
||||
"obsidianLiveSyncSettingTab.msgSetMaxDocSize": "Set couchdb.max_document_size",
|
||||
@@ -698,19 +683,24 @@
|
||||
"obsidianLiveSyncSettingTab.panelSetup": "Setup",
|
||||
"obsidianLiveSyncSettingTab.serverVersion": "Server info: ${info}",
|
||||
"obsidianLiveSyncSettingTab.titleActiveRemoteServer": "Active Remote Server",
|
||||
"obsidianLiveSyncSettingTab.titleAdvancedSettings": "Advanced settings",
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": "Appearance",
|
||||
"obsidianLiveSyncSettingTab.titleConflictResolution": "Conflict resolution",
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": "Congratulations!",
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": "CouchDB",
|
||||
"obsidianLiveSyncSettingTab.titleDeletionPropagation": "Deletion Propagation",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": "Encryption is not enabled",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionPassphraseInvalid": "Encryption Passphrase Invalid",
|
||||
"obsidianLiveSyncSettingTab.titleExtraFeatures": "Enable extra and advanced features",
|
||||
"obsidianLiveSyncSettingTab.titleExtraFeaturesGroup": "Extra features",
|
||||
"obsidianLiveSyncSettingTab.titleExtraMenus": "Extra menus",
|
||||
"obsidianLiveSyncSettingTab.titleFetchConfig": "Fetch Config",
|
||||
"obsidianLiveSyncSettingTab.titleFetchConfigFromRemote": "Fetch config from remote server",
|
||||
"obsidianLiveSyncSettingTab.titleFetchSettings": "Fetch Settings",
|
||||
"obsidianLiveSyncSettingTab.titleHelpAndInformation": "Help and information",
|
||||
"obsidianLiveSyncSettingTab.titleHelpAndTroubleshooting": "Help and troubleshooting",
|
||||
"obsidianLiveSyncSettingTab.titleHiddenFiles": "Hidden Files",
|
||||
"obsidianLiveSyncSettingTab.titleLogging": "Logging",
|
||||
"obsidianLiveSyncSettingTab.titleMaintenanceAndRecovery": "Maintenance and recovery",
|
||||
"obsidianLiveSyncSettingTab.titleMinioS3R2": "Minio,S3,R2",
|
||||
"obsidianLiveSyncSettingTab.titleNotification": "Notification",
|
||||
"obsidianLiveSyncSettingTab.titleOnlineTips": "Online Tips",
|
||||
@@ -719,7 +709,8 @@
|
||||
"obsidianLiveSyncSettingTab.titleRemoteConfigCheckFailed": "Remote Configuration Check Failed",
|
||||
"obsidianLiveSyncSettingTab.titleRemoteServer": "Remote Server",
|
||||
"obsidianLiveSyncSettingTab.titleReset": "Reset",
|
||||
"obsidianLiveSyncSettingTab.titleSetupOtherDevices": "To setup other devices",
|
||||
"obsidianLiveSyncSettingTab.titleSetupOtherDevices": "Set up other devices",
|
||||
"obsidianLiveSyncSettingTab.titleSynchronisation": "Synchronisation",
|
||||
"obsidianLiveSyncSettingTab.titleSynchronizationMethod": "Synchronization Method",
|
||||
"obsidianLiveSyncSettingTab.titleSynchronizationPreset": "Synchronization Preset",
|
||||
"obsidianLiveSyncSettingTab.titleSyncSettings": "Sync Settings",
|
||||
@@ -878,7 +869,7 @@
|
||||
"Replicator.Message.InitialiseFatalError": "No replicator is available, this is the fatal error.",
|
||||
"Replicator.Message.Pending": "Some file events are pending. Replication has been cancelled.",
|
||||
"Replicator.Message.SomeModuleFailed": "Replication has been cancelled by some module failure",
|
||||
"Replicator.Message.VersionUpFlash": "An update has been detected. Please open the Settings dialogue and check the Change Log. Replication has been cancelled.",
|
||||
"Replicator.Message.VersionUpFlash": "Remote synchronisation is paused for compatibility review. Run the 'Review why synchronisation is paused' command for details and available actions.",
|
||||
"Requires restart of Obsidian": "Requires restart of Obsidian",
|
||||
"Requires restart of Obsidian.": "Requires restart of Obsidian.",
|
||||
"Rerun Onboarding Wizard": "Rerun Onboarding Wizard",
|
||||
@@ -1071,6 +1062,7 @@
|
||||
"Target patterns": "Target patterns",
|
||||
"Test Settings and Continue": "Test Settings and Continue",
|
||||
"Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.": "Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.",
|
||||
"The connection test cannot add a signalling relay while P2P is active. Use the active relay settings, or disconnect P2P before testing.": "The connection test cannot add a signalling relay while P2P is active. Use the active relay settings, or disconnect P2P before testing.",
|
||||
"The connection to the server has been configured successfully. As the next step,": "The connection to the server has been configured successfully. As the next step,",
|
||||
"The delay for consecutive on-demand fetches": "The delay for consecutive on-demand fetches",
|
||||
"The files in this Vault are almost identical to the server's.": "The files in this Vault are almost identical to the server's.",
|
||||
@@ -1330,6 +1322,28 @@
|
||||
"Ui.Settings.SyncSettings.Fetch": "Fetch",
|
||||
"Ui.Settings.SyncSettings.Merge": "Merge",
|
||||
"Ui.Settings.SyncSettings.Overwrite": "Overwrite",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ApplyWithoutInitialisation": "Apply without Initialisation",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Back": "Review another way to apply these settings",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.BypassGuidance": "Applying these settings alone can make this device incompatible with its existing synchronisation data. Use this only when you have confirmed that reconstruction is unnecessary.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.BypassTitle": "Apply Settings without Initialisation?",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ContinueFetch": "Continue with Fetch",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOption": "Reset Synchronisation on This Device",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOptionDesc": "After restarting, rebuild this device's local database from the current remote synchronisation data. Files in the Vault will then be reconciled with that data.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOptionP2PDesc": "After restarting, select an online source device. This device's local LiveSync database will be rebuilt from that source.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Guidance": "These setting changes alter how synchronisation data is interpreted. Apply them together with an initialisation operation after restart.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.KeepEditing": "Keep Editing",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedFetch": "Restart and Fetch Synchronisation Data",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedFetchP2P": "Restart and Select a Source Device",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedRebuild": "Restart and Overwrite Server Data",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedRebuildP2P": "Restart and Prepare This Device",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Question": "Which existing data should be used after restart?",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOption": "Overwrite Server Data with This Device's Files",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionDesc": "Rebuild the local and remote databases from the files currently in this Vault. Other synchronising devices must reset their local synchronisation afterwards.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionP2P": "Prepare This Device from This Vault",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionP2PDesc": "Rebuild this device's local LiveSync database from the files currently in this Vault. This does not overwrite another device.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RemoteVerificationGuidance": "The configured remote could not be verified with the current credentials and encryption settings. Continuing may make the Fetch fail after restart.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RemoteVerificationTitle": "Remote Synchronisation Data Could Not Be Verified",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Title": "Apply Settings and Reinitialise Synchronisation",
|
||||
"Ui.SetupWizard.Common.Back": "No, please take me back",
|
||||
"Ui.SetupWizard.Common.Cancel": "Cancel",
|
||||
"Ui.SetupWizard.Common.ProceedSelectOption": "Please select an option to proceed",
|
||||
|
||||
@@ -479,7 +479,6 @@
|
||||
"moduleLiveSyncMain.titleScramEnabled": "Scram habilitado",
|
||||
"moduleLocalDatabase.logWaitingForReady": "Esperando a que la base de datos esté lista...",
|
||||
"moduleLog.showLog": "Mostrar registro",
|
||||
"moduleMigration.docUri": "https://github.com/vrtmrz/obsidian-livesync/blob/main/README_ES.md#how-to-use",
|
||||
"moduleMigration.fix0256.buttons.checkItLater": "Comprobarlo más tarde",
|
||||
"moduleMigration.fix0256.buttons.DismissForever": "Ya lo he corregido, no volver a preguntar",
|
||||
"moduleMigration.fix0256.buttons.fix": "Corregir",
|
||||
@@ -501,26 +500,16 @@
|
||||
"moduleMigration.logRemoteTweakUnavailable": "No se pudieron obtener los valores de ajuste remoto",
|
||||
"moduleMigration.logSetupCancelled": "La configuración ha sido cancelada, ¡Self-hosted LiveSync está esperando tu configuración!",
|
||||
"moduleMigration.msgFetchRemoteAgain": "Como ya sabrás, Self-hosted LiveSync ha cambiado su comportamiento predeterminado y la estructura de la base de datos.\n\nAfortunadamente, con tu tiempo y esfuerzo, la base de datos remota parece haber sido ya migrada. ¡Felicidades!\n\nSin embargo, necesitamos un poco más. La configuración de este dispositivo no es compatible con la base de datos remota. Necesitaremos volver a obtener la base de datos remota. ¿Debemos obtenerla nuevamente ahora?\n\n___Nota: No podemos sincronizar hasta que la configuración haya sido cambiada y la base de datos haya sido obtenida nuevamente.___\n___Nota2: Los fragmentos son completamente inmutables, solo podemos obtener los metadatos y diferencias.___",
|
||||
"moduleMigration.msgInitialSetup": "Tu dispositivo **aún no ha sido configurado**. Permíteme guiarte a través del proceso de configuración.\n\nTen en cuenta que todo el contenido del diálogo se puede copiar al portapapeles. Si necesitas consultarlo más tarde, puedes pegarlo en una nota en Obsidian. También puedes traducirlo a tu idioma utilizando una herramienta de traducción.\n\nPrimero, ¿tienes **URI de configuración**?\n\nNota: Si no sabes qué es, consulta la [documentación](${URI_DOC}).",
|
||||
"moduleMigration.msgRecommendSetupUri": "Te recomendamos encarecidamente que generes una URI de configuración y la utilices.\nSi no tienes conocimientos al respecto, consulta la [documentación](${URI_DOC}) (Lo siento de nuevo, pero es importante).\n\n¿Cómo quieres configurarlo manualmente?",
|
||||
"moduleMigration.msgSinceV02321": "Desde la versión v0.23.21, Self-hosted LiveSync ha cambiado el comportamiento predeterminado y la estructura de la base de datos. Se han realizado los siguientes cambios:\n\n1. **Sensibilidad a mayúsculas de los nombres de archivo**\n El manejo de los nombres de archivo ahora no distingue entre mayúsculas y minúsculas. Este cambio es beneficioso para la mayoría de las plataformas, excepto Linux y iOS, que no gestionan efectivamente la sensibilidad a mayúsculas de los nombres de archivo.\n (En estos, se mostrará una advertencia para archivos con el mismo nombre pero diferentes mayúsculas).\n\n2. **Manejo de revisiones de los fragmentos**\n Los fragmentos son inmutables, lo que permite que sus revisiones sean fijas. Este cambio mejorará el rendimiento al guardar archivos.\n\n___Sin embargo, para habilitar cualquiera de estos cambios, es necesario reconstruir tanto las bases de datos remota como la local. Este proceso toma unos minutos, y recomendamos hacerlo cuando tengas tiempo suficiente.___\n\n- Si deseas mantener el comportamiento anterior, puedes omitir este proceso usando `${KEEP}`.\n- Si no tienes suficiente tiempo, por favor elige `${DISMISS}`. Se te pedirá nuevamente más tarde.\n- Si has reconstruido la base de datos en otro dispositivo, selecciona `${DISMISS}` e intenta sincronizar nuevamente. Dado que se ha detectado una diferencia, se te solicitará nuevamente.",
|
||||
"moduleMigration.optionAdjustRemote": "Ajustar al remoto",
|
||||
"moduleMigration.optionDecideLater": "Decidirlo más tarde",
|
||||
"moduleMigration.optionEnableBoth": "Habilitar ambos",
|
||||
"moduleMigration.optionEnableFilenameCaseInsensitive": "Habilitar solo #1",
|
||||
"moduleMigration.optionEnableFixedRevisionForChunks": "Habilitar solo #2",
|
||||
"moduleMigration.optionHaveSetupUri": "Sí, tengo",
|
||||
"moduleMigration.optionKeepPreviousBehaviour": "Mantener comportamiento anterior",
|
||||
"moduleMigration.optionManualSetup": "Configurarlo todo manualmente",
|
||||
"moduleMigration.optionNoAskAgain": "No, por favor pregúntame de nuevo",
|
||||
"moduleMigration.optionNoSetupUri": "No, no tengo",
|
||||
"moduleMigration.optionRemindNextLaunch": "Recordármelo en el próximo inicio",
|
||||
"moduleMigration.optionSetupViaP2P": "Usar %{short_p2p_sync} para configurarlo",
|
||||
"moduleMigration.optionSetupWizard": "Llévame al asistente de configuración",
|
||||
"moduleMigration.optionYesFetchAgain": "Sí, obtener de nuevo",
|
||||
"moduleMigration.titleCaseSensitivity": "Distinción de mayúsculas y minúsculas",
|
||||
"moduleMigration.titleRecommendSetupUri": "Recomendación de usar un Setup URI",
|
||||
"moduleMigration.titleWelcome": "Bienvenido a Self-hosted LiveSync",
|
||||
"moduleObsidianMenu.replicate": "Replicar",
|
||||
"More actions": "Más acciones",
|
||||
"Mostly Complete: Decision Required": "Casi terminado: se requiere una decisión",
|
||||
@@ -565,12 +554,10 @@
|
||||
"obsidianLiveSyncSettingTab.btnEnable": "Activar",
|
||||
"obsidianLiveSyncSettingTab.btnFix": "Corregir",
|
||||
"obsidianLiveSyncSettingTab.btnGotItAndUpdated": "Lo entendí y actualicé.",
|
||||
"obsidianLiveSyncSettingTab.btnNext": "Siguiente",
|
||||
"obsidianLiveSyncSettingTab.btnStart": "Iniciar",
|
||||
"obsidianLiveSyncSettingTab.btnTest": "Probar",
|
||||
"obsidianLiveSyncSettingTab.btnUse": "Usar",
|
||||
"obsidianLiveSyncSettingTab.buttonFetch": "Obtener",
|
||||
"obsidianLiveSyncSettingTab.buttonNext": "Siguiente",
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": "Predeterminado",
|
||||
"obsidianLiveSyncSettingTab.descConnectSetupURI": "Este es el método recomendado para configurar Self-hosted LiveSync con una URI de configuración.",
|
||||
"obsidianLiveSyncSettingTab.descCopySetupURI": "¡Perfecto para configurar un nuevo dispositivo!",
|
||||
@@ -633,7 +620,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgEnableCorsChttpd": "Establecer chttpd.enable_cors",
|
||||
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "Recomendamos habilitar el cifrado de extremo a extremo y la obfuscación de ruta. ¿Estás seguro de querer continuar sin cifrado?",
|
||||
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "¿Quieres obtener la configuración del servidor remoto?",
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "¡Todo listo! ¿Quieres generar un URI de configuración para configurar otros dispositivos?",
|
||||
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": "Si la configuración del servidor no es persistente (por ejemplo, ejecutándose en docker), los valores aquí pueden cambiar. Una vez que puedas conectarte, por favor actualiza las configuraciones en el local.ini del servidor.",
|
||||
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "Tu frase de contraseña de cifrado podría ser inválida. ¿Estás seguro de querer continuar?",
|
||||
"obsidianLiveSyncSettingTab.msgNewVersionNote": "¿Aquí debido a una notificación de actualización? Por favor, revise el historial de versiones. Si está satisfecho, haga clic en el botón. Una nueva actualización volverá a mostrar esto.",
|
||||
@@ -643,7 +629,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgObjectStorageWarning": "ADVERTENCIA: Esta característica está en desarrollo, así que por favor ten en cuenta lo siguiente:\n- Arquitectura de solo anexado. Se requiere una reconstrucción para reducir el almacenamiento.\n- Un poco frágil.\n- Al sincronizar por primera vez, todo el historial será transferido desde el remoto. Ten en cuenta los límites de datos y las velocidades lentas.\n- Solo las diferencias se sincronizan en vivo.\n\nSi encuentras algún problema o tienes ideas sobre esta característica, por favor crea un issue en GitHub.\nAprecio mucho tu gran dedicación.",
|
||||
"obsidianLiveSyncSettingTab.msgOriginCheck": "Verificación de origen: {org}",
|
||||
"obsidianLiveSyncSettingTab.msgRebuildRequired": "Es necesario reconstruir las bases de datos para aplicar los cambios. Por favor selecciona el método para aplicar los cambios.\n\n<details>\n<summary>Legendas</summary>\n\n| Símbolo | Significado |\n|: ------ :| ------- |\n| ⇔ | Actualizado |\n| ⇄ | Sincronizar para equilibrar |\n| ⇐,⇒ | Transferir para sobrescribir |\n| ⇠,⇢ | Transferir para sobrescribir desde otro lado |\n\n</details>\n\n## ${OPTION_REBUILD_BOTH}\nA simple vista: 📄 ⇒¹ 💻 ⇒² 🛰️ ⇢ⁿ 💻 ⇄ⁿ⁺¹ 📄\nReconstruir tanto la base de datos local como la remota utilizando los archivos existentes de este dispositivo.\nEsto bloquea a otros dispositivos, y necesitan realizar la obtención.\n## ${OPTION_FETCH}\nA simple vista: 📄 ⇄² 💻 ⇐¹ 🛰️ ⇔ 💻 ⇔ 📄\nInicializa la base de datos local y la reconstruye utilizando los datos obtenidos de la base de datos remota.\nEste caso incluye el caso en el que has reconstruido la base de datos remota.\n## ${OPTION_ONLY_SETTING}\nAlmacena solo la configuración. **Precaución: esto puede provocar corrupción de datos**; generalmente es necesario reconstruir la base de datos.",
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": "Por favor, selecciona y aplica cualquier elemento preestablecido para completar el asistente.",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsCredentials": "Configurar cors.credentials",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsOrigins": "Configurar cors.origins",
|
||||
"obsidianLiveSyncSettingTab.msgSetMaxDocSize": "Configurar couchdb.max_document_size",
|
||||
@@ -700,7 +685,6 @@
|
||||
"obsidianLiveSyncSettingTab.titleActiveRemoteServer": "Servidor remoto activo",
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": "Apariencia",
|
||||
"obsidianLiveSyncSettingTab.titleConflictResolution": "Resolución de conflictos",
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": "¡Felicidades!",
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": "Servidor CouchDB",
|
||||
"obsidianLiveSyncSettingTab.titleDeletionPropagation": "Propagación de eliminación",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": "El cifrado no está habilitado",
|
||||
|
||||
@@ -190,7 +190,6 @@
|
||||
"moduleLiveSyncMain.titleScramEnabled": "Mode Scram activé",
|
||||
"moduleLocalDatabase.logWaitingForReady": "En attente de disponibilité...",
|
||||
"moduleLog.showLog": "Afficher le journal",
|
||||
"moduleMigration.docUri": "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use",
|
||||
"moduleMigration.fix0256.buttons.checkItLater": "Vérifier plus tard",
|
||||
"moduleMigration.fix0256.buttons.DismissForever": "J'ai corrigé, et ne plus demander",
|
||||
"moduleMigration.fix0256.buttons.fix": "Corriger",
|
||||
@@ -212,26 +211,16 @@
|
||||
"moduleMigration.logRemoteTweakUnavailable": "Impossible d'obtenir les valeurs d'ajustement distantes",
|
||||
"moduleMigration.logSetupCancelled": "La configuration a été annulée, Self-hosted LiveSync attend votre configuration !",
|
||||
"moduleMigration.msgFetchRemoteAgain": "Comme vous le savez peut-être déjà, Self-hosted LiveSync a modifié son comportement par défaut et la structure de sa base de données.\n\nEt, grâce à votre temps et vos efforts, la base distante semble déjà avoir été migrée. Félicitations !\n\nCependant, il faut encore un peu plus. La configuration de cet appareil n'est pas compatible avec la base distante. Nous devrons récupérer à nouveau la base distante. Devons-nous récupérer depuis le distant maintenant ?\n\n___Note : Nous ne pouvons pas synchroniser tant que la configuration n'a pas été modifiée et que la base n'a pas été récupérée à nouveau.___\n___Note 2 : Les fragments sont complètement immuables, nous ne pouvons récupérer que les métadonnées et les différences.___",
|
||||
"moduleMigration.msgInitialSetup": "Votre appareil n'a **pas encore été configuré**. Laissez-moi vous guider dans le processus de configuration.\n\nVeuillez noter que chaque contenu de boîte de dialogue peut être copié dans le presse-papiers. Si vous souhaitez vous y référer plus tard, vous pouvez le coller dans une note d'Obsidian. Vous pouvez également le traduire dans votre langue via un outil de traduction.\n\nTout d'abord, disposez-vous d'une **URI de configuration** ?\n\nNote : Si vous ne savez pas ce que c'est, consultez la [documentation](${URI_DOC}).",
|
||||
"moduleMigration.msgRecommendSetupUri": "Nous recommandons vivement de générer une URI de configuration et de l'utiliser.\nSi vous ne connaissez pas, veuillez consulter la [documentation](${URI_DOC}) (Désolé encore, mais c'est important).\n\nComment souhaitez-vous effectuer la configuration manuellement ?",
|
||||
"moduleMigration.msgSinceV02321": "Depuis la v0.23.21, Self-hosted LiveSync a modifié son comportement par défaut et la structure de sa base. Les changements suivants ont été effectués :\n\n1. **Sensibilité à la casse des noms de fichiers**\n La gestion des noms de fichiers est désormais insensible à la casse. C'est un changement bénéfique pour la plupart des plateformes, hormis Linux et iOS, qui ne gèrent pas efficacement la casse des noms de fichiers.\n (Sur celles-ci, un avertissement s'affichera pour les fichiers portant le même nom avec une casse différente).\n\n2. **Gestion des révisions des fragments**\n Les fragments sont immuables, ce qui permet de fixer leurs révisions. Ce changement améliore les performances d'enregistrement des fichiers.\n\n___Cependant, pour activer l'un ou l'autre de ces changements, les bases locale et distante doivent être reconstruites. Ce processus prend quelques minutes, et nous recommandons de le faire quand vous avez le temps.___\n\n- Si vous souhaitez conserver le comportement précédent, vous pouvez ignorer ce processus via `${KEEP}`.\n- Si vous n'avez pas le temps, choisissez `${DISMISS}`. Vous serez invité à nouveau plus tard.\n- Si vous avez reconstruit la base sur un autre appareil, sélectionnez `${DISMISS}` et réessayez la synchronisation. Une différence étant détectée, vous serez invité à nouveau.",
|
||||
"moduleMigration.optionAdjustRemote": "Ajuster au distant",
|
||||
"moduleMigration.optionDecideLater": "Décider plus tard",
|
||||
"moduleMigration.optionEnableBoth": "Activer les deux",
|
||||
"moduleMigration.optionEnableFilenameCaseInsensitive": "Activer seulement #1",
|
||||
"moduleMigration.optionEnableFixedRevisionForChunks": "Activer seulement #2",
|
||||
"moduleMigration.optionHaveSetupUri": "Oui, j'en ai une",
|
||||
"moduleMigration.optionKeepPreviousBehaviour": "Conserver le comportement précédent",
|
||||
"moduleMigration.optionManualSetup": "Tout configurer manuellement",
|
||||
"moduleMigration.optionNoAskAgain": "Non, demandez à nouveau",
|
||||
"moduleMigration.optionNoSetupUri": "Non, je n'en ai pas",
|
||||
"moduleMigration.optionRemindNextLaunch": "Me rappeler au prochain lancement",
|
||||
"moduleMigration.optionSetupViaP2P": "Utiliser %{short_p2p_sync} pour configurer",
|
||||
"moduleMigration.optionSetupWizard": "Ouvrir l'assistant de configuration",
|
||||
"moduleMigration.optionYesFetchAgain": "Oui, récupérer à nouveau",
|
||||
"moduleMigration.titleCaseSensitivity": "Sensibilité à la casse",
|
||||
"moduleMigration.titleRecommendSetupUri": "Recommandation d'utilisation de l'URI de configuration",
|
||||
"moduleMigration.titleWelcome": "Bienvenue dans Self-hosted LiveSync",
|
||||
"moduleObsidianMenu.replicate": "Répliquer",
|
||||
"Move remotely deleted files to the trash, instead of deleting.": "Déplacer les fichiers supprimés à distance vers la corbeille, au lieu de les supprimer.",
|
||||
"Not all messages have been translated. And, please revert to \"Default\" when reporting errors.": "Tous les messages n'ont pas été traduits. Et veuillez revenir à « Par défaut » lorsque vous signalez des erreurs.",
|
||||
@@ -249,12 +238,10 @@
|
||||
"obsidianLiveSyncSettingTab.btnEnable": "Activer",
|
||||
"obsidianLiveSyncSettingTab.btnFix": "Corriger",
|
||||
"obsidianLiveSyncSettingTab.btnGotItAndUpdated": "J'ai compris et mis à jour.",
|
||||
"obsidianLiveSyncSettingTab.btnNext": "Suivant",
|
||||
"obsidianLiveSyncSettingTab.btnStart": "Démarrer",
|
||||
"obsidianLiveSyncSettingTab.btnTest": "Tester",
|
||||
"obsidianLiveSyncSettingTab.btnUse": "Utiliser",
|
||||
"obsidianLiveSyncSettingTab.buttonFetch": "Récupérer",
|
||||
"obsidianLiveSyncSettingTab.buttonNext": "Suivant",
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": "Par défaut",
|
||||
"obsidianLiveSyncSettingTab.descConnectSetupURI": "Méthode recommandée pour configurer Self-hosted LiveSync avec une URI de configuration.",
|
||||
"obsidianLiveSyncSettingTab.descCopySetupURI": "Parfait pour configurer un nouvel appareil !",
|
||||
@@ -317,7 +304,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgEnableCorsChttpd": "Définir chttpd.enable_cors",
|
||||
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "Nous recommandons d'activer le chiffrement de bout en bout et l'obfuscation des chemins. Êtes-vous sûr de vouloir continuer sans chiffrement ?",
|
||||
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "Voulez-vous récupérer la configuration depuis le serveur distant ?",
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "Tout est prêt ! Voulez-vous générer une URI de configuration pour configurer d'autres appareils ?",
|
||||
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": "Si la configuration du serveur n'est pas persistante (par ex. fonctionnant sur Docker), les valeurs peuvent changer. Une fois la connexion établie, mettez à jour les paramètres dans le local.ini du serveur.",
|
||||
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "Votre phrase secrète de chiffrement peut être invalide. Êtes-vous sûr de vouloir continuer ?",
|
||||
"obsidianLiveSyncSettingTab.msgNewVersionNote": "Arrivé ici suite à une notification de mise à jour ? Consultez l'historique des versions. Si vous êtes satisfait, cliquez sur le bouton. Une nouvelle mise à jour reproposera ceci.",
|
||||
@@ -327,7 +313,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgObjectStorageWarning": "AVERTISSEMENT : cette fonctionnalité est en cours de développement, gardez à l'esprit ce qui suit :\n- Architecture en ajout seul. Une reconstruction est nécessaire pour réduire le stockage.\n- Un peu fragile.\n- Lors de la première synchronisation, tout l'historique sera transféré depuis le distant. Attention aux limites de données et aux débits lents.\n- Seules les différences sont synchronisées en direct.\n\nSi vous rencontrez des problèmes ou avez des idées sur cette fonctionnalité, merci d'ouvrir un ticket sur GitHub.\nMerci pour votre grand dévouement.",
|
||||
"obsidianLiveSyncSettingTab.msgOriginCheck": "Vérification d'origine : ${org}",
|
||||
"obsidianLiveSyncSettingTab.msgRebuildRequired": "La reconstruction des bases de données est nécessaire pour appliquer les changements. Veuillez sélectionner la méthode d'application.\n\n<details>\n<summary>Légende</summary>\n\n| Symbole | Signification |\n|: ------ :| ------- |\n| ⇔ | À jour |\n| ⇄ | Synchroniser pour équilibrer |\n| ⇐,⇒ | Transférer pour écraser |\n| ⇠,⇢ | Transférer pour écraser depuis l'autre côté |\n\n</details>\n\n## ${OPTION_REBUILD_BOTH}\nEn bref : 📄 ⇒¹ 💻 ⇒² 🛰️ ⇢ⁿ 💻 ⇄ⁿ⁺¹ 📄\nReconstruit les bases locale et distante à partir des fichiers existants de cet appareil.\nCeci provoque un verrouillage des autres appareils, qui devront effectuer une récupération.\n## ${OPTION_FETCH}\nEn bref : 📄 ⇄² 💻 ⇐¹ 🛰️ ⇔ 💻 ⇔ 📄\nInitialise la base locale et la reconstruit à partir des données récupérées depuis la base distante.\nCe cas inclut également celui où vous avez reconstruit la base distante.\n## ${OPTION_ONLY_SETTING}\nNe stocker que les paramètres. **Attention : cela peut entraîner une corruption des données** ; une reconstruction de la base est généralement nécessaire.",
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": "Veuillez sélectionner et appliquer un préréglage pour terminer l'assistant.",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsCredentials": "Définir cors.credentials",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsOrigins": "Définir cors.origins",
|
||||
"obsidianLiveSyncSettingTab.msgSetMaxDocSize": "Définir couchdb.max_document_size",
|
||||
@@ -384,7 +369,6 @@
|
||||
"obsidianLiveSyncSettingTab.titleActiveRemoteServer": "Serveur distant actif",
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": "Apparence",
|
||||
"obsidianLiveSyncSettingTab.titleConflictResolution": "Résolution des conflits",
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": "Félicitations !",
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": "CouchDB",
|
||||
"obsidianLiveSyncSettingTab.titleDeletionPropagation": "Propagation des suppressions",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": "Le chiffrement n'est pas activé",
|
||||
|
||||
@@ -191,7 +191,6 @@
|
||||
"moduleLiveSyncMain.titleScramEnabled": "מצב בלימה פעיל",
|
||||
"moduleLocalDatabase.logWaitingForReady": "ממתין לכשירות...",
|
||||
"moduleLog.showLog": "הצג יומן",
|
||||
"moduleMigration.docUri": "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use",
|
||||
"moduleMigration.fix0256.buttons.checkItLater": "בדוק מאוחר יותר",
|
||||
"moduleMigration.fix0256.buttons.DismissForever": "תיקנתי, ואל תשאל שוב",
|
||||
"moduleMigration.fix0256.buttons.fix": "תקן",
|
||||
@@ -213,26 +212,16 @@
|
||||
"moduleMigration.logRemoteTweakUnavailable": "לא ניתן לקבל ערכי כיוונון מרוחקים",
|
||||
"moduleMigration.logSetupCancelled": "ההגדרה בוטלה, Self-hosted LiveSync ממתין להגדרתך!",
|
||||
"moduleMigration.msgFetchRemoteAgain": "כפי שייתכן שכבר ידוע לך, Self-hosted LiveSync שינה את התנהגות ברירת המחדל ומבנה מסד הנתונים.\n\nובזכות זמנך ומאמציך, מסד הנתונים המרוחד נראה כבר הוגר. ברכות!\n\nעם זאת, נדרש עוד קצת. תצורת מכשיר זה אינה תואמת למסד הנתונים המרוחד. נצטרך למשוך את מסד הנתונים המרוחד שוב. האם למשוך מהשרת המרוחד עכשיו?\n\n___הערה: לא ניתן לסנכרן עד שהתצורה תשתנה ומסד הנתונים יימשך שוב.___\n___הערה 2: הנתחים הם בלתי-ניתנים לשינוי לחלוטין, ניתן למשוך רק את המטה-נתונים וההפרש.___",
|
||||
"moduleMigration.msgInitialSetup": "המכשיר שלך **טרם הוגדר**. אנחנו כאן לעזור לך בתהליך ההגדרה.\n\nשים לב שניתן להעתיק את תוכן כל דיאלוג ללוח. אם צריך לחזור אליו מאוחר יותר, ניתן להדביק אותו כפתק ב-Obsidian. ניתן גם לתרגם לשפתך בעזרת כלי תרגום.\n\nראשית, האם יש לך **Setup URI**?\n\nהערה: אם אינך יודע מהו, אנא עיין ב[תיעוד](${URI_DOC}).",
|
||||
"moduleMigration.msgRecommendSetupUri": "אנו ממליצים בחום לייצר Setup URI ולהשתמש בו.\nאם אין לך ידע בנושא, אנא עיין ב[תיעוד](${URI_DOC}) (מתנצלים שוב, אך זה חשוב).\n\nכיצד ברצונך להגדיר ידנית?",
|
||||
"moduleMigration.msgSinceV02321": "מאז גרסה 0.23.21, Self-hosted LiveSync שינה את התנהגות ברירת המחדל ומבנה מסד הנתונים. השינויים הבאים בוצעו:\n\n1. **תלות רישיות בשמות קבצים**\n הטיפול בשמות קבצים הוא כעת ללא תלות רישיות. זהו שינוי מועיל לרוב הפלטפורמות,\n פרט ל-Linux ו-iOS שאינן מנהלות תלות רישיות בקבצים ביעילות.\n (בפלטפורמות אלה, תוצג אזהרה עבור קבצים עם אותו שם אך רישיות שונה).\n\n2. **טיפול בגרסאות של נתחים**\n נתחים הם בלתי-ניתנים לשינוי, מה שמאפשר גרסאות קבועות. שינוי זה ישפר את\n ביצועי שמירת הקבצים.\n\n___עם זאת, כדי להפעיל אחד מהשינויים הללו, יש לבנות מחדש גם את מסד הנתונים המרוחד וגם את המקומי. תהליך זה לוקח כמה דקות, ואנו ממליצים לעשות זאת כשיש לך זמן פנוי.___\n\n- אם ברצונך לשמור את ההתנהגות הקודמת, ניתן לדלג על תהליך זה באמצעות `${KEEP}`.\n- אם אין לך מספיק זמן, אנא בחר `${DISMISS}`. תקבל תזכורת בהמשך.\n- אם בנית מחדש את מסד הנתונים במכשיר אחר, אנא בחר `${DISMISS}` ונסה לסנכרן שוב. מאחר שזוהה הפרש, תקבל תזכורת שוב.",
|
||||
"moduleMigration.optionAdjustRemote": "התאם לשרת המרוחד",
|
||||
"moduleMigration.optionDecideLater": "החלט מאוחר יותר",
|
||||
"moduleMigration.optionEnableBoth": "הפעל את שניהם",
|
||||
"moduleMigration.optionEnableFilenameCaseInsensitive": "הפעל רק #1",
|
||||
"moduleMigration.optionEnableFixedRevisionForChunks": "הפעל רק #2",
|
||||
"moduleMigration.optionHaveSetupUri": "כן, יש לי",
|
||||
"moduleMigration.optionKeepPreviousBehaviour": "שמור על התנהגות קודמת",
|
||||
"moduleMigration.optionManualSetup": "הגדר הכל ידנית",
|
||||
"moduleMigration.optionNoAskAgain": "לא, אנא שאל שוב",
|
||||
"moduleMigration.optionNoSetupUri": "לא, אין לי",
|
||||
"moduleMigration.optionRemindNextLaunch": "הזכר לי בהפעלה הבאה",
|
||||
"moduleMigration.optionSetupViaP2P": "השתמש ב-%{short_p2p_sync} להגדרה",
|
||||
"moduleMigration.optionSetupWizard": "קח אותי לאשף ההגדרה",
|
||||
"moduleMigration.optionYesFetchAgain": "כן, משוך שוב",
|
||||
"moduleMigration.titleCaseSensitivity": "תלות רישיות",
|
||||
"moduleMigration.titleRecommendSetupUri": "המלצה לשימוש ב-Setup URI",
|
||||
"moduleMigration.titleWelcome": "ברוך הבא ל-Self-hosted LiveSync",
|
||||
"moduleObsidianMenu.replicate": "שכפל",
|
||||
"Move remotely deleted files to the trash, instead of deleting.": "העבר קבצים שנמחקו מרחוק לאשפה, במקום למחוק.",
|
||||
"Not all messages have been translated. And, please revert to \"Default\" when reporting errors.": "לא כל ההודעות תורגמו. בנוסף, אנא חזור ל\"ברירת מחדל\" בעת דיווח על שגיאות.",
|
||||
@@ -250,12 +239,10 @@
|
||||
"obsidianLiveSyncSettingTab.btnEnable": "הפעל",
|
||||
"obsidianLiveSyncSettingTab.btnFix": "תקן",
|
||||
"obsidianLiveSyncSettingTab.btnGotItAndUpdated": "הבנתי ועדכנתי.",
|
||||
"obsidianLiveSyncSettingTab.btnNext": "הבא",
|
||||
"obsidianLiveSyncSettingTab.btnStart": "התחל",
|
||||
"obsidianLiveSyncSettingTab.btnTest": "בדוק",
|
||||
"obsidianLiveSyncSettingTab.btnUse": "השתמש",
|
||||
"obsidianLiveSyncSettingTab.buttonFetch": "משוך",
|
||||
"obsidianLiveSyncSettingTab.buttonNext": "הבא",
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": "ברירת מחדל",
|
||||
"obsidianLiveSyncSettingTab.descConnectSetupURI": "זוהי השיטה המומלצת להגדרת Self-hosted LiveSync עם Setup URI.",
|
||||
"obsidianLiveSyncSettingTab.descCopySetupURI": "מושלם להגדרת מכשיר חדש!",
|
||||
@@ -318,7 +305,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgEnableCorsChttpd": "הגדר chttpd.enable_cors",
|
||||
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "אנו ממליצים להפעיל הצפנה מקצה לקצה ואת ערפול הנתיב. האם אתה בטוח שברצונך להמשיך ללא הצפנה?",
|
||||
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "האם ברצונך למשוך את התצורה מהשרת המרוחד?",
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "הכל מוכן! האם ברצונך לייצר Setup URI להגדרת מכשירים אחרים?",
|
||||
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": "אם תצורת השרת אינה קבועה (למשל, פועלת ב-docker), הערכים כאן עשויים להשתנות. לאחר שתצליח להתחבר, אנא עדכן את ההגדרות ב-local.ini של השרת.",
|
||||
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "ביטוי הסיסמה להצפנה שלך עשוי להיות לא תקין. האם אתה בטוח שברצונך להמשיך?",
|
||||
"obsidianLiveSyncSettingTab.msgNewVersionNote": "הגעת כאן בשל הודעת שדרוג? אנא עיין בהיסטוריית הגרסאות. אם אתה מרוצה, לחץ על הכפתור. עדכון חדש יציג זאת שוב.",
|
||||
@@ -328,7 +314,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgObjectStorageWarning": "אזהרה: תכונה זו בשלב פיתוח, לכן שים לב לנקודות הבאות:\n- ארכיטקטורת הוספה בלבד. נדרשת בנייה מחדש לצמצום האחסון.\n- קצת רגיש.\n- בסנכרון הראשון, כל ההיסטוריה תועבר מהשרת המרוחד. שים לב למגבלות נתונים ומהירות.\n- רק הפרשים מסונכרנים בזמן אמת.\n\nאם נתקלת בבעיות, או שיש לך רעיונות לגבי תכונה זו, אנא פתח Issue ב-GitHub.\nאנחנו מעריכים את ההקדשה הגדולה שלך.",
|
||||
"obsidianLiveSyncSettingTab.msgOriginCheck": "בדיקת מקור: ${org}",
|
||||
"obsidianLiveSyncSettingTab.msgRebuildRequired": "נדרשת בנייה מחדש של מסדי הנתונים כדי להחיל את השינויים. אנא בחר את השיטה.\n\n<details>\n<summary>מקרא</summary>\n\n| סמל | משמעות |\n|: ------ :| ------- |\n| ⇔ | מעודכן |\n| ⇄ | סנכרן לאיזון |\n| ⇐,⇒ | העבר לדריסה |\n| ⇠,⇢ | העבר לדריסה מהצד השני |\n\n</details>\n\n## ${OPTION_REBUILD_BOTH}\nבמבט: 📄 ⇒¹ 💻 ⇒² 🛰️ ⇢ⁿ 💻 ⇄ⁿ⁺¹ 📄\nבנה מחדש גם את מסד הנתונים המקומי וגם המרוחד תוך שימוש בקבצים קיימים ממכשיר זה.\nפעולה זו תנעל מכשירים אחרים שיצטרכו לבצע משיכה.\n## ${OPTION_FETCH}\nבמבט: 📄 ⇄² 💻 ⇐¹ 🛰️ ⇔ 💻 ⇔ 📄\nאתחל את מסד הנתונים המקומי ובנה אותו מחדש תוך שימוש בנתונים שנמשכו ממסד הנתונים המרוחד.\nכולל את המקרה שבו בנית מחדש את מסד הנתונים המרוחד.\n## ${OPTION_ONLY_SETTING}\nשמור רק את ההגדרות. **זהירות: עלול לגרום לפגיעה בנתונים**; בנייה מחדש של מסד הנתונים נדרשת בדרך כלל.",
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": "אנא בחר והחל פריט קבוע מראש כלשהו להשלמת האשף.",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsCredentials": "הגדר cors.credentials",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsOrigins": "הגדר cors.origins",
|
||||
"obsidianLiveSyncSettingTab.msgSetMaxDocSize": "הגדר couchdb.max_document_size",
|
||||
@@ -385,7 +370,6 @@
|
||||
"obsidianLiveSyncSettingTab.titleActiveRemoteServer": "שרת מרוחד פעיל",
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": "מראה",
|
||||
"obsidianLiveSyncSettingTab.titleConflictResolution": "פתרון קונפליקטים",
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": "מזל טוב!",
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": "CouchDB",
|
||||
"obsidianLiveSyncSettingTab.titleDeletionPropagation": "הפצת מחיקות",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": "ההצפנה אינה מופעלת",
|
||||
|
||||
@@ -291,7 +291,6 @@
|
||||
"moduleLiveSyncMain.titleScramEnabled": "緊急停止(Scram)が有効",
|
||||
"moduleLocalDatabase.logWaitingForReady": "しばらくお待ちください...",
|
||||
"moduleLog.showLog": "ログを表示",
|
||||
"moduleMigration.docUri": "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use",
|
||||
"moduleMigration.fix0256.buttons.checkItLater": "後で確認する",
|
||||
"moduleMigration.fix0256.buttons.DismissForever": "修正済み、今後確認しない",
|
||||
"moduleMigration.fix0256.buttons.fix": "修正",
|
||||
@@ -313,26 +312,16 @@
|
||||
"moduleMigration.logRemoteTweakUnavailable": "リモートの調整値を取得できませんでした",
|
||||
"moduleMigration.logSetupCancelled": "セットアップがキャンセルされました。Self-hosted LiveSyncはセットアップを待っています!",
|
||||
"moduleMigration.msgFetchRemoteAgain": "ご存知のとおり、self-hosted LiveSyncはデフォルトの動作とデータベース構造を変更しました。\n\nご協力のおかげで、リモートデータベースはすでに移行されているようです。おめでとうございます!\n\nしかし、もう少し必要です。このデバイスの設定はリモートデータベースと互換性がありません。リモートデータベースを再度フェッチする必要があります。今すぐリモートから再フェッチしますか?\n\n___注意: 設定が変更され、データベースが再フェッチされるまで同期できません。___\n___注意2: チャンクは完全に不変なので、メタデータと差分のみフェッチできます。___",
|
||||
"moduleMigration.msgInitialSetup": "このデバイスは**まだセットアップされていません**。セットアッププロセスをご案内します。\n\nすべてのダイアログの内容はクリップボードにコピーできます。後で参照する必要があれば、Obsidianのノートに貼り付けてください。翻訳ツールを使ってお使いの言語に翻訳することもできます。\n\nまず、**セットアップURI**をお持ちですか?\n\n注意: それが何か分からない場合は、[documentation](${URI_DOC})を参照してください。",
|
||||
"moduleMigration.msgRecommendSetupUri": "セットアップURIを生成して使用することを強くお勧めします。\nこれについて知識がない場合は、[documentation](${URI_DOC})を参照してください(重要です)。\n\n手動でセットアップしますか?",
|
||||
"moduleMigration.msgSinceV02321": "v0.23.21以降、self-hosted LiveSyncはデフォルトの動作とデータベース構造を変更しました。以下の変更が行われました:\n\n1. **ファイル名の大文字小文字の区別**\n ファイル名の処理が大文字小文字を区別しなくなりました。これは、ファイル名の大文字小文字を効果的に管理しないLinuxとiOS以外のほとんどのプラットフォームにとって有益な変更です。\n (これらの環境では、同じ名前で大文字小文字が異なるファイルに対して警告が表示されます)。\n\n2. **チャンクのリビジョン処理**\n チャンクは不変であり、リビジョンを固定できます。この変更により、ファイル保存のパフォーマンスが向上します。\n\n___しかし、これらの変更を有効にするには、リモートとローカルの両方のデータベースを再構築する必要があります。このプロセスは数分かかります。時間に余裕があるときに行うことをお勧めします。___\n\n- 以前の動作を維持したい場合は、`${KEEP}`を使用してこのプロセスをスキップできます。\n- 時間がない場合は、`${DISMISS}`を選択してください。後で再度確認されます。\n- 別のデバイスでデータベースを再構築した場合は、`${DISMISS}`を選択して再度同期してみてください。差異が検出されたため、再度確認されます。",
|
||||
"moduleMigration.optionAdjustRemote": "リモートに合わせる",
|
||||
"moduleMigration.optionDecideLater": "後で決める",
|
||||
"moduleMigration.optionEnableBoth": "両方を有効にする",
|
||||
"moduleMigration.optionEnableFilenameCaseInsensitive": "#1のみ有効にする",
|
||||
"moduleMigration.optionEnableFixedRevisionForChunks": "#2のみ有効にする",
|
||||
"moduleMigration.optionHaveSetupUri": "はい、持っています",
|
||||
"moduleMigration.optionKeepPreviousBehaviour": "以前の動作を維持",
|
||||
"moduleMigration.optionManualSetup": "すべて手動でセットアップ",
|
||||
"moduleMigration.optionNoAskAgain": "いいえ、後で確認する",
|
||||
"moduleMigration.optionNoSetupUri": "いいえ、持っていません",
|
||||
"moduleMigration.optionRemindNextLaunch": "次回起動時にリマインド",
|
||||
"moduleMigration.optionSetupViaP2P": "%{short_p2p_sync}を使ってセットアップ",
|
||||
"moduleMigration.optionSetupWizard": "セットアップウィザードへ",
|
||||
"moduleMigration.optionYesFetchAgain": "はい、再フェッチする",
|
||||
"moduleMigration.titleCaseSensitivity": "大文字小文字の区別",
|
||||
"moduleMigration.titleRecommendSetupUri": "セットアップURIの使用を推奨",
|
||||
"moduleMigration.titleWelcome": "Self-hosted LiveSyncへようこそ",
|
||||
"moduleObsidianMenu.replicate": "レプリケート",
|
||||
"More actions": "その他の操作",
|
||||
"Move remotely deleted files to the trash, instead of deleting.": "リモートで削除されたファイルを削除せずにゴミ箱に移動する。",
|
||||
@@ -361,12 +350,10 @@
|
||||
"obsidianLiveSyncSettingTab.btnEnable": "有効化",
|
||||
"obsidianLiveSyncSettingTab.btnFix": "修正",
|
||||
"obsidianLiveSyncSettingTab.btnGotItAndUpdated": "理解しました、更新しました。",
|
||||
"obsidianLiveSyncSettingTab.btnNext": "次へ",
|
||||
"obsidianLiveSyncSettingTab.btnStart": "開始",
|
||||
"obsidianLiveSyncSettingTab.btnTest": "テスト",
|
||||
"obsidianLiveSyncSettingTab.btnUse": "使用",
|
||||
"obsidianLiveSyncSettingTab.buttonFetch": "フェッチ",
|
||||
"obsidianLiveSyncSettingTab.buttonNext": "次へ",
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": "デフォルト",
|
||||
"obsidianLiveSyncSettingTab.descConnectSetupURI": "セットアップURIを使用してSelf-hosted LiveSyncをセットアップする推奨方法です。",
|
||||
"obsidianLiveSyncSettingTab.descCopySetupURI": "新しいデバイスのセットアップにおすすめ!",
|
||||
@@ -429,7 +416,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgEnableCorsChttpd": "chttpd.enable_corsを設定",
|
||||
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "エンドツーエンド暗号化とパス難読化を有効にすることをお勧めします。暗号化なしで続行してもよろしいですか?",
|
||||
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "リモートサーバーから設定を取得しますか?",
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "完了!他のデバイスをセットアップするためのセットアップURIを生成しますか?",
|
||||
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": "サーバー設定が永続的でない場合(例: Dockerで実行中)、ここの値は変更される可能性があります。接続できるようになったら、サーバーのlocal.iniの設定を更新してください。",
|
||||
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "暗号化パスフレーズが無効かもしれません。続行してもよろしいですか?",
|
||||
"obsidianLiveSyncSettingTab.msgNewVersionNote": "アップグレード通知でここに来ましたか?バージョン履歴を確認してください。納得したらボタンをクリックしてください。新しい更新があると再度確認されます。",
|
||||
@@ -439,7 +425,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgObjectStorageWarning": "警告: この機能は開発中です。以下の点にご注意ください:\n- 追記専用アーキテクチャ。ストレージを縮小するには再構築が必要です。\n- やや不安定です。\n- 初回同期時、すべての履歴がリモートから転送されます。データ制限と速度に注意してください。\n- ライブ同期は差分のみです。\n\n問題があれば、またはこの機能についてアイデアがあれば、GitHubにIssueを作成してください。\nご協力に感謝します。",
|
||||
"obsidianLiveSyncSettingTab.msgOriginCheck": "オリジン確認: ${org}",
|
||||
"obsidianLiveSyncSettingTab.msgRebuildRequired": "変更を適用するにはデータベースの再構築が必要です。変更を適用する方法を選択してください。\n\n<details>\n<summary>凡例</summary>\n\n| 記号 | 意味 |\n|: ------ :| ------- |\n| ⇔ | 最新 |\n| ⇄ | 同期してバランスを取る |\n| ⇐,⇒ | 上書きするため転送 |\n| ⇠,⇢ | 反対側から上書きするため転送 |\n\n</details>\n\n## ${OPTION_REBUILD_BOTH}\n概要: 📄 ⇒¹ 💻 ⇒² 🛰️ ⇢ⁿ 💻 ⇄ⁿ⁺¹ 📄\nこのデバイスの既存ファイルを使用してローカルとリモートの両方のデータベースを再構築します。\n他のデバイスはロックアウトされ、フェッチが必要です。\n## ${OPTION_FETCH}\n概要: 📄 ⇄² 💻 ⇐¹ 🛰️ ⇔ 💻 ⇔ 📄\nローカルデータベースを初期化し、リモートデータベースから取得したデータを使用して再構築します。\nリモートデータベースを再構築した場合も含まれます。\n## ${OPTION_ONLY_SETTING}\n設定のみを保存します。**注意: データ破損につながる可能性があります**。通常、データベースの再構築が必要です。",
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": "ウィザードを完了するには、プリセット項目を選択して適用してください。",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsCredentials": "cors.credentialsを設定",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsOrigins": "cors.originsを設定",
|
||||
"obsidianLiveSyncSettingTab.msgSetMaxDocSize": "couchdb.max_document_sizeを設定",
|
||||
@@ -496,7 +481,6 @@
|
||||
"obsidianLiveSyncSettingTab.titleActiveRemoteServer": "アクティブなリモートサーバー",
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": "外観",
|
||||
"obsidianLiveSyncSettingTab.titleConflictResolution": "競合解決",
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": "おめでとうございます!",
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": "CouchDB サーバー",
|
||||
"obsidianLiveSyncSettingTab.titleDeletionPropagation": "削除の伝播",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": "暗号化が有効になっていません",
|
||||
|
||||
@@ -478,7 +478,6 @@
|
||||
"moduleLiveSyncMain.titleScramEnabled": "긴급 정지 활성화됨",
|
||||
"moduleLocalDatabase.logWaitingForReady": "준비 대기 중...",
|
||||
"moduleLog.showLog": "로그 표시",
|
||||
"moduleMigration.docUri": "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use",
|
||||
"moduleMigration.fix0256.buttons.checkItLater": "나중에 확인",
|
||||
"moduleMigration.fix0256.buttons.DismissForever": "이미 해결했으니 다시 묻지 않기",
|
||||
"moduleMigration.fix0256.buttons.fix": "수정",
|
||||
@@ -500,26 +499,16 @@
|
||||
"moduleMigration.logRemoteTweakUnavailable": "원격 조정 값을 가져올 수 없습니다",
|
||||
"moduleMigration.logSetupCancelled": "설정이 취소되었습니다. Self-hosted LiveSync가 설정을 기다리고 있습니다!",
|
||||
"moduleMigration.msgFetchRemoteAgain": "이미 알고 계시겠지만, Self-hosted LiveSync의 기본 동작 방식과 데이터베이스 구조가 변경되었습니다.\n\n다행히도 여러분의 노력 덕분에 원격 데이터베이스는 이미 성공적으로 데이터 구조 전환이 완료된 것으로 보입니다. 축하드립니다!\n\n하지만 아직 일부 추가 작업이 필요합니다. 이 기기의 설정이 원격 데이터베이스와 호환되지 않으므로, 원격 데이터를 다시 가져와야 합니다. 지금 원격 데이터베이스를 다시 가져오시겠습니까?\n\n___참고: 설정이 변경되고 데이터베이스를 다시 불러오기 전까지는 동기화가 불가능합니다.___\n___참고2: 청크는 변경이 불가능한 구조이므로, 메타데이터와 차이점만 가져올 수 있습니다.___",
|
||||
"moduleMigration.msgInitialSetup": "이 기기는 **아직 초기 설정이 완료되지 않았습니다**. 지금부터 설정 과정을 안내해 드리겠습니다.\n\n모든 대화 내용은 클립보드에 복사할 수 있습니다. 나중에 참고하려면 Obsidian 노트에 붙여넣거나 번역 도구를 활용해 번역하셔도 됩니다.\n\n먼저, **Setup URI**를 가지고 계신가요?\n\n참고: Setup URI가 무엇인지 잘 모르시겠다면 [문서](${URI_DOC})를 참고해 주세요.",
|
||||
"moduleMigration.msgRecommendSetupUri": "Setup URI를 생성해 사용하는 것을 강력히 권장합니다.\nSetup URI가 무엇인지 잘 모르시겠다면 [문서](${URI_DOC})를 참고해 주세요. 중요한 내용이니 꼭 확인하시기 바랍니다.\n\n직접 수동 설정을 진행하시겠습니까?",
|
||||
"moduleMigration.msgSinceV02321": "v0.23.21부터 Self-hosted LiveSync의 기본 동작 방식과 데이터베이스 구조가 변경되었습니다. 변경 내용은 다음과 같습니다:\n\n1. **파일명의 대소문자 구분**\n 이제 파일명을 대소문자 구분 없이 처리합니다. 파일명의 대소문자를 제대로 관리하지 못하는 Linux와 iOS를 제외한 대부분의 플랫폼에서 유리한 변경입니다.\n (해당 플랫폼에서는 이름이 같고 대소문자만 다른 파일에 대해 경고가 표시됩니다)\n\n2. **청크의 리비전 처리**\n 청크는 변경 불가능하므로 리비전을 고정할 수 있습니다. 이 변경으로 파일 저장 성능이 향상됩니다.\n\n___다만 이 변경 중 어느 하나라도 적용하려면 원격과 로컬 데이터베이스를 모두 재구축해야 합니다. 이 과정은 몇 분이 걸리므로 시간이 충분할 때 진행하시기를 권장합니다.___\n\n- 기존 동작을 유지하려면 `${KEEP}`을 선택해 이 과정을 건너뛸 수 있습니다.\n- 시간이 충분하지 않다면 `${DISMISS}`를 선택해 주세요. 나중에 다시 여쭤보겠습니다.\n- 다른 기기에서 이미 데이터베이스를 재구축했다면 `${DISMISS}`를 선택한 뒤 다시 동기화해 보세요. 차이가 감지되면 다시 안내해 드립니다.",
|
||||
"moduleMigration.optionAdjustRemote": "원격에 맞추기",
|
||||
"moduleMigration.optionDecideLater": "나중에 결정하기",
|
||||
"moduleMigration.optionEnableBoth": "둘 다 활성화",
|
||||
"moduleMigration.optionEnableFilenameCaseInsensitive": "#1만 활성화",
|
||||
"moduleMigration.optionEnableFixedRevisionForChunks": "#2만 활성화",
|
||||
"moduleMigration.optionHaveSetupUri": "예, 있습니다",
|
||||
"moduleMigration.optionKeepPreviousBehaviour": "이전 동작 유지",
|
||||
"moduleMigration.optionManualSetup": "모든 것을 수동으로 설정",
|
||||
"moduleMigration.optionNoAskAgain": "아니요 (나중에 다시 물어보기)",
|
||||
"moduleMigration.optionNoSetupUri": "아니요, 없습니다",
|
||||
"moduleMigration.optionRemindNextLaunch": "다음 시작 시 알림",
|
||||
"moduleMigration.optionSetupViaP2P": "%{short_p2p_sync}를 사용하여 설정",
|
||||
"moduleMigration.optionSetupWizard": "설정 마법사로 안내",
|
||||
"moduleMigration.optionYesFetchAgain": "예 (다시 가져오기)",
|
||||
"moduleMigration.titleCaseSensitivity": "대소문자 구분",
|
||||
"moduleMigration.titleRecommendSetupUri": "Setup URI 사용 권장",
|
||||
"moduleMigration.titleWelcome": "Self-hosted LiveSync에 오신 것을 환영합니다",
|
||||
"moduleObsidianMenu.replicate": "복제",
|
||||
"More actions": "추가 작업",
|
||||
"Mostly Complete: Decision Required": "거의 완료: 결정이 필요합니다",
|
||||
@@ -564,12 +553,10 @@
|
||||
"obsidianLiveSyncSettingTab.btnEnable": "활성화",
|
||||
"obsidianLiveSyncSettingTab.btnFix": "수정",
|
||||
"obsidianLiveSyncSettingTab.btnGotItAndUpdated": "알겠습니다. 업데이트했습니다.",
|
||||
"obsidianLiveSyncSettingTab.btnNext": "다음",
|
||||
"obsidianLiveSyncSettingTab.btnStart": "시작",
|
||||
"obsidianLiveSyncSettingTab.btnTest": "테스트",
|
||||
"obsidianLiveSyncSettingTab.btnUse": "사용",
|
||||
"obsidianLiveSyncSettingTab.buttonFetch": "가져오기",
|
||||
"obsidianLiveSyncSettingTab.buttonNext": "다음",
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": "기본값",
|
||||
"obsidianLiveSyncSettingTab.descConnectSetupURI": "이것은 Setup URI로 Self-hosted LiveSync를 설정하는 권장 방법입니다.",
|
||||
"obsidianLiveSyncSettingTab.descCopySetupURI": "새 기기 설정에 완벽합니다!",
|
||||
@@ -633,7 +620,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgEnableCorsChttpd": "chttpd.enable_cors 설정",
|
||||
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "종단 간 암호화와 경로 난독화를 활성화하는 것을 권장합니다. 정말로 암호화 없이 계속하시겠습니까?",
|
||||
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "원격 서버에서 구성을 가져오시겠습니까?",
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "모든 작업이 완료되었습니다! 다른 기기를 설정하기 위해 Setup URI를 생성하시겠습니까?",
|
||||
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": "서버 설정이 영구적으로 저장되지 않는 환경(예: Docker에서 실행 중)에서는 이곳의 값들이 변경될 수 있습니다. 연결이 가능해지면 서버의 local.ini 파일에서 설정을 수동으로 업데이트해 주세요.",
|
||||
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "암호화 패스프레이즈가 유효하지 않을 수 있습니다. 정말로 계속하시겠습니까?",
|
||||
"obsidianLiveSyncSettingTab.msgNewVersionNote": "업그레이드 알림으로 여기에 오셨나요? 버전 기록을 검토해 주세요. 만족하신다면 버튼을 클릭하세요. 새로운 업데이트 시 다시 안내됩니다.",
|
||||
@@ -643,7 +629,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgObjectStorageWarning": "경고: 이 기능은 아직 개발 중이므로 다음 사항을 유의해 주세요:\n- 추가 전용 구조로 동작합니다. 저장 용량을 줄이려면 재구축이 필요합니다.\n- 다소 불안정합니다.\n- 최초 동기화 시 모든 기록이 원격에서 전송됩니다. 데이터 사용량 제한과 느린 속도에 유의해 주세요.\n- 실시간 동기화는 변경분만 처리합니다.\n\n문제가 발생했거나 이 기능에 대한 아이디어가 있다면 GitHub에 이슈를 등록해 주세요.\n큰 관심에 깊이 감사드립니다.",
|
||||
"obsidianLiveSyncSettingTab.msgOriginCheck": "출처 확인: ${org}",
|
||||
"obsidianLiveSyncSettingTab.msgRebuildRequired": "변경 사항을 적용하려면 데이터베이스를 재구축해야 합니다. 변경 사항을 적용할 방법을 선택해 주세요.\n\n<details>\n<summary>범례</summary>\n\n| 기호 | 의미 |\n|: ------ :| ------- |\n| ⇔ | 최신 상태 |\n| ⇄ | 양쪽을 맞추는 동기화 |\n| ⇐,⇒ | 덮어쓰기 전송 |\n| ⇠,⇢ | 반대편에서 덮어쓰기 전송 |\n\n</details>\n\n## ${OPTION_REBUILD_BOTH}\n한눈에 보기: 📄 ⇒¹ 💻 ⇒² 🛰️ ⇢ⁿ 💻 ⇄ⁿ⁺¹ 📄\n이 기기의 기존 파일을 사용해 로컬과 원격 데이터베이스를 모두 재구축합니다.\n이 경우 다른 기기는 잠기며, 가져오기를 수행해야 합니다.\n## ${OPTION_FETCH}\n한눈에 보기: 📄 ⇄² 💻 ⇐¹ 🛰️ ⇔ 💻 ⇔ 📄\n로컬 데이터베이스를 초기화한 뒤, 원격 데이터베이스에서 가져온 데이터로 재구축합니다.\n원격 데이터베이스를 이미 재구축한 경우도 여기에 해당합니다.\n## ${OPTION_ONLY_SETTING}\n설정만 저장합니다. **주의: 데이터가 손상될 수 있습니다.** 일반적으로는 데이터베이스 재구축이 필요합니다.",
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": "마법사를 완료하려면 프리셋 항목을 선택하고 적용해 주세요.",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsCredentials": "cors.credentials 설정",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsOrigins": "cors.origins 설정",
|
||||
"obsidianLiveSyncSettingTab.msgSetMaxDocSize": "couchdb.max_document_size 설정",
|
||||
@@ -700,7 +685,6 @@
|
||||
"obsidianLiveSyncSettingTab.titleActiveRemoteServer": "활성 원격 서버",
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": "모양",
|
||||
"obsidianLiveSyncSettingTab.titleConflictResolution": "충돌 해결",
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": "축하합니다!",
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": "CouchDB",
|
||||
"obsidianLiveSyncSettingTab.titleDeletionPropagation": "삭제 전파",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": "암호화가 활성화되지 않음",
|
||||
|
||||
@@ -306,7 +306,6 @@
|
||||
"moduleLiveSyncMain.titleScramEnabled": "Экстренная остановка включена",
|
||||
"moduleLocalDatabase.logWaitingForReady": "Ожидание готовности...",
|
||||
"moduleLog.showLog": "Показать лог",
|
||||
"moduleMigration.docUri": "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use",
|
||||
"moduleMigration.fix0256.buttons.checkItLater": "Проверить позже",
|
||||
"moduleMigration.fix0256.buttons.DismissForever": "Исправлено, больше не спрашивать",
|
||||
"moduleMigration.fix0256.buttons.fix": "Исправить",
|
||||
@@ -328,26 +327,16 @@
|
||||
"moduleMigration.logRemoteTweakUnavailable": "Не удалось получить удалённые настройки",
|
||||
"moduleMigration.logSetupCancelled": "Настройка отменена, Self-hosted LiveSync ожидает вашей настройки!",
|
||||
"moduleMigration.msgFetchRemoteAgain": "Удалённая база данных, похоже, уже была мигрирована. Конфигурация этого устройства несовместима.",
|
||||
"moduleMigration.msgInitialSetup": "Ваше устройство ещё не настроено. У вас есть Setup URI?",
|
||||
"moduleMigration.msgRecommendSetupUri": "Мы рекомендуем сгенерировать Setup URI.",
|
||||
"moduleMigration.msgSinceV02321": "Начиная с v0.23.21, self-hosted LiveSync изменил поведение и структуру базы данных.",
|
||||
"moduleMigration.optionAdjustRemote": "Настроить под удалённую",
|
||||
"moduleMigration.optionDecideLater": "Решить позже",
|
||||
"moduleMigration.optionEnableBoth": "Включить оба",
|
||||
"moduleMigration.optionEnableFilenameCaseInsensitive": "Включить только #1",
|
||||
"moduleMigration.optionEnableFixedRevisionForChunks": "Включить только #2",
|
||||
"moduleMigration.optionHaveSetupUri": "Да, есть",
|
||||
"moduleMigration.optionKeepPreviousBehaviour": "Сохранить предыдущее поведение",
|
||||
"moduleMigration.optionManualSetup": "Настроить всё вручную",
|
||||
"moduleMigration.optionNoAskAgain": "Нет, спросить снова",
|
||||
"moduleMigration.optionNoSetupUri": "Нет, нет",
|
||||
"moduleMigration.optionRemindNextLaunch": "Напомнить при следующем запуске",
|
||||
"moduleMigration.optionSetupViaP2P": "Использовать short_p2p_sync для настройки",
|
||||
"moduleMigration.optionSetupWizard": "Перейти в мастер настройки",
|
||||
"moduleMigration.optionYesFetchAgain": "Да, загрузить снова",
|
||||
"moduleMigration.titleCaseSensitivity": "Чувствительность к регистру",
|
||||
"moduleMigration.titleRecommendSetupUri": "Рекомендация использовать Setup URI",
|
||||
"moduleMigration.titleWelcome": "Добро пожаловать в Self-hosted LiveSync",
|
||||
"moduleObsidianMenu.replicate": "Реплицировать",
|
||||
"More actions": "Другие действия",
|
||||
"Move remotely deleted files to the trash, instead of deleting.": "Перемещать удалённые на удалённом сервере файлы в корзину вместо удаления.",
|
||||
@@ -377,12 +366,10 @@
|
||||
"obsidianLiveSyncSettingTab.btnEnable": "Включить",
|
||||
"obsidianLiveSyncSettingTab.btnFix": "Исправить",
|
||||
"obsidianLiveSyncSettingTab.btnGotItAndUpdated": "Понял и обновил.",
|
||||
"obsidianLiveSyncSettingTab.btnNext": "Далее",
|
||||
"obsidianLiveSyncSettingTab.btnStart": "Старт",
|
||||
"obsidianLiveSyncSettingTab.btnTest": "Тест",
|
||||
"obsidianLiveSyncSettingTab.btnUse": "Использовать",
|
||||
"obsidianLiveSyncSettingTab.buttonFetch": "Загрузить",
|
||||
"obsidianLiveSyncSettingTab.buttonNext": "Далее",
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": "По умолчанию",
|
||||
"obsidianLiveSyncSettingTab.descConnectSetupURI": "Это рекомендуемый способ настройки Self-hosted LiveSync с помощью Setup URI.",
|
||||
"obsidianLiveSyncSettingTab.descCopySetupURI": "Идеально подходит для настройки нового устройства!",
|
||||
@@ -445,7 +432,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgEnableCorsChttpd": "Установить chttpd.enable_cors",
|
||||
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "Мы рекомендуем включить сквозное шифрование. Вы уверены, что хотите продолжить без шифрования?",
|
||||
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "Вы хотите загрузить конфигурацию с удалённого сервера?",
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "Всё готово! Вы хотите сгенерировать Setup URI для настройки других устройств?",
|
||||
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": "Если конфигурация сервера непостоянна, значения здесь могут измениться.",
|
||||
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "Ваша парольная фраза шифрования может быть недействительна.",
|
||||
"obsidianLiveSyncSettingTab.msgNewVersionNote": "Вы пришли из-за уведомления об обновлении? Просмотрите историю версий.",
|
||||
@@ -455,7 +441,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgObjectStorageWarning": "ПРЕДУПРЕЖДЕНИЕ: Эта функция в разработке.",
|
||||
"obsidianLiveSyncSettingTab.msgOriginCheck": "Проверка origin: org",
|
||||
"obsidianLiveSyncSettingTab.msgRebuildRequired": "Требуется перестроение баз данных для применения изменений.",
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": "Выберите и примените любой пресет для завершения мастера.",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsCredentials": "Установить cors.credentials",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsOrigins": "Установить cors.origins",
|
||||
"obsidianLiveSyncSettingTab.msgSetMaxDocSize": "Установить couchdb.max_document_size",
|
||||
@@ -512,7 +497,6 @@
|
||||
"obsidianLiveSyncSettingTab.titleActiveRemoteServer": "Активный удалённый сервер",
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": "Внешний вид",
|
||||
"obsidianLiveSyncSettingTab.titleConflictResolution": "Разрешение конфликтов",
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": "Поздравляем!",
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": "Сервер CouchDB",
|
||||
"obsidianLiveSyncSettingTab.titleDeletionPropagation": "Распространение удалений",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": "Шифрование не включено",
|
||||
|
||||
+1066
-15
File diff suppressed because it is too large
Load Diff
@@ -293,7 +293,6 @@
|
||||
"moduleLiveSyncMain.titleScramEnabled": "紧急停止已启用",
|
||||
"moduleLocalDatabase.logWaitingForReady": "等待就绪...",
|
||||
"moduleLog.showLog": "显示日志",
|
||||
"moduleMigration.docUri": "https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/zh/README_zh.md#%E5%A6%82%E4%BD%95%E4%BD%BF%E7%94%A8",
|
||||
"moduleMigration.fix0256.buttons.checkItLater": "稍后检查",
|
||||
"moduleMigration.fix0256.buttons.DismissForever": "我已经修复了,不再询问",
|
||||
"moduleMigration.fix0256.buttons.fix": "修复",
|
||||
@@ -315,26 +314,16 @@
|
||||
"moduleMigration.logRemoteTweakUnavailable": "无法获取远程调整值",
|
||||
"moduleMigration.logSetupCancelled": "设置已取消,Self-hosted LiveSync 正在等待您的设置!",
|
||||
"moduleMigration.msgFetchRemoteAgain": "您可能已经知道,Self-hosted LiveSync 更改了其默认行为和数据库结构。\n\n值得庆幸的是,在您的时间和努力下,远程数据库似乎已经迁移完成。恭喜!\n\n但是,我们还需要一点点操作。此设备的配置与远程数据库不兼容。我们需要再次从远程数据库获取。我们现在应该再次从远程获取吗?\n\n___注意:在更改配置并再次获取数据库之前,我们无法进行同步。___\n___注意2:chunks 是完全不可变的,我们只能获取元数据和差异",
|
||||
"moduleMigration.msgInitialSetup": "您的设备**尚未设置**。让我引导您完成设置过程。\n\n请记住,每个对话框内容都可以复制到剪贴板。如果以后需要参考,可以将其粘贴到 Obsidian 的笔记中。您也可以使用翻译工具将其翻译成您的语言。\n\n首先,您有**设置 URI** 吗?\n\n注意:如果您不知道这是什么,请参阅[文档](${URI_DOC})",
|
||||
"moduleMigration.msgRecommendSetupUri": "我们强烈建议您生成一个设置 URI 并使用它。\n如果您对此不了解,请参阅[文档](${URI_DOC})(再次抱歉,但这很重要)。\n\n您想如何手动设置?",
|
||||
"moduleMigration.msgSinceV02321": "自 v0.23.21 起,Self-hosted LiveSync 更改了默认行为和数据库结构。进行了以下更改:\n\n1. **文件名的区分大小写**\n现在处理文件名时不区分大小写。这对于大多数平台来说是一个有益的更改,除了 Linux 和 iOS,它们不能有效地管理文件名的大小写敏感性。\n(在这些平台上,对于名称相同但大小写不同的文件将显示警告)。\n\n2. **chunks 的版本处理**\nchunks 是不可变的,这使得它们的版本可以固定。此更改将提高文件保存的性能。\n\n___然而,要启用这些更改中的任何一个,都需要重建远程和本地数据库。这个过程需要几分钟,我们建议您在有充足时间时进行。___\n\n- 如果您希望保持以前的行为,可以使用 `${KEEP}` 跳过此过程。\n- 如果您没有足够的时间,请选择 `${DISMISS}`。稍后会再次提示您。\n- 如果您已在另一台设备上重建了数据库,请选择 `${DISMISS}` 并尝试再次同步。由于检测到差异,系统会再次提示您",
|
||||
"moduleMigration.optionAdjustRemote": "调整到远程设置",
|
||||
"moduleMigration.optionDecideLater": "稍后决定",
|
||||
"moduleMigration.optionEnableBoth": "启用两者",
|
||||
"moduleMigration.optionEnableFilenameCaseInsensitive": "仅启用 #1",
|
||||
"moduleMigration.optionEnableFixedRevisionForChunks": "仅启用 #2",
|
||||
"moduleMigration.optionHaveSetupUri": "是的,我有",
|
||||
"moduleMigration.optionKeepPreviousBehaviour": "保持以前的行为",
|
||||
"moduleMigration.optionManualSetup": "全部手动设置",
|
||||
"moduleMigration.optionNoAskAgain": "不,请稍后再次询问",
|
||||
"moduleMigration.optionNoSetupUri": "不,我没有",
|
||||
"moduleMigration.optionRemindNextLaunch": "下次启动时提醒我",
|
||||
"moduleMigration.optionSetupViaP2P": "Use %{short_p2p_sync} to set up",
|
||||
"moduleMigration.optionSetupWizard": "带我进入设置向导",
|
||||
"moduleMigration.optionYesFetchAgain": "是的,再次获取",
|
||||
"moduleMigration.titleCaseSensitivity": "大小写敏感性",
|
||||
"moduleMigration.titleRecommendSetupUri": "推荐使用设置 URI",
|
||||
"moduleMigration.titleWelcome": "欢迎使用 Self-hosted LiveSync",
|
||||
"moduleObsidianMenu.replicate": "复制",
|
||||
"More actions": "更多操作",
|
||||
"Move remotely deleted files to the trash, instead of deleting.": "将远程删除的文件移至回收站,而不是直接删除",
|
||||
@@ -363,12 +352,10 @@
|
||||
"obsidianLiveSyncSettingTab.btnEnable": "启用",
|
||||
"obsidianLiveSyncSettingTab.btnFix": "修复",
|
||||
"obsidianLiveSyncSettingTab.btnGotItAndUpdated": "我明白了并且已更新",
|
||||
"obsidianLiveSyncSettingTab.btnNext": "下一步",
|
||||
"obsidianLiveSyncSettingTab.btnStart": "开始",
|
||||
"obsidianLiveSyncSettingTab.btnTest": "测试",
|
||||
"obsidianLiveSyncSettingTab.btnUse": "使用",
|
||||
"obsidianLiveSyncSettingTab.buttonFetch": "获取",
|
||||
"obsidianLiveSyncSettingTab.buttonNext": "下一步",
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": "默认语言",
|
||||
"obsidianLiveSyncSettingTab.descConnectSetupURI": "这是使用设置 URI 设置 Self-hosted LiveSync 的推荐方法",
|
||||
"obsidianLiveSyncSettingTab.descCopySetupURI": "非常适合设置新设备!",
|
||||
@@ -431,7 +418,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgEnableCorsChttpd": "设置 chttpd.enable_cors",
|
||||
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "建议启用端到端加密和路径混淆。你确定要在未加密的情况下继续吗?",
|
||||
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "要从远端服务器获取配置吗?",
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "全部完成!要生成设置 URI 以便配置其他设备吗?",
|
||||
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": "如果服务器配置不是持久的(例如,在 docker 上运行),此处的值可能会更改。一旦能够连接,请更新服务器 local.ini 中的设置",
|
||||
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "你的加密密码短语可能无效。你确定要继续吗?",
|
||||
"obsidianLiveSyncSettingTab.msgNewVersionNote": "因为升级通知来到这里?请查看版本历史。如果您满意,请点击按钮。新的更新将再次提示此信息",
|
||||
@@ -441,7 +427,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgObjectStorageWarning": "警告:此功能仍在开发中,请注意以下几点:\n- 仅追加架构。需要重建才能缩小存储空间。\n- 有点脆弱。\n- 首次同步时,所有历史记录将从远程传输。注意数据上限和慢速。\n- 只有差异会实时同步。\n\n如果您遇到任何问题,或对此功能有任何想法,请在 GitHub 上创建 issue。\n感谢您的巨大贡献",
|
||||
"obsidianLiveSyncSettingTab.msgOriginCheck": "源检查: {org}",
|
||||
"obsidianLiveSyncSettingTab.msgRebuildRequired": "需要重建数据库以应用更改。请选择应用更改的方法。\n\n<details>\n<summary>图例</summary>\n\n| 符号 | 含义 |\n|: ------ :| ------- |\n| ⇔ | 最新 |\n| ⇄ | 同步以平衡 |\n| ⇐,⇒ | 传输以覆盖 |\n| ⇠,⇢ | 从另一侧传输以覆盖 |\n\n</details>\n\n## ${OPTION_REBUILD_BOTH}\n概览:📄 ⇒¹ 💻 ⇒² 🛰️ ⇢ⁿ 💻 ⇄ⁿ⁺¹ 📄\n使用此设备的现有文件重建本地和远程数据库。\n这将导致其他设备被锁定,并且它们需要执行获取操作。\n## ${OPTION_FETCH}\n概览:📄 ⇄² 💻 ⇐¹ 🛰️ ⇔ 💻 ⇔ 📄\n初始化本地数据库并使用从远程数据库获取的数据重建它。\n这种情况包括您已经重建了远程数据库的情况。\n## ${OPTION_ONLY_SETTING}\n仅存储设置。**注意:这可能导致数据损坏**;通常需要重建数据库",
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": "请选择并应用任一预设项以完成向导。",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsCredentials": "设置 cors.credentials",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsOrigins": "设置 cors.origins",
|
||||
"obsidianLiveSyncSettingTab.msgSetMaxDocSize": "设置 couchdb.max_document_size",
|
||||
@@ -498,7 +483,6 @@
|
||||
"obsidianLiveSyncSettingTab.titleActiveRemoteServer": "活动远程服务器",
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": "外观",
|
||||
"obsidianLiveSyncSettingTab.titleConflictResolution": "冲突处理",
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": "恭喜!",
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": "CouchDB 服务器",
|
||||
"obsidianLiveSyncSettingTab.titleDeletionPropagation": "删除传播",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": "尚未启用加密",
|
||||
|
||||
@@ -92,8 +92,6 @@ Normal Files: Normale Dateien
|
||||
obsidianLiveSyncSettingTab:
|
||||
btnApply: Anwenden
|
||||
btnDisable: Deaktivieren
|
||||
btnNext: Weiter
|
||||
buttonNext: Weiter
|
||||
defaultLanguage: Standardsprache
|
||||
labelDisabled: "⏹️ : Deaktiviert"
|
||||
labelEnabled: "🔁 : Aktiviert"
|
||||
@@ -106,12 +104,8 @@ obsidianLiveSyncSettingTab:
|
||||
und Pfadverschleierung zu aktivieren. Möchten Sie wirklich ohne
|
||||
Verschlüsselung fortfahren?
|
||||
msgFetchConfigFromRemote: Möchten Sie die Konfiguration vom Remote-Server abrufen?
|
||||
msgGenerateSetupURI: Alles fertig! Möchten Sie eine Setup-URI erzeugen, um
|
||||
andere Geräte einzurichten?
|
||||
msgInvalidPassphrase: Ihre Verschlüsselungs-Passphrase könnte ungültig sein.
|
||||
Möchten Sie wirklich fortfahren?
|
||||
msgSelectAndApplyPreset: Bitte wählen und übernehmen Sie eine beliebige
|
||||
Voreinstellung, um den Assistenten abzuschließen.
|
||||
nameDisableHiddenFileSync: Synchronisation versteckter Dateien deaktivieren
|
||||
nameEnableHiddenFileSync: Synchronisation versteckter Dateien aktivieren
|
||||
nameHiddenFileSynchronization: Synchronisation versteckter Dateien
|
||||
@@ -122,7 +116,6 @@ obsidianLiveSyncSettingTab:
|
||||
optionPeriodicWithBatch: Periodisch mit Stapelverarbeitung
|
||||
titleAppearance: Darstellung
|
||||
titleConflictResolution: Konfliktbehandlung
|
||||
titleCongratulations: Glückwunsch!
|
||||
titleCouchDB: CouchDB-Server
|
||||
titleDeletionPropagation: Weitergabe von Löschungen
|
||||
titleEncryptionNotEnabled: Verschlüsselung ist nicht aktiviert
|
||||
|
||||
@@ -362,6 +362,7 @@ Export: Export
|
||||
"Failed to connect to the server: ${reason}": "Failed to connect to the server: ${reason}"
|
||||
Failed to connect to the server. Please check your settings.: Failed to connect to the server. Please check your settings.
|
||||
"Failed to connect to the signalling relay: ${reason}": "Failed to connect to the signalling relay: ${reason}"
|
||||
The connection test cannot add a signalling relay while P2P is active. Use the active relay settings, or disconnect P2P before testing.: The connection test cannot add a signalling relay while P2P is active. Use the active relay settings, or disconnect P2P before testing.
|
||||
Failed to create replicator instance.: Failed to create replicator instance.
|
||||
Failed to parse Setup-URI.: Failed to parse Setup-URI.
|
||||
"Failed:": "Failed:"
|
||||
@@ -725,7 +726,6 @@ moduleLocalDatabase:
|
||||
moduleLog:
|
||||
showLog: Show Log
|
||||
moduleMigration:
|
||||
docUri: https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use
|
||||
fix0256:
|
||||
buttons:
|
||||
checkItLater: Check it later
|
||||
@@ -819,30 +819,6 @@ moduleMigration:
|
||||
|
||||
___Note2: The chunks are completely immutable, we can fetch only the
|
||||
metadata and difference.___
|
||||
msgInitialSetup: >-
|
||||
Your device has **not been set up yet**. Let me guide you through the setup
|
||||
process.
|
||||
|
||||
|
||||
Please keep in mind that every dialogue content can be copied to the
|
||||
clipboard. If you need to refer to it later, you can paste it into a note in
|
||||
Obsidian. You can also translate it into your language using a translation
|
||||
tool.
|
||||
|
||||
|
||||
First, do you have **Setup URI**?
|
||||
|
||||
|
||||
Note: If you do not know what it is, please refer to the
|
||||
[documentation](${URI_DOC}).
|
||||
msgRecommendSetupUri: >-
|
||||
We strongly recommend that you generate a set-up URI and use it.
|
||||
|
||||
If you do not have knowledge about it, please refer to the
|
||||
[documentation](${URI_DOC}) (Sorry again, but it is important).
|
||||
|
||||
|
||||
How do you want to set it up manually?
|
||||
msgSinceV02321: >-
|
||||
Since v0.23.21, the self-hosted LiveSync has changed the default behaviour
|
||||
and database structure. The following changes have been made:
|
||||
@@ -874,18 +850,10 @@ moduleMigration:
|
||||
optionEnableBoth: Enable both
|
||||
optionEnableFilenameCaseInsensitive: "Enable only #1"
|
||||
optionEnableFixedRevisionForChunks: "Enable only #2"
|
||||
optionHaveSetupUri: Yes, I have
|
||||
optionKeepPreviousBehaviour: Keep previous behaviour
|
||||
optionManualSetup: Set it up all manually
|
||||
optionNoAskAgain: No, please ask again
|
||||
optionNoSetupUri: No, I do not have
|
||||
optionRemindNextLaunch: Remind me at the next launch
|
||||
optionSetupViaP2P: Use %{short_p2p_sync} to set up
|
||||
optionSetupWizard: Take me into the setup wizard
|
||||
optionYesFetchAgain: Yes, fetch again
|
||||
titleCaseSensitivity: Case Sensitivity
|
||||
titleRecommendSetupUri: Recommendation to use Setup URI
|
||||
titleWelcome: Welcome to Self-hosted LiveSync
|
||||
moduleObsidianMenu:
|
||||
replicate: Replicate
|
||||
More actions: More actions
|
||||
@@ -940,12 +908,10 @@ obsidianLiveSyncSettingTab:
|
||||
btnEnable: Enable
|
||||
btnFix: Fix
|
||||
btnGotItAndUpdated: I got it and updated.
|
||||
btnNext: Next
|
||||
btnStart: Start
|
||||
btnTest: Test
|
||||
btnUse: Use
|
||||
buttonFetch: Fetch
|
||||
buttonNext: Next
|
||||
defaultLanguage: Default
|
||||
descConnectSetupURI: This is the recommended method to set up Self-hosted
|
||||
LiveSync with a Setup URI.
|
||||
@@ -1018,7 +984,6 @@ obsidianLiveSyncSettingTab:
|
||||
msgEnableEncryptionRecommendation: We recommend enabling End-To-End Encryption,
|
||||
and Path Obfuscation. Are you sure you want to continue without encryption?
|
||||
msgFetchConfigFromRemote: Do you want to fetch the config from the remote server?
|
||||
msgGenerateSetupURI: All done! Do you want to generate a setup URI to set up other devices?
|
||||
msgIfConfigNotPersistent: If the server configuration is not persistent (e.g.,
|
||||
running on docker), the values here may change. Once you are able to
|
||||
connect, please update the settings in the server's local.ini.
|
||||
@@ -1098,7 +1063,6 @@ obsidianLiveSyncSettingTab:
|
||||
|
||||
Store only the settings. **Caution: This may lead to data corruption**;
|
||||
database reconstruction is generally necessary.
|
||||
msgSelectAndApplyPreset: Please select and apply any preset item to complete the wizard.
|
||||
msgSetCorsCredentials: Set cors.credentials
|
||||
msgSetCorsOrigins: Set cors.origins
|
||||
msgSetMaxDocSize: Set couchdb.max_document_size
|
||||
@@ -1158,12 +1122,17 @@ obsidianLiveSyncSettingTab:
|
||||
titleActiveRemoteServer: Active Remote Server
|
||||
titleAppearance: Appearance
|
||||
titleConflictResolution: Conflict resolution
|
||||
titleCongratulations: Congratulations!
|
||||
titleCouchDB: CouchDB
|
||||
titleDeletionPropagation: Deletion Propagation
|
||||
titleEncryptionNotEnabled: Encryption is not enabled
|
||||
titleEncryptionPassphraseInvalid: Encryption Passphrase Invalid
|
||||
titleExtraFeatures: Enable extra and advanced features
|
||||
titleExtraFeaturesGroup: Extra features
|
||||
titleExtraMenus: Extra menus
|
||||
titleHelpAndInformation: Help and information
|
||||
titleHelpAndTroubleshooting: Help and troubleshooting
|
||||
titleMaintenanceAndRecovery: Maintenance and recovery
|
||||
titleAdvancedSettings: Advanced settings
|
||||
titleFetchConfig: Fetch Config
|
||||
titleFetchConfigFromRemote: Fetch config from remote server
|
||||
titleFetchSettings: Fetch Settings
|
||||
@@ -1177,7 +1146,8 @@ obsidianLiveSyncSettingTab:
|
||||
titleRemoteConfigCheckFailed: Remote Configuration Check Failed
|
||||
titleRemoteServer: Remote Server
|
||||
titleReset: Reset
|
||||
titleSetupOtherDevices: To setup other devices
|
||||
titleSetupOtherDevices: Set up other devices
|
||||
titleSynchronisation: Synchronisation
|
||||
titleSynchronizationMethod: Synchronization Method
|
||||
titleSynchronizationPreset: Synchronization Preset
|
||||
titleSyncSettings: Sync Settings
|
||||
@@ -1469,8 +1439,8 @@ Replicator:
|
||||
InitialiseFatalError: No replicator is available, this is the fatal error.
|
||||
Pending: Some file events are pending. Replication has been cancelled.
|
||||
SomeModuleFailed: Replication has been cancelled by some module failure
|
||||
VersionUpFlash: An update has been detected. Please open the Settings dialogue
|
||||
and check the Change Log. Replication has been cancelled.
|
||||
VersionUpFlash: Remote synchronisation is paused for compatibility review. Run the
|
||||
'Review why synchronisation is paused' command for details and available actions.
|
||||
Requires restart of Obsidian: Requires restart of Obsidian
|
||||
Requires restart of Obsidian.: Requires restart of Obsidian.
|
||||
Rerun Onboarding Wizard: Rerun Onboarding Wizard
|
||||
@@ -2340,6 +2310,29 @@ Ui:
|
||||
Fetch: Fetch
|
||||
Overwrite: Overwrite
|
||||
SetupWizard:
|
||||
ApplySettingsInitialisation:
|
||||
ApplyWithoutInitialisation: Apply without Initialisation
|
||||
Back: Review another way to apply these settings
|
||||
BypassGuidance: Applying these settings alone can make this device incompatible with its existing synchronisation data. Use this only when you have confirmed that reconstruction is unnecessary.
|
||||
BypassTitle: Apply Settings without Initialisation?
|
||||
ContinueFetch: Continue with Fetch
|
||||
FetchOption: Reset Synchronisation on This Device
|
||||
FetchOptionDesc: After restarting, rebuild this device's local database from the current remote synchronisation data. Files in the Vault will then be reconciled with that data.
|
||||
FetchOptionP2PDesc: After restarting, select an online source device. This device's local LiveSync database will be rebuilt from that source.
|
||||
Guidance: These setting changes alter how synchronisation data is interpreted. Apply them together with an initialisation operation after restart.
|
||||
KeepEditing: Keep Editing
|
||||
ProceedFetch: Restart and Fetch Synchronisation Data
|
||||
ProceedFetchP2P: Restart and Select a Source Device
|
||||
ProceedRebuild: Restart and Overwrite Server Data
|
||||
ProceedRebuildP2P: Restart and Prepare This Device
|
||||
Question: Which existing data should be used after restart?
|
||||
RebuildOption: Overwrite Server Data with This Device's Files
|
||||
RebuildOptionDesc: Rebuild the local and remote databases from the files currently in this Vault. Other synchronising devices must reset their local synchronisation afterwards.
|
||||
RebuildOptionP2P: Prepare This Device from This Vault
|
||||
RebuildOptionP2PDesc: Rebuild this device's local LiveSync database from the files currently in this Vault. This does not overwrite another device.
|
||||
RemoteVerificationGuidance: The configured remote could not be verified with the current credentials and encryption settings. Continuing may make the Fetch fail after restart.
|
||||
RemoteVerificationTitle: Remote Synchronisation Data Could Not Be Verified
|
||||
Title: Apply Settings and Reinitialise Synchronisation
|
||||
Common:
|
||||
Back: No, please take me back
|
||||
Cancel: Cancel
|
||||
|
||||
@@ -751,7 +751,6 @@ moduleLocalDatabase:
|
||||
moduleLog:
|
||||
showLog: Mostrar registro
|
||||
moduleMigration:
|
||||
docUri: https://github.com/vrtmrz/obsidian-livesync/blob/main/README_ES.md#how-to-use
|
||||
logBulkSendCorrupted: El envío de fragmentos en bloque se ha habilitado, sin
|
||||
embargo, esta función se ha corrompido. Disculpe las molestias.
|
||||
Deshabilitado automáticamente.
|
||||
@@ -782,30 +781,6 @@ moduleMigration:
|
||||
|
||||
___Nota2: Los fragmentos son completamente inmutables, solo podemos obtener
|
||||
los metadatos y diferencias.___
|
||||
msgInitialSetup: >-
|
||||
Tu dispositivo **aún no ha sido configurado**. Permíteme guiarte a través
|
||||
del proceso de configuración.
|
||||
|
||||
|
||||
Ten en cuenta que todo el contenido del diálogo se puede copiar al
|
||||
portapapeles. Si necesitas consultarlo más tarde, puedes pegarlo en una nota
|
||||
en Obsidian. También puedes traducirlo a tu idioma utilizando una
|
||||
herramienta de traducción.
|
||||
|
||||
|
||||
Primero, ¿tienes **URI de configuración**?
|
||||
|
||||
|
||||
Nota: Si no sabes qué es, consulta la [documentación](${URI_DOC}).
|
||||
msgRecommendSetupUri: >-
|
||||
Te recomendamos encarecidamente que generes una URI de configuración y la
|
||||
utilices.
|
||||
|
||||
Si no tienes conocimientos al respecto, consulta la
|
||||
[documentación](${URI_DOC}) (Lo siento de nuevo, pero es importante).
|
||||
|
||||
|
||||
¿Cómo quieres configurarlo manualmente?
|
||||
msgSinceV02321: >-
|
||||
Desde la versión v0.23.21, Self-hosted LiveSync ha cambiado el
|
||||
comportamiento predeterminado y la estructura de la base de datos. Se han
|
||||
@@ -838,9 +813,7 @@ moduleMigration:
|
||||
optionEnableBoth: Habilitar ambos
|
||||
optionEnableFilenameCaseInsensitive: "Habilitar solo #1"
|
||||
optionEnableFixedRevisionForChunks: "Habilitar solo #2"
|
||||
optionHaveSetupUri: Sí, tengo
|
||||
optionKeepPreviousBehaviour: Mantener comportamiento anterior
|
||||
optionManualSetup: Configurarlo todo manualmente
|
||||
optionNoAskAgain: No, por favor pregúntame de nuevo
|
||||
fix0256:
|
||||
buttons:
|
||||
@@ -909,14 +882,8 @@ moduleMigration:
|
||||
Nota 2: reconstruir todo y obtener los datos consume algo de tiempo y de
|
||||
tráfico; hazlo en horas de poco uso y con una conexión de red estable.
|
||||
title: ¡Se han encontrado chunks no seguros!
|
||||
optionNoSetupUri: No, no tengo
|
||||
optionRemindNextLaunch: Recordármelo en el próximo inicio
|
||||
optionSetupViaP2P: Usar %{short_p2p_sync} para configurarlo
|
||||
optionSetupWizard: Llévame al asistente de configuración
|
||||
optionYesFetchAgain: Sí, obtener de nuevo
|
||||
titleCaseSensitivity: Distinción de mayúsculas y minúsculas
|
||||
titleRecommendSetupUri: Recomendación de usar un Setup URI
|
||||
titleWelcome: Bienvenido a Self-hosted LiveSync
|
||||
"Mostly Complete: Decision Required": "Casi terminado: se requiere una decisión"
|
||||
My remote server is already set up. I want to join this device.: Mi servidor remoto ya está configurado. Quiero añadir este dispositivo.
|
||||
Name: Nombre
|
||||
@@ -1506,12 +1473,10 @@ obsidianLiveSyncSettingTab:
|
||||
btnEnable: Activar
|
||||
btnFix: Corregir
|
||||
btnGotItAndUpdated: Lo entendí y actualicé.
|
||||
btnNext: Siguiente
|
||||
btnStart: Iniciar
|
||||
btnTest: Probar
|
||||
btnUse: Usar
|
||||
buttonFetch: Obtener
|
||||
buttonNext: Siguiente
|
||||
defaultLanguage: Predeterminado
|
||||
descConnectSetupURI: Este es el método recomendado para configurar Self-hosted
|
||||
LiveSync con una URI de configuración.
|
||||
@@ -1585,8 +1550,6 @@ obsidianLiveSyncSettingTab:
|
||||
a extremo y la obfuscación de ruta. ¿Estás seguro de querer continuar sin
|
||||
cifrado?
|
||||
msgFetchConfigFromRemote: ¿Quieres obtener la configuración del servidor remoto?
|
||||
msgGenerateSetupURI: ¡Todo listo! ¿Quieres generar un URI de configuración para
|
||||
configurar otros dispositivos?
|
||||
msgIfConfigNotPersistent: Si la configuración del servidor no es persistente
|
||||
(por ejemplo, ejecutándose en docker), los valores aquí pueden cambiar. Una
|
||||
vez que puedas conectarte, por favor actualiza las configuraciones en el
|
||||
@@ -1670,8 +1633,6 @@ obsidianLiveSyncSettingTab:
|
||||
|
||||
Almacena solo la configuración. **Precaución: esto puede provocar corrupción
|
||||
de datos**; generalmente es necesario reconstruir la base de datos.
|
||||
msgSelectAndApplyPreset: Por favor, selecciona y aplica cualquier elemento
|
||||
preestablecido para completar el asistente.
|
||||
msgSetCorsCredentials: Configurar cors.credentials
|
||||
msgSetCorsOrigins: Configurar cors.origins
|
||||
msgSetMaxDocSize: Configurar couchdb.max_document_size
|
||||
@@ -1729,7 +1690,6 @@ obsidianLiveSyncSettingTab:
|
||||
panelSetup: Configuración
|
||||
titleAppearance: Apariencia
|
||||
titleConflictResolution: Resolución de conflictos
|
||||
titleCongratulations: ¡Felicidades!
|
||||
titleCouchDB: Servidor CouchDB
|
||||
titleDeletionPropagation: Propagación de eliminación
|
||||
titleEncryptionNotEnabled: El cifrado no está habilitado
|
||||
|
||||
@@ -384,7 +384,6 @@ moduleLocalDatabase:
|
||||
moduleLog:
|
||||
showLog: Afficher le journal
|
||||
moduleMigration:
|
||||
docUri: https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use
|
||||
fix0256:
|
||||
buttons:
|
||||
checkItLater: Vérifier plus tard
|
||||
@@ -482,31 +481,6 @@ moduleMigration:
|
||||
|
||||
___Note 2 : Les fragments sont complètement immuables, nous ne pouvons
|
||||
récupérer que les métadonnées et les différences.___
|
||||
msgInitialSetup: >-
|
||||
Votre appareil n'a **pas encore été configuré**. Laissez-moi vous guider
|
||||
dans le processus de configuration.
|
||||
|
||||
|
||||
Veuillez noter que chaque contenu de boîte de dialogue peut être copié
|
||||
dans le presse-papiers. Si vous souhaitez vous y référer plus tard, vous
|
||||
pouvez le coller dans une note d'Obsidian. Vous pouvez également le
|
||||
traduire dans votre langue via un outil de traduction.
|
||||
|
||||
|
||||
Tout d'abord, disposez-vous d'une **URI de configuration** ?
|
||||
|
||||
|
||||
Note : Si vous ne savez pas ce que c'est, consultez la
|
||||
[documentation](${URI_DOC}).
|
||||
msgRecommendSetupUri: >-
|
||||
Nous recommandons vivement de générer une URI de configuration et de
|
||||
l'utiliser.
|
||||
|
||||
Si vous ne connaissez pas, veuillez consulter la
|
||||
[documentation](${URI_DOC}) (Désolé encore, mais c'est important).
|
||||
|
||||
|
||||
Comment souhaitez-vous effectuer la configuration manuellement ?
|
||||
msgSinceV02321: >-
|
||||
Depuis la v0.23.21, Self-hosted LiveSync a modifié son comportement par
|
||||
défaut et la structure de sa base. Les changements suivants ont été
|
||||
@@ -539,18 +513,10 @@ moduleMigration:
|
||||
optionEnableBoth: Activer les deux
|
||||
optionEnableFilenameCaseInsensitive: "Activer seulement #1"
|
||||
optionEnableFixedRevisionForChunks: "Activer seulement #2"
|
||||
optionHaveSetupUri: Oui, j'en ai une
|
||||
optionKeepPreviousBehaviour: Conserver le comportement précédent
|
||||
optionManualSetup: Tout configurer manuellement
|
||||
optionNoAskAgain: Non, demandez à nouveau
|
||||
optionNoSetupUri: Non, je n'en ai pas
|
||||
optionRemindNextLaunch: Me rappeler au prochain lancement
|
||||
optionSetupViaP2P: Utiliser %{short_p2p_sync} pour configurer
|
||||
optionSetupWizard: Ouvrir l'assistant de configuration
|
||||
optionYesFetchAgain: Oui, récupérer à nouveau
|
||||
titleCaseSensitivity: Sensibilité à la casse
|
||||
titleRecommendSetupUri: Recommandation d'utilisation de l'URI de configuration
|
||||
titleWelcome: Bienvenue dans Self-hosted LiveSync
|
||||
moduleObsidianMenu:
|
||||
replicate: Répliquer
|
||||
Move remotely deleted files to the trash, instead of deleting.: Déplacer les fichiers supprimés à distance vers la corbeille, au lieu de les supprimer.
|
||||
@@ -574,12 +540,10 @@ obsidianLiveSyncSettingTab:
|
||||
btnEnable: Activer
|
||||
btnFix: Corriger
|
||||
btnGotItAndUpdated: J'ai compris et mis à jour.
|
||||
btnNext: Suivant
|
||||
btnStart: Démarrer
|
||||
btnTest: Tester
|
||||
btnUse: Utiliser
|
||||
buttonFetch: Récupérer
|
||||
buttonNext: Suivant
|
||||
defaultLanguage: Par défaut
|
||||
descConnectSetupURI: Méthode recommandée pour configurer Self-hosted LiveSync
|
||||
avec une URI de configuration.
|
||||
@@ -653,7 +617,6 @@ obsidianLiveSyncSettingTab:
|
||||
de bout en bout et l'obfuscation des chemins. Êtes-vous sûr de vouloir
|
||||
continuer sans chiffrement ?
|
||||
msgFetchConfigFromRemote: Voulez-vous récupérer la configuration depuis le serveur distant ?
|
||||
msgGenerateSetupURI: Tout est prêt ! Voulez-vous générer une URI de configuration pour configurer d'autres appareils ?
|
||||
msgIfConfigNotPersistent: Si la configuration du serveur n'est pas persistante
|
||||
(par ex. fonctionnant sur Docker), les valeurs peuvent changer. Une fois la
|
||||
connexion établie, mettez à jour les paramètres dans le local.ini du
|
||||
@@ -736,7 +699,6 @@ obsidianLiveSyncSettingTab:
|
||||
Ne stocker que les paramètres. **Attention : cela peut entraîner une
|
||||
corruption des données** ; une reconstruction de la base est généralement
|
||||
nécessaire.
|
||||
msgSelectAndApplyPreset: Veuillez sélectionner et appliquer un préréglage pour terminer l'assistant.
|
||||
msgSetCorsCredentials: Définir cors.credentials
|
||||
msgSetCorsOrigins: Définir cors.origins
|
||||
msgSetMaxDocSize: Définir couchdb.max_document_size
|
||||
@@ -797,7 +759,6 @@ obsidianLiveSyncSettingTab:
|
||||
titleActiveRemoteServer: Serveur distant actif
|
||||
titleAppearance: Apparence
|
||||
titleConflictResolution: Résolution des conflits
|
||||
titleCongratulations: Félicitations !
|
||||
titleCouchDB: CouchDB
|
||||
titleDeletionPropagation: Propagation des suppressions
|
||||
titleEncryptionNotEnabled: Le chiffrement n'est pas activé
|
||||
|
||||
@@ -344,7 +344,6 @@ moduleLocalDatabase:
|
||||
moduleLog:
|
||||
showLog: הצג יומן
|
||||
moduleMigration:
|
||||
docUri: https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use
|
||||
fix0256:
|
||||
buttons:
|
||||
checkItLater: בדוק מאוחר יותר
|
||||
@@ -430,25 +429,6 @@ moduleMigration:
|
||||
|
||||
___הערה 2: הנתחים הם בלתי-ניתנים לשינוי לחלוטין, ניתן למשוך רק את המטה-נתונים
|
||||
וההפרש.___
|
||||
msgInitialSetup: >-
|
||||
המכשיר שלך **טרם הוגדר**. אנחנו כאן לעזור לך בתהליך ההגדרה.
|
||||
|
||||
|
||||
שים לב שניתן להעתיק את תוכן כל דיאלוג ללוח. אם צריך לחזור אליו מאוחר יותר,
|
||||
ניתן להדביק אותו כפתק ב-Obsidian. ניתן גם לתרגם לשפתך בעזרת כלי תרגום.
|
||||
|
||||
|
||||
ראשית, האם יש לך **Setup URI**?
|
||||
|
||||
|
||||
הערה: אם אינך יודע מהו, אנא עיין ב[תיעוד](${URI_DOC}).
|
||||
msgRecommendSetupUri: >-
|
||||
אנו ממליצים בחום לייצר Setup URI ולהשתמש בו.
|
||||
|
||||
אם אין לך ידע בנושא, אנא עיין ב[תיעוד](${URI_DOC}) (מתנצלים שוב, אך זה חשוב).
|
||||
|
||||
|
||||
כיצד ברצונך להגדיר ידנית?
|
||||
msgSinceV02321: >-
|
||||
מאז גרסה 0.23.21, Self-hosted LiveSync שינה את התנהגות ברירת המחדל ומבנה מסד
|
||||
הנתונים. השינויים הבאים בוצעו:
|
||||
@@ -478,18 +458,10 @@ moduleMigration:
|
||||
optionEnableBoth: הפעל את שניהם
|
||||
optionEnableFilenameCaseInsensitive: "הפעל רק #1"
|
||||
optionEnableFixedRevisionForChunks: "הפעל רק #2"
|
||||
optionHaveSetupUri: כן, יש לי
|
||||
optionKeepPreviousBehaviour: שמור על התנהגות קודמת
|
||||
optionManualSetup: הגדר הכל ידנית
|
||||
optionNoAskAgain: לא, אנא שאל שוב
|
||||
optionNoSetupUri: לא, אין לי
|
||||
optionRemindNextLaunch: הזכר לי בהפעלה הבאה
|
||||
optionSetupViaP2P: השתמש ב-%{short_p2p_sync} להגדרה
|
||||
optionSetupWizard: קח אותי לאשף ההגדרה
|
||||
optionYesFetchAgain: כן, משוך שוב
|
||||
titleCaseSensitivity: תלות רישיות
|
||||
titleRecommendSetupUri: המלצה לשימוש ב-Setup URI
|
||||
titleWelcome: ברוך הבא ל-Self-hosted LiveSync
|
||||
moduleObsidianMenu:
|
||||
replicate: שכפל
|
||||
Move remotely deleted files to the trash, instead of deleting.: העבר קבצים שנמחקו מרחוק לאשפה, במקום למחוק.
|
||||
@@ -512,12 +484,10 @@ obsidianLiveSyncSettingTab:
|
||||
btnEnable: הפעל
|
||||
btnFix: תקן
|
||||
btnGotItAndUpdated: הבנתי ועדכנתי.
|
||||
btnNext: הבא
|
||||
btnStart: התחל
|
||||
btnTest: בדוק
|
||||
btnUse: השתמש
|
||||
buttonFetch: משוך
|
||||
buttonNext: הבא
|
||||
defaultLanguage: ברירת מחדל
|
||||
descConnectSetupURI: זוהי השיטה המומלצת להגדרת Self-hosted LiveSync עם Setup URI.
|
||||
descCopySetupURI: מושלם להגדרת מכשיר חדש!
|
||||
@@ -586,7 +556,6 @@ obsidianLiveSyncSettingTab:
|
||||
msgEnableEncryptionRecommendation: אנו ממליצים להפעיל הצפנה מקצה לקצה ואת ערפול
|
||||
הנתיב. האם אתה בטוח שברצונך להמשיך ללא הצפנה?
|
||||
msgFetchConfigFromRemote: האם ברצונך למשוך את התצורה מהשרת המרוחד?
|
||||
msgGenerateSetupURI: הכל מוכן! האם ברצונך לייצר Setup URI להגדרת מכשירים אחרים?
|
||||
msgIfConfigNotPersistent: אם תצורת השרת אינה קבועה (למשל, פועלת ב-docker), הערכים
|
||||
כאן עשויים להשתנות. לאחר שתצליח להתחבר, אנא עדכן את ההגדרות ב-local.ini של השרת.
|
||||
msgInvalidPassphrase: ביטוי הסיסמה להצפנה שלך עשוי להיות לא תקין. האם אתה בטוח
|
||||
@@ -659,7 +628,6 @@ obsidianLiveSyncSettingTab:
|
||||
|
||||
שמור רק את ההגדרות. **זהירות: עלול לגרום לפגיעה בנתונים**; בנייה מחדש של מסד
|
||||
הנתונים נדרשת בדרך כלל.
|
||||
msgSelectAndApplyPreset: אנא בחר והחל פריט קבוע מראש כלשהו להשלמת האשף.
|
||||
msgSetCorsCredentials: הגדר cors.credentials
|
||||
msgSetCorsOrigins: הגדר cors.origins
|
||||
msgSetMaxDocSize: הגדר couchdb.max_document_size
|
||||
@@ -718,7 +686,6 @@ obsidianLiveSyncSettingTab:
|
||||
titleActiveRemoteServer: שרת מרוחד פעיל
|
||||
titleAppearance: מראה
|
||||
titleConflictResolution: פתרון קונפליקטים
|
||||
titleCongratulations: מזל טוב!
|
||||
titleCouchDB: CouchDB
|
||||
titleDeletionPropagation: הפצת מחיקות
|
||||
titleEncryptionNotEnabled: ההצפנה אינה מופעלת
|
||||
|
||||
@@ -356,7 +356,6 @@ moduleLocalDatabase:
|
||||
moduleLog:
|
||||
showLog: ログを表示
|
||||
moduleMigration:
|
||||
docUri: https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use
|
||||
fix0256:
|
||||
buttons:
|
||||
checkItLater: 後で確認する
|
||||
@@ -416,19 +415,6 @@ moduleMigration:
|
||||
|
||||
___注意: 設定が変更され、データベースが再フェッチされるまで同期できません。___
|
||||
___注意2: チャンクは完全に不変なので、メタデータと差分のみフェッチできます。___
|
||||
msgInitialSetup: |-
|
||||
このデバイスは**まだセットアップされていません**。セットアッププロセスをご案内します。
|
||||
|
||||
すべてのダイアログの内容はクリップボードにコピーできます。後で参照する必要があれば、Obsidianのノートに貼り付けてください。翻訳ツールを使ってお使いの言語に翻訳することもできます。
|
||||
|
||||
まず、**セットアップURI**をお持ちですか?
|
||||
|
||||
注意: それが何か分からない場合は、[documentation](${URI_DOC})を参照してください。
|
||||
msgRecommendSetupUri: |-
|
||||
セットアップURIを生成して使用することを強くお勧めします。
|
||||
これについて知識がない場合は、[documentation](${URI_DOC})を参照してください(重要です)。
|
||||
|
||||
手動でセットアップしますか?
|
||||
msgSinceV02321: |-
|
||||
v0.23.21以降、self-hosted LiveSyncはデフォルトの動作とデータベース構造を変更しました。以下の変更が行われました:
|
||||
|
||||
@@ -449,18 +435,10 @@ moduleMigration:
|
||||
optionEnableBoth: 両方を有効にする
|
||||
optionEnableFilenameCaseInsensitive: "#1のみ有効にする"
|
||||
optionEnableFixedRevisionForChunks: "#2のみ有効にする"
|
||||
optionHaveSetupUri: はい、持っています
|
||||
optionKeepPreviousBehaviour: 以前の動作を維持
|
||||
optionManualSetup: すべて手動でセットアップ
|
||||
optionNoAskAgain: いいえ、後で確認する
|
||||
optionNoSetupUri: いいえ、持っていません
|
||||
optionRemindNextLaunch: 次回起動時にリマインド
|
||||
optionSetupViaP2P: "%{short_p2p_sync}を使ってセットアップ"
|
||||
optionSetupWizard: セットアップウィザードへ
|
||||
optionYesFetchAgain: はい、再フェッチする
|
||||
titleCaseSensitivity: 大文字小文字の区別
|
||||
titleRecommendSetupUri: セットアップURIの使用を推奨
|
||||
titleWelcome: Self-hosted LiveSyncへようこそ
|
||||
moduleObsidianMenu:
|
||||
replicate: レプリケート
|
||||
More actions: その他の操作
|
||||
@@ -486,12 +464,10 @@ obsidianLiveSyncSettingTab:
|
||||
btnEnable: 有効化
|
||||
btnFix: 修正
|
||||
btnGotItAndUpdated: 理解しました、更新しました。
|
||||
btnNext: 次へ
|
||||
btnStart: 開始
|
||||
btnTest: テスト
|
||||
btnUse: 使用
|
||||
buttonFetch: フェッチ
|
||||
buttonNext: 次へ
|
||||
defaultLanguage: デフォルト
|
||||
descConnectSetupURI: セットアップURIを使用してSelf-hosted LiveSyncをセットアップする推奨方法です。
|
||||
descCopySetupURI: 新しいデバイスのセットアップにおすすめ!
|
||||
@@ -556,7 +532,6 @@ obsidianLiveSyncSettingTab:
|
||||
msgEnableCorsChttpd: chttpd.enable_corsを設定
|
||||
msgEnableEncryptionRecommendation: エンドツーエンド暗号化とパス難読化を有効にすることをお勧めします。暗号化なしで続行してもよろしいですか?
|
||||
msgFetchConfigFromRemote: リモートサーバーから設定を取得しますか?
|
||||
msgGenerateSetupURI: 完了!他のデバイスをセットアップするためのセットアップURIを生成しますか?
|
||||
msgIfConfigNotPersistent: "サーバー設定が永続的でない場合(例:
|
||||
Dockerで実行中)、ここの値は変更される可能性があります。接続できるようになったら、サーバーのlocal.iniの設定を更新してください。"
|
||||
msgInvalidPassphrase: 暗号化パスフレーズが無効かもしれません。続行してもよろしいですか?
|
||||
@@ -599,7 +574,6 @@ obsidianLiveSyncSettingTab:
|
||||
リモートデータベースを再構築した場合も含まれます。
|
||||
## ${OPTION_ONLY_SETTING}
|
||||
設定のみを保存します。**注意: データ破損につながる可能性があります**。通常、データベースの再構築が必要です。
|
||||
msgSelectAndApplyPreset: ウィザードを完了するには、プリセット項目を選択して適用してください。
|
||||
msgSetCorsCredentials: cors.credentialsを設定
|
||||
msgSetCorsOrigins: cors.originsを設定
|
||||
msgSetMaxDocSize: couchdb.max_document_sizeを設定
|
||||
@@ -656,7 +630,6 @@ obsidianLiveSyncSettingTab:
|
||||
titleActiveRemoteServer: アクティブなリモートサーバー
|
||||
titleAppearance: 外観
|
||||
titleConflictResolution: 競合解決
|
||||
titleCongratulations: おめでとうございます!
|
||||
titleCouchDB: CouchDB サーバー
|
||||
titleDeletionPropagation: 削除の伝播
|
||||
titleEncryptionNotEnabled: 暗号化が有効になっていません
|
||||
|
||||
@@ -571,7 +571,6 @@ moduleLocalDatabase:
|
||||
moduleLog:
|
||||
showLog: 로그 표시
|
||||
moduleMigration:
|
||||
docUri: https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use
|
||||
fix0256:
|
||||
buttons:
|
||||
checkItLater: 나중에 확인
|
||||
@@ -631,19 +630,6 @@ moduleMigration:
|
||||
|
||||
___참고: 설정이 변경되고 데이터베이스를 다시 불러오기 전까지는 동기화가 불가능합니다.___
|
||||
___참고2: 청크는 변경이 불가능한 구조이므로, 메타데이터와 차이점만 가져올 수 있습니다.___
|
||||
msgInitialSetup: |-
|
||||
이 기기는 **아직 초기 설정이 완료되지 않았습니다**. 지금부터 설정 과정을 안내해 드리겠습니다.
|
||||
|
||||
모든 대화 내용은 클립보드에 복사할 수 있습니다. 나중에 참고하려면 Obsidian 노트에 붙여넣거나 번역 도구를 활용해 번역하셔도 됩니다.
|
||||
|
||||
먼저, **Setup URI**를 가지고 계신가요?
|
||||
|
||||
참고: Setup URI가 무엇인지 잘 모르시겠다면 [문서](${URI_DOC})를 참고해 주세요.
|
||||
msgRecommendSetupUri: |-
|
||||
Setup URI를 생성해 사용하는 것을 강력히 권장합니다.
|
||||
Setup URI가 무엇인지 잘 모르시겠다면 [문서](${URI_DOC})를 참고해 주세요. 중요한 내용이니 꼭 확인하시기 바랍니다.
|
||||
|
||||
직접 수동 설정을 진행하시겠습니까?
|
||||
msgSinceV02321: |-
|
||||
v0.23.21부터 Self-hosted LiveSync의 기본 동작 방식과 데이터베이스 구조가 변경되었습니다. 변경 내용은 다음과 같습니다:
|
||||
|
||||
@@ -664,18 +650,10 @@ moduleMigration:
|
||||
optionEnableBoth: 둘 다 활성화
|
||||
optionEnableFilenameCaseInsensitive: "#1만 활성화"
|
||||
optionEnableFixedRevisionForChunks: "#2만 활성화"
|
||||
optionHaveSetupUri: 예, 있습니다
|
||||
optionKeepPreviousBehaviour: 이전 동작 유지
|
||||
optionManualSetup: 모든 것을 수동으로 설정
|
||||
optionNoAskAgain: 아니요 (나중에 다시 물어보기)
|
||||
optionNoSetupUri: 아니요, 없습니다
|
||||
optionRemindNextLaunch: 다음 시작 시 알림
|
||||
optionSetupViaP2P: "%{short_p2p_sync}를 사용하여 설정"
|
||||
optionSetupWizard: 설정 마법사로 안내
|
||||
optionYesFetchAgain: 예 (다시 가져오기)
|
||||
titleCaseSensitivity: 대소문자 구분
|
||||
titleRecommendSetupUri: Setup URI 사용 권장
|
||||
titleWelcome: Self-hosted LiveSync에 오신 것을 환영합니다
|
||||
moduleObsidianMenu:
|
||||
replicate: 복제
|
||||
More actions: 추가 작업
|
||||
@@ -722,12 +700,10 @@ obsidianLiveSyncSettingTab:
|
||||
btnEnable: 활성화
|
||||
btnFix: 수정
|
||||
btnGotItAndUpdated: 알겠습니다. 업데이트했습니다.
|
||||
btnNext: 다음
|
||||
btnStart: 시작
|
||||
btnTest: 테스트
|
||||
btnUse: 사용
|
||||
buttonFetch: 가져오기
|
||||
buttonNext: 다음
|
||||
defaultLanguage: 기본값
|
||||
descConnectSetupURI: 이것은 Setup URI로 Self-hosted LiveSync를 설정하는 권장 방법입니다.
|
||||
descCopySetupURI: 새 기기 설정에 완벽합니다!
|
||||
@@ -793,7 +769,6 @@ obsidianLiveSyncSettingTab:
|
||||
msgEnableCorsChttpd: chttpd.enable_cors 설정
|
||||
msgEnableEncryptionRecommendation: 종단 간 암호화와 경로 난독화를 활성화하는 것을 권장합니다. 정말로 암호화 없이 계속하시겠습니까?
|
||||
msgFetchConfigFromRemote: 원격 서버에서 구성을 가져오시겠습니까?
|
||||
msgGenerateSetupURI: 모든 작업이 완료되었습니다! 다른 기기를 설정하기 위해 Setup URI를 생성하시겠습니까?
|
||||
msgIfConfigNotPersistent: "서버 설정이 영구적으로 저장되지 않는 환경(예: Docker에서 실행 중)에서는 이곳의 값들이 변경될 수 있습니다. 연결이 가능해지면 서버의 local.ini 파일에서 설정을 수동으로 업데이트해 주세요."
|
||||
msgInvalidPassphrase: 암호화 패스프레이즈가 유효하지 않을 수 있습니다. 정말로 계속하시겠습니까?
|
||||
msgNewVersionNote: 업그레이드 알림으로 여기에 오셨나요? 버전 기록을 검토해 주세요. 만족하신다면 버튼을 클릭하세요. 새로운 업데이트 시 다시 안내됩니다.
|
||||
@@ -835,7 +810,6 @@ obsidianLiveSyncSettingTab:
|
||||
원격 데이터베이스를 이미 재구축한 경우도 여기에 해당합니다.
|
||||
## ${OPTION_ONLY_SETTING}
|
||||
설정만 저장합니다. **주의: 데이터가 손상될 수 있습니다.** 일반적으로는 데이터베이스 재구축이 필요합니다.
|
||||
msgSelectAndApplyPreset: 마법사를 완료하려면 프리셋 항목을 선택하고 적용해 주세요.
|
||||
msgSetCorsCredentials: cors.credentials 설정
|
||||
msgSetCorsOrigins: cors.origins 설정
|
||||
msgSetMaxDocSize: couchdb.max_document_size 설정
|
||||
@@ -892,7 +866,6 @@ obsidianLiveSyncSettingTab:
|
||||
titleActiveRemoteServer: 활성 원격 서버
|
||||
titleAppearance: 모양
|
||||
titleConflictResolution: 충돌 해결
|
||||
titleCongratulations: 축하합니다!
|
||||
titleCouchDB: CouchDB
|
||||
titleDeletionPropagation: 삭제 전파
|
||||
titleEncryptionNotEnabled: 암호화가 활성화되지 않음
|
||||
|
||||
@@ -413,7 +413,6 @@ moduleLocalDatabase:
|
||||
moduleLog:
|
||||
showLog: Показать лог
|
||||
moduleMigration:
|
||||
docUri: https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use
|
||||
fix0256:
|
||||
buttons:
|
||||
checkItLater: Проверить позже
|
||||
@@ -441,8 +440,6 @@ moduleMigration:
|
||||
logSetupCancelled: Настройка отменена, Self-hosted LiveSync ожидает вашей настройки!
|
||||
msgFetchRemoteAgain: Удалённая база данных, похоже, уже была мигрирована.
|
||||
Конфигурация этого устройства несовместима.
|
||||
msgInitialSetup: Ваше устройство ещё не настроено. У вас есть Setup URI?
|
||||
msgRecommendSetupUri: Мы рекомендуем сгенерировать Setup URI.
|
||||
msgSinceV02321: Начиная с v0.23.21, self-hosted LiveSync изменил поведение и
|
||||
структуру базы данных.
|
||||
optionAdjustRemote: Настроить под удалённую
|
||||
@@ -450,18 +447,10 @@ moduleMigration:
|
||||
optionEnableBoth: Включить оба
|
||||
optionEnableFilenameCaseInsensitive: "Включить только #1"
|
||||
optionEnableFixedRevisionForChunks: "Включить только #2"
|
||||
optionHaveSetupUri: Да, есть
|
||||
optionKeepPreviousBehaviour: Сохранить предыдущее поведение
|
||||
optionManualSetup: Настроить всё вручную
|
||||
optionNoAskAgain: Нет, спросить снова
|
||||
optionNoSetupUri: Нет, нет
|
||||
optionRemindNextLaunch: Напомнить при следующем запуске
|
||||
optionSetupViaP2P: Использовать short_p2p_sync для настройки
|
||||
optionSetupWizard: Перейти в мастер настройки
|
||||
optionYesFetchAgain: Да, загрузить снова
|
||||
titleCaseSensitivity: Чувствительность к регистру
|
||||
titleRecommendSetupUri: Рекомендация использовать Setup URI
|
||||
titleWelcome: Добро пожаловать в Self-hosted LiveSync
|
||||
moduleObsidianMenu:
|
||||
replicate: Реплицировать
|
||||
More actions: Другие действия
|
||||
@@ -492,12 +481,10 @@ obsidianLiveSyncSettingTab:
|
||||
btnEnable: Включить
|
||||
btnFix: Исправить
|
||||
btnGotItAndUpdated: Понял и обновил.
|
||||
btnNext: Далее
|
||||
btnStart: Старт
|
||||
btnTest: Тест
|
||||
btnUse: Использовать
|
||||
buttonFetch: Загрузить
|
||||
buttonNext: Далее
|
||||
defaultLanguage: По умолчанию
|
||||
descConnectSetupURI: Это рекомендуемый способ настройки Self-hosted LiveSync с помощью Setup URI.
|
||||
descCopySetupURI: Идеально подходит для настройки нового устройства!
|
||||
@@ -562,7 +549,6 @@ obsidianLiveSyncSettingTab:
|
||||
msgEnableEncryptionRecommendation: Мы рекомендуем включить сквозное шифрование.
|
||||
Вы уверены, что хотите продолжить без шифрования?
|
||||
msgFetchConfigFromRemote: Вы хотите загрузить конфигурацию с удалённого сервера?
|
||||
msgGenerateSetupURI: Всё готово! Вы хотите сгенерировать Setup URI для настройки других устройств?
|
||||
msgIfConfigNotPersistent: Если конфигурация сервера непостоянна, значения здесь могут измениться.
|
||||
msgInvalidPassphrase: Ваша парольная фраза шифрования может быть недействительна.
|
||||
msgNewVersionNote: Вы пришли из-за уведомления об обновлении? Просмотрите историю версий.
|
||||
@@ -572,7 +558,6 @@ obsidianLiveSyncSettingTab:
|
||||
msgObjectStorageWarning: "ПРЕДУПРЕЖДЕНИЕ: Эта функция в разработке."
|
||||
msgOriginCheck: "Проверка origin: org"
|
||||
msgRebuildRequired: Требуется перестроение баз данных для применения изменений.
|
||||
msgSelectAndApplyPreset: Выберите и примените любой пресет для завершения мастера.
|
||||
msgSetCorsCredentials: Установить cors.credentials
|
||||
msgSetCorsOrigins: Установить cors.origins
|
||||
msgSetMaxDocSize: Установить couchdb.max_document_size
|
||||
@@ -629,7 +614,6 @@ obsidianLiveSyncSettingTab:
|
||||
titleActiveRemoteServer: Активный удалённый сервер
|
||||
titleAppearance: Внешний вид
|
||||
titleConflictResolution: Разрешение конфликтов
|
||||
titleCongratulations: Поздравляем!
|
||||
titleCouchDB: Сервер CouchDB
|
||||
titleDeletionPropagation: Распространение удалений
|
||||
titleEncryptionNotEnabled: Шифрование не включено
|
||||
|
||||
+1412
-14
File diff suppressed because it is too large
Load Diff
@@ -358,7 +358,6 @@ moduleLocalDatabase:
|
||||
moduleLog:
|
||||
showLog: 显示日志
|
||||
moduleMigration:
|
||||
docUri: https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/zh/README_zh.md#%E5%A6%82%E4%BD%95%E4%BD%BF%E7%94%A8
|
||||
fix0256:
|
||||
buttons:
|
||||
checkItLater: 稍后检查
|
||||
@@ -420,19 +419,6 @@ moduleMigration:
|
||||
|
||||
___注意:在更改配置并再次获取数据库之前,我们无法进行同步。___
|
||||
___注意2:chunks 是完全不可变的,我们只能获取元数据和差异
|
||||
msgInitialSetup: |-
|
||||
您的设备**尚未设置**。让我引导您完成设置过程。
|
||||
|
||||
请记住,每个对话框内容都可以复制到剪贴板。如果以后需要参考,可以将其粘贴到 Obsidian 的笔记中。您也可以使用翻译工具将其翻译成您的语言。
|
||||
|
||||
首先,您有**设置 URI** 吗?
|
||||
|
||||
注意:如果您不知道这是什么,请参阅[文档](${URI_DOC})
|
||||
msgRecommendSetupUri: |-
|
||||
我们强烈建议您生成一个设置 URI 并使用它。
|
||||
如果您对此不了解,请参阅[文档](${URI_DOC})(再次抱歉,但这很重要)。
|
||||
|
||||
您想如何手动设置?
|
||||
msgSinceV02321: |-
|
||||
自 v0.23.21 起,Self-hosted LiveSync 更改了默认行为和数据库结构。进行了以下更改:
|
||||
|
||||
@@ -453,18 +439,10 @@ moduleMigration:
|
||||
optionEnableBoth: 启用两者
|
||||
optionEnableFilenameCaseInsensitive: "仅启用 #1"
|
||||
optionEnableFixedRevisionForChunks: "仅启用 #2"
|
||||
optionHaveSetupUri: 是的,我有
|
||||
optionKeepPreviousBehaviour: 保持以前的行为
|
||||
optionManualSetup: 全部手动设置
|
||||
optionNoAskAgain: 不,请稍后再次询问
|
||||
optionNoSetupUri: 不,我没有
|
||||
optionRemindNextLaunch: 下次启动时提醒我
|
||||
optionSetupViaP2P: Use %{short_p2p_sync} to set up
|
||||
optionSetupWizard: 带我进入设置向导
|
||||
optionYesFetchAgain: 是的,再次获取
|
||||
titleCaseSensitivity: 大小写敏感性
|
||||
titleRecommendSetupUri: 推荐使用设置 URI
|
||||
titleWelcome: 欢迎使用 Self-hosted LiveSync
|
||||
moduleObsidianMenu:
|
||||
replicate: 复制
|
||||
More actions: 更多操作
|
||||
@@ -490,12 +468,10 @@ obsidianLiveSyncSettingTab:
|
||||
btnEnable: 启用
|
||||
btnFix: 修复
|
||||
btnGotItAndUpdated: 我明白了并且已更新
|
||||
btnNext: 下一步
|
||||
btnStart: 开始
|
||||
btnTest: 测试
|
||||
btnUse: 使用
|
||||
buttonFetch: 获取
|
||||
buttonNext: 下一步
|
||||
defaultLanguage: 默认语言
|
||||
descConnectSetupURI: 这是使用设置 URI 设置 Self-hosted LiveSync 的推荐方法
|
||||
descCopySetupURI: 非常适合设置新设备!
|
||||
@@ -560,7 +536,6 @@ obsidianLiveSyncSettingTab:
|
||||
msgEnableCorsChttpd: 设置 chttpd.enable_cors
|
||||
msgEnableEncryptionRecommendation: 建议启用端到端加密和路径混淆。你确定要在未加密的情况下继续吗?
|
||||
msgFetchConfigFromRemote: 要从远端服务器获取配置吗?
|
||||
msgGenerateSetupURI: 全部完成!要生成设置 URI 以便配置其他设备吗?
|
||||
msgIfConfigNotPersistent: 如果服务器配置不是持久的(例如,在 docker 上运行),此处的值可能会更改。一旦能够连接,请更新服务器 local.ini 中的设置
|
||||
msgInvalidPassphrase: 你的加密密码短语可能无效。你确定要继续吗?
|
||||
msgNewVersionNote: 因为升级通知来到这里?请查看版本历史。如果您满意,请点击按钮。新的更新将再次提示此信息
|
||||
@@ -602,7 +577,6 @@ obsidianLiveSyncSettingTab:
|
||||
这种情况包括您已经重建了远程数据库的情况。
|
||||
## ${OPTION_ONLY_SETTING}
|
||||
仅存储设置。**注意:这可能导致数据损坏**;通常需要重建数据库
|
||||
msgSelectAndApplyPreset: 请选择并应用任一预设项以完成向导。
|
||||
msgSetCorsCredentials: 设置 cors.credentials
|
||||
msgSetCorsOrigins: 设置 cors.origins
|
||||
msgSetMaxDocSize: 设置 couchdb.max_document_size
|
||||
@@ -659,7 +633,6 @@ obsidianLiveSyncSettingTab:
|
||||
titleActiveRemoteServer: 活动远程服务器
|
||||
titleAppearance: 外观
|
||||
titleConflictResolution: 冲突处理
|
||||
titleCongratulations: 恭喜!
|
||||
titleCouchDB: CouchDB 服务器
|
||||
titleDeletionPropagation: 删除传播
|
||||
titleEncryptionNotEnabled: 尚未启用加密
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Run a finite operation with a flow-owned remote resource and release it
|
||||
* after either success or failure.
|
||||
*
|
||||
* Resource implementations make `dispose()` idempotent. This helper makes the
|
||||
* caller's ownership boundary explicit and prevents finite flows from leaking
|
||||
* a provider-owned resource when their operation rejects.
|
||||
*/
|
||||
export async function withOwnedRemoteResource<TResource extends { dispose(): Promise<void> }, TResult>(
|
||||
resource: TResource,
|
||||
operation: (ownedResource: TResource) => Promise<TResult>
|
||||
): Promise<TResult> {
|
||||
try {
|
||||
return await operation(resource);
|
||||
} finally {
|
||||
await resource.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withOwnedRemoteResource } from "./ownedRemoteResource";
|
||||
|
||||
describe("flow-owned remote resources", () => {
|
||||
it("disposes a resource after a successful finite operation", async () => {
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const resource = { dispose };
|
||||
|
||||
await expect(
|
||||
withOwnedRemoteResource(resource, async (owned) => (owned === resource ? "done" : "wrong"))
|
||||
).resolves.toBe("done");
|
||||
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("disposes a resource when the finite operation rejects", async () => {
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const error = new Error("resource operation failed");
|
||||
|
||||
await expect(
|
||||
withOwnedRemoteResource({ dispose }, async () => {
|
||||
throw error;
|
||||
})
|
||||
).rejects.toBe(error);
|
||||
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
type EndpointProjection = readonly [kind: "url" | "invalid-url", value: string];
|
||||
|
||||
function projectEndpoint(value: string): EndpointProjection {
|
||||
try {
|
||||
const endpoint = new URL(value);
|
||||
endpoint.hash = "";
|
||||
endpoint.searchParams.sort();
|
||||
while (endpoint.pathname.length > 1 && endpoint.pathname.endsWith("/")) {
|
||||
endpoint.pathname = endpoint.pathname.slice(0, -1);
|
||||
}
|
||||
return ["url", endpoint.toString()];
|
||||
} catch {
|
||||
return ["invalid-url", value];
|
||||
}
|
||||
}
|
||||
|
||||
function projectHeaders(value: string): readonly (readonly [name: string, value: string])[] {
|
||||
const headers = new Map<string, string>();
|
||||
for (const line of value.split("\n")) {
|
||||
const [name, headerValue] = line.split(":", 2).map((part) => part.trim());
|
||||
if (name && headerValue) {
|
||||
headers.set(name, headerValue);
|
||||
}
|
||||
}
|
||||
return [...headers.entries()].sort(([leftName, leftValue], [rightName, rightValue]) => {
|
||||
const nameOrder = leftName.localeCompare(rightName);
|
||||
return nameOrder || leftValue.localeCompare(rightValue);
|
||||
});
|
||||
}
|
||||
|
||||
function projectRemoteSecurity(settings: RemoteDBSettings) {
|
||||
return settings.encrypt
|
||||
? ([
|
||||
"encrypted",
|
||||
settings.passphrase,
|
||||
settings.useDynamicIterationCount,
|
||||
settings.E2EEAlgorithm,
|
||||
settings.permitEmptyPassphrase,
|
||||
] as const)
|
||||
: (["plain"] as const);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the effective CouchDB connection settings to a private comparison identity.
|
||||
* The returned value can contain credentials and must not be logged, persisted, or displayed.
|
||||
*/
|
||||
export function getCouchDBReplicatorConfigurationIdentity(settings: RemoteDBSettings): string {
|
||||
const authentication = settings.useJWT
|
||||
? ([
|
||||
"jwt",
|
||||
settings.jwtAlgorithm,
|
||||
settings.jwtKey,
|
||||
settings.jwtKid,
|
||||
settings.jwtSub,
|
||||
settings.jwtExpDuration,
|
||||
] as const)
|
||||
: (["basic", settings.couchDB_USER, settings.couchDB_PASSWORD] as const);
|
||||
return JSON.stringify([
|
||||
"couchdb",
|
||||
projectEndpoint(settings.couchDB_URI),
|
||||
settings.couchDB_DBNAME,
|
||||
authentication,
|
||||
projectHeaders(settings.couchDB_CustomHeaders),
|
||||
settings.useRequestAPI,
|
||||
settings.disableRequestURI,
|
||||
projectRemoteSecurity(settings),
|
||||
settings.enableCompression,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the effective Object Storage connection settings to a private comparison identity.
|
||||
* The returned value can contain credentials and must not be logged, persisted, or displayed.
|
||||
*/
|
||||
export function getObjectStorageReplicatorConfigurationIdentity(settings: RemoteDBSettings): string {
|
||||
return JSON.stringify([
|
||||
"s3",
|
||||
projectEndpoint(settings.endpoint),
|
||||
settings.bucket,
|
||||
settings.bucketPrefix,
|
||||
settings.region,
|
||||
settings.accessKey,
|
||||
settings.secretKey,
|
||||
settings.forcePathStyle,
|
||||
settings.useCustomRequestHandler,
|
||||
projectHeaders(settings.bucketCustomHeaders),
|
||||
projectRemoteSecurity(settings),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import {
|
||||
getCouchDBReplicatorConfigurationIdentity,
|
||||
getObjectStorageReplicatorConfigurationIdentity,
|
||||
} from "./replicatorConfigurationIdentity";
|
||||
|
||||
describe("active Replicator configuration identity", () => {
|
||||
function configuredSettings(overrides: Partial<ObsidianLiveSyncSettings> = {}): ObsidianLiveSyncSettings {
|
||||
return Object.assign(createNewVaultSettings(), {
|
||||
activeConfigurationId: "profile-a",
|
||||
couchDB_URI: "https://couch.example.test/base",
|
||||
couchDB_USER: "alice",
|
||||
couchDB_PASSWORD: "secret-a",
|
||||
couchDB_DBNAME: "vault",
|
||||
couchDB_CustomHeaders: "X-Second: two\nX-First: one",
|
||||
endpoint: "https://objects.example.test/base",
|
||||
accessKey: "alice",
|
||||
secretKey: "secret-a",
|
||||
bucket: "vault",
|
||||
bucketPrefix: "notes/",
|
||||
region: "auto",
|
||||
bucketCustomHeaders: "X-Second: two\nX-First: one",
|
||||
encrypt: true,
|
||||
passphrase: "encryption-a",
|
||||
useDynamicIterationCount: false,
|
||||
permitEmptyPassphrase: false,
|
||||
enableCompression: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
["couchDB_URI", "https://other.example.test/base"],
|
||||
["couchDB_DBNAME", "other-vault"],
|
||||
["couchDB_USER", "bob"],
|
||||
["couchDB_PASSWORD", "secret-b"],
|
||||
["couchDB_CustomHeaders", "X-First: changed"],
|
||||
["useRequestAPI", true],
|
||||
["disableRequestURI", true],
|
||||
["encrypt", false],
|
||||
["passphrase", "encryption-b"],
|
||||
["useDynamicIterationCount", true],
|
||||
["E2EEAlgorithm", ""],
|
||||
["permitEmptyPassphrase", true],
|
||||
["enableCompression", true],
|
||||
] satisfies Array<[keyof ObsidianLiveSyncSettings, ObsidianLiveSyncSettings[keyof ObsidianLiveSyncSettings]]>)(
|
||||
"detects a CouchDB %s change",
|
||||
(key, value) => {
|
||||
const settings = configuredSettings();
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...settings, [key]: value })).not.toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(settings)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it("ignores persisted central profile identity when the effective connection settings match", () => {
|
||||
const settings = configuredSettings({ activeConfigurationId: "profile-a" });
|
||||
const otherProfile = { ...settings, activeConfigurationId: "profile-b" };
|
||||
|
||||
expect(getCouchDBReplicatorConfigurationIdentity(otherProfile)).toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(settings)
|
||||
);
|
||||
expect(getObjectStorageReplicatorConfigurationIdentity(otherProfile)).toBe(
|
||||
getObjectStorageReplicatorConfigurationIdentity(settings)
|
||||
);
|
||||
});
|
||||
|
||||
it("projects only the active CouchDB authentication mode", () => {
|
||||
const basic = configuredSettings({ useJWT: false, jwtKey: "inactive-a" });
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...basic, jwtKey: "inactive-b" })).toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(basic)
|
||||
);
|
||||
|
||||
const jwt = configuredSettings({
|
||||
useJWT: true,
|
||||
jwtAlgorithm: "HS256",
|
||||
jwtKey: "jwt-a",
|
||||
jwtKid: "kid-a",
|
||||
jwtSub: "subject-a",
|
||||
jwtExpDuration: 5,
|
||||
});
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...jwt, couchDB_PASSWORD: "inactive" })).toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(jwt)
|
||||
);
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...jwt, jwtKey: "jwt-b" })).not.toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(jwt)
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["endpoint", "https://other.example.test/base"],
|
||||
["bucket", "other-vault"],
|
||||
["bucketPrefix", "archive/"],
|
||||
["region", "eu-west-1"],
|
||||
["accessKey", "bob"],
|
||||
["secretKey", "secret-b"],
|
||||
["forcePathStyle", false],
|
||||
["useCustomRequestHandler", true],
|
||||
["bucketCustomHeaders", "X-First: changed"],
|
||||
["encrypt", false],
|
||||
["passphrase", "encryption-b"],
|
||||
["useDynamicIterationCount", true],
|
||||
["E2EEAlgorithm", ""],
|
||||
["permitEmptyPassphrase", true],
|
||||
] satisfies Array<[keyof ObsidianLiveSyncSettings, ObsidianLiveSyncSettings[keyof ObsidianLiveSyncSettings]]>)(
|
||||
"detects an Object Storage %s change",
|
||||
(key, value) => {
|
||||
const settings = configuredSettings();
|
||||
expect(getObjectStorageReplicatorConfigurationIdentity({ ...settings, [key]: value })).not.toBe(
|
||||
getObjectStorageReplicatorConfigurationIdentity(settings)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it("normalises endpoint and header representation without using the setup URI grammar", () => {
|
||||
const settings = configuredSettings();
|
||||
const couchIdentity = getCouchDBReplicatorConfigurationIdentity(settings);
|
||||
const objectStorageIdentity = getObjectStorageReplicatorConfigurationIdentity(settings);
|
||||
|
||||
expect(
|
||||
getCouchDBReplicatorConfigurationIdentity({
|
||||
...settings,
|
||||
couchDB_URI: "https://couch.example.test:443/base/",
|
||||
couchDB_CustomHeaders: "X-First: one\nX-Second: two",
|
||||
})
|
||||
).toBe(couchIdentity);
|
||||
expect(
|
||||
getObjectStorageReplicatorConfigurationIdentity({
|
||||
...settings,
|
||||
endpoint: "https://objects.example.test:443/base/",
|
||||
bucketCustomHeaders: "X-First: one\nX-Second: two",
|
||||
})
|
||||
).toBe(objectStorageIdentity);
|
||||
});
|
||||
|
||||
it("ignores inactive remote-security credentials", () => {
|
||||
const settings = configuredSettings({ encrypt: false, passphrase: "inactive-a" });
|
||||
|
||||
expect(
|
||||
getCouchDBReplicatorConfigurationIdentity({
|
||||
...settings,
|
||||
passphrase: "inactive-b",
|
||||
useDynamicIterationCount: !settings.useDynamicIterationCount,
|
||||
E2EEAlgorithm: "",
|
||||
permitEmptyPassphrase: !settings.permitEmptyPassphrase,
|
||||
})
|
||||
).toBe(getCouchDBReplicatorConfigurationIdentity(settings));
|
||||
expect(
|
||||
getObjectStorageReplicatorConfigurationIdentity({
|
||||
...settings,
|
||||
passphrase: "inactive-b",
|
||||
useDynamicIterationCount: !settings.useDynamicIterationCount,
|
||||
E2EEAlgorithm: "",
|
||||
permitEmptyPassphrase: !settings.permitEmptyPassphrase,
|
||||
})
|
||||
).toBe(getObjectStorageReplicatorConfigurationIdentity(settings));
|
||||
});
|
||||
|
||||
it("keeps malformed endpoints deterministic and scoped", () => {
|
||||
const settings = configuredSettings({ couchDB_URI: "not a URL", endpoint: "also not a URL" });
|
||||
|
||||
expect(() => getCouchDBReplicatorConfigurationIdentity(settings)).not.toThrow();
|
||||
expect(() => getObjectStorageReplicatorConfigurationIdentity(settings)).not.toThrow();
|
||||
expect(
|
||||
getCouchDBReplicatorConfigurationIdentity({ ...settings, couchDB_URI: "different invalid URL" })
|
||||
).not.toBe(getCouchDBReplicatorConfigurationIdentity(settings));
|
||||
const unrelatedPluginChange = { ...settings, displayLanguage: "ja" };
|
||||
expect(getObjectStorageReplicatorConfigurationIdentity(unrelatedPluginChange)).toBe(
|
||||
getObjectStorageReplicatorConfigurationIdentity(settings)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { REMOTE_COUCHDB, REMOTE_MINIO, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
CAPABILITY_NOT_APPLICABLE,
|
||||
CENTRAL_REMOTE_REPLICATION_READINESS,
|
||||
NO_INTERACTION,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
defineReplicatorProviderDefinitions,
|
||||
supportedOpenReplicationContinuous,
|
||||
replicationBlocked,
|
||||
replicationFailed,
|
||||
supportedStopActiveTransfer,
|
||||
supportedCapability,
|
||||
type ReplicatorProviderDefinitionMap,
|
||||
type ReplicationOutcome,
|
||||
type ReplicatorInstance,
|
||||
type UserInitiatedOneShotRunner,
|
||||
type UnattendedOneShotRunner,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
LiveSyncCouchDBReplicator,
|
||||
type LiveSyncCouchDBReplicatorEnv,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import {
|
||||
getCouchDBReplicatorConfigurationIdentity,
|
||||
getObjectStorageReplicatorConfigurationIdentity,
|
||||
} from "./replicatorConfigurationIdentity";
|
||||
import {
|
||||
createCouchDBConnectionProbeFactory,
|
||||
createCouchDBPreferredTweakProbeFactory,
|
||||
createCouchDBSecuritySeedResourceFactory,
|
||||
createCouchDBSynchronisationInformationResourceFactory,
|
||||
createObjectStorageConnectionProbeFactory,
|
||||
createObjectStoragePreferredTweakProbeFactory,
|
||||
createObjectStorageSecuritySeedResourceFactory,
|
||||
} from "./replicatorResources";
|
||||
import {
|
||||
COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
} from "./centralRemoteAdministration";
|
||||
|
||||
/** Host environment sufficient to construct every current central provider. */
|
||||
export type CentralReplicatorProviderHost = LiveSyncCouchDBReplicatorEnv;
|
||||
|
||||
/** Minimal operation required by both central one-shot adapters. */
|
||||
interface OneShotOutcomeReplicator extends ReplicatorInstance {
|
||||
openOneShotReplicationWithOutcome(setting: RemoteDBSettings, showResult: boolean): Promise<ReplicationOutcome>;
|
||||
}
|
||||
|
||||
function isOneShotOutcomeReplicator(instance: ReplicatorInstance): instance is OneShotOutcomeReplicator {
|
||||
return (
|
||||
"openOneShotReplicationWithOutcome" in instance &&
|
||||
typeof instance.openOneShotReplicationWithOutcome === "function"
|
||||
);
|
||||
}
|
||||
|
||||
async function runOneShotWithOutcome(
|
||||
instance: ReplicatorInstance,
|
||||
setting: RemoteDBSettings,
|
||||
showResult: boolean
|
||||
): Promise<ReplicationOutcome> {
|
||||
if (!isOneShotOutcomeReplicator(instance)) {
|
||||
return replicationFailed(new Error("The configured provider does not implement one-shot replication."));
|
||||
}
|
||||
return await instance.openOneShotReplicationWithOutcome(setting, showResult);
|
||||
}
|
||||
|
||||
const couchDBUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
|
||||
return await runOneShotWithOutcome(
|
||||
instance,
|
||||
setting,
|
||||
request.interaction.kind === "permitted" && request.interaction.permissions.failureRecovery
|
||||
);
|
||||
};
|
||||
|
||||
const couchDBUnattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
|
||||
if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required");
|
||||
return await runOneShotWithOutcome(instance, setting, false);
|
||||
};
|
||||
|
||||
const objectStorageUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
|
||||
return await runOneShotWithOutcome(
|
||||
instance,
|
||||
setting,
|
||||
request.interaction.kind === "permitted" && request.interaction.permissions.failureRecovery
|
||||
);
|
||||
};
|
||||
|
||||
const objectStorageUnattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
|
||||
if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required");
|
||||
return await runOneShotWithOutcome(instance, setting, false);
|
||||
};
|
||||
|
||||
/** Build the complete central-remote provider policy for one LiveSync host. */
|
||||
export function createCentralReplicatorProviderDefinitions(
|
||||
host: CentralReplicatorProviderHost
|
||||
): ReplicatorProviderDefinitionMap {
|
||||
return defineReplicatorProviderDefinitions([REMOTE_COUCHDB, REMOTE_MINIO] as const, {
|
||||
[REMOTE_COUCHDB]: {
|
||||
kind: REMOTE_COUCHDB,
|
||||
diagnosticName: "CouchDB",
|
||||
readiness: CENTRAL_REMOTE_REPLICATION_READINESS,
|
||||
isConfigured: (settings) =>
|
||||
settings.remoteType === REMOTE_COUCHDB &&
|
||||
!!settings.couchDB_URI?.trim() &&
|
||||
!!settings.couchDB_DBNAME?.trim(),
|
||||
configurationIdentity: getCouchDBReplicatorConfigurationIdentity,
|
||||
create: () => Promise.resolve(new LiveSyncCouchDBReplicator(host)),
|
||||
remoteResources: {
|
||||
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(createCouchDBConnectionProbeFactory(host)),
|
||||
[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: supportedCapability(
|
||||
createCouchDBPreferredTweakProbeFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.SECURITY_SEED]: supportedCapability(
|
||||
createCouchDBSecuritySeedResourceFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: supportedCapability(
|
||||
createCouchDBSynchronisationInformationResourceFactory(host)
|
||||
),
|
||||
},
|
||||
centralRemoteAdministration: COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
userInitiatedOneShot: supportedCapability(couchDBUserInitiatedOneShot),
|
||||
unattendedOneShot: supportedCapability(couchDBUnattendedOneShot),
|
||||
continuous: supportedOpenReplicationContinuous(),
|
||||
stopActiveTransfer: supportedStopActiveTransfer(),
|
||||
},
|
||||
[REMOTE_MINIO]: {
|
||||
kind: REMOTE_MINIO,
|
||||
diagnosticName: "Object Storage",
|
||||
readiness: CENTRAL_REMOTE_REPLICATION_READINESS,
|
||||
isConfigured: (settings) =>
|
||||
settings.remoteType === REMOTE_MINIO && !!settings.endpoint?.trim() && !!settings.bucket?.trim(),
|
||||
configurationIdentity: getObjectStorageReplicatorConfigurationIdentity,
|
||||
create: () => Promise.resolve(new LiveSyncJournalReplicator(host)),
|
||||
remoteResources: {
|
||||
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(
|
||||
createObjectStorageConnectionProbeFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: supportedCapability(
|
||||
createObjectStoragePreferredTweakProbeFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.SECURITY_SEED]: supportedCapability(
|
||||
createObjectStorageSecuritySeedResourceFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: CAPABILITY_NOT_APPLICABLE,
|
||||
},
|
||||
centralRemoteAdministration: OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
userInitiatedOneShot: supportedCapability(objectStorageUserInitiatedOneShot),
|
||||
unattendedOneShot: supportedCapability(objectStorageUnattendedOneShot),
|
||||
continuous: CAPABILITY_NOT_APPLICABLE,
|
||||
stopActiveTransfer: supportedStopActiveTransfer(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import {
|
||||
CAPABILITY_SUPPORT_KINDS,
|
||||
NO_INTERACTION,
|
||||
REPLICATION_COMPLETED,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
const constructorMocks = vi.hoisted(() => ({
|
||||
couchDB: vi.fn(),
|
||||
couchDBOneShot: vi.fn(async (..._args: unknown[]) => REPLICATION_COMPLETED),
|
||||
objectStorage: vi.fn(),
|
||||
objectStorageOneShot: vi.fn(async (..._args: unknown[]) => REPLICATION_COMPLETED),
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
|
||||
LiveSyncCouchDBReplicator: class {
|
||||
constructor(host: unknown) {
|
||||
constructorMocks.couchDB(host);
|
||||
}
|
||||
openOneShotReplicationWithOutcome(...args: unknown[]) {
|
||||
return constructorMocks.couchDBOneShot(...args);
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator", () => ({
|
||||
LiveSyncJournalReplicator: class {
|
||||
constructor(host: unknown) {
|
||||
constructorMocks.objectStorage(host);
|
||||
}
|
||||
openOneShotReplicationWithOutcome(...args: unknown[]) {
|
||||
return constructorMocks.objectStorageOneShot(...args);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { createCentralReplicatorProviderDefinitions } from "./replicatorProviders";
|
||||
|
||||
describe("central Replicator provider definitions", () => {
|
||||
it("keeps the retained remote-resource catalogue bounded", () => {
|
||||
expect
|
||||
.soft(Object.values(REMOTE_RESOURCE_KINDS).sort())
|
||||
.toEqual(["connection", "preferred-tweak", "security-seed", "synchronisation-information"].sort());
|
||||
});
|
||||
|
||||
it("composes CouchDB and Object Storage policies outside LiveSyncBaseCore", async () => {
|
||||
const host = {} as Parameters<typeof createCentralReplicatorProviderDefinitions>[0];
|
||||
const definitions = createCentralReplicatorProviderDefinitions(host);
|
||||
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||
const objectStorage = definitions.get(REMOTE_MINIO)!;
|
||||
|
||||
expect([...definitions.keys()]).toEqual([REMOTE_COUCHDB, REMOTE_MINIO]);
|
||||
expect("sameKindReconciliation" in couchDB).toBe(false);
|
||||
expect("sameKindReconciliation" in objectStorage).toBe(false);
|
||||
|
||||
expect(
|
||||
couchDB.isConfigured(
|
||||
Object.assign(createNewVaultSettings(), {
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
couchDB_URI: "https://couch.example.test",
|
||||
couchDB_DBNAME: "vault",
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
objectStorage.isConfigured(
|
||||
Object.assign(createNewVaultSettings(), {
|
||||
remoteType: REMOTE_MINIO,
|
||||
endpoint: "https://objects.example.test",
|
||||
bucket: "vault",
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
await couchDB.create(createNewVaultSettings());
|
||||
await objectStorage.create(createNewVaultSettings());
|
||||
expect(constructorMocks.couchDB).toHaveBeenCalledWith(host);
|
||||
expect(constructorMocks.objectStorage).toHaveBeenCalledWith(host);
|
||||
});
|
||||
|
||||
it("rejects incomplete and wrong-kind settings before construction", () => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||
const objectStorage = definitions.get(REMOTE_MINIO)!;
|
||||
|
||||
expect(couchDB.isConfigured(Object.assign(createNewVaultSettings(), { remoteType: REMOTE_MINIO }))).toBe(false);
|
||||
expect(
|
||||
objectStorage.isConfigured(Object.assign(createNewVaultSettings(), { remoteType: REMOTE_COUCHDB }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("declares the retained owned resources and cohesive optional administration", () => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
const couchResources = definitions.get(REMOTE_COUCHDB)?.remoteResources;
|
||||
const objectResources = definitions.get(REMOTE_MINIO)?.remoteResources;
|
||||
const couchAdministration = definitions.get(REMOTE_COUCHDB)?.centralRemoteAdministration;
|
||||
const objectAdministration = definitions.get(REMOTE_MINIO)?.centralRemoteAdministration;
|
||||
|
||||
expect(Object.keys(couchResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort());
|
||||
expect(Object.keys(objectResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort());
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.CONNECTION].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe(
|
||||
CAPABILITY_SUPPORT_KINDS.SUPPORTED
|
||||
);
|
||||
expect(objectResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(objectResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe(
|
||||
CAPABILITY_SUPPORT_KINDS.NOT_APPLICABLE
|
||||
);
|
||||
expect(couchAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(objectAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect("activeRemoteReads" in definitions.get(REMOTE_COUCHDB)!).toBe(false);
|
||||
expect("fullTransfers" in definitions.get(REMOTE_COUCHDB)!).toBe(false);
|
||||
});
|
||||
|
||||
it("dispatches central finite work through provider-local attempt results", async () => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||
const objectStorage = definitions.get(REMOTE_MINIO)!;
|
||||
const setting = createNewVaultSettings();
|
||||
const couchInstance = await couchDB.create(setting);
|
||||
const objectInstance = await objectStorage.create(setting);
|
||||
if (!couchInstance || !objectInstance) throw new Error("Provider construction failed");
|
||||
if (couchDB.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||
throw new Error("CouchDB OneShot is unavailable");
|
||||
}
|
||||
if (objectStorage.unattendedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||
throw new Error("Object Storage OneShot is unavailable");
|
||||
}
|
||||
|
||||
await expect(
|
||||
couchDB.userInitiatedOneShot.run(couchInstance, setting, {
|
||||
trigger: "manual",
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
})
|
||||
).resolves.toBe(REPLICATION_COMPLETED);
|
||||
await expect(
|
||||
objectStorage.unattendedOneShot.run(objectInstance, setting, {
|
||||
trigger: "resume",
|
||||
interaction: NO_INTERACTION,
|
||||
})
|
||||
).resolves.toBe(REPLICATION_COMPLETED);
|
||||
|
||||
expect(constructorMocks.couchDBOneShot).toHaveBeenCalledWith(setting, true);
|
||||
expect(constructorMocks.objectStorageOneShot).toHaveBeenCalledWith(setting, false);
|
||||
});
|
||||
|
||||
it("dispatches central finite work through the declared operation rather than constructor identity", async () => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||
const objectStorage = definitions.get(REMOTE_MINIO)!;
|
||||
const setting = createNewVaultSettings();
|
||||
const createStructuralOneShotReplicator = () => ({
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
openReplication: vi.fn(async () => true),
|
||||
terminateSync: vi.fn(),
|
||||
closeReplication: vi.fn(),
|
||||
openOneShotReplicationWithOutcome: vi.fn(async () => REPLICATION_COMPLETED),
|
||||
});
|
||||
const couchInstance = createStructuralOneShotReplicator();
|
||||
const objectStorageInstance = createStructuralOneShotReplicator();
|
||||
if (couchDB.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||
throw new Error("CouchDB OneShot is unavailable");
|
||||
}
|
||||
if (objectStorage.unattendedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||
throw new Error("Object Storage OneShot is unavailable");
|
||||
}
|
||||
|
||||
const couchOutcome = await couchDB.userInitiatedOneShot.run(couchInstance, setting, {
|
||||
trigger: "manual",
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
const objectStorageOutcome = await objectStorage.unattendedOneShot.run(objectStorageInstance, setting, {
|
||||
trigger: "resume",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
|
||||
expect.soft(couchOutcome).toBe(REPLICATION_COMPLETED);
|
||||
expect.soft(objectStorageOutcome).toBe(REPLICATION_COMPLETED);
|
||||
expect(couchInstance.openOneShotReplicationWithOutcome).toHaveBeenCalledWith(setting, true);
|
||||
expect(objectStorageInstance.openOneShotReplicationWithOutcome).toHaveBeenCalledWith(setting, false);
|
||||
});
|
||||
|
||||
it("rejects a one-shot adapter whose Replicator does not declare the required operation", async () => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||
const setting = createNewVaultSettings();
|
||||
const incompleteInstance = {
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
openReplication: vi.fn(async () => true),
|
||||
terminateSync: vi.fn(),
|
||||
closeReplication: vi.fn(),
|
||||
};
|
||||
if (couchDB.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||
throw new Error("CouchDB OneShot is unavailable");
|
||||
}
|
||||
|
||||
const outcome = await couchDB.userInitiatedOneShot.run(incompleteInstance, setting, {
|
||||
trigger: "manual",
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
|
||||
expect(outcome.status).toBe("failed");
|
||||
expect(incompleteInstance.openReplication).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
couchDB: [] as Array<{
|
||||
host: unknown;
|
||||
isMobile: ReturnType<typeof vi.fn>;
|
||||
connectRemoteCouchDBWithSetting: ReturnType<typeof vi.fn>;
|
||||
getRemoteStatus: ReturnType<typeof vi.fn>;
|
||||
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
|
||||
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
|
||||
closeReplication: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
objectStorage: [] as Array<{
|
||||
host: unknown;
|
||||
tryConnectRemote: ReturnType<typeof vi.fn>;
|
||||
getRemoteStatus: ReturnType<typeof vi.fn>;
|
||||
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
|
||||
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
|
||||
closeReplication: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
checkSyncInfo: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation", () => ({
|
||||
checkSyncInfo: mocks.checkSyncInfo,
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
|
||||
LiveSyncCouchDBReplicator: class {
|
||||
host: unknown;
|
||||
isMobile = vi.fn(() => false);
|
||||
connectRemoteCouchDBWithSetting = vi.fn();
|
||||
getRemoteStatus = vi.fn();
|
||||
getRemotePreferredTweakValues = vi.fn();
|
||||
getReplicationPBKDF2Salt = vi.fn();
|
||||
closeReplication = vi.fn();
|
||||
|
||||
constructor(host: unknown) {
|
||||
this.host = host;
|
||||
mocks.couchDB.push(this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator", () => ({
|
||||
LiveSyncJournalReplicator: class {
|
||||
host: unknown;
|
||||
tryConnectRemote = vi.fn();
|
||||
getRemoteStatus = vi.fn();
|
||||
getRemotePreferredTweakValues = vi.fn();
|
||||
getReplicationPBKDF2Salt = vi.fn();
|
||||
closeReplication = vi.fn();
|
||||
|
||||
constructor(host: unknown) {
|
||||
this.host = host;
|
||||
mocks.objectStorage.push(this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
createCouchDBConnectionProbeFactory,
|
||||
createCouchDBPreferredTweakProbeFactory,
|
||||
createCouchDBSecuritySeedResourceFactory,
|
||||
createCouchDBSynchronisationInformationResourceFactory,
|
||||
createObjectStorageConnectionProbeFactory,
|
||||
createObjectStoragePreferredTweakProbeFactory,
|
||||
createObjectStorageSecuritySeedResourceFactory,
|
||||
} from "./replicatorResources";
|
||||
|
||||
function createSettings(overrides: Partial<ObsidianLiveSyncSettings> = {}): ObsidianLiveSyncSettings {
|
||||
return Object.assign(createNewVaultSettings(), {
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
couchDB_URI: "https://couch.example.test",
|
||||
couchDB_DBNAME: "vault",
|
||||
endpoint: "https://objects.example.test",
|
||||
bucket: "vault",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("replicator probe factories", () => {
|
||||
beforeEach(() => {
|
||||
mocks.couchDB.length = 0;
|
||||
mocks.objectStorage.length = 0;
|
||||
mocks.checkSyncInfo.mockReset().mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("binds a CouchDB connection probe to a shallow settings snapshot and closes its owned connection", async () => {
|
||||
const host = { name: "host" };
|
||||
const source = createSettings();
|
||||
const snapshot = { ...source };
|
||||
const probe = await createCouchDBConnectionProbeFactory(host as never)(source);
|
||||
const replicator = mocks.couchDB[0];
|
||||
const close = vi.fn(async () => undefined);
|
||||
const databaseClose = vi.fn(async () => undefined);
|
||||
replicator.isMobile.mockReturnValue(true);
|
||||
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({
|
||||
db: { close: databaseClose },
|
||||
info: {},
|
||||
close,
|
||||
});
|
||||
|
||||
source.couchDB_URI = "https://changed.example.test";
|
||||
expect(await probe.check({ createIfMissing: false, showResult: true })).toEqual({ ok: true });
|
||||
|
||||
expect(replicator.connectRemoteCouchDBWithSetting).toHaveBeenCalledWith(snapshot, true, false, false);
|
||||
expect(replicator.connectRemoteCouchDBWithSetting.mock.calls[0][0]).not.toBe(source);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(databaseClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps a CouchDB connection error string and delegates status to the same snapshot", async () => {
|
||||
const source = createSettings();
|
||||
const snapshot = { ...source };
|
||||
const probe = await createCouchDBConnectionProbeFactory({} as never)(source);
|
||||
const replicator = mocks.couchDB[0];
|
||||
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue("connection failed");
|
||||
|
||||
expect(await probe.check()).toEqual({ ok: false, reason: "connection failed" });
|
||||
|
||||
const status = { estimatedSize: 12 };
|
||||
replicator.getRemoteStatus.mockResolvedValue(status);
|
||||
source.couchDB_DBNAME = "changed-vault";
|
||||
expect(await probe.getStatus()).toBe(status);
|
||||
expect(replicator.getRemoteStatus).toHaveBeenCalledWith(snapshot);
|
||||
});
|
||||
|
||||
it("creates an unpublished Object Storage replicator for each probe and normalises connection results", async () => {
|
||||
const host = { name: "host" };
|
||||
const source = createSettings({ remoteType: REMOTE_MINIO });
|
||||
const snapshot = { ...source };
|
||||
const factory = createObjectStorageConnectionProbeFactory(host as never);
|
||||
const firstProbe = await factory(source);
|
||||
const secondProbe = await factory(source);
|
||||
expect(mocks.objectStorage).toHaveLength(2);
|
||||
|
||||
const firstReplicator = mocks.objectStorage[0];
|
||||
firstReplicator.tryConnectRemote.mockResolvedValue(true);
|
||||
source.endpoint = "https://changed.example.test";
|
||||
expect(await firstProbe.check()).toEqual({ ok: true });
|
||||
expect(firstReplicator.tryConnectRemote).toHaveBeenCalledWith(snapshot, false);
|
||||
|
||||
const secondReplicator = mocks.objectStorage[1];
|
||||
secondReplicator.tryConnectRemote.mockResolvedValue(false);
|
||||
expect(await secondProbe.check({ showResult: true })).toEqual({ ok: false });
|
||||
expect(secondReplicator.tryConnectRemote).toHaveBeenCalledWith(snapshot, true);
|
||||
|
||||
const error = new Error("storage offline");
|
||||
secondReplicator.tryConnectRemote.mockRejectedValue(error);
|
||||
expect(await secondProbe.check()).toEqual({ ok: false, reason: error });
|
||||
});
|
||||
|
||||
it("delegates Object Storage status and preferred-tweak reads to the trial snapshot", async () => {
|
||||
const source = createSettings({ remoteType: REMOTE_MINIO });
|
||||
const snapshot = { ...source };
|
||||
const connectionProbe = await createObjectStorageConnectionProbeFactory({} as never)(source);
|
||||
const preferredProbe = await createObjectStoragePreferredTweakProbeFactory({} as never)(source);
|
||||
const connectionReplicator = mocks.objectStorage[0];
|
||||
const preferredReplicator = mocks.objectStorage[1];
|
||||
const status = { estimatedSize: 42 };
|
||||
const preferred = { status: "unsupported" } as const;
|
||||
connectionReplicator.getRemoteStatus.mockResolvedValue(status);
|
||||
preferredReplicator.getRemotePreferredTweakValues.mockResolvedValue(preferred);
|
||||
|
||||
source.bucket = "changed-vault";
|
||||
expect(await connectionProbe.getStatus()).toBe(status);
|
||||
expect(await preferredProbe.read()).toBe(preferred);
|
||||
expect(connectionReplicator.getRemoteStatus).toHaveBeenCalledWith(snapshot);
|
||||
expect(preferredReplicator.getRemotePreferredTweakValues).toHaveBeenCalledWith(snapshot);
|
||||
});
|
||||
|
||||
it("shares one successful asynchronous disposal promise for every probe kind", async () => {
|
||||
const couchProbe = await createCouchDBPreferredTweakProbeFactory({} as never)(createSettings());
|
||||
const objectProbe = await createObjectStoragePreferredTweakProbeFactory({} as never)(
|
||||
createSettings({ remoteType: REMOTE_MINIO })
|
||||
);
|
||||
const couchReplicator = mocks.couchDB[0];
|
||||
const objectReplicator = mocks.objectStorage[0];
|
||||
|
||||
const couchDisposal = couchProbe.dispose();
|
||||
expect(couchProbe.dispose()).toBe(couchDisposal);
|
||||
const objectDisposal = objectProbe.dispose();
|
||||
expect(objectProbe.dispose()).toBe(objectDisposal);
|
||||
await Promise.all([couchDisposal, objectDisposal]);
|
||||
expect(couchReplicator.closeReplication).toHaveBeenCalledOnce();
|
||||
expect(objectReplicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shares a rejected disposal promise and never retries closeReplication", async () => {
|
||||
const probe = await createObjectStorageConnectionProbeFactory({} as never)(
|
||||
createSettings({ remoteType: REMOTE_MINIO })
|
||||
);
|
||||
const replicator = mocks.objectStorage[0];
|
||||
const failure = new Error("close failed");
|
||||
replicator.closeReplication.mockImplementation(() => {
|
||||
throw failure;
|
||||
});
|
||||
|
||||
const disposal = probe.dispose();
|
||||
expect(probe.dispose()).toBe(disposal);
|
||||
await expect(disposal).rejects.toBe(failure);
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reads the Security Seed from a settings snapshot and disposes its private Replicator", async () => {
|
||||
const couchSettings = createSettings();
|
||||
const couchSnapshot = { ...couchSettings };
|
||||
const objectSettings = createSettings({ remoteType: REMOTE_MINIO });
|
||||
const objectSnapshot = { ...objectSettings };
|
||||
const couchResource = await createCouchDBSecuritySeedResourceFactory({} as never)(couchSettings);
|
||||
const objectResource = await createObjectStorageSecuritySeedResourceFactory({} as never)(objectSettings);
|
||||
const couchReplicator = mocks.couchDB[0];
|
||||
const objectReplicator = mocks.objectStorage[0];
|
||||
const couchSeed = new Uint8Array([1]);
|
||||
const objectSeed = new Uint8Array([2]);
|
||||
couchReplicator.getReplicationPBKDF2Salt.mockResolvedValue(couchSeed);
|
||||
objectReplicator.getReplicationPBKDF2Salt.mockResolvedValue(objectSeed);
|
||||
|
||||
couchSettings.couchDB_URI = "https://changed.example.test";
|
||||
objectSettings.endpoint = "https://changed.example.test";
|
||||
await expect(couchResource.read()).resolves.toBe(couchSeed);
|
||||
await expect(objectResource.read()).resolves.toBe(objectSeed);
|
||||
expect(couchReplicator.getReplicationPBKDF2Salt).toHaveBeenCalledWith(couchSnapshot, true);
|
||||
expect(objectReplicator.getReplicationPBKDF2Salt).toHaveBeenCalledWith(objectSnapshot, true);
|
||||
|
||||
await Promise.all([couchResource.dispose(), objectResource.dispose()]);
|
||||
expect(couchReplicator.closeReplication).toHaveBeenCalledOnce();
|
||||
expect(objectReplicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("checks synchronisation information through an owned connection and disposes the private Replicator", async () => {
|
||||
const settings = createSettings();
|
||||
const snapshot = { ...settings };
|
||||
const resource = await createCouchDBSynchronisationInformationResourceFactory({} as never)(settings);
|
||||
const replicator = mocks.couchDB[0];
|
||||
const database = { close: vi.fn() };
|
||||
const close = vi.fn(async () => undefined);
|
||||
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({ db: database, close });
|
||||
|
||||
settings.couchDB_DBNAME = "changed-vault";
|
||||
await expect(resource.check()).resolves.toBe(true);
|
||||
expect(replicator.connectRemoteCouchDBWithSetting).toHaveBeenCalledWith(snapshot, false, true);
|
||||
expect(mocks.checkSyncInfo).toHaveBeenCalledWith(database);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(database.close).not.toHaveBeenCalled();
|
||||
|
||||
await resource.dispose();
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("closes the owned connection when synchronisation-information verification rejects", async () => {
|
||||
const resource = await createCouchDBSynchronisationInformationResourceFactory({} as never)(createSettings());
|
||||
const replicator = mocks.couchDB[0];
|
||||
const database = { close: vi.fn() };
|
||||
const close = vi.fn(async () => undefined);
|
||||
const failure = new Error("verification failed");
|
||||
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({ db: database, close });
|
||||
mocks.checkSyncInfo.mockRejectedValue(failure);
|
||||
|
||||
await expect(resource.check()).rejects.toBe(failure);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(database.close).not.toHaveBeenCalled();
|
||||
|
||||
await resource.dispose();
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type {
|
||||
ConnectionProbeFactory,
|
||||
RemoteConnectionProbe,
|
||||
RemoteConnectionProbeOptions,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
LiveSyncCouchDBReplicator,
|
||||
type LiveSyncCouchDBReplicatorEnv,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import { createReplicatorDisposer, snapshotRemoteSettings } from "./shared";
|
||||
|
||||
/** Host environment sufficient to construct either central connection probe. */
|
||||
export type ConnectionResourceHost = LiveSyncCouchDBReplicatorEnv;
|
||||
|
||||
function createCouchDBConnectionProbe(
|
||||
replicator: LiveSyncCouchDBReplicator,
|
||||
snapshot: RemoteDBSettings
|
||||
): RemoteConnectionProbe {
|
||||
const dispose = createReplicatorDisposer(replicator);
|
||||
return {
|
||||
check: async (options: RemoteConnectionProbeOptions = {}) => {
|
||||
const connection = await replicator.connectRemoteCouchDBWithSetting(
|
||||
snapshot,
|
||||
replicator.isMobile(),
|
||||
options.createIfMissing ?? true,
|
||||
false
|
||||
);
|
||||
if (typeof connection === "string") {
|
||||
return { ok: false, reason: connection };
|
||||
}
|
||||
try {
|
||||
return { ok: true };
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
},
|
||||
getStatus: () => replicator.getRemoteStatus(snapshot),
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
|
||||
function createObjectStorageConnectionProbe(
|
||||
replicator: LiveSyncJournalReplicator,
|
||||
snapshot: RemoteDBSettings
|
||||
): RemoteConnectionProbe {
|
||||
const dispose = createReplicatorDisposer(replicator);
|
||||
return {
|
||||
check: async (options: RemoteConnectionProbeOptions = {}) => {
|
||||
try {
|
||||
const connected = await replicator.tryConnectRemote(snapshot, options.showResult ?? false);
|
||||
return connected ? { ok: true } : { ok: false };
|
||||
} catch (error) {
|
||||
return { ok: false, reason: error };
|
||||
}
|
||||
},
|
||||
getStatus: () => replicator.getRemoteStatus(snapshot),
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an unpublished CouchDB connection probe for one host.
|
||||
*
|
||||
* The probe owns both its concrete Replicator and each connection it opens. It
|
||||
* never publishes that Replicator as the active provider instance.
|
||||
*/
|
||||
export function createCouchDBConnectionProbeFactory(host: ConnectionResourceHost): ConnectionProbeFactory {
|
||||
return (setting) => {
|
||||
const snapshot = snapshotRemoteSettings(setting);
|
||||
return Promise.resolve(createCouchDBConnectionProbe(new LiveSyncCouchDBReplicator(host), snapshot));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an unpublished Object Storage connection probe for one host.
|
||||
*
|
||||
* The probe owns its concrete Replicator and never publishes or replaces the
|
||||
* active provider instance.
|
||||
*/
|
||||
export function createObjectStorageConnectionProbeFactory(host: ConnectionResourceHost): ConnectionProbeFactory {
|
||||
return (setting) => {
|
||||
const snapshot = snapshotRemoteSettings(setting);
|
||||
return Promise.resolve(createObjectStorageConnectionProbe(new LiveSyncJournalReplicator(host), snapshot));
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user