Compare commits

..
Author SHA1 Message Date
vorotamoroz c6a964548a docs: define synchronising devices 2026-08-19 07:34:19 +00:00
vorotamoroz d69cff0bc6 Merge pull request #1120 from vrtmrz/fix/document-history-restoration
Persist Document History restorations before Vault reflection
2026-08-19 15:39:27 +09:00
vorotamoroz f02470ec5c docs: define independent revision properties 2026-08-19 06:25:00 +00:00
vorotamoroz a0684c4b3a fix: use commonlib 0.1.17 for history restoration 2026-08-19 06:25:00 +00:00
vorotamoroz dc5274df27 fix: persist document history restoration in the database 2026-08-19 06:24:50 +00:00
vorotamoroz a6c93c358e Merge pull request #1117 from vrtmrz/fix/couchdb-remote-connection-ownership
Record CouchDB remote connection ownership
2026-08-18 19:41:37 +09:00
vorotamoroz d9df2a859f docs: record pending CouchDB robustness improvement 2026-08-18 09:46:44 +00:00
vorotamoroz b7c2512da6 chore: adopt Commonlib 0.1.16 2026-08-18 09:17:22 +00:00
vorotamoroz 97b0ec25c7 docs: record CouchDB remote connection ownership 2026-08-18 07:52:16 +00:00
vorotamoroz bc41355a74 Merge pull request #1115 from vrtmrz/1_0_15
Releasing 1.0.15
2026-08-16 00:28:55 +09:00
github-actions[bot] 3d69142a52 Releasing 1.0.15 2026-08-15 15:05:11 +00:00
vorotamoroz 2403a66441 Merge pull request #1114 from vrtmrz/release-notes/1.0.15
Add 1.0.15 release notes
2026-08-16 00:03:46 +09:00
vorotamoroz 54e20dede3 docs: add 1.0.15 release notes 2026-08-15 14:57:29 +00:00
vorotamoroz 277091c1e4 Merge pull request #1113 from vrtmrz/docs/clarify-couchdb-provisioning-credentials
Clarify CouchDB setup credentials
2026-08-15 23:50:29 +09:00
vorotamoroz e294747557 Clarify CouchDB setup credentials 2026-08-15 14:38:28 +00:00
vorotamoroz 3062d45dc5 Merge pull request #1106 from nimula/i18n/zh-tw-catalogue-coverage
i18n(zh-tw): complete the catalogue coverage
2026-08-15 23:08:40 +09:00
vorotamoroz 8e14bc35d2 Merge pull request #1112 from vrtmrz/chore/commonlib-0.1.15
Update Commonlib to 0.1.15
2026-08-15 22:43:14 +09:00
vorotamoroz a07082d7bc Update Commonlib to 0.1.15 2026-08-15 13:22:47 +00:00
vorotamoroz 0b31b84598 Merge pull request #1111 from vrtmrz/1_0_14
Releasing 1.0.14
2026-08-14 22:06:44 +09:00
github-actions[bot] 3a4f84443c Releasing 1.0.14 2026-08-14 12:21:36 +00:00
vorotamoroz a5e7ec6546 Merge pull request #1110 from vrtmrz/release-notes/1.0.14
Archive earlier 1.0 release notes
2026-08-14 21:13:55 +09:00
vorotamoroz 80fc493d0e docs: rotate 1.0 release notes 2026-08-14 11:21:45 +00:00
vorotamoroz 7cff820aa6 Merge pull request #1108 from vrtmrz/fix/finite-remote-connection-ownership
Close finite remote database connections owned by LiveSync
2026-08-14 19:55:38 +09:00
vorotamoroz 5fdac724cf Update Commonlib to 0.1.14 2026-08-14 10:40:31 +00:00
vorotamoroz 74fdec7f96 test: separate finite maintenance connection ownership 2026-08-14 10:29:29 +00:00
vorotamoroz de03534d2f fix: close finite remote database connections 2026-08-14 10:29:29 +00:00
vorotamoroz 16c7cc1b0f Merge pull request #1109 from vrtmrz/experiment/community-review-npm10-lockfile
Fix Community Review dependency installation under npm 10
2026-08-14 19:27:52 +09:00
vorotamoroz 0aec7b8cca Document Community Review install compatibility 2026-08-14 10:18:04 +00:00
vorotamoroz 040ce87b2f Make lockfile compatible with npm 10 2026-08-14 09:55:55 +00:00
vorotamoroz a4194a8978 Merge pull request #1107 from vrtmrz/update/commonlib-0.1.13
Update Commonlib to 0.1.13
2026-08-14 11:19:50 +09:00
vorotamoroz 1e9ab39adc Update Commonlib to 0.1.13 2026-08-13 15:43:40 +00:00
nimulaandClaude Sonnet 5 0a1e1f3625 i18n(zh-tw): polish translation naturalness and terminology consistency
- Unify 資料庫適配器 -> 資料庫轉接器 across 6 entries so the database
  adapter setting uses one term throughout the catalogue.
- Drop the calqued "透過⋯⋯來⋯⋯" construction in 3 entries (Testing-only
  conflict resolution, the P2P replicator description, and the
  duplicated garbage-collection cleanup description); the sentences
  read the same without the borrowed English "by X to Y" shape.
- Rename the Eden-chunk "Incubate" feature's label from 孵化 (hatch) to
  暫存 (temporarily store) across 7 entries, matching the wording
  already used in the toggle's own description and avoiding a
  half-metaphor/half-literal split within the same feature.
- Convert the ASCII ellipsis "..." to the full-width "⋯" in 22
  in-progress/loading strings, per Taiwanese typography convention;
  the English source keys (which legitimately end in "...") are
  untouched.
- Reword "Fetch database with previous behaviour" to avoid a 以前/以
  + 前一種 parsing ambiguity, matching the 先前的行為 wording already
  used by the sibling 保留先前的行為 entry.
- Drop the orphaned closing paren in errMaxDocumentSize/errMaxRequestSize,
  fixing a display defect inherited from the English source.
- Add 同步 to beforeLiveSync for clarity.

Regenerated messagesJson/zh-tw.json and combinedMessages.prod.ts via
`npm run i18n:bake`; no other locale's entries changed.

Verified with tsc-check, lint, lint:community, lint:community:tools,
svelte-check, build, check:compatibility, and test:unit (630 tests,
88 files) — all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 15:18:35 +08:00
nimulaandClaude Opus 5 b939687cb3 i18n(zh-tw): complete the catalogue coverage
Continues the zh-tw work from commit 0f50f290, which only translated the
190 keys `$msg()` calls in `src/` resolved through the catalogue at the
time. This closes the remaining gap: 573 of 1450 keys (39.5%) to 1446 of
1450 (99.7%), matching es (99.9%) and comfortably ahead of every other
locale except it.

While re-scanning to find the next batch, the earlier "code-referenced"
count turned out to be wrong: the previous grep only matched single-line
`$msg("literal")` calls and missed multi-line calls and the `msg`, `_msg`,
and `translateMessage` aliases various Svelte components import (e.g.
`import { $msg as msg } from "@/common/translation"`). A corrected scan
found 624 referenced keys, not 309, of which 350 were still untranslated —
mostly the P2P Sync status panes and several SetupWizard dialogues. Since
this pass translates everything remaining regardless of reference status,
that gap closes as a side effect; it is called out here only because it
explains why some of the newly translated strings are already visible in
today's UI rather than being pure catalogue completeness.

