Compare commits

...
Author SHA1 Message Date
vorotamoroz b7c2512da6 chore: adopt Commonlib 0.1.16 2026-08-18 09:17:22 +00:00
vorotamoroz 97b0ec25c7 docs: record CouchDB remote connection ownership 2026-08-18 07:52:16 +00:00
vorotamoroz bc41355a74 Merge pull request #1115 from vrtmrz/1_0_15
Releasing 1.0.15
2026-08-16 00:28:55 +09:00
3 changed files with 334 additions and 5 deletions
@@ -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
+4 -4
View File
@@ -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.16",
"@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.16",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.16.tgz",
"integrity": "sha512-vQlhLP2Qvf72zc/FedJQgOy/+Ri5zWuX+WKSD9fGaSWkBtX+nWkHx/9fgGdwkNEgighd4eCREVXfPvjcJKWX4w==",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.808.0",
+1 -1
View File
@@ -177,7 +177,7 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.15",
"@vrtmrz/livesync-commonlib": "0.1.16",
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",