mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-19 18:07:06 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f02470ec5c | ||
|
|
a0684c4b3a | ||
|
|
dc5274df27 | ||
|
|
a6c93c358e | ||
|
|
d9df2a859f | ||
|
|
b7c2512da6 | ||
|
|
97b0ec25c7 | ||
|
|
bc41355a74 |
@@ -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,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.
|
||||
@@ -94,7 +94,7 @@ 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 live revision and has no conflicts;
|
||||
- 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
|
||||
|
||||
+3
-3
@@ -36,7 +36,7 @@ The flag deliberately enables file logging, which may affect performance. Remove
|
||||
|
||||
Use this workflow when one file, or a small number of known files, has conflicts, missing chunks, or a difference between the current Vault file and the local LiveSync database. The inspection is device-local: it does not query a remote database or prove that another device has the same chunks.
|
||||
|
||||
The `Hatch` recovery controls are ordered by escalation. Running **Recreate chunks for current Vault files** again with unchanged chunk settings and file contents produces the same chunks, and does not alter the revision tree. **Inspect conflicts and file/database differences** then provides actions for exact revisions. **Resolve All conflicted files by the newer one** is last because it applies a modification-time policy in bulk and logically deletes every other live version.
|
||||
The `Hatch` recovery controls are ordered by escalation. Running **Recreate chunks for current Vault files** again with unchanged chunk settings and file contents produces the same chunks, and does not alter the revision tree. **Inspect conflicts and file/database differences** then provides actions for exact revisions. **Resolve All conflicted files by the newer one** is last because it applies a modification-time policy in bulk and logically deletes every other current version.
|
||||
|
||||
1. Stop editing the affected file, pause replication on the participating devices, and keep a separate copy of every readable version.
|
||||
2. If another device or backup has the intended content, preserve that copy before changing any revision.
|
||||
@@ -52,7 +52,7 @@ 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.
|
||||
|
||||
@@ -112,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.
|
||||
|
||||
|
||||
+6
-6
@@ -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,11 +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 one live, unconflicted entry 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.
|
||||
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
|
||||
|
||||
@@ -1063,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
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ This document describes the conflict-resolution and file-reflection guarantees u
|
||||
|
||||
## Revision-tree model
|
||||
|
||||
PouchDB stores a document as a revision tree. It selects one live leaf as the deterministic winner and reports the other live leaves as conflicts. That winner is not proof that its content is newer, safer, or the version currently shown in the Vault.
|
||||
PouchDB stores a document as a revision tree. It selects one current leaf as the deterministic winner and reports the other current leaves as conflicts. That winner is not proof that its content is newer, safer, or the version currently shown in the Vault.
|
||||
|
||||
For example:
|
||||
|
||||
@@ -14,9 +14,25 @@ A1
|
||||
└── B2 ── C2
|
||||
```
|
||||
|
||||
The two live leaves are `D1` and `C2`. Their nearest shared ancestor is `A1`; neither `B1` nor `B2` is shared. A conservative three-way merge therefore compares the changes from `A1` to each leaf. Matching generation numbers, or selecting the first older revision from one branch, does not prove shared ancestry.
|
||||
The two current leaves are `D1` and `C2`. Their nearest shared ancestor is `A1`; neither `B1` nor `B2` is shared. A conservative three-way merge therefore compares the changes from `A1` to each leaf. Matching generation numbers, or selecting the first older revision from one branch, does not prove shared ancestry.
|
||||
|
||||
Resolving a conflict writes the selected or merged result on one observed branch and deletes the other observed live leaf. A stale device may still have the deleted leaf's content in its Vault when it receives the resolution.
|
||||
Resolving a conflict writes the selected or merged result on one observed branch and deletes the other observed conflict leaf. A stale device may still have the deleted leaf's content in its Vault when it receives the resolution.
|
||||
|
||||
### Independent revision properties
|
||||
|
||||
The modifiers defined under [Revision](terms.md#revision) describe independent properties, rather than exclusive revision types. The winner is a database-tree role, Vault-matching describes current file-state equality, and displayed identifies the device-local branch recorded for the Vault. The same revision commonly has all three properties, but synchronisation, conflicts, local edits, and missing provenance can separate them.
|
||||
|
||||
| Situation | Winner | Vault-matching | Displayed |
|
||||
| ---------------------------------------------------- | ------------------ | --------------------------------------------------- | ---------------------------------------------------- |
|
||||
| A present Vault file is fully converged | `R` | `R` | `R` |
|
||||
| The Vault displays conflict leaf `C` | `W` | `C` | `C` |
|
||||
| The database advances before Vault reflection | new winner `W2` | previous revision `R`, while the Vault is unchanged | `R` |
|
||||
| A local edit of displayed revision `R` is pending | independent | none, or a coincidental content match | `R`, as the branch which the edit must extend |
|
||||
| Provenance is missing and exactly one revision fits | independent | `M` | none, then `M` after safe reconstruction |
|
||||
| Provenance is missing and several revisions fit | independent | every matching revision | none |
|
||||
| A logical-deletion winner agrees with an absent file | deleted winner `D` | `D`, and possibly other logical-deletion revisions | none; an absent file retains no displayed provenance |
|
||||
|
||||
At most one revision is the winner, more than one revision can be Vault-matching, and at most one revision can be displayed for a path on one device. A displayed revision may stop matching the Vault while a local edit is pending, but its branch identity remains authoritative until that edit is stored or the relationship is safely reconstructed.
|
||||
|
||||
## Implemented 1.0 guarantees
|
||||
|
||||
@@ -25,7 +41,7 @@ Resolving a conflict writes the selected or merged result on one observed branch
|
||||
- A receiving Vault file which exactly matches any available revision in the document tree is treated as previously synchronised content. This includes an ancestor below a deleted losing leaf.
|
||||
- A receiving Vault file whose bytes do not match any available revision is preserved as an unsynchronised local change.
|
||||
- File bytes, rather than path, size, modification time, or revision generation, determine whether content is known.
|
||||
- Three or more live versions are reviewed one pair at a time in a deterministic order, with each completed pair committed before the next live pair is read.
|
||||
- Three or more current versions are reviewed one pair at a time in a deterministic order, with each completed pair committed before the next pair is read.
|
||||
- Each device records the exact revision most recently reflected in each Vault file. An edit, deletion, or case-only rename made while a conflict is active extends that displayed branch rather than the deterministic database winner.
|
||||
- A cross-path rename stores the target before logically deleting only the displayed source branch.
|
||||
|
||||
@@ -50,32 +66,32 @@ The compatibility implementation currently selects the newer modification time f
|
||||
|
||||
A document revision can remain in the PouchDB tree while one or more chunks needed to reconstruct its content are unavailable. Missing content is not evidence that the revision is obsolete. LiveSync therefore leaves an unreadable winner or conflict revision in the tree instead of deleting it during automatic conflict processing.
|
||||
|
||||
**Hatch** → **Inspect conflicts and file/database differences** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. A logical-deletion winner and an absent Vault file already agree, so that state is not reported unless another live branch still requires attention. When the Vault already matches the winner but conflict branches remain, the card shows the compact status `✅ Vault matches winner · ⚠️ Conflicts: N`; matching the winner does not mean that the conflict has been resolved.
|
||||
**Hatch** → **Inspect conflicts and file/database differences** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. A logical-deletion winner and an absent Vault file already agree, so that state is not reported unless another conflict branch still requires attention. When the Vault already matches the winner but conflict branches remain, the card shows the compact status `✅ Vault matches winner · ⚠️ Conflicts: N`; matching the winner does not mean that the conflict has been resolved.
|
||||
|
||||
Each reported live revision has a compact wrench menu. The available actions depend on the exact revision and current Vault state:
|
||||
Each reported current leaf revision has a compact wrench menu. The available actions depend on the exact revision and current Vault state:
|
||||
|
||||
- **Compare with Vault** opens the existing difference dialogue in read-only mode for differing text files.
|
||||
- **Apply this revision to Vault** writes the selected readable revision, even when it is not the database winner. Replacing an existing file requires confirmation.
|
||||
- **Mark this revision as the Vault version** is offered when the bytes already match. It records exact device-local provenance without creating a child revision, and refuses the operation if the file changed after inspection.
|
||||
- **Store Vault file as a child of this revision** preserves the current Vault bytes on the explicitly selected live branch.
|
||||
- **Store Vault file as a child of this revision** preserves the current Vault bytes on the explicitly selected current branch.
|
||||
- **Apply logical deletion to Vault** removes an existing Vault file after confirmation. An absent file needs no retained deletion provenance.
|
||||
- **Retry reading revision** attempts the configured chunk-retrieval path again. It does not change the revision tree.
|
||||
- **Discard this branch** is available for each exact live revision while at least one other live branch remains. It requires confirmation and creates a logical deletion on only the selected branch without changing the current Vault file.
|
||||
- **Discard unreadable revision** remains available as a recovery action when an unreadable revision is the only live leaf. It requires confirmation because no other database branch remains.
|
||||
- **Discard this branch** is available for each exact current leaf while at least one other current leaf remains. It requires confirmation and creates a logical deletion on only the selected branch without changing the current Vault file.
|
||||
- **Discard unreadable revision** remains available as a recovery action when an unreadable revision is the only current leaf. It requires confirmation because no other database branch remains.
|
||||
|
||||
Every mutating action rechecks that the selected revision is still a current live leaf. If another operation resolved or replaced it, the action fails and the card is refreshed instead of extending an obsolete branch.
|
||||
Every mutating action rechecks that the selected revision is still a current leaf. If another operation resolved or replaced it, the action fails and the card is refreshed instead of extending an obsolete branch.
|
||||
|
||||
The card uses compact, mobile-friendly diagnostic rows with an emoji and a text label. `🧩 Missing chunks: N` identifies an unreadable revision. In the database row, `Δsize` is decoded size minus recorded size; in the Vault row, `Δsize vs DB` is Vault size minus decoded database size. `Δtime` is Vault modification time minus database modification time. The ordinary two-second comparison window still labels which side is newer. These values help diagnose a mismatch; path, size, and modification time do not prove revision identity or decide which content should win.
|
||||
|
||||
A shared ancestor is informational. An ancestor which is no longer a live revision cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable live revisions can still be selected manually.
|
||||
A shared ancestor is informational. An ancestor which is no longer a current leaf cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable current leaf revisions can still be selected manually.
|
||||
|
||||
Logical deletion does not recreate missing bytes, purge the document history, or prove that the deleted version was unimportant. Another replica or backup may still contain the missing chunks. Recover from that source before discarding a revision whenever possible.
|
||||
|
||||
**Recreate chunks for current Vault files** can recreate chunks only from files which are readable in the current Vault. It cannot reconstruct unique bytes from an unavailable historical or conflict revision.
|
||||
|
||||
Garbage Collection V3 treats every live conflict revision and its nearest available shared ancestor as reachable. Their locally available chunks are retained until the conflict is resolved. After resolution, chunks used only by the discarded branch or no-longer-needed merge ancestry can become eligible for collection. See the [Garbage Collection V3 specification](specs_garbage_collection.md).
|
||||
Garbage Collection V3 treats every conflict leaf and its nearest available shared ancestor as reachable. Their locally available chunks are retained until the conflict is resolved. After resolution, chunks used only by the discarded branch or no-longer-needed merge ancestry can become eligible for collection. See the [Garbage Collection V3 specification](specs_garbage_collection.md).
|
||||
|
||||
A generation-one revision has no parent. When its body is unavailable, LiveSync cannot preserve a changed Vault file as a sibling branch without inventing ancestry. It leaves the operation unresolved. Recover the missing chunks from another replica or backup, or explicitly discard that live revision. If the current Vault file is the intended replacement, it can be stored after the unreadable revision has been logically deleted.
|
||||
A generation-one revision has no parent. When its body is unavailable, LiveSync cannot preserve a changed Vault file as a sibling branch without inventing ancestry. It leaves the operation unresolved. Recover the missing chunks from another replica or backup, or explicitly discard that current leaf. If the current Vault file is the intended replacement, it can be stored after the unreadable revision has been logically deleted.
|
||||
|
||||
### Two devices independently create the same path
|
||||
|
||||
@@ -100,19 +116,19 @@ and otherwise asks the user.
|
||||
|
||||
## Stale and concurrent resolutions
|
||||
|
||||
A device can resolve only the leaves which it has observed. If another device has already extended a branch, later replication can reveal another live leaf and require another resolution. Two devices can also produce different resolutions concurrently, leaving multiple live leaves after their trees meet.
|
||||
A device can resolve only the leaves which it has observed. If another device has already extended a branch, later replication can reveal another current leaf and require another resolution. Two devices can also produce different resolutions concurrently, leaving multiple current leaves after their trees meet.
|
||||
|
||||
A higher revision generation or modification time does not make either result authoritative. The resolver must examine every current live leaf again until one result remains or user action is required. This is continued conflict processing, not a reset of the synchronisation checkpoint.
|
||||
A higher revision generation or modification time does not make either result authoritative. The resolver must examine every current leaf again until one result remains or user action is required. This is continued conflict processing, not a reset of the synchronisation checkpoint.
|
||||
|
||||
## More than two live versions
|
||||
## More than two current versions
|
||||
|
||||
When three or more versions remain, LiveSync compares the current PouchDB winner with one conflict leaf at a time. Commonlib orders the remaining candidates by revision generation ascending, original leaf modification time ascending, then the complete revision ID in code-unit lexical order. A missing or non-finite modification time is ordered before a finite value. Modification time makes pair selection reproducible here; it does not decide which content wins.
|
||||
|
||||
For each pair, LiveSync first collapses identical content, then attempts a conservative sensible merge, and finally asks the user when neither automatic action is safe. A completed action is written to the ordinary revision tree and its losing observed leaf is deleted before LiveSync reads the remaining live leaves again. There is no separate persistent merge accumulator.
|
||||
For each pair, LiveSync first collapses identical content, then attempts a conservative sensible merge, and finally asks the user when neither automatic action is safe. A completed action is written to the ordinary revision tree and its losing observed leaf is deleted before LiveSync reads the remaining current leaves again. There is no separate persistent merge accumulator.
|
||||
|
||||
**Concat both** writes the concatenated result as a new child of the displayed PouchDB winner, then deletes only the other leaf shown in that dialogue. With two live versions, that action resolves the conflict. With three or more, the new child remains live against every untouched leaf and becomes part of the next pairwise review; it does not create an unrelated root or consume an unseen branch.
|
||||
**Concat both** writes the concatenated result as a new child of the displayed PouchDB winner, then deletes only the other leaf shown in that dialogue. With two current versions, that action resolves the conflict. With three or more, the new child remains a current leaf against every untouched leaf and becomes part of the next pairwise review; it does not create an unrelated root or consume an unseen branch.
|
||||
|
||||
Consequently, choosing **Not now** or closing Obsidian cannot undo a completed pair. After restart, LiveSync reconstructs the next pair from the live tree. If replication changes either revision while a dialogue is open, LiveSync discards the stale selection, refreshes the live count, and rechecks the path rather than deleting a revision which was not the one shown.
|
||||
Consequently, choosing **Not now** or closing Obsidian cannot undo a completed pair. After restart, LiveSync reconstructs the next pair from the current revision tree. If replication changes either revision while a dialogue is open, LiveSync discards the stale selection, refreshes the current-version count, and rechecks the path rather than deleting a revision which was not the one shown.
|
||||
|
||||
## Device-local file provenance
|
||||
|
||||
@@ -133,7 +149,7 @@ When no record exists, LiveSync may reconstruct the displayed revision only if t
|
||||
## Operations while a conflict exists
|
||||
|
||||
- Editing a file writes a child of its recorded or uniquely reconstructed displayed revision.
|
||||
- Deleting a file writes a logical-deletion child of that revision. It uses LiveSync's `deleted` marker, rather than a PouchDB `_deleted` tombstone, so the deletion remains a live branch which can replicate and be resolved against the other branch.
|
||||
- Deleting a file writes a logical-deletion child of that revision. It uses LiveSync's `deleted` marker, rather than a PouchDB `_deleted` tombstone, so the deletion remains a current branch which can replicate and be resolved against the other branch.
|
||||
- A case-only rename writes the new path as a child in the same document tree.
|
||||
- A cross-path rename stores the target document first, then writes a logical-deletion child on the displayed source branch.
|
||||
|
||||
@@ -146,7 +162,7 @@ uninterrupted conflict episode in the current plug-in session. Ordinary file
|
||||
checks and replication do not reopen the dialogue while at least one conflict
|
||||
leaf remains. If the in-editor status display is enabled, the active file shows
|
||||
**This file has 3 unresolved versions. They will be reviewed one pair at a
|
||||
time.** for three or more live versions, using the current count, and **This
|
||||
time.** for three or more current versions, using the current count, and **This
|
||||
file has unresolved conflicts.** for two. Postponement therefore does not make
|
||||
the conflict invisible.
|
||||
|
||||
@@ -163,8 +179,8 @@ When synchronisation supplies a resolved document, the existing incoming-file
|
||||
processing event closes an open conflict dialogue for that path. The same event
|
||||
rechecks the local revision tree: if no conflict leaf remains, it ends any
|
||||
postponed episode and removes the active-file warning. If conflict leaves still
|
||||
exist, the stale dialogue closes and the warning changes to the current live
|
||||
version count. A postponed episode stays postponed; otherwise, subsequent
|
||||
exist, the stale dialogue closes and the warning changes to the current-version
|
||||
count. A postponed episode stays postponed; otherwise, subsequent
|
||||
conflict processing may open a fresh dialogue for the current revision tree.
|
||||
Each dialogue owns its completion result, so a prompt which is answered or
|
||||
closed immediately still completes the waiting conflict operation; the result
|
||||
@@ -193,7 +209,7 @@ A1
|
||||
└── B2 ── C2 ── D2 Android edit
|
||||
```
|
||||
|
||||
After synchronisation, both devices receive `C1` and `D2` as the live branches. The edit is not moved silently onto `C1`, and ordinary conflict resolution can compare the real descendants.
|
||||
After synchronisation, both devices receive `C1` and `D2` as the current branches. The edit is not moved silently onto `C1`, and ordinary conflict resolution can compare the real descendants.
|
||||
|
||||
### A user deletes the branch shown on one device
|
||||
|
||||
@@ -205,13 +221,13 @@ A1
|
||||
└── B2 ── C2 ── D2 (deleted: true)
|
||||
```
|
||||
|
||||
The deletion remains one side of the live conflict. The user can still choose between the content at `C1` and deleting the file. LiveSync does not delete `C1` merely because PouchDB selected it as the winner.
|
||||
The deletion remains one current leaf of the conflict. The user can still choose between the content at `C1` and deleting the file. LiveSync does not delete `C1` merely because PouchDB selected it as the winner.
|
||||
|
||||
### A user renames a conflicted file
|
||||
|
||||
If the user changes only the spelling case, such as `Note.md` to `note.md`, LiveSync keeps the rename in the same revision tree and extends the revision displayed on that device.
|
||||
|
||||
If the user renames `draft.md` to `published.md`, LiveSync stores `published.md` before it marks the displayed `draft.md` branch as logically deleted. If an interruption occurs between those operations, the recoverable result is a duplicate which can be reviewed, rather than loss of the only copy. Any other live branch of `draft.md` remains available for conflict resolution.
|
||||
If the user renames `draft.md` to `published.md`, LiveSync stores `published.md` before it marks the displayed `draft.md` branch as logically deleted. If an interruption occurs between those operations, the recoverable result is a duplicate which can be reviewed, rather than loss of the only copy. Any other conflict branch of `draft.md` remains available for conflict resolution.
|
||||
|
||||
### A remote resolution reaches a device which still shows the losing content
|
||||
|
||||
@@ -221,7 +237,7 @@ If the user edited the file on Mac before the resolution arrived, the bytes no l
|
||||
|
||||
### A three-version review is interrupted
|
||||
|
||||
Mac receives three live versions of `shared.md`. The active-file status reports three unresolved versions, and the first dialogue compares the deterministic winner with the first ordered conflict leaf. The user completes that pair, leaving two live versions, then chooses **Not now** on the next dialogue and closes Obsidian.
|
||||
Mac receives three current versions of `shared.md`. The active-file status reports three unresolved versions, and the first dialogue compares the deterministic winner with the first ordered conflict leaf. The user completes that pair, leaving two current versions, then chooses **Not now** on the next dialogue and closes Obsidian.
|
||||
|
||||
The first decision has already changed the ordinary revision tree. On restart, LiveSync reads the two surviving versions and presents only that remaining pair; it does not reconstruct the original three-version state. If another device resolves the remaining pair before or while the dialogue is open, the warning disappears and the stale dialogue closes.
|
||||
|
||||
@@ -251,8 +267,8 @@ Do not:
|
||||
|
||||
## Verification
|
||||
|
||||
Commonlib's real-PouchDB and injected-boundary unit tests cover unequal branch lengths, exact shared ancestry, deterministic ordering of multiple live leaves, a sensible stage followed by reconstruction of a manual pair, content below a deleted losing leaf, recorded and reconstructed branch identity, ambiguous matches, conflict-time editing, missing-body preservation when parent metadata is available, refusal to invent a parent for a generation-one revision, logical deletion, case-only rename, cross-path rename, and safe unproven fallbacks.
|
||||
Commonlib's real-PouchDB and injected-boundary unit tests cover unequal branch lengths, exact shared ancestry, deterministic ordering of multiple current leaves, a sensible stage followed by reconstruction of a manual pair, content below a deleted losing leaf, recorded and reconstructed branch identity, ambiguous matches, conflict-time editing, missing-body preservation when parent metadata is available, refusal to invent a parent for a generation-one revision, logical deletion, case-only rename, cross-path rename, and safe unproven fallbacks.
|
||||
|
||||
LiveSync's optional real-Obsidian two-Vault checks have two scopes. `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true` resolves and edits a Markdown conflict, propagates it to a Vault which still displays the deleted losing content, and requires one live result to remain. `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` edits, deletes, case-renames, and cross-path-renames files while conflicts remain active; it verifies the parent revision of each resulting branch, replicates those exact trees, and confirms that the other live branches remain intact.
|
||||
LiveSync's optional real-Obsidian two-Vault checks have two scopes. `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true` resolves and edits a Markdown conflict, propagates it to a Vault which still displays the deleted losing content, and requires one current result to remain. `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` edits, deletes, case-renames, and cross-path-renames files while conflicts remain active; it verifies the parent revision of each resulting branch, replicates those exact trees, and confirms that the other conflict branches remain intact.
|
||||
|
||||
The focused `test:e2e:obsidian:conflict-dialog-policy` scenario creates three live versions in one real Obsidian Vault. It verifies the count warning, commits a concatenated child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the isolated Obsidian profile, and confirms that only the live pair is reconstructed. It also verifies that an incoming resolution closes a stale dialogue, completes the waiting conflict operation, and clears the warning. The repair scenario removes a referenced local chunk, confirms that the exact unreadable live revision remains in the tree, and exercises explicit retry and discard controls without deleting another live revision.
|
||||
The focused `test:e2e:obsidian:conflict-dialog-policy` scenario creates three current versions in one real Obsidian Vault. It verifies the count warning, commits a concatenated child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the isolated Obsidian profile, and confirms that only the current pair is reconstructed. It also verifies that an incoming resolution closes a stale dialogue, completes the waiting conflict operation, and clears the warning. The repair scenario removes a referenced local chunk, confirms that the exact unreadable current leaf remains in the tree, and exercises explicit retry and discard controls without deleting another current leaf.
|
||||
|
||||
@@ -33,19 +33,19 @@ If the initial synchronisation, device inspection, or confirmation fails, the wo
|
||||
A chunk remains reachable when it is referenced by any of the following:
|
||||
|
||||
- the current database winner for a file;
|
||||
- any other live conflict revision for that file;
|
||||
- an available revision on either side of a live conflict which is required to describe the divergence; or
|
||||
- the nearest available revision shared by both live conflict branches.
|
||||
- any conflict leaf for that file;
|
||||
- an available revision on either side of a current conflict which is required to describe the divergence; or
|
||||
- the nearest available revision shared by both conflict branches.
|
||||
|
||||
Chunk identifiers are content-derived and shared between files. Reachability is therefore collected into one set across the database. A chunk used by two or more current files remains protected even when one file is updated or deleted.
|
||||
|
||||
An ordinary superseded linear revision does not protect its former chunks. Once no current file or live conflict branch references a chunk, it can be collected. After a conflict is resolved, chunks unique to the discarded branch and to no-longer-needed merge ancestry can also become eligible.
|
||||
An ordinary superseded linear revision does not protect its former chunks. Once no current file or conflict leaf references a chunk, it can be collected. After a conflict is resolved, chunks unique to the discarded branch and to no-longer-needed merge ancestry can also become eligible.
|
||||
|
||||
## Consequences
|
||||
|
||||
Garbage Collection deliberately trades historical recoverability for storage. A metadata revision may remain in the revision tree after a chunk which only that superseded revision used has been collected, so that historical body can become unreadable. Remote compaction can then discard old CouchDB revision bodies. Tombstones and retained metadata also consume storage, so the operation does not promise the smallest possible database.
|
||||
|
||||
Writing the same bytes again produces the same content-derived chunk identifier. If that chunk was collected previously, the normal chunk-writing path creates a new live revision for it, and ordinary replication can transfer it again. This does not recover an older file revision automatically; it only makes the newly written content available.
|
||||
Writing the same bytes again produces the same content-derived chunk identifier. If that chunk was collected previously, the normal chunk-writing path creates a new non-deleted revision of the Chunk document, and ordinary replication can transfer it again. This does not recover an older file revision automatically; it only makes the newly written content available.
|
||||
|
||||
Garbage Collection does not reconstruct a chunk which is already missing, determine whether an unreadable revision is important, or repair a damaged local database. Use **Inspect conflicts and file/database differences**, another healthy replica, or a backup for those cases. Use **Overwrite Server Data with This Device's Files** only when a chosen Vault is authoritative and a deliberate remote rebuild is required.
|
||||
|
||||
@@ -55,7 +55,7 @@ Commonlib tests use real in-memory PouchDB revision trees to verify:
|
||||
|
||||
- collection eligibility after a normal file update;
|
||||
- protection of chunks shared by multiple current files;
|
||||
- protection of all live conflict branches and their nearest available shared ancestor;
|
||||
- protection of all conflict branches and their nearest available shared ancestor;
|
||||
- eligibility of losing-branch and ancestor-only chunks after conflict resolution;
|
||||
- propagation of chunk deletion to another PouchDB database; and
|
||||
- recreation and propagation when the same content is written again.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -58,11 +58,11 @@ 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.
|
||||
|
||||
@@ -141,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.
|
||||
|
||||
|
||||
Generated
+4
-4
@@ -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.15",
|
||||
"@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",
|
||||
@@ -4771,9 +4771,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.15.tgz",
|
||||
"integrity": "sha512-uQxxzOdzu0MVcS6g20AxNLNj933Q3N5j/gZ2o6sem36vlsXQPKnsjGjTzrYcYAXNJ9mkR82EvC8qmKnOZvjEPg==",
|
||||
"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",
|
||||
|
||||
+2
-1
@@ -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.15",
|
||||
"@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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ const focusedScenarios = new Set([
|
||||
"dialog-mounts",
|
||||
"revision-repair",
|
||||
"document-history-nav",
|
||||
"document-history-restore",
|
||||
"settings-ui",
|
||||
"review-harness",
|
||||
"p2p-pane",
|
||||
|
||||
+17
-2
@@ -12,6 +12,20 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
## 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
|
||||
@@ -51,9 +65,10 @@ Thank you for your patience. At last, it looks as though we can clear some of th
|
||||
#### 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 one live, unconflicted entry 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.
|
||||
|
||||
- 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!
|
||||
|
||||
|
||||
Reference in New Issue
Block a user