Compare commits

..
Author SHA1 Message Date
vorotamoroz c3e12cf946 Avoid resolving CLI development peers in Docker runtime 2026-09-04 16:25:36 +00:00
vorotamoroz b90ef3716c Merge pull request #1167 from vrtmrz/fix/issue-1164-partial-scan-readiness
Keep synchronisation ready after partial startup scans
2026-09-05 00:44:54 +09:00
vorotamoroz e1195629b9 Use Commonlib 0.1.22 2026-09-04 15:12:47 +00:00
vorotamoroz 0ecb73924a Document diagnostic and notice ownership 2026-09-04 14:10:40 +00:00
vorotamoroz 188b749326 Warn after partial startup scans 2026-09-04 13:54:36 +00:00
vorotamoroz 6abc5cba64 Keep startup ready after individual file failures 2026-09-04 12:50:46 +00:00
vorotamoroz 045a328697 Merge pull request #1162 from vrtmrz/refactor/conflict-resolution-service-features
Refactor conflict resolution into service features
2026-09-04 18:31:00 +09:00
vorotamoroz b6b9ce3ba1 Document conflict dialogue lifecycle fixes 2026-09-04 08:30:42 +00:00
vorotamoroz f06f33cbf4 Strengthen conflict resolution regression coverage 2026-09-04 08:16:14 +00:00
vorotamoroz 56a1a19d2c Merge latest main into conflict resolution refactor 2026-09-04 05:35:39 +00:00
vorotamoroz 84be444689 Simplify conflict scheduling and lifecycle subscriptions 2026-09-04 05:15:45 +00:00
vorotamoroz 54d276f5e5 Merge pull request #1161 from vrtmrz/refactor/startup-lifecycle-service-features
Refactor startup lifecycle into service features
2026-09-04 11:37:47 +09:00
vorotamoroz f70bdbbbe0 refactor: clarify startup operation defaults 2026-09-04 02:30:40 +00:00
vorotamoroz 22a835519b fix: preserve startup UI behaviour 2026-09-04 01:55:29 +00:00
vorotamoroz ad91776ad9 Test conflict dialogue concurrency and unload lifecycle 2026-09-04 01:47:29 +00:00
vorotamoroz 6ea906b575 Refactor conflict resolution into service features 2026-09-03 12:35:58 +00:00
vorotamoroz 338aecd888 Refactor startup lifecycle into service features 2026-09-03 12:20:05 +00:00
vorotamoroz 3b2d5aa5af Merge pull request #1160 from vrtmrz/docs/current-data-structure-reference
Correct the database structure reference for 1.0
2026-09-03 16:39:46 +09:00
vorotamoroz f055222160 Document current database structure boundary 2026-09-03 07:23:15 +00:00
vorotamoroz 77882c677b Merge pull request #1159 from vrtmrz/docs/replicator-architecture
Document the implemented Replicator architecture
2026-09-03 15:57:31 +09:00
vorotamoroz 2461d37ead Document the implemented Replicator architecture
- mark capability and lifecycle ADRs as accepted
- add lifecycle, fencing, and provider-extension guidance
- split project terminology into a dedicated glossary
2026-09-03 06:42:34 +00:00
vorotamoroz d00c5ecc56 Merge pull request #1158 from vrtmrz/1_0_24
Releasing 1.0.24
2026-09-03 14:47:22 +09:00
vorotamoroz 7b4f7bf514 Prepare release 1.0.24 2026-09-03 04:17:05 +00:00
vorotamoroz ba8f910865 Merge pull request #1157 from vrtmrz/fix/setup-wizard-synchronising-device-copy
Align existing-device setup guidance with synchronising-device terminology
2026-09-03 12:17:26 +09:00
vorotamoroz c58e462057 Align existing-device setup guidance terminology 2026-09-03 03:01:05 +00:00
vorotamoroz a09c59aaba Merge pull request #1118 from nikhilmaddirala/agent/fix-setup-wizard-existing-device-copy
Fix existing-device setup guidance
2026-09-03 00:38:57 +09:00
vorotamoroz cbbae33d67 Merge pull request #1130 from zeedif/feat/setup-wizard-ux
feat(setup-wizard): improve E2EE dialog UX and password field
2026-09-02 23:54:25 +09:00
vorotamoroz be9c328f4a fix(setup-wizard): enlarge mobile password toggle 2026-09-02 14:47:21 +00:00
vorotamoroz a9b146a0ab test(setup-wizard): enforce mobile password touch target 2026-09-02 14:47:21 +00:00
vorotamoroz 5d5e448c6b Merge pull request #1129 from zeedif/fix/es-locale-and-onboarding-ux
fix(i18n): Spanish catalogue placeholders and missing strings
2026-09-02 23:27:39 +09:00
vorotamoroz 725db213c3 Merge pull request #1150 from vrtmrz/fix/1142-cli-systemd-installer
Fix systemd CLI installation and start-up checks
2026-09-02 21:43:49 +09:00
vorotamoroz a2441b9870 Integrate merged E2EE rebuild fix
# Conflicts:
#	updates.md
2026-09-02 12:34:58 +00:00
vorotamoroz 01c38268c7 Merge pull request #1149 from vrtmrz/fix/1146-preserve-e2ee-on-rebuild
Preserve device E2EE settings during remote rebuild
2026-09-02 21:32:54 +09:00
vorotamoroz 64e17ab920 Clarify systemd CLI installation guidance 2026-09-02 12:20:50 +00:00
vorotamoroz d8fccd6e4b Integrate systemd CLI installer fix with current main 2026-09-02 11:43:59 +00:00
vorotamoroz 50ad4c4bdf Integrate E2EE rebuild preservation with current main 2026-09-02 11:30:20 +00:00
vorotamoroz 8cb5d6d87f Adopt Replicator capability and lifecycle orchestration (#1154)
Merge the reviewed Replicator ownership and capability refactor after exact-release validation, stable promotion, and successful BRAT testing.
2026-09-02 16:59:20 +09:00
vorotamoroz 79eb940d80 Release Self-hosted LiveSync 1.0.23
Merge the exact reviewed release commit after published-artefact and BRAT validation.
2026-09-02 16:23:18 +09:00
vorotamoroz 28e76701c4 Add personal note to 1.0.23 release notes 2026-09-02 05:23:43 +00:00
github-actions[bot] c8aa40775b Releasing 1.0.23 2026-09-02 05:10:17 +00:00
vorotamoroz fdf5de4c10 Reconcile failed 1.0.22 release metadata 2026-09-02 04:57:10 +00:00
vorotamoroz 9e9fb5c326 chore: update livesync-commonlib to 0.1.21 2026-09-02 03:37:13 +00:00
vorotamoroz 2b450a300a Update vulnerable development dependencies 2026-09-01 18:25:27 +00:00
vorotamoroz ef3033d8db Restore Sync now progress and recovery policy 2026-09-01 18:00:08 +00:00
vorotamoroz 8e506fdbc7 Retire redundant P2P Review Harness check 2026-09-01 12:08:50 +00:00
vorotamoroz cab6679c2b Route interactive P2P UI through focused views 2026-09-01 11:33:54 +00:00
vorotamoroz f737701695 Classify P2P replacement probe as test support 2026-09-01 11:33:26 +00:00
vorotamoroz 565749ef6c Remove unused WebdriverIO dependency 2026-09-01 11:02:51 +00:00
vorotamoroz 32c7cc8aca Complete WebApp P2P local preparation 2026-09-01 08:41:39 +00:00
vorotamoroz 5c5f454286 Record Commonlib 0.1.20 in capability ADRs 2026-09-01 06:08:31 +00:00
vorotamoroz c4d9f8687e Adopt Commonlib 0.1.20 2026-09-01 05:39:19 +00:00
vorotamoroz bd0581cdc9 Report incomplete Object Storage wipes 2026-09-01 01:23:34 +00:00
vorotamoroz 9592ce8529 Merge remote-tracking branch 'origin/main' into refactor/replicator-capability-contract
# Conflicts:
#	devs.md
2026-08-31 15:13:31 +00:00
vorotamoroz e2d1bb09ae Refresh capability ADRs and Object Storage E2E evidence 2026-08-31 15:07:13 +00:00
vorotamoroz 3f76ad796e Migrate P2P UI and CLI transfers to service views 2026-08-31 15:06:39 +00:00
vorotamoroz 1d2077d3fc Complete central provider, resource, scheduling, and recovery contracts 2026-08-31 15:06:06 +00:00
vorotamoroz 24228bf7cf Update Hidden File Sync settings guidance 2026-08-31 00:35:42 +00:00
vorotamoroz 9a28d46287 Harden real-runtime workflow validation 2026-08-31 00:35:29 +00:00
vorotamoroz 3a23b7a6b0 Test CLI remote administration exit codes 2026-08-30 16:39:35 +00:00
vorotamoroz 23dd4ab87e Defer replication runtime database acquisition 2026-08-30 16:39:15 +00:00
vorotamoroz 6364988453 Record the final Replicator capability contract 2026-08-30 16:16:44 +00:00
vorotamoroz 76318944a4 Route P2P Setup probes through the room owner 2026-08-30 16:16:17 +00:00
vorotamoroz 704e141fd9 Refresh owned Security Seed observations 2026-08-30 16:15:55 +00:00
vorotamoroz 69cd6250d2 Tighten provider adapter boundaries 2026-08-30 12:42:34 +00:00
vorotamoroz 72f033fca4 Compose replication lifecycle without a legacy module 2026-08-30 10:23:04 +00:00
vorotamoroz 415f81d533 Merge pull request #1151 from vrtmrz/docs/service-feature-guidance
Document service feature and legacy Module boundaries
2026-08-30 19:13:56 +09:00
vorotamoroz 91625a1c76 Document service feature and legacy Module boundaries 2026-08-30 10:10:58 +00:00
vorotamoroz a5756503a2 Reconcile the Replicator capability design 2026-08-30 09:45:19 +00:00
vorotamoroz 7300db08d6 Adopt active Replicator ownership contracts 2026-08-30 09:45:00 +00:00
vorotamoroz 4b47ebbd4d fix(cli): install complete systemd runtime 2026-08-30 09:38:34 +00:00
vorotamoroz b28871ab67 Preserve device E2EE settings during remote rebuild 2026-08-30 07:43:20 +00:00
vorotamoroz db70b4c2b6 Migrate LiveSync flows to provider-owned resources 2026-08-28 17:54:04 +00:00
vorotamoroz f7206b1a6e Compose replication scheduling as a service feature 2026-08-28 06:05:29 +00:00
vorotamoroz fc160ee060 Define the replication scheduling service boundary 2026-08-28 04:10:48 +00:00
vorotamoroz 22b0cc133a Clarify service composition boundaries 2026-08-28 03:34:03 +00:00
vorotamoroz 948f961caa Record the implemented P2P ownership boundary 2026-08-28 03:05:09 +00:00
vorotamoroz ad83ae858d Adopt focused P2P service views 2026-08-28 03:04:53 +00:00
vorotamoroz cc000f2cdf Clarify P2P session lifecycle contracts 2026-08-27 17:58:19 +00:00
vorotamoroz d24bda88be Dispose temporary P2P setup probes 2026-08-27 17:57:59 +00:00
vorotamoroz 4dcc783a71 Use stable P2P service views in the CLI 2026-08-27 15:44:04 +00:00
vorotamoroz be03c25904 Remove obsolete P2P composition comment 2026-08-27 13:52:10 +00:00
vorotamoroz 7cf4ec49ed Route transfer stops through provider capabilities 2026-08-27 13:18:46 +00:00
vorotamoroz 8e5b058eef Define P2P session cancellation ownership 2026-08-27 13:18:17 +00:00
vorotamoroz 56444bb98b Route replication through provider capabilities 2026-08-27 11:16:30 +00:00
vorotamoroz 7aa41baf08 Document replicator capability and lifecycle contracts 2026-08-27 09:02:09 +00:00
Zeedif 2c35f45765 fix(setup-wizard): address review — icon, CSS scope, E2E coverage
- Use a plain emoji glyph for the password visibility toggle instead
  of embedded SVG paths, matching the same pragmatic approach already
  used for the browser build's menu icons in this codebase.
- Scope the wider label width to the E2EE dialogue instead of
  changing it for every InputRow in every Svelte dialogue.
- Extend the existing CouchDB manual setup E2E workflow to assert the
  passphrase and Obfuscate Properties fields are hidden until
  encryption is enabled, then appear, and that the passphrase value
  survives toggling its visibility.
2026-08-26 20:45:44 -06:00
Zeedif caa8c92cbe fix(i18n): keep activateReason default untranslated per review
Commonlib compares this value against the raw "updated" literal to
distinguish automatic activation from a user-requested run, so
translating it changes behaviour rather than just the displayed text.
Keep the default raw and drop the "updated" catalogue entry; the
manually-requested reason stays translated.
2026-08-26 20:41:00 -06:00
vorotamoroz 011a8405b5 Merge pull request #1139 from vrtmrz/1_0_21
Releasing 1.0.21
2026-08-26 20:36:43 +09:00
vorotamoroz f5f7aab11f Polish 1.0.21 release notes 2026-08-26 11:04:15 +00:00
github-actions[bot] 9854319c96 Releasing 1.0.21 2026-08-26 10:43:35 +00:00
vorotamoroz 4ab0689c7f Merge pull request #1138 from vrtmrz/fix/mobile-settings-layout
Fix settings layout on narrow mobile screens
2026-08-26 15:26:08 +09:00
vorotamoroz edeac6f7e2 Separate settings component style helpers 2026-08-26 05:49:37 +00:00
vorotamoroz 783fbb8f23 Refine mobile settings action layout markers 2026-08-26 05:13:38 +00:00
vorotamoroz 8644af6128 Narrow mobile settings layout fix 2026-08-26 04:30:44 +00:00
vorotamoroz 4ff5b4dfe8 Fix mobile settings layout overflow 2026-08-25 17:27:31 +00:00
vorotamoroz 0b1f5ca719 Merge pull request #1137 from vrtmrz/1_0_20
Releasing 1.0.20
2026-08-26 00:06:07 +09:00
ZeedifandClaude Sonnet 5 2cefff43bb fix(i18n): repair Spanish catalogue placeholders and missing strings
- Fix %{Display language} placeholder mismatch so the language-switch
  notice resolves to the actual language name instead of leaving the
  raw token in the Spanish text.
- Translate the remaining English strings shown in the Setup Wizard
  Intro and CouchDB screens (missing setup/CouchDB copy, "Check server
  requirements", "Create/Connect to database and continue", etc.).
- Translate the Config Doctor's activation reason (previously spliced
  into the Spanish sentence as the raw English word "updated" or
  "you wanted(Thank you)!") by routing it through the catalogue at
  the two call sites that set it.
- Fix Spanish strings that kept English-style Title Case instead of
  sentence case, and route the "OK" button through the catalogue so it
  renders as "Aceptar".

Scope is limited to Spanish content plus the minimal code changes
needed to make two hard-coded strings translatable at all; no other
locale files were touched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:33:57 -06:00
ZeedifandClaude Sonnet 5 6b37ea8778 feat(setup-wizard): improve E2EE dialog UX and password field
- Only show the passphrase and "Obfuscate Properties" controls once
  end-to-end encryption is enabled, instead of leaving them visible
  but disabled.
- Replace the password show/hide checkbox with an icon toggle button.
- Let translated labels wrap to their content instead of being
  clipped into a fixed-width column (this clipped longer translated
  labels, e.g. in German and Spanish).
- Scope the onboarding invitation link's 44px touch-target padding to
  mobile, so on desktop it renders as a normal inline link instead of
  a stray, oddly-padded button.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:23:50 -06:00
nikhilmaddirala 01c060558a Fix existing-device setup guidance 2026-08-18 10:27:05 -04:00
228 changed files with 16901 additions and 7415 deletions
+5 -4
View File
@@ -5,10 +5,11 @@ When working on this repository (writing code, comments, documentation, or commi
## Required Reference Files
Before making changes to documentation, user-facing text, or settings:
1. Read [docs/terms.md](docs/terms.md) for terminology, vocabulary conventions, and technical definitions.
2. Read [docs/settings.md](docs/settings.md) (and [docs/settings_ja.md](docs/settings_ja.md)) for UI settings and setting key mappings.
3. Read [docs/troubleshooting.md](docs/troubleshooting.md) for troubleshooting guidelines and common recovery steps (such as flag files and SCRAM state).
4. Read [devs.md](devs.md) for development workflows, module architecture, and testing infrastructure.
1. Read [docs/terms.md](docs/terms.md) for documentation style and vocabulary conventions.
2. Read [docs/glossary.md](docs/glossary.md) for user-facing, operational, developer, and design terminology.
3. Read [docs/settings.md](docs/settings.md) (and [docs/settings_ja.md](docs/settings_ja.md)) for UI settings and setting key mappings.
4. Read [docs/troubleshooting.md](docs/troubleshooting.md) for troubleshooting guidelines and common recovery steps (such as flag files and SCRAM state).
5. Read [devs.md](devs.md) for development workflows, module architecture, and testing infrastructure.
---
+4 -1
View File
@@ -57,7 +57,10 @@ To maintain consistency across the project, we ask that you follow the establish
- **Affirmative Phrasing**: Avoid asking questions using negative forms in user-facing dialogue. Use affirmative questions to prevent translation and interpretation discrepancies.
- **Specific Words**: Use 'dialogue' for documentation and user-facing messages (use 'dialog' only inside source code). Use the hyphenated form 'plug-in' in user-facing text (use 'plugin' only in configuration settings or technical contexts).
For a detailed list of vocabulary conventions and terms, please refer to [docs/terms.md](docs/terms.md).
For writing conventions, see [Documentation style and vocabulary conventions](docs/terms.md).
Project-specific meanings are defined in the [Project glossary](docs/glossary.md),
including internal developer and design terms which might not appear in the
user interface.
### 3. Translations
+64 -35
View File
@@ -85,7 +85,7 @@ To facilitate development and testing, the build process can automatically copy
Regression tests remain in the suite owned by the implementation under test. Plug-in tests may be co-located with their source, while independent application tests remain under `test/apps/` or `test/browser-apps/` so that they stay outside the Community Review source boundary. Prefix a case or group with `compatibility:` when it protects a persisted input or state which current releases still accept, and with `retirement guard:` when it prevents a removed setting, control, or notification from returning. Remove or replace a compatibility case only when the corresponding input is no longer accepted or an equivalent maintained case preserves the contract. Remove a retirement guard only when another current contract makes the old behaviour unreachable. Do not preserve a disconnected historical test as an executable specification when no maintained runner invokes it; Git history is the reference for retired test infrastructure.
- **CLI E2E** (`src/apps/cli/testdeno/`): Host-independent consumer workflows. The canonical Compose P2P suite covers ordinary two-peer synchronisation, replacement of the current replicator followed by transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. Its lifecycle entry point is included only in the Docker test build and does not add a public CLI command. Run `npm run test:e2e:cli` for the ordinary suite or `npm run test:e2e:cli:p2p` for P2P validation.
- **CLI E2E** (`src/apps/cli/testdeno/`): Host-independent consumer workflows. The canonical Compose P2P suite covers ordinary two-peer synchronisation, replacement of the current Replicator followed by transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. Its lifecycle entry point is included only in the Docker test build and does not add a public CLI command. Run `npm run test:e2e:cli` for the ordinary suite or `npm run test:e2e:cli:p2p` for P2P validation.
- **Self-hosted setup tools** (`utils/couchdb/`, `utils/setup/`, and `utils/flyio/`): Deno contract tests consume the exact locked Commonlib registry package, verify current CouchDB, Object Storage, and random-room P2P Setup URI defaults and remote profiles, and keep CouchDB administration separate from package-owned LiveSync database-version negotiation. `unit-ci` also provisions a real temporary CouchDB database and verifies its version document against the installed Commonlib package. Run `npm run test:setup-tools` for the local contract gate.
- **Real Obsidian E2E** (`test/e2e-obsidian/`): Local-first scripts that launch real Obsidian with temporary vaults and the built Self-hosted LiveSync plug-in. Use these for boot-up sequence, vault reflection, RedFlag flows, Fast Setup (Simple Fetch), settings dialogues, restart-sensitive workflows, Object Storage regressions, and other behaviour that depends on Obsidian itself. Run focused scripts such as `npm run test:e2e:obsidian:two-vault-sync`, or use `npm run test:e2e:obsidian:local-suite:services` to run the broader local suite with CouchDB and MinIO fixtures managed by the wrapper.
@@ -129,34 +129,42 @@ Changes spanning both repositories must first produce a packed Commonlib artefac
## Architecture
### Module System
The [Project glossary](docs/glossary.md#developer-and-design-terms) defines the
stable developer and design vocabulary used in this section. The guidance
below describes how those boundaries are applied.
The plugin uses a dynamic module system to reduce coupling and improve maintainability:
### Service composition and legacy Modules
- **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.
The application is composed from Services, ServiceModules, serviceFeatures, add-ons, and a legacy Module layer:
#### Note on Module vs Service
- **Service Hub**: the long-lived registry of service contracts. Add a simple extension, such as a pre-replication check, to the handler owned by the relevant Service.
- **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, user-interface bindings, or other host glue, and may return a focused view. It is not a runtime registry entry.
- **AbstractModule** and **AbstractObsidianModule**: the legacy application Module layer. Existing Modules are loaded by the application and bound after the Service graph has been composed; this broad core access is not the preferred dependency boundary for new orchestration.
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.
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.
Hence, the new feature should be implemented as follows:
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`.
- 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.
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.
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 user-interface 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.
See [Service feature and legacy Module boundaries](docs/design_docs/service_feature_and_legacy_module_boundaries.md) for the selection criteria, current examples, reasons to avoid new `AbstractModule` subclasses, incremental migration guidance, and test shapes. Commonlib's [service feature composition guide](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/service-feature-composition.md) defines the shared host-neutral boundary.
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
@@ -165,7 +173,12 @@ 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.
See [Replicator architecture](docs/design_docs/replicator_architecture.md) for
the implemented provider contract, active Replicator lifecycle, publication and
session fences, P2P ownership exception, compatibility boundaries, and the
steps required to add a built-in provider.
Commonlib owns one stable `LiveSyncP2PService`, its `P2PRoomSessionOwner`, and the replaceable Trystero room session. Host commands, event handlers, and views consume the focused transport, connection-probe admission, directory, peer-admission, transfer, change-relay, configuration, and diagnostic views returned by the service feature. They must not retain the deprecated compatibility Replicator as an ordinary service locator, close Trystero-owned raw peers, or install another Trystero transport generation at the application root. The exact implemented ownership and shutdown boundaries are recorded in Commonlib's [P2P transport lifecycle](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/p2p-transport-lifecycle.md) design document.
### Conflict Merge Policy
@@ -225,22 +238,38 @@ Commonlib owns the typed English fallback for messages requested by its services
- Dev mode creates `ls-debug/` folder in `.obsidian/` for debug outputs (e.g., missing translations)
- This causes pretty significant performance overhead.
#### Diagnostic and notice ownership
- A Commonlib or service operation should normally record detailed diagnostics at `LOG_LEVEL_VERBOSE` and return a typed result which lets its caller distinguish complete, partial, and failed outcomes. Do not make callers infer an outcome by parsing log text.
- Detailed diagnostics may be long and remain in English when they are intended for tracing and the generated report. Include enough context to identify the operation, affected target, and remaining state or retry behaviour.
- The application boundary which owns the workflow should decide whether to raise `LOG_LEVEL_NOTICE`. It has the interaction context to describe the user-visible consequence and the next useful action; an internal stage description alone is not a useful notice.
- When several files fail, issue one concise summary notice after the operation returns. Keep the per-file paths and technical causes at verbose level so that the notice remains readable and the generated report remains traceable.
- Commonlib should raise a notice only when its contract explicitly owns user presentation and no higher-level caller can add the required workflow context.
The ordinary start-up scan provides a concrete comparison:
- Good verbose diagnostic: `Offline scan failed to synchronise ${path} between storage and the local database; this path remains eligible for a later scan.` It identifies the operation, the two states being reconciled, the exact target, and what can happen next. Its length is appropriate for a report.
- Notice which needs more context: `Local database initialisation did not complete. See the log for details.` It describes an internal stage, but does not tell the user whether synchronisation can continue, what may be affected, or how to obtain the detailed log.
- Good application notice for a partial result: `Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.` It states the observable consequence, gives a proportionate action, and leaves the per-file evidence in the report.
- Good application notice for a failed result: `Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.` It states the operational consequence without exposing the internal initialisation stage.
## Common Patterns
### Module Implementation (Now not recommended for new features, use services instead)
### Service feature implementation
```typescript
export class ModuleExample extends AbstractObsidianModule {
async _everyOnloadStart(): Promise<boolean> {
/* ... */
}
type ExampleHost = NecessaryServices<"appLifecycle" | "API", never>;
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.appLifecycle.handleOnInitialise(this._everyOnloadStart.bind(this));
}
}
export const useExampleFeature = createServiceFeature((host: ExampleHost) => {
host.services.appLifecycle.onLoaded.addHandler(async () => {
host.services.API.addLog("Example feature loaded");
return true;
});
});
```
Existing legacy Modules continue to register their handlers in `onBindFunction()`. Follow [Service feature and legacy Module boundaries](docs/design_docs/service_feature_and_legacy_module_boundaries.md) when new behaviour touches one of those Modules.
### Settings Management
- Settings are defined by Commonlib (`ObsidianLiveSyncSettings`)
@@ -249,14 +278,14 @@ export class ModuleExample extends AbstractObsidianModule {
`Plugin.addSettingTab()`. Register a settings tab which reads persisted values
from the sequential `onSettingLoaded` lifecycle, seed its editing snapshot
before registration, and keep definition construction independent of local
database and replicator readiness. See
database and Replicator readiness. See
[the declarative settings adapter ADR](docs/adr/2026_08_declarative_settings_adapter.md).
- Use `this.services.setting.saveSettingData()` instead of using plugin methods directly
### Database Operations
- Local database operations through `LiveSyncLocalDB` (wraps PouchDB)
- Document types: `EntryDoc` (files), `EntryLeaf` (chunks), `PluginDataEntry` (plugin sync)
- Document types are owned by Commonlib. `EntryDoc` covers file Metadata, Chunks, database version information, Milestone information, Node information, and Chunk Packs. Current Customisation Sync data uses ordinary chunked Metadata in the `ix:` namespace rather than the application-local `PluginDataEntry` interface.
## Important Files
+21 -7
View File
@@ -1,9 +1,23 @@
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
# Architectural Decision Record: P2P Room and Transport Lifecycle
## Status
Accepted — implemented and verified through Commonlib owner tests, the Compose transport suite, and the real-Obsidian setup workflow.
The stable P2P service and room-session owner accepted in
[Replicator Capabilities and Lifecycle Orchestration — Part 2](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md)
supersede only this record's replaceable LiveSync P2P Replicator and ownership
of the replaceable result returned by the `serviceFeature`. This record remains
authoritative for serialised room operations, `room.leave()`, Trystero-owned
physical peers, and relay reconnection.
## Context
Self-hosted LiveSync uses Trystero's Nostr strategy for P2P discovery, signalling, and WebRTC transport. Three related resources have different owners and lifetimes:
@@ -12,7 +26,7 @@ Self-hosted LiveSync uses Trystero's Nostr strategy for P2P discovery, signallin
- Trystero owns the underlying WebRTC peers and may share one physical peer across more than one room; and
- Trystero's Nostr relay manager owns WebSocket clients shared by relay URL.
Closing every `RTCPeerConnection` returned by `room.getPeers()` bypasses Trystero's shared-peer manager. The manager may then retain a stale shared peer and prevent a replacement LiveSync replicator from discovering the same remote peer again.
Closing every `RTCPeerConnection` returned by `room.getPeers()` bypasses Trystero's shared-peer manager. The manager may then retain a stale shared peer and prevent a replacement LiveSync Replicator from discovering the same remote peer again.
Room departure and physical transport destruction are not equivalent. `room.leave()` sends the room-leave action, removes that room's actions and callbacks, and detaches its shared-peer binding. Trystero may retain a healthy physical WebRTC peer for later reuse after the last room binding has gone. The retained peer cannot carry actions for the room which has been left.
@@ -40,7 +54,7 @@ The explicit disconnect operation therefore has the following contract:
This operation is a logical LiveSync disconnection and a physical signalling-server disconnection. It does not promise that every browser-owned WebRTC object has been destroyed synchronously.
An explicit connect resumes relay reconnection before opening a new room. Settings application and database lifecycle replacement close the current LiveSync replicator, discard it, construct a new instance from the current settings, and open that current instance when the configured policy requires it. Commands, event handlers, and panes resolve the current service-feature result at the point of use rather than retaining an obsolete replicator.
An explicit connect resumes relay reconnection before opening a new room. Settings application and database lifecycle replacement close the current LiveSync Replicator, discard it, construct a new instance from the current settings, and open that current instance when the configured policy requires it. Commands, event handlers, and panes resolve the focused views returned by the current P2P `serviceFeature` at the point of use rather than retaining an obsolete Replicator.
Lifecycle operations on one `LiveSyncTrysteroReplicator` are serialised. A close requested while an open is in progress must leave no orphan room serving, and repeated opens must not create parallel rooms. No fixed delay is inserted between close and open: readiness is determined by the actual lifecycle operation and peer discovery.
@@ -50,7 +64,7 @@ P2P setup follows the transport's actual ownership model. Initialising the first
## Ownership
Commonlib owns the LiveSync-specific P2P service, RPC, command, and lifecycle composition. Trystero owns WebRTC peer creation, sharing, reuse, stale detection, and destruction, as well as relay-client reconstruction. The Self-hosted LiveSync host owns the current Commonlib service-feature result and supplies the platform services used by its current replicator.
Commonlib owns the LiveSync-specific P2P service, RPC, command, and lifecycle composition. Trystero owns WebRTC peer creation, sharing, reuse, stale detection, and destruction, as well as relay-client reconstruction. The Self-hosted LiveSync host owns the focused views returned by the current Commonlib P2P `serviceFeature` and supplies the platform services used by its current Replicator.
Self-hosted LiveSync does not add a separate root Trystero dependency. Tests which must observe relay sockets resolve the exact Trystero generation owned by the locked Commonlib package, avoiding two independent transport singletons in one process.
@@ -58,7 +72,7 @@ Self-hosted LiveSync does not add a separate root Trystero dependency. Tests whi
### Close every value returned by `room.getPeers()`
This bypasses Trystero's shared-peer manager and can prevent a replacement replicator from rediscovering the same peer.
This bypasses Trystero's shared-peer manager and can prevent a replacement Replicator from rediscovering the same peer.
### Add a fixed close-to-open delay
@@ -76,15 +90,15 @@ This interferes with Trystero's shared relay clients. The public pause and resum
Commonlib unit tests prove that normal P2P host closure calls `room.leave()` without directly closing Trystero-owned peer connections. Additional package tests cover the action API, replaceable peer-event subscriptions, multiple RPC transport disposers, serialised open and close operations, initialisation of the first device without a central remote, and Fetch running once for an additional device.
Self-hosted LiveSync unit tests prove that settings and database replacement leave panes on the current replicator, and that an explicit P2P rebuild bypasses the policy intended for ordinary replication.
Self-hosted LiveSync unit tests prove that settings and database replacement leave panes on the current Replicator, and that an explicit P2P rebuild bypasses the policy intended for ordinary replication.
The canonical Compose P2P suite uses a real local Nostr relay and WebRTC implementation. It covers ordinary two-peer synchronisation, replacement of the active LiveSync replicator followed by discovery and transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. The lifecycle scenario is exposed only through a Docker test build and an injected CLI command runner; it is not part of the public CLI command surface.
The canonical Compose P2P suite uses a real local Nostr relay and WebRTC implementation. It covers ordinary two-peer synchronisation, replacement of the active LiveSync Replicator followed by discovery and transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. The lifecycle scenario is exposed only through a Docker test build and an injected CLI command runner; it is not part of the public CLI command surface.
The real-Obsidian P2P Setup URI workflow creates the first device, generates the second-device URI from it, accepts each peer visibly, and verifies a two-way note round-trip through a local relay. A separate focused pane test covers the principal connection control and teardown without requiring a remote peer. Transport replacement and relay-socket lifecycle remain owned by the package and Compose tests rather than being duplicated in Obsidian.
## Consequences
- Replacing a P2P replicator no longer leaves host views or commands bound to an obsolete instance.
- Replacing a P2P Replicator no longer leaves host views or commands bound to an obsolete instance.
- Explicit signalling-server disconnection has a testable socket-level meaning without claiming immediate destruction of idle WebRTC objects.
- Settings which change the relay, room, passphrase, or TURN configuration can replace the whole LiveSync room safely.
- Trystero may reuse healthy peers across room lifecycles, reducing unnecessary renegotiation.
@@ -72,6 +72,7 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex
### Flag-file recovery order
- For a configured Vault, evaluate and persist the compatibility gate after settings load, before Obsidian layout-ready recovery begins. This blocks ordinary and one-shot replication even while the review dialogue has not yet opened. An existing unconfigured Vault follows the deferred rule above instead.
- Admit configured-only start-up work at priority 1, after ordinary priority-0 layout integration and before flag-file recovery. An unconfigured Vault offers onboarding and returns `false`, so recovery, compatibility review, database preparation, and configured-only request handling do not run. Treat this admission as a property of the current plug-in process: changing `isConfigured` from `false` to `true` requires the scheduled restart before configured work becomes available, and declining that restart deliberately leaves the current process inert. If an admitted process changes `isConfigured` to `false`, retire the Config Doctor and incomplete-document repair request handlers immediately, and recheck the current setting and database readiness when either handler runs.
- Preserve the existing ordered flag-file recovery handlers: SCRAM at priority 5, fetch-all at priority 10, and rebuild-all at priority 20. These files express an explicit recovery instruction and may invoke their focused storage or rebuild service while ordinary replication remains gated.
- Present the compatibility review at priority 30, after any selected recovery operation. A recovery handler which cancels start-up, keeps SCRAM active, or schedules a restart returns `false`, so the current process does not open a competing compatibility dialogue. If recovery completes and start-up continues, the dialogue opens before normal synchronisation is allowed to resume.
- Keep database preparation independent of an unanswered compatibility dialogue, because the compatibility gate already blocks replication. Before Config Doctor begins its interactive checks, await the active initial review so that the two update dialogues cannot overlap.
@@ -100,4 +101,4 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex
- Unit and Compose tests verify that ordinary P2P replication observes the policy, explicit P2P rebuild uses the setup bypass, and replacement leaves host actions on the current replicator.
- A real-Obsidian settings test verifies the dedicated summary and details dialogues, captures representative screenshots, confirms that the acknowledged internal version advances only after explicit resume, and confirms that the Change Log contains no acknowledgement control.
- The real-Obsidian CouchDB workflow starts from configured plug-in data without a device-local marker, verifies the copied-or-restored Vault explanation, resumes through the actual dialogue, and then completes remote metadata, chunk, and activity checks. The two-Vault workflow performs the same review once per isolated Vault before reusing the acknowledged device state for later process launches.
- Unit tests fix the layout-ready priority after the three flag-file recovery priorities, so a recovery which stops start-up cannot race the compatibility dialogue.
- Unit tests fix configured Vault admission at priority 1, the three flag-file recovery priorities at 5, 10, and 20, and compatibility review at priority 30. A recovery which stops start-up therefore cannot race the compatibility dialogue.
@@ -0,0 +1,797 @@
---
date: 2026-09-02
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.23"
status: accepted
series: replicator-capabilities-and-lifecycle
part: 1 of 3
---
# 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
Accepted and implemented in Commonlib 0.1.21 and Self-hosted LiveSync 1.0.23.
This record defines the provider, capability, lifecycle, interaction,
ownership, and probe boundaries required by current Self-hosted LiveSync
consumers. It is the generic part of the series; the P2P-specific ownership
rules live in Part 2, and the completed implementation sequence lives in Part
3. The current structure is summarised in the
[Replicator architecture](../design_docs/replicator_architecture.md) design
document.
Stage 3 in Part 3 is complete. The stable P2P service and room-session owner in
Part 2 supersede the replaceable LiveSync P2P Replicator ownership described by
the earlier P2P room and transport lifecycle record. That accepted record
remains authoritative for its retained Trystero room, physical-peer, and relay
ownership decisions.
## Context
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.
Every current `LiveSyncBaseCore` host composes CouchDB and Object Storage.
`WebPeerRuntime` is a separate P2P-only host composition, and P2P remains a
compile-time feature choice for the other hosts. A host can include or omit a
provider without changing the generic scheduling feature. Adding another
current provider requires a Commonlib kind and support declaration, host
composition, Setup/profile schema handling, and provider-specific tests. It
does not require a runtime plug-in registry or behaviour for unknown provider
kinds.
Each provider definition supplies:
- 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;
const REPLICATION_PROGRESS_PRESENTATIONS = {
QUIET: "quiet",
NOTICE: "notice",
} as const;
type ReplicationProgressPresentation =
(typeof REPLICATION_PROGRESS_PRESENTATIONS)[keyof typeof REPLICATION_PROGRESS_PRESENTATIONS];
interface UserInitiatedOneShotRequest {
readonly trigger: "manual";
readonly progressPresentation: ReplicationProgressPresentation;
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.
Progress presentation and interaction authority are independent parts of the
request. `quiet` suppresses routine Notice-level progress, but does not remove
authorised peer selection or failure-recovery dialogue. `notice` permits
routine progress, but cannot grant an interaction which the request authority
forbids. Unattended work always uses `quiet` with `NO_INTERACTION`; it may retain
an informational diagnostic, but no-target, authentication, tweak-mismatch,
and overlapping-transfer settlements must not promote themselves to a Notice.
This remains a request-local policy, not a new process-wide presentation
framework.
The ordinary typed OneShot entry points deliberately retain these established
interaction shapes:
| Entry point | Routine progress | Interaction authority | Request received while another OneShot attempt is active |
| --- | --- | --- | --- |
| Command-palette `Sync now` | Quiet | Peer selection and failure recovery remain permitted | Settles as `blocked: replication-in-progress` |
| Ribbon synchronisation action | Notice | Full user-initiated authority | Settles as `blocked: replication-in-progress` |
| Config Sync `Sync once` | Notice | Full user-initiated authority | Settles as `blocked: replication-in-progress` |
| CLI `sync` | Notice | Full user-initiated authority, subject to the host's available interaction mechanisms | Settles as `blocked: replication-in-progress` |
| Resume, periodic, database-event, editor-save, file-open, merge, and daemon triggers | Quiet | `NO_INTERACTION` | Settles as `blocked: replication-in-progress` |
The OneShot owner is acquired before readiness evaluation and retained through
provider acquisition, finite activity, and failure handling. A later request
is not queued, joined to the running result, or treated as a failed transfer;
no work starts for that request. Directional Fetch and Rebuild, P2P-specific
commands, and other maintenance workflows remain outside this ordinary
OneShot gate and retain their own activity ownership.
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.
The same rule applies to central milestone mutation and verification. A
milestone mutation may merge an available document or initialise one after an
explicit `not-found` result. An unavailable read rejects before upload. A
postcondition reader reports that unavailability as a read failure with its
diagnostic detail; it does not report that the milestone is missing.
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.
The active context atomically carries the provider, Replicator, and private
configuration identity. A settings-bearing operation captures one effective
settings snapshot, then reprojects and compares its identity inside admission
immediately before provider dispatch. A mismatch settles without combining a
new setting with an earlier Replicator. An explicit stop request acts on the
exact active owner and therefore does not require a settings comparison.
New typed production work cannot synchronously inspect an unreserved active
context. It must acquire the context or run inside the admitted callback
boundary. The synchronous `inspectActiveReplicatorContext()` view is protected
and exists only for lifecycle diagnostics and focused tests.
The public `getActiveReplicator()` remains temporarily for named compatibility
consumers and retains its established missing-active diagnostic. It is not a
typed ownership path. A separate side-effect-free `hasActiveReplicator()`
predicate may distinguish a compatibility Replicator whose provider was not
composed from complete absence. It returns neither the Replicator nor its
context, and cannot be used to dispatch work.
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.
A Journal connectivity preflight is not itself cancelled by this role. The
Replicator instead records a private Stop generation when that preflight begins
and checks it again before entering `sync()`, `sendLocalJournal()`, or
`receiveRemoteJournal()`. A Stop admitted while the preflight is pending can
therefore wait for the attempt to settle without allowing a new client transfer
to start afterwards.
The bounded Continuous startup call and an explicit stop request are admitted
against their exact publication. Continuous admission ends when the provider
has registered ownership and settled startup; it is never retained for the
lifetime of the long-lived task. Directional transfer and central-remote
administration stop the admitted publication's active transfer before their
exclusive operation begins. An unavailable or failed stop prevents that
operation rather than allowing transfer and mutation to overlap.
`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.
The CouchDB synchronisation-information resource resolves `false` only when it
observes incompatible synchronisation information. Connection, setup, and
verification failures reject so a settings caller can report operational
failure separately from incompatibility. Connection-probe result presentation
is likewise explicit: `showResult` retains the established CouchDB success or
failure Notice, while an ordinary silent probe emits neither result Notice.
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 which collapse an
operational failure into incompatibility or absence, 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
- [Project glossary](../glossary.md#developer-and-design-terms)
- [Part 2: P2P service and session lifecycle](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md)
- [Part 3: migration plan and verification](2026_08_replicator_capabilities_03_migration_plan.md)
- [Bounded Remote Activity](2026_07_bounded_remote_activity.md)
- [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,409 @@
---
date: 2026-09-02
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.23"
status: accepted
series: replicator-capabilities-and-lifecycle
part: 2 of 3
---
# 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
Accepted and implemented in Commonlib 0.1.21 and Self-hosted LiveSync 1.0.23.
This record defines the P2P service owner, room-session boundary, narrow
contract views, automation demands, replacement fencing, and trigger
semantics. Generic provider and capability rules are owned by Part 1; the
completed implementation and verification order is recorded in Part 3.
The implemented state is recorded separately in Commonlib's
[P2P transport lifecycle](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/p2p-transport-lifecycle.md)
design document and summarised with the generic provider lifecycle in the
[Replicator architecture](../design_docs/replicator_architecture.md) design
document. It supersedes the replaceable LiveSync P2P Replicator and ownership
of the replaceable result returned by the `serviceFeature`, as described by
the accepted [P2P Room and Transport Lifecycle](2026_07_p2p_transport_lifecycle.md)
record.
The accepted record's decisions about serialised room operations,
`room.leave()`, Trystero-owned physical peers, and relay reconnection remain in
force. This ADR records the product decision; the Commonlib design document
records the implemented names and ownership boundaries.
## 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.
Host lifecycle closure has a second, private, reversible state. The host sets
that state before it cancels delayed automation and closes the room. While it
is set, settings reconciliation and finite stable-view operations cannot add a
room demand. Only explicit connect, database-rebuild continuation, or the
AutoStart schedule established by a resumed host lifecycle clears it; merely
reconciling saved settings does not.
This state does not replace or clear the explicit-disconnect veto. Explicit
connect clears both states, resumed AutoStart still observes the user's veto,
and Rebuild remains the separately authorised continuation described above.
The distinction is private service policy, not another public capability or
room owner.
### 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, the
settings which affect transport, the device identity, and the current local
database identity. It is not a new persisted profile identifier or a
device-local override. Automation and admission policy is reconciled on the
current room. The service reconciles the 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. A profile-selection or policy-only
change which preserves the effective binding keeps the room and reconciles its
current policy. A real replacement 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,
host lifecycle closure has been resumed, 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, resumed lifecycle, cleared host closure, 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` | Cleared host closure, no disconnect veto, configured names, and advertisements 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.
An enabled P2P provider with an empty configured target set settles as
`blocked/no-targets`; it is not reported as an unconfigured provider.
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.
- Host lifecycle closure cannot be undone by settings reconciliation or a
finite operation before an explicit resume boundary.
- 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
- [Project glossary](../glossary.md#developer-and-design-terms)
- [Part 1: core contract](2026_08_replicator_capabilities_01_core_contract.md)
- [Part 3: migration plan and verification](2026_08_replicator_capabilities_03_migration_plan.md)
- [P2P Room and Transport Lifecycle](2026_07_p2p_transport_lifecycle.md)
- [P2P Transport Compatibility Controls](2026_08_p2p_transport_compatibility.md)
- [Bounded Remote Activity](2026_07_bounded_remote_activity.md)
@@ -0,0 +1,729 @@
---
date: 2026-09-02
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.23"
status: accepted
series: replicator-capabilities-and-lifecycle
part: 3 of 3
---
# 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
Accepted and implemented in Commonlib 0.1.21 and Self-hosted LiveSync 1.0.23.
The stages below record the implementation and verification order; they were
not independently releasable states. The target matrix, ownership boundaries,
and contracted production-consumer migrations in Parts 1 and 2 are complete.
Items which Stage 7 explicitly defers remain separate compatibility work rather
than incomplete stages. The current result is summarised in the
[Replicator architecture](../design_docs/replicator_architecture.md) design
document.
## Migration rules
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.
Before Stage 4 supplied target-aware unattended P2P, each host composition
declared generic `P2P_SyncOnReplication` as `not-implemented`. Its automatic
request settled without UI with an explicit blocked result. Existing AutoSync,
AutoWatch, and accepted incoming-request paths continued with the Stage 2 gate.
This was a temporary migration state, not the target matrix in Part 1.
Apply and test the CLI scheduling precedence defined in Part 1, so the daemon
and scheduling context cannot schedule duplicate initial or recurring work.
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.
Completion of this stage added a bounded supersession note to the accepted P2P
lifecycle record and updated `devs.md` from the replaceable concrete Replicator
getter to the stable contract views. The accepted Trystero peer and relay
ownership rules remain in force rather than being rewritten as part of this
migration.
## Stage 4: add target-aware unattended P2P orchestration
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. With Stage 3 complete, Part 2 supersedes the portions of the
accepted July 2026 record concerning the replaceable Replicator and ownership
of the replaceable result returned by the `serviceFeature`. Its Trystero peer
and relay decisions remain unchanged.
Commonlib's
[P2P transport lifecycle](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/p2p-transport-lifecycle.md)
design document records the implemented Stage 3 and Stage 4 ownership, demand,
automation, replacement, and shutdown behaviour. This document remains the
migration and verification sequence rather than a second description of the
implemented state.
## Stage 5: separate active construction and flow-specific probes
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. Stage 7 reviewed its possible removal and deferred it pending
an external compatibility decision.
The current host composition has migrated CouchDB and Object Storage connection
checks, passphrase inspection, preferred-tweak reads, CLI remote status and
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 completed the Stage 5 construction and probe boundary. At that
intermediate point it was not itself a release decision: Stage 6 still had to
complete the active-publication lifecycle and return an exact outcome for each
attempt. Complete retirement of the compatibility facade was not a prerequisite
for issue 1140.
## Stage 6: harden the active lifecycle and exact attempt outcome
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;
- the atomic active context retains that private identity, and every
settings-bearing dispatch checks its captured settings against the admitted
publication immediately before calling provider code;
- typed finite dispatch reserves the exact readiness-tested publication and
releases it before failure recovery;
- the bounded Continuous startup call and explicit stop request re-admit their
exact publication, while a registered long-lived task remains outside a
lifetime reservation;
- directional transfer and central-remote administration stop active transfer
work inside the same admission before beginning their exclusive operation;
- 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 a task admitted against the exact publication while
ignoring an unrelated bounded activity;
- context acquisition waiting for a queued replacement rather than returning a
stale or intermediate publication;
- rejecting central-remote administration releasing its reservation before
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 the lifetime of Continuous replication
outside this reservation. Re-admit only its bounded startup call and the
bounded explicit stop request. 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 the focused
`ReplicatorService.activeReplicatorState`,
`ReplicationService.typedReplication`, `ReplicationService.readiness`,
`ReplicatorService.remoteResourceResolver`, and
`ReplicatorService.centralRemoteAdministration` collaborators, so another
split would not improve the current test seams;
- every provider explicitly declares Continuous support or inapplicability;
- new typed work uses only acquired or admitted active-context access. The
public legacy Replicator getter remains temporarily for named compatibility
consumers, while one side-effect-free presence predicate classifies a legacy
active instance without returning it or emitting its missing-active Notice.
A protected synchronous context 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.
One concrete compatibility risk remains deferred with that maintenance work.
Journal `tryResetRemoteDatabase()` and `tryCreateRemoteDatabase()` synchronously
close and replace their lazy client without first awaiting the transfer
settlement owned by `terminateSync()`. CouchDB awaits its corresponding close.
Maintained reset and rebuild workflows normally stop ordinary synchronisation
before destructive remote work, but the Journal compatibility methods neither
encode nor independently test that precondition. A later bounded change must
first reproduce the race, then choose workflow-owned suspension, admitted
maintenance, or same-instance transfer settlement. It must not add a generic
provider capability merely to retire the compatibility facade.
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.
A subsequent falsification and quality pass found further bounded corrections
inside the same owners:
- directional failures retain the immutable compatibility hint from their
exact admitted attempt, and recovery re-admits that failed context;
- settings-bearing dispatches correlate their snapshot with the active
configuration identity, while bounded Continuous startup and explicit stop
re-admit the exact publication;
- directional transfer and central-remote administration stop active transfer
work before their exclusive operation;
- readiness calls the application lifecycle method, rather than testing the
method object;
- cancelled or incomplete P2P pull and push outcomes are not reported as
success by the CLI or Obsidian UI, and ordinary UI transfer uses the stable
targeted-transfer view rather than the compatibility Replicator. The
retained compatibility entry opens that ordinary UI only; it does not
perform the transfer;
- Journal stop does not construct an unused client, and waits for the transfer
promises admitted at its stop boundary, including synchronous setup re-entry;
and
- remote-size inspection uses one settings snapshot and preserves an observed
zero rather than treating it as absence.
These are lifecycle, settlement, and compatibility-consumer corrections. They
do not expand the capability or facility tables.
A later contract reconciliation found four more bounded truthfulness gaps
inside the same existing owners:
- Object Storage central milestone mutation and postcondition verification
preserve `available`, `not-found`, and `unavailable`; only explicit absence
can initialise a document, and unavailability cannot upload or become a
missing-milestone result;
- an enabled P2P provider with no configured targets preserves the Replicator's
`blocked/no-targets` result instead of reporting provider absence;
- typed active acquisition classifies expected absence through a non-owning,
side-effect-free presence predicate rather than the Notice-producing legacy
getter; and
- the CLI prints a stable explanation when central administration is rejected
because the active configuration changed before admission.
These corrections require no new capability, resource family, state machine,
or presentation framework.
A subsequent lifecycle and diagnostic falsification found four further regressions
at those established boundaries:
- CouchDB synchronisation-information inspection now distinguishes an observed
incompatibility from connection, setup, or verification failure, so the
settings flow retains its separate existing messages;
- an explicitly visible CouchDB connection probe retains its established
success or failure Notice, while silent probe consumers remain silent;
- a private Journal Stop generation prevents a connectivity preflight which
crossed a later Stop boundary from starting a client transfer; and
- host P2P lifecycle closure establishes a private reversible gate which
settings reconciliation and finite views cannot clear. Explicit connect,
database-rebuild continuation, and resumed AutoStart scheduling remain the
declared reopen boundaries.
These corrections refine existing resource, transfer-stop, and P2P lifecycle
semantics. They add no provider capability, resource kind, public state, or
presentation framework, so the Part 1 matrices 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, settings-bearing dispatch correlation, 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 exact
admission, bounded Continuous startup, exclusive-operation ordering, and 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;
- Journal central milestone mutation rejecting unavailable reads without an
upload, and Object Storage postcondition verification preserving the same
read failure and diagnostic detail;
- Journal stop avoiding lazy resource construction, settling the transfer
promises admitted at its stop boundary, and preventing a deferred
connectivity preflight from entering a client transfer after Stop;
- 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;
- caller-authority preservation for unattended P2P presentation and truthful
Journal and active-lifecycle closure diagnostics;
- remote-size inspection retaining one settings snapshot and reporting a zero
estimate as an observation;
- P2P configured-target execution preserving `blocked/no-targets`; and
- P2P host lifecycle closure blocking settings reconciliation and finite room
demand until explicit connect, rebuild continuation, or resumed AutoStart;
- expected typed absence producing no legacy missing-active Notice.
### 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;
- ordinary Obsidian P2P transfer using the stable targeted-transfer view, with
cancelled or incomplete pull and push outcomes remaining non-successful;
- 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, including distinct CouchDB incompatibility and operational
failure messages, explicit visible probe Notices, and silent ordinary
probes;
- readiness invoking the application lifecycle predicate;
- 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, including a diagnostic for active-configuration mismatch;
- 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.
Deterministic verification must also confirm that a temporarily unavailable
synchronisation-parameter read does not upload a new Security Seed.
The verification report must identify which boundary each real-runtime
scenario deliberately exercised. A broad passing suite is not direct evidence
for Object Storage `syncOnStart`, an injected unavailable read, P2P automatic
scheduling, or active-relay Setup arbitration unless that scenario caused the
boundary. Deterministic unavailable-read fault injection may remain at the
owned contract-test seam when no reliable real-runtime injection exists; the
remaining runtime limitation must then be stated rather than represented as a
passing end-to-end scenario.
The maintained Object Storage Setup URI workflow now exercises the migrated
start-up combination deliberately. It persists `liveSync: true` and
`syncOnStart: true` on the first device, keeps Periodic replication disabled,
stops that device before the second device writes the return note, and then
restarts it. The workflow waits for that note without requesting manual
replication. Object Storage declares Continuous not applicable, so a successful
return journey directly exercises the unattended OneShot fallback owned by
start-up scheduling. A settings-save reconciliation before the first device
stops cannot satisfy the assertion because the return note does not yet exist.
The maintained real-Obsidian P2P Setup URI workflow directly exercises Setup
URI application, initial Fetch, peer approval and actions, explicit disconnect
and reconnect, and bidirectional note transfer. It does not deliberately
exercise accepted configured-peer advertising, watched-peer broadcast,
suspension before a delayed AutoStart callback, or active-room relay
arbitration during P2P Setup. Those exact boundaries retain deterministic unit
or contract evidence and are not represented as direct real-runtime proof.
The maintained MinIO harness has no existing seam which can make exactly one
synchronisation-parameter or milestone read unavailable. Stopping MinIO or
using invalid credentials fails the preceding bucket-availability check,
rather than the owned control-document read. The issue 1147 unavailable-read
case therefore remains deterministic at the storage adapter, Journal core,
and Replicator contract-test seams, where the assertions preserve
`unavailable` and forbid creation or upload. It is not represented as a
successful real-Obsidian fault-injection scenario.
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
- [Project glossary](../glossary.md#developer-and-design-terms)
- [Part 1: core contract](2026_08_replicator_capabilities_01_core_contract.md)
- [Part 2: P2P service and session lifecycle](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md)
- [P2P Room and Transport Lifecycle](2026_07_p2p_transport_lifecycle.md)
- [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)
+193 -144
View File
@@ -1,173 +1,222 @@
# Data Structures of Self-Hosted LiveSync
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
## Overview
# Database Data Structures
Self-hosted LiveSync uses the following types of documents:
## Scope and Authority
- Metadata
- Legacy Metadata
- Binary Metadata
- Plain Metadata
- Chunk
- Versioning
- Synchronise Information
- Synchronise Parameters
- Milestone Information
This document is a developer overview of the database structures used by the
current Self-hosted LiveSync 1.0 series. It is not a stable, forward-compatible
API for constructing CouchDB documents by hand.
## Description of Each Data Structure
The executable authority for document types, path and identifier encoding,
chunk splitting and hashing, encryption, compression, and content
reconstruction is the exact `@vrtmrz/livesync-commonlib` version recorded in
the repository lockfile. Commonlib owns this domain under the
[package-boundary decision](adr/2026_07_common_library_package_boundary.md).
When this overview and that installed package differ, correct this document
and treat the package behaviour as authoritative for the affected release.
All documents inherit from the `DatabaseEntry` interface. This is necessary for conflict resolution and deletion flags.
Three representations must be distinguished:
1. the decoded application representation used by Commonlib services;
2. the local PouchDB representation, including CouchDB revision metadata; and
3. the raw remote representation after any configured compression, E2EE, or
path-obfuscation transform.
The examples below describe the first two representations unless a section
explicitly discusses the raw remote representation. The exact raw remote shape
depends on the configured transforms and protocol version and cannot be
inferred from the decoded or local examples alone.
## Principal Document Families
- file Metadata, including compatibility-only legacy Metadata;
- Chunks and compatibility transport structures such as Chunk Packs;
- database version, synchronisation, Milestone, and Node information; and
- CouchDB revision and deletion records.
Commonlib's `EntryDoc` is a union across several of these families. It is not
synonymous with file Metadata.
## Common CouchDB Fields
Database documents share this base shape:
```ts
export interface DatabaseEntry {
_id: DocumentID;
_rev?: string;
_deleted?: boolean;
_conflicts?: string[];
}
```
### Versioning Document
- `_id` identifies one CouchDB document.
- `_rev` identifies one revision of that document.
- `_conflicts` is returned when conflict information is requested. It is
CouchDB revision metadata, not part of the persisted application document.
- `_deleted: true` creates a CouchDB tombstone. It is distinct from the
logical file-deletion field `deleted: true` described below.
This document stores version information for Self-hosted LiveSync.
The ID is fixed as `obsydian_livesync_version` [VERSIONING_DOCID]. Yes, the typo has become a curse.
When Self-hosted LiveSync detects changes to this document via Replication, it reads the version information and checks compatibility.
This internal database version is independent of the plug-in's SemVer version. The last version explicitly acknowledged on a device is stored through Commonlib's device-local configuration contract. When that version differs, or when a settings migration requires review, Self-hosted LiveSync presents a dedicated compatibility dialogue and blocks replication without changing the user's automatic synchronisation choices. A supported upgrade can resume only after explicit review. A downgrade from a newer acknowledged database version, or settings written by a future schema, remains blocked until a compatible plug-in is installed.
Please refer to negotiation.ts.
## File Metadata
### Synchronise Information Document
This document stores information that should be verified in synchronisation settings.
The ID is fixed as `syncinfo` [SYNCINFO_ID].
The information stored in this document is only the conditions necessary for synchronisation to succeed, and as of v0.25.43, only a random string is stored.
This document is only used during rebuilds from the settings screen for CouchDB-based synchronisation, making it like an appendix. It may be removed in the future.
### Synchronise Parameters Document
This document stores synchronisation parameters.
Synchronisation parameters include the protocol version and salt used for encryption, but do not include chunking settings.
The ID is fixed as `_local/obsidian_livesync_sync_parameters` [DOCID_SYNC_PARAMETERS] or `_obsidian_livesync_journal_sync_parameters.json` [DOCID_JOURNAL_SYNC_PARAMETERS].
This document exists only on the remote and not locally.
This document stores the following information.
It is read each time before connecting and is used to verify that E2EE settings match.
This mismatch cannot be ignored and synchronisation will be stopped.
Current files are stored as chunked Metadata. The following is a simplified
shape; the exported Commonlib declarations remain authoritative:
```ts
export interface SyncParameters extends DatabaseEntry {
_id: typeof DOCID_SYNC_PARAMETERS;
type: (typeof EntryTypes)["SYNC_PARAMETERS"];
protocolVersion: ProtocolVersion;
pbkdf2salt: string;
}
```
#### protocolVersion
This field indicates the protocol version used by the remote. Mostly, this value should be `2` (ProtocolVersions.ADVANCED_E2EE), which indicates safer E2EE support.
#### pbkdf2salt
This field stores the salt used for PBKDF2 key derivation on the remote. This salt and the passphrase provides E2EE encryption keys.
### Milestone Information Document
This document stores information about how the remote accepts and recognises clients.
The ID is fixed as `_local/obsidian_livesync_milestone` [MILESTONE_DOCID].
This document exists only on the remote and not locally.
This document is used to indicate synchronisation progress and includes the version range of accepted chunks for each node and adjustment values for each node.
Tweak Mismatched is determined based on the information in this document.
For details, please refer to LiveSyncReplicator.ts, LiveSyncJournalReplicator.ts, and LiveSyncDBFunctions.ts.
```ts
export interface EntryMilestoneInfo extends DatabaseEntry {
_id: typeof MILESTONE_DOCID;
type: EntryTypes["MILESTONE_INFO"];
created: number;
accepted_nodes: string[];
node_info: { [key: NodeKey]: NodeData };
locked: boolean;
cleaned?: boolean;
node_chunk_info: { [key: NodeKey]: ChunkVersionRange };
tweak_values: { [key: NodeKey]: TweakValues };
}
```
### locked
If the remote has been requested to lock out from any client, this is set to true.
When set to true, clients will stop synchronisation unless they are included in accepted_nodes.
### cleaned
If the remote has been cleaned up from any client, this is set to true.
In this case, clients will stop synchronisation as they need to rebuild again.
### Metadata Document
Metadata documents store metadata for Obsidian notes.
```ts
export interface MetadataDocument extends DatabaseEntry {
_id: DocumentID;
type ChunkedMetadata = DatabaseEntry & {
ctime: number;
mtime: number;
size: number;
deleted?: boolean;
eden: Record<string, EdenChunk>; // Obsolete
eden: Record<string, { data: string; epoch: number }>;
path: FilePathWithPrefix;
children: string[];
type: EntryTypes["NOTE_LEGACY" | "NOTE_BINARY" | "NOTE_PLAIN"];
}
```
### type
This field indicates the type of Metadata document.
By convention, Self-hosted LiveSync does not save the mime type of the file, but distinguishes them with this field. Please note this.
Possible values are as follows:
- NOTE_LEGACY: Legacy metadata document
- Please do not use
- NOTE_BINARY: Binary metadata document (newnote)
- NOTE_PLAIN: Plain metadata document (plain)
#### children
This field stores an array of Chunk Document IDs.
#### \_id, path
\_id is generated based on the path of the Obsidian note.
The validation and explicit repair contract for normal-file Metadata whose
actual ID does not match the ID derived from its stored path is defined in
[Normal-file Metadata Document ID Validation and Repair](design_docs/metadata_document_id_validation_and_repair.md).
- If the path starts with `_`, it is converted to `/_` for convenience.
- If Case Sensitive is disabled, it is converted to lowercase.
When Obfuscation is enabled, the path field contains `f:{obfuscated path}`.
The path field stores the path as is. However, when Obfuscation is enabled, the obfuscated path is stored.
When Property Encryption is enabled, the path field stores all properties including children, mtime, ctime, and size in an encrypted state. Please refer to encryption.ts.
### Chunk Document
```ts
export type EntryLeaf = DatabaseEntry & {
_id: DocumentID;
type: EntryTypes["CHUNK"];
data: string;
type: "plain" | "newnote";
};
```
Chunk documents store parts of note content.
`children` contains Chunk document IDs in reconstruction order. A normal save
persists every referenced Chunk before it persists the Metadata which names
those Chunks. The writes are separate database operations rather than one
atomic transaction, so another client may still observe the Metadata first.
The resulting retrieval contract is documented in
[Chunk Retrieval and Waiting](design_docs/chunk_retrieval_and_waiting.md).
- The type field is always `[CHUNK]`, `leaf`.
- The data field stores the chunk content.
- The \_id field is generated based on a hash of the content and the passphrase.
The current persisted file types are:
Hash functions used include xxHash and SHA-1, depending on settings.
Chunking methods used include Contextual Chunking and Rabin-Karp Chunking, depending on settings.
- `plain`, for text content represented by literal text Chunks; and
- `newnote`, for binary content represented by Base64 Chunks.
The compatibility-only `notes` type stores content directly in its `data`
field rather than in `children`. Existing data may be read through selected
legacy paths, but current writers do not create `notes` documents, and not
every current replication path accepts newly created legacy documents.
`datatype` appears on Commonlib's loaded and saving representations. The
current Metadata writer does not persist it, so it is absent from ordinary
current CouchDB Metadata.
`eden` remains in the shared type for existing data compatibility. New
configuration does not enable Eden, and current writers do not create
incubated Eden Chunks for a new configuration.
### Times and Size
`ctime` and `mtime` are Unix epoch times in milliseconds. `size` is the byte
size of the decoded file content supplied by the storage boundary. Current
storage adapters and generated Blob paths obtain it from filesystem metadata
or `Blob.size`; JavaScript `String.length` is a UTF-16 code-unit count and is
not a valid substitute for non-ASCII content.
### Paths, Identifiers, and Namespaces
At the decoded boundary, `path` records the logical path, including any
feature namespace prefix. `_id` is derived from that path by Commonlib's path
service:
- a path beginning with `_` receives a leading `/` in its document ID so that
CouchDB does not interpret it as a reserved identifier;
- the path is folded to lower case only when
`handleFilenameCaseSensitive` is disabled; and
- when path obfuscation is enabled, the body of the document ID is replaced
by an `f:` SHA-256-derived value. A feature prefix is retained, so an
obfuscated Hidden File Sync ID can begin with `i:f:`.
The main namespaces are:
| Prefix | Meaning |
| ------ | ------------------------------------------------------- |
| none | An ordinary Vault file |
| `i:` | Hidden File Sync Metadata |
| `ix:` | Customisation Sync Metadata |
| `ps:` | Compatibility namespace for plug-in storage data |
| `f:` | Obfuscated document-ID body |
| `h:` | Chunk document |
| `h:+` | Chunk whose identifier incorporates encryption material |
Namespaces identify storage and path handling; they do not by themselves
select an Entry `type`. Current Hidden File Sync and Customisation Sync writers
store chunked `plain` or `newnote` documents under `i:` and `ix:`. The
application-local `type: "plugin"` interface is not a Commonlib Entry type and
is not the current Customisation Sync storage format. Although Commonlib
retains an `internalfile` constant for compatibility, current Hidden File Sync
producers do not use it as their persisted type.
The decoded `path` does not become `f:{obfuscated path}`. Compression, E2EE,
and path obfuscation can change raw remote identifiers and properties.
Commonlib owns those transforms, and their exact representation depends on the
selected settings.
The validation and explicit repair contract for ordinary-file Metadata whose
stored `_id` does not agree with its decoded `path` is defined in
[Normal-file Metadata Document ID Validation and Repair](design_docs/metadata_document_id_validation_and_repair.md).
## Chunk Documents
```ts
export type EntryLeaf = DatabaseEntry & {
type: "leaf";
data: string;
isCorrupted?: boolean;
};
```
A `leaf` stores one content-addressed piece. For `plain` Metadata, `data` is
literal text. For `newnote` Metadata, `data` is Base64 text representing binary
bytes. Concatenating and decoding the children in order reconstructs the
decoded file content.
Commonlib's configured `HashManager` produces content-derived Chunk
identifiers. Their representation can vary with compatibility and encryption
settings. Historical hash algorithms remain readable only as compatibility
settings.
Chunk revisions are content-derived irrespective of the obsolete stored
`doNotUseFixedRevisionForChunks` setting. Compression and E2EE may transform a
Chunk's raw remote `data` and add representation markers, so the remote value
can differ from the decoded Chunk data.
## File Deletion
LiveSync distinguishes two operations:
- `deleted: true` is a logical deletion of a file. With Metadata retention
enabled, an ordinary current-file deletion preserves the existing Metadata
fields and Chunk references, updates `mtime`, and creates a new Metadata
revision. This permits the deletion to participate in synchronisation and
conflict history.
- `_deleted: true` is a CouchDB tombstone for one document revision. That
revision does not retain the application body. Tombstones are used by
explicit compatibility and clean-up paths.
A logical deletion does not clear `children` or set `size` to zero. The
`deleteMetadataOfDeletedFiles` setting can request an immediate tombstone
instead. Whether retained, logically deleted Metadata is later tombstoned
depends on the configured deletion-retention settings.
Branch-specific conflict operations and their ancestry requirements are defined
in the [Conflict Resolution specification](specs_conflict_resolution.md).
## Control Documents
The principal control documents are:
| Document | Identifier | Purpose |
| ---------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Version information | `obsydian_livesync_version` | Records the internal database version. The historical spelling is retained for compatibility. |
| Synchronisation information | `syncinfo` | Stores rebuild-related synchronisation information for CouchDB-based operation. |
| CouchDB synchronisation parameters | `_local/obsidian_livesync_sync_parameters` | Stores the protocol version and PBKDF2 salt on the remote. |
| Journal synchronisation parameters | `_obsidian_livesync_journal_sync_parameters.json` | Journal counterpart of the synchronisation-parameter record. |
| Milestone information | `_local/obsidian_livesync_milestone` | Records accepted Nodes, locking, clean-up state, Chunk version ranges, and synchronisation tweak values. |
| Node information | `_local/obsidian_livesync_nodeinfo` | Records the local Node identifier and compatibility markers. |
The `_local/` records are CouchDB-local documents and do not replicate like
ordinary Metadata and Chunks. Synchronisation parameters are checked before
connecting; an incompatible protocol or encryption configuration stops
synchronisation rather than being ignored.
@@ -7,7 +7,7 @@ Accepted for a limited implementation.
## Problem and scope
This document uses the independent revision properties defined under
[Revision](../terms.md#revision) and the general state model in
[Revision](../glossary.md#revision) and the general state model in
[Conflict resolution and revision provenance](../specs_conflict_resolution.md).
Document History can reconstruct an available historical revision from its
@@ -0,0 +1,70 @@
---
date: 2026-09-04
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: unreleased
---
# Path component length compatibility
## Purpose
File systems place limits on each file or folder name, rather than applying one
common limit to an entire Vault-relative path. Those limits are also expressed
in different units. Self-hosted LiveSync therefore treats 255 UTF-8 bytes as a
focused Android and Linux compatibility warning, not as a universal definition
of a valid path.
## Basis for the 255-byte warning
- The Linux kernel documentation gives ext4 a maximum file-name length of
[255 bytes](https://www.kernel.org/doc/html/latest/filesystems/ext4/directory.html).
- The F2FS on-disk header defines
[`F2FS_NAME_LEN` as 255](https://android.googlesource.com/kernel/common/+/88d92fb1c034922572bab93482ac9cc61d4ba43c/include/linux/f2fs_fs.h)
and stores names in byte arrays.
- Android's MediaProvider uses a
[`MAX_FILENAME_BYTES` value of 255](https://android.googlesource.com/platform/packages/providers/MediaProvider/+/bae279463/src/com/android/providers/media/util/FileUtils.java)
when building file names. Its source notes that emulated storage can write to
ext4 through FUSE, where names are encoded as UTF-8.
- Android 11 and later use
[FUSE for emulated storage](https://source.android.com/docs/core/storage/fuse-passthrough),
with requests passing through to the underlying file system.
Together, these provide a conservative compatibility boundary for file names
which may reach Android or Linux storage. They do not show that every Android
device, storage provider, or Linux file system has the same limit.
## Why the rule is not universal
Other platforms describe component limits differently. Microsoft's file-system
comparison documents limits in
[Unicode characters](https://learn.microsoft.com/en-us/windows/win32/fileio/filesystem-functionality-comparison),
not UTF-8 bytes. Apple's HFS Plus format stores a name as up to
[255 16-bit `UniChar` values](https://developer.apple.com/library/archive/technotes/tn/tn1150.html).
Apple's APFS guidance discusses valid UTF-8 names, normalisation, and case
sensitivity, but does not establish a universal
[255-byte component rule](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/APFS_Guide/FAQ/FAQ.html).
A name can consequently exceed 255 UTF-8 bytes and still work on one platform,
or fail for another platform-specific reason while remaining below this
boundary.
## Product policy
Self-hosted LiveSync applies the warning as follows:
1. split the Vault-relative path on `/` and inspect each non-empty component;
2. measure each component after UTF-8 encoding;
3. accept 255 bytes without this warning and warn at 256 bytes or more;
4. identify every over-limit file or folder name in the active-file status;
5. do not reject, truncate, or rename the path; and
6. treat the result of the real storage operation as authoritative.
If a scan cannot process an individual file, its path is recorded in the
verbose log and remains eligible for a later retry. Ordinary start-up may still
become ready so that unaffected files can synchronise. Explicit Fetch and
Rebuild operations retain strict scan completion because they establish an
authoritative local or remote state.
This policy does not replace the existing checks for reserved characters,
case collisions, ignore rules, or configured file-size limits.
+333
View File
@@ -0,0 +1,333 @@
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
commonlib-source-commit: e770f617ff0fc88f4823226b0ab3aefdff50cc1e
status: accepted
---
# Replicator architecture
This is the implemented architecture for Self-hosted LiveSync 1.0.24 with `@vrtmrz/livesync-commonlib` 0.1.21. It is an implementation overview for developers maintaining the composition or adding a built-in provider. The corresponding Commonlib source was inspected at commit `e770f617ff0fc88f4823226b0ab3aefdff50cc1e`.
## Status, scope, and source-of-truth boundary
The plug-in repository is the source of truth for host composition, host scheduling, Obsidian/CLI/WebApp/WebPeer integration, provider declarations, and host-owned resource adapters. Commonlib is the source of truth for the provider contract, the active-publication state machine, typed replication runners, P2P service ownership, and Journal transport primitives. This repository consumes Commonlib as the published `0.1.21` package; it does not maintain a source mirror or a generated fallback.
The implementation has three built-in providers:
- CouchDB, composed by this repository;
- Object Storage, composed by this repository; and
- P2P, composed by Commonlib's `useP2PReplicatorFeature` in each application runtime which supports it.
The catalogue is closed at host composition. `src/common/replicatorProviders.ts` exhaustively composes the central providers, while the Commonlib P2P feature composes its P2P provider. This is not a runtime third-party registry: a provider cannot be added by registering a name, loading a plug-in, or supplying a setting at runtime. Adding a provider means changing the relevant Commonlib and host composition, then shipping and testing that composition.
The three capability ADRs record the decisions which led to this shape. They are useful decision history, but this document describes the current implementation and its ownership boundaries rather than repeating the ADR sequence.
## Terminology
The [Project glossary](../glossary.md#developer-and-design-terms) is canonical
for project-specific vocabulary in this document. In particular, see
[Replicator](../glossary.md#replicator),
[Replicator provider definition](../glossary.md#replicator-provider-definition),
[Active publication](../glossary.md#active-publication),
[Admission and reservation](../glossary.md#admission-and-reservation),
[Configuration identity](../glossary.md#configuration-identity),
[Capability](../glossary.md#capability),
[Central remote](../glossary.md#central-remote),
[Publication retirement](../glossary.md#publication-retirement), and
[Fence, generation, and epoch](../glossary.md#fence-generation-and-epoch).
The glossary also fixes the ownership meanings of
[Adjunct P2P transport](../glossary.md#adjunct-p2p-transport),
[Remote resource and probe](../glossary.md#remote-resource-and-probe),
[Non-owning adapter](../glossary.md#non-owning-adapter),
[P2P service, room session, and demand](../glossary.md#p2p-service-room-session-and-demand),
[Interaction authority](../glossary.md#interaction-authority),
[Journal remote epoch](../glossary.md#journal-remote-epoch),
[Replication outcome](../glossary.md#replication-outcome), and
[Suspension](../glossary.md#suspension).
The user-facing glossary also distinguishes
[OneShot Sync from Continuous replication](../glossary.md#user-facing-and-operational-terms).
Names shown in code font are source identifiers, not additional prose terms.
## Ownership and topology
The portable topology below names ownership rather than merely call order. An arrow means that the object or layer composes, invokes, or owns the next item.
```text
Application composition
Obsidian main / CLI / WebApp --> LiveSyncBaseCore --+
WebPeerRuntime ------------------------------------+--> Service Hub
|
+----------------------------------------+---------------------+
| |
v v
ReplicatorService (Commonlib) ReplicationService (Commonlib)
| |
+--> closed provider catalogue +--> typed replication runners
| CouchDB / Object Storage / P2P | readiness + outcomes
| |
+--> active publication <------ exact-publication admission ---+
provider + instance + identity
useP2PReplicatorFeature (Commonlib)
|
+--> registers the P2P provider, whose factory creates non-owning active adapters
|
+--> stable P2P service views and lifecycle
|
+--> P2PRoomSessionOwner
|
+--> P2PAutomationCoordinator
+--> current P2PRoomSession
|
+--> P2PHost / TrysteroReplicator
|
+--> Trystero room, relays, and physical peers
```
`LiveSyncBaseCore` receives a Service Hub, registers the central provider definitions, composes `serviceFeature` functions, and retains only focused views. `WebPeerRuntime` composes directly over its browser Service Hub because it is a P2P-only host. The P2P feature is composed by each supporting application runtime, so the CLI and web applications can use the same transport ownership without making the active adapter a second owner.
| Owner | Responsibility | Explicitly does not own |
| --- | --- | --- |
| Host composition | Selects the closed provider catalogue, supplies host adapters, and registers features before lifecycle work begins. | Active-instance retirement or operation admission. |
| Commonlib `ReplicatorService` | Provider registration, active publication, exact-publication reservations, serial lifecycle transitions, transfer stop during suspension or retirement, and physical close. | Replication readiness, trigger policy, or P2P room ownership. |
| Commonlib `ReplicationService` and its typed coordinator | User and unattended OneShot dispatch, Continuous startup, readiness, interaction authority, finite-activity accounting, outcomes, and failure hand-off. | Active publication replacement or scheduling policy. |
| Host replication scheduling `serviceFeature` | Decides when resume, Periodic, and Continuous requests may run, and fences stale scheduled work. | Provider construction, transfer mechanics, or active Replicator close. |
| Provider definition and runner | Declares what one remote kind supports and adapts its transfer results to typed outcomes. | Selecting when a request should run. |
| Stable P2P service and room-session owner | P2P demand, binding reconciliation, session retirement, finite room operations, automation state, and focused views. | The active P2P adapter's publication lifetime or Trystero's physical peers. |
## Request flow
1. Composition registers the central provider definitions before lifecycle-driven provider initialisation. P2P composition registers its own Commonlib provider and returns stable P2P views and lifecycle controls.
2. `ReplicatorService` serialises setting realisation, active-provider initialisation, database lifecycle transitions, suspension, and unload. `ReplicationService` owns per-request readiness. Resume work is separately owned by the host scheduling feature and the P2P service lifecycle.
3. `ReplicatorService` resolves the current `remoteType`, checks `isConfigured`, and computes the provider's opaque configuration identity. If the provider and identity are unchanged, the active publication is retained. If either changes, the replacement fence runs before a new publication is created.
4. A typed operation such as `runUserInitiated`, `runUnattended`, or `startContinuous` acquires the current publication after earlier queued lifecycle transitions have settled. It checks interaction authority, capability support, and readiness in the order required by the request type, then takes an immutable settings snapshot.
5. The operation admits a reservation against the exact publication and identity. The provider-specific runner owns the transfer; finite operations are counted as bounded remote activity, while continuous replication is not.
6. Provider resources are created through the declared resource factories when a feature needs a connection, preferred-tweak, Security Seed, or synchronisation-information probe. Resource ownership is explicit, and owned resources are disposed in the caller's `finally` path.
7. The runner returns a `ReplicationOutcome` rather than using `undefined` as a success signal. Documents delivered through `parseSynchroniseResult` are queued by the host result processor for local application; central compatibility recovery and Security Seed preflight remain host features around the typed operation.
8. Automatic `database-event`, `editor-save`, `file-open`, `merge`, `resume`, `periodic`, and `daemon` requests select unattended authority explicitly. Unattended work cannot prompt, select a peer interactively, or silently fall back to a legacy capability.
The active publication may change while a request is being prepared. The reservation keeps the admitted old instance alive until the operation settles; a new request admitted after the queued transition observes the new publication. A callback which holds a reservation must not await a lifecycle transition which itself waits for that reservation.
## Provider contract and current capability matrix
The Commonlib contract is deliberately small. A provider definition supplies the following shape (with the exact generic types omitted here for readability):
```typescript
{
kind,
diagnosticName,
readiness,
isConfigured(setting),
configurationIdentity(setting),
create(setting),
remoteResources,
centralRemoteAdministration?,
userInitiatedOneShot,
unattendedOneShot,
continuous,
stopActiveTransfer
}
```
`defineReplicatorProviderDefinitions` makes the definition map exhaustive for the selected `RemoteType` tuple and rejects duplicate, missing, extra, or mismatched runtime definitions. Capability declarations are explicit. `supported` adapts a provider runner to `ReplicationOutcome`; `not-implemented` and `not-applicable` produce typed blocked outcomes. The contract distinguishes user authority from `NO_INTERACTION`, so a provider cannot accidentally prompt from an unattended trigger.
The factory receives the fully merged effective settings. It returns a `ReplicatorInstance` with only four required lifecycle methods:
| Method | Contract |
| --- | --- |
| `initializeDatabaseForReplication()` | Prepare local state before publication. `false` rejects and disposes the candidate. |
| `openReplication(setting, keepAlive, showResult, ignoreCleanLock)` | Retained compatibility entry point for finite or Continuous work. A typed provider runner must convert its `void` or Boolean settlement to an explicit outcome; `void` is not finite success. |
| `terminateSync()` | Request cancellation of active transfer work and settle synchronously or asynchronously. It does not transfer ownership or replace physical close. |
| `closeReplication()` | Release resources owned by this instance. It runs only after admitted work drains; a non-owning adapter, such as P2P, must leave service-owned resources alone. |
Provider-specific methods remain on provider-specific interfaces or host-owned adapters. They are not added to the generic contract merely because an old compatibility class exposed them.
### Current capability matrix
| Capability | CouchDB | Object Storage | P2P |
| --- | --- | --- | --- |
| Readiness | Central remote preparation required | Central remote preparation required | Central preparation not applicable; peer readiness is provider-owned |
| Active Replicator factory | `LiveSyncCouchDBReplicator` | `LiveSyncJournalReplicator` | `P2PActiveReplicatorAdapter` over the stable P2P service |
| User-initiated OneShot | Supported | Supported | Supported, with explicit peer selection |
| Unattended OneShot | Supported | Supported | Supported for configured targets; no peer-selection prompt |
| Continuous replication | Supported | Not applicable | Not applicable |
| Stop active transfer | Supported | Supported | Supported; cancels finite operations while retaining the room when appropriate |
| Connection resource | Supported | Supported | Not applicable |
| Preferred-tweak resource | Supported | Supported | Not applicable |
| Security Seed resource | Supported | Supported | Not applicable |
| Synchronisation-information resource | Supported | Not applicable | Not applicable |
| Central remote administration | CouchDB administration supported | Object Storage administration supported | Not applicable |
The matrix is the current host composition, not a promise that every provider must support every row. A new provider must declare every resource kind and every operation capability, using `not-applicable` where the concept does not exist. Central remote administration is a cohesive capability: it covers the applicable verification milestone and mark-resolved, lock, and unlock mutations rather than exposing individual legacy helpers as generic operations.
## Active Replicator lifecycle and exact replacement fence
Commonlib's `ReplicatorService` serialises lifecycle transitions on one queue. It owns the active publication, reservations against that publication, transfer stop, and final close. The effective configuration identity is deliberately opaque; comparing it is valid, but inspecting or persisting it is not.
```text
absent
|
| configured lifecycle initialisation
v
candidate (private and not admitted)
| \
| initialised and current \ failed or stale --> closed; no active publication
v
active publication
| \ same provider and identity --> retained
| \ suspension ----------------> transfer stopped; publication retained
|
| provider, identity, database, or terminal lifecycle change
v
quiescing (removed from current; new admission fenced)
|
| stop --> drain exact reservations --> close --> complete retirement
v
absent --> optional replacement candidate
```
For a provider or effective-configuration change, the exact fence is:
1. Enqueue the transition on the serial lifecycle queue.
2. Read the current setting, resolve the closed provider definition, check `isConfigured`, and compute the effective identity.
3. If the current publication has a different provider or identity, call `beginRetirement`. This stops new reservations and removes the publication from the current slot.
4. Request the provider's `stopActiveTransfer` capability. The legacy `terminateSync` path is retained only for an untyped compatibility publication.
5. Await settlement of reservations admitted from the retiring publication. New operations cannot enter it.
6. Call the old instance's `closeReplication`.
7. Mark the retirement complete. No publication is installed between removal of the old publication and this completion.
8. Create a candidate through the provider definition, reset provider statistics, and yield the required microtask boundary.
9. Initialise the candidate for the current database and run `onBeforeReplicatorPublication` handlers.
10. Re-read the current setting, provider, and opaque identity. If any is stale, dispose the candidate and publish nothing; the queued lifecycle work will resolve the newer state.
11. Publish the provider, candidate instance, and identity atomically as the new active publication.
The same provider and identity retain the current publication. Database initialisation is a lifecycle boundary even when the setting identity is unchanged: the old publication is retired before the physical local database is replaced, then a candidate is created for the new database. A failed or stale candidate leaves the service without an active publication; it is not silently substituted with a previous instance.
If transfer stop fails, retirement still proceeds to draining and physical close. If physical close rejects, retirement remains fenced and no replacement can be published; a later serial transition may retry the same retirement. The service never restores admission to a publication once retirement has begun.
## Generation and epoch fences
These values protect different state machines. They must not be collapsed into one general-purpose generation.
| Fence | Owner and representation | Changes when | Protects | Does not protect |
| --- | --- | --- | --- | --- |
| Active publication identity | Commonlib `ActiveReplicatorPublication` object, containing provider, instance, and opaque configuration identity; not a numeric counter | Provider/configuration replacement, or a database lifecycle replacement | Admission and completion against the exact active instance | Delayed scheduling, P2P automation baselines, or Journal remote-wipe decisions |
| Host scheduling lifecycle generation | `ReplicationSchedulingContext.lifecycleGeneration` | Scheduling resumes after the lifecycle was disabled | Resume operations and timer callbacks from a previous host scheduling lifecycle | Provider replacement, P2P room callbacks, or Journal transfers |
| P2P service lifecycle generation | Private `P2PServiceState.lifecycleGeneration` | Explicit disconnect or host lifecycle closure | Delayed P2P AutoStart and service-level automatic demand | A room's operation set, automation baseline, or remote Journal epoch |
| P2P session object / epoch fence | The current `P2PRoomSession` object, its `acceptingOperations` flag, session abort signal, and the owner's lifecycle queue; there is no exported numeric P2P session epoch | Room binding replacement, retirement, or owner close | Room callbacks, finite operations, peer handlers, and stale candidate sessions | Cross-session automation deduplication and host scheduling |
| P2P automation generation | `P2PAutomationCoordinator.generation` | `beginLifecycle` or effective identity reconciliation (namespace or database object) | Completed peer baseline publication and stale automation completions | Room ownership, explicit disconnect veto, and physical peer connection ownership |
| Journal stop generation | `LiveSyncJournalReplicator.journalTransferStopGeneration` | `terminateSync` requests a stop | Admitted Journal transfers after setup and before `client.sync`; repeated stops share settlement | Provider publication identity and remote checkpoint/cache identity |
| Journal remote epoch | `CheckPointInfo.journalEpoch`, derived as `protocolVersion:pbkdf2salt` | Successfully read sync parameters yield a different value; a subsequent history probe decides whether checkpoint caches must be reset | Journal checkpoint and deduplication-cache reconciliation across remote histories | Local cancellation, provider retirement, or transfer admission |
In particular, a numeric value in one row cannot be used as evidence that an operation in another row is current. Failure to read Journal sync parameters does not produce a new remote epoch, and a P2P transport replacement does not by itself clear the automation coordinator's completed-peer baseline.
## Suspension, terminal retirement, and database replacement
| Event | `ReplicatorService` publication | P2P service and adapter | Result |
| --- | --- | --- | --- |
| Application suspension | Requests `stopActiveTransfer` and retains the active publication | `closeForLifecycle` closes the current room/session and invalidates delayed automation; the active adapter is non-owning and does not close the service through `closeReplication` | Suspension is reversible. Resumption schedules the appropriate P2P AutoStart and host replication work. |
| Provider setting or effective identity change | Runs the complete replacement fence | Reconciles or replaces the P2P room when its effective binding changes | The old publication/session cannot receive new work. |
| Database replacement or rebuild | Retires and closes the active publication before physical database teardown; database-ready events permit reinitialisation | Closes the P2P room before database destruction and creates a binding for the new database object | No provider or room may retain the old database. |
| Unload or terminal lifecycle close | Stops, drains, closes, and completes retirement; no active publication remains | `closeForLifecycle` clears owner demand, closes the current room, and invalidates automation | Terminal retirement is not resumed. |
Suspension and retirement therefore have different guarantees. `ReplicatorService` suspension stops transfer but intentionally retains the provider instance and publication. Terminal retirement removes admission, drains it, closes it, and does not publish a replacement unless a later lifecycle event explicitly initialises one. P2P transport is additionally closed on suspension because its service lifecycle owns a room session, but the active P2P adapter is only a compatibility handle and does not own that session.
## Owned resources and probes
| Resource or probe | Owner | Lifetime and disposal rule |
| --- | --- | --- |
| Active Replicator publication | Commonlib `ReplicatorService` | Publication retirement fences admission, drains reservations, calls `closeReplication`, and completes the retirement. |
| Central connection probe | Host resource factory in `src/common/replicatorResources/connection.ts` | A caller-owned CouchDB or Object Storage snapshot backed by a concrete Replicator/connection; dispose it in `finally`. It does not replace the active publication. |
| Preferred-tweak probe | Host `preferredTweak` resource factory | Read through the declared resource, then dispose the owned resource. |
| Security Seed probe | Host `securitySeed` resource factory and the replication preflight | Use `createRemoteResource` and `withOwnedRemoteResource`; reject an empty seed and always dispose the resource. It does not assume that an active Replicator exists. |
| Synchronisation-information probe | Host `synchronisationInformation` resource factory | Check or read through the resource and dispose it; an unavailable remote is not treated as a confirmed absence. |
| Central administration operation | Provider administration runner | Uses its declared verification and mutation ownership. A fresh connection may be owned by the operation; an active Journal client borrowed from the active provider is not disposed by the borrower. |
| Journal client and transfer set | `LiveSyncJournalReplicator` | The Journal Replicator owns the client and active transfer promises. `terminateSync` requests stop and awaits the shared settlement; `closeReplication` disposes the client without lazily creating one. |
| P2P room/session, finite operation set, and relay actions | `P2PRoomSessionOwner` and current `P2PRoomSession` | The owner serialises binding and demand changes. Session retirement rejects admission, aborts and awaits operations, disables broadcast, and disposes the session Replicator. |
| Physical Trystero peer connections | Trystero runtime | Commonlib must not close raw `room.getPeers()` connections merely because a logical room session retires; shared Trystero ownership may outlive an idle room callback. |
| Physical local database | Commonlib DatabaseService | Database lifecycle owns teardown and readiness. Replicator and P2P owners close before the database is destroyed. |
Probe callers must use the resource capability rather than reaching through `LiveSyncBaseCore.replicator`. This keeps a short-lived observation from acquiring ownership of the active transfer or publication.
## P2P special ownership and the non-owning adapter
P2P is composed as a `serviceFeature` and has more state than a central provider. It can remain enabled as an adjunct while CouchDB or Object Storage is the selected main remote; `ReplicatorService` publishes the P2P active adapter only when `remoteType` is P2P. The active-publication owner and the room-session owner are therefore deliberately independent.
The stable service owns persistent demand (`explicit`, `automatic`, or `rebuild-continuation`), finite-operation demand, the lifecycle queue, the effective binding, and the current room session. The room owner compares the local database object separately and includes the effective device name in its binding signature, so the binding is not interchangeable with the P2P provider's active-publication configuration identity. The automation coordinator owns automation-baseline deduplication. The current `P2PRoomSession` owns one room, peer handlers, advertisements, RPC, session cancellation, and finite operations. Trystero owns shared relay clients and physical peer connections.
`P2PActiveReplicatorAdapter` implements the minimal `ReplicatorInstance` view required by the provider contract. Its `initializeDatabaseForReplication`, `openReplication`, and `terminateSync` methods delegate to the P2P service. Its `closeReplication` is intentionally a no-op: closing the active adapter must not close the room, relay actions, finite-operation registry, or stable P2P service. The service lifecycle (`closeForLifecycle`, owner close, or binding reconciliation) is the only owner which retires the room.
The P2P connection probe follows the same boundary. An active compatible room may be observed; an incompatible active binding blocks the probe. An idle probe can run a caller-owned trial through the owner queue and must await clean-up. It must not publish itself as the active room or close resources owned by another session.
Automatic configured-target replication acquires finite room demand, waits for peer advertisements within a bounded window, evaluates admission without prompting, shares the baseline through `P2PAutomationCoordinator`, and returns an explicit completed, partial, blocked, cancelled, or failed outcome. Explicit disconnect veto remains distinct from host lifecycle closure. AutoStart cannot clear an explicit disconnect veto; rebuild continuation is a separately authorised path.
## Adding a built-in provider
Provider work crosses the Commonlib package boundary and this repository's host composition. The following sequence is the smallest complete path; omit a step only when the provider genuinely has no corresponding concept.
### First decide whether this is a provider
A new provider is appropriate when a remote kind needs a distinct active Replicator lifecycle, effective configuration identity, readiness policy, or replication roles. A new S3-compatible service or another backend which retains the Object Storage Journal protocol is usually an `IJournalStorage` adapter instead; see [Journal Replicator 2nd Edition](../design_docs_of_journalsync_2nd.md). A new read-only observation over an existing provider is usually a remote resource or a focused view. Neither case needs another active provider.
1. **Define the canonical remote kind in Commonlib.** Add the `RemoteType` value and its setting type in `src/common/models/setting.const.ts` and `src/common/models/setting.type.ts`, update exports such as `src/common/types.ts`, and add defaults or persistence fields only where the provider needs them. Add focused setting and migration tests.
2. **Implement the Replicator in Commonlib.** Place the provider-specific Replicator and transport code under `src/replication/<provider>/`. Implement the minimal `ReplicatorInstance` contract, cancellation, and close semantics. Keep provider-specific operations on focused facets. Add unit tests for success, cancellation, failure, stop, and replacement-sensitive clean-up.
3. **Define effective configuration identity.** Include every setting which changes the live binding, and exclude profile labels or policy-only settings. Normalise two spellings only when the runtime genuinely treats them as equivalent. Put shared identity logic in Commonlib when the provider is shared there; put the host projection in `src/common/replicatorConfigurationIdentity.ts` when this repository owns it. Test that equivalent effective settings retain an instance and binding changes replace it. Never expose identity values in logs, UI, or persistence.
4. **Add configuration and setup seams in Commonlib.** If the provider has a connection string, profile, migration, or document representation, update the applicable files, including `src/common/ConnectionString.ts`, `src/remoteConfigurations.ts`, `src/common/configForDoc.ts`, `src/API/processSetting.ts`, and their focused tests. These files are conditional: do not add a setting representation which the provider does not need.
5. **Declare the provider contract surface at its composition owner.** Add a central provider to the tuple and definition map in this repository's `src/common/replicatorProviders.ts`; add a P2P-like provider to the closed tuple owned by its Commonlib `serviceFeature`. In the definition, declare readiness, all four remote-resource capability entries, user and unattended OneShot runners, Continuous where applicable, `stopActiveTransfer`, and central administration where applicable. Extend `RemoteResource` kinds only for a genuinely cross-provider resource; do not encode provider-specific helpers as generic capabilities.
6. **Implement stateful transport ownership, if required.** For a P2P-like provider, add a stable service owner, focused views, lifecycle ownership, binding identity, session retirement, and automation fences in Commonlib, then compose it through a `serviceFeature`. Keep any active adapter non-owning if the service owns a replaceable transport. Add lifecycle, stale-callback, probe, and database-replacement tests before host integration.
7. **Compose the provider in each supporting application.** Central definitions are registered by `LiveSyncBaseCore`. A dedicated stateful feature must be composed from the applicable hosts: `src/main.ts`, `src/apps/cli/main.ts`, `src/apps/webapp/WebAppRuntime.ts`, and `src/apps/webpeer/src/WebPeerRuntime.ts`. Each selected catalogue must remain exhaustive and closed; add no runtime provider registry.
8. **Add host-owned resources and administration.** Add or extend `src/common/replicatorResources/` for connection, preferred-tweak, Security Seed, and synchronisation-information probes. Add provider-specific central verification and mutations in `src/common/centralRemoteAdministration.ts` when applicable. Test ownership, snapshots, the distinction between unavailable and absent states, postconditions, and disposal.
9. **Integrate setup and user-facing configuration.** Update the relevant setup dialogue files under `src/modules/features/SetupWizard/dialogs/`, including `dialogs/setupDialogTypes.ts`, SetupManager or setup features, remote configuration handling, and message resources under `src/common/messagesYAML` plus generated baked messages where required. Follow the terminology and settings mappings in the repository documentation, and add setup, serialisation, and migration tests.
10. **Integrate host operations and triggers.** Adapt only the capability call sites which the provider supports. Check `src/serviceFeatures/replicationScheduling.ts`, `src/serviceFeatures/replication/`, CLI commands under `src/apps/cli/commands`, and application-specific lifecycle composition. Ensure unattended paths use `NO_INTERACTION`, periodic and resume fallback obey capability outcomes, and no caller reaches for `getNewReplicator` or the `LiveSyncBaseCore.replicator` compatibility getter.
11. **Validate the package boundary.** In Commonlib, run its focused unit tests, build or pack the exact candidate artefact, and test the downstream LiveSync consumer against that artefact. In this repository, run provider map, configuration-identity, resource, central-administration, scheduling, and replication-feature unit tests. Add a real remote integration test, CLI E2E coverage, or real Obsidian E2E coverage for every boundary the provider claims to support.
A provider is complete only when its source ownership, replacement fence, capability matrix, setup path, and tests agree. Updating a setting type or adding a class without adding the closed composition definition does not make it a built-in provider.
## Compatibility seams and non-goals
- `ReplicatorService.getNewReplicator`, `getActiveReplicator`, and the `LiveSyncBaseCore.replicator` getter remain compatibility seams for existing callers. Beyond `ReplicatorInstance`, `LiveSyncBaseCore.replicator` exposes provider-specific members only as an optional compatibility view. None is a new provider extension point.
- `ReplicationService.performReplication` remains a direct legacy path through the active instance. New call sites use `replicateUserInitiated`, `replicateUnattended`, `replicateUnattendedByEvent`, `startContinuous`, or `stopActiveTransfer`, as appropriate.
- `LiveSyncAbstractReplicator` and other legacy classes may retain methods needed by existing modules. New features use typed provider capabilities, resource factories, and focused service views; they do not infer capabilities from a large legacy class.
- The generic contract does not unify directional Journal operations, Streaming replication, Chunk retrieval, remote-size inspection, garbage collection, repair workflows, or provider-specific administration. Those remain explicit provider or host features.
- The provider catalogue is not a public runtime registry, dynamic plug-in API, or settings-driven discovery mechanism. Unknown `RemoteType` values are composition/configuration failures, not third-party providers which the runtime should load.
- Commonlib remains an external authoritative package. This repository must not recreate `src/lib`, `_types`, or another source mirror to bypass the package boundary.
- P2P logical room retirement does not authorise closing shared raw `RTCPeerConnection` objects. Physical transport ownership remains with Trystero.
- No numeric P2P session epoch is exported. Session object identity, admission flags, abort signals, and the owner queue provide the fence; the P2P automation generation and the host scheduling generation protect different concerns.
- Suspension is not a database replacement or a successful replication result. It stops or closes the appropriate active work and relies on the next lifecycle event to resume, reconcile, or retire it.
## Source map
### This repository
| Concern | Source and tests |
| --- | --- |
| Host composition and compatibility boundary | [`LiveSyncBaseCore.ts`](../../src/LiveSyncBaseCore.ts), [`main.ts`](../../src/main.ts), [`src/apps/cli/main.ts`](../../src/apps/cli/main.ts), [`WebAppRuntime.ts`](../../src/apps/webapp/WebAppRuntime.ts), [`WebPeerRuntime.ts`](../../src/apps/webpeer/src/WebPeerRuntime.ts) |
| Closed central provider map | [`replicatorProviders.ts`](../../src/common/replicatorProviders.ts), [`replicatorProviders.unit.spec.ts`](../../src/common/replicatorProviders.unit.spec.ts) |
| Provider identities and resources | [`replicatorConfigurationIdentity.ts`](../../src/common/replicatorConfigurationIdentity.ts), [`replicatorResources/`](../../src/common/replicatorResources/index.ts), [`replicatorResources.unit.spec.ts`](../../src/common/replicatorResources.unit.spec.ts) |
| Central administration and preflight | [`centralRemoteAdministration.ts`](../../src/common/centralRemoteAdministration.ts), [`centralRemoteAdministration.unit.spec.ts`](../../src/common/centralRemoteAdministration.unit.spec.ts), [`replication/preflight.ts`](../../src/serviceFeatures/replication/preflight.ts) |
| Host scheduling and replication feature | [`replicationScheduling.ts`](../../src/serviceFeatures/replicationScheduling.ts), [`replicationScheduling.unit.spec.ts`](../../src/serviceFeatures/replicationScheduling.unit.spec.ts), [`replication/index.ts`](../../src/serviceFeatures/replication/index.ts) |
| Service graph and bounded local activity | [`ObsidianServices.ts`](../../src/modules/services/ObsidianServices.ts), [`ObsidianServiceHub.ts`](../../src/modules/services/ObsidianServiceHub.ts) |
| Architecture guidance | [`devs.md`](../../devs.md), [Service feature and legacy Module boundaries](service_feature_and_legacy_module_boundaries.md), [Project glossary](../glossary.md), [Documentation style and vocabulary conventions](../terms.md), [`docs/settings.md`](../settings.md), [`docs/troubleshooting.md`](../troubleshooting.md) |
### Commonlib 0.1.21
The exact package tree described here is [pinned at commit `e770f617ff0fc88f4823226b0ab3aefdff50cc1e`](https://github.com/vrtmrz/livesync-commonlib/tree/e770f617ff0fc88f4823226b0ab3aefdff50cc1e). The source and design-document links below target that commit.
| Concern | Commonlib source or design document at the pinned commit |
| --- | --- |
| Provider contract, outcomes, identities, and resource capabilities | [`src/replication/ReplicatorInstance.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/ReplicatorInstance.ts), [`src/replication/ReplicatorProvider.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/ReplicatorProvider.ts), [`src/replication/RemoteResource.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/RemoteResource.ts), [`src/replication/CentralRemoteAdministration.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/CentralRemoteAdministration.ts), [`src/replication/CentralCompatibility.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/CentralCompatibility.ts) |
| Active publication and typed operations | [`src/services/base/ReplicatorService.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/services/base/ReplicatorService.ts), [`activeReplicatorState.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/services/base/ReplicatorService.activeReplicatorState.ts), [`typedReplication.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/services/base/ReplicationService.typedReplication.ts), [`readiness.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/services/base/ReplicationService.readiness.ts) |
| P2P service, room ownership, and automation | [`P2PService.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/p2p/P2PService.ts), [`P2PRoomSessionOwner.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/trystero/P2PRoomSessionOwner.ts), [`P2PRoomSession.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/trystero/P2PRoomSession.ts), [`useP2PReplicatorFeature.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/trystero/useP2PReplicatorFeature.ts), [`P2PAutomationCoordinator.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/trystero/P2PAutomationCoordinator.ts) |
| P2P lifecycle design | [`docs/p2p-transport-lifecycle.md`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/docs/p2p-transport-lifecycle.md) |
| Database and service-feature lifecycle | [`docs/database-lifecycle.md`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/docs/database-lifecycle.md), [`docs/service-feature-composition.md`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/docs/service-feature-composition.md), [`docs/settings-lifecycle.md`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/docs/settings-lifecycle.md) |
| Journal transfer and remote epoch | [`LiveSyncJournalReplicator.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/journal/LiveSyncJournalReplicator.ts), [`JournalSyncCore.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/journal/JournalSyncCore.ts), [`JournalSyncTypes.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/journal/JournalSyncTypes.ts) |
| Journal storage adapter boundary | [`JournalStorageAdapter.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/journal/objectstore/JournalStorageAdapter.ts) |
### Decision records
- [Core provider contract and capabilities ADR](../adr/2026_08_replicator_capabilities_01_core_contract.md)
- [P2P service lifecycle ADR](../adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md)
- [Replicator migration plan ADR](../adr/2026_08_replicator_capabilities_03_migration_plan.md)
- [P2P Room and Transport Lifecycle ADR](../adr/2026_07_p2p_transport_lifecycle.md)
- [Bounded Remote Activity ADR](../adr/2026_07_bounded_remote_activity.md)
@@ -0,0 +1,228 @@
---
date: 2026-09-04
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
# Service feature and legacy Module boundaries
## Purpose
This document guides new Self-hosted LiveSync composition and bounded refactoring of existing application Modules. It supplements Commonlib's [service feature composition guide](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/service-feature-composition.md) with the risks and migration boundaries specific to `AbstractModule` and `AbstractObsidianModule`.
Existing Modules remain supported application structures. This guidance does not require mechanical conversion of working code. It defines why a new feature should normally use an existing Service handler or a serviceFeature, and when retaining a Module is still appropriate.
## Default decision
For new behaviour:
1. add a handler to an existing Service when that Service already owns the result, priority, and lifecycle;
2. use a serviceFeature when the work composes several Services, ServiceModules, lifecycle events, commands, or host effects;
3. keep feature-local state in a private context, with functions which receive that context;
4. use a ServiceModule only when several consumers need the same long-lived operational capability or resource lifetime; and
5. use a focused class when stable identity, polymorphism, serialised ownership, replacement, `abort()`, `close()`, or `dispose()` is part of the contract.
Do not select `AbstractModule` or `AbstractObsidianModule` merely to obtain convenient access to `LiveSyncBaseCore`, settings, Services, or Obsidian APIs.
## How the legacy Module layer works
`LiveSyncBaseCore` currently composes the application in this order:
1. retain the constructed Service Hub;
2. construct the `ServiceModules` record;
3. construct and register built-in and host-supplied Modules;
4. compose the built-in Commonlib serviceFeatures;
5. compose host-supplied serviceFeatures;
6. construct add-ons;
7. compose the late core serviceFeatures whose handlers must follow host features and add-ons; and
8. call `onBindFunction()` for each registered Module.
The Module constructor therefore runs before its handler bindings, while the complete Service Hub and ServiceModules already exist. `bindModuleFunctions()` then invokes every `onBindFunction()` and runs `__$checkInstanceBinding()`. That diagnostic compares underscore-prefixed prototype methods with method references found in the source text of `onBindFunction()`.
This is a compatibility lifecycle. A serviceFeature does not need to wait for Module binding. It can consume the already constructed Services and ServiceModules directly.
## Why new code should avoid `AbstractModule`
### Dependencies are broader than the type signature
An `AbstractModule` constructor receives `LiveSyncBaseCore`. Through that one object, a subclass can reach:
- the complete Service Hub;
- every ServiceModule;
- the active local database;
- settings and setting persistence;
- application commands, views, ribbon icons, and protocol handlers; and
- path, readiness, logging, and test helpers.
A reader cannot determine the real dependency set from the constructor or class declaration. A serviceFeature using `NecessaryServices` makes that set visible and compiler-checked.
### Initialisation is split across construction and binding
Module fields can dereference `this.services` during class field initialisation, while public behaviour is registered later in `onBindFunction()`. Correctness consequently depends on both the host construction order and a second binding phase.
This permits states which are difficult to express in a type:
- the class exists but its handlers are not registered;
- a field has captured a Service before the intended lifecycle point;
- a method passed as a callback has lost its receiver; or
- a test invokes `onBindFunction()` against a partial object which could not occur through ordinary composition.
### Callback safety is checked at runtime
Legacy Modules commonly register `this.method.bind(this)`. `__$checkInstanceBinding()` can report an underscore-prefixed method which is not referenced by `onBindFunction()`, but it does not type-check the registration or prove that a callback retains its receiver. A module-level function receiving an explicit context does not have a receiver to lose.
### Registry and ordering dependencies remain implicit
Modules are stored in one runtime list. Construction order, binding order, `getModule()`, and subclass identity can become hidden dependencies. A serviceFeature is called at the composition root and returns only an intentionally retained view, so its consumers do not need a general Module locator.
### Resource ownership is not part of the base contract
`AbstractModule` has no standard replacement, cancellation, or disposal contract. Individual Modules can register `onUnload` handlers, but accepting the core does not state which object owns a queue, remote handle, room, timer, or in-flight operation.
Use a focused owner when the resource lifetime is meaningful, then compose that owner through a serviceFeature. The owner should expose the smallest necessary `abort()`, `close()`, `dispose()`, or view contract.
### Tests inherit unrelated application structure
Current Module tests sometimes call a prototype method with manually assembled objects:
```typescript
ModuleReplicator.prototype.onBindFunction.call(module, {} as never, services as never);
```
Other tests construct a broad fake core so that the base class can expose one or two collaborators. These tests can verify behaviour, but the fixture cost obscures the actual interaction contract and makes unrelated Service changes more likely to affect them.
When a focused London School test requires a broad core fixture, repeated `as never`, deep mock chains, or manual prototype invocation, treat that friction as a design-review signal.
## Why `AbstractObsidianModule` is a more restrictive boundary
`AbstractObsidianModule` adds direct access to the plug-in and `app` on top of the complete core. This is useful for existing Obsidian-owned integration, but it combines platform policy, application composition, and domain behaviour in one inheritance boundary.
For new behaviour, keep Obsidian-specific presentation or registration in an Obsidian-owned serviceFeature. Pass host-neutral operations or focused views into that feature. This permits the CLI, WebApp, WebPeer, and unit tests to reuse the operation without constructing an Obsidian plug-in.
## Current examples
### A small serviceFeature: language initialisation
`src/serviceFeatures/onLayoutReady/enablei18n.ts` declares only `setting`, `API`, and `appLifecycle`:
```typescript
export const enableI18nFeature = createServiceFeature(async ({ services: { setting, API, appLifecycle } }) => {
// Apply the language, persist a change, and register unload clean-up.
});
```
The local `ObsidianLanguageAppliedNotice` class is still appropriate. It owns one replaceable Obsidian `Notice` and has an explicit `clear()` lifetime operation. The class is not used as a service locator, and the serviceFeature owns its construction and host binding.
### Operation and composition: database preparation
Commonlib's `prepareDatabaseForUse()` is independently callable and receives explicit collaborators. `usePrepareDatabaseForUse()` constructs the error manager and registers the operation with `databaseEvents.initialiseDatabase`.
This split allows tests to verify:
- database opening before scanning;
- short-circuiting after a failed step;
- completion handlers before pending-event commitment;
- readiness only after every required step; and
- registration of the composed operation.
The operation does not need an application Module identity.
### Ordered start-up composition and registration-only features
Configured Vault admission and the checks which follow database preparation are composed by `src/serviceFeatures/startupLifecycle/`. The directory keeps onboarding admission, compromised-chunk inspection, incomplete-document repair, Config Doctor, and the obsolete bulk-send setting migration as separate operations. One feature composer owns their order and receives the compatibility-review wait operation explicitly; an individual operation does not call the composer.
The layout-ready admission handler uses priority 1. This preserves ordinary priority-0 host integration before admission, while keeping an unconfigured Vault outside the flag-file recovery handlers at priorities 5, 10, and 20, and the compatibility review at priority 30. Admission belongs to one plug-in process: an initially unconfigured process remains inert until setup restarts it, and declining the requested restart does not trigger an in-process reconfiguration. Changing an admitted process back to unconfigured retires its Config Doctor and incomplete-document repair request handlers. The handlers also recheck the current configured state and database readiness when invoked, so a pending restart cannot expose partially initialised or retired state. The first-initialise handler rechecks admission before retaining the established order after the file watcher has been started: database readiness, compromised chunks, incomplete documents, compatibility review, Config Doctor, and the bulk-send setting migration.
Command and ribbon registration are serviceFeatures for the same dependency-visibility reason, but they are not start-up migrations. The basic commands remain a host-neutral feature composed by `LiveSyncBaseCore`, while the replication ribbon remains an Obsidian-only feature composed by the Obsidian host. Both retain `onInitialise` registration so moving them out of the Module list does not make their effects run during construction.
### Private state and ordered handlers: target filters
Commonlib's `targetFilter.ts` keeps each cache or readiness gate in the factory which owns one predicate. `useTargetFilters()` constructs those predicates and registers them in their required order.
The state remains private to the composed feature. It does not become a `LiveSyncBaseCore` property or a ServiceModule merely because it persists across calls.
### Implemented composition: conflict resolution
Conflict checking and resolution are composed for every host by `useConflictResolutionFeature`. The feature owns its `QueueProcessor` privately and registers the conflict Service handlers directly. Its operations receive explicit collaborators for settings, active-file state, database and storage access, replication, logging, and host events. No consumer locates a conflict Module or retains the queue.
The scheduling queue remains one state owner. It publishes `conflictProcessQueueCount`, coalesces pending checks for the same path, and makes `ensureAllProcessed()` wait for conflict resolution to finish. Repeated resolver invocations for one path retain only the newest waiting request and close an active comparison for that path before waiting for the per-file resolver, while comparisons for other paths remain open. Resolution remains host-neutral and communicates dialogue cancellation through `services.context.events`, so CLI, WebApp, and Obsidian compositions use their own selected event channel.
Interactive resolution is a separate Obsidian-owned serviceFeature. It registers the manual conflict handler, commands, start-up scan, unresolved-message contribution, cancellation listener, and unload clean-up. Its postponed-conflict set, active dialogue, and dialogue queue are private, session-local state. Manual comparisons are shown one at a time: a request for the active file publishes `EVENT_CONFLICT_CANCELLED` to cancel and replace its dialogue, while a request for another file waits. A resolution received through replication closes an open dialogue for the resolved path through the same event, or discards its waiting request before a stale dialogue can open. On unload, the feature drops waiting requests and publishes the same event for the active path before the host event channel is retired, so the dialogue closes and its waiting operation completes. The feature receives a dialogue-opening adapter and connects to the common feature only through the conflict Service; it does not expose an Obsidian application or dialogue as a general capability.
Both operation layers acquire the active local database through an operation-time accessor. Composition occurs before the database is opened, and a reset may replace the active instance, so retaining the database object at composition time would violate both start-up and reset boundaries.
`ConflictResolveModal` remains a focused class. One instance owns one dialogue's result promise, event subscription, and close lifetime, which is stable identity and resource ownership rather than application composition. This preserves the distinction between a useful object lifetime and a legacy Module used as a service locator.
## Interaction-based testing
Test a serviceFeature at two levels.
First, test the operation or state owner with narrow collaborators:
```typescript
it("does not enqueue after an optional resolver completes the conflict", async () => {
const enqueue = vi.fn();
const resolveOptionally = vi.fn(async () => true);
await queueConflictCheck(contextWith({ enqueue }), dependenciesWith({ resolveOptionally }), path);
expect(resolveOptionally).toHaveBeenCalledWith(path);
expect(enqueue).not.toHaveBeenCalled();
});
```
Second, test the composition:
```typescript
it("registers conflict checking with the conflict Service", () => {
const setHandler = vi.fn();
useConflictChecking(makeHost({ setHandler }));
expect(setHandler).toHaveBeenCalledOnce();
expect(setHandler).toHaveBeenCalledWith(expect.any(Function));
});
```
The test should make the interaction contract legible: which collaborator is called, in which order, what result is returned, and what must not run after a failure.
Do not expose a private constructor, publish a broad mock, or attach a context to `LiveSyncBaseCore` solely to make a test possible. If the narrow test cannot be written cleanly, reconsider the responsibility split.
## When retaining a Module is appropriate
Retain or extend an existing Module when the current change depends on its established:
- Module identity or `getModule()` lookup;
- binding order with neighbouring legacy Modules;
- Obsidian plug-in lifecycle integration;
- user interface object lifetime; or
- compatibility behaviour whose extraction would materially expand the change.
Even then, new domain operations can receive explicit dependencies instead of accepting the Module or complete core. Improve the affected ownership boundary without converting unrelated neighbours.
## Migration approach
When a Module is already in scope:
1. name the behaviour being changed and the state or resource which owns it;
2. identify the smallest operation which can accept explicit dependencies;
3. add a focused regression or interaction test around that operation;
4. keep host-specific registration in the Module initially, if that is the smallest safe step;
5. move registration to a serviceFeature only when the current integration can do so without changing ordering or lifetime; and
6. remove the legacy Module only when no identity, lookup, ordering, or compatibility consumer remains.
This is an incremental boundary change, not an inheritance-removal campaign.
## Review checklist
Before adding or changing application composition, confirm that:
- dependencies are visible in a function, context, or constructor type;
- mutable state has one named owner;
- shared state is not promoted to a ServiceModule without multiple consumers;
- external resources have explicit replacement and disposal semantics;
- host-specific UI remains outside host-neutral operations;
- a consumer receives a focused view rather than the complete core;
- handler ordering and failure short-circuiting are tested; and
- retaining a legacy Module is an explicit compatibility decision.
+384
View File
@@ -0,0 +1,384 @@
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
# Project glossary
This glossary records stable, project-specific meanings used by Self-hosted
LiveSync. Ordinary English and established technology terms retain their usual
meanings unless they are defined here. Exact code identifiers, API names, and
user-interface labels retain their source spelling.
The sections describe the intended audience, not a visibility guarantee. A
term in the developer and design section can appear in code, tests, logs, or
diagnostics. That does not make it user-interface vocabulary or a public
extension contract.
## User-facing and operational terms
These terms can appear in the user interface, user documentation, setup and
recovery guidance, or diagnostics intended for users.
### AR
- **Boot-up sequence (boot sequence):** The initialisation process of the
plug-in when Obsidian starts. It begins with loading the plug-in, setting up
core services, loading saved settings, and opening the local database. After
the layout is ready, the plug-in checks for flag files, runs configuration
diagnostics, connects to the remote database, and begins file watching. The
sequence finishes when the plug-in is ready and operational.
- **Broken files (size mismatch):** A state where a file's Metadata and the
content stored in its Chunks do not match, causing file retrieval or
synchronisation failures. Inspect these mismatches with **Inspect conflicts
and file/database differences** in the Hatch pane, then handle one exact
revision at a time.
- **Chunk / Chunks:** Divided units of data stored in the database or Object
Storage to support efficient synchronisation.
- **Compaction:** A database maintenance procedure which discards old
historical document revisions to reduce remote database size.
- **Continuous replication:** A provider's long-running replication mode. It
remains active to exchange changes until stopped and is distinct from a
finite OneShot Sync operation.
- **Custom HTTP Handler / Use Internal API (CORS bypass settings):** Settings
which bypass CORS restrictions by routing requests through Obsidian's native
request APIs. There are separate settings for each central remote type:
- **S3-compatible Object Storage (`useCustomRequestHandler`):** Labelled
**Use Custom HTTP Handler** in the standard settings tab and **Use internal
API** in the Svelte Setup Wizard dialogue. It is represented as `useProxy`
in Setup URI query parameters for compatibility.
- **CouchDB (`useRequestAPI`):** Labelled **Use Request API to avoid
inevitable CORS problem** in the standard settings tab and **Use Internal
API** in the Svelte Setup Wizard dialogue. It is represented as
`useRequestAPI` in Setup URI query parameters.
- **Customisation Sync:** The feature which synchronises settings, snippets,
themes, and plug-ins. Write 'Customisation' with an 's' in documentation;
technical configuration and links can use `customization` where required.
- **Database Adapter (IDB and IndexedDB):** The local database storage
interface used by PouchDB. The `IDB` adapter is recommended because the older
`IndexedDB` adapter is obsolete and can cause memory leaks in LiveSync mode.
Switching adapters requires local data migration and an Obsidian restart,
but not a full database rebuild.
- **Database Suffix (`additionalSuffixOfDatabaseName`):** A suffix appended to
the database name so that multiple Vaults with the same name can synchronise
to the same remote server.
- **E2EE Algorithm:** The cryptographic algorithm version used for end-to-end
encryption. All synchronising devices must use a compatible version, such as
`V2` or `V1`.
- **Eden (Eden Chunks):** A sunset-compatibility optimisation in which newly
created Chunks are held inside the document until they stabilise, before
becoming independent Chunks.
- **Fast Setup (Simple Fetch):** The preferred automated initial
synchronisation flow for a secondary device. It uses Streaming replication
for the initial download and delays local file reflection to avoid temporary
synchronisation warnings.
- **Fast Fetch:** The CouchDB-specific Streaming replication path used by Fast
Setup. It reads the changes feed in bounded pages and persists a checkpoint
so an interrupted transfer can resume. An ineligible transport uses the
ordinary fetch path instead.
- **Flag files (`redflag.md`, `redflag2.md`, and `redflag3.md`):** Special
Markdown files or directories at the Vault root which stop the boot-up
sequence or trigger recovery work. `redflag.md` suspends all processes,
`redflag2.md` (`flag_rebuild.md`) triggers a full database rebuild, and
`redflag3.md` (`flag_fetch.md`) discards and fetches the local database again.
- **Garbage Collection (GC):** The maintenance process which identifies Chunk
documents not reachable from a current file or conflict branch, records
logical deletions for them, propagates those deletions, and requests remote
compaction to reclaim storage.
- **Hatch (Hatch pane):** The troubleshooting and maintenance section in the
plug-in settings. It contains diagnostics, database reset controls, status
reports, and advanced edge-case settings.
- **Hidden File Sync:** The feature which synchronises files in hidden
directories, such as `.obsidian`.
- **JWT Authentication:** An experimental CouchDB authentication option which
uses a JSON Web Token instead of standard credentials. It requires a private
key or secret, algorithm, expiry duration, subject, and key ID.
- **LiveSync:** This name has two established meanings: the shortened plug-in
name for Self-hosted LiveSync, and the Sync Mode for continuous, real-time
synchronisation. Prefer 'Continuous replication' in design documentation
when the mode, rather than the product, is meant.
- **livesync-serverpeer / WebPeer:** Specialised clients which assist WebRTC
peer-to-peer communication.
- **Metadata (file metadata):** A database document which stores file
properties, including its name, path, size, modification time, and references
to the Chunks containing its content. PouchDB or CouchDB revision metadata
carries conflict state; the file Metadata document has no separate history
field. Metadata and file content are stored separately.
- **OneShot Sync (OneShot replication):** One finite bidirectional
synchronisation operation, normally pull then push, which is requested
directly or by an event. It is distinct from Continuous replication.
- **Overwrite Server Data with This Device's Files:** A maintenance operation,
formerly named `Rebuild everything`, which discards the remote database and
rebuilds the local and remote databases from the current files on one
authoritative device.
- **Path Obfuscation:** A privacy option which encrypts file paths and folder
names on the remote server.
- **plug-in:** The spelling used in user-facing messages and general prose.
Retain `plugin` in code, configuration, and established technical names.
- **Remediation (`maxMTimeForReflectEvents`):** A recovery setting which limits
reflection of changes from the database to the Vault by ignoring file events
after a specified date and time.
- **Reset Synchronisation on This Device:** A maintenance operation, formerly
named `Fetch everything`, which discards the local database and rebuilds it
from the remote database.
### Revision
A revision is a version of one PouchDB or CouchDB document. Concurrent changes
can form a revision tree with more than one current branch.
Revision modifiers describe independent properties. More than one can apply to
the same revision:
- **leaf:** Has no known child revision.
- **winner:** Is the leaf selected by PouchDB or CouchDB as the current
document.
- **conflict:** Is another current leaf which was not selected as the winner.
- **Vault-matching:** Represents the same file content, or the same absent-file
state, as the current Vault. More than one revision can match.
- **displayed:** Is recorded by valid device-local file provenance as the
branch represented in the Vault. A pending local edit might no longer match
its bytes, but still extends this recorded branch.
- **logically deleted:** Represents absence of the file through a deletion
marker. A logically deleted revision can also be a leaf, winner, conflict,
or Vault-matching revision. An absent file retains no displayed provenance.
Avoid 'live revision' because it can mean either a current leaf or a
non-deleted revision. See
[Independent revision properties](specs_conflict_resolution.md#independent-revision-properties)
for the relationship between revision-tree roles, Vault state, and
device-local provenance.
### SZ
- **Scram (Scram Switches):** Emergency controls which suspend file watching or
database reflection to reduce the risk of corruption or unintended changes.
- **Security Seed:** The remote PBKDF2 salt used to derive the encryption key
for replication. It must be read from, or established on, the remote before
encrypted synchronisation.
- **Segmenter (Segmented-splitter):** A chunking method which divides files at
semantic boundaries, such as paragraphs or sections, rather than arbitrary
byte boundaries.
- **Self-hosted LiveSync:** The name of this plug-in. 'Self-hosted' is one
hyphenated word.
- **Setting Doctor (Config Doctor):** A diagnostic utility which identifies
configuration mismatches or suboptimal settings and presents recommended
values and reasons.
- **Setup URI:** An encrypted representation of plug-in settings and remote
configuration which can be transferred to another device and opened with a
passphrase.
- **Signalling relay (P2P):** A Nostr-compatible WebSocket relay used for peer
discovery and WebRTC connection negotiation. It does not store or transfer
Vault content. The project author operates a public relay as a best-effort
convenience, and users can supply another compatible relay.
- **Streaming replication (stream-based replication):** A transfer method
which downloads database documents as a continuous stream of events. Fast
Setup uses it to retrieve remote Metadata efficiently.
- **Sync Mode:** The trigger mechanism for synchronisation. Current modes are
**LiveSync**, for continuous replication, **Periodic Sync**, for work at a
configured interval, and **On Events**, for configured application events.
- **Synchronising devices:** Devices which participate in the same
synchronisation for a Vault. The term describes membership rather than
current activity, so it includes offline and idle devices.
- **TURN Server (WebRTC P2P):** A Traversal Using Relays around NAT server used
as an optional fallback when NAT or firewall rules prevent a direct WebRTC
connection. It relays encrypted WebRTC traffic and is distinct from the
signalling relay.
- **Update Thinning (Batch database update):** An optimisation which groups
local file edits over a short delay before committing them to the local
database, reducing database writes.
- **WebRTC P2P (peer-to-peer):** A synchronisation method which allows devices
to communicate directly without a central remote database.
## Developer and design terms
These definitions are stable vocabulary for architecture documents, ADRs,
implementation, tests, and code review. They might never appear in the user
interface. Inclusion here fixes their project meaning; it does not make the
named surface a public API or extension point.
### Active publication
The atomic publication of one Replicator provider, its `ReplicatorInstance`,
and its configuration identity, owned by Commonlib's `ReplicatorService`. Its
object identity is the admission fence for operations. An active publication
is also called the active Replicator publication, and its instance is the
**active Replicator**. 'Active publication' is more precise than 'current
Replicator' when admission or retirement matters.
### Adjunct P2P transport
P2P operating as an additional transport while CouchDB or Object Storage is
the selected main remote. It retains its own service and room-session
ownership; it is not the active Replicator for the main remote. Architecture
documents can shorten this to 'adjunct P2P' where the distinction is already
clear.
### Admission and reservation
**Admission** is permission for an operation to use one exact active
publication or P2P room session. A **reservation** records admitted work and
keeps its owner alive until that work settles. Retirement closes admission
before it waits for existing reservations, so later work cannot enter the
retiring generation.
### Bounded remote activity
A finite logical operation which can involve remote work, waiting, queueing, or
local result handling. Its lifetime is broader than an individual network
request. Continuous replication is not bounded remote activity. See the
[Bounded Remote Activity ADR](adr/2026_07_bounded_remote_activity.md).
### Capability
A typed declaration that a Replicator provider supports an operation or remote
resource, does not implement it, or considers it inapplicable. Capability
support is explicit; callers do not infer it from a legacy method, a Boolean
default, or a neutral return value.
### Central remote
A CouchDB or Object Storage remote which can require central preparation and
administration before replication. P2P is not a central remote. The **main
remote** is the `RemoteType` selected for the active Replicator; P2P can also
operate as an additional transport when a central remote is selected.
### Configuration identity
An opaque projection of the effective settings which determine whether an
existing provider instance or P2P binding can be retained. It can contain
credentials. Code can compare an identity for equality, but must not inspect,
log, persist, or display it.
### Fence, generation, and epoch
A **fence** prevents stale work or work admitted by one owner from affecting a
replacement owner or state. A **generation** normally changes when one local
lifecycle is invalidated. An **epoch** identifies one session or data history
where the owning contract uses that term. These values belong to distinct state
machines and are not interchangeable or evidence that another owner is
current.
### Focused view
A narrow interface exposing only the operations required by a consumer. It
delegates to a stable owner and does not independently own the underlying
mutable state or resource.
### Interaction authority
The explicit upper bound on user interaction permitted during an operation.
User-initiated work can receive selected permissions; unattended work carries
`NO_INTERACTION` and cannot open a dialogue, request peer selection, or obtain
authority through a fallback path.
### Journal remote epoch
The `protocolVersion:pbkdf2salt` value stored as
`CheckPointInfo.journalEpoch`. It identifies Journal checkpoint and
deduplication-cache history. It is data-history state, not a cancellation,
Replicator retirement, or operation-admission fence.
### Non-owning adapter
An adapter which implements a contract by delegating to another component
without owning the delegated resource. Closing it releases only resources
which the adapter itself owns. In particular, closing the active P2P adapter
does not close the stable P2P service or its room session.
### Owner and ownership
The **owner** is the single component responsible for creating, replacing,
stopping, and disposing a resource or stateful lifecycle. A borrower, adapter,
or focused view can use that resource only within its declared boundary and
must not perform the owner's lifecycle operations.
### P2P service, room session, and demand
The **P2P service** is the stable Commonlib owner which supplies focused views
and owns replaceable room sessions. A **P2P room session** is one active room
membership and the resources whose validity depends on it. **Demand** is one
persistent or finite reason for the owner to retain a room. Releasing one
demand does not close a room retained by another. A room's effective binding
includes the settings, local database object, and device identity which make
that session valid. A **session epoch** is the internal identity and fence of
one room-session object, not a persisted room name or a public numeric counter.
An **automation baseline** records peers for which the initial transfer
completed in the current logical automation lifecycle; it is owned
independently of a replaceable room session. A **configured target** is a
persisted peer name selected for unattended `P2P_SyncOnReplication`; the
request can wait for its advertisement, but cannot prompt for peer selection.
### Publication retirement
The lifecycle transition which removes an active publication from current
admission, asks its provider to stop transfer work, drains reservations for
that exact publication, closes the old instance, and marks retirement
complete. **Quiescing** is the state after admission has closed and before
retirement completes. A replacement cannot be published across an incomplete
retirement fence. A **candidate** is a newly constructed instance which remains
private until initialisation and freshness checks permit atomic publication.
### Remote resource and probe
A **remote resource** is a provider-declared, caller-owned object created from
one effective-settings snapshot for a bounded task. A **probe** is a bounded,
flow-specific validation or observation for connection, compatibility, setup,
or diagnostics. It can use an owned remote resource or an owner-arbitrated P2P
trial. It does not publish or replace the active Replicator, and the caller
disposes every resource which it owns.
### Replicator
The project abstraction which performs replication for one configured remote
kind and implements the `ReplicatorInstance` lifecycle contract. Use
'replication' for the process and 'Replicator' for this runtime abstraction. A
Replicator can be an owning transport implementation or a non-owning adapter;
the provider contract determines the boundary.
### Replicator provider definition
The exhaustive, host-composed declaration for one `RemoteType`: its
configuration identity, Replicator factory, readiness requirement,
capabilities, remote-resource factories, operation runners, and optional
central administration. The readiness requirement declares which layer must
establish operation preconditions. The provider catalogue is **closed
composition**, not a runtime registry: adding a provider requires changing,
shipping, and testing the owning composition. A **built-in provider** is one
included in that shipped catalogue. Architecture prose can shorten 'Replicator
provider definition' to 'provider'; it does not mean only the transport
instance.
### Replication outcome
The typed settlement of an attempted replication operation, represented by
`ReplicationOutcome`. Completed, partial, blocked, cancelled, and failed states
remain explicit; `undefined`, an empty value, or a compatibility default is not
treated as successful work.
### Service composition terms
- A **Service Hub** is the long-lived registry of service contracts for one
application composition.
- A **Service** owns a stable shared capability and its lifecycle.
- A **ServiceModule** is a host-created, long-lived stateful or resource-owning
capability shared through the typed `ServiceModules` record.
- A **serviceFeature** is a typed composition function which accepts declared
Services and ServiceModules, registers host integration, and can return a
focused view. It is not a runtime registry entry.
- A **legacy Module** is an existing application structure retained for
compatibility. New behaviour does not acquire the complete core merely to
imitate that locator pattern.
See [Service feature and legacy Module boundaries](design_docs/service_feature_and_legacy_module_boundaries.md)
for the selection and composition rules.
### Suspension
A reversible lifecycle action which stops active transfer work without
retiring the active Replicator publication. The P2P service also closes its
current room session during application suspension because that session has a
separate owner and lifecycle. Resumption can retain the Replicator instance and
open a new P2P room as required.
+16
View File
@@ -2,6 +2,22 @@
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
+13 -1
View File
@@ -443,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
@@ -1105,7 +1113,11 @@ Purge all download/upload cache.
#### Fresh Start Wipe
Delete all data on the remote server.
Delete all data on the remote server in batches; this operation is not
transactional. Stop all synchronising devices before starting. If the
operation is interrupted or reports failure, keep them stopped, rerun Fresh
Start Wipe, and then use **Overwrite Server Data with This Device's Files**
from an authoritative Vault.
### 6. Garbage Collection V3 (CouchDB only)
+1 -1
View File
@@ -20,7 +20,7 @@ Resolving a conflict writes the selected or merged result on one observed branch
### Independent revision properties
The modifiers defined under [Revision](terms.md#revision) describe independent properties, rather than exclusive revision types. The winner is a database-tree role, Vault-matching describes current file-state equality, and displayed identifies the device-local branch recorded for the Vault. The same revision commonly has all three properties, but synchronisation, conflicts, local edits, and missing provenance can separate them.
The modifiers defined under [Revision](glossary.md#revision) describe independent properties, rather than exclusive revision types. The winner is a database-tree role, Vault-matching describes current file-state equality, and displayed identifies the device-local branch recorded for the Vault. The same revision commonly has all three properties, but synchronisation, conflicts, local edits, and missing provenance can separate them.
| Situation | Winner | Vault-matching | Displayed |
| ---------------------------------------------------- | ------------------ | --------------------------------------------------- | ---------------------------------------------------- |
+19 -1
View File
@@ -11,6 +11,24 @@
Note: The figure is drawn as single-directional, between two devices for demonstration purposes. Everything actually occurs bi-directionally between many devices at the same time.
## Current technical references
- [Database Data Structures](datastructure.md) describes current Metadata and
Chunk shapes, identifier handling, deletion, and raw remote representations.
- [Replicator architecture](design_docs/replicator_architecture.md) describes
provider composition, active Replicator publication, retirement, and P2P
ownership.
- [Conflict resolution and revision provenance](specs_conflict_resolution.md)
defines the current revision-tree and file-provenance rules.
- [Chunk Retrieval and Waiting](design_docs/chunk_retrieval_and_waiting.md)
defines missing-Chunk arrival and quiescence handling.
- [Path component length compatibility](design_docs/path_component_length_compatibility.md)
explains why 255 UTF-8 bytes is an Android and Linux compatibility warning,
rather than a universal rule for deciding whether a path is valid.
- [Data Compression](specs_data_compression.md) and [Garbage Collection
V3](specs_garbage_collection.md) describe their respective storage and
maintenance contracts.
## Techniques to keep bandwidth consumption low.
![dedupe](../images/2.png)
![dedupe](../images/2.png)
+9 -94
View File
@@ -1,3 +1,10 @@
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
# Notes on Terminology, Spelling, Vocabulary Conventions
## Spelling and Vocabulary conventions
@@ -24,97 +31,5 @@ All guidelines and conventions listed below are disclosed and maintained solely
### Terminology
- Boot-up sequence (boot-sequence)
- The initialisation process of the plug-in when Obsidian starts. It starts with the loading of the plug-in, setting up core services, loading saved settings, and opening the local database. Once the layout is ready, the plug-in checks for the presence of flag files, runs configuration diagnostics, connects to the remote database, and begins file watching. The sequence finishes once the plug-in is fully ready and operational.
- Broken files (Size mismatch)
- A state where a file's metadata and the actual content stored in its chunks do not match, causing file retrieval or synchronisation failures. These mismatches can be inspected with `Inspect conflicts and file/database differences` on the Hatch pane, then handled one exact revision at a time.
- Chunk / Chunks
- Divided units of data stored in the database or object storage to facilitate efficient synchronisation.
- Compaction
- A database maintenance procedure that discards old historical document revisions to shrink the remote database size.
- Custom HTTP Handler / Use Internal API (CORS Bypass Settings)
- Settings used to bypass CORS restrictions by routing requests through Obsidian's native request APIs. There are two distinct settings under the hood depending on the remote server type:
- **For S3-compatible Object Storage (useCustomRequestHandler)**: Labeled as **"Use Custom HTTP Handler"** in the standard settings tab, **"Use internal API"** in the Svelte-based Setup Wizard dialogue, and represented as `useProxy` in the Setup URI's query parameters due to an unfortunate misunderstanding during development.
- **For CouchDB (useRequestAPI)**: Labeled as **"Use Request API to avoid `inevitable` CORS problem"** in the standard settings tab, **"Use Internal API"** in the Svelte-based Setup Wizard dialogue, and represented as `useRequestAPI` in the Setup URI's query parameters.
- Customisation Sync
- The feature that synchronises settings, snippets, themes, and plug-ins. Write with an "s" in documentation (`Customisation`), though technical configurations and links may use `customization`.
- Database Adapter (IDB vs. IndexedDB)
- The local database storage interface used by PouchDB. The `IDB` adapter is recommended since the older `IndexedDB` adapter is obsolete and known to cause memory leaks in `LiveSync` mode. Users can switch between these adapters without a full database rebuild, although a local data migration and an Obsidian restart are required.
- Database Suffix (additionalSuffixOfDatabaseName)
- A unique suffix appended to the database name to allow synchronising multiple vaults with the same name on the same remote server.
- E2EE Algorithm
- The cryptographic algorithm version used for end-to-end encryption. All synchronising devices must be configured with a compatible version (such as `V2` or `V1`).
- Eden (Eden Chunks)
- A performance optimisation where newly created chunks are held within the document until they stabilise, before graduating to independent chunks.
- Fast Setup (Simple Fetch)
- A simplified, automated initial synchronisation flow triggered when setting up subsequent devices or recovering a database. It bypasses the detailed step-by-step setup wizard dialogues, prompting the user with high-level data processing decisions and completing the initial download and local file scan in one continuous process.
- Flag files (redflag.md, redflag2.md, redflag3.md)
- Special Markdown files (or directories) placed at the root of the vault to stop the boot-up sequence or trigger recovery tasks. For instance, `redflag.md` suspends all processes, while `redflag2.md` (`flag_rebuild.md`) triggers a full database rebuild and `redflag3.md` (`flag_fetch.md`) discards the local database to fetch it again from the remote.
- Garbage Collection (GC)
- The process of identifying and purging unreferenced chunks (unused data) from local and remote databases to reclaim storage space.
- Hatch (Hatch pane)
- A dedicated troubleshooting and maintenance section in the plug-in settings, typically hidden behind a warning-labeled collapsible panel to prevent accidental misconfiguration. It contains diagnostic utilities, database reset controls, status reports, and advanced edge-case patches.
- Hidden File Sync
- The feature that synchronises files located in hidden directories (like `.obsidian`).
- JWT Authentication
- An experimental authentication option for CouchDB allowing secure token-based authentication instead of standard credentials. It requires a configured private key/secret, algorithm, expiration duration, subject, and key ID.
- LiveSync
- A very confusing term.
- As a shortened form of `Self-hosted LiveSync`.
- As the name of a synchronisation mode. This should be changed to `Continuous`, in contrast to `Periodic`.
- livesync-serverpeer / webpeer
- Pseudo-clients that assist in WebRTC peer-to-peer communication.
- Metadata (File metadata)
- A database document that stores properties of a file, including its filename, path, size, modification time, and references (hashes) of the chunks that comprise the file's content. Conflict state is carried by the surrounding PouchDB/CouchDB revision metadata rather than by a separate history field inside the file metadata document. In Self-hosted LiveSync, file metadata is stored separately from the actual file content to enable efficient synchronisation and versioning.
- OneShot Sync
- A single, immediate bidirectional synchronisation (pull then push) triggered on demand or on specific events, as opposed to continuous (live) replication.
- Overwrite Server Data with This Device's Files
- A maintenance operation (formerly known as `Rebuild everything`) that discards the remote database and reconstructs it by uploading all current local files as a fresh database, overwriting any remote changes.
- Path Obfuscation
- A privacy option that encrypts file paths and folder names on the remote server.
- plug-in
- We use the hyphenated form `plug-in` in user-facing messages and general documentation, while `plugin` may appear in codebase files, configuration settings, or technical contexts.
- Signalling relay (P2P)
- A Nostr-compatible WebSocket relay used for peer discovery and WebRTC connection negotiation. It does not store or transfer Vault contents. The project author operates a public relay as a best-effort convenience, and users can provide another compatible relay.
- Remediation (maxMTimeForReflectEvents)
- A recovery setting that restricts the propagation of changes from the database to local storage, ignoring any file events (such as accidental mass deletions) that occurred after a specified date and time.
- Reset Synchronisation on This Device
- A maintenance operation (formerly known as `Fetch everything`) that discards the local database and reconstructs it by downloading all data from the remote server.
#### Revision
A revision is a version of one PouchDB/CouchDB document. Concurrent changes can form a revision tree with more than one current branch.
Revision modifiers describe independent properties. More than one may apply to the same revision:
- **leaf**: Has no known child revision.
- **winner**: Is the leaf selected by PouchDB/CouchDB as the current document.
- **conflict**: Is another current leaf which was not selected as the winner.
- **Vault-matching**: Represents the same file contents, or the same absent-file state, as the current Vault. More than one revision may match.
- **displayed**: Is recorded by valid device-local file provenance as the branch represented in the Vault. A pending local edit may no longer match its bytes, but still extends this recorded branch.
- **logically deleted**: Represents the absence of the file through a deletion marker. A logically deleted revision may also be a leaf, winner, conflict, or Vault-matching revision. An absent file retains no displayed provenance.
Avoid **live revision** in prose because it can ambiguously mean either a current leaf or a non-deleted revision. See [Independent revision properties](specs_conflict_resolution.md#independent-revision-properties) for the relationship between revision-tree roles, Vault state, and device-local provenance.
- Scram (Scram Switches)
- Emergency controls in the settings that allow users to suspend file watching or database writes to prevent corruption.
- Segmenter (Segmented-splitter)
- A chunking method that divides files on semantic boundaries (such as paragraphs or sections) rather than arbitrary byte boundaries.
- Self-hosted LiveSync
- The name of this plug-in. `Self-hosted` is one word.
- Setting Doctor (Config Doctor)
- A diagnostic utility that checks for mismatches or suboptimal configurations, presenting users with ideal values and recommendation reasons to easily resolve issues during migration, configuration import, or general troubleshooting.
- Setup URI
- An encrypted representation of the plug-in's settings containing server configuration, which allows users to clone their configuration across devices securely using a passphrase.
- Streaming replication (Stream-based replication)
- A data transfer method that downloads database documents as a continuous stream of events. It is significantly faster than traditional chunk-by-chunk HTTP requests and is used during Fast Setup to retrieve remote metadata quickly.
- Sync Mode
- The replication trigger mechanism. Users can select from `On Events` (synchronising on local file changes), `Periodic and Events` (synchronising at fixed intervals as well as on events), or `LiveSync` (continuous, real-time synchronisation).
- Synchronising devices
- Devices which participate in the same synchronisation for a Vault. The term describes membership rather than current activity, so it includes offline and idle devices.
- TURN Server (WebRTC P2P)
- A Traversal Using Relays around NAT server used as an optional fallback to relay encrypted WebRTC traffic when strict NAT or firewall rules block a direct peer connection. It is distinct from the signalling relay.
- Update Thinning (Batch database update)
- An optimisation that groups multiple local file edits together over a short delay before committing them to the local database, reducing the number of database write operations.
- WebRTC P2P (Peer-to-Peer)
- A synchronisation method enabling direct communication between devices without a central server database.
Project-specific meanings are defined separately in the
[Project glossary](glossary.md).
+1 -1
View File
@@ -20,7 +20,7 @@ Enabling Hidden File Sync requires an initialisation direction:
## Review the file selection
1. Open Self-hosted LiveSync settings.
2. Open `Setup`, find `Enable extra and advanced features`, and enable `Advanced features`.
2. Open `General Settings``Extra menus`, and enable `Advanced features`.
![Advanced features enabled](../../images/hidden-file-sync/guide-hidden-file-advanced-features.png)
+2
View File
@@ -88,6 +88,8 @@ Some settings must match across devices. LiveSync pauses synchronisation when th
Current releases automatically align compatible settings which control how new chunks are created, by default and where possible. This applies to the chunk hash algorithm, chunk size, and splitter version. Existing content remains readable across these choices, although using different choices can reduce chunk reuse and increase storage or transfer work. An explicit opt-out retains the manual review. A mismatch involving encryption, path obfuscation, file-name case handling, or any combination which includes one of those settings always remains a manual decision.
The `Sync now` command keeps routine replication progress quiet so that it is convenient to assign to a keyboard shortcut; assign one in Obsidian if that suits your workflow. A quiet command may still open this dialogue when a mismatch or another decision requires your attention.
The available actions depend on when the mismatch is found:
- While checking a remote profile, `Use configured settings` accepts the shared values already stored in that remote. `Dismiss` leaves this device's settings unchanged.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "1.0.20",
"version": "1.0.24",
"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",
+52 -2928
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "1.0.20",
"version": "1.0.24",
"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",
@@ -165,7 +165,6 @@
"typescript-eslint": "^8.61.0",
"vite": "^8.0.16",
"vitest": "^4.1.8",
"webdriverio": "^9.27.0",
"yaml": "^2.8.2"
},
"dependencies": {
@@ -178,7 +177,7 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.19",
"@vrtmrz/livesync-commonlib": "0.1.22",
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
@@ -187,7 +186,7 @@
"markdown-it": "^14.2.0",
"minimatch": "^10.2.5",
"obsidian": "^1.13.1",
"octagonal-wheels": "^0.1.53",
"octagonal-wheels": "^0.1.54",
"qrcode-generator": "^1.4.4",
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
},
+44 -30
View File
@@ -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,36 +15,36 @@ 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";
import { ModuleLiveSyncMain } from "./modules/main/ModuleLiveSyncMain";
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { ModuleBasicMenu } from "./modules/essential/ModuleBasicMenu";
import { usePrepareDatabaseForUse } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/prepareDatabaseForUse";
import type { Constructor } from "@vrtmrz/livesync-commonlib/compat/common/utils.type";
import { useReplicationScheduling, type ReplicationSchedulingControl } from "./serviceFeatures/replicationScheduling";
import { createCentralReplicatorProviderDefinitions } from "./common/replicatorProviders";
import { useReplicationFeature } from "./serviceFeatures/replication";
import { useConflictResolutionFeature } from "./serviceFeatures/conflictResolution";
import { useBasicCommandsFeature } from "./serviceFeatures/basicCommands";
/** Focused views returned by serviceFeatures which the host may consume during composition. */
export interface LiveSyncCoreFeatureViews {
readonly replicationScheduling: ReplicationSchedulingControl;
}
type CompatibilityReplicatorView = ReplicatorInstance & Partial<LiveSyncAbstractReplicator>;
export class LiveSyncBaseCore<
T extends ServiceContext = ServiceContext,
TCommands extends IMinimumLiveSyncCommands = IMinimumLiveSyncCommands,
>
implements
LiveSyncLocalDBEnv,
LiveSyncReplicatorEnv,
LiveSyncJournalReplicatorEnv,
LiveSyncCouchDBReplicatorEnv,
HasSettings<ObsidianLiveSyncSettings>
implements LiveSyncLocalDBEnv, LiveSyncCouchDBReplicatorEnv, HasSettings<ObsidianLiveSyncSettings>
{
addOns = [] as TCommands[];
@@ -74,18 +78,23 @@ 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);
}
// Compose late core features after host features and add-ons, then bind
// legacy modules so lifecycle handlers observe the required order.
useReplicationFeature(this);
useBasicCommandsFeature(this);
this.bindModuleFunctions();
}
/**
@@ -136,16 +145,16 @@ 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));
for (const module of extraModules) {
this._registerModule(module);
@@ -223,10 +232,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 +283,16 @@ export class LiveSyncBaseCore<
* Initialise ServiceFeatures.
* (Please refer `serviceFeatures` for more details)
*/
initialiseServiceFeatures() {
initialiseServiceFeatures(): LiveSyncCoreFeatureViews {
useConflictResolutionFeature(this);
useTargetFilters(this);
// enable target filter feature.
usePrepareDatabaseForUse(this);
// Migration to multiple remote configurations
useRemoteConfigurationMigration(this);
return Object.freeze({
replicationScheduling: useReplicationScheduling(this),
});
}
}
+5 -3
View File
@@ -82,9 +82,11 @@ RUN apt-get update \
WORKDIR /deps
# package.json lists only the packages that the CLI requires
COPY src/apps/cli/package.json ./package.json
RUN npm install --omit=dev
# Remove build-only dependencies before resolving the standalone runtime tree.
# npm --omit=dev omits them from disk, but still resolves their peer graph.
COPY src/apps/cli/package.json ./package.json
RUN npm pkg delete devDependencies \
&& npm install --omit=dev
# ─────────────────────────────────────────────────────────────────────────────
# Stage 3 — runtime
+51 -32
View File
@@ -48,7 +48,7 @@ CLI Main
- Settings management (JSON file)
- Graceful shutdown handling
## Usage
## Command overview
The CLI operates on a **database directory** which contains PouchDB data and settings.
@@ -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
@@ -146,6 +151,46 @@ npm run cli -- [database-path] [command] [args...]
node src/apps/cli/dist/index.cjs [database-path] [command] [args...]
```
### systemd installation
The `deploy/` directory contains a systemd unit template and an install script.
**Automated installation (user service, recommended):**
```bash
bash src/apps/cli/deploy/install.sh --vault /path/to/vault
```
**With a polling interval:**
```bash
bash src/apps/cli/deploy/install.sh --vault /path/to/vault --interval 60
```
**System-wide installation** (requires root or `sudo` for `/etc/systemd/system/`):
```bash
bash src/apps/cli/deploy/install.sh --system --vault /path/to/vault
```
The script:
1. Installs the repository dependencies and builds the CLI.
2. Installs the complete CLI bundle and its production dependencies under `~/.local/lib/livesync-cli` (user) or `/usr/local/lib/livesync-cli` (system), then checks that the installed CLI can start.
3. Installs the command wrapper as `~/.local/bin/livesync-cli` (user) or `/usr/local/bin/livesync-cli` (system).
4. Writes the unit file to `~/.config/systemd/user/livesync-cli.service` (user) or `/etc/systemd/system/livesync-cli.service` (system).
5. Reloads systemd, enables and starts the service, and reports success only after confirming that the service remains active.
Ensure that `~/.local/bin` for a user installation, or `/usr/local/bin` for a system-wide installation, is on the shell's `PATH` before invoking `livesync-cli` interactively. For example, add the following to the appropriate shell start-up file for a user installation when needed:
```bash
export PATH="$HOME/.local/bin:$PATH"
```
The generated systemd unit uses the wrapper's absolute path and does not depend on the shell's `PATH`.
**Manual setup** — if you prefer to manage the unit yourself, copy `deploy/livesync-cli.service`, replace `LIVESYNC_BIN` and `LIVESYNC_VAULT_PATH` with the actual binary path and Vault path, then install it in the appropriate systemd directory.
### Docker
A Docker image is provided for headless / server deployments. Build from the repository root:
@@ -205,7 +250,9 @@ candidate carries the host's public IP and peers can connect normally.
### Adding `livesync-cli` alias
To use the `livesync-cli` command globally, you can add an alias to your shell configuration file (e.g., `.zshrc` or `.bashrc`).
If you used the [systemd installer](#systemd-installation), no alias is required: it installs the `livesync-cli` wrapper in `~/.local/bin` or `/usr/local/bin`. If the installed command is not found, follow the `PATH` guidance in the systemd installation section.
The aliases below are only for running the CLI from a source checkout, or from Docker without using the installer. Add the appropriate alias to your shell configuration file, such as `.zshrc` or `.bashrc`.
If you are using `npm run`, add the following line:
@@ -338,6 +385,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:
@@ -499,36 +548,6 @@ Patterns apply in both directions: the chokidar watcher will not emit events for
Changes to this file require a daemon restart to take effect.
### Systemd Installation
The `deploy/` directory contains a systemd unit template and an install script.
**Automated install (user service, recommended):**
```bash
bash src/apps/cli/deploy/install.sh --vault /path/to/vault
```
**With polling interval:**
```bash
bash src/apps/cli/deploy/install.sh --vault /path/to/vault --interval 60
```
**System-wide install** (requires root / sudo for `/etc/systemd/system/`):
```bash
bash src/apps/cli/deploy/install.sh --system --vault /path/to/vault
```
The script:
1. Builds the CLI (`npm install` + `npm run build`).
2. Installs the binary to `~/.local/bin/livesync-cli` (user) or `/usr/local/bin/livesync-cli` (system).
3. Writes the unit file to `~/.config/systemd/user/livesync-cli.service` (user) or `/etc/systemd/system/livesync-cli.service` (system).
4. Runs `systemctl [--user] daemon-reload && systemctl [--user] enable --now livesync-cli`.
**Manual setup** — if you prefer to manage the unit yourself, copy `deploy/livesync-cli.service`, replace `LIVESYNC_BIN` and `LIVESYNC_VAULT_PATH` with the actual binary path and vault path, then install to the appropriate systemd directory.
### Planned options:
- `--immediate`: Perform sync after the command (e.g. `push`, `pull`, `put`, `rm`).
@@ -0,0 +1,137 @@
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 assertNeverCentralRemoteAdministrationFailureReason(reason: never): never {
throw new Error(`Unexpected central remote administration failure reason: ${String(reason)}`);
}
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;
}
const reason = result.reason;
switch (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.ACTIVE_CONFIGURATION_MISMATCH:
standardIo.writeStderr(
"[Verification] The active remote configuration changed before remote administration could begin.\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;
default:
return assertNeverCentralRemoteAdministrationFailureReason(reason);
}
}
/**
* 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 27 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);
+55 -65
View File
@@ -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,17 +11,13 @@ 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));
}
/** Parse a CLI timeout expressed as a finite, non-negative number of seconds. */
export function parseTimeoutSeconds(value: string, commandName: string): number {
const timeoutSec = Number(value);
if (!Number.isFinite(timeoutSec) || timeoutSec < 0) {
@@ -43,35 +38,36 @@ 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));
}
/** Connect for a bounded discovery interval, return a stable peer ordering, and disconnect. */
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 +86,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 +95,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 +136,25 @@ async function writePeerConnectionStatsIfRequested(
localCandidate,
remoteCandidate,
};
await fsPromises.appendFile(outputPath, `${JSON.stringify(payload)}\n`, "utf8");
return payload;
}
/** Resolve one peer token, complete pull then push, and disconnect on every settlement. */
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 +166,14 @@ 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);
if (!pullResult || pullResult.status !== "completed") {
throw LiveSyncError.fromError("P2P sync failed while pulling from peer");
}
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 +181,19 @@ 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;
/** Connect the headless P2P host and transfer transport ownership to the caller. */
export async function openP2PHost(
core: LiveSyncBaseCore<ServiceContext, never>,
p2pService: CLIP2PService | undefined
): Promise<CLIP2PService> {
const service = requireP2PService(core, p2pService);
await service.transportLifecycle.connect();
return service;
}
+131 -2
View File
@@ -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 () => ({ status: "completed" as const, ok: true as const }));
const requestPushToPeer = vi.fn(async () => ({ status: "completed" as const, ok: true as const }));
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,98 @@ 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("rejects a cancelled pull without requesting a peer push", async () => {
const { service, disconnect, pullFromPeer, requestPushToPeer } = createP2PService();
pullFromPeer.mockResolvedValue({ status: "cancelled" } as never);
await expect(syncWithPeer(createCore(), service as never, "peer-a", 0)).rejects.toBeDefined();
expect(requestPushToPeer).not.toHaveBeenCalled();
expect(disconnect).toHaveBeenCalledOnce();
});
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",
},
});
});
});
+73 -172
View File
@@ -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";
@@ -19,75 +15,34 @@ import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_b
import type { CLICommandContext, CLIOptions } from "./types";
import { toArrayBuffer, toDatabaseRelativePath } from "./utils";
import { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./p2p";
import { performFullScan } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import {
performFullScan,
VaultScanResults,
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
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,
REPLICATION_PROGRESS_PRESENTATIONS,
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;
}
try {
milestone = await dbRet.db.get(MILESTONE_DOCID);
} finally {
await dbRet.db.close();
}
} 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;
@@ -95,19 +50,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);
@@ -129,8 +93,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
@@ -153,7 +118,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);
@@ -182,11 +153,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) {
@@ -204,13 +175,20 @@ 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",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
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` +
@@ -218,7 +196,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
);
}
}
return !!result;
return isReplicationCompleted(result);
}
if (options.command === "p2p-peers") {
@@ -227,7 +205,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");
}
@@ -244,14 +222,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;
@@ -554,7 +532,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
writeStderrLine(standardIo, "[Command] mirror");
const log = (msg: unknown) => writeStderrLine(standardIo, `[Mirror] ${String(msg)}`);
const errorManager = new UnresolvedErrorManager(core.services.appLifecycle, core.services.context.events);
return await performFullScan(core, log, errorManager, false, true);
return (await performFullScan(core, log, errorManager, false, true)) === VaultScanResults.COMPLETED;
}
if (options.command === "remote-add") {
@@ -757,88 +735,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") {
@@ -863,13 +761,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;
+249 -20
View File
@@ -2,10 +2,26 @@ 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,
REPLICATION_PROGRESS_PRESENTATIONS,
replicationFailed,
} from "@vrtmrz/livesync-commonlib/replication";
function createStandardIoMock() {
return {
@@ -44,8 +60,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 +127,7 @@ function makeOptions(command: CLIOptions["command"], commandArgs: string[]): CLI
databasePath: "/tmp/vault",
verbose: false,
force: false,
compatRemoteAdminExitZero: false,
};
}
@@ -231,6 +266,42 @@ describe("runCommand abnormal cases", () => {
vi.restoreAllMocks();
});
it("retains visible progress for the interactive sync command", async () => {
const core = createCoreMock();
await expect(
runCommand(makeOptions("sync", []), {
...context,
core,
})
).resolves.toBe(true);
expect(core.services.replication.replicateUserInitiated).toHaveBeenCalledWith(
expect.objectContaining({ progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE })
);
});
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,28 +777,158 @@ 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("reports when the active remote configuration changes before administration begins", async () => {
const core = createCoreMock();
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.ACTIVE_CONFIGURATION_MISMATCH,
});
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] The active remote configuration changed before remote administration could begin.\n"
);
});
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 remoteDatabase = {
close: vi.fn(async () => undefined),
get: vi.fn(async () => ({
locked: false,
accepted_nodes: ["test-node-id"],
})),
};
core.services.replicator.getActiveReplicator.mockReturnValueOnce({
nodeid: "test-node-id",
initializeDatabaseForReplication: vi.fn(async () => undefined),
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase })),
});
const result = await runCommand(makeOptions("mark-resolved", []), {
...context,
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(remoteDatabase.close).toHaveBeenCalledOnce();
expect(core.services.replication.markResolved).not.toHaveBeenCalled();
});
it("mark-resolved with remote-id temporarily activates it and runs markResolved", async () => {
@@ -745,7 +946,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);
@@ -758,7 +961,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();
});
@@ -777,7 +982,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);
@@ -790,7 +997,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();
});
@@ -809,7 +1018,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);
@@ -817,6 +1028,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,
@@ -827,6 +1049,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 () => {
+6 -1
View File
@@ -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;
+57 -8
View File
@@ -8,7 +8,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../../.." && pwd)"
CLI_DIR="$REPO_ROOT/src/apps/cli"
SERVICE_TEMPLATE="$SCRIPT_DIR/livesync-cli.service"
@@ -104,30 +104,70 @@ fi
# ── Install binary ───────────────────────────────────────────────────────────
if [[ "$INSTALL_MODE" == "user" ]]; then
BIN_DIR="$HOME/.local/bin"
LIB_DIR="$HOME/.local/lib/livesync-cli"
UNIT_DIR="$HOME/.config/systemd/user"
SYSTEMCTL_FLAGS="--user"
else
BIN_DIR="/usr/local/bin"
LIB_DIR="/usr/local/lib/livesync-cli"
UNIT_DIR="/etc/systemd/system"
SYSTEMCTL_FLAGS=""
fi
mkdir -p "$BIN_DIR"
LIB_PARENT="$(dirname -- "$LIB_DIR")"
mkdir -p "$BIN_DIR" "$LIB_PARENT"
LIVESYNC_BIN="$BIN_DIR/livesync-cli"
LIVESYNC_JS="$BIN_DIR/livesync-cli.js"
LIVESYNC_JS="$LIB_DIR/dist/index.cjs"
# Copy the CJS bundle so the wrapper is self-contained and independent of the
# build directory location.
cp "$BUILT_CJS" "$LIVESYNC_JS"
# Build a complete runtime payload before replacing any previous installation.
# The Vite output contains hashed sibling chunks, while some Node dependencies
# deliberately remain external and must be installed next to the bundle.
PAYLOAD_STAGING="$(mktemp -d "$LIB_PARENT/.livesync-cli.install.XXXXXX")"
cleanup_payload() {
if [[ -n "$PAYLOAD_STAGING" ]] && [[ -e "$PAYLOAD_STAGING" ]]; then
rm -rf -- "$PAYLOAD_STAGING"
fi
}
trap cleanup_payload EXIT
# Write a bash wrapper that invokes node on the installed bundle.
cp "$CLI_DIR/package.json" "$PAYLOAD_STAGING/package.json"
npm install --omit=dev --no-audit --no-fund --prefix "$PAYLOAD_STAGING"
cp -R "$CLI_DIR/dist" "$PAYLOAD_STAGING/dist"
if ! node "$PAYLOAD_STAGING/dist/index.cjs" --help >/dev/null; then
echo "Error: installed CLI failed its start-up check" >&2
exit 1
fi
PAYLOAD_BACKUP=""
if [[ -e "$LIB_DIR" ]] || [[ -L "$LIB_DIR" ]]; then
PAYLOAD_BACKUP="$(mktemp -d "$LIB_PARENT/.livesync-cli.backup.XXXXXX")"
rmdir "$PAYLOAD_BACKUP"
mv -- "$LIB_DIR" "$PAYLOAD_BACKUP"
fi
if ! mv -- "$PAYLOAD_STAGING" "$LIB_DIR"; then
if [[ -n "$PAYLOAD_BACKUP" ]]; then
mv -- "$PAYLOAD_BACKUP" "$LIB_DIR"
fi
echo "Error: failed to install the CLI files at $LIB_DIR" >&2
exit 1
fi
PAYLOAD_STAGING=""
if [[ -n "$PAYLOAD_BACKUP" ]]; then
rm -rf -- "$PAYLOAD_BACKUP"
fi
trap - EXIT
# Write a bash wrapper that invokes Node.js on the installed payload.
cat > "$LIVESYNC_BIN" <<WRAPPER
#!/usr/bin/env bash
exec node "$LIVESYNC_JS" "\$@"
WRAPPER
chmod +x "$LIVESYNC_BIN"
echo "[INFO] Installed bundle: $LIVESYNC_JS"
echo "[INFO] Installed CLI files: $LIB_DIR"
echo "[INFO] Installed binary: $LIVESYNC_BIN"
# ── Write systemd unit ───────────────────────────────────────────────────────
@@ -180,6 +220,15 @@ systemctl $SYSTEMCTL_FLAGS daemon-reload
# shellcheck disable=SC2086
systemctl $SYSTEMCTL_FLAGS enable --now livesync-cli
sleep 1
# shellcheck disable=SC2086
if ! systemctl $SYSTEMCTL_FLAGS is-active --quiet livesync-cli; then
echo "Error: livesync-cli service did not remain active after startup." >&2
# shellcheck disable=SC2086
systemctl $SYSTEMCTL_FLAGS status livesync-cli --no-pager || true
exit 1
fi
echo ""
echo "[Done] livesync-cli service installed and started."
echo ""
+192
View File
@@ -0,0 +1,192 @@
import { spawnSync } from "node:child_process";
import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
const deploySourceDirectory = dirname(fileURLToPath(import.meta.url));
const temporaryDirectories: string[] = [];
type InstallerFixture = {
cliDirectory: string;
environment: NodeJS.ProcessEnv;
homeDirectory: string;
installerPath: string;
npmCallLog: string;
repositoryRoot: string;
systemctlCallLog: string;
vaultDirectory: string;
};
async function writeExecutable(path: string, content: string): Promise<void> {
await writeFile(path, `${content}\n`, "utf8");
await chmod(path, 0o755);
}
async function createInstallerFixture(serviceActive: boolean): Promise<InstallerFixture> {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "livesync-cli-installer-"));
temporaryDirectories.push(temporaryDirectory);
const repositoryRoot = join(temporaryDirectory, "repository");
const cliDirectory = join(repositoryRoot, "src", "apps", "cli");
const deployDirectory = join(cliDirectory, "deploy");
const distDirectory = join(cliDirectory, "dist");
const fakeBinDirectory = join(temporaryDirectory, "fake-bin");
const homeDirectory = join(temporaryDirectory, "home");
const vaultDirectory = join(temporaryDirectory, "vault");
const npmCallLog = join(temporaryDirectory, "npm-calls.log");
const systemctlCallLog = join(temporaryDirectory, "systemctl-calls.log");
await Promise.all([
mkdir(deployDirectory, { recursive: true }),
mkdir(distDirectory, { recursive: true }),
mkdir(fakeBinDirectory, { recursive: true }),
mkdir(homeDirectory, { recursive: true }),
mkdir(vaultDirectory, { recursive: true }),
]);
await Promise.all([
copyFile(join(deploySourceDirectory, "install.sh"), join(deployDirectory, "install.sh")),
copyFile(join(deploySourceDirectory, "livesync-cli.service"), join(deployDirectory, "livesync-cli.service")),
writeFile(
join(repositoryRoot, "package.json"),
JSON.stringify({ private: true, workspaces: ["src/apps/*"] }),
"utf8"
),
writeFile(
join(cliDirectory, "package.json"),
JSON.stringify({
name: "self-hosted-livesync-cli",
private: true,
version: "0.0.0",
dependencies: { "fixture-runtime-dependency": "1.0.0" },
}),
"utf8"
),
writeFile(
join(distDirectory, "index.cjs"),
'const chunk = require("./chunk.cjs");\n' +
'const dependency = require("fixture-runtime-dependency");\n' +
"process.stdout.write(`${chunk}:${dependency}\\n`);\n",
"utf8"
),
writeFile(join(distDirectory, "chunk.cjs"), 'module.exports = "chunk-ready";\n', "utf8"),
]);
await writeExecutable(
join(fakeBinDirectory, "npm"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'printf \'%s|%s\\n\' "$PWD" "$*" >> "$NPM_CALL_LOG"',
'prefix=""',
"expect_prefix=0",
'for argument in "$@"; do',
' if [[ "$expect_prefix" -eq 1 ]]; then',
' prefix="$argument"',
" expect_prefix=0",
' elif [[ "$argument" == "--prefix" ]]; then',
" expect_prefix=1",
" fi",
"done",
'if [[ -n "$prefix" ]]; then',
' mkdir -p "$prefix/node_modules/fixture-runtime-dependency"',
" printf '%s\\n' 'module.exports = \"dependency-ready\";' > \"$prefix/node_modules/fixture-runtime-dependency/index.js\"",
"fi",
].join("\n")
);
await writeExecutable(
join(fakeBinDirectory, "systemctl"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'printf \'%s\\n\' "$*" >> "$SYSTEMCTL_CALL_LOG"',
'if [[ " $* " == *" is-active "* ]]; then',
' [[ "${FAKE_SYSTEMCTL_ACTIVE:-1}" == "1" ]]',
" exit",
"fi",
'if [[ " $* " == *" status "* ]]; then',
" printf '%s\\n' \"fixture service status\"",
"fi",
].join("\n")
);
await writeExecutable(join(fakeBinDirectory, "sleep"), ["#!/usr/bin/env bash", "exit 0"].join("\n"));
return {
cliDirectory,
environment: {
...process.env,
FAKE_SYSTEMCTL_ACTIVE: serviceActive ? "1" : "0",
HOME: homeDirectory,
NPM_CALL_LOG: npmCallLog,
PATH: `${fakeBinDirectory}${delimiter}${process.env.PATH ?? ""}`,
SYSTEMCTL_CALL_LOG: systemctlCallLog,
},
homeDirectory,
installerPath: join(deployDirectory, "install.sh"),
npmCallLog,
repositoryRoot,
systemctlCallLog,
vaultDirectory,
};
}
function runInstaller(fixture: InstallerFixture) {
return spawnSync("bash", [fixture.installerPath, "--vault", fixture.vaultDirectory], {
encoding: "utf8",
env: fixture.environment,
});
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))
);
});
describe.skipIf(process.platform === "win32")("CLI systemd installer", () => {
it("installs a runnable CLI independently of the source repository", async () => {
const fixture = await createInstallerFixture(true);
const installation = runInstaller(fixture);
expect(installation.error).toBeUndefined();
expect(installation.status, installation.stderr).toBe(0);
expect(installation.stdout).toContain("[Done] livesync-cli service installed and started.");
const installedCommand = join(fixture.homeDirectory, ".local", "bin", "livesync-cli");
const installedPayload = join(fixture.homeDirectory, ".local", "lib", "livesync-cli", "dist", "index.cjs");
const installedUnit = join(fixture.homeDirectory, ".config", "systemd", "user", "livesync-cli.service");
expect(await readFile(installedPayload, "utf8")).toContain('require("./chunk.cjs")');
expect(await readFile(installedUnit, "utf8")).toContain("Type=exec");
await rm(fixture.repositoryRoot, { recursive: true });
const command = spawnSync(installedCommand, [], { encoding: "utf8", env: fixture.environment });
expect(command.error).toBeUndefined();
expect(command.status, command.stderr).toBe(0);
expect(command.stdout).toBe("chunk-ready:dependency-ready\n");
const npmCalls = await readFile(fixture.npmCallLog, "utf8");
expect(npmCalls).toContain(`${fixture.repositoryRoot}|install --silent`);
expect(npmCalls).toContain(`${fixture.cliDirectory}|run build`);
expect(npmCalls).toMatch(/install .*--omit=dev|install --omit=dev/);
const systemctlCalls = await readFile(fixture.systemctlCallLog, "utf8");
expect(systemctlCalls).toContain("--user enable --now livesync-cli");
expect(systemctlCalls).toContain("--user is-active --quiet livesync-cli");
});
it("does not report success when the service fails to remain active", async () => {
const fixture = await createInstallerFixture(false);
const installation = runInstaller(fixture);
const combinedOutput = `${installation.stdout}\n${installation.stderr}`;
expect(installation.error).toBeUndefined();
expect(installation.status).not.toBe(0);
expect(combinedOutput).toContain("service did not remain active after startup");
expect(combinedOutput).not.toContain("[Done]");
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Type=exec
ExecStart=LIVESYNC_BIN LIVESYNC_VAULT_PATH
Restart=on-failure
RestartSec=10
+24 -5
View File
@@ -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,
+10
View File
@@ -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([]);
});
});
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "1.0.20-cli",
"version": "1.0.24-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
@@ -12,7 +12,7 @@
"buildRun": "npm run build && npm run cli --",
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
"check": "tsc -p tsconfig.json",
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts",
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts",
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
@@ -5,7 +5,7 @@ import { createNodeStandardIo } from "@vrtmrz/livesync-commonlib/node";
import { writeStderrLine } from "@/apps/cli/cliOutput";
import { main, type CliCommandRunner } from "@/apps/cli/main";
import { parseTimeoutSeconds } from "@/apps/cli/commands/p2p";
import { runP2PReplicatorReplacementProbe } from "./p2p-replicator-replacement";
import { runP2PReplicatorReplacementProbe } from "./p2p-replicator-replacement.test";
if (
typeof (compatGlobal as unknown as Record<string, unknown>).RTCPeerConnection === "undefined" &&
@@ -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;
}
+1
View File
@@ -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
View File
@@ -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:");
});
+18 -1
View File
@@ -146,6 +146,13 @@ export class WebAppRuntime {
return this.paneHost;
}
/**
* Import local files and complete the readiness boundary needed by optional P2P.
*
* An unconfigured central remote cannot use the normal offline-scan path, so
* the explicit WebApp scan completes the same post-scan finalisation without
* treating the central remote as configured.
*/
async scanLocalFiles(): Promise<boolean> {
const core = this.core;
const fileAccess = this.platformServiceModules?.vaultAccess;
@@ -171,7 +178,17 @@ export class WebAppRuntime {
this.addLog(`Failed to import ${path}: ${String(error)}`, LOG_LEVEL_NOTICE, "scan");
}
}
return succeeded;
if (!succeeded || core.services.appLifecycle.isReady()) {
return succeeded;
}
if (!(await core.services.databaseEvents.onDatabaseInitialised(false))) {
return false;
}
if (!(await core.services.fileProcessing.commitPendingFileEvents())) {
return false;
}
core.services.appLifecycle.markIsReady();
return true;
}
async start(): Promise<void> {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "1.0.20-webapp",
"version": "1.0.24-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "1.0.20-webpeer",
"version": "1.0.24-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -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;
+5 -12
View File
@@ -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()
+283
View File
@@ -0,0 +1,283 @@
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";
/**
* Central milestone administration shared by the two central providers.
*
* The provider definition selects a reader before mutation. CouchDB then owns
* a fresh verification connection, while Object Storage borrows the active
* Journal client. Local node identity is established before either mutation.
*/
const JOURNAL_MILESTONE_PATH = "_00000000-milestone.json";
/** A provider read result, including failures which settled without a throw. */
type CentralMilestoneReadResult =
| { readonly milestone: EntryMilestoneInfo | false | undefined }
| { readonly failureReason: CentralRemoteAdministrationFailureReason; readonly detail?: unknown };
/** A settings-bound postcondition reader prepared before remote mutation. */
type PreparedCentralMilestoneReader = () => Promise<CentralMilestoneReadResult>;
/** Select and validate the provider-specific reader without performing I/O. */
type CentralMilestoneReaderPreparer = (
replicator: CentralRemoteAdministrationReplicator,
setting: RemoteDBSettings
) => PreparedCentralMilestoneReader;
type CouchDBAdministrationReplicator = CentralRemoteAdministrationReplicator &
Pick<LiveSyncCouchDBReplicator, "connectRemoteCouchDBWithSetting" | "isMobile">;
type JournalAdministrationClient = Pick<LiveSyncJournalReplicator["client"], "downloadJsonWithResult">;
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 () => {
// This verification connection is fresh and owned by this read. It is
// always closed here rather than retained by the active Replicator.
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 &&
"downloadJsonWithResult" in client &&
typeof client.downloadJsonWithResult === "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 assertNeverJournalStorageRead(result: never): never {
throw new Error(`Unexpected Journal storage read result: ${String(result)}`);
}
function prepareObjectStorageMilestoneReader(
replicator: CentralRemoteAdministrationReplicator
): PreparedCentralMilestoneReader {
// The Journal client belongs to the active Replicator. This reader borrows
// it for the provider's distinct milestone path and must not dispose it.
const client = requireJournalAdministrationClient(replicator);
return async () => {
try {
const result = await client.downloadJsonWithResult<EntryMilestoneInfo>(JOURNAL_MILESTONE_PATH);
switch (result.status) {
case "available":
return { milestone: result.value };
case "not-found":
return { milestone: undefined };
case "unavailable":
return {
failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED,
detail: result.error,
};
default:
return assertNeverJournalStorageRead(result);
}
} 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,260 @@
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 milestone = { locked: false, accepted_nodes: ["node-1"] };
const downloadJson = vi.fn(async () => milestone);
const downloadJsonWithResult = vi.fn(async () => ({
status: "available" as const,
value: milestone,
}));
const replicator = {
nodeid: "node-1",
initializeDatabaseForReplication: vi.fn(async () => true),
markRemoteLocked: vi.fn(async () => undefined),
markRemoteResolved: vi.fn(async () => undefined),
client: { downloadJson, downloadJsonWithResult },
};
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(downloadJsonWithResult).toHaveBeenCalledWith("_00000000-milestone.json");
expect(downloadJson).not.toHaveBeenCalled();
});
it("keeps a missing Object Storage milestone as an unverified postcondition", async () => {
const downloadJson = vi.fn(async () => false);
const downloadJsonWithResult = vi.fn(async () => ({ status: "not-found" as const }));
const replicator = {
nodeid: "node-1",
initializeDatabaseForReplication: vi.fn(async () => true),
markRemoteLocked: vi.fn(async () => undefined),
markRemoteResolved: vi.fn(async () => undefined),
client: { downloadJson, downloadJsonWithResult },
};
const result = await OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY.run(
replicator as never,
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO },
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED }
);
expect(result).toEqual({
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND,
});
expect(downloadJsonWithResult).toHaveBeenCalledWith("_00000000-milestone.json");
expect(downloadJson).not.toHaveBeenCalled();
});
it("returns a typed failure with diagnostic detail when Object Storage milestone reading is unavailable", async () => {
const diagnostic = new Error("object storage unavailable");
const downloadJson = vi.fn(async () => false);
const downloadJsonWithResult = vi.fn(async () => ({
status: "unavailable" as const,
error: diagnostic,
}));
const replicator = {
nodeid: "node-1",
initializeDatabaseForReplication: vi.fn(async () => true),
markRemoteLocked: vi.fn(async () => undefined),
markRemoteResolved: vi.fn(async () => undefined),
client: { downloadJson, downloadJsonWithResult },
};
const result = await OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY.run(
replicator as never,
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO },
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED }
);
expect(result).toEqual({
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED,
detail: diagnostic,
});
expect(downloadJsonWithResult).toHaveBeenCalledWith("_00000000-milestone.json");
expect(downloadJson).not.toHaveBeenCalled();
});
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 Object Storage adapter which only exposes lossy milestone reading", async () => {
const markRemoteResolved = vi.fn(async () => undefined);
const replicator = {
nodeid: "node-1",
initializeDatabaseForReplication: vi.fn(async () => true),
markRemoteLocked: vi.fn(async () => undefined),
markRemoteResolved,
client: { downloadJson: vi.fn(async () => false) },
};
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();
});
});
@@ -177,6 +177,7 @@ describe("packaged Commonlib compatibility gate", () => {
databaseService: {},
fileProcessingService: { commitPendingFileEvents: vi.fn().mockResolvedValue(true) },
replicatorService: {
acquireActiveReplicatorContext: vi.fn().mockResolvedValue(undefined),
getActiveReplicator: () => ({ openReplication }),
runFiniteReplicationActivity,
},
@@ -7,8 +7,6 @@
* remove it from this map in the same change.
*/
export const liveSyncProvisionalEnglishMessages = {
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.":
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.",
"Setup Complete: Preparing to Fetch from Another Device": "Setup Complete: Preparing to Fetch from Another Device",
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.":
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.",
@@ -59,10 +57,6 @@ export const liveSyncProvisionalEnglishMessages = {
"Follow whenever this device connects": "Follow whenever this device connects",
"Include in the P2P synchronisation command": "Include in the P2P synchronisation command",
"More actions for ${DEVICE}": "More actions for ${DEVICE}",
"Create or connect to database and continue": "Create or connect to database and continue",
"Connect to existing database and continue": "Connect to existing database and continue",
"Test connection and save": "Test connection and save",
"Save without connecting": "Save without connecting",
"Use this device's settings": "Use this device's settings",
Retry: "Retry",
"No Synchronisation Settings Found": "No Synchronisation Settings Found",
@@ -75,14 +69,6 @@ export const liveSyncProvisionalEnglishMessages = {
"Could not read the remote's synchronisation settings. Retry, or continue the overwrite with this device's settings. A working connection is still required.",
"Skips checking and applying synchronisation settings from the remote.":
"Skips checking and applying synchronisation settings from the remote.",
"Enter a complete HTTP or HTTPS URL.": "Enter a complete HTTP or HTTPS URL.",
"CouchDB validates the database name when you connect. The name must not be empty.":
"CouchDB validates the database name when you connect. The name must not be empty.",
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.":
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.",
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.":
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.",
"Check server requirements": "Check server requirements",
"Change CouchDB server setting": "Change CouchDB server setting",
"Change CouchDB server setting '${SETTING}' to '${VALUE}'?":
"Change CouchDB server setting '${SETTING}' to '${VALUE}'?",
+87 -16
View File
@@ -148,16 +148,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "(正则表达式)如果已设置,则所有匹配此模式的本地和远端文件变更都会被跳过。",
"zh-tw": "(正則表示式)若已設定,所有符合此模式的本機與遠端檔案變更都會被略過。",
},
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.":
"(Select this if you already have another synchronising device.) This option adds this device to the same synchronisation.":
{
def: "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.",
es: "(Seleccione esto si ya utiliza la sincronización en otro ordenador o teléfono). Esta opción es adecuada si desea añadir este dispositivo a una configuración de LiveSync existente。",
ja: "(別の PC やスマートフォンですでに同期を利用している場合に選択してください。)この端末を既存の LiveSync 構成に追加する場合に適しています。",
ko: "(다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.",
ru: "(Выберите этот вариант, если вы уже используете синхронизацию на другом компьютере или смартфоне.) Он подходит, если вы хотите добавить это устройство к уже существующей конфигурации LiveSync。",
zh: "(如果你已经在另一台电脑或手机上使用同步,请选择此项。)此选项适合将当前设备加入现有 LiveSync 配置的用户。",
"zh-tw":
"(如果你已經在另一台電腦或手機上使用同步,請選擇此項。)此選項適合將目前裝置加入既有 LiveSync 設定的使用者。",
def: "(Select this if you already have another synchronising device.) This option adds this device to the same synchronisation.",
},
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.":
{
@@ -740,6 +733,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "检查尚未转换为路径混淆 ID 的文档,并在需要时将其转换。",
"zh-tw": "檢查尚未轉換為路徑混淆 ID 的文件,並在需要時進行轉換。",
},
"Check server requirements": {
def: "Check server requirements",
es: "Comprobar los requisitos del servidor",
},
"Checking connection... Please wait.": {
def: "Checking connection... Please wait.",
es: "Comprobando la conexión... Espera un momento.",
@@ -993,6 +990,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
ko: "연결",
"zh-tw": "連線",
},
"Connect to existing database and continue": {
def: "Connect to existing database and continue",
es: "Conectar a la base de datos existente y continuar",
},
"Connected to Signaling Server (as Peer ID: ${peerId})": {
def: "Connected to Signaling Server (as Peer ID: ${peerId})",
es: "Conectado al servidor de señalización (como ID de par: ${peerId})",
@@ -1094,6 +1095,14 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "CouchDB 连接调优",
"zh-tw": "CouchDB 連線調校",
},
"CouchDB validates the database name when you connect. The name must not be empty.": {
def: "CouchDB validates the database name when you connect. The name must not be empty.",
es: "CouchDB valida el nombre de la base de datos al conectar. El nombre no puede estar vacío.",
},
"Create or connect to database and continue": {
def: "Create or connect to database and continue",
es: "Crear o conectar a la base de datos y continuar",
},
"Create P2P remote": {
def: "Create P2P remote",
es: "Crear remoto P2P",
@@ -1433,7 +1442,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"dialog.yourLanguageAvailable": {
def: "Self-hosted LiveSync had translations for your language, so the %{Display language} setting was enabled.\n\nNote: Not all messages are translated. We are waiting for your contributions!\nNote 2: If you create an Issue, **please revert to Default** and then take screenshots, messages and logs. This can be done in the setting dialogue.\nMay you find it easy to use!",
es: "Self-hosted LiveSync tenía traducciones para tu idioma, así que se ha activado el ajuste %{Display language}.\n\nNota: no todos los mensajes están traducidos. ¡Esperamos tus contribuciones!\nNota 2: si abres una incidencia, **vuelve antes a Predeterminado** y luego haz las capturas de pantalla y recoge los mensajes y registros. Puedes hacerlo desde el diálogo de ajustes.\n¡Que lo disfrutes!",
es: "Self-hosted LiveSync tenía traducciones para tu idioma, así que se ha activado el ajuste Idioma de visualización.\n\nNota: no todos los mensajes están traducidos. ¡Esperamos tus contribuciones!\nNota 2: si abres una incidencia, **vuelve antes a Predeterminado** y luego haz las capturas de pantalla y recoge los mensajes y registros. Puedes hacerlo desde el diálogo de ajustes.\n¡Que lo disfrutes!",
fr: "Self-hosted LiveSync dispose d'une traduction pour votre langue, le paramètre %{Display language} a donc été activé.\n\nNote : Tous les messages ne sont pas traduits. Nous attendons vos contributions !\nNote 2 : Si vous créez un ticket, **veuillez revenir à Par défaut** puis prendre des captures d'écran, messages et journaux. Cela peut être fait dans la boîte de dialogue des paramètres.\nBonne utilisation !",
he: "ל-Self-hosted LiveSync יש תרגום לשפתך, ולכן הגדרת %{Display language} הופעלה.\n\nהערה: לא כל ההודעות מתורגמות. אנחנו ממתינים לתרומותיך!\nהערה 2: אם אתה פותח Issue, **אנא חזור ל-%{lang-def}** ואז צלם צילומי מסך, הודעות ויומנים. ניתן לעשות זאת בדיאלוג ההגדרות.\nנקווה שתמצא/י את הפלאגין נוח לשימוש!",
ja: "Self-hosted LiveSync に設定されている言語の翻訳がありましたので、インターフェースの表示言語が適用されました。\n\n注意: 全てのメッセージは翻訳されていません。あなたの貢献をお待ちしています!\nGithubにIssueを作成する際には、 インターフェースの表示言語 を一旦 Default に戻してから、スクショやメッセージ、ログを収集してください。これは設定から変更できます。\n\n便利に使用できれば幸いです。",
@@ -2035,6 +2044,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "增大块大小",
"zh-tw": "擴大 chunk 大小",
},
"Enter a complete HTTP or HTTPS URL.": {
def: "Enter a complete HTTP or HTTPS URL.",
es: "Introduce una URL HTTP o HTTPS completa.",
},
"Enter a folder prefix (optional)": {
def: "Enter a folder prefix (optional)",
es: "Introduce un prefijo de carpeta (opcional)",
@@ -2530,6 +2543,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
ko: "해당 없는 항목 숨기기",
"zh-tw": "隱藏不適用的項目",
},
"Hide password": {
def: "Hide password",
es: "Ocultar contraseña",
},
"Higher (${local} > ${remote})": {
def: "Higher (${local} > ${remote})",
es: "Superior (${local} > ${remote})",
@@ -4195,6 +4212,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "等待就绪...",
"zh-tw": "正在等待就緒⋯",
},
"moduleLog.pathComponentTooLong": {
def: "A file or folder name exceeds ${maxBytes} UTF-8 bytes and may not work on some Android and Linux file systems: ${components}",
},
"moduleLog.showLog": {
def: "Show Log",
es: "Mostrar registro",
@@ -5369,7 +5389,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"obsidianLiveSyncSettingTab.logConfiguredLiveSync": {
def: "Configured synchronization mode: LiveSync",
es: "Modo de sincronización configurado: Sincronización en Vivo",
es: "Modo de sincronización configurado: Sincronización en vivo",
fr: "Mode de synchronisation configuré : LiveSync",
he: "מצב סנכרון שהוגדר: LiveSync",
ja: "設定された同期モード: LiveSync",
@@ -5981,7 +6001,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"obsidianLiveSyncSettingTab.nameTestDatabaseConnection": {
def: "Test Database Connection",
es: "Probar Conexión de Base de Datos",
es: "Probar conexión de base de datos",
fr: "Tester la connexion à la base de données",
he: "בדוק חיבור למסד נתונים",
ja: "データベース接続テスト",
@@ -5992,7 +6012,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"obsidianLiveSyncSettingTab.nameValidateDatabaseConfig": {
def: "Validate Database Configuration",
es: "Validar Configuración de la Base de Datos",
es: "Validar configuración de la base de datos",
fr: "Valider la configuration de la base de données",
he: "אמת תצורת מסד נתונים",
ja: "データベース設定を検証",
@@ -6300,7 +6320,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"obsidianLiveSyncSettingTab.panelGeneralSettings": {
def: "General Settings",
es: "Configuraciones Generales",
es: "Configuraciones generales",
fr: "Paramètres généraux",
he: "הגדרות כלליות",
ja: "一般設定",
@@ -6311,7 +6331,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"obsidianLiveSyncSettingTab.panelPrivacyEncryption": {
def: "Privacy & Encryption",
es: "Privacidad y Cifrado",
es: "Privacidad y cifrado",
fr: "Confidentialité et chiffrement",
he: "פרטיות והצפנה",
ja: "プライバシーと暗号化",
@@ -6640,7 +6660,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"obsidianLiveSyncSettingTab.titleSyncSettings": {
def: "Sync Settings",
es: "Configuraciones de Sincronización",
es: "Configuraciones de sincronización",
fr: "Paramètres de synchronisation",
he: "הגדרות סנכרון",
ja: "同期設定",
@@ -8384,6 +8404,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "将设置保存到一个 Markdown 文件中。当新设置到达时,您将收到通知。您可以根据平台设置不同的文件 ",
"zh-tw": "將設定儲存到 Markdown 檔案中。有新設定送達時會通知你,可依平台設定不同的檔案。",
},
"Save without connecting": {
def: "Save without connecting",
es: "Guardar sin conectar",
},
"Saving will be performed forcefully after this number of seconds.": {
def: "Saving will be performed forcefully after this number of seconds.",
es: "Guardado forzado tras esta cantidad de segundos",
@@ -8395,6 +8419,11 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "在此秒数后将强制执行保存 ",
"zh-tw": "經過這個秒數後,會強制執行儲存。",
},
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.":
{
def: "Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.",
es: "Guardar sin una prueba de conexión correcta conserva este perfil, pero la sincronización automática puede fallar hasta que se corrija la conexión.",
},
"Scan a QR Code (Recommended for mobile)": {
def: "Scan a QR Code (Recommended for mobile)",
es: "Escanear un código QR (recomendado para móviles)",
@@ -9414,6 +9443,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "仅显示通知",
"zh-tw": "僅顯示通知",
},
"Show password": {
def: "Show password",
es: "Mostrar contraseña",
},
"Show status as icons only": {
def: "Show status as icons only",
es: "Mostrar estado solo con íconos",
@@ -9773,6 +9806,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "目标模式",
"zh-tw": "目標模式",
},
"Test connection and save": {
def: "Test connection and save",
es: "Probar la conexión y guardar",
},
"Test Settings and Continue": {
def: "Test Settings and Continue",
es: "Probar los ajustes y continuar",
@@ -9791,6 +9828,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "仅供测试 - 通过同步文件的较新副本来解决文件冲突,这可能会覆盖修改过的文件。请注意 ",
"zh-tw": "僅供測試 —— 透過同步較新的檔案版本解決衝突,這可能會覆寫已修改的檔案,請注意。",
},
"The connection test cannot add a signalling relay while P2P is active. Use the active relay settings, or disconnect P2P before testing.":
{
def: "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,": {
def: "The connection to the server has been configured successfully. As the next step,",
es: "La conexión con el servidor se ha configurado correctamente. Como paso siguiente,",
@@ -9994,6 +10035,11 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
"zh-tw":
"此功能可在裝置之間直接同步,無需伺服器;但同步時兩台裝置必須同時在線,且部分功能可能受限。網際網路連線僅用於訊號交換(偵測對端),不用於資料傳輸。",
},
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.":
{
def: "This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.",
es: "Esta primera configuración consta de varios pasos breves, ya que confirma el cifrado, el método de conexión y qué dispositivo aporta los datos iniciales. Una vez completada, los demás dispositivos podrán reutilizar un Setup URI.",
},
"This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": {
def: "This is an advanced option for users who do not have a URI or who wish to configure detailed settings.",
es: "Esta es una opción avanzada para usuarios que no disponen de un URI o que desean configurar parámetros detallados。",
@@ -10020,6 +10066,11 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "这是最符合当前设计的同步方式,所有功能均可用。你需要事先部署好 CouchDB 实例。",
"zh-tw": "這是最符合目前設計的同步方式,所有功能皆可使用。你需要事先部署好 CouchDB 實例。",
},
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.":
{
def: "This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.",
es: "Esta comprobación opcional usa la API interna de solicitudes de Obsidian y envía las credenciales anteriores al servidor CouchDB. Utilízala solo con un servidor de confianza; puede requerir acceso de administrador.",
},
"This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": {
def: "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.",
es: "Esta frase no se copia a otros dispositivos. Usará `Default` hasta reconfigurar",
@@ -10366,6 +10417,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "Use Remote Configuration",
"zh-tw": "使用遠端設定",
},
"Ui.Common.LocalDatabaseInitialisationFailed": {
def: "Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.",
},
"Ui.Common.Signal.Caution": {
def: "CAUTION",
es: "PRECAUCIÓN",
@@ -10394,6 +10448,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "警告",
"zh-tw": "警告",
},
"Ui.Common.SomeFilesCouldNotBeSynchronised": {
def: "Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.",
},
"Ui.Settings.Advanced.LocalDatabaseTweak": {
def: "Local Database Tweak",
es: "Ajuste fino de la base de datos local",
@@ -12680,6 +12737,20 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
"zh-tw":
"只有在特殊情況下才應執行此操作,例如伺服器資料已完全損毀、其他所有裝置上的變更都已不再需要,或資料庫大小相對於 Vault 大小已變得異常龐大時。",
},
"you wanted(Thank you)!": {
def: "you wanted(Thank you)!",
es: "tu solicitud (¡gracias!)",
},
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.":
{
es: "(Seleccione esto si ya utiliza la sincronización en otro ordenador o teléfono). Esta opción es adecuada si desea añadir este dispositivo a una configuración de LiveSync existente。",
ja: "(別の PC やスマートフォンですでに同期を利用している場合に選択してください。)この端末を既存の LiveSync 構成に追加する場合に適しています。",
ko: "(다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.",
ru: "(Выберите этот вариант, если вы уже используете синхронизацию на другом компьютере или смартфоне.) Он подходит, если вы хотите добавить это устройство к уже существующей конфигурации LiveSync。",
zh: "(如果你已经在另一台电脑或手机上使用同步,请选择此项。)此选项适合将当前设备加入现有 LiveSync 配置的用户。",
"zh-tw":
"(如果你已經在另一台電腦或手機上使用同步,請選擇此項。)此選項適合將目前裝置加入既有 LiveSync 設定的使用者。",
},
"Compute revisions for chunks (Previous behaviour)": {
es: "Calcular revisiones para chunks (comportamiento anterior)",
},
+1 -1
View File
@@ -2,7 +2,7 @@
"(Active)": "(Aktiv)",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(RegExp) Leer lassen, um alle Dateien zu synchronisieren. Legen Sie einen Filter als regulären Ausdruck fest, um die zu synchronisierenden Dateien einzuschränken.",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(RegExp) Wenn dies gesetzt ist, werden alle Änderungen an lokalen und Remote-Dateien übersprungen, die diesem Muster entsprechen.",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Wählen Sie dies, wenn Sie die Synchronisation bereits auf einem anderen Computer oder Smartphone verwenden.) Diese Option ist geeignet, wenn Sie dieses Gerät zu einer bestehenden LiveSync-Einrichtung hinzufügen möchten.",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(Wählen Sie dies, wenn Sie die Synchronisation bereits auf einem anderen Computer oder Smartphone verwenden.) Diese Option ist geeignet, wenn Sie dieses Gerät zu einer bestehenden LiveSync-Einrichtung hinzufügen möchten.",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Wählen Sie dies, wenn Sie dieses Gerät als erstes Synchronisationsgerät einrichten.) Diese Option ist geeignet, wenn Sie LiveSync neu verwenden und von Grund auf einrichten möchten.",
"> [!INFO]- The connected devices have been detected as follows:\n${devices}": "> [!INFO]- Die folgenden verbundenen Geräte wurden erkannt:\n${devices}",
"A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": "Eine Setup-URI ist eine einzelne Zeichenfolge, die Ihre Serveradresse und Authentifizierungsdaten enthält. Wenn Ihre Serverinstallation eine URI erzeugt hat, bietet deren Verwendung eine einfache und sichere Konfiguration。",
+19 -2
View File
@@ -15,7 +15,7 @@
"(Obsolete) Use an old adapter for compatibility": "(Obsolete) Use an old adapter for compatibility",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(RegExp) If this is set, any changes to local and remote files that match this will be skipped.",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.",
"(Select this if you already have another synchronising device.) This option adds this device to the same synchronisation.": "(Select this if you already have another synchronising device.) This option adds this device to the same synchronisation.",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.",
"↑: Overwrite Remote": "↑: Overwrite Remote",
"↓: Overwrite Local": "↓: Overwrite Local",
@@ -90,6 +90,7 @@
"Check": "Check",
"Check and convert non-path-obfuscated files": "Check and convert non-path-obfuscated files",
"Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.": "Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.",
"Check server requirements": "Check server requirements",
"Checking connection... Please wait.": "Checking connection... Please wait.",
"Chunks": "Chunks",
"Close": "Close",
@@ -121,6 +122,7 @@
"Configure Remote": "Configure Remote",
"Configure the same server information as your other devices again, manually, very advanced users only.": "Configure the same server information as your other devices again, manually, very advanced users only.",
"Connect": "Connect",
"Connect to existing database and continue": "Connect to existing database and continue",
"Connected to Signaling Server (as Peer ID: ${peerId})": "Connected to Signaling Server (as Peer ID: ${peerId})",
"Connected:": "Connected:",
"Connection Method": "Connection Method",
@@ -134,6 +136,8 @@
"Copy Report to clipboard": "Copy Report to clipboard",
"CouchDB Configuration": "CouchDB Configuration",
"CouchDB Connection Tweak": "CouchDB Connection Tweak",
"CouchDB validates the database name when you connect. The name must not be empty.": "CouchDB validates the database name when you connect. The name must not be empty.",
"Create or connect to database and continue": "Create or connect to database and continue",
"Create P2P remote": "Create P2P remote",
"Cross-platform": "Cross-platform",
"Current adapter: {adapter}": "Current adapter: {adapter}",
@@ -236,6 +240,7 @@
"End-to-End Encryption": "End-to-End Encryption",
"Endpoint URL": "Endpoint URL",
"Enhance chunk size": "Enhance chunk size",
"Enter a complete HTTP or HTTPS URL.": "Enter a complete HTTP or HTTPS URL.",
"Enter a folder prefix (optional)": "Enter a folder prefix (optional)",
"Enter Server Information": "Enter Server Information",
"Enter Setup URI": "Enter Setup URI",
@@ -303,6 +308,7 @@
"Hidden Files": "Hidden Files",
"Hide completely": "Hide completely",
"Hide not applicable items": "Hide not applicable items",
"Hide password": "Hide password",
"Higher (${local} > ${remote})": "Higher (${local} > ${remote})",
"Highlight diff": "Highlight diff",
"How to display network errors when the sync server is unreachable.": "How to display network errors when the sync server is unreachable.",
@@ -477,6 +483,7 @@
"moduleLiveSyncMain.optionResumeAndRestart": "Resume and restart Obsidian",
"moduleLiveSyncMain.titleScramEnabled": "Scram Enabled",
"moduleLocalDatabase.logWaitingForReady": "Waiting for ready...",
"moduleLog.pathComponentTooLong": "A file or folder name exceeds ${maxBytes} UTF-8 bytes and may not work on some Android and Linux file systems: ${components}",
"moduleLog.showLog": "Show Log",
"moduleMigration.fix0256.buttons.checkItLater": "Check it later",
"moduleMigration.fix0256.buttons.DismissForever": "I have fixed it, and do not ask again",
@@ -910,7 +917,9 @@
"Same or local only": "Same or local only",
"Save and Apply": "Save and Apply",
"Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.": "Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.",
"Save without connecting": "Save without connecting",
"Saving will be performed forcefully after this number of seconds.": "Saving will be performed forcefully after this number of seconds.",
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.": "Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.",
"Scan a QR Code (Recommended for mobile)": "Scan a QR Code (Recommended for mobile)",
"Scan changes": "Scan changes",
"Scan changes on customization sync": "Scan changes on customization sync",
@@ -1019,6 +1028,7 @@
"Show history": "Show history",
"Show icon only": "Show icon only",
"Show only notifications": "Show only notifications",
"Show password": "Show password",
"Show status as icons only": "Show status as icons only",
"Show status icon instead of file warnings banner": "Show status icon instead of file warnings banner",
"Show status inside the editor": "Show status inside the editor",
@@ -1060,8 +1070,10 @@
"Syncing": "Syncing",
"Syncing...": "Syncing...",
"Target patterns": "Target patterns",
"Test connection and save": "Test connection and save",
"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.",
@@ -1088,9 +1100,11 @@
"This device": "This device",
"This device name": "This device name",
"This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": "This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.",
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.": "This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.",
"This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": "This is an advanced option for users who do not have a URI or who wish to configure detailed settings.",
"This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.": "This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.",
"This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.": "This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.",
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.": "This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.",
"This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.",
"This password is used to encrypt the connection. Use something long enough.": "This password is used to encrypt the connection. Use something long enough.",
"This procedure will first delete all existing synchronisation data from the server. Following this, the server data will be completely rebuilt, using the current state of your Vault on this device (including its local database) as": "This procedure will first delete all existing synchronisation data from the server. Following this, the server data will be completely rebuilt, using the current state of your Vault on this device (including its local database) as",
@@ -1129,10 +1143,12 @@
"TweakMismatchResolve.Title.AutoAcceptCompatible": "Auto-Accept Available",
"TweakMismatchResolve.Title.TweakResolving": "Configuration Mismatch Detected",
"TweakMismatchResolve.Title.UseRemoteConfig": "Use Remote Configuration",
"Ui.Common.LocalDatabaseInitialisationFailed": "Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.",
"Ui.Common.Signal.Caution": "CAUTION",
"Ui.Common.Signal.Danger": "DANGER",
"Ui.Common.Signal.Notice": "NOTICE",
"Ui.Common.Signal.Warning": "WARNING",
"Ui.Common.SomeFilesCouldNotBeSynchronised": "Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.",
"Ui.Settings.Advanced.LocalDatabaseTweak": "Local Database Tweak",
"Ui.Settings.Advanced.MemoryCache": "Memory Cache",
"Ui.Settings.Advanced.TransferTweak": "Transfer Tweak",
@@ -1461,5 +1477,6 @@
"You are adding this device to an existing synchronisation setup.": "You are adding this device to an existing synchronisation setup.",
"You can configure in the Obsidian Plugin Settings.": "You can configure in the Obsidian Plugin Settings.",
"You should create a new synchronisation destination and rebuild your data there.": "You should create a new synchronisation destination and rebuild your data there.",
"You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.": "You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size."
"You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.": "You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.",
"you wanted(Thank you)!": "you wanted(Thank you)!"
}
+22 -9
View File
@@ -15,7 +15,7 @@
"(Obsolete) Use an old adapter for compatibility": "(Obsoleto) Usar adaptador antiguo",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(RegExp) Déjelo vacío para sincronizar todos los archivos. Defina un filtro como expresión regular para limitar los archivos que se sincronizan.",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(RegExp) Si se establece, se omitirá cualquier cambio en archivos locales y remotos que coincida con este patrón.",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Seleccione esto si ya utiliza la sincronización en otro ordenador o teléfono). Esta opción es adecuada si desea añadir este dispositivo a una configuración de LiveSync existente。",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(Seleccione esto si ya utiliza la sincronización en otro ordenador o teléfono). Esta opción es adecuada si desea añadir este dispositivo a una configuración de LiveSync existente。",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Seleccione esto si está configurando este dispositivo como el primer dispositivo de sincronización). Esta opción es adecuada si es nuevo en LiveSync y desea configurarlo desde cero。",
"↑: Overwrite Remote": "↑: Sobrescribir remoto",
"↓: Overwrite Local": "↓: Sobrescribir local",
@@ -90,6 +90,7 @@
"Check": "Comprobar",
"Check and convert non-path-obfuscated files": "Comprobar y convertir archivos sin ofuscación de ruta",
"Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.": "Comprueba los documentos que aún no se hayan convertido a identificadores con ruta ofuscada y conviértelos si es necesario.",
"Check server requirements": "Comprobar los requisitos del servidor",
"Checking connection... Please wait.": "Comprobando la conexión... Espera un momento.",
"Chunks": "Fragmentos (chunks)",
"Close": "Cerrar",
@@ -122,6 +123,7 @@
"Configure Remote": "Configurar remoto",
"Configure the same server information as your other devices again, manually, very advanced users only.": "Configure manualmente la misma información del servidor que en sus otros dispositivos. Solo para usuarios muy avanzados。",
"Connect": "Conectar",
"Connect to existing database and continue": "Conectar a la base de datos existente y continuar",
"Connected to Signaling Server (as Peer ID: ${peerId})": "Conectado al servidor de señalización (como ID de par: ${peerId})",
"Connected:": "Conectadas:",
"Connection Method": "Método de conexión",
@@ -135,6 +137,8 @@
"Copy Report to clipboard": "Copiar el informe al portapapeles",
"CouchDB Configuration": "Configuración de CouchDB",
"CouchDB Connection Tweak": "Ajustes de conexión de CouchDB",
"CouchDB validates the database name when you connect. The name must not be empty.": "CouchDB valida el nombre de la base de datos al conectar. El nombre no puede estar vacío.",
"Create or connect to database and continue": "Crear o conectar a la base de datos y continuar",
"Create P2P remote": "Crear remoto P2P",
"Cross-platform": "Multiplataforma",
"Current adapter: {adapter}": "Adaptador actual: {adapter}",
@@ -177,7 +181,7 @@
"Device Setup Method": "Método de configuración del dispositivo",
"Devices:": "Dispositivos:",
"Diagnostic RTCPeerConnection is enabled": "El RTCPeerConnection de diagnóstico está habilitado",
"dialog.yourLanguageAvailable": "Self-hosted LiveSync tenía traducciones para tu idioma, así que se ha activado el ajuste %{Display language}.\n\nNota: no todos los mensajes están traducidos. ¡Esperamos tus contribuciones!\nNota 2: si abres una incidencia, **vuelve antes a %{lang-def}** y luego haz las capturas de pantalla y recoge los mensajes y registros. Puedes hacerlo desde el diálogo de ajustes.\n¡Que lo disfrutes!",
"dialog.yourLanguageAvailable": "Self-hosted LiveSync tenía traducciones para tu idioma, así que se ha activado el ajuste %{Display Language}.\n\nNota: no todos los mensajes están traducidos. ¡Esperamos tus contribuciones!\nNota 2: si abres una incidencia, **vuelve antes a %{lang-def}** y luego haz las capturas de pantalla y recoge los mensajes y registros. Puedes hacerlo desde el diálogo de ajustes.\n¡Que lo disfrutes!",
"dialog.yourLanguageAvailable.btnRevertToDefault": "Mantener %{lang-def}",
"dialog.yourLanguageAvailable.Title": " ¡Hay traducción disponible!",
"Diff": "Diferencias",
@@ -237,6 +241,7 @@
"End-to-End Encryption": "Cifrado de extremo a extremo",
"Endpoint URL": "URL del endpoint",
"Enhance chunk size": "Mejorar tamaño de chunks",
"Enter a complete HTTP or HTTPS URL.": "Introduce una URL HTTP o HTTPS completa.",
"Enter a folder prefix (optional)": "Introduce un prefijo de carpeta (opcional)",
"Enter Server Information": "Introducir información del servidor",
"Enter Setup URI": "Introducir el Setup URI",
@@ -304,6 +309,7 @@
"Hidden Files": "Archivos ocultos",
"Hide completely": "Ocultar por completo",
"Hide not applicable items": "Ocultar elementos no aplicables",
"Hide password": "Ocultar contraseña",
"Higher (${local} > ${remote})": "Superior (${local} > ${remote})",
"Highlight diff": "Resaltar las diferencias",
"How to display network errors when the sync server is unreachable.": "Cómo mostrar los errores de red cuando el servidor de sincronización no está disponible.",
@@ -593,7 +599,7 @@
"obsidianLiveSyncSettingTab.logCheckingDbConfig": "Verificando la configuración de la base de datos",
"obsidianLiveSyncSettingTab.logCheckPassphraseFailed": "ERROR: Error al comprobar la frase de contraseña con el servidor remoto:\n${db}.",
"obsidianLiveSyncSettingTab.logConfiguredDisabled": "Modo de sincronización configurado: DESACTIVADO",
"obsidianLiveSyncSettingTab.logConfiguredLiveSync": "Modo de sincronización configurado: Sincronización en Vivo",
"obsidianLiveSyncSettingTab.logConfiguredLiveSync": "Modo de sincronización configurado: Sincronización en vivo",
"obsidianLiveSyncSettingTab.logConfiguredPeriodic": "Modo de sincronización configurado: Periódico",
"obsidianLiveSyncSettingTab.logCouchDbConfigFail": "Configuración de CouchDB: ${title} falló",
"obsidianLiveSyncSettingTab.logCouchDbConfigSet": "Configuración de CouchDB: ${title} -> Establecer ${key} en ${value}",
@@ -648,8 +654,8 @@
"obsidianLiveSyncSettingTab.nameHiddenFileSynchronization": "Sincronización de archivos ocultos",
"obsidianLiveSyncSettingTab.nameManualSetup": "Configuración manual",
"obsidianLiveSyncSettingTab.nameTestConnection": "Probar conexión",
"obsidianLiveSyncSettingTab.nameTestDatabaseConnection": "Probar Conexión de Base de Datos",
"obsidianLiveSyncSettingTab.nameValidateDatabaseConfig": "Validar Configuración de la Base de Datos",
"obsidianLiveSyncSettingTab.nameTestDatabaseConnection": "Probar conexión de base de datos",
"obsidianLiveSyncSettingTab.nameValidateDatabaseConfig": "Validar configuración de la base de datos",
"obsidianLiveSyncSettingTab.okAdminPrivileges": "✔ Tienes privilegios de administrador.",
"obsidianLiveSyncSettingTab.okCorsCredentials": "✔ cors.credentials está correcto.",
"obsidianLiveSyncSettingTab.okCorsCredentialsForOrigin": "CORS credenciales OK",
@@ -677,8 +683,8 @@
"obsidianLiveSyncSettingTab.optionRebuildBoth": "Reconstructuir ambos desde este dispositivo",
"obsidianLiveSyncSettingTab.optionSaveOnlySettings": "(Peligro) Guardar solo configuración",
"obsidianLiveSyncSettingTab.panelChangeLog": "Registro de cambios",
"obsidianLiveSyncSettingTab.panelGeneralSettings": "Configuraciones Generales",
"obsidianLiveSyncSettingTab.panelPrivacyEncryption": "Privacidad y Cifrado",
"obsidianLiveSyncSettingTab.panelGeneralSettings": "Configuraciones generales",
"obsidianLiveSyncSettingTab.panelPrivacyEncryption": "Privacidad y cifrado",
"obsidianLiveSyncSettingTab.panelRemoteConfiguration": "Configuración remota",
"obsidianLiveSyncSettingTab.panelSetup": "Configuración",
"obsidianLiveSyncSettingTab.serverVersion": "Información del servidor: ${info}",
@@ -706,7 +712,7 @@
"obsidianLiveSyncSettingTab.titleSetupOtherDevices": "Para configurar otros dispositivos",
"obsidianLiveSyncSettingTab.titleSynchronizationMethod": "Método de sincronización",
"obsidianLiveSyncSettingTab.titleSynchronizationPreset": "Preestablecimiento de sincronización",
"obsidianLiveSyncSettingTab.titleSyncSettings": "Configuraciones de Sincronización",
"obsidianLiveSyncSettingTab.titleSyncSettings": "Configuraciones de sincronización",
"obsidianLiveSyncSettingTab.titleSyncSettingsViaMarkdown": "Configuración de sincronización a través de Markdown",
"obsidianLiveSyncSettingTab.titleUpdateThinning": "Actualización de adelgazamiento",
"obsidianLiveSyncSettingTab.warnCorsOriginUnmatched": "⚠ El origen de CORS no coincide: {from}->{to}",
@@ -903,7 +909,9 @@
"Same or local only": "Igual o solo local",
"Save and Apply": "Guardar y aplicar",
"Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.": "Guardar configuración en archivo markdown. Se notificarán nuevos ajustes. Puede definir diferentes archivos por plataforma",
"Save without connecting": "Guardar sin conectar",
"Saving will be performed forcefully after this number of seconds.": "Guardado forzado tras esta cantidad de segundos",
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.": "Guardar sin una prueba de conexión correcta conserva este perfil, pero la sincronización automática puede fallar hasta que se corrija la conexión.",
"Scan a QR Code (Recommended for mobile)": "Escanear un código QR (recomendado para móviles)",
"Scan changes": "Buscar cambios",
"Scan changes on customization sync": "Escanear cambios en sincronización de personalización",
@@ -1053,6 +1061,7 @@
"Show history": "Mostrar el historial",
"Show icon only": "Mostrar solo el icono",
"Show only notifications": "Mostrar solo notificaciones",
"Show password": "Mostrar contraseña",
"Show status as icons only": "Mostrar estado solo con íconos",
"Show status icon instead of file warnings banner": "Mostrar icono de estado en lugar del banner de advertencia de archivos",
"Show status inside the editor": "Mostrar estado dentro del editor",
@@ -1094,6 +1103,7 @@
"Syncing": "Sincronización",
"Syncing...": "Sincronizando...",
"Target patterns": "Patrones objetivo",
"Test connection and save": "Probar la conexión y guardar",
"Test Settings and Continue": "Probar los ajustes y continuar",
"Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.": "Solo pruebas - Resolver conflictos sincronizando copias nuevas (puede sobrescribir modificaciones)",
"The connection to the server has been configured successfully. As the next step,": "La conexión con el servidor se ha configurado correctamente. Como paso siguiente,",
@@ -1122,9 +1132,11 @@
"This device": "Este dispositivo",
"This device name": "Nombre de este dispositivo",
"This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": "Esta función permite la sincronización directa entre dispositivos. No requiere servidor, pero ambos dispositivos deben estar en línea al mismo tiempo para que la sincronización se produzca, y algunas funciones pueden ser limitadas. La conexión a Internet solo se necesita para la señalización (detección de pares), no para la transferencia de datos。",
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.": "Esta primera configuración consta de varios pasos breves, ya que confirma el cifrado, el método de conexión y qué dispositivo aporta los datos iniciales. Una vez completada, los demás dispositivos podrán reutilizar un Setup URI.",
"This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": "Esta es una opción avanzada para usuarios que no disponen de un URI o que desean configurar parámetros detallados。",
"This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.": "Esta es una operación extremadamente potente. Te recomendamos encarecidamente copiar la carpeta de tu Vault a un lugar seguro.",
"This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.": "Este es el método de sincronización más adecuado para el diseño. Todas las funciones están disponibles. Debe tener configurada una instancia de CouchDB。",
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.": "Esta comprobación opcional usa la API interna de solicitudes de Obsidian y envía las credenciales anteriores al servidor CouchDB. Utilízala solo con un servidor de confianza; puede requerir acceso de administrador.",
"This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": "Esta frase no se copia a otros dispositivos. Usará `Default` hasta reconfigurar",
"This password is used to encrypt the connection. Use something long enough.": "Esta contraseña se usa para cifrar la conexión. Usa algo suficientemente largo.",
"This procedure will first delete all existing synchronisation data from the server. Following this, the server data will be completely rebuilt, using the current state of your Vault on this device (including its local database) as": "Este procedimiento eliminará primero todos los datos de sincronización existentes en el servidor. A continuación, los datos del servidor se reconstruirán por completo usando el estado actual del Vault de este dispositivo (incluida su base de datos local) como",
@@ -1473,5 +1485,6 @@
"You are adding this device to an existing synchronisation setup.": "Está añadiendo este dispositivo a una configuración de sincronización existente。",
"You can configure in the Obsidian Plugin Settings.": "Puedes configurarlo en los ajustes del complemento de Obsidian.",
"You should create a new synchronisation destination and rebuild your data there.": "Deberías crear un nuevo destino de sincronización y reconstruir allí tus datos.",
"You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.": "Solo deberías realizar esta operación en circunstancias excepcionales: cuando los datos del servidor estén completamente corruptos, cuando ya no necesites los cambios de los demás dispositivos o cuando el tamaño de la base de datos sea inusualmente grande respecto al del Vault."
"You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.": "Solo deberías realizar esta operación en circunstancias excepcionales: cuando los datos del servidor estén completamente corruptos, cuando ya no necesites los cambios de los demás dispositivos o cuando el tamaño de la base de datos sea inusualmente grande respecto al del Vault.",
"you wanted(Thank you)!": "tu solicitud (¡gracias!)"
}
+1 -1
View File
@@ -10,7 +10,7 @@
"(Obsolete) Use an old adapter for compatibility": "(廃止済み)古いアダプターを互換性のために利用",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(正規表現)空欄で全ファイルを同期します。正規表現を指定すると、同期対象のファイルを絞り込めます。",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(正規表現)設定すると、これに一致するローカル/リモートファイルの変更はすべてスキップされます。",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(別の PC やスマートフォンですでに同期を利用している場合に選択してください。)この端末を既存の LiveSync 構成に追加する場合に適しています。",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(別の PC やスマートフォンですでに同期を利用している場合に選択してください。)この端末を既存の LiveSync 構成に追加する場合に適しています。",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(この端末を最初の同期端末として設定する場合に選択してください。)LiveSync を初めて利用し、最初から設定したい場合に適しています。",
"> [!INFO]- The connected devices have been detected as follows:\n${devices}": "> [!INFO]- 次の接続済みデバイスが検出されました:\n${devices}",
"A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": "Setup URI は、サーバーアドレスと認証情報を含む 1 本の文字列です。サーバーのインストールスクリプトで生成された URI がある場合は、それを使うと簡単かつ安全に設定できます。",
+1 -1
View File
@@ -15,7 +15,7 @@
"(Obsolete) Use an old adapter for compatibility": "(사용 중단) 호환성을 위해 이전 어댑터 사용",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(정규식) 비워 두면 모든 파일을 동기화합니다. 정규식을 지정하면 동기화할 파일을 제한할 수 있습니다.",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(정규식) 설정하면 이 패턴과 일치하는 로컬 및 원격 파일 변경은 모두 건너뜁니다.",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(이 기기를 첫 번째 동기화 기기로 설정한다면 선택하세요.) LiveSync를 처음 사용하며 처음부터 설정하려는 경우에 적합합니다.",
"↑: Overwrite Remote": "↑: 원격 덮어쓰기",
"↓: Overwrite Local": "↓: 로컬 덮어쓰기",
+1 -1
View File
@@ -11,7 +11,7 @@
"(Obsolete) Use an old adapter for compatibility": "(Устарело) Использовать старый адаптер для совместимости",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(RegExp) Оставьте пустым, чтобы синхронизировать все файлы. Укажите регулярное выражение, чтобы ограничить синхронизируемые файлы.",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(RegExp) Если задано, любые изменения локальных и удалённых файлов, соответствующих этому шаблону, будут пропускаться.",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Выберите этот вариант, если вы уже используете синхронизацию на другом компьютере или смартфоне.) Он подходит, если вы хотите добавить это устройство к уже существующей конфигурации LiveSync。",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(Выберите этот вариант, если вы уже используете синхронизацию на другом компьютере или смартфоне.) Он подходит, если вы хотите добавить это устройство к уже существующей конфигурации LiveSync。",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Выберите этот вариант, если настраиваете это устройство как первое устройство синхронизации.) Он подходит, если вы впервые используете LiveSync и хотите настроить всё с нуля。",
"> [!INFO]- The connected devices have been detected as follows:\n${devices}": "> [!INFO]- Обнаружены следующие подключённые устройства:\n${devices}",
"A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": "Setup URI — это одна строка текста, содержащая адрес сервера и данные аутентификации. Если URI был создан скриптом установки сервера, его использование обеспечивает простую и безопасную настройку。",
+1 -1
View File
@@ -15,7 +15,7 @@
"(Obsolete) Use an old adapter for compatibility": "(已淘汰)使用舊版轉接器以維持相容性",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(正則表示式)留空即同步所有檔案。設定正則表示式可限制要同步的檔案。",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(正則表示式)若已設定,所有符合此模式的本機與遠端檔案變更都會被略過。",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你已經在另一台電腦或手機上使用同步,請選擇此項。)此選項適合將目前裝置加入既有 LiveSync 設定的使用者。",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(如果你已經在另一台電腦或手機上使用同步,請選擇此項。)此選項適合將目前裝置加入既有 LiveSync 設定的使用者。",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你正在將此裝置設定為第一台同步裝置,請選擇此項。)此選項適合初次使用 LiveSync,並希望從頭開始設定的使用者。",
"↑: Overwrite Remote": "↑:覆寫遠端",
"↓: Overwrite Local": "↓:覆寫本機",
+1 -1
View File
@@ -10,7 +10,7 @@
"(Obsolete) Use an old adapter for compatibility": "(已弃用)为兼容性使用旧适配器",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(正则表达式)留空表示同步所有文件。可设置正则表达式来限制需要同步的文件。",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(正则表达式)如果已设置,则所有匹配此模式的本地和远端文件变更都会被跳过。",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你已经在另一台电脑或手机上使用同步,请选择此项。)此选项适合将当前设备加入现有 LiveSync 配置的用户。",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(如果你已经在另一台电脑或手机上使用同步,请选择此项。)此选项适合将当前设备加入现有 LiveSync 配置的用户。",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你正在将此设备配置为第一台同步设备,请选择此项。)此选项适合初次使用 LiveSync,并希望从头开始配置的用户。",
"> [!INFO]- The connected devices have been detected as follows:\n${devices}": "> [!INFO]- 已检测到以下已连接设备:\n${devices}",
"A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": "Setup URI 是一段包含服务器地址与认证信息的文本。如果服务器安装脚本已经生成了 URI,使用它可以更简单且更安全地完成配置。",
+1 -1
View File
@@ -283,7 +283,7 @@ xxhash64 (Fastest): xxhash64 (am schnellsten)
"I am setting this up for the first time": "Ich richte dies zum ersten Mal ein"
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Wählen Sie dies, wenn Sie dieses Gerät als erstes Synchronisationsgerät einrichten.) Diese Option ist geeignet, wenn Sie LiveSync neu verwenden und von Grund auf einrichten möchten."
"I am adding a device to an existing synchronisation setup": "Ich füge ein Gerät zu einer bestehenden Synchronisationseinrichtung hinzu"
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Wählen Sie dies, wenn Sie die Synchronisation bereits auf einem anderen Computer oder Smartphone verwenden.) Diese Option ist geeignet, wenn Sie dieses Gerät zu einer bestehenden LiveSync-Einrichtung hinzufügen möchten."
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(Wählen Sie dies, wenn Sie die Synchronisation bereits auf einem anderen Computer oder Smartphone verwenden.) Diese Option ist geeignet, wenn Sie dieses Gerät zu einer bestehenden LiveSync-Einrichtung hinzufügen möchten."
"Yes, I want to set up a new synchronisation": "Ja, ich möchte eine neue Synchronisation einrichten"
"Yes, I want to add this device to my existing synchronisation": "Ja, ich möchte dieses Gerät zu meiner bestehenden Synchronisation hinzufügen"
"No, please take me back": "Nein, bitte zurück"
+30 -1
View File
@@ -129,6 +129,7 @@ Check and convert non-path-obfuscated files: Check and convert non-path-obfuscat
Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.:
Check for documents that have not been converted to path-obfuscated IDs and
convert them if necessary.
Check server requirements: Check server requirements
Checking connection... Please wait.: Checking connection... Please wait.
Chunks: Chunks
Close: Close
@@ -158,6 +159,7 @@ Configure And Change Remote: Configure And Change Remote
Configure E2EE: Configure E2EE
Configure Remote: Configure Remote
Connect: Connect
Connect to existing database and continue: Connect to existing database and continue
"Connected to Signaling Server (as Peer ID: ${peerId})": "Connected to Signaling Server (as Peer ID: ${peerId})"
"Connected:": "Connected:"
Connection Settings: Connection Settings
@@ -167,7 +169,11 @@ Copy: Copy
Copy Report to clipboard: Copy Report to clipboard
CouchDB Configuration: CouchDB Configuration
CouchDB Connection Tweak: CouchDB Connection Tweak
CouchDB validates the database name when you connect. The name must not be empty.:
CouchDB validates the database name when you connect. The name must not be
empty.
Create P2P remote: Create P2P remote
Create or connect to database and continue: Create or connect to database and continue
Cross-platform: Cross-platform
"Current adapter: {adapter}": "Current adapter: {adapter}"
Custom Headers: Custom Headers
@@ -340,6 +346,7 @@ Encryption phassphrase. If changed, you should overwrite the server's database w
End-to-End Encryption: End-to-End Encryption
Endpoint URL: Endpoint URL
Enhance chunk size: Enhance chunk size
Enter a complete HTTP or HTTPS URL.: Enter a complete HTTP or HTTPS URL.
Enter a folder prefix (optional): Enter a folder prefix (optional)
Enter Setup URI: Enter Setup URI
Enter TURN credential: Enter TURN credential
@@ -362,6 +369,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:"
@@ -401,6 +409,7 @@ Hidden file synchronization have been temporarily disabled. Please enable them a
Hidden Files: Hidden Files
Hide completely: Hide completely
Hide not applicable items: Hide not applicable items
Hide password: Hide password
Higher (${local} > ${remote}): Higher (${local} > ${remote})
Highlight diff: Highlight diff
How to display network errors when the sync server is unreachable.: How to display network errors when the sync server is unreachable.
@@ -723,6 +732,9 @@ moduleLiveSyncMain:
moduleLocalDatabase:
logWaitingForReady: Waiting for ready...
moduleLog:
pathComponentTooLong: >-
A file or folder name exceeds ${maxBytes} UTF-8 bytes and may not work on
some Android and Linux file systems: ${components}
showLog: Show Log
moduleMigration:
fix0256:
@@ -1487,7 +1499,11 @@ Save and Apply: Save and Apply
Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.:
Save settings to a markdown file. You will be notified when new settings
arrive. You can set different files by the platform.
Save without connecting: Save without connecting
Saving will be performed forcefully after this number of seconds.: Saving will be performed forcefully after this number of seconds.
Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.:
Saving without a successful connection test keeps this profile, but
automatic synchronisation may fail until the connection is corrected.
Scan changes: Scan changes
Scan changes on customization sync: Scan changes on customization sync
Scan customization automatically: Scan customization automatically
@@ -1749,6 +1765,7 @@ Sync: Sync
Sync once: Sync once
Syncing...: Syncing...
Test Settings and Continue: Test Settings and Continue
Test connection and save: Test connection and save
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,
@@ -1799,6 +1816,7 @@ Show full banner: Show full banner
Show history: Show history
Show icon only: Show icon only
Show only notifications: Show only notifications
Show password: Show password
Show status as icons only: Show status as icons only
Show status icon instead of file warnings banner: Show status icon instead of file warnings banner
Show status inside the editor: Show status inside the editor
@@ -1875,9 +1893,17 @@ This can isolate your connections between devices. Use the same Room ID for the
the same devices.
This device: This device
This device name: This device name
This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.:
This first setup has several short steps because it confirms encryption,
the connection method, and which device provides the initial data. Once it
is complete, additional devices can reuse a Setup URI.
This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.:
This is an extremely powerful operation. We strongly recommend that you copy
your Vault folder to a safe location.
This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.:
This optional check uses Obsidian's internal request API and sends the
credentials above to the CouchDB server. Use it only with a server you
trust; administrator access may be required.
This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.:
This passphrase will not be copied to another device. It will be set to
`Default` until you configure it again.
@@ -2070,7 +2096,7 @@ xxhash64 (Fastest): xxhash64 (Fastest)
"I am setting this up for the first time": "I am setting this up for the first time"
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch."
"I am adding a device to an existing synchronisation setup": "I am adding a device to an existing synchronisation setup"
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch."
"(Select this if you already have another synchronising device.) This option adds this device to the same synchronisation.": "(Select this if you already have another synchronising device.) This option adds this device to the same synchronisation."
"Yes, I want to set up a new synchronisation": "Yes, I want to set up a new synchronisation"
"Yes, I want to add this device to my existing synchronisation": "Yes, I want to add this device to my existing synchronisation"
"No, please take me back": "No, please take me back"
@@ -2103,6 +2129,8 @@ xxhash64 (Fastest): xxhash64 (Fastest)
"This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": "This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer."
Ui:
Common:
LocalDatabaseInitialisationFailed: Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.
SomeFilesCouldNotBeSynchronised: Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.
Signal:
Caution: CAUTION
Danger: DANGER
@@ -2418,6 +2446,7 @@ Ui:
Title: Choose a synchronisation remote
You can configure in the Obsidian Plugin Settings.: You can configure in the Obsidian Plugin Settings.
"you wanted(Thank you)!": "you wanted(Thank you)!"
You should create a new synchronisation destination and rebuild your data there.:
You should create a new synchronisation destination and rebuild your data
+31 -8
View File
@@ -132,6 +132,7 @@ Check and convert non-path-obfuscated files: Comprobar y convertir archivos sin
Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.:
Comprueba los documentos que aún no se hayan convertido a identificadores con
ruta ofuscada y conviértelos si es necesario.
Check server requirements: Comprobar los requisitos del servidor
Checking connection... Please wait.: Comprobando la conexión... Espera un momento.
Chunks: Fragmentos (chunks)
Close: Cerrar
@@ -166,6 +167,7 @@ Configure And Change Remote: Configurar y cambiar remoto
Configure E2EE: Configurar E2EE
Configure Remote: Configurar remoto
Connect: Conectar
Connect to existing database and continue: Conectar a la base de datos existente y continuar
"Connected to Signaling Server (as Peer ID: ${peerId})": "Conectado al servidor de señalización (como ID de par: ${peerId})"
"Connected:": "Conectadas:"
Connection Settings: Ajustes de conexión
@@ -176,6 +178,10 @@ Copy Report to clipboard: Copiar el informe al portapapeles
CouchDB Configuration: Configuración de CouchDB
CouchDB Connection Tweak: Ajustes de conexión de CouchDB
Create P2P remote: Crear remoto P2P
CouchDB validates the database name when you connect. The name must not be empty.:
CouchDB valida el nombre de la base de datos al conectar. El nombre no puede
estar vacío.
Create or connect to database and continue: Crear o conectar a la base de datos y continuar
Cross-platform: Multiplataforma
"Current adapter: {adapter}": "Adaptador actual: {adapter}"
Custom Headers: Encabezados personalizados
@@ -224,7 +230,7 @@ dialog:
yourLanguageAvailable:
_value: >-
Self-hosted LiveSync tenía traducciones para tu idioma, así que se ha
activado el ajuste %{Display language}.
activado el ajuste %{Display Language}.
Nota: no todos los mensajes están traducidos. ¡Esperamos tus
@@ -345,6 +351,7 @@ Encryption phassphrase. If changed, you should overwrite the server's database w
End-to-End Encryption: Cifrado de extremo a extremo
Endpoint URL: URL del endpoint
Enhance chunk size: Mejorar tamaño de chunks
Enter a complete HTTP or HTTPS URL.: Introduce una URL HTTP o HTTPS completa.
Enter a folder prefix (optional): Introduce un prefijo de carpeta (opcional)
Enter Setup URI: Introducir el Setup URI
Enter TURN credential: Introduce la credencial de TURN
@@ -425,6 +432,7 @@ Hidden file synchronization have been temporarily disabled. Please enable them a
Hidden Files: Archivos ocultos
Hide completely: Ocultar por completo
Hide not applicable items: Ocultar elementos no aplicables
Hide password: Ocultar contraseña
Higher (${local} > ${remote}): Superior (${local} > ${remote})
Highlight diff: Resaltar las diferencias
How to display network errors when the sync server is unreachable.:
@@ -1518,7 +1526,7 @@ obsidianLiveSyncSettingTab:
ERROR: Error al comprobar la frase de contraseña con el servidor remoto:
${db}.
logConfiguredDisabled: "Modo de sincronización configurado: DESACTIVADO"
logConfiguredLiveSync: "Modo de sincronización configurado: Sincronización en Vivo"
logConfiguredLiveSync: "Modo de sincronización configurado: Sincronización en vivo"
logConfiguredPeriodic: "Modo de sincronización configurado: Periódico"
logCouchDbConfigFail: "Configuración de CouchDB: ${title} falló"
logCouchDbConfigSet: "Configuración de CouchDB: ${title} -> Establecer ${key} en ${value}"
@@ -1656,8 +1664,8 @@ obsidianLiveSyncSettingTab:
nameHiddenFileSynchronization: Sincronización de archivos ocultos
nameManualSetup: Configuración manual
nameTestConnection: Probar conexión
nameTestDatabaseConnection: Probar Conexión de Base de Datos
nameValidateDatabaseConfig: Validar Configuración de la Base de Datos
nameTestDatabaseConnection: Probar conexión de base de datos
nameValidateDatabaseConfig: Validar configuración de la base de datos
okAdminPrivileges: ✔ Tienes privilegios de administrador.
okCorsCredentials: ✔ cors.credentials está correcto.
okCorsCredentialsForOrigin: CORS credenciales OK
@@ -1684,8 +1692,8 @@ obsidianLiveSyncSettingTab:
optionRebuildBoth: Reconstructuir ambos desde este dispositivo
optionSaveOnlySettings: (Peligro) Guardar solo configuración
panelChangeLog: Registro de cambios
panelGeneralSettings: Configuraciones Generales
panelPrivacyEncryption: Privacidad y Cifrado
panelGeneralSettings: Configuraciones generales
panelPrivacyEncryption: Privacidad y cifrado
panelRemoteConfiguration: Configuración remota
panelSetup: Configuración
titleAppearance: Apariencia
@@ -1711,7 +1719,7 @@ obsidianLiveSyncSettingTab:
titleSetupOtherDevices: Para configurar otros dispositivos
titleSynchronizationMethod: Método de sincronización
titleSynchronizationPreset: Preestablecimiento de sincronización
titleSyncSettings: Configuraciones de Sincronización
titleSyncSettings: Configuraciones de sincronización
titleSyncSettingsViaMarkdown: Configuración de sincronización a través de Markdown
titleUpdateThinning: Actualización de adelgazamiento
warnCorsOriginUnmatched: "⚠ El origen de CORS no coincide: {from}->{to}"
@@ -1804,7 +1812,11 @@ Restore or reconstruct local database from remote.: Restaura o reconstruye la ba
Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.:
Guardar configuración en archivo markdown. Se notificarán nuevos ajustes.
Puede definir diferentes archivos por plataforma
Save without connecting: Guardar sin conectar
Saving will be performed forcefully after this number of seconds.: Guardado forzado tras esta cantidad de segundos
Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.:
Guardar sin una prueba de conexión correcta conserva este perfil, pero la
sincronización automática puede fallar hasta que se corrija la conexión.
Scan changes on customization sync: Escanear cambios en sincronización de personalización
Scan customization automatically: Escanear personalización automáticamente
Scan customization before replicating.: Escanear personalización antes de replicar
@@ -1910,6 +1922,7 @@ Show full banner: Mostrar banner completo
Show history: Mostrar el historial
Show icon only: Mostrar solo el icono
Show only notifications: Mostrar solo notificaciones
Show password: Mostrar contraseña
Show status as icons only: Mostrar estado solo con íconos
Show status icon instead of file warnings banner: Mostrar icono de estado en lugar del banner de advertencia de archivos
Show status inside the editor: Mostrar estado dentro del editor
@@ -1960,6 +1973,7 @@ Syncing:
"": Sincronizando...
Target patterns: Patrones objetivo
Test Settings and Continue: Probar los ajustes y continuar
Test connection and save: Probar la conexión y guardar
Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.:
Solo pruebas - Resolver conflictos sincronizando copias nuevas (puede
sobrescribir modificaciones)
@@ -2022,9 +2036,17 @@ This can isolate your connections between devices. Use the same Room ID for the
para los mismos dispositivos.
This device: Este dispositivo
This device name: Nombre de este dispositivo
This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.:
Esta primera configuración consta de varios pasos breves, ya que confirma el
cifrado, el método de conexión y qué dispositivo aporta los datos iniciales.
Una vez completada, los demás dispositivos podrán reutilizar un Setup URI.
This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.:
Esta es una operación extremadamente potente. Te recomendamos encarecidamente
copiar la carpeta de tu Vault a un lugar seguro.
This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.:
Esta comprobación opcional usa la API interna de solicitudes de Obsidian y
envía las credenciales anteriores al servidor CouchDB. Utilízala solo con un
servidor de confianza; puede requerir acceso de administrador.
This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.: Esta frase no se copia a otros dispositivos. Usará `Default` hasta reconfigurar
This password is used to encrypt the connection. Use something long enough.: Esta contraseña se usa para cifrar la conexión. Usa algo suficientemente largo.
This procedure will first delete all existing synchronisation data from the server. Following this, the server data will be completely rebuilt, using the current state of your Vault on this device (including its local database) as:
@@ -2610,7 +2632,7 @@ xxhash64 (Fastest): xxhash64 (el más rápido)
"I am adding a device to an existing synchronisation setup":
"Estoy agregando un dispositivo a una configuración de sincronización
existente"
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.":
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.":
"(Seleccione esto si ya utiliza la sincronización en otro ordenador o
teléfono). Esta opción es adecuada si desea añadir este dispositivo a una
configuración de LiveSync existente。"
@@ -2666,6 +2688,7 @@ xxhash64 (Fastest): xxhash64 (el más rápido)
limitadas. La conexión a Internet solo se necesita para la señalización
(detección de pares), no para la transferencia de datos。"
You can configure in the Obsidian Plugin Settings.: Puedes configurarlo en los ajustes del complemento de Obsidian.
"you wanted(Thank you)!": "tu solicitud (¡gracias!)"
You should create a new synchronisation destination and rebuild your data there.: Deberías crear un nuevo destino de sincronización y reconstruir allí tus datos.
You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.:
"Solo deberías realizar esta operación en circunstancias excepcionales: cuando
+1 -1
View File
@@ -1186,7 +1186,7 @@ The minimum interval for automatic synchronisation on event.: イベント発生
"I am setting this up for the first time": "はじめて設定します"
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(この端末を最初の同期端末として設定する場合に選択してください。)LiveSync を初めて利用し、最初から設定したい場合に適しています。"
"I am adding a device to an existing synchronisation setup": "既存の同期構成に端末を追加します"
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(別の PC やスマートフォンですでに同期を利用している場合に選択してください。)この端末を既存の LiveSync 構成に追加する場合に適しています。"
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(別の PC やスマートフォンですでに同期を利用している場合に選択してください。)この端末を既存の LiveSync 構成に追加する場合に適しています。"
"Yes, I want to set up a new synchronisation": "はい、新しい同期を設定します"
"Yes, I want to add this device to my existing synchronisation": "はい、この端末を既存の同期に追加します"
"No, please take me back": "いいえ、前に戻ります"
+1 -1
View File
@@ -14,7 +14,7 @@
(Obsolete) Use an old adapter for compatibility: (사용 중단) 호환성을 위해 이전 어댑터 사용
(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.: (정규식) 비워 두면 모든 파일을 동기화합니다. 정규식을 지정하면 동기화할 파일을 제한할 수 있습니다.
(RegExp) If this is set, any changes to local and remote files that match this will be skipped.: (정규식) 설정하면 이 패턴과 일치하는 로컬 및 원격 파일 변경은 모두 건너뜁니다.
(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.: (다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.
(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.: (다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.
(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.: (이 기기를 첫 번째 동기화 기기로 설정한다면 선택하세요.) LiveSync를 처음 사용하며 처음부터 설정하려는 경우에 적합합니다.
"↑: Overwrite Remote": "↑: 원격 덮어쓰기"
"↓: Overwrite Local": "↓: 로컬 덮어쓰기"
+1 -1
View File
@@ -1058,7 +1058,7 @@ xxhash64 (Fastest): xxhash64 (самый быстрый)
"I am setting this up for the first time": "Я настраиваю это впервые"
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Выберите этот вариант, если настраиваете это устройство как первое устройство синхронизации.) Он подходит, если вы впервые используете LiveSync и хотите настроить всё с нуля。"
"I am adding a device to an existing synchronisation setup": "Я добавляю устройство к существующей настройке синхронизации"
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Выберите этот вариант, если вы уже используете синхронизацию на другом компьютере или смартфоне.) Он подходит, если вы хотите добавить это устройство к уже существующей конфигурации LiveSync。"
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(Выберите этот вариант, если вы уже используете синхронизацию на другом компьютере или смартфоне.) Он подходит, если вы хотите добавить это устройство к уже существующей конфигурации LiveSync。"
"Yes, I want to set up a new synchronisation": "Да, я хочу настроить новую синхронизацию"
"Yes, I want to add this device to my existing synchronisation": "Да, я хочу добавить это устройство к существующей синхронизации"
"No, please take me back": "Нет, верните меня назад"
+1 -1
View File
@@ -1667,7 +1667,7 @@ xxhash64 (Fastest): xxhash64(最快)
"I am setting this up for the first time": "我是第一次進行設定"
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你正在將此裝置設定為第一台同步裝置,請選擇此項。)此選項適合初次使用 LiveSync,並希望從頭開始設定的使用者。"
"I am adding a device to an existing synchronisation setup": "我要將裝置加入既有同步設定"
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你已經在另一台電腦或手機上使用同步,請選擇此項。)此選項適合將目前裝置加入既有 LiveSync 設定的使用者。"
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(如果你已經在另一台電腦或手機上使用同步,請選擇此項。)此選項適合將目前裝置加入既有 LiveSync 設定的使用者。"
"Yes, I want to set up a new synchronisation": "是的,我要設定新的同步"
"Yes, I want to add this device to my existing synchronisation": "是的,我要把這台裝置加入既有同步"
"No, please take me back": "不,返回上一步"
+1 -1
View File
@@ -1568,7 +1568,7 @@ xxhash64 (Fastest): xxhash64(最快)
"I am setting this up for the first time": "我是第一次进行设置"
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你正在将此设备配置为第一台同步设备,请选择此项。)此选项适合初次使用 LiveSync,并希望从头开始配置的用户。"
"I am adding a device to an existing synchronisation setup": "我要将设备加入现有同步配置"
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你已经在另一台电脑或手机上使用同步,请选择此项。)此选项适合将当前设备加入现有 LiveSync 配置的用户。"
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(如果你已经在另一台电脑或手机上使用同步,请选择此项。)此选项适合将当前设备加入现有 LiveSync 配置的用户。"
"Yes, I want to set up a new synchronisation": "是的,我要配置新的同步"
"Yes, I want to add this device to my existing synchronisation": "是的,我要把这台设备加入现有同步"
"No, please take me back": "不,返回上一步"
+18
View File
@@ -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();
});
});
+26
View File
@@ -0,0 +1,26 @@
export const ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY = 255;
export interface OversizedPathComponent {
component: string;
utf8Bytes: number;
}
const utf8Encoder = new TextEncoder();
/**
* Return path components which exceed the conservative Android/Linux
* compatibility boundary.
*
* Obsidian paths use forward slashes. The limit applies to each file or
* folder name, not to the combined Vault-relative path.
*/
export function findPathComponentsExceedingUtf8Limit(
path: string,
maxBytes: number = ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY
): OversizedPathComponent[] {
return path
.split("/")
.filter((component) => component.length > 0)
.map((component) => ({ component, utf8Bytes: utf8Encoder.encode(component).byteLength }))
.filter(({ utf8Bytes }) => utf8Bytes > maxBytes);
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY,
findPathComponentsExceedingUtf8Limit,
} from "./pathCompatibility.ts";
describe("findPathComponentsExceedingUtf8Limit", () => {
it("accepts 255 UTF-8 bytes and reports 256 UTF-8 bytes", () => {
expect(findPathComponentsExceedingUtf8Limit("a".repeat(255))).toEqual([]);
expect(findPathComponentsExceedingUtf8Limit("a".repeat(256))).toEqual([
{
component: "a".repeat(256),
utf8Bytes: 256,
},
]);
});
it("counts UTF-8 bytes rather than JavaScript characters", () => {
expect(findPathComponentsExceedingUtf8Limit("界".repeat(85))).toEqual([]);
expect(findPathComponentsExceedingUtf8Limit(`${"界".repeat(85)}a`)).toEqual([
{
component: `${"界".repeat(85)}a`,
utf8Bytes: 256,
},
]);
});
it("does not apply the component limit to the whole path", () => {
const path = `${"a".repeat(200)}/${"b".repeat(200)}`;
expect(new TextEncoder().encode(path).byteLength).toBeGreaterThan(
ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY
);
expect(findPathComponentsExceedingUtf8Limit(path)).toEqual([]);
});
it("reports an oversized folder component as well as an oversized file name", () => {
const folder = "界".repeat(86);
const file = `${"b".repeat(256)}.md`;
expect(findPathComponentsExceedingUtf8Limit(`parent/${folder}/${file}`)).toEqual([
{ component: folder, utf8Bytes: 258 },
{ component: file, utf8Bytes: 259 },
]);
});
});
@@ -0,0 +1,102 @@
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
type EndpointProjection = readonly [kind: "url" | "invalid-url", value: string];
/**
* Compare the effective endpoint rather than inconsequential URI spelling.
* Fragments are not sent, query order is immaterial, and redundant trailing
* slashes do not bind a different adapter. Invalid input is retained verbatim
* and tagged so comparison remains deterministic and fails closed.
*/
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];
}
}
/**
* Mirror the effective custom-header parser: trim each first name/value pair,
* ignore incomplete lines, and let the last duplicate name win. Sorting the
* resulting entries prevents line order alone from replacing a Replicator.
*/
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)
);
});
});
+161
View File
@@ -0,0 +1,161 @@
import { REMOTE_COUCHDB, REMOTE_MINIO, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
CAPABILITY_NOT_APPLICABLE,
CENTRAL_REMOTE_REPLICATION_READINESS,
NO_INTERACTION,
REPLICATION_PROGRESS_PRESENTATIONS,
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>;
}
/** Narrow structurally so the shared adapter does not depend on either concrete provider class. */
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);
}
// Manual and unattended wrappers share the provider transfer operation, but
// keep interaction authority and result presentation explicit at this boundary.
const couchDBUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
return await runOneShotWithOutcome(
instance,
setting,
request.progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE
);
};
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.progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE
);
};
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, deliberately concrete central-provider matrix for one
* LiveSync host. This closed composition is not a runtime provider registry.
*/
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(),
},
});
}
+248
View File
@@ -0,0 +1,248 @@
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,
REPLICATION_PROGRESS_PRESENTATIONS,
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",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
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.each([
["CouchDB", REMOTE_COUCHDB],
["Object Storage", REMOTE_MINIO],
] as const)("maps %s progress presentation independently of recovery authority", async (label, remoteType) => {
const definitions = createCentralReplicatorProviderDefinitions({} as never);
const definition = definitions.get(remoteType)!;
if (definition.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
throw new Error(`${label} OneShot is unavailable`);
}
const setting = Object.assign(createNewVaultSettings(), { remoteType });
const openOneShotReplicationWithOutcome = vi.fn(async () => REPLICATION_COMPLETED);
const instance = {
initializeDatabaseForReplication: vi.fn(async () => true),
openReplication: vi.fn(async () => true),
terminateSync: vi.fn(),
closeReplication: vi.fn(),
openOneShotReplicationWithOutcome,
};
await definition.userInitiatedOneShot.run(instance, setting, {
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
await definition.userInitiatedOneShot.run(instance, setting, {
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
expect(openOneShotReplicationWithOutcome).toHaveBeenNthCalledWith(1, setting, false);
expect(openOneShotReplicationWithOutcome).toHaveBeenNthCalledWith(2, setting, true);
});
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",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
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",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
expect(outcome.status).toBe("failed");
expect(incompleteInstance.openReplication).not.toHaveBeenCalled();
});
});
+333
View File
@@ -0,0 +1,333 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { LOG_LEVEL_NOTICE, 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(() => ({
logger: vi.fn(),
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/common/logger", async (importOriginal) => {
const actual = await importOriginal<typeof import("@vrtmrz/livesync-commonlib/compat/common/logger")>();
return { ...actual, Logger: mocks.logger };
});
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);
mocks.logger.mockClear();
});
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("emits a result Notice only for an explicitly visible successful CouchDB probe", async () => {
const probe = await createCouchDBConnectionProbeFactory({} as never)(createSettings());
const replicator = mocks.couchDB[0];
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({
info: { db_name: "vault" },
close: vi.fn(async () => undefined),
});
await expect(probe.check({ showResult: true })).resolves.toEqual({ ok: true });
expect(mocks.logger).toHaveBeenCalledTimes(1);
expect(mocks.logger).toHaveBeenCalledWith("Connected to vault successfully", LOG_LEVEL_NOTICE);
mocks.logger.mockClear();
await expect(probe.check()).resolves.toEqual({ ok: true });
expect(mocks.logger).not.toHaveBeenCalled();
});
it("emits a result Notice only for an explicitly visible CouchDB connection failure", async () => {
const reason = "connection failed";
const translatedFailure = "translated CouchDB connection failure";
const translate = vi.fn(() => translatedFailure);
const settings = createSettings();
const probe = await createCouchDBConnectionProbeFactory({ services: { context: { translate } } } as never)(
settings
);
const replicator = mocks.couchDB[0];
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue(reason);
await expect(probe.check({ showResult: true })).resolves.toEqual({ ok: false, reason });
expect(mocks.logger).toHaveBeenCalledTimes(1);
expect(translate).toHaveBeenCalledWith("liveSyncReplicator.couldNotConnectTo", {
uri: settings.couchDB_URI,
name: settings.couchDB_DBNAME,
db: reason,
});
expect(mocks.logger).toHaveBeenCalledWith(translatedFailure, LOG_LEVEL_NOTICE);
mocks.logger.mockClear();
translate.mockClear();
await expect(probe.check()).resolves.toEqual({ ok: false, reason });
expect(mocks.logger).not.toHaveBeenCalled();
expect(translate).not.toHaveBeenCalled();
});
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("preserves a CouchDB connection or setup failure for the settings flow to report", async () => {
const reason = "connection failed";
const resource = await createCouchDBSynchronisationInformationResourceFactory({} as never)(createSettings());
const replicator = mocks.couchDB[0];
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue(reason);
await expect(resource.check()).rejects.toMatchObject({ message: reason });
});
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,104 @@
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 { LOG_LEVEL_NOTICE, Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
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,
host: ConnectionResourceHost
): 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") {
if (options.showResult) {
Logger(
host.services.context.translate("liveSyncReplicator.couldNotConnectTo", {
uri: snapshot.couchDB_URI,
name: snapshot.couchDB_DBNAME,
db: connection,
}),
LOG_LEVEL_NOTICE
);
}
return { ok: false, reason: connection };
}
try {
if (options.showResult) {
Logger(`Connected to ${connection.info.db_name} successfully`, LOG_LEVEL_NOTICE);
}
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. A caller
* may request the established result Notice explicitly; ordinary probes remain
* silent.
*/
export function createCouchDBConnectionProbeFactory(host: ConnectionResourceHost): ConnectionProbeFactory {
return (setting) => {
const snapshot = snapshotRemoteSettings(setting);
return Promise.resolve(createCouchDBConnectionProbe(new LiveSyncCouchDBReplicator(host), snapshot, host));
};
}
/**
* 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));
};
}
+16
View File
@@ -0,0 +1,16 @@
export {
createCouchDBConnectionProbeFactory,
createObjectStorageConnectionProbeFactory,
type ConnectionResourceHost,
} from "./connection";
export {
createCouchDBPreferredTweakProbeFactory,
createObjectStoragePreferredTweakProbeFactory,
type PreferredTweakResourceHost,
} from "./preferredTweak";
export {
createCouchDBSecuritySeedResourceFactory,
createObjectStorageSecuritySeedResourceFactory,
type SecuritySeedResourceHost,
} from "./securitySeed";
export { createCouchDBSynchronisationInformationResourceFactory } from "./synchronisationInformation";
@@ -0,0 +1,43 @@
import type { RemoteDBSettings, RemotePreferredTweakResult } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { PreferredTweakProbe, PreferredTweakProbeFactory } 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, type ResourceReplicator } from "./shared";
/** Host environment sufficient to construct either preferred-tweak probe. */
export type PreferredTweakResourceHost = LiveSyncCouchDBReplicatorEnv;
interface PreferredTweakReplicator extends ResourceReplicator {
getRemotePreferredTweakValues(setting: RemoteDBSettings): Promise<RemotePreferredTweakResult>;
}
function createPreferredTweakProbe(
replicator: PreferredTweakReplicator,
snapshot: RemoteDBSettings
): PreferredTweakProbe {
return {
read: () => replicator.getRemotePreferredTweakValues(snapshot),
dispose: createReplicatorDisposer(replicator),
};
}
/** Build an unpublished, independently disposed CouchDB preferred-tweak probe. */
export function createCouchDBPreferredTweakProbeFactory(host: PreferredTweakResourceHost): PreferredTweakProbeFactory {
return (setting) => {
const snapshot = snapshotRemoteSettings(setting);
return Promise.resolve(createPreferredTweakProbe(new LiveSyncCouchDBReplicator(host), snapshot));
};
}
/** Build an unpublished, independently disposed Object Storage preferred-tweak probe. */
export function createObjectStoragePreferredTweakProbeFactory(
host: PreferredTweakResourceHost
): PreferredTweakProbeFactory {
return (setting) => {
const snapshot = snapshotRemoteSettings(setting);
return Promise.resolve(createPreferredTweakProbe(new LiveSyncJournalReplicator(host), snapshot));
};
}
@@ -0,0 +1,41 @@
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { SecuritySeedResourceFactory } 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, type ResourceReplicator } from "./shared";
/** Host environment sufficient to construct either Security Seed resource. */
export type SecuritySeedResourceHost = LiveSyncCouchDBReplicatorEnv;
/** Minimal private Replicator surface required by a Security Seed resource. */
interface SecuritySeedReplicator extends ResourceReplicator {
getReplicationPBKDF2Salt(setting: RemoteDBSettings, refresh?: boolean): Promise<Uint8Array<ArrayBuffer>>;
}
function createSecuritySeedResourceFactory(
createReplicator: () => SecuritySeedReplicator
): SecuritySeedResourceFactory {
return (setting) => {
const snapshot = snapshotRemoteSettings(setting);
const replicator = createReplicator();
return Promise.resolve({
read: () => replicator.getReplicationPBKDF2Salt(snapshot, true),
dispose: createReplicatorDisposer(replicator),
});
};
}
/** Build an unpublished, independently disposed CouchDB Security Seed resource. */
export function createCouchDBSecuritySeedResourceFactory(host: SecuritySeedResourceHost): SecuritySeedResourceFactory {
return createSecuritySeedResourceFactory(() => new LiveSyncCouchDBReplicator(host));
}
/** Build an unpublished, independently disposed Object Storage Security Seed resource. */
export function createObjectStorageSecuritySeedResourceFactory(
host: SecuritySeedResourceHost
): SecuritySeedResourceFactory {
return createSecuritySeedResourceFactory(() => new LiveSyncJournalReplicator(host));
}
+28
View File
@@ -0,0 +1,28 @@
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
/**
* Closeable surface of a concrete Replicator owned by one private resource.
*
* It deliberately exposes no active-provider controls: the resource may use
* the helper for one bounded operation, then must dispose it without
* publishing or replacing the active Replicator.
*/
export interface ResourceReplicator {
closeReplication(): void | Promise<void>;
}
/** Create one idempotent asynchronous disposer for a private Replicator. */
export function createReplicatorDisposer(replicator: ResourceReplicator): () => Promise<void> {
let disposal: Promise<void> | undefined;
return () => {
if (disposal === undefined) {
disposal = Promise.resolve().then(() => replicator.closeReplication());
}
return disposal;
};
}
/** Fence a finite resource from later edits to its source settings object. */
export function snapshotRemoteSettings(setting: RemoteDBSettings): RemoteDBSettings {
return { ...setting };
}
@@ -0,0 +1,42 @@
import type { SynchronisationInformationResourceFactory } from "@vrtmrz/livesync-commonlib/replication";
import {
LiveSyncCouchDBReplicator,
type LiveSyncCouchDBReplicatorEnv,
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { checkSyncInfo } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
import { createReplicatorDisposer, snapshotRemoteSettings } from "./shared";
/**
* Build an unpublished CouchDB synchronisation-information verifier.
*
* The resource owns its concrete Replicator and connection, and cannot replace
* the active provider instance. Its check resolves to `false` only for observed
* incompatibility; connection, setup, and verification failures reject so the
* caller can report an operational failure separately.
*/
export function createCouchDBSynchronisationInformationResourceFactory(
host: LiveSyncCouchDBReplicatorEnv
): SynchronisationInformationResourceFactory {
return (setting) => {
const snapshot = snapshotRemoteSettings(setting);
const replicator = new LiveSyncCouchDBReplicator(host);
return Promise.resolve({
check: async () => {
const connection = await replicator.connectRemoteCouchDBWithSetting(
snapshot,
replicator.isMobile(),
true
);
if (typeof connection === "string") {
throw new Error(connection);
}
try {
return await checkSyncInfo(connection.db);
} finally {
await connection.close();
}
},
dispose: createReplicatorDisposer(replicator),
});
};
}
+9 -1
View File
@@ -24,6 +24,10 @@
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
import { $msg as translateMessage } from "@/common/translation";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
export let plugin: ObsidianLiveSyncPlugin;
export let core :LiveSyncBaseCore;
// $: core = plugin.core;
@@ -104,7 +108,11 @@
await requestUpdate();
}
async function replicate() {
await core.services.replication.replicate(true);
await core.services.replication.replicateUserInitiated({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
}
function selectAllNewest(selectMode: boolean) {
selectNewestPulse++;
@@ -17,6 +17,7 @@ import { serialized } from "octagonal-wheels/concurrency/lock_v2";
import { arrayToChunkedArray } from "octagonal-wheels/collection";
import { EVENT_ANALYSE_DB_USAGE, EVENT_REQUEST_PERFORM_GC_V3, eventHub } from "@/common/events";
import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import type { ReplicatorInstance } from "@vrtmrz/livesync-commonlib/replication";
import { delay } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
import { ensureLocalDatabaseMaintenancePrerequisites } from "./maintenancePrerequisites";
@@ -29,6 +30,31 @@ type NoteDocumentID = DocumentID;
type Rev = string;
type ChunkUsageMap = Map<NoteDocumentID, Map<Rev, Set<ChunkID>>>;
type CouchDBCompactionReplicator = ReplicatorInstance &
Pick<LiveSyncCouchDBReplicator, "connectRemoteCouchDBWithSetting">;
type CouchDBGarbageCollectionReplicator = ReplicatorInstance &
Pick<LiveSyncCouchDBReplicator, "getConnectedDeviceList" | "openOneShotReplication">;
function canCompactCouchDBRemote(replicator: ReplicatorInstance): replicator is CouchDBCompactionReplicator {
return (
"connectRemoteCouchDBWithSetting" in replicator &&
typeof replicator.connectRemoteCouchDBWithSetting === "function"
);
}
function canRunCouchDBGarbageCollection(
replicator: ReplicatorInstance
): replicator is CouchDBGarbageCollectionReplicator {
return (
"getConnectedDeviceList" in replicator &&
typeof replicator.getConnectedDeviceList === "function" &&
"openOneShotReplication" in replicator &&
typeof replicator.openOneShotReplication === "function"
);
}
export class LocalDatabaseMaintenance extends LiveSyncCommands {
onunload(): void {
// NO OP.
@@ -737,7 +763,8 @@ Success: ${successCount}, Errored: ${errored}`;
}
async compactDatabase() {
const replicator = this.core.replicator as LiveSyncCouchDBReplicator;
const replicator = this.core.replicator;
if (!canCompactCouchDBRemote(replicator)) return;
const remote = await replicator.connectRemoteCouchDBWithSetting(this.settings, false, false, true);
if (!remote) {
this._notice("Failed to connect to remote for compaction.", "gc-compact");
@@ -840,8 +867,9 @@ Success: ${successCount}, Errored: ${errored}`;
// }
// }
async gcv3() {
const replicator = this.core.replicator;
if (this.settings.remoteType !== REMOTE_COUCHDB || !canRunCouchDBGarbageCollection(replicator)) return;
if (!(await this.ensureAvailable("Garbage Collection"))) return;
const replicator = this.core.replicator as LiveSyncCouchDBReplicator;
// Start one-shot replication to ensure all changes are synced before GC.
const r0 = await replicator.openOneShotReplication(this.settings, false, false, "sync");
if (!r0) {
@@ -854,7 +882,7 @@ Success: ${successCount}, Errored: ${errored}`;
// Delete the chunk, but first verify the following:
// Fetch the list of accepted nodes from the replicator.
const OPTION_CANCEL = "Cancel Garbage Collection";
const info = await this.core.replicator.getConnectedDeviceList();
const info = await replicator.getConnectedDeviceList();
if (!info) {
this._notice("No connected device information found. Cancelling Garbage Collection.");
return;
@@ -1,15 +1,19 @@
import { App, Modal } from "@/deps.ts";
import P2POpenReplicationPane from "./P2POpenReplicationPane.svelte";
import { mount, unmount } from "svelte";
import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
/**
* Reports action completion so the pane does not infer success merely from a
* settled Promise.
*/
export type P2POpenReplicationModalCallback = {
onSync: (peerId: string) => Promise<void>;
onSyncAndClose: (peerId: string) => Promise<void>;
onSync: (peerId: string) => Promise<boolean>;
onSyncAndClose: (peerId: string) => Promise<boolean>;
};
export class P2POpenReplicationModal extends Modal {
liveSyncReplicator: LiveSyncTrysteroReplicator;
p2p: P2PServiceViews;
callback?: P2POpenReplicationModalCallback;
component?: ReturnType<typeof mount>;
showResult: boolean;
@@ -19,7 +23,7 @@ export class P2POpenReplicationModal extends Modal {
constructor(
app: App,
liveSyncReplicator: LiveSyncTrysteroReplicator,
p2p: P2PServiceViews,
callback?: P2POpenReplicationModalCallback,
showResult: boolean = false,
title: string = "P2P Replication",
@@ -27,7 +31,7 @@ export class P2POpenReplicationModal extends Modal {
rebuildMode: boolean = false
) {
super(app);
this.liveSyncReplicator = liveSyncReplicator;
this.p2p = p2p;
this.callback = callback;
this.showResult = showResult;
this.title = title;
@@ -35,17 +39,20 @@ export class P2POpenReplicationModal extends Modal {
this.rebuildMode = rebuildMode;
}
async onSync(peerId: string) {
async onSync(peerId: string): Promise<boolean> {
if (this.callback?.onSync) {
await this.callback.onSync(peerId);
return await this.callback.onSync(peerId);
}
return false;
}
async onSyncAndClose(peerId: string) {
async onSyncAndClose(peerId: string): Promise<boolean> {
let completed = false;
if (this.callback?.onSyncAndClose) {
await this.callback.onSyncAndClose(peerId);
completed = await this.callback.onSyncAndClose(peerId);
}
this.close();
return completed;
}
override onOpen() {
@@ -57,7 +64,7 @@ export class P2POpenReplicationModal extends Modal {
this.component = mount(P2POpenReplicationPane, {
target: contentEl,
props: {
liveSyncReplicator: this.liveSyncReplicator,
p2p: this.p2p,
onSync: (peerId: string) => this.onSync(peerId),
onSyncAndClose: (peerId: string) => this.onSyncAndClose(peerId),
onClose: () => this.close(),
@@ -9,29 +9,28 @@
// import type { TrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator";
import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
import { delay, fireAndForget } from "octagonal-wheels/promises";
import P2PServerStatusCard from "./P2PServerStatusCard.svelte";
import { $msg as translateMessage } from "@/common/translation";
interface Props {
liveSyncReplicator: LiveSyncTrysteroReplicator;
onSync: (_peerId: string) => Promise<void>;
onSyncAndClose: (_peerId: string) => Promise<void>;
p2p: P2PServiceViews;
onSync: (_peerId: string) => Promise<boolean>;
onSyncAndClose: (_peerId: string) => Promise<boolean>;
onClose: () => void;
showResult: boolean;
rebuildMode?: boolean;
}
let { onSync, onSyncAndClose, onClose, showResult, liveSyncReplicator, rebuildMode = false }: Props = $props();
const getLiveSyncReplicator = () => liveSyncReplicator;
let { onSync, onSyncAndClose, onClose, showResult, p2p, rebuildMode = false }: Props = $props();
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
let syncingPeerId = $state<string | null>(null);
const logLevel = $derived(showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
async function requestServerStatus() {
await liveSyncReplicator.requestStatus();
p2p.diagnostics.requestStatus();
eventHub.emitEvent(EVENT_REQUEST_STATUS);
}
onMount(() => {
@@ -50,8 +49,8 @@
try {
syncingPeerId = peerId;
Logger(`Starting sync with ${peerId}`, logLevel);
await onSync(peerId);
Logger(`Sync completed with ${peerId}`, logLevel);
const completed = await onSync(peerId);
if (completed) Logger(`Sync completed with ${peerId}`, logLevel);
} catch (e) {
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
} finally {
@@ -62,8 +61,8 @@
try {
syncingPeerId = peerId;
Logger(`Starting sync with ${peerId}`, logLevel);
await onSyncAndClose(peerId);
Logger(`Sync completed with ${peerId}`, logLevel);
const completed = await onSyncAndClose(peerId);
if (completed) Logger(`Sync completed with ${peerId}`, logLevel);
} catch (e) {
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
} finally {
@@ -73,7 +72,7 @@
async function disconnect() {
try {
await liveSyncReplicator.close();
await p2p.transportLifecycle.disconnect();
Logger("Signalling connection closed.", logLevel);
} catch (e) {
Logger(`Failed to close signalling connection: ${e instanceof Error ? e.message : String(e)}`, logLevel);
@@ -100,7 +99,7 @@
</script>
<div class="p2p-container">
<P2PServerStatusCard {getLiveSyncReplicator} showBroadcastToggle={false} />
<P2PServerStatusCard {p2p} showBroadcastToggle={false} />
<div class="peers-section">
<h3>{translateMessage("Available Peers")}</h3>
@@ -2,21 +2,24 @@ import type { App } from "@/deps.ts";
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
import { P2POpenReplicationModal } from "./P2POpenReplicationModal";
/**
* Creates an openReplicationUI factory for Obsidian environments.
* Returns a per-replicator closure that opens the P2P Replication modal
* and performs bidirectional sync (pull then push on success).
* Create the Obsidian-owned interactive P2P entry for stable service views.
*
* Peer selection belongs to the host UI rather than the concrete compatibility
* Replicator. The returned operation opens the modal and performs bidirectional
* synchronisation, pulling before pushing, through the targeted-transfer view.
*
* Usage:
* const factory = createOpenReplicationUI(app);
* useP2PReplicatorFeature(core, factory);
* const createInteractiveReplication = createOpenReplicationUI(app);
* const openInteractiveReplication = createInteractiveReplication(p2p);
*/
export function createOpenReplicationUI(
app: App
): (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise<boolean | void> {
return (replicator: LiveSyncTrysteroReplicator) =>
): (p2p: P2PServiceViews) => (showResult: boolean) => Promise<boolean | void> {
return (p2p: P2PServiceViews) =>
(showResult: boolean): Promise<boolean | void> => {
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
return new Promise<boolean | void>((resolve) => {
@@ -36,20 +39,25 @@ export function createOpenReplicationUI(
activeSynchronisations++;
try {
// Pull first, then push only when the pull succeeds.
const pullResult = await replicator.replicateFrom(peerId, showResult);
if (!pullResult?.ok) {
const pullResult = await p2p.targetedTransfer.pullFromPeer(peerId, {
showNotice: showResult,
});
if (pullResult.status !== "completed" || !pullResult.ok) {
sessionResult = false;
return;
return false;
}
const pushResult = await replicator.requestSynchroniseToPeer(peerId);
sessionResult = pushResult?.ok ?? true;
if (sessionResult && closeConnection) await replicator.close();
const pushResult = await p2p.targetedTransfer.requestPushToPeer(peerId);
const completed = pushResult.status === "completed" && pushResult.ok === true;
sessionResult = completed;
if (completed && closeConnection) await p2p.transportLifecycle.disconnect();
return completed;
} catch (e) {
Logger(
`Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
logLevel
);
sessionResult = false;
return false;
} finally {
activeSynchronisations--;
settleClosedSession();
@@ -57,7 +65,7 @@ export function createOpenReplicationUI(
};
const modal = new P2POpenReplicationModal(
app,
replicator,
p2p,
{
onSync: (peerId: string) => synchronise(peerId, false),
onSyncAndClose: (peerId: string) => synchronise(peerId, true),
@@ -81,12 +89,12 @@ export function createOpenReplicationUI(
*
* Usage:
* const factory = createOpenRebuildUI(app);
* useP2PReplicatorFeature(core, createOpenReplicationUI(app), factory);
* useP2PReplicatorFeature(core, openReplicationUIFactory, factory);
*/
export function createOpenRebuildUI(
app: App
): (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise<boolean | void> {
return (replicator: LiveSyncTrysteroReplicator) =>
): (replicator: LiveSyncTrysteroReplicator, p2p: P2PServiceViews) => (showResult: boolean) => Promise<boolean | void> {
return (replicator: LiveSyncTrysteroReplicator, p2p: P2PServiceViews) =>
(showResult: boolean): Promise<boolean | void> => {
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
return new Promise<boolean | void>((resolve) => {
@@ -113,12 +121,14 @@ export function createOpenRebuildUI(
Logger(`Rebuilding from peer ${peerId}`, logLevel);
const result = await replicator.replicateFrom(peerId, showResult, true);
sessionResult = result?.ok ?? false;
return sessionResult;
} catch (e) {
Logger(
`Error in rebuild from ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
logLevel
);
sessionResult = false;
return false;
} finally {
try {
replicator.clearOnSetup();
@@ -132,7 +142,7 @@ export function createOpenRebuildUI(
const modal = new P2POpenReplicationModal(
app,
replicator,
p2p,
{
onSync: doRebuild,
onSyncAndClose: doRebuild,
@@ -2,9 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const modalState = vi.hoisted(() => ({
instances: [] as Array<{
p2p: unknown;
callback: {
onSync: (peerId: string) => Promise<void>;
onSyncAndClose: (peerId: string) => Promise<void>;
onSync: (peerId: string) => Promise<boolean>;
onSyncAndClose: (peerId: string) => Promise<boolean>;
};
onClosed?: () => void;
open: ReturnType<typeof vi.fn>;
@@ -15,18 +16,20 @@ vi.mock("@/deps.ts", () => ({ App: class {} }));
vi.mock("./P2POpenReplicationModal", () => ({
P2POpenReplicationModal: class {
p2p;
callback;
onClosed;
open = vi.fn();
constructor(
_app: unknown,
_replicator: unknown,
p2p: unknown,
callback: (typeof modalState.instances)[number]["callback"],
_showResult: boolean,
_title?: string,
onClosed?: () => void
) {
this.p2p = p2p;
this.callback = callback;
this.onClosed = onClosed;
modalState.instances.push(this);
@@ -38,23 +41,38 @@ import { createOpenRebuildUI, createOpenReplicationUI } from "./P2PReplicationUI
function createReplicator() {
return {
replicateFrom: vi.fn(async () => ({ ok: true })),
requestSynchroniseToPeer: vi.fn(async () => ({ ok: true })),
replicateFrom: vi.fn(async () => ({ status: "completed" as const, ok: true as const })),
requestSynchroniseToPeer: vi.fn(async () => ({ status: "completed" as const, ok: true as const })),
close: vi.fn(async () => undefined),
setOnSetup: vi.fn(),
clearOnSetup: vi.fn(),
} as any;
}
function createP2PServiceViews() {
return {
transportLifecycle: {
disconnect: vi.fn(async () => undefined),
},
targetedTransfer: {
pullFromPeer: vi.fn(async () => ({ status: "completed" as const, ok: true as const })),
requestPushToPeer: vi.fn(async () => ({ status: "completed" as const, ok: true as const })),
},
diagnostics: {},
} as any;
}
describe("createOpenReplicationUI", () => {
beforeEach(() => {
modalState.instances.length = 0;
});
it("settles a cancelled peer-selection session when the modal closes", async () => {
const session = createOpenReplicationUI({} as any)(createReplicator())(true);
const p2p = createP2PServiceViews();
const session = createOpenReplicationUI({} as any)(p2p)(true);
const modal = modalState.instances[0];
expect(modal.p2p).toBe(p2p);
expect(modal.onClosed).toBeTypeOf("function");
modal.onClosed?.();
@@ -62,36 +80,49 @@ describe("createOpenReplicationUI", () => {
});
it("keeps repeated synchronisation inside the session boundary until the modal closes", async () => {
const replicator = createReplicator();
const session = createOpenReplicationUI({} as any)(replicator)(true);
const p2p = createP2PServiceViews();
const session = createOpenReplicationUI({} as any)(p2p)(true);
const modal = modalState.instances[0];
let settled = false;
void session.finally(() => {
settled = true;
});
await modal.callback.onSync("peer-a");
await expect(modal.callback.onSync("peer-a")).resolves.toBe(true);
await Promise.resolve();
expect(settled).toBe(false);
await modal.callback.onSync("peer-b");
expect(replicator.replicateFrom).toHaveBeenCalledTimes(2);
expect(replicator.requestSynchroniseToPeer).toHaveBeenCalledTimes(2);
expect(p2p.targetedTransfer.pullFromPeer).toHaveBeenCalledTimes(2);
expect(p2p.targetedTransfer.requestPushToPeer).toHaveBeenCalledTimes(2);
modal.onClosed?.();
await expect(session).resolves.toBe(true);
});
it("routes ordinary peer transfer through the stable targeted-transfer view", async () => {
const p2p = createP2PServiceViews();
const session = createOpenReplicationUI({} as any)(p2p)(true);
const modal = modalState.instances[0];
await modal.callback.onSync("peer-a");
modal.onClosed?.();
await expect(session).resolves.toBe(true);
expect(p2p.targetedTransfer.pullFromPeer).toHaveBeenCalledWith("peer-a", { showNotice: true });
expect(p2p.targetedTransfer.requestPushToPeer).toHaveBeenCalledWith("peer-a");
});
it("waits for an in-flight synchronisation when the modal closes", async () => {
let finishPull!: (value: { ok: boolean }) => void;
const replicator = createReplicator();
replicator.replicateFrom.mockImplementation(
let finishPull!: (value: { status: "completed"; ok: true }) => void;
const p2p = createP2PServiceViews();
p2p.targetedTransfer.pullFromPeer.mockImplementation(
async () =>
await new Promise<{ ok: boolean }>((resolve) => {
await new Promise<{ status: "completed"; ok: true }>((resolve) => {
finishPull = resolve;
})
);
const session = createOpenReplicationUI({} as any)(replicator)(true);
const session = createOpenReplicationUI({} as any)(p2p)(true);
const modal = modalState.instances[0];
let settled = false;
void session.finally(() => {
@@ -104,19 +135,19 @@ describe("createOpenReplicationUI", () => {
expect(settled).toBe(false);
finishPull({ ok: true });
finishPull({ status: "completed", ok: true });
await synchronisation;
await expect(session).resolves.toBe(true);
});
it("closes the P2P connection after a successful sync-and-close action", async () => {
const replicator = createReplicator();
const session = createOpenReplicationUI({} as any)(replicator)(true);
const p2p = createP2PServiceViews();
const session = createOpenReplicationUI({} as any)(p2p)(true);
const modal = modalState.instances[0];
await modal.callback.onSyncAndClose("peer-a");
expect(replicator.close).toHaveBeenCalledOnce();
expect(p2p.transportLifecycle.disconnect).toHaveBeenCalledOnce();
let settled = false;
void session.finally(() => {
settled = true;
@@ -127,6 +158,19 @@ describe("createOpenReplicationUI", () => {
modal.onClosed?.();
await expect(session).resolves.toBe(true);
});
it("returns a cancelled peer push as non-success to the presentation boundary", async () => {
const p2p = createP2PServiceViews();
p2p.targetedTransfer.requestPushToPeer.mockResolvedValue({ status: "cancelled" } as never);
const session = createOpenReplicationUI({} as any)(p2p)(true);
const modal = modalState.instances[0];
const actionResult = await modal.callback.onSync("peer-a");
modal.onClosed?.();
expect(actionResult).toBe(false);
await expect(session).resolves.toBe(false);
});
});
describe("createOpenRebuildUI", () => {
@@ -135,15 +179,15 @@ describe("createOpenRebuildUI", () => {
});
it("waits for an in-flight rebuild when the modal closes", async () => {
let finishPull!: (value: { ok: boolean }) => void;
let finishPull!: (value: { status: "completed"; ok: true }) => void;
const replicator = createReplicator();
replicator.replicateFrom.mockImplementation(
async () =>
await new Promise<{ ok: boolean }>((resolve) => {
await new Promise<{ status: "completed"; ok: true }>((resolve) => {
finishPull = resolve;
})
);
const session = createOpenRebuildUI({} as any)(replicator)(true);
const session = createOpenRebuildUI({} as any)(replicator, createP2PServiceViews())(true);
const modal = modalState.instances[0];
let settled = false;
void session.finally(() => {
@@ -156,8 +200,8 @@ describe("createOpenRebuildUI", () => {
expect(settled).toBe(false);
finishPull({ ok: true });
await rebuild;
finishPull({ status: "completed", ok: true });
await expect(rebuild).resolves.toBe(true);
await expect(session).resolves.toBe(true);
expect(replicator.setOnSetup).toHaveBeenCalledOnce();
expect(replicator.replicateFrom).toHaveBeenCalledWith("peer-a", true, true);
@@ -166,7 +210,7 @@ describe("createOpenRebuildUI", () => {
it("does not complete Fetch when the rebuild dialogue closes without selecting a peer", async () => {
const replicator = createReplicator();
const session = createOpenRebuildUI({} as any)(replicator)(true);
const session = createOpenRebuildUI({} as any)(replicator, createP2PServiceViews())(true);
const modal = modalState.instances[0];
modal.onClosed?.();
@@ -13,7 +13,6 @@
type PeerInfo,
type P2PServerInfo,
EVENT_SERVER_STATUS,
EVENT_REQUEST_STATUS,
EVENT_P2P_REPLICATOR_STATUS,
} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
import type { P2PReplicatorStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator";
@@ -29,7 +28,6 @@
let services = $derived(host.services);
let events = $derived(services.context.events);
const currentSettings = () => services.setting.currentSettings() as P2PSyncSetting;
const currentReplicator = () => host.p2p.replicator;
const initialSettings = { ...currentSettings() } as P2PSyncSetting;
let settings = $state<P2PSyncSetting>(initialSettings);
@@ -146,7 +144,7 @@
replicatorInfo = status;
});
applyLoadSettings(currentSettings(), true);
events.emitEvent(EVENT_REQUEST_STATUS);
host.p2p.diagnostics.requestStatus();
return () => {
r();
rx();
@@ -223,16 +221,16 @@
}
async function openServer() {
await currentReplicator().open();
await host.p2p.transportLifecycle.connect();
}
async function closeServer() {
await currentReplicator().close();
await host.p2p.transportLifecycle.disconnect();
}
function startBroadcasting() {
currentReplicator().enableBroadcastChanges();
host.p2p.changeRelay.enableBroadcastChanges();
}
function stopBroadcasting() {
currentReplicator().disableBroadcastChanges();
host.p2p.changeRelay.disableBroadcastChanges();
}
const initialDialogStatusKey = `p2p-dialog-status`;
@@ -1,12 +1,20 @@
import type { RequiredServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
import type { PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
export type P2PReplicatorHandle = Pick<UseP2PReplicatorResult, "replicator">;
/**
* The shared pane only needs the contracts which represent its visible
* actions. In particular, it must not receive the compatibility Replicator
* facade, whose lifecycle methods can bypass the stable P2P service owner.
*/
export type P2PReplicatorPaneP2P = Pick<
P2PServiceViews,
"transportLifecycle" | "peerDirectory" | "peerAdmission" | "targetedTransfer" | "changeRelay" | "diagnostics"
>;
/** Host capabilities consumed by the shared P2P pane. */
export interface P2PReplicatorPaneHost {
readonly services: RequiredServices<"API" | "config" | "setting" | "vault">;
readonly p2p: P2PReplicatorHandle;
readonly p2p: P2PReplicatorPaneP2P;
readonly showPeerMenu?: (peer: PeerStatus, event: MouseEvent) => void;
}
@@ -8,7 +8,7 @@ import { LOG_LEVEL_NOTICE, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import type { PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
import type { P2PPaneParams } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
export const VIEW_TYPE_P2P = "p2p-replicator";
function addToList(item: string, list: string) {
@@ -31,7 +31,7 @@ function removeFromList(item: string, list: string) {
export class P2PReplicatorPaneView extends SvelteItemView {
core: LiveSyncBaseCore;
private _p2pResult: P2PPaneParams;
private _p2p: P2PServiceViews;
override icon = "waypoints";
title: string = "";
override navigation = false;
@@ -39,21 +39,18 @@ export class P2PReplicatorPaneView extends SvelteItemView {
override getIcon(): string {
return "waypoints";
}
get replicator() {
return this._p2pResult.replicator;
}
async replicateFrom(peer: PeerStatus) {
await this.replicator.replicateFrom(peer.peerId);
await this._p2p.targetedTransfer.pullFromPeer(peer.peerId);
}
async replicateTo(peer: PeerStatus) {
await this.replicator.requestSynchroniseToPeer(peer.peerId);
await this._p2p.targetedTransfer.requestPushToPeer(peer.peerId);
}
async getRemoteConfig(peer: PeerStatus) {
Logger(
`Requesting remote config for ${peer.name}. Please input the passphrase on the remote device`,
LOG_LEVEL_NOTICE
);
const remoteConfig = await this.replicator.getRemoteConfig(peer.peerId);
const remoteConfig = await this._p2p.configurationExchange.getRemoteConfiguration(peer.peerId);
if (remoteConfig) {
Logger(`Remote config for ${peer.name} is retrieved successfully`);
const DROP = "Yes, and drop local database";
@@ -122,10 +119,10 @@ And you can also drop the local database to rebuild from the remote device.`,
await this.core.services.setting.applyPartial(currentSetting, true);
}
m?: Menu;
constructor(leaf: WorkspaceLeaf, core: LiveSyncBaseCore, p2pResult: P2PPaneParams) {
constructor(leaf: WorkspaceLeaf, core: LiveSyncBaseCore, p2p: P2PServiceViews) {
super(leaf);
this.core = core;
this._p2pResult = p2pResult;
this._p2p = p2p;
}
private showPeerMenu(peer: PeerStatus, event: MouseEvent): void {
@@ -187,7 +184,7 @@ And you can also drop the local database to rebuild from the remote device.`,
props: {
host: {
services: this.core.services,
p2p: this._p2pResult,
p2p: this._p2p,
showPeerMenu: (peer: PeerStatus, event: MouseEvent) => this.showPeerMenu(peer, event),
},
},
@@ -9,19 +9,19 @@
EVENT_P2P_REPLICATOR_STATUS,
} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
import type { P2PReplicatorStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator";
import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { $msg as translateMessage } from "@/common/translation";
interface Props {
getLiveSyncReplicator: () => LiveSyncTrysteroReplicator;
p2p: P2PServiceViews;
showBroadcastToggle?: boolean;
core?: LiveSyncBaseCore;
}
let { getLiveSyncReplicator, showBroadcastToggle = true, core }: Props = $props();
let { p2p, showBroadcastToggle = true, core }: Props = $props();
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
let replicatorStatus = $state<P2PReplicatorStatus | undefined>(undefined);
// Later setting changes arrive through EVENT_SETTING_SAVED; these values only seed local state at mount time.
@@ -31,25 +31,25 @@
let useDiagRTC = $state<boolean>(initialSettings?.P2P_useDiagRTC ?? false);
async function requestServerStatus() {
await Promise.resolve(getLiveSyncReplicator().requestStatus());
p2p.diagnostics.requestStatus();
eventHub.emitEvent(EVENT_REQUEST_STATUS);
}
async function onOpenConnection() {
await getLiveSyncReplicator().makeSureOpened();
await p2p.transportLifecycle.connect();
await requestServerStatus();
}
async function onDisconnect() {
await getLiveSyncReplicator().close();
await p2p.transportLifecycle.disconnect();
await requestServerStatus();
}
function toggleBroadcast() {
if (replicatorStatus?.isBroadcasting) {
getLiveSyncReplicator().disableBroadcastChanges();
p2p.changeRelay.disableBroadcastChanges();
} else {
getLiveSyncReplicator().enableBroadcastChanges();
p2p.changeRelay.enableBroadcastChanges();
}
}
@@ -8,7 +8,7 @@
EVENT_P2P_REPLICATOR_PROGRESS,
type P2PServerInfo,
} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
import type { P2PReplicatorStatus, P2PReplicationReport } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator";
import { delay, fireAndForget } from "octagonal-wheels/promises";
import P2PServerStatusCard from "./P2PServerStatusCard.svelte";
@@ -23,7 +23,6 @@
} from "@vrtmrz/livesync-commonlib/remote-configurations";
import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { SetupManager } from "@/modules/features/SetupManager";
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
import { Menu } from "@/deps";
import { $msg as translateMessage } from "@/common/translation";
import {
@@ -33,11 +32,11 @@
} from "./p2pPeerSettings";
interface Props {
getLiveSyncReplicator: () => LiveSyncTrysteroReplicator;
p2p: P2PServiceViews;
core: LiveSyncBaseCore;
}
let { getLiveSyncReplicator, core }: Props = $props();
let { p2p, core }: Props = $props();
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
let decidingPeerId = $state<string | null>(null);
@@ -121,7 +120,7 @@
}
async function requestServerStatus() {
await getLiveSyncReplicator().requestStatus();
p2p.diagnostics.requestStatus();
eventHub.emitEvent(EVENT_REQUEST_STATUS);
}
@@ -213,9 +212,8 @@
async function createAndSelectP2PRemote() {
const setupManager = core.getModule(SetupManager);
const dialogManager = setupManager.dialogManager;
const currentSettings = core.services.setting.currentSettings();
const p2pConf = await dialogManager.openWithExplicitCancel(SetupRemoteP2P, currentSettings);
const p2pConf = await setupManager.openP2PSetup(currentSettings);
if (p2pConf === "cancelled" || typeof p2pConf !== "object" || !p2pConf) {
return;
}
@@ -296,7 +294,7 @@
) {
decidingPeerId = peer.peerId;
try {
await getLiveSyncReplicator().makeDecision({
await p2p.peerAdmission.makeDecision({
peerId: peer.peerId,
name: peer.name,
decision,
@@ -311,7 +309,7 @@
async function revokeDecision(peer: P2PServerInfo["knownAdvertisements"][number]) {
decidingPeerId = peer.peerId;
try {
await getLiveSyncReplicator().revokeDecision({
await p2p.peerAdmission.revokeDecision({
peerId: peer.peerId,
name: peer.name,
});
@@ -324,10 +322,7 @@
async function startReplication(peer: P2PServerInfo["knownAdvertisements"][number]) {
replicatingPeerId = peer.peerId;
try {
const pullResult = await getLiveSyncReplicator().replicateFrom(peer.peerId, true);
if (pullResult?.ok) {
await getLiveSyncReplicator().requestSynchroniseToPeer(peer.peerId);
}
await p2p.targetedTransfer.synchroniseWithPeer(peer.peerId, true);
await requestServerStatus();
} finally {
replicatingPeerId = null;
@@ -347,9 +342,9 @@
return;
}
if (isWatching(peerId)) {
getLiveSyncReplicator().unwatchPeer(peerId);
p2p.changeRelay.unwatchPeer(peerId);
} else {
getLiveSyncReplicator().watchPeer(peerId);
p2p.changeRelay.watchPeer(peerId);
}
}
@@ -455,7 +450,7 @@
</p>
{/if}
<P2PServerStatusCard {getLiveSyncReplicator} {core} />
<P2PServerStatusCard {p2p} {core} />
<div class="peers-section">
<div class="peers-header">

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