- Translated by area: P2P Sync replicator panes and the `P2P.*` runtime
  messages; ConfigSync's plugin/snippet/theme comparison UI; the
  SetupWizard's remote-setup, rebuild/fetch, and outro dialogues (both the
  legacy flat-key strings and the newer `Ui.SetupWizard.*` Svelte
  component keys); the Svelte-based `Ui.Settings.*` settings panels
  (Maintenance, Patches, Hatch, Remote, Common, Selector, PowerUsers,
  CustomisationSync, Advanced, Setup, SyncSettings); Setting Doctor
  (`Doctor.*`); the remote-size warning flow (`moduleCheckRemoteSize.*`);
  the redflag3 fetch-method chooser (`RedFlag.*`); the remaining
  `moduleMigration.*`, `liveSyncReplicator.*`, `Replicator.*`, and
  `TweakMismatchResolve.*` entries; and the rest of the plain
  English-sentence keys, including the language-picker's native-script
  entries (added `lang-he: עברית`, matching how `lang-de`/`lang-ja`/etc.
  already show each language's own name rather than a Chinese gloss).
- Wording follows docs/terms.md and the register already established in
  this file: `Setup URI`, `Vault`, `chunk(s)`, `Peer`, `Peer-to-Peer`,
  `Hatch`, and `CouchDB` stay untranslated as proper nouns; `resolved`
  (device/database state) is consistently 已處理, matching the existing
  Scram-dialogue usage.
- `${...}` placeholders and `%{...}` keyword references are kept
  byte-identical to the English source, including the multi-paragraph
  `RedFlag.Fetch.Method.Desc` and `moduleCheckRemoteSize.msgDatabaseGrowing`
  callout blocks; verified programmatically against all 1446 zh-tw
  entries.
- 4 keys are still deliberately left untranslated, unchanged from the
  previous commit: `moduleMigration.docUri` and
  `obsidianLiveSyncSettingTab.linkTroubleshooting` are a URL and a doc
  path, `obsidianLiveSyncSettingTab.logServerConfigurationCheck`'s English
  value is the key name itself, and `moduleLiveSyncMain.logPluginVersion`
  is a version-number template.
- Regenerated `messagesJson/zh-tw.json` and `combinedMessages.prod.ts`
  through the documented `i18n:bake` pipeline; no other language's
  entries changed.

Verified with tsc-check, tsc-check:apps, lint, lint:community,
lint:community:tools, svelte-check, check:compatibility, and test:unit
(604 tests, 86 files) — all pass.

Not in scope, unchanged from the previous commit: the ~137 Commonlib-owned
setting labels/descriptions never reach any translator because
`getConfig()` call sites in SettingDialogue omit the `translate` argument;
German is staged in the catalogue but never imported by
`combinedMessages.dev.ts`; Hebrew keyword references are never expanded by
`expandKeywords()`. None of these are specific to zh-tw and none are
touched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:18:35 +08:00
nimulaandClaude Opus 5 df8c79538e i18n(zh-tw): translate the keys the application actually renders
The zh-tw catalogue was the weakest of the ten supported locales at 380 of
1450 keys (26%), well behind zh (75%) and ja (57%). Users who select
繁體中文 saw English for most of the interface.

This does not attempt full parity. It closes the gap for the 190 keys that
`$msg()` calls in `src/` actually resolve through the catalogue (zh-tw had
no entry for any of them), plus the 7 `K.*` keyword fragments that
`expandKeywords()` substitutes into `%{...}` references at build time
(zh-tw had none of these either, so any zh-tw message referencing `%{Peer}`
etc. would have shown the raw token on screen — the same defect currently
affecting `he`).

- 186 of the 190 code-referenced keys are translated. The remaining 4 are
  left as-is because their English value is not translatable prose:
  `moduleMigration.docUri` and `obsidianLiveSyncSettingTab.linkTroubleshooting`
  are a URL and a doc path, `obsidianLiveSyncSettingTab.logServerConfigurationCheck`'s
  English value is the key name itself, and `moduleLiveSyncMain.logPluginVersion`
  is a version-number template.
- Two pre-existing entries are corrected for internal consistency: `Bucket
  Name` (儲存桶→儲存庫) and `Peer-to-Peer Synchronisation` (點對點同步→
  Peer-to-Peer 同步), the latter matching the other three occurrences of
  "Peer-to-Peer" already left untranslated in this same file.
- Wording follows docs/terms.md and the existing zh-tw register: `Setup
  URI`, `Vault`, `chunk(s)`, `Hatch`, and `Peer-to-Peer` stay untranslated
  as proper nouns, matching how the rest of the file already treats them.
- `${...}` placeholders and `%{...}` keyword references are kept
  byte-identical to the English source; verified programmatically against
  all 573 zh-tw entries, not just the new ones.
- Regenerated `messagesJson/zh-tw.json` and `combinedMessages.prod.ts`
  through the documented `i18n:bake` pipeline; no other language's entries
  changed.

Coverage: 380/1450 (26.2%) -> 573/1450 (39.5%).

Verified with tsc-check, tsc-check:apps, lint, lint:community,
lint:community:tools, svelte-check, check:compatibility, and test:unit
(604 tests, 86 files) — all pass.

Not in scope, left for follow-up: the ~137 Commonlib-owned setting
labels/descriptions never reach any translator because `getConfig()` call
sites in SettingDialogue omit the `translate` argument; German is staged
in the catalogue but never imported by `combinedMessages.dev.ts`; Hebrew
keyword references are never expanded by `expandKeywords()`. None of these
are specific to zh-tw and none are touched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:18:35 +08:00
vorotamoroz 3973290485 Add note: Fix Fast Fetch tombstone handling for deletions
Fast Fetch now correctly handles deletion tombstones by writing them to the local database without decryption, allowing new devices to sync with vaults containing old deletions.
2026-08-13 14:38:18 +09:00
vorotamoroz 510db277cd Merge pull request #1105 from vrtmrz/1_0_13
Releasing 1.0.13
2026-08-13 13:01:17 +09:00
github-actions[bot] b3bf717947 Releasing 1.0.13 2026-08-13 02:48:28 +00:00
vorotamoroz 93f0f78494 Merge pull request #1104 from vrtmrz/release-notes/1.0.13
Add Metadata document ID repair release note
2026-08-13 11:46:39 +09:00
vorotamoroz 11cb09d49a docs: add metadata repair release note 2026-08-13 02:42:18 +00:00
vorotamoroz e0160f15f3 Merge pull request #1103 from vrtmrz/docs/clarify-metadata-identity-handling
Clarify Metadata document ID mismatch handling
2026-08-13 11:31:18 +09:00
vorotamoroz a3aa79ead9 docs: clarify metadata identity mismatch handling 2026-08-13 02:22:34 +00:00
vorotamoroz db8dac2db4 Merge pull request #1102 from vrtmrz/fix/metadata-document-id-repair
Add safe Metadata document ID repair
2026-08-13 11:13:13 +09:00
vorotamoroz c933674a0d Add safe Metadata document ID repair 2026-08-12 18:56:17 +00:00
vorotamoroz eda78dee36 Merge pull request #1098 from vrtmrz/1_0_12
Releasing 1.0.12
2026-08-11 18:18:44 +09:00
vorotamoroz 67c8424231 Polish 1.0.12 release notes 2026-08-11 03:37:13 +00:00
github-actions[bot] 1735bc4d66 Releasing 1.0.12 2026-08-11 03:29:34 +00:00
vorotamoroz d11a92498d Merge pull request #1097 from vrtmrz/update/commonlib-0.1.11
Update Commonlib to 0.1.11
2026-08-11 12:06:49 +09:00
vorotamoroz 950623f5d6 Update Commonlib to 0.1.11 2026-08-11 02:54:07 +00:00
vorotamoroz bb72b6b317 Merge pull request #991 from odpay/docs/zenith-badge
docs: add managed CouchDB hosting option
2026-08-11 10:53:53 +09:00
vorotamoroz 769b7ff4b6 Merge pull request #1095 from vrtmrz/1_0_11
Releasing 1.0.11
2026-08-10 01:10:53 +09:00
github-actions[bot] 1bb50c1580 Releasing 1.0.11 2026-08-09 14:49:28 +00:00
vorotamoroz d17330f3c7 Merge pull request #1094 from vrtmrz/fix/issue-1020-fast-fetch-transport
Fall back to Standard Fetch for CouchDB's internal request API
2026-08-09 23:29:40 +09:00
vorotamoroz bd45924649 Use Commonlib 0.1.10 for buffered Fast Fetch fallback 2026-08-09 12:52:03 +00:00
vorotamoroz b1ad3e0653 docs: define Fast Fetch transport eligibility 2026-08-09 11:37:14 +00:00
vorotamoroz 6b94f0ce47 Merge pull request #1039 from andrewleech/fix/float-mtime-mobile-crash
fix: prevent float mtime from crashing mobile clients
2026-08-09 19:04:35 +09:00
vorotamoroz 08b55b7677 docs: note fractional timestamp fix 2026-08-09 09:14:54 +00:00
vorotamoroz 21d904cfd6 fix(storage): adapt timestamp guard to current Commonlib
Use the packaged Commonlib type import and add regression coverage for every Obsidian vault and storage write method. Verify that fractional timestamps are floored without mutating caller-owned options.
2026-08-09 08:46:21 +00:00
vorotamoroz 00de35e5d4 Merge current main into PR 1039 2026-08-09 08:45:16 +00:00
vorotamoroz f2976bc89a Merge pull request #1093 from vrtmrz/1_0_10
Releasing 1.0.10
2026-08-09 13:19:50 +09:00
odpay 34ae802796 docs: add managed CouchDB hosting option 2026-08-03 16:26:10 +10:00
Andrew Leech a3a09df3c8 fix(storage): floor write-option timestamps in Obsidian adapters
Obsidian's mobile storage layer forwards mtime/ctime to Capacitor's
Filesystem.setTimes, whose native binding casts the value to a Java Long.
A non-integer (float) timestamp makes that cast throw (ClassCastException:
Double cannot be cast to Long), which crashes the app on launch as soon as
such a document is replicated in.

Float timestamps can reach the database from any client that stores
fs.Stats.mtimeMs without flooring. Coerce mtime/ctime to integer ms at the
Obsidian vault and storage adapter write boundary, so a float already
present in the mesh can't crash the app regardless of where it came from.

The truly central choke point is dbToStorage in livesync-commonlib; a
matching guard there would cover the CLI and webapp too. This change
protects the platform that actually crashes.

Claude-Session: https://claude.ai/code/session_0123E9jVQrsgu3zb82Csuwhi
2026-07-22 14:08:21 +10:00
Andrew Leech 4393a49cba fix(cli): floor watch-mode stat timestamps to integer ms
CLIWatchAdapter._toNodeFile passed chokidar's raw stats.ctimeMs/mtimeMs
straight through. On Linux those carry sub-millisecond precision (e.g.
1778511180024.462), so watch-mode file changes wrote non-integer
timestamps into the database.

The earlier floor fix (3f7bb047) covered the scan/stat adapters but missed
this watch path, which is the one the daemon actually uses at runtime.
Mobile clients then crash on such a document because Capacitor's
Filesystem.setTimes casts the value to a Java Long (ClassCastException:
Double cannot be cast to Long).

Floor ctimeMs/mtimeMs here, with a null guard so a partial stat still
falls back to Date.now(). Adds a regression test that a fractional stat is
floored.

Claude-Session: https://claude.ai/code/session_0123E9jVQrsgu3zb82Csuwhi
2026-07-22 14:08:21 +10:00
59 changed files with 6618 additions and 455 deletions
+7
View File
@@ -68,6 +68,13 @@ Each workflow establishes ordinary note synchronisation on the first device, gen
> CouchDB can also be run on a Raspberry Pi (please be mindful of your server's security).
### Third-party managed CouchDB hosting
> [!NOTE]
> The following is a third-party hosting option proposed by Zenith Hosting. It is not an official Self-hosted LiveSync service, and it is neither endorsed nor recommended by this project.
If you would rather not set up and maintain a server yourself, Zenith Hosting offers a managed CouchDB server which this plug-in can be configured to connect to: [Zenith Hosting](https://zenith.hosting/host/obsidian-livesync). As with any hosted service, your data will reside on a server operated by a third party, so please consider whether that is acceptable for your vault before using it.
## Information in the Status Bar
Synchronisation status is shown in the status bar with the following icons.
+16
View File
@@ -27,6 +27,21 @@ npm ci
npm run build
```
#### Community Review dependency installation
Community Review installs dependencies independently before applying type-aware source rules. A successful installation with the npm version bundled with the repository's current Node.js CI does not prove that the lockfile is accepted by the scanner's npm version.
After changing `package.json`, a workspace manifest, or `package-lock.json`, verify both installation paths:
```bash
npm ci --ignore-scripts
npx --yes npm@10.9.2 ci --ignore-scripts
```
The npm 10.9.2 command is the current project-side compatibility check for the Community Review installation path. Update this check when the scanner runtime changes.
If Community Review reports widespread TypeScript `error` types across unrelated external packages, confirm that dependency installation completed successfully before changing source imports, declarations, or lint rules. An installation failure can make every unresolved external type appear as downstream unsafe-type findings.
### Commands
```bash
@@ -261,6 +276,7 @@ export class ModuleExample extends AbstractObsidianModule {
- Avoid listing purely internal refactors, maintenance chores, generated-file changes, and dependency updates unless they affect users; group and label them when they are included.
- When preparing a release, replace `## Unreleased` with the target version heading (for example, `## 0.25.81`) and add a fresh empty `## Unreleased` section above it for the next cycle.
- Review and polish the released section in the release PR before tagging, because the content is embedded into the plug-in and may be reused as the GitHub Release notes.
- Keep approximately the five most recent published plug-in versions in the embedded `updates.md`. Move older published sections unchanged into the appropriate release-line archive under `docs/releases/`, and update the history references when rotating them.
## Release Workflow
@@ -0,0 +1,329 @@
# Architectural Decision Record: CouchDB Remote Connection Ownership
## Status
Accepted. The first implementation is deliberately limited to the
abort-capable CouchDB connectivity preflight used by one-shot replication.
## Context
Commonlib opens a remote CouchDB database through `RemoteService.connect()`.
Before this decision, a successful call returned only a PouchDB handle and an
information snapshot:
```typescript
{
db: PouchDB.Database<EntryDoc>;
info: PouchDB.Core.DatabaseInfo;
}
```
The value did not state who must close the handle or how requests which outlive
the current operation are cancelled. Callers consequently accumulated their
own `try`/`finally` blocks and closing helpers. Commonlib PR 112 made finite
handle clean-up substantially safer, but closing a raw PouchDB handle still did
not define ownership of its outstanding HTTP work.
A remote PouchDB HTTP handle is not a dedicated socket. `db.close()` emits
PouchDB's normal `closed` event and closes the logical handle, but the HTTP
adapter does not establish that a pending browser fetch or response-body read
has stopped. `RemoteService.performFetch()` also records a physical request as
complete once response headers have arrived, while PouchDB may still be reading
and parsing the body. Neither the request counters nor raw `db.close()` can
therefore prove that the transport work has settled.
One-shot CouchDB replication performs the following work before it creates the
replication controller:
1. prepare the encryption security seed;
2. construct the remote PouchDB handle and read database information;
3. check and, where required, migrate the remote database version;
4. read and update compatibility Metadata, including the milestone document;
and
5. create the PouchDB replication operation.
The controller owned by `processSync()` begins only at step 5. A request which
never settles during steps 2 to 4 is outside that cancellation scope. The
shared one-shot result remains pending as well, so later triggers join the same
pending operation rather than beginning a fresh attempt.
Self-hosted LiveSync issue 1116 provides evidence of this failure shape. On one
Linux and Electron combination, a one-shot attempt remained pending while
writing the remote milestone document. Bypassing that write allowed the
attempt to reach later replication requests. A local real-Obsidian exercise
reproduced the preceding Fast Fetch state but not the indefinite write. The
evidence does not establish a milestone-specific defect, a CouchDB defect, a
browser connection-pool defect, or a lock cycle. It does establish that the
connectivity preflight lacks an owner which can terminate its abort-capable
transport work.
No code-level circular wait has been identified. `shareRunningResult()` shares
a logical promise, the remote-activity counters observe work, and the global
replication concurrency controller is entered only after the preflight. Adding
a semaphore or another connection lock would let a stalled request retain the
permit; it would not make that request settle.
## Decision
### Extend the existing connection result
Commonlib will define the owned connection as the existing flat result with one
additional operation:
```typescript
interface RemoteConnectionOpenOptions {
readonly signal?: AbortSignal;
readonly allowNativeFallback?: boolean;
}
interface OwnedCouchDBConnection<T extends object> {
readonly db: PouchDB.Database<T>;
readonly info: PouchDB.Core.DatabaseInfo;
close(): Promise<void>;
}
interface CouchDBReplicationConnection extends OwnedCouchDBConnection<EntryDoc> {
readonly syncOptionBase: PouchDB.Replication.SyncOptions;
readonly syncOption: PouchDB.Replication.SyncOptions;
}
```
`RemoteService.connect()` remains the entry point. It returns
`OwnedCouchDBConnection` directly; there is no nested resource wrapper, separate
lease API, public connection signal, or public `abort()` operation. The
connection and checked replication types are owned and documented by
Commonlib. Compatibility checks enrich the same connection with replication
options instead of creating another lifetime object.
The following properties are part of the contract:
- `close()` is idempotent;
- `close()` first cancels abort-capable requests scoped to the connection, then
calls PouchDB's ordinary `db.close()`;
- PouchDB retains its normal `closed` event behaviour;
- the optional input signal cancels the same internal request scope;
- skipping the information request retains the established placeholder in
`info` for source and behaviour compatibility;
- a close failure is reported diagnostically but does not replace the primary
replication result; and
- ownership of the connection does not imply ownership of a dedicated physical
HTTP socket.
The public connection does not expose its internal signal because no caller
needs to make a second shutdown decision. Owners either cancel through the
input signal or finish through `close()`. This leaves one operation responsible
for final clean-up.
Replacing the existing error-string union with a typed connection-failure
result remains desirable, but it is outside this change. Mixing that migration
into the first implementation would enlarge the consumer and user-message
surface without improving cancellation.
### Bind cancellation at the custom fetch boundary
`RemoteService.connect()` creates its internal request scope before PouchDB is
constructed, so the initial `db.info()` request is covered. The custom PouchDB
fetch implementation combines, without replacing, both applicable signals:
- the signal supplied by PouchDB for the individual request; and
- the signal for the owned connection, which follows its optional owner signal.
The combined signal remains applicable while the response body is consumed.
Receiving response headers is not the end of cancellation ownership.
Cancellation is not a CORS failure. If the connection scope has been aborted,
the request must not enter the diagnostic fallback from the web-compatible
fetch path to the native request API. A bounded owner can also disable that
fallback explicitly.
The web-compatible fetch path honours `AbortSignal`. Obsidian's current native
`requestUrl` adapter does not expose physical cancellation through
`RequestInit.signal`. The implementation must not use `Promise.race()` to
declare a native remote write cancelled while it may still complete. A bounded
guarantee therefore applies only where the selected request path remains
abort-capable.
### Transfer the same connection between owners
At every point, exactly one operation is responsible for calling `close()`:
1. `RemoteService.connect()` owns the connection until it returns successfully;
2. the connectivity preflight owns it while checking version and compatibility;
3. a successful preflight transfers the same connection to the one-shot or
continuous replication operation; and
4. the final replication owner closes it after replication settles or is
terminated.
A failed factory or preflight closes the connection in its own failure path. A
successful transfer clears the former owner's deadline before replication
continues. Borrowing `connection.db` does not transfer close responsibility.
`shareRunningResult()` owns no connection. It may share the result of an
operation which owns one, but that operation must settle and close the
connection before the shared entry can be released for a later attempt.
## Limited Introduction
The first bounded consumer is the CouchDB connectivity preflight reached from
one-shot replication. Its boundary includes:
- PouchDB construction and the initial `db.info()` request;
- the database-version check and migration negotiation; and
- compatibility and milestone reads and writes before replication starts.
The preflight receives an internal 60-second wall-clock deadline. This is a
last-resort safety fuse for an owner which would otherwise remain pending
indefinitely. It is not the expected completion time, a service-level target, a
per-request inactivity timeout, a user setting, a limit on replication
duration, or the `useTimeouts` changes-feed setting. Tests inject a shorter
deadline.
When that deadline expires on the web-compatible path:
1. the owner signal aborts the connection's request scope;
2. the preflight closes the connection;
3. no PouchDB replication operation is created;
4. the shared one-shot result settles as failed; and
5. a later trigger may create a fresh connection and attempt.
On success, the deadline is cleared before the connection is transferred to
replication, so an old timer cannot interrupt a healthy long-running transfer.
The explicitly selected native Request API retains its previous unbounded
behaviour because its host adapter cannot honour transport cancellation. The
security-seed preparation which precedes the shared one-shot operation is also
outside this first boundary. Fast Fetch, setup probes, maintenance commands,
status inspection, and other direct CouchDB consumers retain their existing
deadline and retry policies. They receive the additive `close()` contract but
are not silently given this one-shot deadline.
The first implementation does not add automatic retry. Retrying before the old
request is known to be cancelled could duplicate remote writes or consume more
connections without changing the failing condition.
## Ownership
The legacy `_ensureConnection()` method retains its raw PouchDB return type for
source compatibility. New Commonlib paths use an internal owned-connection
helper and do not discard the lifecycle object.
Commonlib owns:
- `OwnedCouchDBConnection`, `RemoteConnectionOpenOptions`, and
`CouchDBReplicationConnection`;
- composition of PouchDB request and connection cancellation signals;
- the PouchDB custom-fetch integration;
- idempotent connection close behaviour;
- ownership transfer within the CouchDB replicator; and
- timeout classification at the connectivity-preflight boundary.
Self-hosted LiveSync owns:
- the concrete Obsidian fetch adapters and their declared capabilities;
- user-facing logs or notices for a timed-out attempt;
- integration of an immutable Commonlib release; and
- real-Obsidian validation of the affected consumer path.
No host may claim physical cancellation unless its injected fetch
implementation honours the supplied signal.
## Non-Goals
This decision does not:
- identify the exact environmental cause reported in issue 1116;
- guarantee that a one-shot attempt succeeds;
- special-case the milestone document or its URL;
- impose a global connection limit or connection semaphore;
- add an automatic retry, fallback, or remote reconciliation policy;
- apply a deadline to ordinary replication, continuous changes feeds, Fast
Fetch, rebuilds, or bulk transfers;
- change CouchDB documents, checkpoints, encryption, the security seed, or
compatibility Metadata;
- make `close()` equivalent to closing a browser socket;
- detach a potentially mutating native request and report it as cancelled; or
- migrate every direct PouchDB borrower to the one-shot deadline policy.
## Alternatives Rejected
### Time out only the milestone write
The observed write is where one report stopped, not an established ownership
boundary. Another environment could stop at `db.info()`, version Metadata,
response-body parsing, or an adjacent compatibility request.
### Race the preflight without cancelling its transport
This would release `shareRunningResult()` while the old request remained able
to complete. It is especially unsafe for a remote `PUT`, because a later
attempt could begin after the first had been reported as failed.
### Add a global semaphore or lower the connection count
No connection-limit failure has been demonstrated. A stalled owner would
retain its permit indefinitely and turn an unexplained request into an explicit
queue deadlock.
### Add a separate lease wrapper
A nested `{ connection: { db, close }, info }` result would make ownership
visible, but it would duplicate the existing connection shape, force callers
through another projection, and expose lifetime operations which have no
consumer. Adding `close()` to the existing value preserves source compatibility
and keeps the PouchDB handle, information snapshot, and lifetime together.
### Share one global remote PouchDB handle
A singleton would couple setup, maintenance, one-shot, and continuous
lifecycles, make credential and setting changes harder to isolate, and turn one
stalled request into a process-wide resource.
## Verification
The regression tests were changed before the implementation. Against the old
flat result they demonstrated that:
- an owner signal left an in-flight request pending;
- the result had no `close()` operation capable of interrupting a body read;
- a bounded web-compatible request could enter the native fallback; and
- the one-shot path still depended on the discarded nested connection API.
After the change, focused Commonlib tests verify that:
- an owner signal settles a pending request;
- `close()` interrupts a response body read before closing PouchDB;
- repeated `close()` calls close the handle once;
- an aborted or bounded request does not enter the non-abortable native adapter;
- deadline expiry closes the same flat connection and releases the shared
one-shot attempt;
- a later invocation begins after that release;
- a successful preflight clears its deadline and transfers ownership; and
- close failures are logged without replacing timeout or replication results.
Type checking and package-boundary checks verify the Commonlib-owned
declarations and compatibility export. Self-hosted LiveSync must then validate
the exact packed Commonlib artefact with its focused consumer tests and an
ordinary real-Obsidian and CouchDB smoke test. The reporter's environment
remains the validation boundary for the original platform-specific symptom.
## Consequences
- A successful remote connection has one explicit close operation without a
parallel lease abstraction.
- Existing `{ db, info }` consumers remain source-compatible and may adopt
`close()` without changing their projections.
- One-shot connectivity can become a bounded failure on an abort-capable
transport instead of retaining the shared operation indefinitely.
- Native `requestUrl` cancellation remains an acknowledged gap rather than a
falsely satisfied contract.
- Other finite consumers can adopt owner signals and explicit `close()` one at
a time, with tests for their own side effects and retry policies.
## References
- [Bounded Remote Activity](2026_07_bounded_remote_activity.md)
- [Fast Fetch Persistence and Completion Semantics](2026_08_fast_fetch_persistence_and_completion.md)
- Commonlib PR 112, which closes finite remote PouchDB handles after their
logical owners settle
- Self-hosted LiveSync issue 1116, which reports a one-shot compatibility write
remaining pending on one Linux and Electron environment
@@ -0,0 +1,78 @@
# Architectural Decision Record: Fast Fetch Transport Eligibility
## Status
Accepted
## Context
Fast Fetch accelerates Fast Setup (Simple Fetch) by reading bounded pages from
CouchDB's continuous changes feed. It consumes each response incrementally,
persists documents while the page is still arriving, and cancels the underlying
request when the page completes or fails.
The CouchDB setting `useRequestAPI`, labelled 'Use Internal API', routes ordinary
replication through Obsidian's `requestUrl` API to avoid browser CORS
restrictions. This API exposes a completed response as text, JSON, or an
`ArrayBuffer`; it does not expose the network response progressively or accept
the Fetch API's `AbortSignal`. Wrapping its result in a `Response` does not
restore those transport properties.
Custom headers can cause a browser preflight, and an authenticating proxy may
reject that preflight before the requested header values are sent. Custom
headers do not, however, make Fast Fetch intrinsically incompatible. A server
with correctly configured CORS can accept the same headers through the ordinary
Fetch API and retain streaming behaviour.
Ordinary PouchDB replication has a different response contract. Standard Fetch
uses finite batches, while LiveSync uses long-poll responses whose change
payload is bounded by the replication batch size. Both can process each response
after it has completed and do not depend on progressively reading a
document-bearing continuous feed.
## Decision
Fast Fetch requires a Fetch-compatible transport which exposes the response
body progressively and honours request cancellation.
When `useRequestAPI` is enabled for a CouchDB remote, Fast Fetch falls back to
Standard Fetch before entering the Fast Fetch activity or resetting the local
database through the Fast Fetch path. The presence of custom headers alone does
not disable Fast Fetch. Once Standard Fetch resets the local database, it
invalidates any retained Fast Fetch checkpoint for that database.
Commonlib's Rebuilder owns this eligibility decision because it owns both Fast
Fetch and the existing Standard Fetch fallback. The streaming implementation
does not receive Obsidian's buffered request adapter, and LiveSync does not add
proxy-specific or Cloudflare-specific policy.
## Consequences
- Initial retrieval through Standard Fetch may be slower and issue more HTTP
requests because PouchDB uses the configured batch size, document retrieval,
and checkpoint operations. The decision does not assume that `requestUrl` is
faster; its benefit here is compatibility with connections which browser CORS
would otherwise reject.
- LiveSync remains supported with `useRequestAPI`. Its HTTP adapter uses
long-poll responses whose change payload is bounded by the replication batch
size, rather than the document-bearing stream required by Fast Fetch.
- A user whose server accepts the configured custom headers through correct
CORS handling can leave `useRequestAPI` disabled and continue to use Fast
Fetch.
- The decision can be revisited if Obsidian provides a progressively readable,
cancellable internal request API, or if a separately designed buffered
transport establishes explicit payload bounds and equivalent cancellation
semantics.
## Verification
Commonlib unit tests verify that `useRequestAPI` selects only the existing
Standard Fetch activity, does not invoke Streaming Fetch, and invalidates any
retained Fast Fetch checkpoint after the local database is reset. Existing
tests continue to verify that custom headers are passed to Fast Fetch when
`useRequestAPI` is disabled.
## References
- [Fast Fetch Persistence and Completion Semantics](2026_08_fast_fetch_persistence_and_completion.md)
- [Apache CouchDB changes-feed API](https://docs.couchdb.org/en/stable/api/database/changes.html)
+4
View File
@@ -141,6 +141,10 @@ This field stores an array of Chunk Document IDs.
\_id is generated based on the path of the Obsidian note.
The validation and explicit repair contract for normal-file Metadata whose
actual ID does not match the ID derived from its stored path is defined in
[Normal-file Metadata Document ID Validation and Repair](design_docs/metadata_document_id_validation_and_repair.md).
- If the path starts with `_`, it is converted to `/_` for convenience.
- If Case Sensitive is disabled, it is converted to lowercase.
@@ -0,0 +1,214 @@
# Document History Revision Restoration
## Status
Accepted for a limited implementation.
## Problem and scope
This document uses the independent revision properties defined under
[Revision](../terms.md#revision) and the general state model in
[Conflict resolution and revision provenance](../specs_conflict_resolution.md).
Document History can reconstruct an available historical revision from its
Chunks and write that content to the Vault through **Back to this revision**.
The current action writes directly through the storage adapter. It does not
create a new Metadata revision, clear a logical deletion through a successor,
or record which database revision produced the restored Vault file.
This leaves a logically deleted Metadata document at `deleted: true` after the
file has returned to the Vault. A later ordinary Vault save may create a
non-deleted successor, but restoration must not depend on an unrelated later
file event.
The same action does not inspect or preserve revision-tree intent explicitly
when conflicts exist. Document History currently displays the ancestry of the
PouchDB winner. It does not display the complete revision tree or the ancestry
of every conflict leaf.
This design covers restoration of one readable historical revision of a normal
Vault file. It creates a new non-deleted successor revision, reflects that exact
revision to the Vault, and leaves every other conflict branch available for the
existing conflict workflow.
## Evidence
A real-Obsidian exercise created a Markdown document, removed it through the
Vault API, and waited for LiveSync to store a logical-deletion successor. The
Metadata retained all referenced Chunks, and Document History could reconstruct
the deleted content.
Selecting **Back to this revision** restored the file and its exact bytes to the
Vault. The local database nevertheless retained the same deleted current
revision after file processing had settled. An additional ordinary Vault save
then created a new non-deleted successor. This demonstrates that content
reconstruction works and that the missing operation is the database-aware
restoration step.
## Revision-tree decision
The selected historical revision is the content source. It is not necessarily
a current leaf and is not used as the parent of the new write.
At the time of the restoration operation, LiveSync reads the current PouchDB
winner. The new revision is written as a child of that exact winner revision
and contains the selected historical content with no logical-deletion marker.
For an unconflicted logical deletion:
```text
A -- D (logically deleted winner) -- R (non-deleted restored successor)
```
For an existing conflict:
```text
A -- W (winner) -- R (restored successor)
\
C (existing conflict remains current)
```
Advancing the winner branch is the ordinary meaning of reverting its content.
The previous winner remains in revision history, while every other conflict
leaf remains available for conflict resolution. Restoration does not
manufacture an additional independent branch merely to retain the previous
winner as another current conflict.
The existing **Inspect conflicts and file/database differences** workflow owns
subsequent comparison and resolution of the restored revision and the remaining
conflict leaves. Document History supplies content which may no longer be a
current leaf; the Inspector operates on the current revision tree after that
content has been restored. Neither interface replaces the other.
## Persistence and reflection order
Restoration performs these steps in order:
1. read and reconstruct the exact selected historical revision;
2. read the current winner and use its exact revision as the write base;
3. create Chunks and conditionally write a new non-deleted Metadata revision
containing the selected content below that exact winner;
4. obtain the exact created revision from the database write;
5. reflect that exact revision to the Vault; and
6. record the reflected revision as the device-local file provenance.
The database write precedes Vault reflection. If the database write fails, the
Vault remains unchanged. If the new revision is stored but Vault reflection
fails, the restored revision remains in database history and the operation
reports that persistence completed without successful reflection. It does not
remove the new revision in an attempted rollback. Concurrent database activity
may subsequently change whether that stored revision is a current leaf or the
winner.
The new Metadata keeps the current document's creation time, records the
restoration as a new modification, and derives its size and type from the
reconstructed bytes. Reusing the historical modification time would make an
explicit present-day restoration appear older than concurrent changes and
would interact poorly with modification-time policies.
### Restoration state transition
The selected historical revision `H` supplies content, the winner `W` supplies
the conditional write base, and `R` is the non-deleted restored revision. These
are operation roles; winner, Vault-matching, and displayed remain independent
properties.
| Stage | Content source | Winner | Vault relationship |
| ---------------------------- | -------------- | -------------------------------------------- | -------------------------------------------------------------------- |
| Before restoration | `H` | current winner `W` | unchanged |
| After the conditional write | `H` | `R` immediately after writing | Vault state and displayed provenance remain unchanged |
| After exact Vault reflection | `H` | normally `R`; concurrent activity may differ | `R` is Vault-matching and displayed |
| After a reflection failure | `H` | depends on subsequent database activity | Vault state and displayed provenance remain unchanged; `R` is stored |
## Conflicts and concurrent changes
Existing conflict leaves are never deleted by restoration. When conflicts
remain after the new revision is written, LiveSync keeps the ordinary conflict
indicator and conflict-resolution workflow available. The interface explains
that restoration advances the currently shown branch and does not resolve the
other versions.
Document History does not perform a separate comparison with the winner which
was current when the dialogue opened. The conditional exact-base write uses an
ordinary PouchDB new edit. If that base is no longer a writable current leaf,
PouchDB rejects the write and the dialogue reports that the revision tree
changed and the operation should be retried. If the base remains current while
another conflict leaf appears, the write may succeed and both leaves remain
available.
Commonlib's existing `storeWithBaseRevision` operation cannot provide this
condition. Its force-write behaviour intentionally uses `new_edits: false` so
that conflict-preservation workflows can create a branch from a supplied
ancestor. The restoration path therefore uses a separate
`storeWithLiveBaseRevision` operation. That operation writes below the supplied
current leaf with ordinary PouchDB revision checking and never falls back to
the force path.
The action must not silently fall back to an unbased write after an exact-base
failure.
## History presentation boundary
The current slider continues to represent the available ancestry of the
PouchDB winner. Building a complete branch-aware history viewer would require
loading the ancestry of every current leaf, joining shared ancestors,
representing missing or compacted revisions, and adding an explicit
branch-selection interface. That work is not required to restore the history
currently shown.
When the document has conflicts, the dialogue may state that restoration will
create a new revision on the winner branch which is current when the action
runs, and leave the other versions unresolved. Detailed branch comparison
remains in the Inspector.
## Ownership
LiveSync owns:
- selection and reconstruction of the historical revision;
- the Document History user interaction and result messages;
- orchestration of the exact-base write and exact-revision reflection; and
- presentation of any remaining conflict state.
Commonlib owns:
- Chunk creation and Metadata persistence;
- `storeWithLiveBaseRevision`, which writes content as a normal child of an
exact current leaf and returns the created revision;
- rejecting an unavailable or stale base revision;
- reflecting an exact current leaf revision to storage; and
- recording device-local file-reflection provenance.
The implementation retains `storeWithBaseRevision` for the conflict workflows
which deliberately create branches. The new conditional operation is narrower
and is not a replacement for that existing behaviour.
## Non-goals
This change does not:
- identify the origin of malformed or doubled Metadata paths;
- turn Document History into a complete revision-tree viewer;
- select, merge, or discard existing conflict leaves;
- restore unavailable content whose Chunks cannot be reconstructed;
- mutate an old revision or clear its deletion marker in place;
- rebuild a local or remote database; or
- change automatic conflict-resolution policy.
## Verification
Focused tests cover:
- restoring readable content as a non-deleted child of a deleted winner;
- returning and reflecting the exact created revision;
- retaining every existing conflict leaf while advancing the winner branch;
- refusing an exact-base write when the base is no longer a current leaf;
- retaining the existing force-write behaviour for callers which deliberately
create a conflict branch;
- leaving the Vault unchanged when database persistence fails; and
- retaining the stored revision when subsequent Vault reflection fails.
The real-Obsidian regression exercise removes the additional normal Vault save
from the earlier reproduction. **Back to this revision** must itself produce a
new non-deleted successor revision, restore the exact content to the Vault, and
reopen Document History at that successor.
@@ -0,0 +1,187 @@
# Normal-file Metadata Document ID Validation and Repair
## Status
Accepted
## Problem and scope
A normal-file Metadata document is addressed by an ID derived from its recorded
Vault-relative path. Historical data can contain a readable Metadata document
whose actual local database ID no longer matches that derivation. An ordinary
path-based read then looks up a different ID. It may reach a separate,
consistently addressed Metadata document, or it may find no document at all;
it cannot reach the mismatched document which the Offline Scanner enumerated.
The mismatch can repeatedly produce failed reflection, and an offline-deletion
decision can be made before that failure. The scanner must therefore recognise
the mismatch before any file reflection, database deletion, expired-history
cleanup, or last-seen update.
This design covers ordinary Vault files. Hidden File Sync, Customisation Sync,
and the obsolete plug-in storage namespace retain their feature-specific
processing. A disagreement between the document-ID namespace and recorded-path
namespace is reported, but is not repaired by this workflow.
## Evidence and cause boundary
The reported data included readable paths which could be enumerated from
Metadata but could not be fetched again through the path-derived lookup. The
Vault also had a history of case changes in folder names. This is consistent
with an ID/path mismatch, but it does not prove whether a historical rename,
interrupted migration, or earlier path-setting change created it.
The repair workflow must not infer that the current path is authoritative merely
because it is readable. It is available only when the local evidence is
unambiguous and current.
## Identity invariant
For normal-file Metadata:
actualDocumentId === path2id(declaredPath)
The active path service owns the derivation. In particular,
handleFilenameCaseSensitive, usePathObfuscation, and the path-obfuscation
passphrase can change the expected ID. The E2EE Security Seed and Chunk settings
do not directly participate in this ID.
Inspection and repair use the current local path service. They do not query the
remote or decide whether this device's settings should become authoritative.
Commonlib recalculates the expected ID during its pre-mutation inspection, so a
local ID-derivation setting change makes an earlier approval stale. An
intentional whole-database change to ID-derivation settings requires the
established rebuild workflow, not this one-entry repair.
## Offline Scanner decision
The Offline Scanner validates each decoded Metadata document while its actual ID
is still available. It does this before target-file policy and path-keyed pair
construction.
- Consistent normal-file Metadata continues through the existing scan.
- Consistent special-namespace Metadata remains owned by its feature.
- An ID/path or namespace mismatch is left unchanged and does not enter pair
processing.
- If consistently addressed Metadata is selected for the same case-normalised
path, that Metadata and its storage file continue through the established
path-based scan. A stale enumerated document must not suppress this flow.
- If no consistently addressed Metadata is selected for that logical path, its
storage entry is also withheld. No storage write, database deletion, or
last-seen update is performed for that withheld path.
- Expired logical deletion history with an inconsistent identity is left
unchanged.
The ordinary scan still returns its established Boolean execution result. A
recognised mismatch is left unchanged and omitted before file-pair processing.
It therefore does not add a `FilePairProcessResult`, change the ordinary Boolean
scan contract, or change Fast Setup or CLI completion policy. Detailed
inspection remains separate.
## Inspection decision
`inspectMetadataDocumentIdentities` is read-only and enumerates the local
database by actual document ID. This is necessary because inspection through a
path-derived lookup cannot discover the mismatched source.
The existing **Inspect conflicts and file/database differences** interface shows
one card for each mismatch. It excludes that card's path from ordinary
path-based repair only when no consistently addressed Metadata document can be
resolved for the same logical path. A stale entry does not hide the normal
inspection of a resolvable entry. Multiple affected files are presented
separately; there is no batch repair.
A one-entry repair is offered only when all of these checks pass:
- the mismatch is within the normal-file namespace;
- the source is the current winner and has no conflict leaves;
- the recorded path is valid and selected by current synchronisation policy;
- one case-normalised path maps to one source under the active filename setting;
- only one mismatched source expects the target ID; and
- the target ID is absent, or contains an exact structural copy left by an
earlier attempt.
An exact structural copy has the same path, timestamps, size, type, Chunk
references, Eden data, and logical-deletion state. Inspection does not fetch
Chunk content or query the remote. Missing content remains the responsibility
of the existing file and Chunk repair tools.
## Repair decision
The user explicitly confirms one actual ID, expected ID, and source revision.
Commonlib then:
1. acquires the existing ordered document locks for the source and target IDs;
2. reruns the complete inspection and rejects stale or unsafe input;
3. reads the exact approved source revision;
4. removes the path from the Offline Scanner's durable last-seen map;
5. writes the expected target ID when it is absent;
6. reads the target back and verifies the exact structural copy;
7. writes a deletion revision for the source against the approved source
revision; and
8. returns control to LiveSync, which requests an ordinary Vault scan.
The repair result and the follow-up scan result remain separate. If the scan is
suspended, returns false, or raises an error after the source has been removed,
LiveSync reports that the identity repair completed and directs the operator to
run the ordinary scan separately. It does not describe the completed mutation
as a failed or rolled-back repair.
The target is always verified before the source is removed. If target creation
fails, the source remains. If source removal fails, the exact target remains and
the same one-entry action can finish the operation after a new inspection. This
retry property is an implementation safety guarantee, not a separate public
repair mode.
The target receives new CouchDB revision ancestry because ancestry cannot move
between document IDs. Users are told to back up the device, pause editing and
synchronisation on other devices, allow the change to replicate, and inspect
again.
## Case handling
The active handleFilenameCaseSensitive setting defines whether path claims are
folded before ambiguity is assessed. When case-insensitive handling is active,
a consistently addressed entry may continue through the existing path-based
flow even if a stale case variant is also reported. The stale entry is not
automatically selected or removed unless the one-entry repair preconditions
hold. When case-sensitive handling is active, intentional variants remain
distinct.
This workflow does not rename Vault files or folders, infer a preferred folder
name from one device, or coordinate a repair across devices. The repair changes
one local database and relies on ordinary replication afterwards. Other devices
must remain paused until that result has replicated and a new inspection is
clean.
For widespread cross-device naming differences, the operator must choose an
authoritative Vault, stop every participating device, correct its storage names
outside Obsidian, rebuild the central remote from that Vault, and reset the
other devices from the verified remote. During Fast Setup on an empty Vault,
there are no storage names to correct: the scanner reflects every consistently
addressable Metadata entry and reports only the references which remain
unresolved.
## Non-goals
This change does not:
- repair several entries automatically or in a batch;
- choose between competing case variants;
- rename storage files or folders;
- coordinate a distributed repair across devices;
- migrate an entire database after path-obfuscation or case-setting changes;
- repair special-namespace Metadata;
- reconstruct unavailable Chunk content;
- query or modify the remote directly; or
- change Fast Setup, daemon, or CLI completion policy.
## Verification
Focused Commonlib tests cover unresolved-identity exclusion before pair
construction, continued processing of a resolvable same-path entry, expired
logical-deletion retention, namespace routing, read-only actual-ID inspection,
repair preconditions, target-first ordering, stale approval, exact-target retry,
source preservation on failure, and last-seen clearing. LiveSync tests cover
selective presentation-path withholding, separate confirmation, cancellation,
and the ordinary scan request after a completed repair.
+22 -3
View File
@@ -36,7 +36,7 @@ The flag deliberately enables file logging, which may affect performance. Remove
Use this workflow when one file, or a small number of known files, has conflicts, missing chunks, or a difference between the current Vault file and the local LiveSync database. The inspection is device-local: it does not query a remote database or prove that another device has the same chunks.
The `Hatch` recovery controls are ordered by escalation. Running **Recreate chunks for current Vault files** again with unchanged chunk settings and file contents produces the same chunks, and does not alter the revision tree. **Inspect conflicts and file/database differences** then provides actions for exact revisions. **Resolve All conflicted files by the newer one** is last because it applies a modification-time policy in bulk and logically deletes every other live version.
The `Hatch` recovery controls are ordered by escalation. Running **Recreate chunks for current Vault files** again with unchanged chunk settings and file contents produces the same chunks, and does not alter the revision tree. **Inspect conflicts and file/database differences** then provides actions for exact revisions. **Resolve All conflicted files by the newer one** is last because it applies a modification-time policy in bulk and logically deletes every other current version.
1. Stop editing the affected file, pause replication on the participating devices, and keep a separate copy of every readable version.
2. If another device or backup has the intended content, preserve that copy before changing any revision.
@@ -52,7 +52,26 @@ The `Hatch` recovery controls are ordered by escalation. Running **Recreate chun
- **Apply logical deletion to Vault**, **Discard this branch**, and **Discard unreadable revision** are destructive decisions. Use them only after preserving every version which may still be needed.
7. Synchronise the healthy source if chunks were restored, scan again, and confirm that the expected conflict or difference has disappeared before resuming ordinary editing.
An absent Vault file and a logical-deletion winner already agree and do not require a repair card unless another live branch remains. If the scan reports many unrelated files, or the local database itself is incomplete or corrupt, stop the per-file workflow and use [Reset synchronisation on this device](#reset-synchronisation-on-this-device) from a trusted remote. If the central remote must instead be reconstructed from an authoritative Vault, use [Overwrite server data with this device's files](#overwrite-server-data-with-this-devices-files).
An absent Vault file and a logical-deletion winner already agree and do not require a repair card unless another conflict branch remains. If the scan reports many unrelated files, or the local database itself is incomplete or corrupt, stop the per-file workflow and use [Reset synchronisation on this device](#reset-synchronisation-on-this-device) from a trusted remote. If the central remote must instead be reconstructed from an authoritative Vault, use [Overwrite server data with this device's files](#overwrite-server-data-with-this-devices-files).
Metadata document-ID mismatches use a separate action in the same Inspector. Follow [Repair a Metadata document ID mismatch](#repair-a-metadata-document-id-mismatch) rather than applying a file revision by path.
## Repair a Metadata document ID mismatch
Use this workflow when **Inspect conflicts and file/database differences** reports `Metadata entry requires review and was left unchanged`. The Inspector found local Metadata whose stored document ID no longer represents its recorded path. It leaves the entry unchanged, while any consistently addressed Metadata for the same logical path remains available to ordinary inspection and Vault reflection. This inspection does not query the remote.
1. Back up this device. If other devices share the database, stop editing and pause synchronisation on them.
2. Confirm that the current file-name case and path obfuscation settings are intended for this database. If either setting was deliberately changed for the whole database, stop this workflow and use Rebuild instead.
3. Open **Self-hosted LiveSync settings****Hatch****Inspect conflicts and file/database differences**, then select **Begin inspection**.
4. Find the affected Metadata card and review its recorded path, stored document ID, expected document ID, and source revision.
5. Continue only when the card says `Repair is available for this entry.` Open its wrench menu and select **Repair this Metadata document ID**. If the action is unavailable, do not force an ID: the entry is ambiguous, conflicted, deleted, outside the normal-file namespace, or otherwise unsafe for one-entry repair.
6. Review the warning and select **Repair Metadata ID**. LiveSync rechecks the source revision and expected ID, writes and verifies the target, then removes the obsolete ID.
7. Wait for the ordinary Vault scan to complete. If LiveSync reports that the repair completed but the scan did not run, keep synchronisation paused, resolve the reported scan condition, then run the **Scan storage and database again** command.
8. Allow this device to upload the repair. Resume the other devices one at a time, then run the inspection again and confirm that the Metadata card no longer appears and the Vault file has the intended content.
This action changes one local database entry. It does not rename Vault files or folders, repair several entries at once, coordinate other devices, or preserve CouchDB revision ancestry across the two document IDs.
If many entries reflect folder-name differences across devices, stop every device, choose the authoritative Vault, close Obsidian, correct the actual storage names with operating-system tools, then rebuild the central remote from that Vault and reset the other devices. During Fast Setup on an empty Vault, there are no storage names to correct: allow consistently addressable Metadata to be reflected, then inspect any remaining unresolved references.
## Reset synchronisation on this device
@@ -93,7 +112,7 @@ Garbage Collection removes unreferenced chunks while preserving the current data
- all relevant devices have synchronised; and
- the remaining historical and deletion state is understood.
Deleted documents, tombstones, live conflicts, and retained metadata are not free. Live conflict branches keep the chunks needed for review, while an ordinary superseded linear revision does not protect its former chunks. Garbage Collection can therefore make old content unreadable and cannot promise the smallest possible remote. Review the [Garbage Collection V3 specification](specs_garbage_collection.md) before using it.
Deleted documents, tombstones, unresolved conflicts, and retained metadata are not free. Conflict branches keep the chunks needed for review, while an ordinary superseded linear revision does not protect its former chunks. Garbage Collection can therefore make old content unreadable and cannot promise the smallest possible remote. Review the [Garbage Collection V3 specification](specs_garbage_collection.md) before using it.
Rebuild is a different operation. It reconstructs the database from a chosen authoritative state and is the more certain way to remove unwanted history or repair a damaged remote, but it is also more disruptive and can discard changes which exist only elsewhere.
+1 -1
View File
@@ -1,6 +1,6 @@
# 1.0 preview release history
This document records the opt-in beta and release-candidate builds published before 1.0.0. Most users upgrading from 0.25.83 only need the consolidated [1.0.0 release notes](../../updates.md).
This document records the opt-in beta and release-candidate builds published before 1.0.0. Most users upgrading from 0.25.83 only need the consolidated [1.0.0 release notes](1.0.md#100).
The prepared `1.0.0-rc.0` tag was not published as a plug-in release and is therefore omitted.
+247
View File
@@ -0,0 +1,247 @@
# 1.0 release history
This document contains earlier published releases from the 1.0 line of the [current Self-hosted LiveSync release history](../../updates.md). Beta and release-candidate builds published before 1.0.0 are recorded in the [1.0 preview history](1.0-previews.md). Earlier release lines continue in the [0.25 history](0.25.md) and the [legacy history](legacy.md).
## 1.0.9
8th August, 2026
For the first time in a while, I published a release that could not be promoted to a stable release. Sorry about that! I am glad that we caught it while it was still a pre-release.
### Setup and compatibility
#### Fixed
- Multi-part settings QR codes now preserve special characters in passwords, passphrases, and other settings (PR #1083). Thank you to @calvinbui for the improvement!
- Fast Setup now sizes each finite CouchDB changes page from a one-row status probe, counts the returned result together with `pending`, and resumes from the page's opaque `last_seq` without comparing token representations. Each page uses a one-second idle timeout instead of a heartbeat, allowing CouchDB 3.2 to return its terminator after the currently available rows have been persisted.
## 1.0.8
8th August, 2026
This version was published for pre-release validation only and was not promoted to a stable release.
### Setup and compatibility
#### Fixed
- Fast Setup now sizes each finite CouchDB changes page from a one-row status probe, counts the returned result together with `pending`, and resumes from the page's opaque `last_seq` without comparing token representations. Heartbeat-enabled feeds no longer wait for future writes after the currently available rows have been persisted (#1065).
- Cancelling remote selection during a scheduled Fetch now removes the Fetch flag before restarting with file and database reflection paused, preventing the same selection dialogue from reopening on every start-up.
## 1.0.7
8th August, 2026
### Setup and compatibility
#### Fixed
- Fast Setup now completes only after the captured CouchDB changes target has been persisted. Decryption, protocol, and local write failures stop the operation without finalising an incomplete database, while transient interruptions resume from the last durable checkpoint (#1065).
## 1.0.6
6th August, 2026
I know that onboarding, and other parts which feel unclear or confusing, still need improvement. Please do report any such cases.
### Setup and compatibility
#### Fixed
- Initial setup now distinguishes an empty remote with no saved synchronisation settings from a failed remote read. New remotes can use this device's settings without an unnecessary retry; Fetch pauses on unreadable settings, while Rebuild can explicitly continue with this device's settings. Cancelling preserves the selected automatic synchronisation mode and restarts with Vault and database reflection paused (#1064). Thank you to @mateus2k2 for the follow-up report!
## 1.0.5
5th August, 2026
### Synchronisation and storage
#### Improved
- Added settings to control whether finite synchronisation operations keep the screen awake. Desktop devices now allow automatic sleep by default, while mobile devices retain screen-awake protection unless the general option is enabled (#1073).
### Interface and translation
#### Improved
- Korean translations now cover the complete current catalogue across Setup, P2P, remote configuration, diagnostics, and maintenance (PR #1075). Thank you to @motolies for the improvement!
#### Fixed
- The in-editor LiveSync status on iOS now remains below the view-header controls instead of overlapping them (PR #1067). Thank you to @Hsiii for the improvement!
## 1.0.4
5th August, 2026
### Synchronisation and storage
#### Fixed
- Testing or saving a fresh remote configuration no longer tries to access the local database while constructing a replicator, avoiding 'Local database is not ready yet' failures before local database initialisation (#1064).
### Command-line tool
#### Fixed
- Successful setup and remote-configuration commands now retain their settings changes. Other commands leave the settings file unchanged unless `--write-settings` is supplied, and temporary CLI suspension values are never written (#1070).
## 1.0.3
3rd August, 2026
### Synchronisation and storage
#### Fixed
- File consistency checks no longer read older revisions after the current Vault content matches known synchronised history, avoiding unnecessary 'Missing document content' warnings from obsolete unreadable revisions.
- Remote chunk fetching now retains chunks which were returned successfully when another chunk in the same request is unavailable, so the available content can still be processed (#771).
### Interface
#### Fixed
- The Remediation setting now displays its configured modification-time limit without raising a `HierarchyRequestError`.
## 1.0.2
31st July, 2026
I am aware that some of the Community Directory review checks have become a little more sensitive again. I will watch them for a little longer, then consider the most appropriate way to adapt.
### Synchronisation and storage
#### Improved
- Downloaded document batches retain best-effort screen-awake and lifecycle protection until every queued file has been applied to local storage, without extending the remote-activity indicator (#1031, PR #1032). Thank you to @apple-ouyang for the improvement!
#### Fixed
- Leading UTF-8 byte order marks are preserved during Vault ingestion, keeping stored content sizes consistent with file metadata and preventing persistent three-byte integrity mismatches (#1056, PR #1058).
### Interface and translation
#### Improved
- Document History now provides previous and next revision controls, reports the current revision position, and disables navigation at the oldest and newest boundaries without changing search-result navigation (#990, PR #1009). Thank you to @SeleiXi for the improvement!
- Remaining user-visible text in Setup, P2P, Customisation Sync, Global History, JSON conflict handling, and remote configuration now uses the translation catalogue (PR #1015). Thank you to @zeedif for the improvement!
- Korean translations have broader coverage and corrections for placeholders, punctuation, and established terminology (PR #1055). Thank you to @motolies for the improvement!
- Spanish translation coverage has been expanded across settings, Setup, P2P, maintenance, and newly catalogued interface text (PR #1059). Thank you to @zeedif for the improvement!
### Command-line tool
#### Fixed
- Large-buffer base64 encoding under Node.js now uses the published `octagonal-wheels` fallback when `FileReader` is unavailable, including correctly handling sliced binary views (#1036, PR #1060; [Fancy Kit PR #44](https://github.com/vrtmrz/fancy-kit/pull/44)).
## 1.0.1
29th July, 2026
I am taking this opportunity to update the experimental features as well.
This maintenance release mainly improves the robustness and maintainability of the experimental WebApp, WebPeer, and shared dialogue composition. Most plug-in users can skip it. I have reviewed the changes through CI and a real Obsidian instance, and I will validate the exact published build before merging the release commit.
### Interface
#### Improved
- Removed a custom positioning workaround from the onboarding Notice so that it follows Obsidian's standard placement and dismissal behaviour.
- WebApp now points users to **Scan local files** when automatic file observation is unavailable, instead of relying on a fixed browser-version recommendation.
## 1.0.0
27th July, 2026
The work towards 1.0 has become so substantial that I have written [an article about it](https://fancy-syncing.vrtmrz.net/blog/0036-livesync-1_0_0-en.html) (linked again here).
### Setup and compatibility
#### Improved
- An unconfigured Vault now waits for the user to start setup. Onboarding is offered through a persistent Notice and remains available from **Self-hosted LiveSync settings****Setup**.
- Setup now creates named CouchDB, Object Storage, and P2P connections. Setup URIs preserve their connection names and selections, and reserve Fetch or Rebuild before the ordinary start-up scan begins.
- Manual CouchDB setup distinguishes creating the first database from connecting another device. Onboarding requires a successful connection, while Settings can explicitly save an unverified connection and offers each server-setting correction separately.
- Compatible differences limited to the chunk hash algorithm, chunk size, or splitter version are aligned automatically by default. Existing chunks remain readable, an explicit opt-out remains available, and differences involving incompatible settings still require review.
#### Fixed
- Existing Vaults retain their effective legacy settings, including the case-insensitive file-name fallback used when an older release had no explicit case setting.
#### Security
- Fly.io setup generates CouchDB and Vault encryption secrets with cryptographically secure randomness.
- Dependency updates address excessive CPU use from crafted path patterns and `mailto:` links.
### Conflict handling and recovery
#### Improved
- **Not now** postpones repeated automatic merge dialogues while retaining the unresolved-conflict warning. Three or more live revisions are reviewed one reproducible pair at a time, completed pairs remain resolved across restart, and explicit commands can reopen a postponed conflict.
- **Inspect conflicts and file/database differences** compares the Vault with the database winner and every live conflict revision. Compact indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflicts remain.
- Each reported file and live revision has a compact wrench menu for comparison, applying an exact readable revision, recording an exact byte match, storing the Vault content as a child of a selected branch, retrying missing chunks without changing the tree, or explicitly discarding one selected live branch.
#### Fixed
- Automatic text and structured-data merge now uses the nearest revision actually shared by both branches. A resolution received from another device no longer recreates the same conflict merely because the Vault still contains the exact content of the removed branch.
- Edits, logical deletions, and renames made while a file remains conflicted extend the revision displayed on that device. When the relationship cannot be proved, LiveSync preserves the branches for review.
- Unreadable live revisions are preserved during automatic handling. An absent Vault file and a winning logical deletion are treated as agreement unless another live branch still requires attention.
- Garbage Collection V3 is limited to CouchDB and now protects every live conflict branch, required shared ancestry, and shared chunks. It stops when device progress cannot be verified and reports compaction failure without a contradictory success message.
### P2P and optional synchronisation features
#### Improved
- P2P and Hidden File Sync remain supported opt-in features. Customisation Sync remains a supported Advanced workflow, while Data Compression remains available but disabled by default.
- P2P controls remain outside the ordinary CouchDB experience until P2P is configured. The current status pane distinguishes announcing changes, following a peer, and persistent per-device actions.
- P2P setup and guidance now distinguish the required signalling relay from optional TURN and describe the replaceable public relay's privacy and availability limits.
- Enabling Hidden File Sync opens one progress Notice before saving the setting and reuses it until the initial scan has finished instead of stacking phase, reload, and restart messages.
#### Fixed
- First-device P2P setup can complete its signalling test without another peer online. Fetch on an additional device still requires an available source peer and a completed P2P Rebuild.
- P2P relay connections now close and are recreated reliably after settings changes and database resets.
### Interface, translation, and operations
#### Improved
- Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands retain their identifiers so that existing hotkeys continue to work.
- Setup and review dialogue text can be selected for copying or translation.
- Remote-size warnings use persistent clickable Notices. Initial uploads and Rebuild no longer ask to send every chunk in advance; ordinary replication completes the transfer.
- Obsolete controls for the plug-in trash setting and fixed chunk revisions were removed. The Change Log remains available but no longer opens automatically or tracks an unread count.
- Self-hosted LiveSync now owns its translation catalogue. Commonlib supplies canonical English to other consumers, while translation contributions can be made in the main Self-hosted LiveSync repository.
#### Fixed
- Applying an available interface translation no longer holds start-up behind an unsolicited dialogue; a persistent Notice opens the existing details on demand.
- Action buttons are arranged for narrow mobile screens, long dialogues keep their controls reachable, and persistent Notices no longer cover close controls.
### Storage and file selection
#### Fixed
- The optional Custom HTTP Handler used by Object Storage sends the correct byte range from binary request bodies and reports unsupported body types instead of silently sending an empty request.
- Broadening selectors, ignore rules, size or modification-time limits, or file-name case handling now rechecks previously received files without requiring another remote update.
- Start-up and full-inspection scans omit built-in legacy LiveSync log files and recovery flag files before comparing Vault and local-database state. Existing ignored database records remain untouched, and user-configured ignore behaviour is unchanged.
### Command-line tool
#### Fixed
- CLI Setup URI validation now uses the supported Commonlib ESM package interface.
- The non-root Docker image no longer depends on permissions inherited from the source checkout.
#### Security
- The CLI rejects detected path traversal and symbolic-link components before Vault operations.
### Validation
#### Testing
- Expanded automated Real Obsidian coverage for upgrades, two-device synchronisation, CouchDB, Object Storage, P2P, Hidden File Sync, mobile dialogues, conflict and revision recovery, failure diagnostics, and strict clean-up.
- Real CouchDB integration coverage verifies logical deletion, shared and conflict chunk retention, compaction, downstream replication, and recreation of content-addressed chunks.
- An encrypted Real Obsidian reconnect scenario replaces the remote Security Seed while one client retains the previous value, verifies that synchronisation adopts the replacement without restoring the old value, and proves a bidirectional encrypted round-trip.
- The plug-in code in this release was installed through BRAT and validated on macOS, iOS, and Android, including upgrade from 0.25.83, bidirectional synchronisation, P2P setup, conflict handling, recovery controls, mobile layouts, and start-up with existing configurations.
- Native and non-root Docker CLI scenarios cover setup, write, read, list, information, deletion, conflict resolution, and revision retrieval with the packaged Commonlib dependency.
+1 -1
View File
@@ -1,6 +1,6 @@
# Legacy release history
This history covers releases before 0.25. Later releases are recorded in the [0.25 history](0.25.md) and the [current release history](../../updates.md).
This history covers releases before 0.25. Later releases are recorded in the [0.25 history](0.25.md), the [earlier 1.0 history](1.0.md), and the [current release history](../../updates.md).
## 0.24
+7 -5
View File
@@ -573,7 +573,7 @@ Should we keep folders that do not have any files inside?
### 5. Conflict resolution (Advanced)
Conflict resolution preserves unknown local content and automatically merges only when the available revision history supplies a safe shared base. See [Conflict resolution and revision provenance](specs_conflict_resolution.md) for the revision-tree rules, stale and concurrent resolutions, binary-file limitation, and the device-local provenance used for operations while a conflict is live.
Conflict resolution preserves unknown local content and automatically merges only when the available revision history supplies a safe shared base. See [Conflict resolution and revision provenance](specs_conflict_resolution.md) for the revision-tree rules, stale and concurrent resolutions, binary-file limitation, and the device-local provenance used while a conflict remains unresolved.
#### (BETA) Always overwrite with a newer file
@@ -745,9 +745,11 @@ Recreate chunks from files currently present in the Vault. This can repair missi
#### Inspect conflicts and file/database differences
Compare each Vault file with every current live revision in the local database. Each winner and conflict revision is shown separately with its exact revision identifier, local chunk availability, and relationship to the current Vault file. Unavailable shared ancestors are reported separately because they prevent conservative three-way merging but are not live revisions which can be discarded.
Compare each Vault file with every current leaf revision in the local database. Each winner and conflict revision is shown separately with its exact revision identifier, local chunk availability, and relationship to the current Vault file. Unavailable shared ancestors are reported separately because they prevent conservative three-way merging but are not current leaves which can be discarded.
Select **Begin inspection** to run the inspection. Each reported file and live revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history.
Select **Begin inspection** to run the inspection. Each reported file and current leaf revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history.
The same inspection also reports local Metadata whose stored document ID does not agree with its recorded path. A stale entry does not suppress ordinary inspection when consistently addressed Metadata can still be resolved for that logical path; otherwise, the unresolved path is excluded from ordinary file-repair actions. When the current winner has no conflict leaves and has an unambiguous target, its wrench menu offers a separately confirmed, one-entry repair. The target is derived from the current local file-name case and path obfuscation settings, then written and verified before the obsolete ID is removed. Ambiguous, conflicted, deleted, excluded, or otherwise unsafe entries remain read-only. This action does not rename Vault files or folders. Follow [Repair a Metadata document ID mismatch](recovery.md#repair-a-metadata-document-id-mismatch) for the complete backup, repair, propagation, and verification procedure. For widespread naming differences across devices, use that guide to choose an authoritative Vault, correct its storage names while Obsidian is closed, rebuild the central remote, and reset the other devices.
#### Resolve All conflicted files by the newer one
@@ -1061,9 +1063,9 @@ Delete all data on the remote server.
### 6. Garbage Collection V3 (CouchDB only)
Garbage Collection V3 identifies chunk documents which are not reachable from any current file or live conflict branch, creates logical deletions for those chunks locally, propagates the deletions to CouchDB, and requests remote compaction.
Garbage Collection V3 identifies Chunk documents which are not reachable from any current file or conflict branch, creates logical deletions for those Chunks locally, propagates the deletions to CouchDB, and requests remote compaction.
Use it only when the Vault, local database, and remote are healthy, and every relevant device has synchronised. It can make an ordinary superseded file revision unreadable when no live state still needs its chunks. It does not repair corruption or replace a deliberate rebuild. See the [Garbage Collection V3 specification](specs_garbage_collection.md).
Use it only when the Vault, local database, and remote are healthy, and every relevant device has synchronised. It can make an ordinary superseded file revision unreadable when no current state still needs its Chunks. It does not repair corruption or replace a deliberate rebuild. See the [Garbage Collection V3 specification](specs_garbage_collection.md).
### 7. Reset
+9 -4
View File
@@ -118,10 +118,12 @@ Please refer to the [official document](https://docs.couchdb.org/en/stable/insta
Deno 2 is required. Export the CouchDB connection and database details, then run the provisioning wrapper:
The `username` and `password` in this step are CouchDB administrator credentials. For Docker or Docker Compose, use the same values supplied as `COUCHDB_USER` and `COUCHDB_PASSWORD`. For a direct installation, use the administrator configured during CouchDB setup. The wrapper does not create a separate non-administrator synchronisation account.
```
export hostname=http://localhost:5984
export username=<INSERT USERNAME HERE>
export password=<INSERT PASSWORD HERE>
export username=<INSERT COUCHDB ADMINISTRATOR USERNAME HERE>
export password=<INSERT COUCHDB ADMINISTRATOR PASSWORD HERE>
export database=obsidiannotes
curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | bash
```
@@ -135,7 +137,7 @@ The wrapper runs the exact registry-pinned Commonlib consumer. When `database` i
If you are using Docker Compose and the above command does not work or displays `ERROR: Hostname missing`, you can try running the following command, replacing the placeholders with your own values:
```
curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | hostname=http://<YOUR SERVER IP>:5984 username=<INSERT USERNAME HERE> password=<INSERT PASSWORD HERE> database=obsidiannotes bash
curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | hostname=http://<YOUR SERVER IP>:5984 username=<INSERT COUCHDB ADMINISTRATOR USERNAME HERE> password=<INSERT COUCHDB ADMINISTRATOR PASSWORD HERE> database=obsidiannotes bash
```
## 3. Expose CouchDB to the Internet
@@ -170,10 +172,13 @@ Now `https://tiles-photograph-routine-groundwater.trycloudflare.com` is our serv
> A generated Setup URI is the recommended path because it carries the current defaults for a new Vault and the selected remote profile. If a Setup URI cannot be generated, follow [Configure CouchDB manually on the first device](./quick_setup.md#configure-couchdb-manually-on-the-first-device), then generate a new Setup URI from that working device for every additional device.
### 1. Generate the setup URI on a desktop device or server
The `username` and `password` here are the credentials which Self-hosted LiveSync will store for routine access to this database. They may be the administrator credentials from step 2. If you have separately configured a CouchDB account with the required access to this database, use that account instead. Neither the provisioning wrapper nor the Setup URI generator creates that separate account.
```bash
export hostname=https://tiles-photograph-routine-groundwater.trycloudflare.com
export database=obsidiannotes
export username=johndoe
export username=<INSERT COUCHDB USERNAME FOR LIVESYNC>
export password=<INSERT THE COUCHDB PASSWORD>
export passphrase=<INSERT A STRONG VAULT ENCRYPTION PASSPHRASE>
export uri_passphrase=<INSERT A SEPARATE SETUP URI PASSPHRASE> # Optional
+46 -30
View File
@@ -4,7 +4,7 @@ This document describes the conflict-resolution and file-reflection guarantees u
## Revision-tree model
PouchDB stores a document as a revision tree. It selects one live leaf as the deterministic winner and reports the other live leaves as conflicts. That winner is not proof that its content is newer, safer, or the version currently shown in the Vault.
PouchDB stores a document as a revision tree. It selects one current leaf as the deterministic winner and reports the other current leaves as conflicts. That winner is not proof that its content is newer, safer, or the version currently shown in the Vault.
For example:
@@ -14,9 +14,25 @@ A1
└── B2 ── C2
```
The two live leaves are `D1` and `C2`. Their nearest shared ancestor is `A1`; neither `B1` nor `B2` is shared. A conservative three-way merge therefore compares the changes from `A1` to each leaf. Matching generation numbers, or selecting the first older revision from one branch, does not prove shared ancestry.
The two current leaves are `D1` and `C2`. Their nearest shared ancestor is `A1`; neither `B1` nor `B2` is shared. A conservative three-way merge therefore compares the changes from `A1` to each leaf. Matching generation numbers, or selecting the first older revision from one branch, does not prove shared ancestry.
Resolving a conflict writes the selected or merged result on one observed branch and deletes the other observed live leaf. A stale device may still have the deleted leaf's content in its Vault when it receives the resolution.
Resolving a conflict writes the selected or merged result on one observed branch and deletes the other observed conflict leaf. A stale device may still have the deleted leaf's content in its Vault when it receives the resolution.
### Independent revision properties
The modifiers defined under [Revision](terms.md#revision) describe independent properties, rather than exclusive revision types. The winner is a database-tree role, Vault-matching describes current file-state equality, and displayed identifies the device-local branch recorded for the Vault. The same revision commonly has all three properties, but synchronisation, conflicts, local edits, and missing provenance can separate them.
| Situation | Winner | Vault-matching | Displayed |
| ---------------------------------------------------- | ------------------ | --------------------------------------------------- | ---------------------------------------------------- |
| A present Vault file is fully converged | `R` | `R` | `R` |
| The Vault displays conflict leaf `C` | `W` | `C` | `C` |
| The database advances before Vault reflection | new winner `W2` | previous revision `R`, while the Vault is unchanged | `R` |
| A local edit of displayed revision `R` is pending | independent | none, or a coincidental content match | `R`, as the branch which the edit must extend |
| Provenance is missing and exactly one revision fits | independent | `M` | none, then `M` after safe reconstruction |
| Provenance is missing and several revisions fit | independent | every matching revision | none |
| A logical-deletion winner agrees with an absent file | deleted winner `D` | `D`, and possibly other logical-deletion revisions | none; an absent file retains no displayed provenance |
At most one revision is the winner, more than one revision can be Vault-matching, and at most one revision can be displayed for a path on one device. A displayed revision may stop matching the Vault while a local edit is pending, but its branch identity remains authoritative until that edit is stored or the relationship is safely reconstructed.
## Implemented 1.0 guarantees
@@ -25,7 +41,7 @@ Resolving a conflict writes the selected or merged result on one observed branch
- A receiving Vault file which exactly matches any available revision in the document tree is treated as previously synchronised content. This includes an ancestor below a deleted losing leaf.
- A receiving Vault file whose bytes do not match any available revision is preserved as an unsynchronised local change.
- File bytes, rather than path, size, modification time, or revision generation, determine whether content is known.
- Three or more live versions are reviewed one pair at a time in a deterministic order, with each completed pair committed before the next live pair is read.
- Three or more current versions are reviewed one pair at a time in a deterministic order, with each completed pair committed before the next pair is read.
- Each device records the exact revision most recently reflected in each Vault file. An edit, deletion, or case-only rename made while a conflict is active extends that displayed branch rather than the deterministic database winner.
- A cross-path rename stores the target before logically deleting only the displayed source branch.
@@ -50,32 +66,32 @@ The compatibility implementation currently selects the newer modification time f
A document revision can remain in the PouchDB tree while one or more chunks needed to reconstruct its content are unavailable. Missing content is not evidence that the revision is obsolete. LiveSync therefore leaves an unreadable winner or conflict revision in the tree instead of deleting it during automatic conflict processing.
**Hatch****Inspect conflicts and file/database differences** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. A logical-deletion winner and an absent Vault file already agree, so that state is not reported unless another live branch still requires attention. When the Vault already matches the winner but conflict branches remain, the card shows the compact status `✅ Vault matches winner · ⚠️ Conflicts: N`; matching the winner does not mean that the conflict has been resolved.
**Hatch****Inspect conflicts and file/database differences** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. A logical-deletion winner and an absent Vault file already agree, so that state is not reported unless another conflict branch still requires attention. When the Vault already matches the winner but conflict branches remain, the card shows the compact status `✅ Vault matches winner · ⚠️ Conflicts: N`; matching the winner does not mean that the conflict has been resolved.
Each reported live revision has a compact wrench menu. The available actions depend on the exact revision and current Vault state:
Each reported current leaf revision has a compact wrench menu. The available actions depend on the exact revision and current Vault state:
- **Compare with Vault** opens the existing difference dialogue in read-only mode for differing text files.
- **Apply this revision to Vault** writes the selected readable revision, even when it is not the database winner. Replacing an existing file requires confirmation.
- **Mark this revision as the Vault version** is offered when the bytes already match. It records exact device-local provenance without creating a child revision, and refuses the operation if the file changed after inspection.
- **Store Vault file as a child of this revision** preserves the current Vault bytes on the explicitly selected live branch.
- **Store Vault file as a child of this revision** preserves the current Vault bytes on the explicitly selected current branch.
- **Apply logical deletion to Vault** removes an existing Vault file after confirmation. An absent file needs no retained deletion provenance.
- **Retry reading revision** attempts the configured chunk-retrieval path again. It does not change the revision tree.
- **Discard this branch** is available for each exact live revision while at least one other live branch remains. It requires confirmation and creates a logical deletion on only the selected branch without changing the current Vault file.
- **Discard unreadable revision** remains available as a recovery action when an unreadable revision is the only live leaf. It requires confirmation because no other database branch remains.
- **Discard this branch** is available for each exact current leaf while at least one other current leaf remains. It requires confirmation and creates a logical deletion on only the selected branch without changing the current Vault file.
- **Discard unreadable revision** remains available as a recovery action when an unreadable revision is the only current leaf. It requires confirmation because no other database branch remains.
Every mutating action rechecks that the selected revision is still a current live leaf. If another operation resolved or replaced it, the action fails and the card is refreshed instead of extending an obsolete branch.
Every mutating action rechecks that the selected revision is still a current leaf. If another operation resolved or replaced it, the action fails and the card is refreshed instead of extending an obsolete branch.
The card uses compact, mobile-friendly diagnostic rows with an emoji and a text label. `🧩 Missing chunks: N` identifies an unreadable revision. In the database row, `Δsize` is decoded size minus recorded size; in the Vault row, `Δsize vs DB` is Vault size minus decoded database size. `Δtime` is Vault modification time minus database modification time. The ordinary two-second comparison window still labels which side is newer. These values help diagnose a mismatch; path, size, and modification time do not prove revision identity or decide which content should win.
A shared ancestor is informational. An ancestor which is no longer a live revision cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable live revisions can still be selected manually.
A shared ancestor is informational. An ancestor which is no longer a current leaf cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable current leaf revisions can still be selected manually.
Logical deletion does not recreate missing bytes, purge the document history, or prove that the deleted version was unimportant. Another replica or backup may still contain the missing chunks. Recover from that source before discarding a revision whenever possible.
**Recreate chunks for current Vault files** can recreate chunks only from files which are readable in the current Vault. It cannot reconstruct unique bytes from an unavailable historical or conflict revision.
Garbage Collection V3 treats every live conflict revision and its nearest available shared ancestor as reachable. Their locally available chunks are retained until the conflict is resolved. After resolution, chunks used only by the discarded branch or no-longer-needed merge ancestry can become eligible for collection. See the [Garbage Collection V3 specification](specs_garbage_collection.md).
Garbage Collection V3 treats every conflict leaf and its nearest available shared ancestor as reachable. Their locally available chunks are retained until the conflict is resolved. After resolution, chunks used only by the discarded branch or no-longer-needed merge ancestry can become eligible for collection. See the [Garbage Collection V3 specification](specs_garbage_collection.md).
A generation-one revision has no parent. When its body is unavailable, LiveSync cannot preserve a changed Vault file as a sibling branch without inventing ancestry. It leaves the operation unresolved. Recover the missing chunks from another replica or backup, or explicitly discard that live revision. If the current Vault file is the intended replacement, it can be stored after the unreadable revision has been logically deleted.
A generation-one revision has no parent. When its body is unavailable, LiveSync cannot preserve a changed Vault file as a sibling branch without inventing ancestry. It leaves the operation unresolved. Recover the missing chunks from another replica or backup, or explicitly discard that current leaf. If the current Vault file is the intended replacement, it can be stored after the unreadable revision has been logically deleted.
### Two devices independently create the same path
@@ -100,19 +116,19 @@ and otherwise asks the user.
## Stale and concurrent resolutions
A device can resolve only the leaves which it has observed. If another device has already extended a branch, later replication can reveal another live leaf and require another resolution. Two devices can also produce different resolutions concurrently, leaving multiple live leaves after their trees meet.
A device can resolve only the leaves which it has observed. If another device has already extended a branch, later replication can reveal another current leaf and require another resolution. Two devices can also produce different resolutions concurrently, leaving multiple current leaves after their trees meet.
A higher revision generation or modification time does not make either result authoritative. The resolver must examine every current live leaf again until one result remains or user action is required. This is continued conflict processing, not a reset of the synchronisation checkpoint.
A higher revision generation or modification time does not make either result authoritative. The resolver must examine every current leaf again until one result remains or user action is required. This is continued conflict processing, not a reset of the synchronisation checkpoint.
## More than two live versions
## More than two current versions
When three or more versions remain, LiveSync compares the current PouchDB winner with one conflict leaf at a time. Commonlib orders the remaining candidates by revision generation ascending, original leaf modification time ascending, then the complete revision ID in code-unit lexical order. A missing or non-finite modification time is ordered before a finite value. Modification time makes pair selection reproducible here; it does not decide which content wins.
For each pair, LiveSync first collapses identical content, then attempts a conservative sensible merge, and finally asks the user when neither automatic action is safe. A completed action is written to the ordinary revision tree and its losing observed leaf is deleted before LiveSync reads the remaining live leaves again. There is no separate persistent merge accumulator.
For each pair, LiveSync first collapses identical content, then attempts a conservative sensible merge, and finally asks the user when neither automatic action is safe. A completed action is written to the ordinary revision tree and its losing observed leaf is deleted before LiveSync reads the remaining current leaves again. There is no separate persistent merge accumulator.
**Concat both** writes the concatenated result as a new child of the displayed PouchDB winner, then deletes only the other leaf shown in that dialogue. With two live versions, that action resolves the conflict. With three or more, the new child remains live against every untouched leaf and becomes part of the next pairwise review; it does not create an unrelated root or consume an unseen branch.
**Concat both** writes the concatenated result as a new child of the displayed PouchDB winner, then deletes only the other leaf shown in that dialogue. With two current versions, that action resolves the conflict. With three or more, the new child remains a current leaf against every untouched leaf and becomes part of the next pairwise review; it does not create an unrelated root or consume an unseen branch.
Consequently, choosing **Not now** or closing Obsidian cannot undo a completed pair. After restart, LiveSync reconstructs the next pair from the live tree. If replication changes either revision while a dialogue is open, LiveSync discards the stale selection, refreshes the live count, and rechecks the path rather than deleting a revision which was not the one shown.
Consequently, choosing **Not now** or closing Obsidian cannot undo a completed pair. After restart, LiveSync reconstructs the next pair from the current revision tree. If replication changes either revision while a dialogue is open, LiveSync discards the stale selection, refreshes the current-version count, and rechecks the path rather than deleting a revision which was not the one shown.
## Device-local file provenance
@@ -133,7 +149,7 @@ When no record exists, LiveSync may reconstruct the displayed revision only if t
## Operations while a conflict exists
- Editing a file writes a child of its recorded or uniquely reconstructed displayed revision.
- Deleting a file writes a logical-deletion child of that revision. It uses LiveSync's `deleted` marker, rather than a PouchDB `_deleted` tombstone, so the deletion remains a live branch which can replicate and be resolved against the other branch.
- Deleting a file writes a logical-deletion child of that revision. It uses LiveSync's `deleted` marker, rather than a PouchDB `_deleted` tombstone, so the deletion remains a current branch which can replicate and be resolved against the other branch.
- A case-only rename writes the new path as a child in the same document tree.
- A cross-path rename stores the target document first, then writes a logical-deletion child on the displayed source branch.
@@ -146,7 +162,7 @@ uninterrupted conflict episode in the current plug-in session. Ordinary file
checks and replication do not reopen the dialogue while at least one conflict
leaf remains. If the in-editor status display is enabled, the active file shows
**This file has 3 unresolved versions. They will be reviewed one pair at a
time.** for three or more live versions, using the current count, and **This
time.** for three or more current versions, using the current count, and **This
file has unresolved conflicts.** for two. Postponement therefore does not make
the conflict invisible.
@@ -163,8 +179,8 @@ When synchronisation supplies a resolved document, the existing incoming-file
processing event closes an open conflict dialogue for that path. The same event
rechecks the local revision tree: if no conflict leaf remains, it ends any
postponed episode and removes the active-file warning. If conflict leaves still
exist, the stale dialogue closes and the warning changes to the current live
version count. A postponed episode stays postponed; otherwise, subsequent
exist, the stale dialogue closes and the warning changes to the current-version
count. A postponed episode stays postponed; otherwise, subsequent
conflict processing may open a fresh dialogue for the current revision tree.
Each dialogue owns its completion result, so a prompt which is answered or
closed immediately still completes the waiting conflict operation; the result
@@ -193,7 +209,7 @@ A1
└── B2 ── C2 ── D2 Android edit
```
After synchronisation, both devices receive `C1` and `D2` as the live branches. The edit is not moved silently onto `C1`, and ordinary conflict resolution can compare the real descendants.
After synchronisation, both devices receive `C1` and `D2` as the current branches. The edit is not moved silently onto `C1`, and ordinary conflict resolution can compare the real descendants.
### A user deletes the branch shown on one device
@@ -205,13 +221,13 @@ A1
└── B2 ── C2 ── D2 (deleted: true)
```
The deletion remains one side of the live conflict. The user can still choose between the content at `C1` and deleting the file. LiveSync does not delete `C1` merely because PouchDB selected it as the winner.
The deletion remains one current leaf of the conflict. The user can still choose between the content at `C1` and deleting the file. LiveSync does not delete `C1` merely because PouchDB selected it as the winner.
### A user renames a conflicted file
If the user changes only the spelling case, such as `Note.md` to `note.md`, LiveSync keeps the rename in the same revision tree and extends the revision displayed on that device.
If the user renames `draft.md` to `published.md`, LiveSync stores `published.md` before it marks the displayed `draft.md` branch as logically deleted. If an interruption occurs between those operations, the recoverable result is a duplicate which can be reviewed, rather than loss of the only copy. Any other live branch of `draft.md` remains available for conflict resolution.
If the user renames `draft.md` to `published.md`, LiveSync stores `published.md` before it marks the displayed `draft.md` branch as logically deleted. If an interruption occurs between those operations, the recoverable result is a duplicate which can be reviewed, rather than loss of the only copy. Any other conflict branch of `draft.md` remains available for conflict resolution.
### A remote resolution reaches a device which still shows the losing content
@@ -221,7 +237,7 @@ If the user edited the file on Mac before the resolution arrived, the bytes no l
### A three-version review is interrupted
Mac receives three live versions of `shared.md`. The active-file status reports three unresolved versions, and the first dialogue compares the deterministic winner with the first ordered conflict leaf. The user completes that pair, leaving two live versions, then chooses **Not now** on the next dialogue and closes Obsidian.
Mac receives three current versions of `shared.md`. The active-file status reports three unresolved versions, and the first dialogue compares the deterministic winner with the first ordered conflict leaf. The user completes that pair, leaving two current versions, then chooses **Not now** on the next dialogue and closes Obsidian.
The first decision has already changed the ordinary revision tree. On restart, LiveSync reads the two surviving versions and presents only that remaining pair; it does not reconstruct the original three-version state. If another device resolves the remaining pair before or while the dialogue is open, the warning disappears and the stale dialogue closes.
@@ -251,8 +267,8 @@ Do not:
## Verification
Commonlib's real-PouchDB and injected-boundary unit tests cover unequal branch lengths, exact shared ancestry, deterministic ordering of multiple live leaves, a sensible stage followed by reconstruction of a manual pair, content below a deleted losing leaf, recorded and reconstructed branch identity, ambiguous matches, conflict-time editing, missing-body preservation when parent metadata is available, refusal to invent a parent for a generation-one revision, logical deletion, case-only rename, cross-path rename, and safe unproven fallbacks.
Commonlib's real-PouchDB and injected-boundary unit tests cover unequal branch lengths, exact shared ancestry, deterministic ordering of multiple current leaves, a sensible stage followed by reconstruction of a manual pair, content below a deleted losing leaf, recorded and reconstructed branch identity, ambiguous matches, conflict-time editing, missing-body preservation when parent metadata is available, refusal to invent a parent for a generation-one revision, logical deletion, case-only rename, cross-path rename, and safe unproven fallbacks.
LiveSync's optional real-Obsidian two-Vault checks have two scopes. `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true` resolves and edits a Markdown conflict, propagates it to a Vault which still displays the deleted losing content, and requires one live result to remain. `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` edits, deletes, case-renames, and cross-path-renames files while conflicts remain active; it verifies the parent revision of each resulting branch, replicates those exact trees, and confirms that the other live branches remain intact.
LiveSync's optional real-Obsidian two-Vault checks have two scopes. `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true` resolves and edits a Markdown conflict, propagates it to a Vault which still displays the deleted losing content, and requires one current result to remain. `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` edits, deletes, case-renames, and cross-path-renames files while conflicts remain active; it verifies the parent revision of each resulting branch, replicates those exact trees, and confirms that the other conflict branches remain intact.
The focused `test:e2e:obsidian:conflict-dialog-policy` scenario creates three live versions in one real Obsidian Vault. It verifies the count warning, commits a concatenated child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the isolated Obsidian profile, and confirms that only the live pair is reconstructed. It also verifies that an incoming resolution closes a stale dialogue, completes the waiting conflict operation, and clears the warning. The repair scenario removes a referenced local chunk, confirms that the exact unreadable live revision remains in the tree, and exercises explicit retry and discard controls without deleting another live revision.
The focused `test:e2e:obsidian:conflict-dialog-policy` scenario creates three current versions in one real Obsidian Vault. It verifies the count warning, commits a concatenated child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the isolated Obsidian profile, and confirms that only the current pair is reconstructed. It also verifies that an incoming resolution closes a stale dialogue, completes the waiting conflict operation, and clears the warning. The repair scenario removes a referenced local chunk, confirms that the exact unreadable current leaf remains in the tree, and exercises explicit retry and discard controls without deleting another current leaf.
+6 -6
View File
@@ -33,19 +33,19 @@ If the initial synchronisation, device inspection, or confirmation fails, the wo
A chunk remains reachable when it is referenced by any of the following:
- the current database winner for a file;
- any other live conflict revision for that file;
- an available revision on either side of a live conflict which is required to describe the divergence; or
- the nearest available revision shared by both live conflict branches.
- any conflict leaf for that file;
- an available revision on either side of a current conflict which is required to describe the divergence; or
- the nearest available revision shared by both conflict branches.
Chunk identifiers are content-derived and shared between files. Reachability is therefore collected into one set across the database. A chunk used by two or more current files remains protected even when one file is updated or deleted.
An ordinary superseded linear revision does not protect its former chunks. Once no current file or live conflict branch references a chunk, it can be collected. After a conflict is resolved, chunks unique to the discarded branch and to no-longer-needed merge ancestry can also become eligible.
An ordinary superseded linear revision does not protect its former chunks. Once no current file or conflict leaf references a chunk, it can be collected. After a conflict is resolved, chunks unique to the discarded branch and to no-longer-needed merge ancestry can also become eligible.
## Consequences
Garbage Collection deliberately trades historical recoverability for storage. A metadata revision may remain in the revision tree after a chunk which only that superseded revision used has been collected, so that historical body can become unreadable. Remote compaction can then discard old CouchDB revision bodies. Tombstones and retained metadata also consume storage, so the operation does not promise the smallest possible database.
Writing the same bytes again produces the same content-derived chunk identifier. If that chunk was collected previously, the normal chunk-writing path creates a new live revision for it, and ordinary replication can transfer it again. This does not recover an older file revision automatically; it only makes the newly written content available.
Writing the same bytes again produces the same content-derived chunk identifier. If that chunk was collected previously, the normal chunk-writing path creates a new non-deleted revision of the Chunk document, and ordinary replication can transfer it again. This does not recover an older file revision automatically; it only makes the newly written content available.
Garbage Collection does not reconstruct a chunk which is already missing, determine whether an unreadable revision is important, or repair a damaged local database. Use **Inspect conflicts and file/database differences**, another healthy replica, or a backup for those cases. Use **Overwrite Server Data with This Device's Files** only when a chosen Vault is authoritative and a deliberate remote rebuild is required.
@@ -55,7 +55,7 @@ Commonlib tests use real in-memory PouchDB revision trees to verify:
- collection eligibility after a normal file update;
- protection of chunks shared by multiple current files;
- protection of all live conflict branches and their nearest available shared ancestor;
- protection of all conflict branches and their nearest available shared ancestor;
- eligibility of losing-branch and ancestor-only chunks after conflict resolution;
- propagation of chunk deletion to another PouchDB database; and
- recreation and propagation when the same content is written again.
+19 -1
View File
@@ -43,7 +43,7 @@ All guidelines and conventions listed below are disclosed and maintained solely
- Database Suffix (additionalSuffixOfDatabaseName)
- A unique suffix appended to the database name to allow synchronising multiple vaults with the same name on the same remote server.
- E2EE Algorithm
- The cryptographic algorithm version used for end-to-end encryption. All devices in the synchronisation group must be configured with a compatible version (such as `V2` or `V1`).
- The cryptographic algorithm version used for end-to-end encryption. All synchronising devices must be configured with a compatible version (such as `V2` or `V1`).
- Eden (Eden Chunks)
- A performance optimisation where newly created chunks are held within the document until they stabilise, before graduating to independent chunks.
- Fast Setup (Simple Fetch)
@@ -80,6 +80,22 @@ All guidelines and conventions listed below are disclosed and maintained solely
- A recovery setting that restricts the propagation of changes from the database to local storage, ignoring any file events (such as accidental mass deletions) that occurred after a specified date and time.
- Reset Synchronisation on This Device
- A maintenance operation (formerly known as `Fetch everything`) that discards the local database and reconstructs it by downloading all data from the remote server.
#### Revision
A revision is a version of one PouchDB/CouchDB document. Concurrent changes can form a revision tree with more than one current branch.
Revision modifiers describe independent properties. More than one may apply to the same revision:
- **leaf**: Has no known child revision.
- **winner**: Is the leaf selected by PouchDB/CouchDB as the current document.
- **conflict**: Is another current leaf which was not selected as the winner.
- **Vault-matching**: Represents the same file contents, or the same absent-file state, as the current Vault. More than one revision may match.
- **displayed**: Is recorded by valid device-local file provenance as the branch represented in the Vault. A pending local edit may no longer match its bytes, but still extends this recorded branch.
- **logically deleted**: Represents the absence of the file through a deletion marker. A logically deleted revision may also be a leaf, winner, conflict, or Vault-matching revision. An absent file retains no displayed provenance.
Avoid **live revision** in prose because it can ambiguously mean either a current leaf or a non-deleted revision. See [Independent revision properties](specs_conflict_resolution.md#independent-revision-properties) for the relationship between revision-tree roles, Vault state, and device-local provenance.
- Scram (Scram Switches)
- Emergency controls in the settings that allow users to suspend file watching or database writes to prevent corruption.
- Segmenter (Segmented-splitter)
@@ -94,6 +110,8 @@ All guidelines and conventions listed below are disclosed and maintained solely
- A data transfer method that downloads database documents as a continuous stream of events. It is significantly faster than traditional chunk-by-chunk HTTP requests and is used during Fast Setup to retrieve remote metadata quickly.
- Sync Mode
- The replication trigger mechanism. Users can select from `On Events` (synchronising on local file changes), `Periodic and Events` (synchronising at fixed intervals as well as on events), or `LiveSync` (continuous, real-time synchronisation).
- Synchronising devices
- Devices which participate in the same synchronisation for a Vault. The term describes membership rather than current activity, so it includes offline and idle devices.
- TURN Server (WebRTC P2P)
- A Traversal Using Relays around NAT server used as an optional fallback to relay encrypted WebRTC traffic when strict NAT or firewall rules block a direct peer connection. It is distinct from the signalling relay.
- Update Thinning (Batch database update)
+12 -4
View File
@@ -58,14 +58,22 @@ If the log reports missing chunks or a size mismatch:
3. synchronise a device or restore a backup which still has the correct content;
4. on that healthy device, run `Recreate chunks for current Vault files`, then synchronise;
5. follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file); run `Inspect conflicts and file/database differences` from `Hatch`, then use each revision's wrench menu to review and act on that exact branch; and
6. use `Discard this branch` only after confirming that the exact live branch is no longer wanted. Use the separate `Discard unreadable revision` recovery action only when an unreadable revision is the sole live leaf.
6. use `Discard this branch` only after confirming that the exact current branch is no longer wanted. Use the separate `Discard unreadable revision` recovery action only when an unreadable revision is the sole current leaf.
The repair card uses compact diagnostic rows which remain readable in a narrow mobile settings pane. `🧩 Missing chunks: N` marks an unreadable revision. In the database row, `Δsize` means decoded size minus recorded size; `Δsize vs DB` means Vault size minus decoded database size; and `Δtime` means Vault modification time minus database modification time. These are diagnostic values, not a rule for deciding which revision is correct. `✅ Vault matches winner · ⚠️ Conflicts: N` means that the current Vault bytes agree with the database winner while other live branches still need a decision. Every mutating action rechecks that its selected revision is still live. Applying a logical deletion to an existing Vault file requires confirmation; a logical-deletion winner with no Vault file already agrees and is omitted.
The repair card uses compact diagnostic rows which remain readable in a narrow mobile settings pane. `🧩 Missing chunks: N` marks an unreadable revision. In the database row, `Δsize` means decoded size minus recorded size; `Δsize vs DB` means Vault size minus decoded database size; and `Δtime` means Vault modification time minus database modification time. These are diagnostic values, not a rule for deciding which revision is correct. `✅ Vault matches winner · ⚠️ Conflicts: N` means that the current Vault bytes agree with the database winner while other conflict branches still need a decision. Every mutating action rechecks that its selected revision is still a current leaf. Applying a logical deletion to an existing Vault file requires confirmation; a logical-deletion winner with no Vault file already agrees and is omitted.
`Retry reading revision` does not change the revision tree. `Discard this branch` creates a logical deletion on one exact live revision while another live branch remains and leaves the current Vault file unchanged. If the discarded revision was recorded as the Vault's exact source, that stale device-local provenance is removed. `Discard unreadable revision` provides the corresponding explicit escape hatch for a sole unreadable live leaf. Neither action purges history or reconstructs missing content. An unavailable non-live ancestor cannot be deleted through this workflow; it disables conservative three-way merge but does not prevent explicit selection between readable live revisions.
`Retry reading revision` does not change the revision tree. `Discard this branch` creates a logical deletion on one exact current leaf while another current leaf remains and leaves the current Vault file unchanged. If the discarded revision was recorded as the Vault's exact source, that stale device-local provenance is removed. `Discard unreadable revision` provides the corresponding explicit escape hatch for a sole unreadable current leaf. Neither action purges history or reconstructs missing content. An unavailable ancestor which is not a current leaf cannot be deleted through this workflow; it disables conservative three-way merge but does not prevent explicit selection between readable current leaves.
`Recreate chunks for current Vault files` uses current Vault content. It cannot recreate unique bytes which exist only in an unreadable historical or conflict revision.
## A Metadata entry requires review
When **Inspect conflicts and file/database differences** reports `Metadata entry requires review and was left unchanged`, the local database contains Metadata whose stored document ID does not agree with the ID derived from its recorded path. LiveSync withholds that entry from ordinary file reflection and deletion rather than guessing which identity is intended. The inspection is local and does not query the remote.
Do not change file-name case handling or path obfuscation merely to make the displayed IDs agree. Follow [Repair a Metadata document ID mismatch](recovery.md#repair-a-metadata-document-id-mismatch) when the card offers **Repair this Metadata document ID**. If no repair action is offered, the entry is ambiguous, conflicted, deleted, outside the normal-file namespace, or otherwise unsafe for one-entry repair. Preserve the evidence and use the wider recovery guidance instead of forcing a target ID.
If many entries reflect deliberate folder-name or ID-derivation differences across devices, choose an authoritative Vault and use the established Rebuild workflow. A one-entry repair is not a distributed rename or database migration.
## A configuration mismatch dialogue blocks synchronisation
Some settings must match across devices. LiveSync pauses synchronisation when the local and remote values differ rather than propagating an unexpected change silently.
@@ -133,7 +141,7 @@ Browser security errors, particularly CORS failures, may reach the plug-in only
LiveSync stores file metadata, chunks, revision history, conflicts, deletions, and tombstones. Deleting or shortening a file therefore does not immediately remove every object which once represented it.
Garbage Collection V3 can remove unreferenced chunks from a healthy CouchDB setup, but it is appropriate only when the Vault and local database are healthy and all relevant devices have synchronised. Current files and live conflict branches protect their required chunks; an ordinary superseded revision does not. Tombstones and retained metadata are not free, so Garbage Collection does not guarantee a minimal database. Review the [Garbage Collection V3 specification](specs_garbage_collection.md) before using it.
Garbage Collection V3 can remove unreferenced chunks from a healthy CouchDB setup, but it is appropriate only when the Vault and local database are healthy and all relevant devices have synchronised. Current files and conflict branches protect their required chunks; an ordinary superseded revision does not. Tombstones and retained metadata are not free, so Garbage Collection does not guarantee a minimal database. Review the [Garbage Collection V3 specification](specs_garbage_collection.md) before using it.
`Overwrite Server Data with This Device's Files` is a separate rebuild operation and is the more certain way to reconstruct a central remote from a chosen authoritative Vault. It is also destructive and may discard changes which exist only on another device. Review [Recovery and flag files](recovery.md#garbage-collection-is-not-rebuild) before choosing between them.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "1.0.10",
"version": "1.0.15",
"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",
+40 -41
View File
@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
"version": "1.0.10",
"version": "1.0.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "1.0.10",
"version": "1.0.15",
"license": "MIT",
"workspaces": [
"src/apps/cli",
@@ -23,7 +23,7 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.9",
"@vrtmrz/livesync-commonlib": "0.1.17",
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
@@ -32,7 +32,7 @@
"markdown-it": "^14.2.0",
"minimatch": "^10.2.5",
"obsidian": "^1.13.1",
"octagonal-wheels": "^0.1.52",
"octagonal-wheels": "^0.1.53",
"qrcode-generator": "^1.4.4",
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
},
@@ -988,7 +988,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -1353,7 +1352,6 @@
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
@@ -1365,7 +1363,6 @@
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -2273,7 +2270,8 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@microsoft/eslint-plugin-sdl": {
"version": "1.1.0",
@@ -4048,7 +4046,6 @@
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.18.0"
}
@@ -4626,7 +4623,6 @@
"integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.8",
@@ -4775,9 +4771,9 @@
}
},
"node_modules/@vrtmrz/livesync-commonlib": {
"version": "0.1.9",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.9.tgz",
"integrity": "sha512-qacNUbRAqt32j756XTTdI5K+/co8oc2BKjzsUAhEsDHC8tc18q4a3cQrUHNJuxYO+nYnx0FKhf9lHMZcCX+Scg==",
"version": "0.1.17",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.17.tgz",
"integrity": "sha512-5EGZOKOfoe8jUKwr7Yecyxi6SuHYtMPpWVygKTtkuC8Nf9ZayvYqd6R3VpPqtRoD/nD1KqxrbwJzUbS+qAJgHw==",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.808.0",
@@ -4793,7 +4789,7 @@
"idb": "^8.0.3",
"markdown-it": "^14.2.0",
"minimatch": "^10.2.5",
"octagonal-wheels": "^0.1.51",
"octagonal-wheels": "^0.1.53",
"pouchdb-adapter-http": "^9.0.0",
"pouchdb-adapter-idb": "^9.0.0",
"pouchdb-adapter-indexeddb": "^9.0.0",
@@ -5138,7 +5134,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6030,7 +6025,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -6502,7 +6496,8 @@
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
@@ -7344,7 +7339,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -7476,7 +7470,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -10036,7 +10029,6 @@
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -11510,7 +11502,6 @@
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.13.1.tgz",
"integrity": "sha512-qtTEA2pmhJzhuhJqzbBFRYhpIOqvW+krDYjtFynv66KbxBbumHBlsJfWw3I4jtnK/6fZwbQhCrmmDdRwXmX56w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/codemirror": "5.60.8",
"moment": "2.29.4"
@@ -11532,9 +11523,9 @@
"license": "MIT"
},
"node_modules/octagonal-wheels": {
"version": "0.1.52",
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.52.tgz",
"integrity": "sha512-9WJN2UveNh90Op1S07cIso1WyNrQbO/unibDLfUGnpomIcU4g6F+p8reZHW0Ed8sGzKF5qGnQp991Y9MB1TwNg==",
"version": "0.1.53",
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.53.tgz",
"integrity": "sha512-4NJsb96Sk6rJXhrTyjAY5GRIoWMFJsFp56b5ba9fV8/87ys2HCjMo0hior4F8k4ma72pLTJT7i6DvuihHxSsMA==",
"license": "MIT",
"dependencies": {
"idb": "^8.0.3"
@@ -12001,7 +11992,6 @@
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright-core": "1.61.0"
},
@@ -12058,7 +12048,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
@@ -13752,7 +13741,8 @@
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/sublevel-pouchdb": {
"version": "9.0.0",
@@ -13821,7 +13811,6 @@
"integrity": "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -13887,6 +13876,21 @@
}
}
},
"node_modules/svelte-check/node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/svelte-eslint-parser": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.8.0.tgz",
@@ -14060,7 +14064,6 @@
"integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
@@ -14158,7 +14161,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14457,7 +14459,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -14525,7 +14526,6 @@
"integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/types": "8.61.1",
@@ -14855,7 +14855,6 @@
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -14982,7 +14981,6 @@
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.1.8",
"@vitest/mocker": "4.1.8",
@@ -15097,7 +15095,8 @@
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/wait-port": {
"version": "1.1.0",
@@ -15924,11 +15923,11 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
"version": "1.0.10-cli",
"version": "1.0.15-cli",
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
"octagonal-wheels": "^0.1.52",
"octagonal-wheels": "^0.1.53",
"pouchdb-adapter-http": "^9.0.0",
"pouchdb-adapter-leveldb": "^9.0.0",
"pouchdb-core": "^9.0.0",
@@ -15949,9 +15948,9 @@
},
"src/apps/webapp": {
"name": "livesync-webapp",
"version": "1.0.10-webapp",
"version": "1.0.15-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.52"
"octagonal-wheels": "^0.1.53"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
@@ -15961,9 +15960,9 @@
}
},
"src/apps/webpeer": {
"version": "1.0.10-webpeer",
"version": "1.0.15-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.52"
"octagonal-wheels": "^0.1.53"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "1.0.10",
"version": "1.0.15",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js",
"type": "module",
@@ -59,6 +59,7 @@
"test:e2e:obsidian:conflict-dialog-policy": "tsx test/e2e-obsidian/scripts/conflict-dialog-policy.ts",
"test:e2e:obsidian:revision-repair": "tsx test/e2e-obsidian/scripts/revision-repair.ts",
"test:e2e:obsidian:document-history-nav": "tsx test/e2e-obsidian/scripts/document-history-nav.ts",
"test:e2e:obsidian:document-history-restore": "tsx test/e2e-obsidian/scripts/document-history-restore.ts",
"test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts",
"test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts",
"test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts",
@@ -177,7 +178,7 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.9",
"@vrtmrz/livesync-commonlib": "0.1.17",
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
@@ -186,7 +187,7 @@
"markdown-it": "^14.2.0",
"minimatch": "^10.2.5",
"obsidian": "^1.13.1",
"octagonal-wheels": "^0.1.52",
"octagonal-wheels": "^0.1.53",
"qrcode-generator": "^1.4.4",
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
},
+5 -1
View File
@@ -58,7 +58,11 @@ async function verifyRemoteState(
standardIo.writeStderr(`[Verification] Failed to connect to remote CouchDB: ${dbRet}\n`);
return false;
}
milestone = await dbRet.db.get(MILESTONE_DOCID);
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");
}
@@ -708,6 +708,18 @@ describe("runCommand abnormal cases", () => {
describe("mark-resolved and unlock-remote commands", () => {
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,
@@ -715,6 +727,7 @@ describe("runCommand abnormal cases", () => {
expect(result).toBe(true);
expect(core.services.replication.markResolved).toHaveBeenCalledTimes(1);
expect(core.services.control.applySettings).not.toHaveBeenCalled();
expect(remoteDatabase.close).toHaveBeenCalledOnce();
});
it("mark-resolved with remote-id temporarily activates it and runs markResolved", async () => {
@@ -121,8 +121,10 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
return {
path: path.relative(this.basePath, filePath).replace(/\\/g, "/") as FilePath,
stat: {
ctime: stats?.ctimeMs ?? Date.now(),
mtime: stats?.mtimeMs ?? Date.now(),
// Floor to integer milliseconds; Linux fs.Stats.*Ms carry sub-millisecond
// precision, and timestamps are stored as integer ms everywhere else.
ctime: Math.floor(stats?.ctimeMs ?? Date.now()),
mtime: Math.floor(stats?.mtimeMs ?? Date.now()),
size: stats?.size ?? 0,
type: "file",
},
@@ -84,6 +84,27 @@ describe("CLIStorageEventManagerAdapter", () => {
expect(created.stat?.size).toBe(42);
});
it("floors sub-millisecond stat timestamps so mobile clients do not receive floats", async () => {
const basePath = "/vault/base";
const adapter = new CLIStorageEventManagerAdapter(basePath, undefined, true);
const handlers = makeHandlers();
await adapter.watch.beginWatch(handlers);
const addCallback = mockWatcher.on.mock.calls.find(([event]) => event === "add")![1] as (
filePath: string,
stats: any
) => void;
// Linux fs.Stats carry nanosecond-derived sub-millisecond precision.
const floatStats = { ctimeMs: 1778511180024.462, mtimeMs: 1778511180999.913, size: 7 };
addCallback(`${basePath}/note.md`, floatStats);
const created = (handlers.onCreate as ReturnType<typeof vi.fn>).mock.calls[0][0] as NodeFile;
expect(created.stat?.ctime).toBe(1778511180024);
expect(created.stat?.mtime).toBe(1778511180999);
});
it("close() calls watcher.close()", async () => {
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
const handlers = makeHandlers();
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "1.0.10-cli",
"version": "1.0.15-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
@@ -37,7 +37,7 @@
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
"octagonal-wheels": "^0.1.52",
"octagonal-wheels": "^0.1.53",
"pouchdb-adapter-http": "^9.0.0",
"pouchdb-adapter-leveldb": "^9.0.0",
"pouchdb-core": "^9.0.0",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "1.0.10-webapp",
"version": "1.0.15-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
@@ -15,7 +15,7 @@
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webapp/browser-smoke.test.ts"
},
"dependencies": {
"octagonal-wheels": "^0.1.52"
"octagonal-wheels": "^0.1.53"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "1.0.10-webpeer",
"version": "1.0.15-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
@@ -15,7 +15,7 @@
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webpeer/browser-smoke.test.ts"
},
"dependencies": {
"octagonal-wheels": "^0.1.52"
"octagonal-wheels": "^0.1.53"
},
"devDependencies": {
"eslint-plugin-svelte": "^3.19.0",
@@ -150,9 +150,38 @@ export const liveSyncProvisionalEnglishMessages = {
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.",
"Resolve all conflicts by the newest version": "Resolve all conflicts by the newest version",
"Inspect conflicts and file/database differences": "Inspect conflicts and file/database differences",
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.":
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.",
"Scan Vault files and local-database Metadata for conflicts, missing chunks, identity mismatches, and differences. Each result provides actions for one exact entry or revision.":
"Scan Vault files and local-database Metadata for conflicts, missing chunks, identity mismatches, and differences. Each result provides actions for one exact entry or revision.",
"Begin inspection": "Begin inspection",
"Metadata entry requires review and was left unchanged": "Metadata entry requires review and was left unchanged",
"The stored document ID does not match the ID derived from its recorded path.":
"The stored document ID does not match the ID derived from its recorded path.",
"The stored document ID and recorded path are handled by different synchronisation features.":
"The stored document ID and recorded path are handled by different synchronisation features.",
"Stored document ID: ${ID}": "Stored document ID: ${ID}",
"Expected document ID: ${ID}": "Expected document ID: ${ID}",
"Source revision: ${REVISION}": "Source revision: ${REVISION}",
"One-step repair is unavailable because this entry is ambiguous, no longer current, or unsafe to change.":
"One-step repair is unavailable because this entry is ambiguous, no longer current, or unsafe to change.",
"An exact target is already present; repair can remove the obsolete ID.":
"An exact target is already present; repair can remove the obsolete ID.",
"Repair is available for this entry.": "Repair is available for this entry.",
"Repair this Metadata document ID": "Repair this Metadata document ID",
"Repair Metadata ID": "Repair Metadata ID",
"Keep unchanged": "Keep unchanged",
"Repair Metadata document ID": "Repair Metadata document ID",
"This moves one local Metadata entry to the ID derived from its recorded path.\n\n**File:** `${FILE}` \n**Source:** `${SOURCE}@${REVISION}` \n**Target:** `${TARGET}`\n\nThe target is verified before the source is removed. Its CouchDB revision ancestry cannot be preserved.\n\n> [!warning] Before repairing\n> - Back up this device.\n> - If file-name case or path obfuscation was intentionally changed for the whole database, use Rebuild instead.\n> - If other devices share this database, pause them, allow this device to upload the repair, then resume them one at a time.":
"This moves one local Metadata entry to the ID derived from its recorded path.\n\n**File:** `${FILE}` \n**Source:** `${SOURCE}@${REVISION}` \n**Target:** `${TARGET}`\n\nThe target is verified before the source is removed. Its CouchDB revision ancestry cannot be preserved.\n\n> [!warning] Before repairing\n> - Back up this device.\n> - If file-name case or path obfuscation was intentionally changed for the whole database, use Rebuild instead.\n> - If other devices share this database, pause them, allow this device to upload the repair, then resume them one at a time.",
"Metadata document ID repair and the ordinary Vault scan completed. Run this inspection again after synchronisation.":
"Metadata document ID repair and the ordinary Vault scan completed. Run this inspection again after synchronisation.",
"Metadata document ID repair completed, but the ordinary Vault scan did not run. Keep synchronisation paused, resolve the scan condition, then run 'Scan storage and database again'.":
"Metadata document ID repair completed, but the ordinary Vault scan did not run. Keep synchronisation paused, resolve the scan condition, then run 'Scan storage and database again'.",
"The inspected state changed. No repair was performed; run inspection again.":
"The inspected state changed. No repair was performed; run inspection again.",
"Repair stopped after creating the target. The source was retained. Run inspection again before retrying.":
"Repair stopped after creating the target. The source was retained. Run inspection again before retrying.",
"Repair failed before the source was removed. Run inspection again before retrying.":
"Repair failed before the source was removed. Run inspection again before retrying.",
"Connection settings": "Connection settings",
"Saved connections": "Saved connections",
} as const;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -95,18 +95,19 @@ function requiredEnvironment(name: "hostname" | "username" | "password"): string
describe("LocalDatabaseMaintenance Garbage Collection V3 with CouchDB", () => {
it("propagates collection safely, completes compaction, and permits content-addressed chunk recreation", async () => {
const databaseName = `livesync-gcv3-${crypto.randomUUID()}`;
const local = new PouchDB<FixtureContent>(`${databaseName}-source`, { adapter: "memory" });
const replica = new PouchDB<FixtureContent>(`${databaseName}-replica`, { adapter: "memory" });
const remote = new PouchDB<FixtureContent>(
`${requiredEnvironment("hostname").replace(/\/+$/u, "")}/${databaseName}`,
{
const createRemote = () =>
new PouchDB<FixtureContent>(`${requiredEnvironment("hostname").replace(/\/+$/u, "")}/${databaseName}`, {
adapter: "http",
auth: {
username: requiredEnvironment("username"),
password: requiredEnvironment("password"),
},
}
);
});
const local = new PouchDB<FixtureContent>(`${databaseName}-source`, { adapter: "memory" });
const replica = new PouchDB<FixtureContent>(`${databaseName}-replica`, { adapter: "memory" });
const remote = createRemote();
const maintenanceRemote = createRemote();
const closeMaintenanceRemote = vi.spyOn(maintenanceRemote, "close");
try {
await remote.info();
@@ -178,7 +179,7 @@ describe("LocalDatabaseMaintenance Garbage Collection V3 with CouchDB", () => {
},
})
),
connectRemoteCouchDBWithSetting: vi.fn(() => Promise.resolve({ db: remote })),
connectRemoteCouchDBWithSetting: vi.fn(() => Promise.resolve({ db: maintenanceRemote })),
};
const notice = vi.fn();
const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
@@ -201,6 +202,7 @@ describe("LocalDatabaseMaintenance Garbage Collection V3 with CouchDB", () => {
await maintenance.gcv3();
expect(closeMaintenanceRemote).toHaveBeenCalledOnce();
expect(replicationModes).toEqual(["sync", "pushOnly"]);
expect(clearHash).toHaveBeenCalledOnce();
expect(notice).toHaveBeenCalledWith("Compaction on remote database completed successfully.", "gc-compact");
@@ -251,6 +253,9 @@ describe("LocalDatabaseMaintenance Garbage Collection V3 with CouchDB", () => {
data: "recreated",
});
} finally {
if (closeMaintenanceRemote.mock.calls.length === 0) {
await maintenanceRemote.close();
}
await Promise.all([local.destroy(), replica.destroy(), remote.destroy()]);
}
}, 30_000);
@@ -747,29 +747,33 @@ Success: ${successCount}, Errored: ${errored}`;
this._notice(`Failed to connect to remote for compaction. ${remote}`, "gc-compact");
return;
}
const compactResult = await remote.db.compact({
interval: 1000,
});
// Probably no need to wait, but just in case.
let timeout = 2 * 60 * 1000; // 2 minutes
for (;;) {
const status = await remote.db.info();
if ("compact_running" in status && status?.compact_running) {
this._notice("Compaction in progress on remote database...", "gc-compact");
await delay(2000);
timeout -= 2000;
if (timeout <= 0) {
this._notice("Compaction on remote database timed out.", "gc-compact");
return;
try {
const compactResult = await remote.db.compact({
interval: 1000,
});
// Probably no need to wait, but just in case.
let timeout = 2 * 60 * 1000; // 2 minutes
for (;;) {
const status = await remote.db.info();
if ("compact_running" in status && status?.compact_running) {
this._notice("Compaction in progress on remote database...", "gc-compact");
await delay(2000);
timeout -= 2000;
if (timeout <= 0) {
this._notice("Compaction on remote database timed out.", "gc-compact");
return;
}
} else {
break;
}
} else {
break;
}
}
if (compactResult && "ok" in compactResult) {
this._notice("Compaction on remote database completed successfully.", "gc-compact");
} else {
this._notice("Compaction on remote database failed.", "gc-compact");
if (compactResult && "ok" in compactResult) {
this._notice("Compaction on remote database completed successfully.", "gc-compact");
} else {
this._notice("Compaction on remote database failed.", "gc-compact");
}
} finally {
await remote.db.close();
}
}
@@ -222,6 +222,7 @@ describe("LocalDatabaseMaintenance Garbage Collection V3", () => {
const remoteDatabase = {
compact: vi.fn(async () => ({ ok: true })),
info: vi.fn(async () => ({ compact_running: true })),
close: vi.fn(async () => undefined),
};
Object.assign(maintenance, {
core: {
@@ -243,6 +244,7 @@ describe("LocalDatabaseMaintenance Garbage Collection V3", () => {
"Compaction on remote database completed successfully.",
"gc-compact"
);
expect(remoteDatabase.close).toHaveBeenCalledOnce();
});
it.each([
+22 -18
View File
@@ -187,27 +187,31 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
return false;
}
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
this.localDatabase.clearCaches();
// Perform the synchronisation once.
const replicated = await this.services.replicator.runFiniteReplicationActivity(
() => this.core.replicator.openReplication(this.settings, false, showMessage, true),
{ label: "replication" }
);
if (replicated) {
await balanceChunkPurgedDBs(this.localDatabase.localDatabase, remoteDB.db);
try {
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
this.localDatabase.clearCaches();
await this.services.replicator.getActiveReplicator()?.markRemoteResolved(this.settings);
Logger(
"The local database has been cleaned up.",
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
);
} else {
Logger(
"Replication has been cancelled. Please try it again.",
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
// Perform the synchronisation once.
const replicated = await this.services.replicator.runFiniteReplicationActivity(
() => this.core.replicator.openReplication(this.settings, false, showMessage, true),
{ label: "replication" }
);
if (replicated) {
await balanceChunkPurgedDBs(this.localDatabase.localDatabase, remoteDB.db);
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
this.localDatabase.clearCaches();
await this.services.replicator.getActiveReplicator()?.markRemoteResolved(this.settings);
Logger(
"The local database has been cleaned up.",
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
);
} else {
Logger(
"Replication has been cancelled. Please try it again.",
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
);
}
} finally {
await remoteDB.db.close();
}
},
{ label: "database-cleanup" }
@@ -128,8 +128,11 @@ describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", (
});
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
const openReplication = vi.fn(async () => true);
const remoteDatabase = {
close: vi.fn(async () => undefined),
};
const activeReplicator = Object.assign(new LiveSyncCouchDBReplicator({} as any), {
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: {} })),
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase })),
markRemoteResolved: vi.fn(async () => undefined),
});
const services = {
@@ -177,5 +180,9 @@ describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", (
expect(openReplication).toHaveBeenCalledOnce();
expect(openReplication.mock.invocationCallOrder[0]).toBeLessThan(activityFinished.mock.invocationCallOrder[0]);
expect(chunkMocks.balanceChunkPurgedDBs).toHaveBeenCalledOnce();
expect(remoteDatabase.close).toHaveBeenCalledOnce();
expect(remoteDatabase.close.mock.invocationCallOrder[0]).toBeLessThan(
activityFinished.mock.invocationCallOrder[0]
);
});
});
@@ -12,7 +12,7 @@ import {
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { isErrorOfMissingDoc } from "@vrtmrz/livesync-commonlib/compat/pouchdb/utils_couchdb";
import { fireAndForget, getDocData, readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { fireAndForget, getDocData } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { isPlainText, stripPrefix } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { scheduleOnceIfDuplicated } from "octagonal-wheels/concurrency/lock";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
@@ -23,6 +23,10 @@ import {
saveDocumentHistoryPreference,
} from "./documentHistoryPreferences.ts";
import type PouchDB from "pouchdb-core";
import {
restoreDocumentHistoryRevision,
type DocumentHistoryRestorationResult,
} from "@/serviceFeatures/documentHistoryRestoration";
function isImage(path: string) {
const ext = path.split(".").splice(-1)[0].toLowerCase();
@@ -277,6 +281,7 @@ export class DocumentHistoryModal extends Modal {
async showExactRev(rev: string) {
const db = this.core.localDatabase;
this.currentDoc = undefined;
const w = await db.getDBEntry(this.file, { rev: rev }, false, false, true);
this.currentText = "";
this.currentDeleted = false;
@@ -702,7 +707,6 @@ export class DocumentHistoryModal extends Modal {
e.addClass("mod-cta");
e.addEventListener("click", () => {
fireAndForget(async () => {
// const pathToWrite = this.plugin.id2path(this.id, true);
const pathToWrite = stripPrefix(this.file);
if (!isValidPath(pathToWrite)) {
Logger("Path is not valid to write content.", LOG_LEVEL_INFO);
@@ -712,9 +716,94 @@ export class DocumentHistoryModal extends Modal {
Logger("No active file loaded.", LOG_LEVEL_INFO);
return;
}
const d = readContent(this.currentDoc);
await this.core.storageAccess.writeHiddenFileAuto(pathToWrite, d);
await focusFile(pathToWrite);
const sourceRevision = this.currentDoc._rev;
if (!sourceRevision) {
Logger("The selected revision does not have a revision identifier.", LOG_LEVEL_NOTICE);
return;
}
e.disabled = true;
let result: DocumentHistoryRestorationResult;
try {
result = await restoreDocumentHistoryRevision(this.core, this.file, sourceRevision, {
isPathValid: isValidPath,
});
} catch (ex) {
Logger(
"Restoring the selected revision failed before Vault reflection completed. Review the file with 'Inspect conflicts and file/database differences' before retrying.",
LOG_LEVEL_NOTICE
);
Logger(ex, LOG_LEVEL_VERBOSE);
e.disabled = false;
return;
}
if (result.status === "source-unavailable") {
Logger(
"The selected revision could not be restored because its content is no longer available.",
LOG_LEVEL_NOTICE
);
e.disabled = false;
return;
}
if (result.status === "current-unavailable") {
Logger(
"The current database revision could not be read. The Vault was not changed.",
LOG_LEVEL_NOTICE
);
e.disabled = false;
return;
}
if (result.status === "unsupported-path") {
Logger(
"Only an ordinary valid Vault path can be restored from Document History.",
LOG_LEVEL_NOTICE
);
e.disabled = false;
return;
}
if (result.status === "database-write-refused") {
Logger(
"The restored revision could not be created. The file may have changed during the operation, and the Vault was not changed.",
LOG_LEVEL_NOTICE
);
e.disabled = false;
return;
}
if (result.status === "stored-not-reflected") {
Logger(
"The restored revision was saved in the local database, but it could not be reflected to the Vault. Use 'Inspect conflicts and file/database differences' to review and apply it.",
LOG_LEVEL_NOTICE
);
if (result.cause) {
Logger(result.cause, LOG_LEVEL_VERBOSE);
}
this.close();
return;
}
if (result.conflictCheckError) {
Logger(result.conflictCheckError, LOG_LEVEL_VERBOSE);
}
if (result.conflictsRemain === true) {
Logger(
"The selected content was restored as a new revision. Other versions remain unresolved; use 'Inspect conflicts and file/database differences' to review them.",
LOG_LEVEL_NOTICE
);
} else if (result.conflictsRemain === false) {
Logger("The selected content was restored as a new revision.", LOG_LEVEL_NOTICE);
} else {
Logger(
"The selected content was restored as a new revision. LiveSync could not confirm whether other versions remain; use 'Inspect conflicts and file/database differences' to review the file.",
LOG_LEVEL_NOTICE
);
}
try {
await focusFile(result.path);
} catch (ex) {
Logger("The restored file could not be opened in the editor.", LOG_LEVEL_NOTICE);
Logger(ex, LOG_LEVEL_VERBOSE);
}
this.close();
});
});
@@ -547,7 +547,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
if (typeof db === "string") {
Logger($msg("obsidianLiveSyncSettingTab.logCheckPassphraseFailed", { db }), LOG_LEVEL_NOTICE);
return false;
} else {
}
try {
if (await checkSyncInfo(db.db)) {
// Logger($msg("obsidianLiveSyncSettingTab.logDatabaseConnected"), LOG_LEVEL_NOTICE);
return true;
@@ -555,6 +556,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
Logger($msg("obsidianLiveSyncSettingTab.logPassphraseNotCompatible"), LOG_LEVEL_NOTICE);
return false;
}
} finally {
await db.db.close();
}
};
isPassphraseValid = async () => {
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
const negotiationMocks = vi.hoisted(() => ({
checkSyncInfo: vi.fn(async () => true),
}));
vi.mock("@/deps.ts", () => ({
App: class {},
Component: class {},
PluginSettingTab: class {},
}));
vi.mock("@/main.ts", () => ({ default: class {} }));
vi.mock("@/common/events.ts", () => ({
EVENT_REQUEST_RELOAD_SETTING_TAB: "request-reload-setting-tab",
eventHub: { onEvent: vi.fn() },
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation", () => negotiationMocks);
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
LiveSyncCouchDBReplicator: class {},
}));
vi.mock("./LiveSyncSetting.ts", () => ({ LiveSyncSetting: class {} }));
vi.mock("./SettingPane.ts", () => ({
enableOnly: vi.fn(() => vi.fn()),
setLevelClass: vi.fn(),
setStyle: vi.fn(),
visibleOnly: vi.fn(() => vi.fn()),
}));
vi.mock("./PaneChangeLog.ts", () => ({ paneChangeLog: vi.fn() }));
vi.mock("./PaneSetup.ts", () => ({ paneSetup: vi.fn() }));
vi.mock("./PaneGeneral.ts", () => ({ paneGeneral: vi.fn() }));
vi.mock("./PaneRemoteConfig.ts", () => ({ paneRemoteConfig: vi.fn() }));
vi.mock("./PaneSelector.ts", () => ({ paneSelector: vi.fn() }));
vi.mock("./PaneSyncSettings.ts", () => ({ paneSyncSettings: vi.fn() }));
vi.mock("./PaneCustomisationSync.ts", () => ({ paneCustomisationSync: vi.fn() }));
vi.mock("./PaneHatch.ts", () => ({ paneHatch: vi.fn() }));
vi.mock("./PaneAdvanced.ts", () => ({ paneAdvanced: vi.fn() }));
vi.mock("./PanePowerUsers.ts", () => ({ panePowerUsers: vi.fn() }));
vi.mock("./PanePatches.ts", () => ({ panePatches: vi.fn() }));
vi.mock("./PaneMaintenance.ts", () => ({ paneMaintenance: vi.fn() }));
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
it("closes the finite remote connection after checking synchronisation information", async () => {
const remoteDatabase = {
close: vi.fn(async () => undefined),
};
const replicator = Object.assign(new LiveSyncCouchDBReplicator({} as never), {
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase })),
});
const plugin = {
app: {},
core: {
services: {
API: { isMobile: vi.fn(() => false) },
replicator: { getNewReplicator: vi.fn(() => replicator) },
},
},
};
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
Object.assign(tab, {
_editingSettings: {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
},
});
await expect(tab.checkWorkingPassphrase()).resolves.toBe(true);
expect(negotiationMocks.checkSyncInfo).toHaveBeenCalledWith(remoteDatabase);
expect(remoteDatabase.close).toHaveBeenCalledOnce();
});
});
@@ -7,7 +7,7 @@ import {
type EntryDoc,
type diff_result,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createBlob, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { createBlob, escapeMarkdownValue, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { shouldBeIgnored } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { Menu, diff_match_patch, setIcon } from "@/deps.ts";
@@ -44,6 +44,21 @@ import {
getFileRepairRevisionComparison,
} from "@/serviceFeatures/fileRepairPresentation.ts";
import { ConflictResolveModal } from "@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
import {
inspectMetadataDocumentIdentities,
MetadataDocumentRepairResults,
OfflineScanUnresolvedReasons,
repairMetadataDocumentIdentity,
type MetadataDocumentIdentityIssue,
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import {
metadataIdentityPathKey,
selectUnresolvedMetadataIdentityEntries,
} from "@/serviceFeatures/metadataIdentityInspection.ts";
import {
executeMetadataIdentityRepair,
MetadataIdentityRepairExecutions,
} from "@/serviceFeatures/metadataIdentityRepair.ts";
export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
// const hatchWarn = this.createEl(paneEl, "div", { text: `To stop the boot up sequence for fixing problems on databases, you can put redflag.md on top of your vault (Rebooting obsidian is required).` });
// hatchWarn.addClass("op-warn-info");
@@ -178,6 +193,150 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
});
});
};
const addMetadataIdentityResult = (entry: MetadataDocumentIdentityIssue) => {
const { diagnostic } = entry.inspection;
const card = this.createEl(resultArea, "div", { cls: "sls-repair-result" });
this.createEl(card, "h6", { text: diagnostic.declaredPath });
this.createEl(card, "div", {
text: $msg("Metadata entry requires review and was left unchanged"),
cls: "sls-repair-status-warning",
});
this.createEl(card, "div", {
text:
diagnostic.reason === OfflineScanUnresolvedReasons.DOCUMENT_ID_MISMATCH
? $msg("The stored document ID does not match the ID derived from its recorded path.")
: $msg(
"The stored document ID and recorded path are handled by different synchronisation features."
),
cls: "sls-repair-metric",
});
this.createEl(card, "div", {
text: $msg("Stored document ID: ${ID}", { ID: diagnostic.actualDocumentId }),
cls: "sls-repair-metric",
});
if (diagnostic.expectedDocumentId !== undefined) {
this.createEl(card, "div", {
text: $msg("Expected document ID: ${ID}", { ID: diagnostic.expectedDocumentId }),
cls: "sls-repair-metric",
});
}
this.createEl(card, "div", {
text: $msg("Source revision: ${REVISION}", {
REVISION: entry.sourceRevision ?? $msg("Unknown revision"),
}),
cls: "sls-repair-metric",
});
if (entry.logicallyDeleted) {
this.createEl(card, "div", {
text: $msg("🗑️ Logical deletion"),
cls: "sls-repair-metric mod-warning",
});
}
if (entry.conflictRevisions.length > 0) {
this.createEl(card, "div", {
text: $msg("⚠️ Conflicts: ${COUNT}", { COUNT: `${entry.conflictRevisions.length}` }),
cls: "sls-repair-metric mod-warning",
});
}
if (
!entry.repairAvailable ||
diagnostic.expectedDocumentId === undefined ||
entry.sourceRevision === null
) {
this.createEl(card, "div", {
text: $msg(
"One-step repair is unavailable because this entry is ambiguous, no longer current, or unsafe to change."
),
cls: "sls-repair-metric mod-warning",
});
return;
}
this.createEl(card, "div", {
text: entry.targetAlreadyPresent
? $msg("An exact target is already present; repair can remove the obsolete ID.")
: $msg("Repair is available for this entry."),
cls: "sls-repair-status-ok",
});
const request = {
actualDocumentId: diagnostic.actualDocumentId,
expectedDocumentId: diagnostic.expectedDocumentId,
sourceRevision: entry.sourceRevision,
};
const repairAction = $msg("Repair Metadata ID");
const keepAction = $msg("Keep unchanged");
addActionMenu(card, $msg("More actions for ${FILE}", { FILE: diagnostic.declaredPath }), [
{
title: $msg("Repair this Metadata document ID"),
warning: true,
run: async () => {
const execution = await executeMetadataIdentityRepair(request, {
confirm: async () =>
(await this.core.confirm.confirmWithMessage(
$msg("Repair Metadata document ID"),
$msg(
"This moves one local Metadata entry to the ID derived from its recorded path.\n\n**File:** `${FILE}` \n**Source:** `${SOURCE}@${REVISION}` \n**Target:** `${TARGET}`\n\nThe target is verified before the source is removed. Its CouchDB revision ancestry cannot be preserved.\n\n> [!warning] Before repairing\n> - Back up this device.\n> - If file-name case or path obfuscation was intentionally changed for the whole database, use Rebuild instead.\n> - If other devices share this database, pause them, allow this device to upload the repair, then resume them one at a time.",
{
FILE: escapeMarkdownValue(diagnostic.declaredPath),
SOURCE: escapeMarkdownValue(diagnostic.actualDocumentId),
REVISION: escapeMarkdownValue(entry.sourceRevision!),
TARGET: escapeMarkdownValue(diagnostic.expectedDocumentId!),
}
),
[repairAction, keepAction],
keepAction,
undefined,
"vertical"
)) === repairAction,
repair: async (repairRequest) =>
await repairMetadataDocumentIdentity(this.core, repairRequest),
requestOrdinaryScan: async () => await this.services.vault.scanVault(true, false),
});
if (execution.status === MetadataIdentityRepairExecutions.CANCELLED) return;
const result = execution.result;
if (result.message) Logger(result.message, LOG_LEVEL_VERBOSE);
if (result.status === MetadataDocumentRepairResults.COMPLETED) {
if (execution.scanError !== undefined) {
Logger(execution.scanError, LOG_LEVEL_VERBOSE);
}
resultArea.replaceChildren();
this.createEl(resultArea, "div", {
text: execution.scanCompleted
? $msg(
"Metadata document ID repair and the ordinary Vault scan completed. Run this inspection again after synchronisation."
)
: $msg(
"Metadata document ID repair completed, but the ordinary Vault scan did not run. Keep synchronisation paused, resolve the scan condition, then run 'Scan storage and database again'."
),
cls: execution.scanCompleted
? "sls-repair-status-ok"
: "sls-repair-metric mod-warning",
});
return;
}
const resultMessage =
result.status === MetadataDocumentRepairResults.STALE ||
result.status === MetadataDocumentRepairResults.BLOCKED
? $msg("The inspected state changed. No repair was performed; run inspection again.")
: result.targetCreated
? $msg(
"Repair stopped after creating the target. The source was retained. Run inspection again before retrying."
)
: $msg(
"Repair failed before the source was removed. Run inspection again before retrying."
);
this.createEl(card, "div", {
text: resultMessage,
cls: "sls-repair-metric mod-warning",
});
},
},
]);
};
const findHiddenFile = async (path: string) => {
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
if (!addOn) {
@@ -827,7 +986,7 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
.setName($msg("Inspect conflicts and file/database differences"))
.setDesc(
$msg(
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision."
"Scan Vault files and local-database Metadata for conflicts, missing chunks, identity mismatches, and differences. Each result provides actions for one exact entry or revision."
)
)
.addButton((button) =>
@@ -839,6 +998,13 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
resultArea.replaceChildren();
Logger("Start inspecting file/database state", LOG_LEVEL_NOTICE, "verify");
this.core.localDatabase.clearCaches();
const identityEntries = await inspectMetadataDocumentIdentities(this.core);
const handleFilenameCaseSensitive = this.core.settings.handleFilenameCaseSensitive;
const unresolvedIdentity = selectUnresolvedMetadataIdentityEntries(
identityEntries,
handleFilenameCaseSensitive
);
unresolvedIdentity.entries.forEach(addMetadataIdentityResult);
const allPaths = await collectFileDatabaseInfoPaths(this.core);
let i = 0;
const incProc = () => {
@@ -853,6 +1019,13 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
const semaphore = Semaphore(10);
const processes = allPaths.map(async (path) => {
try {
if (
unresolvedIdentity.unresolvedPathKeys.has(
metadataIdentityPathKey(path, handleFilenameCaseSensitive)
)
) {
return incProc();
}
if (shouldBeIgnored(path)) {
return incProc();
}
@@ -8,7 +8,7 @@ export type CouchDBConnectionProbeResult = { ok: true } | { ok: false; reason: s
type CouchDBConnectionResult =
| string
| {
db: unknown;
db: { close(): Promise<void> };
info: unknown;
};
@@ -50,7 +50,11 @@ export async function probeCouchDBConnection(
if (typeof result === "string") {
return { ok: false, reason: result };
}
return { ok: true };
try {
return { ok: true };
} finally {
await result.db.close();
}
}
export function isValidCouchDBServerURL(value: string): boolean {
@@ -14,8 +14,9 @@ describe("CouchDB setup connection policy", () => {
] as const)(
"%s can %s without changing the Commonlib connection contract",
async (createIfMissing, _description) => {
const close = vi.fn(async () => undefined);
const connectRemoteCouchDBWithSetting = vi.fn(async () => ({
db: {},
db: { close },
info: { db_name: "notes" },
}));
const replicator = {
@@ -27,6 +28,7 @@ describe("CouchDB setup connection policy", () => {
await expect(probeCouchDBConnection(replicator, settings, createIfMissing)).resolves.toEqual({ ok: true });
expect(connectRemoteCouchDBWithSetting).toHaveBeenCalledWith(settings, false, createIfMissing, false);
expect(replicator.tryConnectRemote).not.toHaveBeenCalled();
expect(close).toHaveBeenCalledOnce();
}
);
@@ -0,0 +1,98 @@
import type { FilePath, FilePathWithPrefix, UXFileInfo } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler";
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
export type DocumentHistoryRestorationCore = {
databaseFileAccess: Pick<
DatabaseFileAccess,
"fetchEntry" | "fetchEntryMeta" | "getConflictedRevs" | "storeWithLiveBaseRevision"
>;
fileHandler: Pick<IFileHandler, "dbToStorageWithSpecificRev">;
};
export type DocumentHistoryRestorationResult =
| {
status: "restored";
path: FilePath;
revision: string;
conflictsRemain: boolean | undefined;
conflictCheckError?: unknown;
}
| { status: "source-unavailable" }
| { status: "current-unavailable" }
| { status: "unsupported-path" }
| { status: "database-write-refused" }
| { status: "stored-not-reflected"; path: FilePath; revision: string; cause?: unknown };
export type DocumentHistoryRestorationOptions = {
now?: () => number;
isPathValid?: (path: FilePath) => boolean;
};
/**
* Restore historical content as a new child of the current database winner, then reflect
* that exact new revision to the Vault. The historical revision supplies content, not ancestry.
*/
export async function restoreDocumentHistoryRevision(
core: DocumentHistoryRestorationCore,
path: FilePathWithPrefix,
sourceRevision: string,
options: DocumentHistoryRestorationOptions = {}
): Promise<DocumentHistoryRestorationResult> {
const now = options.now ?? Date.now;
const isPathValid = options.isPathValid ?? (() => true);
const source = await core.databaseFileAccess.fetchEntry(path, sourceRevision, true, true);
if (source === false) {
return { status: "source-unavailable" };
}
const current = await core.databaseFileAccess.fetchEntryMeta(path, undefined, true);
if (current === false || !current._rev) {
return { status: "current-unavailable" };
}
const body = readAsBlob(source);
const storagePath = stripAllPrefixes(current.path);
if (storagePath !== current.path || !isPathValid(storagePath)) {
return { status: "unsupported-path" };
}
const file: UXFileInfo = {
name: storagePath.split("/").pop() ?? storagePath,
path: storagePath,
stat: {
ctime: current.ctime,
mtime: now(),
size: body.size,
type: "file",
},
body,
};
const revision = await core.databaseFileAccess.storeWithLiveBaseRevision(file, current._rev, true);
if (revision === false) {
return { status: "database-write-refused" };
}
try {
const reflected = await core.fileHandler.dbToStorageWithSpecificRev(storagePath, revision, true);
if (!reflected) {
return { status: "stored-not-reflected", path: storagePath, revision };
}
} catch (cause) {
return { status: "stored-not-reflected", path: storagePath, revision, cause };
}
try {
const conflictsRemain = (await core.databaseFileAccess.getConflictedRevs(storagePath)).length > 0;
return { status: "restored", path: storagePath, revision, conflictsRemain };
} catch (conflictCheckError) {
return {
status: "restored",
path: storagePath,
revision,
conflictsRemain: undefined,
conflictCheckError,
};
}
}
@@ -0,0 +1,246 @@
import { describe, expect, it, vi } from "vitest";
import type {
FilePath,
FilePathWithPrefix,
LoadedEntry,
MetaEntry,
UXFileInfo,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { restoreDocumentHistoryRevision, type DocumentHistoryRestorationCore } from "./documentHistoryRestoration";
const path = "history.md" as FilePathWithPrefix;
const currentPath = "History.md" as FilePathWithPrefix;
function createSource(): LoadedEntry {
return {
_id: "f:history",
_rev: "2-source",
path,
ctime: 10,
mtime: 20,
size: 18,
type: "plain",
datatype: "plain",
children: ["h:source"],
data: ["historical content"],
eden: {},
} as LoadedEntry;
}
function createCurrent(): MetaEntry {
return {
_id: "f:history",
_rev: "4-deleted",
path: currentPath,
ctime: 10,
mtime: 40,
size: 18,
type: "plain",
children: ["h:current"],
deleted: true,
eden: {},
} as MetaEntry;
}
function createCore(
overrides: {
source?: LoadedEntry | false;
current?: MetaEntry | false;
storedRevision?: string | false;
reflected?: boolean;
reflectionError?: unknown;
conflicts?: string[];
conflictCheckError?: unknown;
} = {}
) {
const calls: string[] = [];
const fetchEntry = vi.fn(async () => {
calls.push("read-source");
return overrides.source === undefined ? createSource() : overrides.source;
});
const fetchEntryMeta = vi.fn(async () => {
calls.push("read-current");
return overrides.current === undefined ? createCurrent() : overrides.current;
});
const storeWithLiveBaseRevision = vi.fn(async (_file: UXFileInfo) => {
calls.push("store");
return overrides.storedRevision === undefined ? "5-restored" : overrides.storedRevision;
});
const getConflictedRevs = vi.fn(async () => {
calls.push("check-conflicts");
if (overrides.conflictCheckError !== undefined) {
throw overrides.conflictCheckError;
}
return overrides.conflicts ?? [];
});
const dbToStorageWithSpecificRev = vi.fn(async () => {
calls.push("reflect");
if (overrides.reflectionError !== undefined) {
throw overrides.reflectionError;
}
return overrides.reflected ?? true;
});
const core: DocumentHistoryRestorationCore = {
databaseFileAccess: {
fetchEntry,
fetchEntryMeta,
getConflictedRevs,
storeWithLiveBaseRevision,
},
fileHandler: {
dbToStorageWithSpecificRev,
},
};
return {
calls,
core,
dbToStorageWithSpecificRev,
fetchEntry,
fetchEntryMeta,
getConflictedRevs,
storeWithLiveBaseRevision,
};
}
describe("restoreDocumentHistoryRevision", () => {
it("stores historical bytes below the current deleted winner before reflecting the exact new revision", async () => {
const { calls, core, dbToStorageWithSpecificRev, fetchEntry, fetchEntryMeta, storeWithLiveBaseRevision } =
createCore();
await expect(restoreDocumentHistoryRevision(core, path, "2-source", { now: () => 50 })).resolves.toEqual({
status: "restored",
path: "History.md" as FilePath,
revision: "5-restored",
conflictsRemain: false,
});
expect(calls).toEqual(["read-source", "read-current", "store", "reflect", "check-conflicts"]);
expect(fetchEntry).toHaveBeenCalledWith(path, "2-source", true, true);
expect(fetchEntryMeta).toHaveBeenCalledWith(path, undefined, true);
expect(storeWithLiveBaseRevision).toHaveBeenCalledWith(
expect.objectContaining({
name: "History.md",
path: "History.md",
stat: {
ctime: 10,
mtime: 50,
size: 18,
type: "file",
},
body: expect.any(Blob),
}),
"4-deleted",
true
);
const storedFile = storeWithLiveBaseRevision.mock.calls[0][0];
await expect(storedFile.body.text()).resolves.toBe("historical content");
expect(storedFile.deleted).toBeUndefined();
expect(dbToStorageWithSpecificRev).toHaveBeenCalledWith("History.md", "5-restored", true);
});
it("leaves the Vault unchanged when the conditional database write is refused", async () => {
const { calls, core, dbToStorageWithSpecificRev } = createCore({ storedRevision: false });
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
status: "database-write-refused",
});
expect(calls).toEqual(["read-source", "read-current", "store"]);
expect(dbToStorageWithSpecificRev).not.toHaveBeenCalled();
});
it("reports a stored revision when its subsequent Vault reflection fails", async () => {
const { core, storeWithLiveBaseRevision } = createCore({ reflected: false });
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
status: "stored-not-reflected",
path: "History.md" as FilePath,
revision: "5-restored",
});
expect(storeWithLiveBaseRevision).toHaveBeenCalledOnce();
});
it("reports a stored revision when its subsequent Vault reflection throws", async () => {
const reflectionError = new Error("adapter unavailable");
const { core, getConflictedRevs } = createCore({ reflectionError });
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
status: "stored-not-reflected",
path: "History.md" as FilePath,
revision: "5-restored",
cause: reflectionError,
});
expect(getConflictedRevs).not.toHaveBeenCalled();
});
it("reports remaining conflict leaves after restoration", async () => {
const { core, getConflictedRevs } = createCore({ conflicts: ["3-other"] });
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
status: "restored",
path: "History.md" as FilePath,
revision: "5-restored",
conflictsRemain: true,
});
expect(getConflictedRevs).toHaveBeenCalledWith("History.md");
});
it("does not turn a completed restoration into a failure when conflict inspection fails", async () => {
const conflictCheckError = new Error("inspection unavailable");
const { core } = createCore({ conflictCheckError });
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
status: "restored",
path: "History.md" as FilePath,
revision: "5-restored",
conflictsRemain: undefined,
conflictCheckError,
});
});
it("does not read or mutate the current tree when the historical source is unavailable", async () => {
const { core, fetchEntryMeta, storeWithLiveBaseRevision } = createCore({ source: false });
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
status: "source-unavailable",
});
expect(fetchEntryMeta).not.toHaveBeenCalled();
expect(storeWithLiveBaseRevision).not.toHaveBeenCalled();
});
it("does not write when the current winner cannot supply an exact base revision", async () => {
const current = { ...createCurrent(), _rev: undefined } as MetaEntry;
const { core, storeWithLiveBaseRevision } = createCore({ current });
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
status: "current-unavailable",
});
expect(storeWithLiveBaseRevision).not.toHaveBeenCalled();
});
it("refuses prefixed internal Metadata before creating a database revision", async () => {
const current = { ...createCurrent(), path: "i:.obsidian/config.json" as FilePathWithPrefix } as MetaEntry;
const { core, storeWithLiveBaseRevision } = createCore({ current });
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
status: "unsupported-path",
});
expect(storeWithLiveBaseRevision).not.toHaveBeenCalled();
});
it("uses the host path validator before creating a database revision", async () => {
const { core, storeWithLiveBaseRevision } = createCore();
await expect(
restoreDocumentHistoryRevision(core, path, "2-source", { isPathValid: () => false })
).resolves.toEqual({ status: "unsupported-path" });
expect(storeWithLiveBaseRevision).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,31 @@
import type { MetadataDocumentIdentityIssue } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
export function metadataIdentityPathKey(path: string, handleFilenameCaseSensitive: boolean): string {
const vaultPath = stripAllPrefixes(path as FilePathWithPrefix);
return handleFilenameCaseSensitive ? vaultPath : vaultPath.toLowerCase();
}
/**
* Select unresolved identity evidence for read-only presentation and create
* the path keys which must be withheld from ordinary path-based repair.
*/
export function selectUnresolvedMetadataIdentityEntries(
entries: readonly MetadataDocumentIdentityIssue[],
handleFilenameCaseSensitive: boolean
): {
entries: MetadataDocumentIdentityIssue[];
unresolvedPathKeys: ReadonlySet<string>;
} {
return {
entries: [...entries],
unresolvedPathKeys: new Set(
entries
.filter(({ ordinaryPathAvailable }) => !ordinaryPathAvailable)
.map(({ inspection }) =>
metadataIdentityPathKey(inspection.diagnostic.declaredPath, handleFilenameCaseSensitive)
)
),
};
}
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import type { MetadataDocumentIdentityIssue } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { metadataIdentityPathKey, selectUnresolvedMetadataIdentityEntries } from "./metadataIdentityInspection";
function createEntries(): MetadataDocumentIdentityIssue[] {
return [
{
inspection: {
status: "unresolved",
diagnostic: {
reason: "document-id-mismatch",
actualDocumentId: "f:stale",
declaredPath: "Folder/Renamed.md",
expectedDocumentId: "f:renamed",
actualNamespace: "normal",
declaredPathNamespace: "normal",
},
},
sourceRevision: "3-stale",
logicallyDeleted: false,
conflictRevisions: [],
repairAvailable: false,
targetAlreadyPresent: false,
ordinaryPathAvailable: false,
},
{
inspection: {
status: "unresolved",
diagnostic: {
reason: "namespace-mismatch",
actualDocumentId: "f:stale-internal-path",
declaredPath: "i:.Obsidian/App.json",
actualNamespace: "normal",
declaredPathNamespace: "internal",
},
},
sourceRevision: "2-stale",
logicallyDeleted: false,
conflictRevisions: [],
repairAvailable: false,
targetAlreadyPresent: false,
ordinaryPathAvailable: false,
},
] as unknown as MetadataDocumentIdentityIssue[];
}
describe("Metadata identity inspection presentation", () => {
it("derives case-insensitive Vault path keys for unresolved evidence", () => {
const result = selectUnresolvedMetadataIdentityEntries(createEntries(), false);
expect(result.entries.map(({ sourceRevision }) => sourceRevision)).toEqual(["3-stale", "2-stale"]);
expect([...result.unresolvedPathKeys]).toEqual(["folder/renamed.md", ".obsidian/app.json"]);
expect(metadataIdentityPathKey("folder/RENAMED.md", false)).toBe("folder/renamed.md");
expect(result.unresolvedPathKeys.has(metadataIdentityPathKey("folder/RENAMED.md", false))).toBe(true);
});
it("retains case distinctions when filename handling is case-sensitive", () => {
const result = selectUnresolvedMetadataIdentityEntries(createEntries(), true);
expect(result.unresolvedPathKeys.has("Folder/Renamed.md")).toBe(true);
expect(result.unresolvedPathKeys.has("folder/renamed.md")).toBe(false);
});
it("does not suppress ordinary inspection when the path has resolvable Metadata", () => {
const entries = createEntries();
entries[0] = {
...entries[0],
ordinaryPathAvailable: true,
} as MetadataDocumentIdentityIssue;
const result = selectUnresolvedMetadataIdentityEntries(entries, false);
expect(result.entries).toHaveLength(2);
expect(result.unresolvedPathKeys.has("folder/renamed.md")).toBe(false);
expect(result.unresolvedPathKeys.has(".obsidian/app.json")).toBe(true);
});
});
@@ -0,0 +1,71 @@
import type {
MetadataDocumentRepairRequest,
MetadataDocumentRepairResult,
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { MetadataDocumentRepairResults } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
export const MetadataIdentityRepairExecutions = {
CANCELLED: "cancelled",
REPAIR_RESULT: "repair-result",
} as const;
export type MetadataIdentityRepairExecution =
| { status: typeof MetadataIdentityRepairExecutions.CANCELLED }
| {
status: typeof MetadataIdentityRepairExecutions.REPAIR_RESULT;
result: MetadataDocumentRepairResult;
scanCompleted: boolean;
scanError?: unknown;
};
export interface MetadataIdentityRepairDependencies {
confirm: () => Promise<boolean>;
repair: (request: MetadataDocumentRepairRequest) => Promise<MetadataDocumentRepairResult>;
requestOrdinaryScan: () => Promise<boolean>;
}
/**
* Coordinate one explicitly confirmed Metadata identity repair.
*
* This consumer boundary deliberately keeps inspection approval, Commonlib
* mutation, and the subsequent ordinary Vault scan as separate operations.
* Cancellation cannot reach the mutation, and only a completed repair hands
* reconciliation back to the Offline Scanner. Commonlib re-inspects the
* source and expected ID under the current local path settings immediately
* before mutation, so remote replication state is not part of this boundary.
*/
export async function executeMetadataIdentityRepair(
request: MetadataDocumentRepairRequest,
dependencies: MetadataIdentityRepairDependencies
): Promise<MetadataIdentityRepairExecution> {
if (!(await dependencies.confirm())) {
return { status: MetadataIdentityRepairExecutions.CANCELLED };
}
const result = await dependencies.repair(request);
if (result.status !== MetadataDocumentRepairResults.COMPLETED) {
return {
status: MetadataIdentityRepairExecutions.REPAIR_RESULT,
result,
scanCompleted: false,
};
}
try {
const scanCompleted = await dependencies.requestOrdinaryScan();
return {
status: MetadataIdentityRepairExecutions.REPAIR_RESULT,
result,
scanCompleted,
};
} catch (scanError) {
// The Metadata identity mutation has already completed. Preserve that
// result separately so a follow-up scan failure cannot be mistaken for
// a failed or rolled-back repair.
return {
status: MetadataIdentityRepairExecutions.REPAIR_RESULT,
result,
scanCompleted: false,
scanError,
};
}
}
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from "vitest";
import type { DocumentID } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type {
MetadataDocumentRepairRequest,
MetadataDocumentRepairResult,
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { executeMetadataIdentityRepair } from "./metadataIdentityRepair";
const request: MetadataDocumentRepairRequest = {
actualDocumentId: "f:stale" as DocumentID,
expectedDocumentId: "f:expected" as DocumentID,
sourceRevision: "4-source",
};
function createDependencies() {
const events: string[] = [];
return {
events,
confirm: vi.fn(async () => true),
repair: vi.fn(async (): Promise<MetadataDocumentRepairResult> => {
events.push("repair");
return {
status: "completed" as const,
...request,
targetCreated: true,
};
}),
requestOrdinaryScan: vi.fn(async () => {
events.push("scan");
return true;
}),
};
}
describe("executeMetadataIdentityRepair", () => {
it("performs no mutation when the separate confirmation is cancelled", async () => {
const dependencies = createDependencies();
dependencies.confirm.mockResolvedValue(false);
await expect(executeMetadataIdentityRepair(request, dependencies)).resolves.toEqual({
status: "cancelled",
});
expect(dependencies.repair).not.toHaveBeenCalled();
expect(dependencies.requestOrdinaryScan).not.toHaveBeenCalled();
});
it("requests an ordinary scan only after Commonlib completes the exact repair", async () => {
const dependencies = createDependencies();
await expect(executeMetadataIdentityRepair(request, dependencies)).resolves.toMatchObject({
status: "repair-result",
result: { status: "completed" },
scanCompleted: true,
});
expect(dependencies.repair).toHaveBeenCalledWith(request);
expect(dependencies.requestOrdinaryScan).toHaveBeenCalledOnce();
expect(dependencies.repair.mock.invocationCallOrder[0]).toBeLessThan(
dependencies.requestOrdinaryScan.mock.invocationCallOrder[0]
);
expect(dependencies.events).toEqual(["repair", "scan"]);
});
it("keeps a completed repair distinct when the ordinary scan cannot start", async () => {
const dependencies = createDependencies();
dependencies.requestOrdinaryScan.mockResolvedValue(false);
await expect(executeMetadataIdentityRepair(request, dependencies)).resolves.toMatchObject({
status: "repair-result",
result: { status: "completed" },
scanCompleted: false,
});
expect(dependencies.requestOrdinaryScan).toHaveBeenCalledOnce();
expect(dependencies.events).toEqual(["repair"]);
});
it("keeps a completed repair distinct when requesting the ordinary scan throws", async () => {
const dependencies = createDependencies();
const error = new Error("scan unavailable");
dependencies.requestOrdinaryScan.mockRejectedValue(error);
await expect(executeMetadataIdentityRepair(request, dependencies)).resolves.toMatchObject({
status: "repair-result",
result: { status: "completed" },
scanCompleted: false,
scanError: error,
});
expect(dependencies.events).toEqual(["repair"]);
});
it("does not scan after a stale, blocked, or failed repair result", async () => {
for (const status of ["stale", "blocked", "failed"] as const) {
const dependencies = createDependencies();
dependencies.repair.mockResolvedValue({
status,
...request,
targetCreated: false,
});
await expect(executeMetadataIdentityRepair(request, dependencies)).resolves.toMatchObject({
status: "repair-result",
result: { status },
scanCompleted: false,
});
expect(dependencies.requestOrdinaryScan).not.toHaveBeenCalled();
}
});
});
@@ -2,6 +2,7 @@ import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/commo
import type { IStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
import type { Stat, App } from "obsidian";
import { toIntegerTimestamps } from "./sanitizeWriteOptions";
/**
* Storage adapter implementation for Obsidian
@@ -40,15 +41,15 @@ export class ObsidianStorageAdapter implements IStorageAdapter<Stat> {
}
async write(path: string, data: string, options?: UXDataWriteOptions): Promise<void> {
return await this.app.vault.adapter.write(path, data, options);
return await this.app.vault.adapter.write(path, data, toIntegerTimestamps(options));
}
async writeBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
return await this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), options);
return await this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), toIntegerTimestamps(options));
}
async append(path: string, data: string, options?: UXDataWriteOptions): Promise<void> {
return await this.app.vault.adapter.append(path, data, options);
return await this.app.vault.adapter.append(path, data, toIntegerTimestamps(options));
}
list(basePath: string): Promise<{ files: string[]; folders: string[] }> {
@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from "vitest";
import type { App } from "obsidian";
import { ObsidianStorageAdapter } from "./ObsidianStorageAdapter";
describe("ObsidianStorageAdapter", () => {
it("floors write-option timestamps before calling Obsidian storage methods", async () => {
const write = vi.fn().mockResolvedValue(undefined);
const writeBinary = vi.fn().mockResolvedValue(undefined);
const append = vi.fn().mockResolvedValue(undefined);
const app = {
vault: {
adapter: {
write,
writeBinary,
append,
},
},
} as unknown as App;
const adapter = new ObsidianStorageAdapter(app);
const options = { ctime: 1778511180024.462, mtime: 1778511180999.913 };
const expectedOptions = { ctime: 1778511180024, mtime: 1778511180999 };
await adapter.write("note.md", "text", options);
await adapter.writeBinary("image.bin", new ArrayBuffer(0), options);
await adapter.append("log.md", "text", options);
expect(write).toHaveBeenCalledWith("note.md", "text", expectedOptions);
expect(writeBinary).toHaveBeenCalledWith("image.bin", expect.any(ArrayBuffer), expectedOptions);
expect(append).toHaveBeenCalledWith("log.md", "text", expectedOptions);
expect(options).toEqual({ ctime: 1778511180024.462, mtime: 1778511180999.913 });
});
});
@@ -2,6 +2,7 @@ import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/commo
import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
import type { TFile, App, TFolder } from "obsidian";
import { toIntegerTimestamps } from "./sanitizeWriteOptions";
/**
* Vault adapter implementation for Obsidian
@@ -23,19 +24,19 @@ export class ObsidianVaultAdapter implements IVaultAdapter<TFile, TFolder> {
}
async modify(file: TFile, data: string, options?: UXDataWriteOptions): Promise<void> {
return await this.app.vault.modify(file, data, options);
return await this.app.vault.modify(file, data, toIntegerTimestamps(options));
}
async modifyBinary(file: TFile, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
return await this.app.vault.modifyBinary(file, toArrayBuffer(data), options);
return await this.app.vault.modifyBinary(file, toArrayBuffer(data), toIntegerTimestamps(options));
}
async create(path: string, data: string, options?: UXDataWriteOptions): Promise<TFile> {
return await this.app.vault.create(path, data, options);
return await this.app.vault.create(path, data, toIntegerTimestamps(options));
}
async createBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<TFile> {
return await this.app.vault.createBinary(path, toArrayBuffer(data), options);
return await this.app.vault.createBinary(path, toArrayBuffer(data), toIntegerTimestamps(options));
}
async rename(file: TFile, newPath: string): Promise<void> {
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { App, TFile } from "obsidian";
import { ObsidianVaultAdapter } from "./ObsidianVaultAdapter";
describe("ObsidianVaultAdapter.read", () => {
describe("ObsidianVaultAdapter", () => {
it("preserves a UTF-8 BOM so the content size matches the file stat", async () => {
const path = "Transcripts/字幕.md";
const contentWithoutBom = "字幕の検証行です。\n";
@@ -34,4 +34,34 @@ describe("ObsidianVaultAdapter.read", () => {
expect(adapterRead).toHaveBeenCalledWith(path);
expect(read).not.toHaveBeenCalled();
});
it("floors write-option timestamps before calling Obsidian vault methods", async () => {
const modify = vi.fn().mockResolvedValue(undefined);
const modifyBinary = vi.fn().mockResolvedValue(undefined);
const create = vi.fn().mockResolvedValue({});
const createBinary = vi.fn().mockResolvedValue({});
const app = {
vault: {
modify,
modifyBinary,
create,
createBinary,
},
} as unknown as App;
const file = { path: "note.md" } as TFile;
const adapter = new ObsidianVaultAdapter(app);
const options = { ctime: 1778511180024.462, mtime: 1778511180999.913 };
const expectedOptions = { ctime: 1778511180024, mtime: 1778511180999 };
await adapter.modify(file, "text", options);
await adapter.modifyBinary(file, new ArrayBuffer(0), options);
await adapter.create("created.md", "text", options);
await adapter.createBinary("created.bin", new ArrayBuffer(0), options);
expect(modify).toHaveBeenCalledWith(file, "text", expectedOptions);
expect(modifyBinary).toHaveBeenCalledWith(file, expect.any(ArrayBuffer), expectedOptions);
expect(create).toHaveBeenCalledWith("created.md", "text", expectedOptions);
expect(createBinary).toHaveBeenCalledWith("created.bin", expect.any(ArrayBuffer), expectedOptions);
expect(options).toEqual({ ctime: 1778511180024.462, mtime: 1778511180999.913 });
});
});
@@ -0,0 +1,25 @@
import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types";
/**
* Coerce the timestamp fields of a write-options object to integer milliseconds.
*
* On mobile, Obsidian forwards `mtime`/`ctime` to Capacitor's
* Filesystem.setTimes, whose native binding casts the value to a Java `Long`.
* A non-integer (float) timestamp makes that cast throw
* `ClassCastException: Double cannot be cast to Long`, which crashes the app on
* launch as soon as such a document is replicated in. Float timestamps can
* enter the database from any client that stores `fs.Stats.mtimeMs` without
* flooring. Flooring at the storage boundary guarantees every Obsidian write
* carries an integer, so a float timestamp already present in the mesh cannot
* brick the app.
*
* Returns a shallow copy so the caller's options object is not mutated; passes
* `undefined` through unchanged.
*/
export function toIntegerTimestamps(options?: UXDataWriteOptions): UXDataWriteOptions | undefined {
if (!options) return options;
const sanitized: UXDataWriteOptions = { ...options };
if (typeof sanitized.mtime === "number") sanitized.mtime = Math.floor(sanitized.mtime);
if (typeof sanitized.ctime === "number") sanitized.ctime = Math.floor(sanitized.ctime);
return sanitized;
}
+5 -3
View File
@@ -157,7 +157,7 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
`test:e2e:obsidian:setup-uri-workflow` runs the repository's public Commonlib-backed CouchDB provisioning and Setup URI tools against the local CouchDB fixture. It configures a new, empty Vault in the first real Obsidian session through the visible onboarding wizard and uses Rebuild. After that device is working, it generates a new Setup URI through the registered command; the second real Obsidian Vault uses that URI for Fetch instead of reusing the initial Setup URI produced by the provisioning tool. The workflow verifies ordinary notes from the first device to the second and back again, independently enables Hidden File Sync on each device, and verifies a snippet. The retained Setup URI screenshots show only encrypted URIs and visually masked Setup URI passphrases; plaintext credentials are not captured. Files prefixed with `guide-` capture the relevant dialogue, settings panel, or workspace leaf without transient Notices. Public documentation copies selected images only after visual inspection; the E2E run does not overwrite repository documentation assets.
`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, ordinary rename, a case-only file name change within the same directory, deletion, and a separate encrypted round-trip with Path Obfuscation enabled. Its target-filter scenario confirms that one Vault receives and checkpoints a remote document without reflecting it, restarts with the same profile and filter, and then reflects the stored document after the filter is broadened through the settings service. Directory case changes deliberately remain outside this scenario because they require directory-aware rename handling. The optional Markdown conflict check can be enabled with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`. It creates divergent revisions in two separate Vaults, performs a conservative merge on one Vault, edits that result again, and requires the other Vault to replace its known deleted losing revision without recreating the conflict. The separate `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` check keeps four conflicts active while one Vault edits, deletes, performs a case-only rename, and performs a cross-path rename. It asserts that each operation extends the revision displayed on that device, replicates the exact resulting revision tree, and preserves the other live branch. During focused development, `E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS=true` runs that self-contained scope without the ordinary, target-filter, or encrypted scenarios. Both conflict checks remain outside the default local suite.
`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, ordinary rename, a case-only file name change within the same directory, deletion, and a separate encrypted round-trip with Path Obfuscation enabled. Its target-filter scenario confirms that one Vault receives and checkpoints a remote document without reflecting it, restarts with the same profile and filter, and then reflects the stored document after the filter is broadened through the settings service. Directory case changes deliberately remain outside this scenario because they require directory-aware rename handling. The optional Markdown conflict check can be enabled with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`. It creates divergent revisions in two separate Vaults, performs a conservative merge on one Vault, edits that result again, and requires the other Vault to replace its known deleted losing revision without recreating the conflict. The separate `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` check keeps four conflicts active while one Vault edits, deletes, performs a case-only rename, and performs a cross-path rename. It asserts that each operation extends the revision displayed on that device, replicates the exact resulting revision tree, and preserves the other conflict branch. During focused development, `E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS=true` runs that self-contained scope without the ordinary, target-filter, or encrypted scenarios. Both conflict checks remain outside the default local suite.
`test:e2e:obsidian:security-seed-reconnect` is a focused CouchDB release-acceptance workflow. Device A first recognises an initial remote Security Seed, stops automatic replication while remaining open, and creates an unsent note. The runner replaces only the Security Seed in the managed remote synchronisation-parameter fixture. Device A must retain its deliberately stale cached value until the next one-shot synchronisation, refresh it before sending, and upload an HKDF-encrypted payload which uses the replacement value. A fresh device B must decrypt that note and send an encrypted note back; the original device A then receives the return journey with its Vault and isolated profile preserved. Desktop Obsidian may enforce a single application instance, so the two device sessions run sequentially after the same-process stale-cache assertion has completed.
@@ -165,9 +165,11 @@ The workflow creates a random dedicated database, records only SHA-256 Seed fing
This proves in real Obsidian the plug-in behaviour shared by supported platforms, including the encrypted bidirectional round-trip and protection against a stale client restoring the old remote Seed. It does not verify iPadOS-specific background or reconnect lifecycle behaviour, and it does not count as Android device evidence. The workflow remains outside `test:e2e:obsidian:local-suite` because it is a focused release-acceptance check.
`test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two live versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them.
`test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two current versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them.
`test:e2e:obsidian:revision-repair` creates an ordinary healthy logical deletion and two conflicting live revisions in a temporary real Obsidian Vault, then removes a chunk used only by the non-winning revision. It proves that automatic conflict checking does not discard the unreadable branch, and that a healthy logical deletion with no Vault file is neither reported nor retained as Vault provenance. **Inspect conflicts and file/database differences** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, show the compact `Δsize` and `Δtime` diagnostics, and expose a wrench menu with the appropriate actions for each branch. The scenario opens the existing comparison dialogue in read-only mode, applies the readable winner to the Vault, shows the compact matching-winner and remaining-conflict status, records the exact winner as Vault provenance without creating a child, and confirms that retrying the unreadable branch leaves the revision tree unchanged. It then verifies both the cancellation path and the explicit confirmation path for discarding only that selected live branch, requires the winner and its Vault provenance to remain unchanged, and captures the repair card, a 360-pixel-wide reflow check, the matching-winner status, both revision menus, and the read-only comparison. The narrow capture checks responsive layout, not a mobile operating-system lifecycle. The scenario uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally.
`test:e2e:obsidian:revision-repair` creates an ordinary healthy logical deletion and two current leaf revisions in a temporary real Obsidian Vault, then removes a chunk used only by the non-winning revision. It proves that automatic conflict checking does not discard the unreadable branch, and that a healthy logical deletion with no Vault file is neither reported nor retained as Vault provenance. **Inspect conflicts and file/database differences** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, show the compact `Δsize` and `Δtime` diagnostics, and expose a wrench menu with the appropriate actions for each branch. The scenario opens the existing comparison dialogue in read-only mode, applies the readable winner to the Vault, shows the compact matching-winner and remaining-conflict status, records the exact winner as Vault provenance without creating a child, and confirms that retrying the unreadable branch leaves the revision tree unchanged. It then verifies both the cancellation path and the explicit confirmation path for discarding only that selected current branch, requires the winner and its Vault provenance to remain unchanged, and captures the repair card, a 360-pixel-wide reflow check, the matching-winner status, both revision menus, and the read-only comparison. The narrow capture checks responsive layout, not a mobile operating-system lifecycle. The scenario uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally.
`test:e2e:obsidian:document-history-restore` creates a normal note, records a logical deletion while retaining readable chunks, and restores the deleted content through the visible Document History dialogue. It requires the action itself to create and reflect a new non-deleted successor revision, reopens the history at that successor, and captures the file picker, readable deleted revision, restored Vault file, and new successor revision. This scenario owns the ordinary-history restoration boundary; conflict resolution remains with **Inspect conflicts and file/database differences**.
`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
@@ -0,0 +1,376 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { Page } from "playwright";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
createE2eObsidianDeviceLocalState,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
process.env.E2E_OBSIDIAN_CORE_READY_TIMEOUT_MS ??= "60000";
process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ??= "30000";
const notePath = "E2E/document-history-soft-deleted.md";
const contentMarker = "Recoverable content from the soft-deleted revision";
const noteContent = [
"# Document History recovery E2E",
"",
contentMarker,
"",
...Array.from(
{ length: 96 },
(_, index) => `Preserved content line ${String(index + 1).padStart(3, "0")}: ${"R".repeat(64)}`
),
"",
].join("\n");
type SoftDeletionState = {
id: string;
revision: string;
revisionCount: number;
chunkReferences: number;
availableChunks: number;
contentReadable: boolean;
storageExists: boolean;
};
type VaultRestoreState = {
revision: string;
revisionCount: number;
deleted: boolean;
storageExists: boolean;
contentMatches: boolean;
};
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (actual !== expected) {
throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`);
}
}
function assertTrue(value: boolean, message: string): void {
if (!value) {
throw new Error(message);
}
}
async function dismissWelcomeWizard(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
const cancel = page.getByText("No, please take me back");
if (await cancel.isVisible({ timeout: 5000 }).catch(() => false)) {
await cancel.click();
await page.waitForTimeout(500);
}
});
}
async function createNote(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(notePath)};`,
`const content=${JSON.stringify(noteContent)};`,
"if(!(await app.vault.adapter.exists('E2E'))) await app.vault.createFolder('E2E');",
"const existing=app.vault.getAbstractFileByPath(path);",
"if(existing) await app.vault.delete(existing);",
"const file=await app.vault.create(path,content);",
"await app.workspace.getLeaf(false).openFile(file);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
await waitForLocalDatabaseEntry(cliBinary, env, notePath);
}
async function createSoftDeletion(cliBinary: string, env: NodeJS.ProcessEnv): Promise<SoftDeletionState> {
return await evalObsidianJson<SoftDeletionState>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(notePath)};`,
`const expectedContent=${JSON.stringify(noteContent)};`,
"const timeoutMs=30000;",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const file=app.vault.getAbstractFileByPath(path);",
"if(!file) throw new Error(`Recovery fixture is missing from the Vault: ${path}`);",
"const id=await core.services.path.path2id(path);",
"await app.vault.delete(file);",
"const deadline=Date.now()+timeoutMs;",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"while(Date.now()<deadline){",
" await core.services.fileProcessing.commitPendingFileEvents();",
" const raw=await core.localDatabase.getRaw(id,{revs_info:true}).catch(()=>false);",
" if(raw?.deleted&&!app.vault.getAbstractFileByPath(path)){",
" const loaded=await core.localDatabase.getDBEntry(path,{rev:raw._rev},false,true,true);",
" const loadedContent=loaded===false?'':Array.isArray(loaded.data)?loaded.data.join(''):loaded.data;",
" const children=Array.isArray(raw.children)?raw.children:[];",
" const chunkRows=children.length===0?{rows:[]}:await core.localDatabase.allDocsRaw({keys:children,include_docs:true});",
" const availableChunks=chunkRows.rows.filter((row)=>row.doc&&!row.value?.deleted).length;",
" return JSON.stringify({",
" id,",
" revision:raw._rev,",
" revisionCount:(raw._revs_info||[]).filter((entry)=>entry?.status==='available').length,",
" chunkReferences:children.length,",
" availableChunks,",
" contentReadable:loadedContent===expectedContent,",
" storageExists:!!app.vault.getAbstractFileByPath(path),",
" });",
" }",
" await sleep(250);",
"}",
"throw new Error(`Timed out waiting for a readable soft deletion: ${path}`);",
"})()",
].join(""),
env
);
}
async function openHistoryPicker(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"document.querySelectorAll('.modal-close-button').forEach((button)=>button.click());",
"await new Promise((resolve)=>setTimeout(resolve,300));",
"await app.commands.executeCommandById('obsidian-livesync:livesync-filehistory');",
"await new Promise((resolve)=>setTimeout(resolve,500));",
"return JSON.stringify({opened:!!document.querySelector('.prompt-input')});",
"})()",
].join(""),
env
);
}
async function waitForVaultRestore(cliBinary: string, env: NodeJS.ProcessEnv): Promise<VaultRestoreState> {
return await evalObsidianJson<VaultRestoreState>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(notePath)};`,
`const expectedContent=${JSON.stringify(noteContent)};`,
"const timeoutMs=30000;",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const id=await core.services.path.path2id(path);",
"const deadline=Date.now()+timeoutMs;",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"while(Date.now()<deadline){",
" await core.services.fileProcessing.commitPendingFileEvents();",
" const file=app.vault.getAbstractFileByPath(path);",
" const raw=await core.localDatabase.getRaw(id,{revs_info:true}).catch(()=>false);",
" const content=file?await app.vault.read(file):'';",
" if(file&&raw&&!raw.deleted&&!raw._deleted&&content===expectedContent){",
" return JSON.stringify({",
" revision:raw._rev,",
" revisionCount:(raw._revs_info||[]).filter((entry)=>entry?.status==='available').length,",
" deleted:false,",
" storageExists:true,",
" contentMatches:true,",
" });",
" }",
" await sleep(250);",
"}",
"const file=app.vault.getAbstractFileByPath(path);",
"const raw=await core.localDatabase.getRaw(id,{revs_info:true}).catch(()=>false);",
"const content=file?await app.vault.read(file):'';",
"throw new Error(`Timed out waiting for History to create and reflect a non-deleted successor revision: ${JSON.stringify({storageExists:!!file,deleted:!!(raw&&((raw.deleted||raw._deleted))),contentMatches:content===expectedContent,revision:raw&&raw._rev})}`);",
"})()",
].join(""),
env
);
}
async function openActiveFileHistory(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(notePath)};`,
"document.querySelectorAll('.modal-close-button').forEach((button)=>button.click());",
"const file=app.vault.getAbstractFileByPath(path);",
"if(!file) throw new Error(`Restored file is missing before reopening history: ${path}`);",
"await app.workspace.getLeaf(false).openFile(file);",
"await new Promise((resolve)=>setTimeout(resolve,300));",
"await app.commands.executeCommandById('obsidian-livesync:livesync-history');",
"await new Promise((resolve)=>setTimeout(resolve,500));",
"return JSON.stringify({opened:!!document.querySelector('.modal-container .modal-title')});",
"})()",
].join(""),
env
);
}
async function captureStep(page: Page, screenshotDir: string, step: string): Promise<string> {
await mkdir(screenshotDir, { recursive: true });
const path = join(screenshotDir, `${step}.png`);
await page.screenshot({ path, fullPage: true, animations: "disabled" });
console.log(`Screenshot: ${path}`);
return path;
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
const screenshotDir =
process.env.E2E_OBSIDIAN_HISTORY_RESTORE_SCREENSHOT_DIR ??
join(process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e", "document-history-restore");
const reportPath =
process.env.E2E_OBSIDIAN_HISTORY_RESTORE_REPORT ?? join(screenshotDir, "document-history-restore.json");
try {
console.log(`Using Obsidian executable: ${binary}`);
console.log(`Temporary vault: ${vault.path}`);
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
doctorProcessedVersion: "1.0.0",
isConfigured: true,
liveSync: false,
remoteType: "",
couchDB_URI: "",
couchDB_DBNAME: "",
couchDB_USER: "",
couchDB_PASSWORD: "",
remoteConfigurations: {},
activeConfigurationId: "",
notifyThresholdOfRemoteStorageSize: -1,
periodicReplication: false,
syncAfterMerge: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncOnSave: false,
syncOnStart: false,
deleteMetadataOfDeletedFiles: false,
},
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await dismissWelcomeWizard(session.remoteDebuggingPort);
await createNote(cli.binary, session.cliEnv);
const deletion = await createSoftDeletion(cli.binary, session.cliEnv);
assertEqual(deletion.storageExists, false, "The deletion fixture still existed in the Vault.");
assertTrue(deletion.chunkReferences > 0, "The deleted document did not retain chunk references.");
assertEqual(
deletion.availableChunks,
deletion.chunkReferences,
"Not all chunks referenced by the deleted document remained available."
);
assertEqual(
deletion.contentReadable,
true,
"The soft-deleted revision could not be reconstructed from chunks."
);
await openHistoryPicker(cli.binary, session.cliEnv);
const screenshots = await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const screenshotPaths: string[] = [];
const prompt = page.locator(".prompt");
await prompt.waitFor({ state: "visible", timeout: 10000 });
const promptInput = prompt.locator(".prompt-input");
assertEqual(
await promptInput.getAttribute("placeholder"),
"File to view History",
"Unexpected history picker placeholder."
);
await promptInput.fill(notePath);
const suggestion = prompt.locator(".suggestion-item").filter({ hasText: notePath }).first();
await suggestion.waitFor({ state: "visible", timeout: 10000 });
screenshotPaths.push(await captureStep(page, screenshotDir, "01-soft-deleted-file-picker"));
await suggestion.click();
const modal = page.locator(".modal-container").filter({ hasText: "Document History" });
await modal.waitFor({ state: "visible", timeout: 10000 });
await modal.getByText("(At this revision, the file has been deleted)", { exact: false }).waitFor({
state: "visible",
timeout: 10000,
});
await modal.getByText(contentMarker, { exact: false }).waitFor({ state: "visible", timeout: 10000 });
const restoreButton = modal.getByRole("button", { name: "Back to this revision", exact: true });
await restoreButton.waitFor({ state: "visible", timeout: 10000 });
screenshotPaths.push(await captureStep(page, screenshotDir, "02-readable-deleted-revision"));
await restoreButton.click();
await modal.waitFor({ state: "hidden", timeout: 10000 });
return screenshotPaths;
});
const restored = await waitForVaultRestore(cli.binary, session.cliEnv);
assertEqual(restored.storageExists, true, "Document History did not restore the Vault file.");
assertEqual(restored.contentMatches, true, "The restored Vault file did not match the deleted revision.");
assertEqual(restored.deleted, false, "Document History did not produce a non-deleted database revision.");
assertTrue(
restored.revision !== deletion.revision,
"Document History did not create a successor database revision."
);
assertEqual(
restored.revisionCount,
deletion.revisionCount + 1,
"Document History did not add exactly one non-deleted successor revision."
);
screenshots.push(
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page
.getByText(contentMarker, { exact: false })
.first()
.waitFor({ state: "visible", timeout: 10000 });
return await captureStep(page, screenshotDir, "03-non-deleted-successor-after-history-restore");
})
);
await openActiveFileHistory(cli.binary, session.cliEnv);
screenshots.push(
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = page.locator(".modal-container").filter({ hasText: "Document History" });
await modal.waitFor({ state: "visible", timeout: 10000 });
await modal.getByText(contentMarker, { exact: false }).waitFor({ state: "visible", timeout: 10000 });
assertEqual(
await modal.getByText("(At this revision, the file has been deleted)", { exact: false }).count(),
0,
"The non-deleted successor revision was still displayed as deleted."
);
assertEqual(
(await modal.locator(".history-rev-indicator").innerText()).trim(),
"Rev 3/3",
"Document History did not open at the new non-deleted successor revision."
);
return await captureStep(page, screenshotDir, "04-restored-revision-in-history");
})
);
await mkdir(screenshotDir, { recursive: true });
await writeFile(
reportPath,
`${JSON.stringify({ notePath, deletion, restored, screenshots }, null, 2)}\n`,
"utf-8"
);
console.log("Document History soft-deletion restoration E2E passed.");
console.log(`Report: ${reportPath}`);
console.log(`Screenshots: ${screenshotDir}`);
} finally {
if (session) await session.app.stop();
await vault.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+1
View File
@@ -10,6 +10,7 @@ const focusedScenarios = new Set([
"dialog-mounts",
"revision-repair",
"document-history-nav",
"document-history-restore",
"settings-ui",
"review-harness",
"p2p-pane",
+91 -245
View File
@@ -8,10 +8,100 @@ None of this would have been possible without your issue reports, pull requests,
This will call for your help once again. I would be very grateful for your co-operation as we build a sounder foundation for the project and its future development.
Earlier releases remain available in the 0.25 release history and the legacy release history.
Earlier releases remain available in the 1.0 release history, the 1.0 preview history, the 0.25 release history, and the legacy release history.
## Unreleased
### Conflict handling and recovery
#### Fixed
- **Back to this revision** in Document History now restores the selected content as a new non-deleted successor revision before reflecting it to the Vault. A readable revision restored after a logical deletion therefore remains restored through later synchronisation instead of being overwritten by the deletion.
- If the file changes while restoration is in progress, the operation stops instead of extending a stale revision. Existing conflicts remain available through **Inspect conflicts and file/database differences**.
### Synchronisation and storage
#### Improved
- One-shot CouchDB synchronisation now releases stalled web-compatible connection checks before replication starts, so a later synchronisation can make a fresh attempt (Commonlib 0.1.16).
- The 60-second safeguard applies only to pre-replication checks. It does not limit ordinary synchronisation, and the **Use Internal API** path is unchanged.
## 1.0.15
15th August, 2026
### Synchronisation and storage
#### Improved
- Start-up offline scanning is now faster, especially for larger Vaults using path obfuscation (Commonlib 0.1.15).
### Interface and translation
#### Improved
- The Traditional Chinese translation catalogue has been completed and polished for broader coverage and more natural, consistent terminology (PR #1106). Thank you to @nimula for the contribution!
## 1.0.14
14th August, 2026
Thank you for your patience. At last, it looks as though we can clear some of the Community Review warnings.
### Synchronisation and storage
#### Fixed
- CouchDB operations which run to completion now close their temporary remote database connections after use across both Commonlib and LiveSync, including Setup Wizard and settings probes, command-line milestone verification, database maintenance, Security Seed refreshes, status queries, and retry and error paths (Commonlib PR #112).
- This covers every temporary connection currently identified as a possible contributor to the long-running resource growth tracked in #1034. Validation over extended sessions is continuing.
- Thank you to @apple-ouyang for the contribution!
## 1.0.13
13th August, 2026
### Conflict handling and recovery
#### Improved
- **Inspect conflicts and file/database differences** now reports local Metadata whose stored document ID does not match the ID derived from its recorded path. Ordinary scans leave unresolved entries and their corresponding Vault paths unchanged, while allowing consistently addressed Metadata for the same logical path to proceed normally.
- When the current winner has no conflict leaves and has an unambiguous target, its wrench menu can repair that one local Metadata document after separate confirmation. The target is written and verified before the mismatched source ID is removed; ambiguous or otherwise unsafe entries remain read-only.
#### Fixed
- Fast Fetch now writes deletion tombstones to the local database without attempting to decrypt them. A tombstone has no encrypted payload, and decryption previously aborted the whole fetch at the first deleted document. New devices could not complete their initial sync on vaults that contain old deletions (Commonlib PR #108).
- Thank you to @KennethLloyd for the contribution!
## 1.0.12
11th August, 2026
### Synchronisation and storage
#### Fixed
- One-shot CouchDB replication now closes its temporary remote database after each run and before retrying, preventing inactive PouchDB instances from accumulating during long-running periodic synchronisation (Commonlib PR #75). Thank you to @apple-ouyang for the contribution!
- Start-up and recovery scans now keep failed database-to-Vault writes retryable instead of recording them as successful and later mistaking the still-missing file for a local deletion (Commonlib PR #106).
- Files over the size limit or in conflict remain deliberately skipped, while actual write failures are reported to Fast Setup, CLI mirror, and daemon callers.
## 1.0.11
9th August, 2026
### Setup and compatibility
#### Fixed
- Fast Setup now uses Standard Fetch when CouchDB's 'Use Internal API' setting is enabled, avoiding a streaming request path which Obsidian's buffered API cannot support (#1020).
- Custom headers alone continue to use Fast Fetch when browser CORS permits them; Standard Fetch clears any obsolete Fast Fetch checkpoint after resetting the local database.
### Synchronisation and storage
#### Fixed
- Fractional file timestamps no longer cause affected mobile clients to crash after synchronisation (#1087, PR #1039). Thank you to @andrewleech for the contribution!
- Timestamps are now normalised in the command-line tool and before Obsidian's native file-system writes.
## 1.0.10
9th August, 2026
@@ -21,247 +111,3 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
#### Fixed
- Fast Setup now sends configured CouchDB custom headers with every changes-feed request, allowing reverse proxies such as Cloudflare Access to authenticate initial setup in the same way as ordinary synchronisation ([Commonlib PR #82](https://github.com/vrtmrz/livesync-commonlib/pull/82)). Thank you to @nimula for the contribution!
## 1.0.9
8th August, 2026
For the first time in a while, I published a release that could not be promoted to a stable release. Sorry about that! I am glad that we caught it while it was still a pre-release.
### Setup and compatibility
#### Fixed
- Multi-part settings QR codes now preserve special characters in passwords, passphrases, and other settings (PR #1083). Thank you to @calvinbui for the improvement!
- Fast Setup now sizes each finite CouchDB changes page from a one-row status probe, counts the returned result together with `pending`, and resumes from the page's opaque `last_seq` without comparing token representations. Each page uses a one-second idle timeout instead of a heartbeat, allowing CouchDB 3.2 to return its terminator after the currently available rows have been persisted.
## 1.0.8
8th August, 2026
This version was published for pre-release validation only and was not promoted to a stable release.
### Setup and compatibility
#### Fixed
- Fast Setup now sizes each finite CouchDB changes page from a one-row status probe, counts the returned result together with `pending`, and resumes from the page's opaque `last_seq` without comparing token representations. Heartbeat-enabled feeds no longer wait for future writes after the currently available rows have been persisted (#1065).
- Cancelling remote selection during a scheduled Fetch now removes the Fetch flag before restarting with file and database reflection paused, preventing the same selection dialogue from reopening on every start-up.
## 1.0.7
8th August, 2026
### Setup and compatibility
#### Fixed
- Fast Setup now completes only after the captured CouchDB changes target has been persisted. Decryption, protocol, and local write failures stop the operation without finalising an incomplete database, while transient interruptions resume from the last durable checkpoint (#1065).
## 1.0.6
6th August, 2026
I know that onboarding, and other parts which feel unclear or confusing, still need improvement. Please do report any such cases.
### Setup and compatibility
#### Fixed
- Initial setup now distinguishes an empty remote with no saved synchronisation settings from a failed remote read. New remotes can use this device's settings without an unnecessary retry; Fetch pauses on unreadable settings, while Rebuild can explicitly continue with this device's settings. Cancelling preserves the selected automatic synchronisation mode and restarts with Vault and database reflection paused (#1064). Thank you to @mateus2k2 for the follow-up report!
## 1.0.5
5th August, 2026
### Synchronisation and storage
#### Improved
- Added settings to control whether finite synchronisation operations keep the screen awake. Desktop devices now allow automatic sleep by default, while mobile devices retain screen-awake protection unless the general option is enabled (#1073).
### Interface and translation
#### Improved
- Korean translations now cover the complete current catalogue across Setup, P2P, remote configuration, diagnostics, and maintenance (PR #1075). Thank you to @motolies for the improvement!
#### Fixed
- The in-editor LiveSync status on iOS now remains below the view-header controls instead of overlapping them (PR #1067). Thank you to @Hsiii for the improvement!
## 1.0.4
5th August, 2026
### Synchronisation and storage
#### Fixed
- Testing or saving a fresh remote configuration no longer tries to access the local database while constructing a replicator, avoiding 'Local database is not ready yet' failures before local database initialisation (#1064).
### Command-line tool
#### Fixed
- Successful setup and remote-configuration commands now retain their settings changes. Other commands leave the settings file unchanged unless `--write-settings` is supplied, and temporary CLI suspension values are never written (#1070).
## 1.0.3
3rd August, 2026
### Synchronisation and storage
#### Fixed
- File consistency checks no longer read older revisions after the current Vault content matches known synchronised history, avoiding unnecessary 'Missing document content' warnings from obsolete unreadable revisions.
- Remote chunk fetching now retains chunks which were returned successfully when another chunk in the same request is unavailable, so the available content can still be processed (#771).
### Interface
#### Fixed
- The Remediation setting now displays its configured modification-time limit without raising a `HierarchyRequestError`.
## 1.0.2
31st July, 2026
I am aware that some of the Community Directory review checks have become a little more sensitive again. I will watch them for a little longer, then consider the most appropriate way to adapt.
### Synchronisation and storage
#### Improved
- Downloaded document batches retain best-effort screen-awake and lifecycle protection until every queued file has been applied to local storage, without extending the remote-activity indicator (#1031, PR #1032). Thank you to @apple-ouyang for the improvement!
#### Fixed
- Leading UTF-8 byte order marks are preserved during Vault ingestion, keeping stored content sizes consistent with file metadata and preventing persistent three-byte integrity mismatches (#1056, PR #1058).
### Interface and translation
#### Improved
- Document History now provides previous and next revision controls, reports the current revision position, and disables navigation at the oldest and newest boundaries without changing search-result navigation (#990, PR #1009). Thank you to @SeleiXi for the improvement!
- Remaining user-visible text in Setup, P2P, Customisation Sync, Global History, JSON conflict handling, and remote configuration now uses the translation catalogue (PR #1015). Thank you to @zeedif for the improvement!
- Korean translations have broader coverage and corrections for placeholders, punctuation, and established terminology (PR #1055). Thank you to @motolies for the improvement!
- Spanish translation coverage has been expanded across settings, Setup, P2P, maintenance, and newly catalogued interface text (PR #1059). Thank you to @zeedif for the improvement!
### Command-line tool
#### Fixed
- Large-buffer base64 encoding under Node.js now uses the published `octagonal-wheels` fallback when `FileReader` is unavailable, including correctly handling sliced binary views (#1036, PR #1060; [Fancy Kit PR #44](https://github.com/vrtmrz/fancy-kit/pull/44)).
## 1.0.1
29th July, 2026
I am taking this opportunity to update the experimental features as well.
This maintenance release mainly improves the robustness and maintainability of the experimental WebApp, WebPeer, and shared dialogue composition. Most plug-in users can skip it. I have reviewed the changes through CI and a real Obsidian instance, and I will validate the exact published build before merging the release commit.
### Interface
#### Improved
- Removed a custom positioning workaround from the onboarding Notice so that it follows Obsidian's standard placement and dismissal behaviour.
- WebApp now points users to **Scan local files** when automatic file observation is unavailable, instead of relying on a fixed browser-version recommendation.
## 1.0.0
27th July, 2026
The work towards 1.0 has become so substantial that I have written [an article about it](https://fancy-syncing.vrtmrz.net/blog/0036-livesync-1_0_0-en.html) (linked again here).
### Setup and compatibility
#### Improved
- An unconfigured Vault now waits for the user to start setup. Onboarding is offered through a persistent Notice and remains available from **Self-hosted LiveSync settings****Setup**.
- Setup now creates named CouchDB, Object Storage, and P2P connections. Setup URIs preserve their connection names and selections, and reserve Fetch or Rebuild before the ordinary start-up scan begins.
- Manual CouchDB setup distinguishes creating the first database from connecting another device. Onboarding requires a successful connection, while Settings can explicitly save an unverified connection and offers each server-setting correction separately.
- Compatible differences limited to the chunk hash algorithm, chunk size, or splitter version are aligned automatically by default. Existing chunks remain readable, an explicit opt-out remains available, and differences involving incompatible settings still require review.
#### Fixed
- Existing Vaults retain their effective legacy settings, including the case-insensitive file-name fallback used when an older release had no explicit case setting.
#### Security
- Fly.io setup generates CouchDB and Vault encryption secrets with cryptographically secure randomness.
- Dependency updates address excessive CPU use from crafted path patterns and `mailto:` links.
### Conflict handling and recovery
#### Improved
- **Not now** postpones repeated automatic merge dialogues while retaining the unresolved-conflict warning. Three or more live revisions are reviewed one reproducible pair at a time, completed pairs remain resolved across restart, and explicit commands can reopen a postponed conflict.
- **Inspect conflicts and file/database differences** compares the Vault with the database winner and every live conflict revision. Compact indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflicts remain.
- Each reported file and live revision has a compact wrench menu for comparison, applying an exact readable revision, recording an exact byte match, storing the Vault content as a child of a selected branch, retrying missing chunks without changing the tree, or explicitly discarding one selected live branch.
#### Fixed
- Automatic text and structured-data merge now uses the nearest revision actually shared by both branches. A resolution received from another device no longer recreates the same conflict merely because the Vault still contains the exact content of the removed branch.
- Edits, logical deletions, and renames made while a file remains conflicted extend the revision displayed on that device. When the relationship cannot be proved, LiveSync preserves the branches for review.
- Unreadable live revisions are preserved during automatic handling. An absent Vault file and a winning logical deletion are treated as agreement unless another live branch still requires attention.
- Garbage Collection V3 is limited to CouchDB and now protects every live conflict branch, required shared ancestry, and shared chunks. It stops when device progress cannot be verified and reports compaction failure without a contradictory success message.
### P2P and optional synchronisation features
#### Improved
- P2P and Hidden File Sync remain supported opt-in features. Customisation Sync remains a supported Advanced workflow, while Data Compression remains available but disabled by default.
- P2P controls remain outside the ordinary CouchDB experience until P2P is configured. The current status pane distinguishes announcing changes, following a peer, and persistent per-device actions.
- P2P setup and guidance now distinguish the required signalling relay from optional TURN and describe the replaceable public relay's privacy and availability limits.
- Enabling Hidden File Sync opens one progress Notice before saving the setting and reuses it until the initial scan has finished instead of stacking phase, reload, and restart messages.
#### Fixed
- First-device P2P setup can complete its signalling test without another peer online. Fetch on an additional device still requires an available source peer and a completed P2P Rebuild.
- P2P relay connections now close and are recreated reliably after settings changes and database resets.
### Interface, translation, and operations
#### Improved
- Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands retain their identifiers so that existing hotkeys continue to work.
- Setup and review dialogue text can be selected for copying or translation.
- Remote-size warnings use persistent clickable Notices. Initial uploads and Rebuild no longer ask to send every chunk in advance; ordinary replication completes the transfer.
- Obsolete controls for the plug-in trash setting and fixed chunk revisions were removed. The Change Log remains available but no longer opens automatically or tracks an unread count.
- Self-hosted LiveSync now owns its translation catalogue. Commonlib supplies canonical English to other consumers, while translation contributions can be made in the main Self-hosted LiveSync repository.
#### Fixed
- Applying an available interface translation no longer holds start-up behind an unsolicited dialogue; a persistent Notice opens the existing details on demand.
- Action buttons are arranged for narrow mobile screens, long dialogues keep their controls reachable, and persistent Notices no longer cover close controls.
### Storage and file selection
#### Fixed
- The optional Custom HTTP Handler used by Object Storage sends the correct byte range from binary request bodies and reports unsupported body types instead of silently sending an empty request.
- Broadening selectors, ignore rules, size or modification-time limits, or file-name case handling now rechecks previously received files without requiring another remote update.
- Start-up and full-inspection scans omit built-in legacy LiveSync log files and recovery flag files before comparing Vault and local-database state. Existing ignored database records remain untouched, and user-configured ignore behaviour is unchanged.
### Command-line tool
#### Fixed
- CLI Setup URI validation now uses the supported Commonlib ESM package interface.
- The non-root Docker image no longer depends on permissions inherited from the source checkout.
#### Security
- The CLI rejects detected path traversal and symbolic-link components before Vault operations.
### Validation
#### Testing
- Expanded automated Real Obsidian coverage for upgrades, two-device synchronisation, CouchDB, Object Storage, P2P, Hidden File Sync, mobile dialogues, conflict and revision recovery, failure diagnostics, and strict clean-up.
- Real CouchDB integration coverage verifies logical deletion, shared and conflict chunk retention, compaction, downstream replication, and recreation of content-addressed chunks.
- An encrypted Real Obsidian reconnect scenario replaces the remote Security Seed while one client retains the previous value, verifies that synchronisation adopts the replacement without restoring the old value, and proves a bidirectional encrypted round-trip.
- The plug-in code in this release was installed through BRAT and validated on macOS, iOS, and Android, including upgrade from 0.25.83, bidirectional synchronisation, P2P setup, conflict handling, recovery controls, mobile layouts, and start-up with existing configurations.
- Native and non-root Docker CLI scenarios cover setup, write, read, list, information, deletion, conflict resolution, and revision retrieval with the packaged Commonlib dependency.
+1
View File
@@ -3,6 +3,7 @@
The release history is now kept as one chronological sequence across smaller files:
- [Current 1.x releases](updates.md)
- [Earlier 1.0 releases](docs/releases/1.0.md)
- [1.0 beta and release-candidate history](docs/releases/1.0-previews.md)
- [0.25 releases](docs/releases/0.25.md)
- [Releases before 0.25](docs/releases/legacy.md)
+6 -1
View File
@@ -22,5 +22,10 @@
"1.0.7": "1.7.2",
"1.0.8": "1.7.2",
"1.0.9": "1.7.2",
"1.0.10": "1.7.2"
"1.0.10": "1.7.2",
"1.0.11": "1.7.2",
"1.0.12": "1.7.2",
"1.0.13": "1.7.2",
"1.0.14": "1.7.2",
"1.0.15": "1.7.2"
}