mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-15 04:30:12 +00:00
fix: enable guarded recovery of unapplied deletions
This commit is contained in:
1 parent
517f5c248f
commit
18ff38e04c
14 files changed
+1253
-13
No files matched your search
@@ -0,0 +1,563 @@
|
||||
---
|
||||
date: 2026-11-10
|
||||
commonlib-version: "0.4.1"
|
||||
self-hosted-livesync-version: "1.0.47"
|
||||
status: accepted; unreleased
|
||||
---
|
||||
|
||||
# Recover unapplied database deletions during an ordinary scan
|
||||
|
||||
## Status and purpose
|
||||
|
||||
Accepted on 10 November 2026 after the content-preservation, Fetcher-policy,
|
||||
startup integration, and three-file concurrency review. Implementation is complete
|
||||
in the local candidate and remains unreleased. The reviewed versions describe the
|
||||
baseline, not a release containing this recovery. Historical review findings and measured
|
||||
baseline calls remain below; implementation verification is tracked separately.
|
||||
|
||||
The [current write-path reference](file_write_and_reflection_paths.md) records
|
||||
which entry points preserve content, what their completion means, and who retries
|
||||
failed work. [Issue #1338](https://github.com/vrtmrz/obsidian-livesync/issues/1338)
|
||||
exposes a gap after a received deletion reaches the local database but its Vault
|
||||
reflection does not complete. The original iPhone failure remains unconfirmed.
|
||||
|
||||
An existing ordinary scan should apply a retained, newer deletion to a provably
|
||||
unchanged file without Fetching the database again. It should also finish
|
||||
associated provenance and last-seen cleanup after interruption. Preserving unique
|
||||
local content takes priority over automatic convergence. Runtime storage access,
|
||||
memory, and persistence overhead are design constraints.
|
||||
|
||||
## Recommended scope
|
||||
|
||||
Add this policy to ordinary `NEWER_WINS` with no explicit extra policy. Reuse the
|
||||
scanner's collected pairs and existing last-seen map. Let `N` be the selected
|
||||
pairs already examined by the scan. Distinguish:
|
||||
|
||||
- `K`: selected ordinary files paired with valid, retained logical-deletion
|
||||
Metadata, no conflicts, and a deletion newer under the existing freshness
|
||||
comparison. Freshness selects candidates; it never proves unchanged content.
|
||||
- `D`: selected paths with valid, retained logical-deletion Metadata and no Vault
|
||||
file or conflicts. These are candidates for bookkeeping cleanup only.
|
||||
- `P`: existing provenance keys in the device-local namespace. When `D` is
|
||||
non-empty, obtain these keys once, without reading their record values or file
|
||||
bodies. `A` is the subset of `D` with a provenance key or last-seen entry.
|
||||
|
||||
`K` and `A` share normal target, identity, and suspension checks. Equal-time or
|
||||
storage-newer present files retain the existing conservative policy. Logical
|
||||
deletions removed by retention or already absent from normal Metadata enumeration
|
||||
remain outside this scope; the proposal does not change retention order.
|
||||
|
||||
The extra key enumeration is an explicit change from the initial present-file
|
||||
proposal. It discovers interrupted cleanup without a durable retry journal.
|
||||
There is no additional Vault listing, Metadata traversal, or historical-revision
|
||||
search. Pairs outside `K` and `A` incur no added body reads or persistent writes;
|
||||
the provenance key enumeration still has a cost proportional to `P`.
|
||||
|
||||
Commonlib owns shared comparison, document locking, reflection, and scanner
|
||||
policy. The Obsidian host supplies complete, namespace-scoped provenance key
|
||||
enumeration through its existing key-value store and presents scan results.
|
||||
The current provenance interface has no enumeration method: exposing this
|
||||
capability is part of the proposed work. It must use keys only and the correct
|
||||
namespace bounds, rather than a general database scan or the current store
|
||||
wrapper's default empty range. This capability does not add a stored index.
|
||||
Other hosts retain their existing behaviour until they supply and verify it.
|
||||
|
||||
## Proposed outcomes
|
||||
|
||||
The implementation models these observations as named subtypes of
|
||||
`DeletionRecoveryResult`, rather than an unrestricted product of status fields.
|
||||
Only an absent file with cleared provenance can be applied. Pending cleanup and
|
||||
unknown removal effects retain retry evidence; a protected edited or unprovable
|
||||
file need not converge automatically. The subtype table is recorded in Commonlib's
|
||||
`docs/deletion-recovery.md`, included in the local package candidate. This contract
|
||||
remains unreleased.
|
||||
|
||||
Represent file effect, bookkeeping, and reason separately. For example, the file
|
||||
can be unchanged, confirmed absent, or unknown after an error; provenance can be
|
||||
unchanged, cleared, or pending. Scanner-owned last-seen persistence has its own
|
||||
completion result. A Boolean cannot express these boundaries.
|
||||
|
||||
| Observed state | Required automatic recovery result |
|
||||
| --- | --- |
|
||||
| Recorded evidence matches the present file and final checks agree | Remove the file, confirm absence, attempt provenance cleanup, and remove its in-memory last-seen entry. Report complete only after the required persistence succeeds. |
|
||||
| No provenance record, unprovable origin, or differing content | Defer without changing the file, provenance, or database tree. Preserve ordinary observation bookkeeping. |
|
||||
| Provenance, base retrieval, or verification I/O fails | Report the phase and failure, rather than treating the error as a missing record. Do not delete on incomplete evidence. |
|
||||
| A conflict, restoration, replacement revision, or changed file is observed | Defer this attempt. Do not follow successive revisions or loop on the same path. |
|
||||
| Removal fails and the file remains present | Report failure; the pair remains discoverable as a present-file candidate. Retain its provenance and observation bookkeeping. |
|
||||
| The file is confirmed absent but provenance cleanup fails | Report physical completion with cleanup pending. Remove the in-memory last-seen entry; remaining provenance keys rediscover the cleanup on a later scan. |
|
||||
| The file's state cannot be established after an error | Report an unknown file effect and retain bookkeeping. Reconcile current state on a later scan; do not infer absence from an exception. |
|
||||
| A previously absent file still has bookkeeping | Revalidate the absent-file/deleted-Metadata pair under the document lock, then clear only its stale bookkeeping. Do not invoke physical deletion. |
|
||||
| Only empty-parent removal fails | Report the file's actual result and a folder-cleanup warning. The empty folder does not make the file deletion incomplete, and this proposal creates no folder retry queue. |
|
||||
|
||||
The host reports applied deletions, conservative deferrals, failed verification
|
||||
or removal, and pending bookkeeping separately in one scan summary. A skipped or
|
||||
deferred file is not reported as converged. Detailed paths and reasons remain
|
||||
available through diagnostics and existing inspection; emit no per-file notice
|
||||
storm for a large backlog.
|
||||
|
||||
## Processing and ownership
|
||||
|
||||
### Read-only eligibility and final checks
|
||||
|
||||
Acquire the existing document lock before loading content. Do not call public
|
||||
wrappers which reacquire that lock. Recheck the current target, file, winning
|
||||
deletion, and conflicts after waiting for the lock, then read provenance strictly.
|
||||
An error is distinct from an absent record. The new operation always requires
|
||||
preservation, including when `writeDocumentsIfConflicted` is enabled; existing
|
||||
incoming reflection and explicit overwrite choices keep their current policy.
|
||||
|
||||
Use the recorded-content comparison through a read-only boundary for file
|
||||
Metadata and provenance. Normal cache updates and persistence of remotely
|
||||
delivered Chunks remain owned by the retrieval pipeline and are included in
|
||||
its I/O cost:
|
||||
|
||||
1. Missing provenance defers before a body read. A valid saved content size which
|
||||
differs from the observed file size may also defer cheaply. Size can reject
|
||||
automatic deletion, but cannot authorise it.
|
||||
2. With valid saved hash evidence, read and hash the current file once. A mismatch
|
||||
defers immediately; it does not need old Chunks, live-leaf searches, branch
|
||||
creation, or provenance deletion to establish this decision. Hashing failure
|
||||
is a verification failure, not proof of a mismatch.
|
||||
3. For a legacy record without usable hash evidence, try only its exact recorded
|
||||
base through the existing Chunk retrieval pipeline before loading the current
|
||||
file. Do not force `localOnly: true`, suppress remote requests, or disable
|
||||
delivery waiting merely because this is recovery eligibility. Respect the
|
||||
pipeline's existing settings, transport capabilities, and delivery lifecycle.
|
||||
A base unavailable after that policy settles defers; a distinguishable access
|
||||
failure fails.
|
||||
If the current database boundary conflates these cases, make that boundary
|
||||
explicit as part of this operation. Compare readable base bytes with one
|
||||
captured current body and release the base when classification finishes.
|
||||
4. For a match, make one final fresh body read and compare it with the captured
|
||||
body. A changed path or raw mtime defers even if the bytes match; equal stats
|
||||
alone never establish equality. Recheck provenance, candidate freshness, and
|
||||
the same winning deletion and conflict state; a matching `_rev` alone does not
|
||||
rule out a newly arrived conflict. Perform the final path/type/target check
|
||||
and removal in the same reflection stage,
|
||||
without unrelated awaited work or falling back to forced deletion.
|
||||
|
||||
Reuse the actual content-evidence and equality rules. Do not call the full
|
||||
`classifyStorageContent` or conflict-preservation fallback unchanged: those
|
||||
helpers can mutate provenance or write branches. Keep their existing callers'
|
||||
behaviour when extracting the observational comparison.
|
||||
|
||||
These checks are not a transaction across storage, replication, and provenance.
|
||||
Document locks exclude competing file-handler operations, not external edits or
|
||||
database replication. Preserve and test the final-check protection; do not claim
|
||||
protection against every edit after the last observation. The two-read target
|
||||
must not be met by removing a required protection.
|
||||
|
||||
### Physical deletion and provenance cleanup
|
||||
|
||||
The shared storage responsibility must expose the file effect separately from
|
||||
selection no-ops and empty-parent cleanup. A resolved `deleteVaultItem` call alone
|
||||
does not prove removal. Confirm the target is absent through a fresh storage
|
||||
lookup, including after an error, before acknowledging removal or clearing its
|
||||
provenance. A replacement or recreated file prevents cleanup based on the old
|
||||
observation. The lookup adds no body read; its real adapter cost is measured.
|
||||
|
||||
Attempt provenance deletion only after confirming absence, while still holding
|
||||
the document lock and respecting the current deletion. Use an error-propagating
|
||||
cleanup operation; the existing best-effort helper cannot express pending work.
|
||||
Clearing provenance before file removal is rejected because interruption could
|
||||
leave the remaining file without the evidence needed for safe recovery.
|
||||
|
||||
Do not roll back a confirmed file deletion merely because later bookkeeping
|
||||
fails. Do not retry physical deletion to finish bookkeeping. Optional parent
|
||||
cleanup retains the host's existing preferences, but its result is separate.
|
||||
|
||||
### Rediscovering cleanup after interruption
|
||||
|
||||
During pair preparation, compute `D`. If it is empty, skip provenance enumeration.
|
||||
Otherwise, obtain the namespaced key list once and intersect it with the existing
|
||||
pair and last-seen maps to construct `A`. Process these pre-existing absent-file
|
||||
cases before starting new candidate deletions. A failed cleanup is attempted at
|
||||
most once in this scan, without being immediately requeued.
|
||||
|
||||
For each path in `A`, acquire the document lock and freshly verify that the file
|
||||
remains absent and the current database result is a conflict-free logical
|
||||
deletion. Defer if a file, restoration, or conflict has appeared. Clear remaining
|
||||
provenance when a key exists, and remove the last-seen entry after confirmed
|
||||
absence even if provenance cleanup fails. The owner must not delete a new record
|
||||
or file on the strength of an old enumeration alone.
|
||||
|
||||
This covers interruption after file removal but before either cleanup: provenance
|
||||
keys or the persisted last-seen map supply the remaining evidence. If provenance
|
||||
cleanup succeeded but map persistence did not, the old map entry identifies the
|
||||
remaining work. If both are gone, there is no bookkeeping left to retry. No new
|
||||
failure record, completion marker, journal, or queue snapshot is written.
|
||||
|
||||
If key enumeration fails, report maintenance failure and do not acknowledge that
|
||||
cleanup as complete. Independently verifiable present-file work may proceed; a
|
||||
later existing scan can retry discovery. These guarantees require retained
|
||||
logical-deletion Metadata, successful future scans, and the required storage
|
||||
capabilities. They do not extend to tombstone discovery or database reset.
|
||||
|
||||
### Last-seen correctness and persistence cost
|
||||
|
||||
Update the already loaded map in memory. A confirmed absent target removes its
|
||||
entry; a deferred or still-present target retains ordinary observed-file mtime
|
||||
bookkeeping and receives no successful-reflection timestamp. An unknown physical
|
||||
outcome does not authorise clearing the entry.
|
||||
|
||||
Recovery must not call `clearOfflineScannerLastSeen` per path: that public helper
|
||||
reads and writes the complete persisted map each time. Coalesce recovery changes
|
||||
into the scanner's end-of-scan persistence, with no new per-path timer or write.
|
||||
Await the ordered flush of the latest map, including previously queued writes,
|
||||
before reporting bookkeeping completion. Capture the map inside the existing
|
||||
serialisation boundary so that an older snapshot cannot replace newer changes.
|
||||
|
||||
The recovery policy schedules at most one additional final whole-map flush per
|
||||
scan when necessary. Existing unrelated scanner writes remain separately counted.
|
||||
Suppressing recovery's per-path persistence requests is part of this change;
|
||||
merely using a one-second debounce during a long backlog does not meet this
|
||||
budget. A flush failure produces an incomplete-bookkeeping result, not a claim
|
||||
that removed files remain present. The next scan uses the persisted map and
|
||||
provenance keys to reconcile the retained logical-deletion pairs again.
|
||||
|
||||
### Repeated deferrals and bounded work
|
||||
|
||||
Each candidate receives at most one attempt per existing scan. There is no new
|
||||
timer, automatic full rescan, or same-session retry loop. A changed file may stay
|
||||
deferred if no local event saves it; report that state and retain existing
|
||||
inspection choices. This is preservation, not automatic convergence.
|
||||
|
||||
Allow at most three recovery file operations at a time per Vault, including
|
||||
waiting for their document locks and Chunk delivery. Acquire the shared recovery
|
||||
permit before the document lock and before loading or reconstructing bodies;
|
||||
release it in every completion, deferral, failure, and cancellation path. Share
|
||||
this cap across overlapping recovery invocations for the same Vault. Recovery
|
||||
occupies the scan's existing worker slots: do not add three workers on top of the
|
||||
reviewed scanner's ten. Other scan paths retain their existing limit.
|
||||
|
||||
Use a continuously replenished pool: when one operation finishes, start the next
|
||||
eligible file without waiting for the other two. Do not process fixed groups of
|
||||
three or introduce an ordered-result barrier. This allows small files to continue
|
||||
while a large file waits for storage or remote delivery. Reuse the existing
|
||||
bounded-concurrency primitives; no new scheduler or dependency is required.
|
||||
|
||||
Keep Fetcher's own request concurrency, spacing, and retry ownership unchanged.
|
||||
Keep the shared content-hash semaphore unchanged too: it currently serialises
|
||||
hash-buffer allocation and hashing. Three file operations allow overlap around
|
||||
those boundaries; they do not promise three simultaneous hashes or continued
|
||||
progress if a shared dependency itself stalls.
|
||||
|
||||
Release bodies between candidates and do not preload queued files. Honour
|
||||
existing suspension/cancellation boundaries between attempts, and do not cache a
|
||||
destructive eligibility decision by path, mtime, or size. No persistent or
|
||||
session-wide negative cache is proposed.
|
||||
|
||||
The choice deliberately accepts a repeat cost for deferred content on later
|
||||
scans. Valid size mismatches and missing evidence avoid body reads; a hash mismatch
|
||||
costs at most one current-body read per scan. For `r` scans of unchanged deferred
|
||||
candidates, budget up to `r * sum(S_deferred)` additional logical current-body
|
||||
bytes, plus any exact legacy-base retrievals, remote delivery, and Fetcher-owned
|
||||
retries. One recovery attempt is not a limit on Fetcher attempts. If this exceeds measured device
|
||||
limits, reconsider scheduling or a safe deferral cache as a separate decision;
|
||||
do not hide that trade-off behind the successful-deletion read count.
|
||||
|
||||
### Chunk retrieval policy and the start-up boundary
|
||||
|
||||
The eligibility operation must delegate missing-Chunk handling to the existing
|
||||
[retrieval policy](chunk_retrieval_and_waiting.md) and its
|
||||
[accepted lifecycle contract](../adr/2026_07_chunk_arrival_quiescence.md).
|
||||
`localOnly: true` currently forces both `preventRemoteRequest: true` and
|
||||
`waitForDelivery: false`; it would bypass delivery even when the ordinary policy
|
||||
could obtain the required Chunks. The earlier proposal's unconditional local-only
|
||||
restriction is withdrawn following user review. This changes the proposed
|
||||
recovery, not the recorded Commonlib 0.4.1 reflection baseline.
|
||||
|
||||
For CouchDB, direct retrieval is available when `useOnlyLocalChunk` is false,
|
||||
independently of `readChunksOnline`. Other transports and suppression settings
|
||||
retain their established policy, including waiting for an already-observable
|
||||
finite producer where permitted. Use the normal delivery-waiting read; do not
|
||||
change a global setting, start another replication, call the remote directly,
|
||||
or introduce recovery-specific arrival deadlines or missing-result retries.
|
||||
Local absence is not proof of remote absence. Issue
|
||||
[#1224](https://github.com/vrtmrz/obsidian-livesync/issues/1224) is a related timing
|
||||
precedent: Metadata can become visible before its Chunks, so the delivery owner
|
||||
must finish its retry and completion policy before the read is abandoned.
|
||||
|
||||
Integrate recovery into the same ordinary start-up scan. Reuse its collected
|
||||
pairs, run pre-existing absent-file cleanup before new recovery attempts, and
|
||||
include candidate processing and the final map flush in that scan's completion.
|
||||
This is not a second concurrent Vault/Metadata scan. Restored local work keeps
|
||||
its existing ordering before pair collection. Normal non-recovery pairs retain
|
||||
their existing bounded processing; recovery operations use the shared
|
||||
three-file cap within those scan slots.
|
||||
|
||||
The current `prepareDatabaseForUse` awaits `scanVault` before initialisation
|
||||
handlers, pending-event commit, and `markIsReady`; the Obsidian host awaits that
|
||||
initialisation before applying its normal synchronisation settings. Additional
|
||||
recovery on this path can therefore delay LiveSync readiness. The word
|
||||
'offline' identifies reconciliation of local database and Vault observations; it
|
||||
does not prohibit the Chunk pipeline's existing on-demand delivery.
|
||||
|
||||
Use this workload model rather than a promised start-up time. The current ordinary scan skips these present-file deletion pairs, so
|
||||
their verification and removal are additional work relative to current start-up.
|
||||
The four-to-two comparison measures reuse versus the proposed handler, not a
|
||||
speed-up over the existing scan:
|
||||
|
||||
`additional work ≈ key discovery + absent-file cleanup + sum(candidate work) + final map persistence`.
|
||||
|
||||
Candidate work includes body reads, hashing/comparison, current-state checks,
|
||||
removal, and provenance cleanup. For legacy evidence, add exact-base assembly,
|
||||
Fetcher queueing/throttling, remote delivery and local Chunk persistence, and any
|
||||
retry/waiting time. Some normal scan work may overlap; shared resource contention
|
||||
can also change its duration. Measure the change in the actual start-up boundary
|
||||
rather than equating the work sum with a precise wall-clock delay.
|
||||
|
||||
For illustration only, 1,160 candidates averaging 10 ms locally represent 11.6
|
||||
seconds of serial work; at 50 ms they represent 58 seconds. With three equally
|
||||
busy operations and perfect overlap, these become about 3.9 and 19.3 seconds of
|
||||
candidate processing. These are hypothetical sensitivity examples, not iPhone
|
||||
measurements or promises: the slowest file, storage contention, serial hashing,
|
||||
Fetcher limits, key discovery, cleanup, and final persistence can dominate.
|
||||
|
||||
A missing first remote response can introduce the Fetcher's two-second retry;
|
||||
active finite delivery can extend the wait further. There is no fixed total
|
||||
arrival deadline, and the five-minute inactivity fuse is not a five-minute
|
||||
start-up cap. Three file operations permit more cross-file request overlap, while
|
||||
Fetcher still owns physical request concurrency and batching.
|
||||
|
||||
User review identified the head-of-line cost of a single large file, so the
|
||||
previous one-file proposal is replaced by a three-file cap. This balances overlap
|
||||
against the number of retained file snapshots; it is not a verified latency or
|
||||
memory guarantee. Compare one and three operations with representative mixed-size
|
||||
and remote-dependent backlogs. Do not obtain a faster start by silently disabling
|
||||
permitted Chunk delivery or by adding an independent timeout.
|
||||
|
||||
## Existing access cost and proposed budget
|
||||
|
||||
A source trace and a small execution probe of Commonlib 0.4.1's actual
|
||||
`applyDatabaseEntryToStorage` export establish the following stable successful
|
||||
core path. The probe supplies synthetic storage, database, and provenance
|
||||
boundaries, uses Node.js 24.18.0, and performs no real Vault I/O. It verifies
|
||||
logical calls, not mobile latency, physical reads, cache effectiveness, or caller
|
||||
preparation. No production code was changed for the probe.
|
||||
|
||||
| Core operation | Matching saved hash | Legacy record with readable matching base |
|
||||
| --- | ---: | ---: |
|
||||
| `readFileFromStub` | 4 | 4 |
|
||||
| `getStub` | 4 | 4 |
|
||||
| Provenance reads | 4 | 4 |
|
||||
| Current Metadata reads | 1 | 1 |
|
||||
| Conflict queries | 1 | 1 |
|
||||
| Exact recorded-base body fetches | 0 | 1 |
|
||||
| Vault deletions / provenance deletions | 1 / 1 | 1 / 1 |
|
||||
|
||||
The four current-body requests are the initial protection read, validation inside
|
||||
protection, validation on returning to deletion, and final validation after the
|
||||
current database revision check. Caller preparation adds work, and a database or
|
||||
storage operation can contain several lower-level reads. A matching hash avoids
|
||||
the old Chunk read pipeline; it still hashes current file bytes. The legacy
|
||||
fallback reads the exact base using local-only Chunk retrieval.
|
||||
|
||||
For `K` stable deletions of sizes `S_i`, unmodified reuse of that core requests
|
||||
`4K` Vault-body reads, representing `4 * sum(S_i)` logical file-body bytes before
|
||||
Chunk reads, comparisons, hashing, adapter effects, and caches. For a backlog of
|
||||
1,160 such files, that is 4,640 body-read requests. The report does not establish
|
||||
all those file sizes, so it does not establish elapsed time or physical I/O.
|
||||
The unchanged `N - K` pairs must not inherit these additional reads.
|
||||
|
||||
The proposed budgets below are acceptance targets, not measured performance of
|
||||
an implementation:
|
||||
|
||||
| Scope | Additional work proposed |
|
||||
| --- | --- |
|
||||
| Neither `K` nor `D` exists | No added body reads, provenance enumeration, or persistent writes. |
|
||||
| `D` exists, including a normally clean deleted-file backlog | One provenance keys-only enumeration of `P`; in-memory intersections; no record-value fetch for each healthy file. This cost occurs even when `A` is empty. |
|
||||
| Missing provenance or valid size mismatch | No Vault-body read, no Chunk read, and no mutation from eligibility classification. |
|
||||
| Valid hash mismatch | At most one Vault-body read and hash; no historical Chunk or live-leaf search and no preservation write. |
|
||||
| Stable hash-matching deletion | Two current-body reads as the target; fresh database/provenance/storage checks; one file removal and one provenance deletion. No Metadata revision or Chunk write. |
|
||||
| Legacy evidence | One reconstruction request for the exact recorded base per recovery attempt, using normal Chunk policy; remote reads, Fetcher retries, and fetched-Chunk persistence are possible. Add zero, one, or two current-body reads according to availability and comparison. |
|
||||
| Previously absent cleanup, `A` | Fresh target/absence/database checks; provenance deletion only for a remaining key; no file-body read or physical file deletion. |
|
||||
| Recovery last-seen changes | In-memory updates and a shared, awaited final map flush; no per-path whole-map persistence. |
|
||||
|
||||
The initial 1,160-file example would target 2,320 current-body requests for stable,
|
||||
proven hash-matching deletions, compared with the measured core's 4,640. It does
|
||||
not include key enumeration, verification lookups, provenance writes, parent
|
||||
cleanup, or adapter internals, and it is not a prediction of elapsed time.
|
||||
|
||||
The three-file cap bounds admitted recovery operations, not memory to three file
|
||||
sizes. Each operation may retain its captured and final bodies, or a legacy base;
|
||||
comparison and hashing can allocate further whole-file buffers. Other scan work
|
||||
and Chunk caches also consume memory. Do not preload waiting candidates. Apply existing
|
||||
target-size rules and measure memory at the intended attachment sizes. Key
|
||||
enumeration and the existing map also consume memory proportional to their keys.
|
||||
A single keys-only call is not necessarily cheaper than targeted record reads
|
||||
when `D` is small and `P` is large; compare that alternative in the device
|
||||
measurements before accepting the discovery cost.
|
||||
|
||||
## Alternatives and implementation cost
|
||||
|
||||
| Approach | Consequences | Assessment |
|
||||
| --- | --- | --- |
|
||||
| Guarded candidate recovery, keys-only cleanup discovery, and coalesced bookkeeping | Changes shared read-only comparison, storage/result reporting, provenance capability, and scanner integration. Adds one conditional key enumeration and per-target verification; no persistent schema or journal. | Recommended proposed design. More work than a dispatch-only change. |
|
||||
| Reuse ordinary non-forced reflection unchanged | Four body reads on the measured stable path; changed/unknown content can write branches; settings can bypass protection and cleanup errors can be hidden. | Does not meet this policy. |
|
||||
| Read provenance separately for every absent deleted pair | Avoids the enumeration capability but adds one store read for every `D` path on every scan, including already clean deletions. | Simpler fallback with potentially large recurring call overhead; not the default proposal. |
|
||||
| Persist a cleanup queue or completion marker | Can target retries, but adds writes, acknowledgement/interruption rules, bootstrap, and reset semantics. | Not justified before measuring keys-only discovery. |
|
||||
| Clear provenance before deleting the file | Avoids stale provenance after deletion, but a failed/interrupted removal can leave a present file with no recovery evidence. | Rejected for this recovery policy. |
|
||||
| Retain failed received items and retry existing triggers | Addresses future queue retention, not old failures already dropped. Requires separate outcome and snapshot ownership work. | Separate follow-up. |
|
||||
| Requeue every document, add a full Vault/Metadata scan, or force-delete ordinary pairs | Broader work or loss of the required content protection. | Outside this design. Existing explicit Fetch/Inspector choices remain available. |
|
||||
|
||||
Implement Commonlib's small shared boundaries first, then scanner dispatch,
|
||||
cleanup discovery, result aggregation, and persistence integration. The host
|
||||
supplies the enumeration capability and summary. Keep old public wrappers and
|
||||
their existing caller behaviour where possible; validate any shared extraction
|
||||
against saving and incoming reflection tests. No new third-party dependency,
|
||||
setting, translation, or retry timer is required by this proposal.
|
||||
|
||||
## Verification before accepting an implementation
|
||||
|
||||
1. Reproduce the current skip using a newer logical deletion, an unchanged file,
|
||||
known provenance, and no pending received snapshot. Confirm the expected
|
||||
failure on unmodified production code before implementing the fix.
|
||||
2. Verify content and revision history, provenance, last-seen persistence, and
|
||||
restart using the real shared handler and database. No recovery-created
|
||||
Metadata revision or conflict branch is permitted for these attempts. Normal
|
||||
persistence of Chunks delivered by Fetcher remains permitted and is measured.
|
||||
3. Inject interruption after physical removal, after provenance deletion, and
|
||||
before/after map flush. On restart, verify discovered cleanup and no repeated
|
||||
physical deletion. Test map flush failure, enumeration failure, strict
|
||||
provenance failure, selection no-op, and empty-parent removal failure.
|
||||
4. Cover same-stat edits, missing/invalid/legacy evidence, pruned bases, conflicts
|
||||
arriving without a winning-revision change, restoration during verification,
|
||||
file recreation during cleanup, competing handlers, and database reset. A
|
||||
stale key enumeration must not delete a new file or its valid provenance.
|
||||
5. Preserve explicit Fetch/Inspector choices, incoming policy, selectors, Unicode
|
||||
identity, quarantine, retention, and equal-time/storage-newer boundaries.
|
||||
Test permitted on-demand delivery, explicit suppression, both
|
||||
`readChunksOnline` settings, unsupported direct-fetch transports, delayed Chunk
|
||||
arrival, terminal absence, and transport failure. Startup waiting must not
|
||||
depend on work which can start only after the same scan has returned.
|
||||
Hosts without the new capability keep existing behaviour. Recheck existing
|
||||
save and reflection contracts after extracting shared comparisons.
|
||||
6. Assert the operation budgets above, including clean retained deletions with
|
||||
`D > 0, A = 0`, `K = D = 0`, stable deletion, persistent deferral, and cleanup
|
||||
failure. Include a large `P` with only one absent deletion, so that the cost of
|
||||
key enumeration is not hidden by a large backlog. Count keys and bytes returned,
|
||||
record/Metadata/Chunk reads and writes,
|
||||
hashing, full-map serialisations, and physical adapter calls separately. Run
|
||||
repeated scans, not only a successful initial backlog.
|
||||
7. Compare elapsed time, peak memory, responsiveness, and suspension recovery for
|
||||
representative notes and attachments with the same baseline and conditions.
|
||||
Include cold remote-dependent legacy bases, Fetcher backoff/throttling, and
|
||||
the actual boundary from `initialiseDatabase` to `markIsReady`. Separate
|
||||
scan/recovery time, remote wait time, and overall plug-in readiness.
|
||||
Compare the one-file baseline with the proposed three-file cap. Hold one
|
||||
operation at a controlled I/O or delivery boundary and require later small
|
||||
files to complete through the other slots. Also cover three large files,
|
||||
same-document serialisation, overlapping scans, permit release after failure,
|
||||
unchanged Fetcher/hash limits, and absence of a three-at-a-time batch barrier.
|
||||
Check that recovery does not expand the existing scan worker limit or preload
|
||||
waiting files. Mobile latency and memory acceptance require device
|
||||
measurements; the proposal does
|
||||
not invent acceptable timings from logical call counts. Unexpected whole-Vault
|
||||
body work, per-candidate map writes, or removed preservation checks fail the
|
||||
design budget regardless of a favourable timing result.
|
||||
|
||||
Validate Commonlib's exact packed artefact, then the pinned Self-hosted LiveSync
|
||||
consumer and real Obsidian restart boundary. Physical iPhone suspension and
|
||||
performance remain separate evidence requirements. The proposed state transitions
|
||||
resolve the review's design questions; implementation and runtime acceptance still
|
||||
require these tests and measurements.
|
||||
|
||||
## Design review before implementation
|
||||
|
||||
The original 10 November review identified the following gaps. This revision
|
||||
records the agreed responses while retaining the baseline evidence. They are
|
||||
implemented in the unreleased source candidate; validation and publication remain
|
||||
separate outcomes.
|
||||
|
||||
### Physical deletion, cleanup, and restart outcomes
|
||||
|
||||
The original present-file scope could not rediscover cleanup after the file was
|
||||
gone. Current `deleteProvenance` swallows errors, and `deleteVaultItem` can resolve
|
||||
a selection no-op or throw after successful file removal. The proposed response
|
||||
separates physical effect and cleanup, exposes failures, and discovers remaining
|
||||
bookkeeping through `D`, existing provenance keys, and the loaded last-seen map.
|
||||
|
||||
### Last-seen correctness and persistence cost
|
||||
|
||||
The current public clear helper performs a complete map read/write per call; a
|
||||
probe of the actual 0.4.1 export observed three reads and three writes for three
|
||||
paths. A stale entry can also affect a later `db-only` decision. The proposed
|
||||
response removes confirmed-absent entries in memory and awaits one shared final
|
||||
flush, with no per-candidate persistence request.
|
||||
|
||||
### Read-only classification and error propagation
|
||||
|
||||
The current classifier may delete unavailable legacy provenance and search live
|
||||
leaves; default snapshot reads can mask an access error as absence. The proposed
|
||||
response extracts observational comparison, uses strict reads, and stops after
|
||||
a proved mismatch instead of invoking preservation fallbacks.
|
||||
|
||||
### Repeated deferrals and performance acceptance
|
||||
|
||||
The original four-to-two comparison covered successful deletion only. This
|
||||
revision budgets repeated deferrals, keys-only cleanup discovery, whole-map
|
||||
persistence, and bounded recovery concurrency explicitly. User review replaced
|
||||
the initial one-file proposal with a three-file cap so that one large waiting
|
||||
file does not monopolise recovery. User review also withdrew the blanket
|
||||
local-only restriction; exact-base reads follow existing Chunk delivery policy,
|
||||
with remote waits and fetched-Chunk writes counted separately. Device performance
|
||||
remains unverified; the implementation must establish its suitability before
|
||||
release.
|
||||
|
||||
|
||||
## Implementation design
|
||||
|
||||
Commonlib owns `ServiceFileHandlerBase/deletionRecovery.ts`, exposed by the
|
||||
existing facade through `recoverDatabaseDeletion`. Its result reports decision,
|
||||
physical file state, provenance state, and a diagnostic reason independently.
|
||||
`supportsDeletionRecovery` is a metadata-only capability check; enumeration is
|
||||
invoked only for absent-file cleanup discovery. `listDeletionRecoveryPaths`
|
||||
forwards the optional provenance capability. The existing `IFileHandler` methods
|
||||
remain source compatible for hosts which do not opt in.
|
||||
|
||||
`StoredFileReflectionProvenance` enables namespace enumeration only when its host
|
||||
opts in. The shared host factory defaults to disabled; Obsidian composition
|
||||
explicitly enables it using the existing reset-scoped store. CLI and browser
|
||||
composition retain the default capability until separately adopted. The key-value adapter must enumerate
|
||||
the full namespace with correct inclusive/exclusive bounds, including paths
|
||||
above a U+FFFF sentinel, without loading record values.
|
||||
|
||||
The scanner discovers recovery pairs from its existing collection. It processes
|
||||
previously absent cleanup pairs before new deletions, then includes both results
|
||||
in its normal scan summary. Recovery updates last-seen state in memory and awaits
|
||||
a final ordered flush. Status and timers belong to the host key-value store;
|
||||
overlapping scans of that store are serialised so that a stale load or write
|
||||
cannot overwrite an in-progress recovery. No persistent format changes.
|
||||
|
||||
The handler shares three recovery permits for a Vault's provenance owner and
|
||||
retains the existing document lock. Normal scanner admission remains ten. The
|
||||
permit covers waiting, validation, removal, and cleanup, and is released on all
|
||||
outcomes. Existing shared hashing and Fetcher ownership remain intact.
|
||||
|
||||
A legacy read which returns only `false` cannot establish why the existing
|
||||
retrieval pipeline failed. Report `base-unavailable` as failed verification,
|
||||
retain the file, and do not claim remote absence. Exceptions retain their phase
|
||||
and diagnostic cause. This conservative result avoids changing existing Chunk
|
||||
retrieval semantics or adding a second retry policy.
|
||||
|
||||
The implementation is prepared against an exact local packed Commonlib artefact.
|
||||
Publication and replacement with an immutable registry dependency are separate
|
||||
release operations. Local candidate validation must identify the packed bytes;
|
||||
it does not establish registry availability or iPhone performance.
|
||||
|
||||
|
||||
## Implementation and publication status
|
||||
|
||||
The guarded handler, opt-in provenance enumeration, cleanup discovery, shared
|
||||
three-file admission, and ordered scanner bookkeeping are implemented in the
|
||||
unreleased Commonlib source candidate. The Obsidian host enables the capability.
|
||||
The reviewed-version frontmatter remains the published baseline, not a claim
|
||||
that registry Commonlib 0.4.1 includes this change.
|
||||
|
||||
Owner tests establish the two-body-read path and preservation/restart contracts
|
||||
through real local revision storage with injected host boundaries. Packed-package
|
||||
checks validate the candidate independently. Real Obsidian restart validation and
|
||||
consumer checks use those same packed bytes; physical iPhone timing and peak
|
||||
memory remain separate evidence. A releasing application must replace the local
|
||||
candidate with the exact published immutable Commonlib version and integrity.
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
date: 2026-11-10
|
||||
commonlib-version: "0.4.1"
|
||||
self-hosted-livesync-version: "1.0.47"
|
||||
status: reference
|
||||
---
|
||||
|
||||
# File writing and database-to-Vault reflection
|
||||
|
||||
## Scope and baseline
|
||||
|
||||
This implementation reference maps ordinary file events, incoming replication,
|
||||
full scans, Simple Fetch, and Inspector actions in the Obsidian host. It records
|
||||
Self-hosted LiveSync `517f5c248fdd777acb657f6d70de3913ba6390aa` with the exact
|
||||
Commonlib 0.4.1 registry package selected by its lockfile. It describes current
|
||||
behaviour of that published baseline; it introduces no new recovery guarantee.
|
||||
The [accepted deletion recovery design](database_to_vault_deletion_recovery.md)
|
||||
records the additional opt-in policy in the unreleased implementation candidate.
|
||||
|
||||
Hidden File Sync and Customisation Sync retain their owning contracts. Document
|
||||
History restoration has a separate [restoration contract](document_history_revision_restoration.md).
|
||||
CLI and browser hosts share Commonlib operations, but their lifecycle and storage
|
||||
adapters require separate verification.
|
||||
|
||||
Commonlib owns file handling, per-document locking, provenance, and scanner
|
||||
policy. This repository owns Obsidian composition, received-result processing,
|
||||
Fetch choices, and confirmation dialogues. Use the linked owner contracts rather
|
||||
than copying their complete action matrices into this reference.
|
||||
|
||||
## Directions and responsibilities
|
||||
|
||||
A local file event records a change which has already happened in the Vault.
|
||||
Its handler updates Metadata and Chunks in the local database. A queued
|
||||
`DELETE` therefore requests a database change, not a physical file deletion.
|
||||
|
||||
Incoming replication first changes the local database. Result processing then
|
||||
requests database-to-Vault reflection. Receiving a database revision does not
|
||||
establish that its file effect completed. Full scans reconcile current database
|
||||
and storage observations independently of the received-result queue.
|
||||
|
||||
`ServiceFileHandlerBase` remains Commonlib's public facade. Its responsibility
|
||||
functions separate saving, deletion, renaming, reflection, protection,
|
||||
provenance, selection, and locking. Public operations acquire document locks;
|
||||
internal operations reuse the lock already held. Final storage revalidation and
|
||||
the destructive operation remain together in reflection. See the
|
||||
[Commonlib implementation map](https://github.com/vrtmrz/livesync-commonlib/blob/0.4.1/docs/development.md#ordinary-file-handler-implementation).
|
||||
|
||||
## Entry points and protection
|
||||
|
||||
The following rows describe selected ordinary files under the owning target,
|
||||
identity, size, conflict, and suspension policies.
|
||||
|
||||
| Entry | Operation | Protection and important policy differences |
|
||||
| --- | --- | --- |
|
||||
| Vault watcher, restored event, or same-session local retry | `StorageEventManager` dispatches ordinary or reconciled file events to the file handler. | Saves retain the displayed branch or preserve unknown content independently. Deletion and rename revalidate current storage and source ownership. They update the database. |
|
||||
| Incoming replicated Metadata | `ReplicateResultProcessor` calls `processSynchroniseResult`; Commonlib enters `dbToStorageCore` through its replicated-event handler. | Reflection is non-forced. With `writeDocumentsIfConflicted` disabled, current content and provenance protect unsynchronised edits; a changed winning revision is re-read before deletion. |
|
||||
| `NEWER_WINS`, existing live database entry and Vault file | `syncFileBetweenDBandStorage` compares freshness. Its database-newer branch calls `dbToStorage(..., false)`. | The non-forced reflection checks still apply. Equal-time reconciliation follows its separate content/provenance contract. `strictContentCheckOnStartup` only affects an eligible equal-time optimisation. |
|
||||
| Scanner `update-storage`, including `DB_APPLY` live entries and database-only creation | `updateToStorage` calls `dbToStorage(path, null, true)`. | This is a forced operation selected by scanner policy. Deleted entries are skipped by this helper. It cannot implement guarded deletion merely by changing the pair action. |
|
||||
| Simple Fetch with an explicit deletion choice | The selected `DB_APPLY` or `NEWER_WINS` options can choose `delete-local`. | `delete-local` calls `storageAccess.delete(path, true)` directly. It does not enter the file handler's content/provenance protection. The choice belongs to this explicit recovery workflow. |
|
||||
| Inspector: apply a readable revision or logical deletion | `dbToStorageWithSpecificRev(path, revision, true)` after the relevant confirmation. | The selected revision must still be a current leaf. Explicit force permits the selected branch to replace/remove the Vault file; it does not mean that the selected branch is the winner. |
|
||||
|
||||
`force: false` alone is not an unconditional preservation guarantee:
|
||||
`writeDocumentsIfConflicted` bypasses conflict deferral and incoming-content
|
||||
protection in the reviewed handler. Recovery design must account for that
|
||||
setting explicitly. A new automatic path must not inherit forced behaviour just
|
||||
because another caller uses the same facade.
|
||||
|
||||
In this Obsidian host, both adapter `delete` and `trash` delegate to
|
||||
`FileManager.trashFile`, following Obsidian's deletion preferences. A direct
|
||||
scanner delete bypasses provenance checks; it does not by itself prove permanent
|
||||
erasure. The legacy `trashInsteadDelete` value is not a substitute for checking
|
||||
which operation the caller invokes.
|
||||
|
||||
## Completion, retained work, and restart
|
||||
|
||||
| Owner | What completion establishes | Failure and later processing |
|
||||
| --- | --- | --- |
|
||||
| Local file-event manager | Admission is distinct from handler completion. A failed attempt remains associated with its pending operation until the owning completion/snapshot step succeeds. | Existing finite flushes and related later events can retry eligible ordinary failures. Start-up restores stored event intents and reconciles current state before collecting scan pairs. See the [local retry ADR](../adr/2026_11_local_file_operation_retry.md). |
|
||||
| Received-result processor | Queue counters show queued/running application work. Its handler Boolean can also represent conflict deferral, protected local content, or an accepted no-op. | `parseDocumentChange` removes an item in `finally`, including after reflection returns false or throws. A later snapshot can therefore contain no failed item. Persisted queued/running items can be restored, but this is not a durable ledger of failed reflection. |
|
||||
| Full scanner | Each selected pair is `completed`, `skipped`, or `failed`. A policy skip is a successful scan outcome without proving Vault/database equality. | A later scan re-evaluates current pairs. Ordinary Obsidian start-up can return `completed-with-file-failures`; explicit Fetch remains strict. A failed path must not acquire a successful database reflection timestamp in the last-seen map. |
|
||||
| Simple Fetch | Database reconstruction and subsequent Vault reflection are separate stages. | A failed scan offers the workflow's retry/release decision. Re-entering Fetch can repeat database reconstruction; it is more work than retrying one retained local pair. |
|
||||
| Inspector mutation | The action attempts the exact selected live revision and refreshes inspection through its owning UI workflow. | It is an explicit action, not a background retry registration. A failed action must be assessed from its result and refreshed state. |
|
||||
|
||||
A true file-handler result does not universally mean that the Vault now matches
|
||||
the database winner. In particular, preserving a local edit as a conflict and
|
||||
queuing conflict review can return true. Conversely, failed or inconclusive
|
||||
reflection is not proof that a deletion was intentionally rejected.
|
||||
|
||||
Local database persistence, provenance persistence, snapshot acknowledgement,
|
||||
remote replication, and physical Vault reflection are separate boundaries.
|
||||
They do not form a cross-store transaction.
|
||||
|
||||
## Current deletion recovery gap
|
||||
|
||||
For a valid, retained logical-deletion Metadata document and an existing Vault
|
||||
file, ordinary `NEWER_WINS` without extra options selects `both-db-deleted` and
|
||||
`skip`. It does not reach freshness comparison or content protection. This is
|
||||
an explicit conservative entry in the
|
||||
[Commonlib action matrix](https://github.com/vrtmrz/livesync-commonlib/blob/0.4.1/docs/offline-scanner.md#action-matrix).
|
||||
|
||||
Consequently, after incoming reflection fails and its item leaves the received
|
||||
queue, restarting need not apply the deletion. A log which says the path remains
|
||||
eligible for a later scan does not establish deletion recovery. This path is
|
||||
consistent with [issue #1338](https://github.com/vrtmrz/obsidian-livesync/issues/1338);
|
||||
it does not identify the cause of the original iPhone reflection failure.
|
||||
|
||||
Snapshot recovery remains useful when pending work was saved. The processor also
|
||||
accepts an explicitly requeued copy of the current latest revision; its revision
|
||||
check rejects known older ancestors, not every revision already in the database.
|
||||
Changing reflection-filter settings has its own stored-document requeue path,
|
||||
but ordinary restart and a failed application do not trigger that full requeue.
|
||||
|
||||
Deletion retention is a separate limitation. Before pair collection, enabled
|
||||
`automaticallyDeleteMetadataOfDeletedFiles` can tombstone expired logical
|
||||
Metadata. A remaining file can then become `storage-only`; the logical-deletion
|
||||
pair rule no longer applies. Likewise, already-tombstoned entries which are
|
||||
absent from normal document enumeration need a different discovery source.
|
||||
|
||||
## Runtime access costs
|
||||
|
||||
Count operations at their owning boundary. A Vault index lookup or `getStub`
|
||||
call is not necessarily a physical filesystem read. A database body fetch can
|
||||
read several Chunks or use cached data. A content hash still requires reading
|
||||
the file bytes. Cache hits, remote Chunk requests, and physical I/O must be
|
||||
measured separately.
|
||||
|
||||
The existing scanner already collects selected Vault files and normal Metadata,
|
||||
validates document identity, and constructs pairs. Recognising an additional
|
||||
`both-db-deleted` action can reuse those observations. It does not require
|
||||
another Vault listing or body reads for every otherwise unaffected file.
|
||||
|
||||
Existing non-forced deletion reflection reads current content and can re-read
|
||||
it for snapshot validation before deletion. Valid saved content evidence can
|
||||
avoid reconstructing an old revision body; it does not avoid reading the current
|
||||
Vault file. Real edits or unknown provenance can cause database writes to retain
|
||||
content. These costs differ from a forced scanner delete, which bypasses that
|
||||
protection.
|
||||
|
||||
The last-seen map and the received/event snapshots also have costs. The scanner
|
||||
coalesces last-seen persistence and serialises it; a scheduled write serialises
|
||||
the map, not just one changed path. Adding a new per-file journal, second full
|
||||
scan, or whole-queue snapshot on every retry is therefore a separate cost decision.
|
||||
|
||||
This reference records source-level access paths, not a latency or memory
|
||||
benchmark. The
|
||||
[deletion recovery proposal](database_to_vault_deletion_recovery.md)
|
||||
compares work limited to candidate pairs with other recovery approaches and states the
|
||||
measurement still required before implementation is accepted.
|
||||
|
||||
## Source and contract map
|
||||
|
||||
| Responsibility | Maintained reference or implementation |
|
||||
| --- | --- |
|
||||
| Obsidian start-up scan options | [ModuleLiveSyncMain](../../src/modules/main/ModuleLiveSyncMain.ts) |
|
||||
| Received-result admission, application, and snapshots | [ReplicateResultProcessor](../../src/serviceFeatures/replication/ReplicateResultProcessor.ts), [composition](../../src/serviceFeatures/replication/index.ts), and [filter requeue](../../src/serviceFeatures/replication/automaticTriggers.ts) |
|
||||
| Simple Fetch mode and deletion choices | [redFlag.simpleFetch](../../src/serviceFeatures/redFlag.simpleFetch.ts) |
|
||||
| Exact-revision Inspector actions | [PaneHatch](../../src/modules/features/SettingDialogue/PaneHatch.ts) and [action policy](../../src/serviceFeatures/fileRepairPresentation.ts) |
|
||||
| Ordinary events and shared reflection | [Commonlib event/reflection contract](https://github.com/vrtmrz/livesync-commonlib/blob/0.4.1/docs/storage-events-and-reflection.md) |
|
||||
| Scanner pair policies and result meanings | [Commonlib offline scanner](https://github.com/vrtmrz/livesync-commonlib/blob/0.4.1/docs/offline-scanner.md) |
|
||||
| Revision identity and content preservation | [Conflict and provenance contract](../specs_conflict_resolution.md) |
|
||||
| Accepted local operation completion and retries | [Save completion ADR](../adr/2026_10_local_file_save_completion_and_batching.md) and [retry ADR](../adr/2026_11_local_file_operation_retry.md) |
|
||||
Reference in new issue
Block a user