mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-05 07:48:18 +00:00
Merge main into NFC transport support
This commit is contained in:
commit
4957f8d28f
160 files changed
+12441
-1388
No files matched your search
@@ -49,6 +49,12 @@ npm run test:e2e:browser-apps:interop # Run WebApp → WebPeer → CLI in Com
|
|||||||
npm run test:e2e:obsidian:local-suite # Run the real Obsidian local suite
|
npm run test:e2e:obsidian:local-suite # Run the real Obsidian local suite
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Production minification
|
||||||
|
|
||||||
|
Production builds remove unused declarations and shorten local identifiers while retaining class and function names used by module discovery and diagnostic stacks. `terser.config.mjs` also preserves property names, top-level bindings, and identifiers visible to direct `eval` calls. Keep these boundaries when changing compression settings; `test/build/terserConfig.unit.spec.ts` checks the generated code against host properties, persisted keys, reflective names, and exception stacks.
|
||||||
|
|
||||||
|
Compare bundle bytes and gzip bytes from the same source, dependency artefacts, and build inputs. Validate import-time registration and the resulting plug-in in the affected real-runtime workflows. The iOS syntax check is one part of compatibility verification; it does not measure performance or replace physical-device testing.
|
||||||
|
|
||||||
### Tips
|
### Tips
|
||||||
|
|
||||||
Select the narrowest unit, integration, CLI E2E, or real Obsidian E2E command that owns the behaviour being changed. The obsolete mocked browser Harness has been retired.
|
Select the narrowest unit, integration, CLI E2E, or real Obsidian E2E command that owns the behaviour being changed. The obsolete mocked browser Harness has been retired.
|
||||||
@@ -146,6 +152,8 @@ The application is composed from Services, ServiceModules, serviceFeatures, add-
|
|||||||
|
|
||||||
The normal composition order is the Service Hub, Replicator provider registration, ServiceModules, serviceFeatures, add-ons, and finally legacy Module binding. A serviceFeature may therefore consume an already constructed ServiceModule. Preferring a serviceFeature for new composition is a dependency-boundary rule, not an initialisation-order rule.
|
The normal composition order is the Service Hub, Replicator provider registration, ServiceModules, serviceFeatures, add-ons, and finally legacy Module binding. A serviceFeature may therefore consume an already constructed ServiceModule. Preferring a serviceFeature for new composition is a dependency-boundary rule, not an initialisation-order rule.
|
||||||
|
|
||||||
|
Declare the feature host type explicitly when a narrow dependency contract is intended. `NecessaryServices<T, U, THub>` selects service keys from `THub` and module keys from `ServiceModules`; it retains `context` only when the selected source provides it, and defaults to `ServiceHub`. `createServiceFeature()` returns the supplied function with its complete signature and result type, but it does not infer dependency keys from the callback body. Unannotated legacy callbacks retain the full default `ServiceHub` contract for compatibility. Put feature-only collaborators, such as a borrowed `LogFunction`, in the feature's own dependency type.
|
||||||
|
|
||||||
Mutable state is permitted in a serviceFeature. State alone is not a reason to create a class, a ServiceModule, or retain an AbstractModule. Prefer one private context, with module-level functions which receive that context, when identity and polymorphism are not part of the contract. Separate the state, transitions, and invariants from the surrounding function which registers lifecycle handlers and connects downstream effects. Give the stateful boundary narrow collaborators rather than `LiveSyncBaseCore`.
|
Mutable state is permitted in a serviceFeature. State alone is not a reason to create a class, a ServiceModule, or retain an AbstractModule. Prefer one private context, with module-level functions which receive that context, when identity and polymorphism are not part of the contract. Separate the state, transitions, and invariants from the surrounding function which registers lifecycle handlers and connects downstream effects. Give the stateful boundary narrow collaborators rather than `LiveSyncBaseCore`.
|
||||||
|
|
||||||
Use a class when stable object identity, replaceable implementations, or an explicit external-resource lifecycle such as serialised ownership, `dispose()`, or `abort()` is part of the contract. Use a ServiceModule when that operational capability or resource lifecycle must also be shared explicitly by several consumers. Do not introduce a class merely to group dependencies or make private functions callable.
|
Use a class when stable object identity, replaceable implementations, or an explicit external-resource lifecycle such as serialised ownership, `dispose()`, or `abort()` is part of the contract. Use a ServiceModule when that operational capability or resource lifecycle must also be shared explicitly by several consumers. Do not introduce a class merely to group dependencies or make private functions callable.
|
||||||
@@ -158,7 +166,7 @@ Commonlib's `targetFilter.ts` and `prepareDatabaseForUse.ts` demonstrate the int
|
|||||||
|
|
||||||
Use interaction-based, London School unit tests for the composition boundary. Verify collaborator calls, ordering, failure short-circuiting, and handler registration, then test the focused state owner for its transitions and invariants. If a test needs a broad core fixture, a large class mock, deep mock chains, or unrelated Services, treat that friction as a design-review signal and consider a private context with narrower functions before adding more test machinery.
|
Use interaction-based, London School unit tests for the composition boundary. Verify collaborator calls, ordering, failure short-circuiting, and handler registration, then test the focused state owner for its transitions and invariants. If a test needs a broad core fixture, a large class mock, deep mock chains, or unrelated Services, treat that friction as a design-review signal and consider a private context with narrower functions before adding more test machinery.
|
||||||
|
|
||||||
See [Service feature and legacy Module boundaries](docs/design_docs/service_feature_and_legacy_module_boundaries.md) for the selection criteria, current examples, reasons to avoid new `AbstractModule` subclasses, incremental migration guidance, and test shapes. Commonlib's [service feature composition guide](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/service-feature-composition.md) defines the shared host-neutral boundary.
|
See [Service feature and legacy Module boundaries](docs/design_docs/service_feature_and_legacy_module_boundaries.md) for the selection criteria, current examples, reasons to avoid new `AbstractModule` subclasses, incremental migration guidance, and test shapes. Commonlib's [service feature composition guide](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/service-feature-composition.md) defines the shared host-neutral boundary. Fancy Kit's [shared runtime composition guide](https://github.com/vrtmrz/fancy-kit/blob/main/docs/runtime-composition.md) maps content application, cleanup, progress, and host adapters to their owning APIs, with their publication status and consumer responsibilities.
|
||||||
|
|
||||||
Legacy Modules remain grouped by directory:
|
Legacy Modules remain grouped by directory:
|
||||||
|
|
||||||
@@ -176,6 +184,18 @@ Declare shared UI requests with `declareUICommand<Request, Result>` from `@vrtmr
|
|||||||
|
|
||||||
The registry belongs to one UIService instance. Reuse the same declaration object at each call site, and keep active UI cleanup in the feature and dialogue owner. `UIService` disposes the registry when the host's context emits the plug-in-unloaded event.
|
The registry belongs to one UIService instance. Reuse the same declaration object at each call site, and keep active UI cleanup in the feature and dialogue owner. `UIService` disposes the registry when the host's context emits the plug-in-unloaded event.
|
||||||
|
|
||||||
|
Each browser service hub creates a local `BrowserUiScope` with a `document` and
|
||||||
|
`signal`, exposed as readonly `hub.browserUi`. Pass overrides through
|
||||||
|
the service-hub option `ui: { document, signal }`; when `document` is omitted,
|
||||||
|
the scope snapshots legacy `_activeDocument`. The scope is bound to
|
||||||
|
its root `ServiceContext` unload event and optional parent `AbortSignal`, and is
|
||||||
|
shared by browser confirmation and the Svelte dialogue manager. A dialogue
|
||||||
|
resolves with its result or `undefined` on ordinary close; cancellation rejects
|
||||||
|
with the abort reason, including `Error('Plug-in unloaded')` on root-context
|
||||||
|
unload. A `Menu` can receive `hub.browserUi`; closing, replacing, or aborting it
|
||||||
|
unmounts its Svelte component, and callbacks from a closed session cannot close
|
||||||
|
the replacement.
|
||||||
|
|
||||||
### Key Architectural Components
|
### Key Architectural Components
|
||||||
|
|
||||||
- **LiveSyncLocalDB** (`@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB`): Local PouchDB database wrapper
|
- **LiveSyncLocalDB** (`@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB`): Local PouchDB database wrapper
|
||||||
@@ -190,6 +210,8 @@ steps required to add a built-in provider.
|
|||||||
|
|
||||||
Commonlib owns one stable `LiveSyncP2PService`, its `P2PRoomSessionOwner`, and the replaceable Trystero room session. Host commands, event handlers, and views consume the focused transport, connection-probe admission, directory, peer-admission, transfer, change-relay, configuration, and diagnostic views returned by the service feature. They must not retain the deprecated compatibility Replicator as an ordinary service locator, close Trystero-owned raw peers, or install another Trystero transport generation at the application root. The exact implemented ownership and shutdown boundaries are recorded in Commonlib's [P2P transport lifecycle](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/p2p-transport-lifecycle.md) design document.
|
Commonlib owns one stable `LiveSyncP2PService`, its `P2PRoomSessionOwner`, and the replaceable Trystero room session. Host commands, event handlers, and views consume the focused transport, connection-probe admission, directory, peer-admission, transfer, change-relay, configuration, and diagnostic views returned by the service feature. They must not retain the deprecated compatibility Replicator as an ordinary service locator, close Trystero-owned raw peers, or install another Trystero transport generation at the application root. The exact implemented ownership and shutdown boundaries are recorded in Commonlib's [P2P transport lifecycle](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/p2p-transport-lifecycle.md) design document.
|
||||||
|
|
||||||
|
The Obsidian `useP2PReplicatorUI` feature declares only `API`, `appLifecycle`, `setting`, and `replicator`, and consumes the public `P2PServiceViews` type instead of the broader `UseP2PReplicatorResult`. Its separate `LiveSyncCore` argument and global `eventHub` remain the legacy pane bridge; this type narrowing leaves the existing presentation and lifecycle wiring intact.
|
||||||
|
|
||||||
The [TURN connection settings design](docs/design_docs/renewable_turn_credentials.md) describes how the host prepares temporary ICE credentials in a connection-only settings copy. It covers room reuse and expiry, replication continuation, profile persistence and sharing, and report redaction.
|
The [TURN connection settings design](docs/design_docs/renewable_turn_credentials.md) describes how the host prepares temporary ICE credentials in a connection-only settings copy. It covers room reuse and expiry, replication continuation, profile persistence and sharing, and report redaction.
|
||||||
|
|
||||||
### Conflict Merge Policy
|
### Conflict Merge Policy
|
||||||
@@ -279,6 +301,8 @@ The scenario uses only fixed in-memory inputs. It does not read or write the Vau
|
|||||||
- Dev mode creates `ls-debug/` folder in `.obsidian/` for debug outputs (e.g., missing translations)
|
- Dev mode creates `ls-debug/` folder in `.obsidian/` for debug outputs (e.g., missing translations)
|
||||||
- This causes pretty significant performance overhead.
|
- This causes pretty significant performance overhead.
|
||||||
|
|
||||||
|
`ModuleLog` owns an `OwnedResource<LogFunction>` for its `API.addLog.setHandler` registration and keeps service log input in a per-instance buffer. The host wraps the complete asynchronous `control.onUnload()` in `withOwnedResource`. On successful shutdown, the handler and its global and per-instance observers remain registered through the final database close and final unload log. If `control.onUnload()` rejects, `withOwnedResource` releases them when the operation settles. Do not release it from an `onBeforeUnload` or module `onUnload` handler; both run before the host's complete unload finishes. The import-time global `Logger` bridge and display and report histories remain shared, while service log calls stay with their owning instance.
|
||||||
|
|
||||||
#### Diagnostic and notice ownership
|
#### Diagnostic and notice ownership
|
||||||
|
|
||||||
- A Commonlib or service operation should normally record detailed diagnostics at `LOG_LEVEL_VERBOSE` and return a typed result which lets its caller distinguish complete, partial, and failed outcomes. Do not make callers infer an outcome by parsing log text.
|
- A Commonlib or service operation should normally record detailed diagnostics at `LOG_LEVEL_VERBOSE` and return a typed result which lets its caller distinguish complete, partial, and failed outcomes. Do not make callers infer an outcome by parsing log text.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ Use these references for the area being changed, together with its relevant ADR.
|
|||||||
| Encryption and sharing | [Metadata encryption](design_docs/internal_metadata_encryption.md) and [time-bound Setup URIs](design_docs/time_bound_setup_uri.md) |
|
| Encryption and sharing | [Metadata encryption](design_docs/internal_metadata_encryption.md) and [time-bound Setup URIs](design_docs/time_bound_setup_uri.md) |
|
||||||
| P2P connection preparation | [TURN connection settings](design_docs/renewable_turn_credentials.md) |
|
| P2P connection preparation | [TURN connection settings](design_docs/renewable_turn_credentials.md) |
|
||||||
| Journal storage internals | [Journal Replicator 2nd Edition](design_docs_of_journalsync_2nd.md) |
|
| Journal storage internals | [Journal Replicator 2nd Edition](design_docs_of_journalsync_2nd.md) |
|
||||||
|
| CLI database selection and interrupted reconstruction | [CLI database continuity](design_docs/cli_database_continuity.md) |
|
||||||
|
|
||||||
The installed `@vrtmrz/livesync-commonlib` package is authoritative for shared runtime contracts. Use the exact dependency selected by [package.json](../package.json) and its lockfile when checking behaviour. The [Commonlib documentation](https://github.com/vrtmrz/livesync-commonlib/tree/main/docs) is maintained in that repository; this index does not replace its package contracts.
|
The installed `@vrtmrz/livesync-commonlib` package is authoritative for shared runtime contracts. Use the exact dependency selected by [package.json](../package.json) and its lockfile when checking behaviour. The [Commonlib documentation](https://github.com/vrtmrz/livesync-commonlib/tree/main/docs) is maintained in that repository; this index does not replace its package contracts.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
date: 2026-11-01
|
||||||
|
commonlib-version: "0.2.2"
|
||||||
|
self-hosted-livesync-version: "1.0.43"
|
||||||
|
status: accepted
|
||||||
|
---
|
||||||
|
|
||||||
|
# CLI database continuity
|
||||||
|
|
||||||
|
Publication note: the correction for #1304 is implemented and locally validated,
|
||||||
|
but has not been released. The reviewed baseline is Self-hosted LiveSync `0e458e5e` with
|
||||||
|
Commonlib `0.2.2`. The [CLI guide](../../src/apps/cli/README.md) describes the
|
||||||
|
command interface; the [test procedure](../../test/README.md#cli-fetch-database-continuity)
|
||||||
|
describes the Object Storage reproduction.
|
||||||
|
|
||||||
|
## Required behaviour
|
||||||
|
|
||||||
|
A successful `fetch` or `rebuild` must leave subsequent CLI commands using the
|
||||||
|
reconstructed local database. The Commonlib Rebuilder selects the host app ID
|
||||||
|
as the database suffix. The CLI saves that selection on success even without
|
||||||
|
`--write-settings`; the option continues to govern other lasting settings
|
||||||
|
changes. Temporary suspension values remain runtime state.
|
||||||
|
|
||||||
|
Before reconstruction can mutate a database, the CLI records the operation,
|
||||||
|
database directory, Vault path, and settings path. An interrupted operation
|
||||||
|
blocks ordinary mutation and synchronisation. Retrying the same reconstruction
|
||||||
|
with the same paths and `--force` is an explicit recovery action. It can repeat
|
||||||
|
the destructive operation, so users must preserve unique edits and establish
|
||||||
|
the authoritative copy before retrying.
|
||||||
|
|
||||||
|
Completion records the settings update before applying it. A later invocation
|
||||||
|
can finish a pending settings commit; if the settings file has independently
|
||||||
|
changed, it must stop for review rather than overwrite those changes. The
|
||||||
|
pending record is removed only after completion and settings persistence.
|
||||||
|
|
||||||
|
## Ownership of persistent state
|
||||||
|
|
||||||
|
The selected local database name identifies the dataset. Its runtime state is
|
||||||
|
further scoped by the canonical physical Vault path. This includes Journal
|
||||||
|
checkpoints, scanner state, replication-result queues, file-reflection
|
||||||
|
provenance, and storage-event snapshots. Paths identify local ownership; they
|
||||||
|
are not sent to the remote as a new document identity.
|
||||||
|
|
||||||
|
A local database reset invalidates every Vault scope associated with that
|
||||||
|
database, because each scope refers to the same discarded revision history.
|
||||||
|
Other local database suffixes retain their state. PouchDB database names and
|
||||||
|
Commonlib's reconstruction contract remain unchanged.
|
||||||
|
|
||||||
|
KV writes replace the file atomically. Invalid persisted data must fail
|
||||||
|
initialisation instead of becoming an empty checkpoint history. Closing a KV
|
||||||
|
handle must not overwrite the file from an old in-memory snapshot.
|
||||||
|
|
||||||
|
## Legacy state
|
||||||
|
|
||||||
|
The former shared `keyvalue-db.json` and Vault-local `.livesync-snapshot.json`
|
||||||
|
do not record their complete owner. A single surviving database directory does
|
||||||
|
not prove which physical Vault produced those records. Automatically importing
|
||||||
|
them can replay events against the wrong database; dropping them and immediately
|
||||||
|
scanning can misinterpret file absence before remote history is recovered.
|
||||||
|
|
||||||
|
Preserve unowned legacy state. When it contains work, stop ordinary mutating
|
||||||
|
commands and require explicit reconstruction from a verified source. Inspection
|
||||||
|
remains available. This deliberately requires more user action than automatic
|
||||||
|
migration, and must not silently delete the old records.
|
||||||
|
|
||||||
|
Check the selected scope's completion flag before reading legacy files. For an
|
||||||
|
unreviewed scope, inspect both legacy files after settings and scoped KV loading,
|
||||||
|
but before readiness starts scanning or event replay. Distinguish non-empty
|
||||||
|
unowned state, invalid JSON or shape, and read failures. Report affected paths
|
||||||
|
once per invocation on stderr without logging file contents. Read commands may
|
||||||
|
continue inspection; ordinary writes stop, and explicit reconstruction retains
|
||||||
|
the files. Once reconstruction has succeeded for that scope, legacy parse or
|
||||||
|
read failures must not block it again. The active scoped KV still fails
|
||||||
|
initialisation on corruption.
|
||||||
|
|
||||||
|
## Evidence and limits
|
||||||
|
|
||||||
|
The original Object Storage scenario uses encryption, path obfuscation, and a
|
||||||
|
second-generation file revision. Without settings writeback, A reopens an empty
|
||||||
|
database while sharing the fetched database's Journal history, creates an
|
||||||
|
independent root, and receives B's same-content resolution tombstone without
|
||||||
|
the original surviving revision. The original assertion fails because A's
|
||||||
|
physical file disappears. The explicit `--write-settings` control preserves
|
||||||
|
the file and the original revision. The remote and B retain the content in this
|
||||||
|
reproduction; it does not demonstrate irreversible loss.
|
||||||
|
|
||||||
|
Validation of the correction must check bytes and revision ancestry after
|
||||||
|
restart, interrupted reconstruction admission, pending settings completion,
|
||||||
|
legacy-state preservation, and reset isolation. A successful exit or an empty
|
||||||
|
queue alone does not establish preservation.
|
||||||
|
|
||||||
|
Local validation passed the encrypted Object Storage CLI regression and the
|
||||||
|
explicit-settings control on Node.js 26. Unit tests cover failed-operation
|
||||||
|
admission, saved running/completed markers, settings-edit preservation, scoped
|
||||||
|
stores, reset isolation, corrupt KV files, and legacy-state refusal. The marker
|
||||||
|
tests inject interruption states; they do not emulate a machine power failure.
|
||||||
|
|
||||||
|
The shared device-value JSON file retains its existing concurrency limitations.
|
||||||
|
This change does not establish concurrent CLI writers to one database directory
|
||||||
|
as a supported workflow. Native Obsidian and other transport behaviour require
|
||||||
|
their own runtime evidence.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
date: 2026-09-04
|
date: 2026-10-25
|
||||||
commonlib-version: "0.1.21"
|
commonlib-version: "0.2.0"
|
||||||
self-hosted-livesync-version: "1.0.24"
|
self-hosted-livesync-version: "1.0.40"
|
||||||
status: accepted
|
status: accepted
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -104,14 +104,22 @@ For new behaviour, keep Obsidian-specific presentation or registration in an Obs
|
|||||||
|
|
||||||
### A small serviceFeature: language initialisation
|
### A small serviceFeature: language initialisation
|
||||||
|
|
||||||
`src/serviceFeatures/onLayoutReady/enablei18n.ts` declares only `setting`, `API`, and `appLifecycle`:
|
`src/serviceFeatures/onLayoutReady/enablei18n.ts` declares only `setting`, `API`, and `appLifecycle`, with an explicit dependency type:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export const enableI18nFeature = createServiceFeature(async ({ services: { setting, API, appLifecycle } }) => {
|
type EnableI18nHost = NecessaryServices<"setting" | "API" | "appLifecycle", never>;
|
||||||
// Apply the language, persist a change, and register unload clean-up.
|
|
||||||
});
|
export const enableI18nFeature = createServiceFeature(
|
||||||
|
async (host: EnableI18nHost): Promise<boolean> => {
|
||||||
|
const { setting, API, appLifecycle } = host.services;
|
||||||
|
// Apply the language, persist a change, and register unload clean-up.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The host annotation keeps the dependency contract visible because `createServiceFeature()` does not infer dependency keys from the callback body. This feature retains its `Promise<boolean>` result.
|
||||||
|
|
||||||
The local `ObsidianLanguageAppliedNotice` class is still appropriate. It owns one replaceable Obsidian `Notice` and has an explicit `clear()` lifetime operation. The class is not used as a service locator, and the serviceFeature owns its construction and host binding.
|
The local `ObsidianLanguageAppliedNotice` class is still appropriate. It owns one replaceable Obsidian `Notice` and has an explicit `clear()` lifetime operation. The class is not used as a service locator, and the serviceFeature owns its construction and host binding.
|
||||||
|
|
||||||
### Operation and composition: database preparation
|
### Operation and composition: database preparation
|
||||||
@@ -130,6 +138,22 @@ The operation does not need an application Module identity.
|
|||||||
|
|
||||||
### Ordered start-up composition and registration-only features
|
### Ordered start-up composition and registration-only features
|
||||||
|
|
||||||
|
Compatibility review uses distinct controller and composer dependencies:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type CompatibilityReviewDependencies = NecessaryServicesInterfaces<
|
||||||
|
"setting" | "config" | "vault" | "control",
|
||||||
|
never
|
||||||
|
>;
|
||||||
|
|
||||||
|
type CompatibilityReviewHost = NecessaryServices<
|
||||||
|
"setting" | "config" | "vault" | "control" | "appLifecycle" | "API",
|
||||||
|
never
|
||||||
|
>;
|
||||||
|
```
|
||||||
|
|
||||||
|
The controller receives the four interface-only Services it uses. The composer adds `appLifecycle` and `API` for ordered lifecycle handlers and command registration; the priority-30 compatibility review remains after flag-file recovery.
|
||||||
|
|
||||||
Configured Vault admission and the checks which follow database preparation are composed by `src/serviceFeatures/startupLifecycle/`. The directory keeps onboarding admission, compromised-chunk inspection, incomplete-document repair, Config Doctor, and the obsolete bulk-send setting migration as separate operations. One feature composer owns their order and receives the compatibility-review wait operation explicitly; an individual operation does not call the composer.
|
Configured Vault admission and the checks which follow database preparation are composed by `src/serviceFeatures/startupLifecycle/`. The directory keeps onboarding admission, compromised-chunk inspection, incomplete-document repair, Config Doctor, and the obsolete bulk-send setting migration as separate operations. One feature composer owns their order and receives the compatibility-review wait operation explicitly; an individual operation does not call the composer.
|
||||||
|
|
||||||
The layout-ready admission handler uses priority 1. This preserves ordinary priority-0 host integration before admission, while keeping an unconfigured Vault outside the flag-file recovery handlers at priorities 5, 10, and 20, and the compatibility review at priority 30. Admission belongs to one plug-in process: an initially unconfigured process remains inert until setup restarts it, and declining the requested restart does not trigger an in-process reconfiguration. Changing an admitted process back to unconfigured retires its Config Doctor and incomplete-document repair request handlers. The handlers also recheck the current configured state and database readiness when invoked, so a pending restart cannot expose partially initialised or retired state. The first-initialise handler rechecks admission before retaining the established order after the file watcher has been started: database readiness, compromised chunks, incomplete documents, compatibility review, Config Doctor, and the bulk-send setting migration.
|
The layout-ready admission handler uses priority 1. This preserves ordinary priority-0 host integration before admission, while keeping an unconfigured Vault outside the flag-file recovery handlers at priorities 5, 10, and 20, and the compatibility review at priority 30. Admission belongs to one plug-in process: an initially unconfigured process remains inert until setup restarts it, and declining the requested restart does not trigger an in-process reconfiguration. Changing an admitted process back to unconfigured retires its Config Doctor and incomplete-document repair request handlers. The handlers also recheck the current configured state and database readiness when invoked, so a pending restart cannot expose partially initialised or retired state. The first-initialise handler rechecks admission before retaining the established order after the file watcher has been started: database readiness, compromised chunks, incomplete documents, compatibility review, Config Doctor, and the bulk-send setting migration.
|
||||||
@@ -144,7 +168,19 @@ The state remains private to the composed feature. It does not become a `LiveSyn
|
|||||||
|
|
||||||
### Implemented composition: conflict resolution
|
### Implemented composition: conflict resolution
|
||||||
|
|
||||||
Conflict checking and resolution are composed for every host by `useConflictResolutionFeature`. The feature owns its `QueueProcessor` privately and registers the conflict Service handlers directly. Its operations receive explicit collaborators for settings, active-file state, database and storage access, replication, logging, and host events. No consumer locates a conflict Module or retains the queue.
|
Conflict checking and resolution are composed for every host by `useConflictResolutionFeature`. The feature owns its `QueueProcessor` privately and registers the conflict Service handlers directly. Its host type selects the required service and module contracts and adds the borrowed logger explicitly:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ConflictResolutionHost = NecessaryServices<
|
||||||
|
"appLifecycle" | "conflict" | "database" | "replication" | "setting" | "vault",
|
||||||
|
"databaseFileAccess" | "fileHandler" | "storageAccess",
|
||||||
|
InjectableServiceHub
|
||||||
|
> & {
|
||||||
|
readonly log: LogFunction;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The operations receive explicit collaborators for settings, active-file state, database and storage access, replication, a borrowed `LogFunction`, and host events. `LiveSyncBaseCore` supplies `log` using `createInstanceLogFunction("SF:ConflictResolution", this.services.API)`. Because feature composition precedes legacy Module binding, the host supplies the existing lazy service logger and does not read `logResource.value` before binding. `ModuleLog` and the host retain resource ownership. No consumer locates a conflict Module or retains the queue.
|
||||||
|
|
||||||
The scheduling queue remains one state owner. It publishes `conflictProcessQueueCount`, coalesces pending checks for the same path, and makes `ensureAllProcessed()` wait for conflict resolution to finish. Repeated resolver invocations for one path retain only the newest waiting request and close an active comparison for that path before waiting for the per-file resolver, while comparisons for other paths remain open. Resolution remains host-neutral and communicates dialogue cancellation through `services.context.events`, so CLI, WebApp, and Obsidian compositions use their own selected event channel.
|
The scheduling queue remains one state owner. It publishes `conflictProcessQueueCount`, coalesces pending checks for the same path, and makes `ensureAllProcessed()` wait for conflict resolution to finish. Repeated resolver invocations for one path retain only the newest waiting request and close an active comparison for that path before waiting for the per-file resolver, while comparisons for other paths remain open. Resolution remains host-neutral and communicates dialogue cancellation through `services.context.events`, so CLI, WebApp, and Obsidian compositions use their own selected event channel.
|
||||||
|
|
||||||
|
|||||||
@@ -160,10 +160,11 @@ preflight failure. If a required setup decision cannot be made in a headless
|
|||||||
CLI invocation, the command must fail with a clear remediation message rather
|
CLI invocation, the command must fail with a clear remediation message rather
|
||||||
than report success after cancellation.
|
than report success after cancellation.
|
||||||
|
|
||||||
`--write-settings` retains the Rebuilder's selected local database name and
|
The unreleased CLI correction for #1304 always retains the Rebuilder's selected
|
||||||
lasting settings changes for subsequent invocations. Without this option, the
|
local database suffix after successful `fetch` or `rebuild`. `--write-settings`
|
||||||
existing CLI policy leaves the settings file unchanged, so a later command can
|
still controls other lasting settings changes. Earlier versions required that
|
||||||
reopen the previously selected database.
|
option to retain the database selection; omitting it could reopen the previous
|
||||||
|
database on the next invocation. The examples retain the option for compatibility.
|
||||||
|
|
||||||
## State which follows the selected identity
|
## State which follows the selected identity
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,37 @@ Use the least destructive operation which matches the evidence:
|
|||||||
|
|
||||||
Do not switch transport, enable P2P, or run Garbage Collection as a substitute for diagnosing a stopped CouchDB or Object Storage setup.
|
Do not switch transport, enable P2P, or run Garbage Collection as a substitute for diagnosing a stopped CouchDB or Object Storage setup.
|
||||||
|
|
||||||
|
## Recover files truncated during Object Storage reception
|
||||||
|
|
||||||
|
This procedure addresses [issue #1303](https://github.com/vrtmrz/obsidian-livesync/issues/1303): an older Journal receiver can truncate a Chunk at a raw unit separator (`U+001F`) within its data. This can damage a received text file, including a plug-in's `main.js`, while the original batch remains intact in the bucket.
|
||||||
|
|
||||||
|
The receiver correction is available in Commonlib 0.2.3 and is included in the unreleased Self-hosted LiveSync build. Use a build containing that correction on every participating client before recovery; versions 1.0.42 and 1.0.43 still contain the defect. Updating alone does not repair Chunks already stored locally. Resetting only the Journal received history is also insufficient because ordinary reception skips existing Chunk IDs.
|
||||||
|
|
||||||
|
### Preserve the intact source
|
||||||
|
|
||||||
|
1. Stop editing and synchronisation on every device. If necessary, use the [emergency suspension flag](#suspend-before-diagnosis).
|
||||||
|
2. Back up every Vault, including hidden files such as `.obsidian`, and the complete remote bucket before resetting anything.
|
||||||
|
3. Verify the actual contents of the proposed recovery source. A file's existence, recorded size, or successful synchronisation status does not prove that its content is intact. Preserve unique unsynchronised edits and any needed content which exists only in database history separately.
|
||||||
|
4. Update all participating clients to a build containing the correction, keeping the other devices stopped while recovering one source and then one receiving device at a time.
|
||||||
|
|
||||||
|
### Rebuild from a complete, intact Vault
|
||||||
|
|
||||||
|
When one Vault contains all the intended current files, a deliberate Rebuild avoids relying on potentially damaged local databases. Use [Overwrite Server Data with This Device's Files](#overwrite-server-data-with-this-devices-files) on that authoritative device, then prepare an empty recovery Vault on each other device and [Reset Synchronisation on This Device](#reset-synchronisation-on-this-device) from the rebuilt remote. Keep the previous Vaults as backups until verification is complete.
|
||||||
|
|
||||||
|
Rebuild reconstructs the local and remote databases from the current Vault files. It does not retain revisions available only in the previous local database, remote history, or another device. Keep the backups and preserve any required historical content before proceeding. An intact copy of one affected file does not establish that the whole Vault is a complete recovery source.
|
||||||
|
|
||||||
|
### Reuse a verified intact remote
|
||||||
|
|
||||||
|
If the selected remote file contents are verified intact, the remote need not be overwritten. Prepare a new empty recovery Vault with the same remote configuration and use [Reset Synchronisation on This Device](#reset-synchronisation-on-this-device). Keep the previous Vault, including its hidden files and unsynchronised work, as a separate backup. This reconstructs the local database and its receive checkpoints, allowing the corrected receiver to read the original batches again.
|
||||||
|
|
||||||
|
Fetch does not unconditionally overwrite existing Vault files. A differing local file can remain and be preserved as a separate conflict branch alongside the intact remote content. An empty recovery Vault avoids retaining damaged local files during this process. In the CLI, use the empty Vault with `fetch --force --write-settings`; `--force` confirms database replacement, not forced file overwriting.
|
||||||
|
|
||||||
|
### Restore optional files and verify the result
|
||||||
|
|
||||||
|
Rebuild and Fetch disable Hidden File Sync and Customisation Sync. Complete ordinary note recovery first, then initialise the optional feature deliberately. For Hidden File Sync, follow its [initial source selection](tips/hidden-file-sync.md#choose-the-initial-source): after a full Rebuild, use `Overwrite` on the device with the intact hidden files, then `Fetch` on additional devices. When retaining an intact remote hidden-file state, use `Fetch` on the recovering device. Restore the intended Customisation Sync selections separately; do not enable both features for the same files.
|
||||||
|
|
||||||
|
Compare the recovered files with the preserved originals, including the affected hidden files, then restart Obsidian and synchronise again. Confirm that the contents remain intact and that any affected plug-in loads before resuming ordinary work on the remaining devices. Keep the backups until this verification is complete.
|
||||||
|
|
||||||
## Migrate to Unicode NFC path identity
|
## Migrate to Unicode NFC path identity
|
||||||
|
|
||||||
Use this coordinated migration when CLI and Obsidian clients should identify composed and decomposed Unicode spellings as the same path. Version `0` remains the default and preserves legacy identity. CouchDB support is released; Object Storage and P2P support belong to the unreleased extension described in the [Unicode path identity design](design_docs/unicode_path_identity.md).
|
Use this coordinated migration when CLI and Obsidian clients should identify composed and decomposed Unicode spellings as the same path. Version `0` remains the default and preserves legacy identity. CouchDB support is released; Object Storage and P2P support belong to the unreleased extension described in the [Unicode path identity design](design_docs/unicode_path_identity.md).
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ Hidden File Sync must be enabled independently after ordinary setup on each devi
|
|||||||
|
|
||||||
If you later run Rebuild or Fetch as a recovery operation, expect optional features to be disabled again. Complete ordinary recovery first, then repeat this guide deliberately.
|
If you later run Rebuild or Fetch as a recovery operation, expect optional features to be disabled again. Complete ordinary recovery first, then repeat this guide deliberately.
|
||||||
|
|
||||||
|
## Resolve a JSON conflict
|
||||||
|
|
||||||
|
When automatic merging cannot combine conflicting JSON changes and no overwrite pattern applies, the **Conflicted Setting** dialogue compares two revisions. Review the proposed contents before choosing **Apply**. Closing the dialogue leaves the conflict unresolved.
|
||||||
|
|
||||||
|
A later conflict check for the same file cancels the earlier comparison and checks the latest revisions again. If manual resolution is still needed, a replacement dialogue opens. A cancelled dialogue cannot apply a later selection. A resolution received through replication closes the comparison for that file; a remaining conflict is checked again. Dialogues for other files wait their turn. An **Apply** operation which has already started finishes before the replacement proceeds.
|
||||||
|
|
||||||
## Automatic in Customisation Sync
|
## Automatic in Customisation Sync
|
||||||
|
|
||||||
**Automatic** delegates an item's files to Hidden File Sync. Enable Hidden File Sync on this device through the steps above before choosing Automatic. If Hidden File Sync is disabled, the choice displays a Notice without changing the item's mode or starting initialisation. Previously saved Automatic assignments remain saved, so this prerequisite also applies after importing settings from another device.
|
**Automatic** delegates an item's files to Hidden File Sync. Enable Hidden File Sync on this device through the steps above before choosing Automatic. If Hidden File Sync is disabled, the choice displays a Notice without changing the item's mode or starting initialisation. Previously saved Automatic assignments remain saved, so this prerequisite also applies after importing settings from another device.
|
||||||
|
|||||||
+1
-1
@@ -194,7 +194,7 @@ const context = await esbuild.context({
|
|||||||
platform: "browser",
|
platform: "browser",
|
||||||
metafile: true,
|
metafile: true,
|
||||||
sourcemap: prod ? false : "inline",
|
sourcemap: prod ? false : "inline",
|
||||||
treeShaking: false,
|
treeShaking: true,
|
||||||
outfile: "main_org.js",
|
outfile: "main_org.js",
|
||||||
mainFields: ["browser", "module", "main"],
|
mainFields: ["browser", "module", "main"],
|
||||||
minifyWhitespace: false,
|
minifyWhitespace: false,
|
||||||
|
|||||||
@@ -120,6 +120,13 @@ export default defineConfig([
|
|||||||
"@typescript-eslint/restrict-template-expressions": "warn",
|
"@typescript-eslint/restrict-template-expressions": "warn",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ["src/apps/cli/vite.config.ts", "src/apps/webapp/vite.config.ts"],
|
||||||
|
rules: {
|
||||||
|
// Vite loads these configurations before the application source alias is available.
|
||||||
|
"livesync-imports/prefer-source-alias": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ["src/apps/browser/**/*.{ts,svelte}", "src/apps/webapp/**/*.ts"],
|
files: ["src/apps/browser/**/*.{ts,svelte}", "src/apps/webapp/**/*.ts"],
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
Generated
+9
-8
@@ -24,7 +24,7 @@
|
|||||||
"@smithy/util-retry": "^4.4.5",
|
"@smithy/util-retry": "^4.4.5",
|
||||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||||
"@vrtmrz/livesync-commonlib": "file:_testdata/nfc/vrtmrz-livesync-commonlib-0.2.3-nfc-21c639f65430.tgz",
|
"@vrtmrz/livesync-commonlib": "file:_testdata/nfc/vrtmrz-livesync-commonlib-0.2.3-nfc-21c639f65430.tgz",
|
||||||
"@vrtmrz/obsidian-plugin-kit": "0.1.6",
|
"@vrtmrz/obsidian-plugin-kit": "0.1.7",
|
||||||
"@vrtmrz/ui-interactions": "0.1.2",
|
"@vrtmrz/ui-interactions": "0.1.2",
|
||||||
"@vrtmrz/value-store": "0.1.0",
|
"@vrtmrz/value-store": "0.1.0",
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"markdown-it": "^14.3.3",
|
"markdown-it": "^14.3.3",
|
||||||
"minimatch": "^10.2.5",
|
"minimatch": "^10.2.5",
|
||||||
"obsidian": "^1.13.1",
|
"obsidian": "^1.13.1",
|
||||||
"octagonal-wheels": "^0.1.55",
|
"octagonal-wheels": "0.1.56",
|
||||||
"qrcode-generator": "^1.4.4",
|
"qrcode-generator": "^1.4.4",
|
||||||
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
|
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
|
||||||
},
|
},
|
||||||
@@ -4188,9 +4188,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vrtmrz/obsidian-plugin-kit": {
|
"node_modules/@vrtmrz/obsidian-plugin-kit": {
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-plugin-kit/-/obsidian-plugin-kit-0.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-plugin-kit/-/obsidian-plugin-kit-0.1.7.tgz",
|
||||||
"integrity": "sha512-88HhpN3G53qIQxvsTpe7VE6/unW3YtcZjmqko3Q58yRl/Y+iJDruT1HuEQYEgtkWAB6F2T93z2fhqhvRBXDDWQ==",
|
"integrity": "sha512-Ax6u6CEHVrY+DJJtaCcVkSGzf9fia1ypzGblQ1uG4AvMjgc2o4STs6l2P7D7ZB81pryE4MfkSpBLvENxyTbWLw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vrtmrz/secret-store": "0.1.0",
|
"@vrtmrz/secret-store": "0.1.0",
|
||||||
@@ -11765,9 +11765,10 @@
|
|||||||
"name": "self-hosted-livesync-cli",
|
"name": "self-hosted-livesync-cli",
|
||||||
"version": "1.0.43-cli",
|
"version": "1.0.43-cli",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-crypto/sha256-js": "5.2.0",
|
||||||
"chokidar": "^4.0.0",
|
"chokidar": "^4.0.0",
|
||||||
"minimatch": "^10.2.5",
|
"minimatch": "^10.2.5",
|
||||||
"octagonal-wheels": "^0.1.55",
|
"octagonal-wheels": "0.1.56",
|
||||||
"pouchdb-adapter-http": "^9.0.0",
|
"pouchdb-adapter-http": "^9.0.0",
|
||||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||||
"pouchdb-core": "^9.0.0",
|
"pouchdb-core": "^9.0.0",
|
||||||
@@ -11790,7 +11791,7 @@
|
|||||||
"name": "livesync-webapp",
|
"name": "livesync-webapp",
|
||||||
"version": "1.0.43-webapp",
|
"version": "1.0.43-webapp",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"octagonal-wheels": "^0.1.55"
|
"octagonal-wheels": "0.1.56"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
@@ -11802,7 +11803,7 @@
|
|||||||
"src/apps/webpeer": {
|
"src/apps/webpeer": {
|
||||||
"version": "1.0.43-webpeer",
|
"version": "1.0.43-webpeer",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"octagonal-wheels": "^0.1.55"
|
"octagonal-wheels": "0.1.56"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
|||||||
+6
-3
@@ -100,6 +100,7 @@
|
|||||||
"test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts",
|
"test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts",
|
||||||
"test:e2e:obsidian:customisation-catalogue-reload": "tsx test/e2e-obsidian/scripts/customisation-catalogue-reload.ts",
|
"test:e2e:obsidian:customisation-catalogue-reload": "tsx test/e2e-obsidian/scripts/customisation-catalogue-reload.ts",
|
||||||
"test:e2e:obsidian:optional-file-save-completion": "tsx test/e2e-obsidian/scripts/optional-file-save-completion.ts",
|
"test:e2e:obsidian:optional-file-save-completion": "tsx test/e2e-obsidian/scripts/optional-file-save-completion.ts",
|
||||||
|
"test:e2e:obsidian:log-lifecycle": "tsx test/e2e-obsidian/scripts/log-lifecycle.ts",
|
||||||
"test:e2e:obsidian:livesync-batch": "tsx test/e2e-obsidian/scripts/issue1207-livesync-batch.ts",
|
"test:e2e:obsidian:livesync-batch": "tsx test/e2e-obsidian/scripts/issue1207-livesync-batch.ts",
|
||||||
"test:e2e:obsidian:received-change-readiness": "tsx test/e2e-obsidian/scripts/received-change-readiness.ts",
|
"test:e2e:obsidian:received-change-readiness": "tsx test/e2e-obsidian/scripts/received-change-readiness.ts",
|
||||||
"test:e2e:obsidian:sync-guard": "tsx test/e2e-obsidian/scripts/sync-guard.ts",
|
"test:e2e:obsidian:sync-guard": "tsx test/e2e-obsidian/scripts/sync-guard.ts",
|
||||||
@@ -132,7 +133,9 @@
|
|||||||
"version": "node version-bump.mjs && node update-workspaces.mjs && npm run pretty:json && git add manifest.json versions.json src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json",
|
"version": "node version-bump.mjs && node update-workspaces.mjs && npm run pretty:json && git add manifest.json versions.json src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json",
|
||||||
"test:e2e:obsidian:startup-integrity": "tsx test/e2e-obsidian/scripts/startup-integrity.ts",
|
"test:e2e:obsidian:startup-integrity": "tsx test/e2e-obsidian/scripts/startup-integrity.ts",
|
||||||
"test:e2e:obsidian:third-party-chunks": "tsx test/e2e-obsidian/scripts/third-party-chunks.ts",
|
"test:e2e:obsidian:third-party-chunks": "tsx test/e2e-obsidian/scripts/third-party-chunks.ts",
|
||||||
"test:e2e:obsidian:upgrade-livesync-batching": "tsx test/e2e-obsidian/scripts/upgrade-livesync-batching.ts"
|
"test:e2e:obsidian:upgrade-livesync-batching": "tsx test/e2e-obsidian/scripts/upgrade-livesync-batching.ts",
|
||||||
|
"pretest:browser-ui-lifecycle": "svelte-check --tsconfig test/browser-apps/ui-lifecycle/tsconfig.json --fail-on-warnings && vite build --config test/browser-apps/ui-lifecycle/vite.config.ts",
|
||||||
|
"test:browser-ui-lifecycle": "deno test -A --no-check --frozen --config test/browser-apps/deno.json --lock test/browser-apps/deno.lock test/browser-apps/ui-lifecycle/browser-ui-lifecycle.test.ts"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "vorotamoroz",
|
"author": "vorotamoroz",
|
||||||
@@ -207,7 +210,7 @@
|
|||||||
"@smithy/util-retry": "^4.4.5",
|
"@smithy/util-retry": "^4.4.5",
|
||||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||||
"@vrtmrz/livesync-commonlib": "file:_testdata/nfc/vrtmrz-livesync-commonlib-0.2.3-nfc-21c639f65430.tgz",
|
"@vrtmrz/livesync-commonlib": "file:_testdata/nfc/vrtmrz-livesync-commonlib-0.2.3-nfc-21c639f65430.tgz",
|
||||||
"@vrtmrz/obsidian-plugin-kit": "0.1.6",
|
"@vrtmrz/obsidian-plugin-kit": "0.1.7",
|
||||||
"@vrtmrz/ui-interactions": "0.1.2",
|
"@vrtmrz/ui-interactions": "0.1.2",
|
||||||
"@vrtmrz/value-store": "0.1.0",
|
"@vrtmrz/value-store": "0.1.0",
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
@@ -215,7 +218,7 @@
|
|||||||
"markdown-it": "^14.3.3",
|
"markdown-it": "^14.3.3",
|
||||||
"minimatch": "^10.2.5",
|
"minimatch": "^10.2.5",
|
||||||
"obsidian": "^1.13.1",
|
"obsidian": "^1.13.1",
|
||||||
"octagonal-wheels": "^0.1.55",
|
"octagonal-wheels": "0.1.56",
|
||||||
"qrcode-generator": "^1.4.4",
|
"qrcode-generator": "^1.4.4",
|
||||||
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
|
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { useReplicationScheduling, type ReplicationSchedulingControl } from "./s
|
|||||||
import { createCentralReplicatorProviderDefinitions } from "./common/replicatorProviders";
|
import { createCentralReplicatorProviderDefinitions } from "./common/replicatorProviders";
|
||||||
import { useReplicationFeature } from "./serviceFeatures/replication";
|
import { useReplicationFeature } from "./serviceFeatures/replication";
|
||||||
import { useConflictResolutionFeature } from "./serviceFeatures/conflictResolution";
|
import { useConflictResolutionFeature } from "./serviceFeatures/conflictResolution";
|
||||||
|
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||||
import { useBasicCommandsFeature } from "./serviceFeatures/basicCommands";
|
import { useBasicCommandsFeature } from "./serviceFeatures/basicCommands";
|
||||||
|
|
||||||
/** Focused views returned by serviceFeatures which the host may consume during composition. */
|
/** Focused views returned by serviceFeatures which the host may consume during composition. */
|
||||||
@@ -290,7 +291,11 @@ export class LiveSyncBaseCore<
|
|||||||
* (Please refer `serviceFeatures` for more details)
|
* (Please refer `serviceFeatures` for more details)
|
||||||
*/
|
*/
|
||||||
initialiseServiceFeatures(): LiveSyncCoreFeatureViews {
|
initialiseServiceFeatures(): LiveSyncCoreFeatureViews {
|
||||||
useConflictResolutionFeature(this);
|
useConflictResolutionFeature({
|
||||||
|
services: this.services,
|
||||||
|
serviceModules: this.serviceModules,
|
||||||
|
log: createInstanceLogFunction("SF:ConflictResolution", this.services.API),
|
||||||
|
});
|
||||||
useTargetFilters(this);
|
useTargetFilters(this);
|
||||||
// enable target filter feature.
|
// enable target filter feature.
|
||||||
usePrepareDatabaseForUse(this);
|
usePrepareDatabaseForUse(this);
|
||||||
@@ -303,7 +308,7 @@ export class LiveSyncBaseCore<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IMinimumLiveSyncCommands {
|
export interface IMinimumLiveSyncCommands {
|
||||||
onunload(): void;
|
onunload(): void | Promise<void>;
|
||||||
onload(): void | Promise<void>;
|
onload(): void | Promise<void>;
|
||||||
constructor: { name: string };
|
constructor: { name: string };
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
|
||||||
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
|
||||||
import { BrowserUiNotifications, createBrowserUi } from "@vrtmrz/browser-ui-kit";
|
import { BrowserUiNotifications, createBrowserUi } from "@vrtmrz/browser-ui-kit";
|
||||||
|
|
||||||
import { createNativeElement } from "@/apps/browserDom";
|
import { createNativeElement } from "@/apps/browserDom";
|
||||||
import { renderMessageMarkdownInto } from "./ui/renderMessageMarkdown";
|
import { renderMessageMarkdownInto } from "./ui/renderMessageMarkdown";
|
||||||
import { UiInteractionsConfirm } from "./UiInteractionsConfirm";
|
import { UiInteractionsConfirm } from "./UiInteractionsConfirm";
|
||||||
|
import { createBrowserUiScope, type BrowserUiScope } from "./BrowserUiScope";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compatibility facade consumed by Commonlib while browser presentation is
|
* Compatibility facade consumed by Commonlib while browser presentation is
|
||||||
@@ -14,26 +13,26 @@ import { UiInteractionsConfirm } from "./UiInteractionsConfirm";
|
|||||||
export class BrowserConfirm<T extends ServiceContext> extends UiInteractionsConfirm {
|
export class BrowserConfirm<T extends ServiceContext> extends UiInteractionsConfirm {
|
||||||
readonly context: T;
|
readonly context: T;
|
||||||
|
|
||||||
constructor(context: T) {
|
constructor(context: T, ui: BrowserUiScope = createBrowserUiScope(context)) {
|
||||||
const dialogueController = new AbortController();
|
|
||||||
const notifications = new BrowserUiNotifications({
|
const notifications = new BrowserUiNotifications({
|
||||||
document: _activeDocument,
|
document: ui.document,
|
||||||
});
|
});
|
||||||
super({
|
super({
|
||||||
ui: createBrowserUi({
|
ui: createBrowserUi({
|
||||||
document: _activeDocument,
|
document: ui.document,
|
||||||
signal: dialogueController.signal,
|
signal: ui.signal,
|
||||||
renderMarkdown: ({ container, markdown }) => {
|
renderMarkdown: ({ container, markdown }) => {
|
||||||
renderMessageMarkdownInto(container, markdown);
|
renderMessageMarkdownInto(container, markdown);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
notifications,
|
notifications,
|
||||||
createActionAnchor: () => createNativeElement(_activeDocument, "a"),
|
createActionAnchor: () => createNativeElement(ui.document, "a"),
|
||||||
});
|
});
|
||||||
this.context = context;
|
this.context = context;
|
||||||
context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
|
if (ui.signal.aborted) {
|
||||||
dialogueController.abort();
|
|
||||||
notifications.dispose();
|
notifications.dispose();
|
||||||
});
|
} else {
|
||||||
|
ui.signal.addEventListener("abort", () => notifications.dispose(), { once: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
import { promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
|
import { fireAndForget, promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
|
||||||
import { createNativeElement } from "@/apps/browserDom";
|
import { createNativeElement } from "@/apps/browserDom";
|
||||||
import { mount } from "svelte";
|
import type { BrowserUiOptions } from "./BrowserUiScope";
|
||||||
|
import { mount, unmount } from "svelte";
|
||||||
import MenuView from "./ui/MenuView.svelte";
|
import MenuView from "./ui/MenuView.svelte";
|
||||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||||
|
|
||||||
|
type MountedMenu = ReturnType<typeof mount>;
|
||||||
|
type MenuSession = {
|
||||||
|
waitingForClose: PromiseWithResolvers<void>;
|
||||||
|
element: HTMLElement;
|
||||||
|
component?: MountedMenu;
|
||||||
|
abortListener?: () => void;
|
||||||
|
closed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export class MenuItem {
|
export class MenuItem {
|
||||||
type = "item";
|
type = "item";
|
||||||
title = "";
|
title = "";
|
||||||
@@ -29,7 +39,15 @@ export class Menu {
|
|||||||
type = "menu";
|
type = "menu";
|
||||||
items: (MenuItem | MenuSeparator)[] = [];
|
items: (MenuItem | MenuSeparator)[] = [];
|
||||||
|
|
||||||
constructor() {}
|
private readonly document: Document;
|
||||||
|
private readonly signal?: AbortSignal;
|
||||||
|
private activeSession?: MenuSession;
|
||||||
|
|
||||||
|
constructor(options: BrowserUiOptions = {}) {
|
||||||
|
this.document = options.document ?? _activeDocument;
|
||||||
|
this.signal = options.signal;
|
||||||
|
}
|
||||||
|
|
||||||
addItem(callback: (item: MenuItem) => void) {
|
addItem(callback: (item: MenuItem) => void) {
|
||||||
const item = new MenuItem();
|
const item = new MenuItem();
|
||||||
callback(item);
|
callback(item);
|
||||||
@@ -41,32 +59,75 @@ export class Menu {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
waitingForClose?: PromiseWithResolvers<void>;
|
waitingForClose?: PromiseWithResolvers<void>;
|
||||||
showAtPosition(pos: { x: number; y: number }) {
|
|
||||||
const el = createNativeElement(_activeDocument, "div");
|
showAtPosition(pos: { x: number; y: number }): Promise<void> {
|
||||||
if (this.waitingForClose) {
|
if (this.activeSession) {
|
||||||
this.waitingForClose.resolve();
|
this.closeSession(this.activeSession);
|
||||||
}
|
}
|
||||||
this.waitingForClose = promiseWithResolvers<void>();
|
if (this.signal?.aborted) {
|
||||||
mount(MenuView, {
|
this.waitingForClose = undefined;
|
||||||
target: el,
|
return Promise.resolve();
|
||||||
props: {
|
}
|
||||||
items: this.items,
|
|
||||||
closeMenu: () => {
|
const waitingForClose = promiseWithResolvers<void>();
|
||||||
this.waitingForClose?.resolve();
|
const element = createNativeElement(this.document, "div");
|
||||||
this.waitingForClose = undefined;
|
const session: MenuSession = {
|
||||||
|
waitingForClose,
|
||||||
|
element,
|
||||||
|
closed: false,
|
||||||
|
};
|
||||||
|
this.activeSession = session;
|
||||||
|
this.waitingForClose = waitingForClose;
|
||||||
|
|
||||||
|
const closeMenu = () => this.closeSession(session);
|
||||||
|
session.abortListener = closeMenu;
|
||||||
|
this.signal?.addEventListener("abort", closeMenu, { once: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
session.component = mount(MenuView, {
|
||||||
|
target: element,
|
||||||
|
props: {
|
||||||
|
items: this.items,
|
||||||
|
closeMenu,
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
},
|
},
|
||||||
x: pos.x,
|
});
|
||||||
y: pos.y,
|
if (!session.closed) {
|
||||||
},
|
this.document.body.appendChild(element);
|
||||||
});
|
} else if (session.component) {
|
||||||
_activeDocument.body.appendChild(el);
|
fireAndForget(() => unmount(session.component!));
|
||||||
void this.waitingForClose.promise.finally(() => {
|
}
|
||||||
el.remove();
|
} catch (error) {
|
||||||
});
|
this.closeSession(session);
|
||||||
return this.waitingForClose.promise;
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return waitingForClose.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private closeSession(session: MenuSession): void {
|
||||||
|
if (session.closed) return;
|
||||||
|
session.closed = true;
|
||||||
|
if (session.abortListener) {
|
||||||
|
this.signal?.removeEventListener("abort", session.abortListener);
|
||||||
|
session.abortListener = undefined;
|
||||||
|
}
|
||||||
|
if (this.activeSession === session) {
|
||||||
|
this.activeSession = undefined;
|
||||||
|
this.waitingForClose = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.element.remove();
|
||||||
|
if (session.component) {
|
||||||
|
fireAndForget(() => unmount(session.component!));
|
||||||
|
}
|
||||||
|
session.waitingForClose.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
this.waitingForClose?.resolve();
|
if (this.activeSession) {
|
||||||
this.waitingForClose = undefined;
|
this.closeSession(this.activeSession);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import type { SvelteDialogManagerDependencies } from "@vrtmrz/livesync-commonlib
|
|||||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||||
import DialogHost from "@/modules/services/LiveSyncUI/DialogHost.svelte";
|
import DialogHost from "@/modules/services/LiveSyncUI/DialogHost.svelte";
|
||||||
import { SvelteDialogSession } from "@/modules/services/SvelteDialogSession";
|
import { SvelteDialogSession } from "@/modules/services/SvelteDialogSession";
|
||||||
|
import { createBrowserUiScope, type BrowserUiScope } from "./BrowserUiScope";
|
||||||
|
|
||||||
export class BrowserModal {
|
export class BrowserModal {
|
||||||
contentEl: HTMLElement;
|
contentEl: HTMLElement;
|
||||||
@@ -15,14 +16,14 @@ export class BrowserModal {
|
|||||||
modalEl: HTMLElement;
|
modalEl: HTMLElement;
|
||||||
isOpen: boolean = false;
|
isOpen: boolean = false;
|
||||||
baseEl: HTMLElement;
|
baseEl: HTMLElement;
|
||||||
constructor() {
|
constructor(document: Document = _activeDocument) {
|
||||||
const baseEl = createNativeElement(_activeDocument, "popup");
|
const baseEl = createNativeElement(document, "popup");
|
||||||
this.baseEl = baseEl;
|
this.baseEl = baseEl;
|
||||||
this.contentEl = createNativeElement(_activeDocument, "div");
|
this.contentEl = createNativeElement(document, "div");
|
||||||
this.contentEl.className = "modal-content";
|
this.contentEl.className = "modal-content";
|
||||||
this.titleEl = createNativeElement(_activeDocument, "div");
|
this.titleEl = createNativeElement(document, "div");
|
||||||
this.titleEl.className = "modal-title";
|
this.titleEl.className = "modal-title";
|
||||||
this.modalEl = createNativeElement(_activeDocument, "div");
|
this.modalEl = createNativeElement(document, "div");
|
||||||
this.modalEl.className = "modal";
|
this.modalEl.className = "modal";
|
||||||
this.modalEl.hidden = true;
|
this.modalEl.hidden = true;
|
||||||
this.modalEl.appendChild(this.titleEl);
|
this.modalEl.appendChild(this.titleEl);
|
||||||
@@ -33,7 +34,7 @@ export class BrowserModal {
|
|||||||
this.isOpen = true;
|
this.isOpen = true;
|
||||||
this.modalEl.hidden = false;
|
this.modalEl.hidden = false;
|
||||||
if (!this.baseEl.parentElement) {
|
if (!this.baseEl.parentElement) {
|
||||||
_activeDocument.body.appendChild(this.baseEl);
|
this.baseEl.ownerDocument.body.appendChild(this.baseEl);
|
||||||
}
|
}
|
||||||
this.onOpen();
|
this.onOpen();
|
||||||
}
|
}
|
||||||
@@ -57,9 +58,10 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
|
|||||||
context: C,
|
context: C,
|
||||||
dependents: SvelteDialogManagerDependencies<C>,
|
dependents: SvelteDialogManagerDependencies<C>,
|
||||||
component: ComponentHasResult<T, U>,
|
component: ComponentHasResult<T, U>,
|
||||||
initialData?: U
|
initialData?: U,
|
||||||
|
private readonly ui?: BrowserUiScope
|
||||||
) {
|
) {
|
||||||
super();
|
super(ui?.document ?? _activeDocument);
|
||||||
this.session = new SvelteDialogSession({
|
this.session = new SvelteDialogSession({
|
||||||
surface: this,
|
surface: this,
|
||||||
context,
|
context,
|
||||||
@@ -67,9 +69,15 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
|
|||||||
dialogHost: DialogHost,
|
dialogHost: DialogHost,
|
||||||
component,
|
component,
|
||||||
initialData,
|
initialData,
|
||||||
|
signal: ui?.signal,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override open(): void {
|
||||||
|
if (this.ui?.signal.aborted) throw this.ui.signal.reason ?? new Error("Plug-in unloaded");
|
||||||
|
super.open();
|
||||||
|
}
|
||||||
|
|
||||||
override onOpen(): void {
|
override onOpen(): void {
|
||||||
this.session.onOpen();
|
this.session.onOpen();
|
||||||
}
|
}
|
||||||
@@ -83,11 +91,26 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class BrowserSvelteDialogManager<T extends ServiceContext> extends SvelteDialogManagerBase<T> {
|
export class BrowserSvelteDialogManager<T extends ServiceContext> extends SvelteDialogManagerBase<T> {
|
||||||
|
constructor(
|
||||||
|
context: T,
|
||||||
|
dependencies: SvelteDialogManagerDependencies<T>,
|
||||||
|
private readonly ui: BrowserUiScope = createBrowserUiScope(context)
|
||||||
|
) {
|
||||||
|
super(context, dependencies);
|
||||||
|
}
|
||||||
|
|
||||||
override async openSvelteDialog<TT, TU>(
|
override async openSvelteDialog<TT, TU>(
|
||||||
component: ComponentHasResult<TT, TU>,
|
component: ComponentHasResult<TT, TU>,
|
||||||
initialData?: TU
|
initialData?: TU
|
||||||
): Promise<TT | undefined> {
|
): Promise<TT | undefined> {
|
||||||
const dialog = new LiveSyncBrowserDialog<TT, TU, T>(this.context, this.dependents, component, initialData);
|
if (this.ui.signal.aborted) throw this.ui.signal.reason ?? new Error("Plug-in unloaded");
|
||||||
|
const dialog = new LiveSyncBrowserDialog<TT, TU, T>(
|
||||||
|
this.context,
|
||||||
|
this.dependents,
|
||||||
|
component,
|
||||||
|
initialData,
|
||||||
|
this.ui
|
||||||
|
);
|
||||||
dialog.open();
|
dialog.open();
|
||||||
return await dialog.waitForClose();
|
return await dialog.waitForClose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
|
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||||
|
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||||
|
|
||||||
|
export interface BrowserUiOptions {
|
||||||
|
readonly document?: Document;
|
||||||
|
readonly signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Browser presentation target and lifetime owned by one application context. */
|
||||||
|
export interface BrowserUiScope {
|
||||||
|
readonly document: Document;
|
||||||
|
readonly signal: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBrowserUiScope(
|
||||||
|
context: Pick<ServiceContext, "events">,
|
||||||
|
options: BrowserUiOptions = {}
|
||||||
|
): BrowserUiScope {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const parent = options.signal;
|
||||||
|
let unregisterUnload: (() => void) | undefined;
|
||||||
|
const end = (reason: unknown): void => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
unregisterUnload?.();
|
||||||
|
unregisterUnload = undefined;
|
||||||
|
parent?.removeEventListener("abort", onParentAbort);
|
||||||
|
controller.abort(reason);
|
||||||
|
};
|
||||||
|
const onParentAbort = () => end(parent?.reason);
|
||||||
|
if (parent?.aborted) {
|
||||||
|
controller.abort(parent.reason);
|
||||||
|
} else {
|
||||||
|
unregisterUnload = context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => end(new Error("Plug-in unloaded")));
|
||||||
|
parent?.addEventListener("abort", onParentAbort, { once: true });
|
||||||
|
}
|
||||||
|
return { document: options.document ?? _activeDocument, signal: controller.signal };
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/ser
|
|||||||
import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService";
|
import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService";
|
||||||
import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte";
|
import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte";
|
||||||
import { BrowserSvelteDialogManager } from "./BrowserSvelteDialogManager";
|
import { BrowserSvelteDialogManager } from "./BrowserSvelteDialogManager";
|
||||||
|
import type { BrowserUiScope } from "./BrowserUiScope";
|
||||||
|
|
||||||
export interface LiveSyncBrowserUIServiceDependencies<T extends ServiceContext> {
|
export interface LiveSyncBrowserUIServiceDependencies<T extends ServiceContext> {
|
||||||
API: InjectableAPIService<T>;
|
API: InjectableAPIService<T>;
|
||||||
@@ -20,15 +21,19 @@ export class LiveSyncBrowserUIService<T extends ServiceContext> extends UIServic
|
|||||||
override get dialogToCopy() {
|
override get dialogToCopy() {
|
||||||
return DialogToCopy;
|
return DialogToCopy;
|
||||||
}
|
}
|
||||||
constructor(context: T, dependents: LiveSyncBrowserUIServiceDependencies<T>) {
|
constructor(context: T, dependents: LiveSyncBrowserUIServiceDependencies<T>, ui?: BrowserUiScope) {
|
||||||
const browserConfirm = dependents.API.confirm;
|
const browserConfirm = dependents.API.confirm;
|
||||||
const obsidianSvelteDialogManager = new BrowserSvelteDialogManager<T>(context, {
|
const obsidianSvelteDialogManager = new BrowserSvelteDialogManager<T>(
|
||||||
appLifecycle: dependents.appLifecycle,
|
context,
|
||||||
config: dependents.config,
|
{
|
||||||
replicator: dependents.replicator,
|
appLifecycle: dependents.appLifecycle,
|
||||||
confirm: browserConfirm,
|
config: dependents.config,
|
||||||
control: dependents.control,
|
replicator: dependents.replicator,
|
||||||
});
|
confirm: browserConfirm,
|
||||||
|
control: dependents.control,
|
||||||
|
},
|
||||||
|
ui
|
||||||
|
);
|
||||||
super(context, {
|
super(context, {
|
||||||
dialogManager: obsidianSvelteDialogManager,
|
dialogManager: obsidianSvelteDialogManager,
|
||||||
APIService: dependents.API,
|
APIService: dependents.API,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/
|
|||||||
|
|
||||||
import { setLang, translateLiveSyncMessage } from "@/common/translation";
|
import { setLang, translateLiveSyncMessage } from "@/common/translation";
|
||||||
import { BrowserConfirm } from "./BrowserConfirm";
|
import { BrowserConfirm } from "./BrowserConfirm";
|
||||||
|
import { createBrowserUiScope, type BrowserUiOptions, type BrowserUiScope } from "./BrowserUiScope";
|
||||||
import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase";
|
import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase";
|
||||||
import { LiveSyncBrowserAPIService, type LiveSyncBrowserAPIServiceOptions } from "./LiveSyncBrowserAPIService";
|
import { LiveSyncBrowserAPIService, type LiveSyncBrowserAPIServiceOptions } from "./LiveSyncBrowserAPIService";
|
||||||
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
|
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
|
||||||
@@ -41,6 +42,7 @@ export interface LiveSyncBrowserRestartPolicy {
|
|||||||
export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
|
export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
|
||||||
deviceValues: ValueStore & ValueDeleter;
|
deviceValues: ValueStore & ValueDeleter;
|
||||||
context?: T;
|
context?: T;
|
||||||
|
ui?: BrowserUiOptions;
|
||||||
getSystemVaultName?: () => string;
|
getSystemVaultName?: () => string;
|
||||||
settings?: LiveSyncBrowserSettingsPersistence;
|
settings?: LiveSyncBrowserSettingsPersistence;
|
||||||
restart?: LiveSyncBrowserRestartPolicy;
|
restart?: LiveSyncBrowserRestartPolicy;
|
||||||
@@ -56,15 +58,18 @@ class LiveSyncBrowserKeyValueDBService<T extends ServiceContext> extends KeyValu
|
|||||||
|
|
||||||
/** LiveSync-owned service composition shared by WebApp and WebPeer. */
|
/** LiveSync-owned service composition shared by WebApp and WebPeer. */
|
||||||
export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
|
export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
|
||||||
|
readonly browserUi: BrowserUiScope;
|
||||||
|
|
||||||
constructor(options: LiveSyncBrowserServiceHubOptions<T>) {
|
constructor(options: LiveSyncBrowserServiceHubOptions<T>) {
|
||||||
const context =
|
const context =
|
||||||
options.context ??
|
options.context ??
|
||||||
(new ServiceContext({
|
(new ServiceContext({
|
||||||
translate: translateLiveSyncMessage,
|
translate: translateLiveSyncMessage,
|
||||||
}) as T);
|
}) as T);
|
||||||
|
const browserUi = createBrowserUiScope(context, options.ui);
|
||||||
const API = new LiveSyncBrowserAPIService(context, {
|
const API = new LiveSyncBrowserAPIService(context, {
|
||||||
...options.API,
|
...options.API,
|
||||||
confirm: new BrowserConfirm(context),
|
confirm: new BrowserConfirm(context, browserUi),
|
||||||
getSystemVaultName: options.getSystemVaultName ?? (() => "livesync-browser"),
|
getSystemVaultName: options.getSystemVaultName ?? (() => "livesync-browser"),
|
||||||
});
|
});
|
||||||
const conflict = new InjectableConflictService(context);
|
const conflict = new InjectableConflictService(context);
|
||||||
@@ -147,13 +152,17 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
|||||||
APIService: API,
|
APIService: API,
|
||||||
replicatorService: replicator,
|
replicatorService: replicator,
|
||||||
});
|
});
|
||||||
const ui = new LiveSyncBrowserUIService(context, {
|
const ui = new LiveSyncBrowserUIService(
|
||||||
API,
|
context,
|
||||||
appLifecycle,
|
{
|
||||||
config,
|
API,
|
||||||
control,
|
appLifecycle,
|
||||||
replicator,
|
config,
|
||||||
});
|
control,
|
||||||
|
replicator,
|
||||||
|
},
|
||||||
|
browserUi
|
||||||
|
);
|
||||||
|
|
||||||
super(context, {
|
super(context, {
|
||||||
API,
|
API,
|
||||||
@@ -175,6 +184,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
|||||||
ui,
|
ui,
|
||||||
vault,
|
vault,
|
||||||
});
|
});
|
||||||
|
this.browserUi = browserUi;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export async function findCaseInsensitiveFile<T extends object>(
|
||||||
|
requestedPath: string,
|
||||||
|
findExact: () => Promise<T | null>,
|
||||||
|
cachedFiles: ReadonlyMap<string, T>,
|
||||||
|
scan: () => Promise<void>
|
||||||
|
): Promise<T | null> {
|
||||||
|
const exact = await findExact();
|
||||||
|
if (exact) return exact;
|
||||||
|
|
||||||
|
const lowerPath = requestedPath.toLowerCase();
|
||||||
|
const findCached = (): T | null => {
|
||||||
|
for (const [cachedPath, cachedFile] of cachedFiles) {
|
||||||
|
if (cachedPath.toLowerCase() === lowerPath) return cachedFile;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cached = findCached();
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
await scan();
|
||||||
|
return findCached();
|
||||||
|
}
|
||||||
+37
-1
@@ -70,6 +70,7 @@ livesync-cli [database-path] [command] [args...]
|
|||||||
- `--force` / `-f`: Required by `rebuild` and `fetch` because they replace a database. It also retains its existing `init-settings` overwrite behaviour.
|
- `--force` / `-f`: Required by `rebuild` and `fetch` because they replace a database. It also retains its existing `init-settings` overwrite behaviour.
|
||||||
- `--write-settings`: Write setting migrations and other lasting changes after the command succeeds.
|
- `--write-settings`: Write setting migrations and other lasting changes after the command succeeds.
|
||||||
- `init-settings` writes its target file. `setup`, `remote-add`, `remote-rm`, `remote-set`, and `remote-activate` write their settings changes without this option.
|
- `init-settings` writes its target file. `setup`, `remote-add`, `remote-rm`, `remote-set`, and `remote-activate` write their settings changes without this option.
|
||||||
|
- `fetch` and `rebuild` always retain the selected local database suffix after success. Other lasting changes from these commands still require this option.
|
||||||
- All remaining commands leave the settings file unchanged by default.
|
- All remaining commands leave the settings file unchanged by default.
|
||||||
- Temporary values used to suspend synchronisation or select a remote for one command are never written.
|
- Temporary values used to suspend synchronisation or select a remote for one command are never written.
|
||||||
- `--compat-remote-admin-exit-zero`: Preserve the former zero exit code when `mark-resolved`, `lock-remote`, or `unlock-remote` returns a provider verification failure.
|
- `--compat-remote-admin-exit-zero`: Preserve the former zero exit code when `mark-resolved`, `lock-remote`, or `unlock-remote` returns a provider verification failure.
|
||||||
@@ -84,8 +85,10 @@ livesync-cli [database-path] [command] [args...]
|
|||||||
- If `vault-path` is omitted, it defaults to `database-path` (compatibility mode).
|
- If `vault-path` is omitted, it defaults to `database-path` (compatibility mode).
|
||||||
- Use this command to keep your local `.md` files in sync with the database.
|
- Use this command to keep your local `.md` files in sync with the database.
|
||||||
- `rebuild`: Rebuild the local and remote databases from the selected Vault. This replaces existing local and remote database state and requires `--force`.
|
- `rebuild`: Rebuild the local and remote databases from the selected Vault. This replaces existing local and remote database state and requires `--force`.
|
||||||
- This command may select a new local database suffix. Add `--write-settings` if later CLI commands should use the resulting database selection; otherwise the existing settings file remains unchanged.
|
- The selected local database suffix is saved automatically, so later commands use the reconstructed database.
|
||||||
- `fetch`: Replace the local database with the configured remote state, then reflect fetched files to the selected Vault. Existing local database state is discarded, so this command requires `--force`.
|
- `fetch`: Replace the local database with the configured remote state, then reflect fetched files to the selected Vault. Existing local database state is discarded, so this command requires `--force`.
|
||||||
|
- The selected local database suffix is saved automatically, including when `--write-settings` is omitted.
|
||||||
|
- Differing local files can remain and be preserved as conflict branches alongside the fetched content. For recovery from an intact remote, preserve the existing Vault separately and Fetch into an empty Vault; `--force` does not force file overwriting.
|
||||||
- `ls [prefix]`: List files currently stored in the local database.
|
- `ls [prefix]`: List files currently stored in the local database.
|
||||||
- `push <src> <dst>`: Push a local file `<src>` into the database at path `<dst>`.
|
- `push <src> <dst>`: Push a local file `<src>` into the database at path `<dst>`.
|
||||||
- `pull <src> <dst>`: Pull a file `<src>` from the database into local file `<dst>`.
|
- `pull <src> <dst>`: Pull a file `<src>` from the database into local file `<dst>`.
|
||||||
@@ -105,6 +108,39 @@ livesync-cli [database-path] [command] [args...]
|
|||||||
|
|
||||||
Remote-administration commands verify the resulting milestone state through the selected provider. The existing `[Verification]` lines remain suitable for scripts which inspect command output, while the default exit code now reflects whether that verification succeeded.
|
Remote-administration commands verify the resulting milestone state through the selected provider. The existing `[Verification]` lines remain suitable for scripts which inspect command output, while the default exit code now reflects whether that verification succeeded.
|
||||||
|
|
||||||
|
### Interrupted reconstruction and older runtime state
|
||||||
|
|
||||||
|
An interrupted `fetch` or `rebuild` leaves a record in
|
||||||
|
`<database-path>/.livesync/reconstruction.json`. Ordinary synchronisation and
|
||||||
|
mutating commands stop while reconstruction is incomplete. Preserve the Vault,
|
||||||
|
database directory, and settings, check the reported failure, then retry the
|
||||||
|
same command with `--force` and the same database, Vault, and settings paths.
|
||||||
|
`rebuild` replaces remote state as well: confirm that the Vault remains the
|
||||||
|
authoritative copy before retrying it. Do not remove the record merely to
|
||||||
|
restart `daemon`.
|
||||||
|
|
||||||
|
If reconstruction finished but its settings update was interrupted, the next
|
||||||
|
invocation completes that update before starting the core. If the settings
|
||||||
|
file was independently edited, the CLI stops for review. The pending record
|
||||||
|
can contain settings and credentials; protect it like the settings file.
|
||||||
|
|
||||||
|
Older CLI versions stored runtime state without recording its database and
|
||||||
|
Vault owner. If unowned state is present, the CLI preserves it and stops
|
||||||
|
ordinary mutating commands. Read commands remain available for inspection.
|
||||||
|
Back up unique edits and establish the authoritative copy before explicitly
|
||||||
|
using `fetch --force` or `rebuild --force`. The old runtime files remain
|
||||||
|
available for diagnosis; they are not automatically imported into a new scope.
|
||||||
|
|
||||||
|
This check runs before scanning, event replay, and synchronisation. Its message
|
||||||
|
identifies each affected file and distinguishes an unknown owner from a file
|
||||||
|
which cannot be read or parsed. Read commands display the warning and continue
|
||||||
|
inspection. After successful reconstruction for the selected database and Vault,
|
||||||
|
retained legacy files are no longer inspected, even if they are malformed.
|
||||||
|
Corruption in the currently selected runtime KV remains an initialisation error.
|
||||||
|
|
||||||
|
Use one CLI process at a time for each database directory. Separate runtime
|
||||||
|
scopes do not make concurrent writers to shared device settings supported.
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { NodeVaultAdapter } from "./NodeVaultAdapter";
|
|||||||
import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
|
import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
|
||||||
import { path } from "@vrtmrz/livesync-commonlib/node";
|
import { path } from "@vrtmrz/livesync-commonlib/node";
|
||||||
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
||||||
|
import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete file system adapter implementation for Node.js
|
* Complete file system adapter implementation for Node.js
|
||||||
@@ -87,27 +88,12 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
|||||||
return await this.refreshResolvedFile(resolvedPath);
|
return await this.refreshResolvedFile(resolvedPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const exact = await this.getAbstractFileByPath(pathStr);
|
return await findCaseInsensitiveFile(
|
||||||
if (exact) {
|
pathStr,
|
||||||
return exact;
|
() => this.getAbstractFileByPath(pathStr),
|
||||||
}
|
this.fileCache,
|
||||||
|
() => this.scanDirectory()
|
||||||
const lowerPath = pathStr.toLowerCase();
|
);
|
||||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
|
||||||
if (cachedPath.toLowerCase() === lowerPath) {
|
|
||||||
return cachedFile;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.scanDirectory();
|
|
||||||
|
|
||||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
|
||||||
if (cachedPath.toLowerCase() === lowerPath) {
|
|
||||||
return cachedFile;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFiles(): Promise<NodeFile[]> {
|
async getFiles(): Promise<NodeFile[]> {
|
||||||
|
|||||||
@@ -124,6 +124,83 @@ describe("NodeFileSystemAdapter file enumeration", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("NodeFileSystemAdapter case-insensitive path lookup", () => {
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
async function createVault(files: string[] = []) {
|
||||||
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-cli-insensitive-"));
|
||||||
|
tempDirs.push(directory);
|
||||||
|
for (const file of files) {
|
||||||
|
await fs.mkdir(path.dirname(path.join(directory, file)), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(directory, file), `content of ${file}`);
|
||||||
|
}
|
||||||
|
return { directory, adapter: new NodeFileSystemAdapter(directory) };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(tempDirs.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an exact path without scanning the directory", async () => {
|
||||||
|
const { adapter } = await createVault(["Calculus.md"]);
|
||||||
|
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||||
|
|
||||||
|
await expect(adapter.getAbstractFileByPathInsensitive("Calculus.md")).resolves.toMatchObject({
|
||||||
|
path: "Calculus.md",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scanDirectory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a cached case-insensitive match without rescanning", async () => {
|
||||||
|
const { adapter } = await createVault(["Calculus.md"]);
|
||||||
|
await adapter.scanDirectory();
|
||||||
|
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||||
|
|
||||||
|
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||||
|
path: "Calculus.md",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scanDirectory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rescans once and returns a newly cached case-insensitive match", async () => {
|
||||||
|
const { adapter } = await createVault(["Calculus.md"]);
|
||||||
|
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||||
|
|
||||||
|
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||||
|
path: "Calculus.md",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||||
|
await expect(adapter.getAbstractFileByPath("Calculus.md")).resolves.toMatchObject({ path: "Calculus.md" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null after one scan when no cached path matches", async () => {
|
||||||
|
const { adapter } = await createVault(["other.md"]);
|
||||||
|
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||||
|
|
||||||
|
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||||
|
|
||||||
|
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null and reports a directory scan failure", async () => {
|
||||||
|
const missingDirectory = path.join(os.tmpdir(), `livesync-cli-insensitive-missing-${Date.now()}`);
|
||||||
|
const reportDiagnostic = vi.fn();
|
||||||
|
const adapter = new NodeFileSystemAdapter(missingDirectory, reportDiagnostic);
|
||||||
|
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||||
|
|
||||||
|
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||||
|
|
||||||
|
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||||
|
expect(reportDiagnostic).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Error scanning directory"),
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -78,12 +78,20 @@ describe("CLI database preparation", () => {
|
|||||||
await rm(directory, { recursive: true, force: true });
|
await rm(directory, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
async function start(command: CLICommand, runner: CliCommandRunner, exitCode = 1, force = false) {
|
async function start(
|
||||||
|
command: CLICommand,
|
||||||
|
runner: CliCommandRunner,
|
||||||
|
exitCode = 1,
|
||||||
|
force = false,
|
||||||
|
writeSettings = false
|
||||||
|
) {
|
||||||
|
process.exitCode = undefined;
|
||||||
process.argv = [
|
process.argv = [
|
||||||
"node",
|
"node",
|
||||||
"livesync-cli",
|
"livesync-cli",
|
||||||
directory,
|
directory,
|
||||||
...(force ? ["--force"] : []),
|
...(force ? ["--force"] : []),
|
||||||
|
...(writeSettings ? ["--write-settings"] : []),
|
||||||
"--vault",
|
"--vault",
|
||||||
vaultPath,
|
vaultPath,
|
||||||
"--settings",
|
"--settings",
|
||||||
@@ -386,6 +394,194 @@ describe("CLI database preparation", () => {
|
|||||||
expect(await readFile(join(vaultPath, "notes/local.md"), "utf-8")).toBe("local content");
|
expect(await readFile(join(vaultPath, "notes/local.md"), "utf-8")).toBe("local content");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["fetch", "rebuild"] as const)(
|
||||||
|
"persists the selected database after %s without other command settings",
|
||||||
|
async (command) => {
|
||||||
|
const before = JSON.parse(await readFile(settingsPath, "utf-8"));
|
||||||
|
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
|
||||||
|
await core.services.setting.applyPartial({
|
||||||
|
additionalSuffixOfDatabaseName: "headless-app",
|
||||||
|
customChunkSize: before.customChunkSize + 10,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await start(command, runner, 0, true);
|
||||||
|
|
||||||
|
const after = JSON.parse(await readFile(settingsPath, "utf-8"));
|
||||||
|
expect(after.additionalSuffixOfDatabaseName).toBe("headless-app");
|
||||||
|
expect(after.customChunkSize).toBe(before.customChunkSize);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["suffix", "vault"] as const)("isolates persistent state by selected %s", async (selection) => {
|
||||||
|
await start("ls", async (_options, { core }) => {
|
||||||
|
await core.services.keyValueDB.kvDB.set("scope-probe", "original");
|
||||||
|
await core.services.config.setValue("scope-probe", "original");
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (selection === "suffix") {
|
||||||
|
const settings = JSON.parse(await readFile(settingsPath, "utf-8"));
|
||||||
|
settings.additionalSuffixOfDatabaseName = "other";
|
||||||
|
await writeFile(settingsPath, JSON.stringify(settings));
|
||||||
|
} else {
|
||||||
|
vaultPath = join(directory, "other-vault");
|
||||||
|
await mkdir(vaultPath);
|
||||||
|
}
|
||||||
|
const observed: unknown[] = [];
|
||||||
|
await start("ls", async (_options, { core }) => {
|
||||||
|
observed.push(await core.services.keyValueDB.kvDB.get("scope-probe"));
|
||||||
|
observed.push(await core.services.config.getValue("scope-probe"));
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
expect(observed).toEqual([undefined, null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["sync", "daemon", "mirror", "put"] as const)(
|
||||||
|
"blocks %s after failed reconstruction before command preparation",
|
||||||
|
async (command) => {
|
||||||
|
await start("fetch", async () => false, 1, true);
|
||||||
|
const runner = vi.fn<CliCommandRunner>(async () => false);
|
||||||
|
const ready = vi.spyOn(ControlService.prototype, "onReady");
|
||||||
|
|
||||||
|
await start(command, runner);
|
||||||
|
|
||||||
|
expect(runner).not.toHaveBeenCalled();
|
||||||
|
expect(ready).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("retains legacy queued work and blocks a scanner until explicit reconstruction succeeds", async () => {
|
||||||
|
const legacy = join(directory, ".livesync/runtime/keyvalue-db.json");
|
||||||
|
const legacyContents = JSON.stringify({
|
||||||
|
"queued-files": ["notes/unique.md"],
|
||||||
|
fileStatusMap: { "notes/local.md": 1 },
|
||||||
|
});
|
||||||
|
await mkdir(join(directory, ".livesync/runtime"), { recursive: true });
|
||||||
|
await writeFile(legacy, legacyContents);
|
||||||
|
const runner = vi.fn<CliCommandRunner>(async () => false);
|
||||||
|
const scan = vi.spyOn(ServiceFileAccessCLI.prototype, "getFiles");
|
||||||
|
|
||||||
|
await start("mirror", runner);
|
||||||
|
expect(runner).not.toHaveBeenCalled();
|
||||||
|
expect(scan).not.toHaveBeenCalled();
|
||||||
|
expect(await readFile(legacy, "utf-8")).toBe(legacyContents);
|
||||||
|
|
||||||
|
await start("fetch", async () => true, 0, true);
|
||||||
|
const later = vi.fn<CliCommandRunner>(async () => false);
|
||||||
|
await start("sync", later);
|
||||||
|
expect(later).toHaveBeenCalledOnce();
|
||||||
|
expect(await readFile(legacy, "utf-8")).toBe(legacyContents);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("permits normal commands after reconstruction while preserving malformed legacy JSON", async () => {
|
||||||
|
const legacy = join(directory, ".livesync/runtime/keyvalue-db.json");
|
||||||
|
await mkdir(join(directory, ".livesync/runtime"), { recursive: true });
|
||||||
|
await writeFile(legacy, '{"unfinished":');
|
||||||
|
await start("fetch", async () => true, 0, true);
|
||||||
|
const runner = vi.fn<CliCommandRunner>(async () => false);
|
||||||
|
await start("sync", runner);
|
||||||
|
expect(runner).toHaveBeenCalledOnce();
|
||||||
|
expect(await readFile(legacy, "utf-8")).toBe('{"unfinished":');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["kv", '{"unfinished":', "cannot be parsed"],
|
||||||
|
["snapshot", "[", "cannot be parsed"],
|
||||||
|
["kv", '{"queued-files":["unique.md"]}', "owner is unknown"],
|
||||||
|
["snapshot", '[{"path":"unique.md"}]', "owner is unknown"],
|
||||||
|
])("diagnoses unowned %s (%s) before readiness and ignores it after recovery", async (kind, contents, reason) => {
|
||||||
|
const legacy =
|
||||||
|
kind === "kv"
|
||||||
|
? join(directory, ".livesync/runtime/keyvalue-db.json")
|
||||||
|
: join(vaultPath, ".livesync-snapshot.json");
|
||||||
|
await mkdir(join(directory, ".livesync/runtime"), { recursive: true });
|
||||||
|
await writeFile(legacy, contents);
|
||||||
|
const messages = () => standardIo.writeStderr.mock.calls.flat().join("");
|
||||||
|
const reader = vi.fn<CliCommandRunner>(async () => false);
|
||||||
|
await start("ls", reader);
|
||||||
|
expect(reader).toHaveBeenCalledOnce();
|
||||||
|
expect(messages()).toContain(reason);
|
||||||
|
expect(messages()).toContain(legacy);
|
||||||
|
expect(messages()).toContain("inspection may continue");
|
||||||
|
expect(messages().split("[Legacy state]")).toHaveLength(2);
|
||||||
|
expect(standardIo.writeStdout.mock.calls.flat().join("")).not.toContain("[Legacy state]");
|
||||||
|
|
||||||
|
standardIo.writeStderr.mockClear();
|
||||||
|
const writer = vi.fn<CliCommandRunner>(async () => false);
|
||||||
|
const ready = vi.spyOn(ControlService.prototype, "onReady");
|
||||||
|
await start("mirror", writer);
|
||||||
|
expect(writer).not.toHaveBeenCalled();
|
||||||
|
expect(ready).not.toHaveBeenCalled();
|
||||||
|
expect(messages()).toContain("stopped before scanning");
|
||||||
|
expect(messages()).toContain(reason);
|
||||||
|
expect(messages().split("[Legacy state]")).toHaveLength(2);
|
||||||
|
ready.mockRestore();
|
||||||
|
|
||||||
|
await start("fetch", async () => true, 0, true);
|
||||||
|
standardIo.writeStderr.mockClear();
|
||||||
|
const later = vi.fn<CliCommandRunner>(async () => false);
|
||||||
|
await start("sync", later);
|
||||||
|
expect(later).toHaveBeenCalledOnce();
|
||||||
|
expect(messages()).not.toContain("[Legacy state]");
|
||||||
|
expect(await readFile(legacy, "utf-8")).toBe(contents);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks read commands which request settings writes while reconstruction is interrupted", async () => {
|
||||||
|
await start("fetch", async () => false, 1, true);
|
||||||
|
const runner = vi.fn<CliCommandRunner>(async () => false);
|
||||||
|
const load = vi.spyOn(ControlService.prototype, "onLoad");
|
||||||
|
|
||||||
|
await start("ls", runner, 1, false, true);
|
||||||
|
|
||||||
|
expect(runner).not.toHaveBeenCalled();
|
||||||
|
expect(load).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates every Vault scope for the reset database and retains other database state", async () => {
|
||||||
|
const originalVault = vaultPath;
|
||||||
|
const otherVault = join(directory, "other-vault");
|
||||||
|
await mkdir(otherVault);
|
||||||
|
const select = async (suffix: string, selectedVault: string) => {
|
||||||
|
vaultPath = selectedVault;
|
||||||
|
const settings = JSON.parse(await readFile(settingsPath, "utf-8"));
|
||||||
|
settings.additionalSuffixOfDatabaseName = suffix;
|
||||||
|
await writeFile(settingsPath, JSON.stringify(settings));
|
||||||
|
};
|
||||||
|
for (const [suffix, selectedVault] of [
|
||||||
|
["", originalVault],
|
||||||
|
["", otherVault],
|
||||||
|
["other", originalVault],
|
||||||
|
]) {
|
||||||
|
await select(suffix, selectedVault);
|
||||||
|
await start("ls", async (_options, { core }) => {
|
||||||
|
await core.services.keyValueDB.kvDB.set("scope-probe", "stored");
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await select("", originalVault);
|
||||||
|
let reset: boolean | undefined;
|
||||||
|
await start("ls", async (_options, { core }) => {
|
||||||
|
reset = await core.services.database.resetDatabase();
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
expect(reset).toBe(true);
|
||||||
|
|
||||||
|
const observed: unknown[] = [];
|
||||||
|
for (const [suffix, selectedVault] of [
|
||||||
|
["", originalVault],
|
||||||
|
["", otherVault],
|
||||||
|
["other", originalVault],
|
||||||
|
]) {
|
||||||
|
await select(suffix, selectedVault);
|
||||||
|
await start("ls", async (_options, { core }) => {
|
||||||
|
observed.push(await core.services.keyValueDB.kvDB.get("scope-probe"));
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(observed).toEqual([undefined, undefined, "stored"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not import vault files for standalone database commands", async () => {
|
it("does not import vault files for standalone database commands", async () => {
|
||||||
const storedPaths: string[] = [];
|
const storedPaths: string[] = [];
|
||||||
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
|
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
|
||||||
|
|||||||
+83
-19
@@ -41,6 +41,14 @@ import {
|
|||||||
reconcileDurableSettings,
|
reconcileDurableSettings,
|
||||||
settingsEqual,
|
settingsEqual,
|
||||||
} from "./settingsPersistence";
|
} from "./settingsPersistence";
|
||||||
|
import {
|
||||||
|
inspectLegacyState,
|
||||||
|
isMutatingCommand,
|
||||||
|
isReconstructionCommand,
|
||||||
|
readOptionalFile,
|
||||||
|
ReconstructionState,
|
||||||
|
writeFileAtomically,
|
||||||
|
} from "./runtimeState";
|
||||||
|
|
||||||
const SETTINGS_FILE = ".livesync/settings.json";
|
const SETTINGS_FILE = ".livesync/settings.json";
|
||||||
|
|
||||||
@@ -381,7 +389,7 @@ export async function main(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve database path
|
// Resolve database path
|
||||||
const databasePath = path.resolve(options.databasePath!);
|
let databasePath = path.resolve(options.databasePath!);
|
||||||
// Check if database directory exists
|
// Check if database directory exists
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(databasePath);
|
const stat = await fs.stat(databasePath);
|
||||||
@@ -389,18 +397,19 @@ export async function main(
|
|||||||
writeStderrLine(standardIo, `Error: ${databasePath} is not a directory`);
|
writeStderrLine(standardIo, `Error: ${databasePath} is not a directory`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
databasePath = await fs.realpath(databasePath);
|
||||||
} catch {
|
} catch {
|
||||||
writeStderrLine(standardIo, `Error: Database directory ${databasePath} does not exist`);
|
writeStderrLine(standardIo, `Error: Database directory ${databasePath} does not exist`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve settings path
|
// Resolve settings path
|
||||||
const settingsPath = options.settingsPath
|
let settingsPath = options.settingsPath
|
||||||
? path.resolve(options.settingsPath)
|
? path.resolve(options.settingsPath)
|
||||||
: path.join(databasePath, SETTINGS_FILE);
|
: path.join(databasePath, SETTINGS_FILE);
|
||||||
// Resolve vault path: mirror positional argument takes priority,
|
// Resolve vault path: mirror positional argument takes priority,
|
||||||
// then --vault flag, otherwise fall back to databasePath.
|
// then --vault flag, otherwise fall back to databasePath.
|
||||||
const vaultPath =
|
let vaultPath =
|
||||||
options.command === "mirror" && options.commandArgs[0]
|
options.command === "mirror" && options.commandArgs[0]
|
||||||
? path.resolve(options.commandArgs[0])
|
? path.resolve(options.commandArgs[0])
|
||||||
: options.vaultPath
|
: options.vaultPath
|
||||||
@@ -414,11 +423,22 @@ export async function main(
|
|||||||
writeStderrLine(standardIo, `Error: Vault path ${vaultPath} is not a directory`);
|
writeStderrLine(standardIo, `Error: Vault path ${vaultPath} is not a directory`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
vaultPath = await fs.realpath(vaultPath);
|
||||||
} catch {
|
} catch {
|
||||||
writeStderrLine(standardIo, `Error: Vault directory ${vaultPath} does not exist`);
|
writeStderrLine(standardIo, `Error: Vault directory ${vaultPath} does not exist`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
settingsPath = await fs.realpath(settingsPath).catch(() => settingsPath);
|
||||||
|
const reconstruction = new ReconstructionState(databasePath, vaultPath, settingsPath);
|
||||||
|
try {
|
||||||
|
reconstruction.prepare(options.command, options.force ?? false, options.writeSettings ?? false);
|
||||||
|
} catch (error) {
|
||||||
|
writeStderrLine(standardIo, "[Error] Reconstruction recovery is required:", error);
|
||||||
|
process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
infoLog(`Self-hosted LiveSync CLI`);
|
infoLog(`Self-hosted LiveSync CLI`);
|
||||||
infoLog(`Database Path: ${databasePath}`);
|
infoLog(`Database Path: ${databasePath}`);
|
||||||
infoLog(`Vault Path: ${vaultPath}`);
|
infoLog(`Vault Path: ${vaultPath}`);
|
||||||
@@ -437,7 +457,7 @@ export async function main(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create service context and hub
|
// Create service context and hub
|
||||||
const context = new NodeServiceContext(databasePath, standardIo);
|
const context = new NodeServiceContext(databasePath, standardIo, vaultPath);
|
||||||
const serviceHubInstance = new NodeServiceHub<NodeServiceContext>(databasePath, context);
|
const serviceHubInstance = new NodeServiceHub<NodeServiceContext>(databasePath, context);
|
||||||
serviceHubInstance.API.addLog.setHandler((message: unknown, level: LOG_LEVEL) => {
|
serviceHubInstance.API.addLog.setHandler((message: unknown, level: LOG_LEVEL) => {
|
||||||
let levelStr = "";
|
let levelStr = "";
|
||||||
@@ -479,7 +499,7 @@ export async function main(
|
|||||||
|
|
||||||
// Setup settings handlers
|
// Setup settings handlers
|
||||||
const settingService = serviceHubInstance.setting;
|
const settingService = serviceHubInstance.setting;
|
||||||
const originalSettingsText = await fs.readFile(settingsPath, "utf-8").catch(() => undefined);
|
const originalSettingsText = readOptionalFile(settingsPath);
|
||||||
let latestPreparedSettingsText: string | undefined;
|
let latestPreparedSettingsText: string | undefined;
|
||||||
let preparedSettingsRevision = 0;
|
let preparedSettingsRevision = 0;
|
||||||
let commandIsRunning = false;
|
let commandIsRunning = false;
|
||||||
@@ -600,21 +620,19 @@ export async function main(
|
|||||||
|
|
||||||
const writeSettingsAtomically = async (content: string | undefined): Promise<void> => {
|
const writeSettingsAtomically = async (content: string | undefined): Promise<void> => {
|
||||||
if (content === undefined || content === originalSettingsText) return;
|
if (content === undefined || content === originalSettingsText) return;
|
||||||
const temporaryPath = `${settingsPath}.${process.pid}.tmp`;
|
writeFileAtomically(settingsPath, content);
|
||||||
await fs.mkdir(path.dirname(settingsPath), { recursive: true });
|
if (options.verbose) {
|
||||||
try {
|
writeStderrLine(standardIo, `[Settings] Saved to ${settingsPath}`);
|
||||||
await fs.writeFile(temporaryPath, content, "utf-8");
|
|
||||||
await fs.rename(temporaryPath, settingsPath);
|
|
||||||
if (options.verbose) {
|
|
||||||
writeStderrLine(standardIo, `[Settings] Saved to ${settingsPath}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
await fs.unlink(temporaryPath).catch(() => {});
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start the core
|
// Start the core
|
||||||
|
let unloaded = false;
|
||||||
|
const unload = async () => {
|
||||||
|
if (unloaded) return;
|
||||||
|
unloaded = true;
|
||||||
|
await core.services.control.onUnload();
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
infoLog(`[Starting] Initializing LiveSync...`);
|
infoLog(`[Starting] Initializing LiveSync...`);
|
||||||
|
|
||||||
@@ -623,6 +641,31 @@ export async function main(
|
|||||||
writeStderrLine(standardIo, `[Error] Failed to initialize LiveSync`);
|
writeStderrLine(standardIo, `[Error] Failed to initialize LiveSync`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
if ((await core.services.keyValueDB.kvDB.get("cli-legacy-state-reviewed")) !== true) {
|
||||||
|
const legacyIssues = inspectLegacyState(databasePath, vaultPath);
|
||||||
|
if (legacyIssues.length > 0) {
|
||||||
|
const reasons = {
|
||||||
|
unowned: "database and Vault owner is unknown",
|
||||||
|
invalid: "state cannot be parsed in its expected format; owner and contents cannot be established",
|
||||||
|
unreadable: "state cannot be read; owner and contents cannot be established",
|
||||||
|
};
|
||||||
|
const details = legacyIssues.map((issue) => `${issue.path}: ${reasons[issue.reason]}`).join("; ");
|
||||||
|
const advice =
|
||||||
|
"Preserve these files, back up unique edits, and verify the authoritative source before choosing fetch --force or rebuild --force.";
|
||||||
|
const blocked =
|
||||||
|
!isReconstructionCommand(options.command) &&
|
||||||
|
(isMutatingCommand(options.command) || options.writeSettings);
|
||||||
|
if (blocked) {
|
||||||
|
throw new Error(
|
||||||
|
`[Legacy state] ${details}. Command '${options.command}' stopped before scanning, event replay, or synchronisation. ${advice}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const action = isReconstructionCommand(options.command)
|
||||||
|
? "Explicit reconstruction will proceed; the legacy files will be retained."
|
||||||
|
: "Read-only inspection may continue; the legacy files will be retained.";
|
||||||
|
writeStderrLine(standardIo, `[Legacy state] ${details}. ${action} ${advice}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
const settingsAfterLoadText = latestPreparedSettingsText
|
const settingsAfterLoadText = latestPreparedSettingsText
|
||||||
? preserveStoredSetting(latestPreparedSettingsText, originalSettingsText, "useIndexedDBAdapter")
|
? preserveStoredSetting(latestPreparedSettingsText, originalSettingsText, "useIndexedDBAdapter")
|
||||||
: originalSettingsText;
|
: originalSettingsText;
|
||||||
@@ -655,7 +698,7 @@ export async function main(
|
|||||||
try {
|
try {
|
||||||
readyResult = await core.services.control.onReady();
|
readyResult = await core.services.control.onReady();
|
||||||
} finally {
|
} finally {
|
||||||
if (!readyResult) await core.services.control.onUnload();
|
if (!readyResult) await unload();
|
||||||
}
|
}
|
||||||
if (!readyResult) {
|
if (!readyResult) {
|
||||||
throw new Error("Failed to initialise LiveSync.");
|
throw new Error("Failed to initialise LiveSync.");
|
||||||
@@ -751,6 +794,21 @@ export async function main(
|
|||||||
settingsTextToCommit = settingsAfterLoadText;
|
settingsTextToCommit = settingsAfterLoadText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (result && isReconstructionCommand(options.command)) {
|
||||||
|
if (!options.writeSettings) {
|
||||||
|
const durable =
|
||||||
|
originalSettingsText === undefined
|
||||||
|
? cloneSettings(durableSettingsBeforeSuspend)
|
||||||
|
: (JSON.parse(originalSettingsText) as ObsidianLiveSyncSettings);
|
||||||
|
durable.additionalSuffixOfDatabaseName =
|
||||||
|
core.services.setting.currentSettings().additionalSuffixOfDatabaseName;
|
||||||
|
settingsTextToCommit = JSON.stringify(durable, null, 2);
|
||||||
|
}
|
||||||
|
settingsTextToCommit ??= originalSettingsText;
|
||||||
|
if (settingsTextToCommit === undefined)
|
||||||
|
throw new Error("Reconstruction completed without database selection settings.");
|
||||||
|
await core.services.keyValueDB.kvDB.set("cli-legacy-state-reviewed", true);
|
||||||
|
}
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
writeStderrLine(standardIo, `[Error] Command '${options.command}' failed`);
|
writeStderrLine(standardIo, `[Error] Command '${options.command}' failed`);
|
||||||
@@ -764,10 +822,16 @@ export async function main(
|
|||||||
// Keep the process running
|
// Keep the process running
|
||||||
await new Promise(() => {});
|
await new Promise(() => {});
|
||||||
} else {
|
} else {
|
||||||
await core.services.control.onUnload();
|
await unload();
|
||||||
await writeSettingsAtomically(settingsTextToCommit);
|
if (result && isReconstructionCommand(options.command)) {
|
||||||
|
reconstruction.complete(settingsTextToCommit!);
|
||||||
|
reconstruction.commit();
|
||||||
|
} else {
|
||||||
|
await writeSettingsAtomically(settingsTextToCommit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await unload().catch(() => {});
|
||||||
writeStderrLine(standardIo, `[Error] Failed to start:`, error);
|
writeStderrLine(standardIo, `[Error] Failed to start:`, error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { FilePath, UXFileInfoStub, UXInternalFileInfoStub } from "@vrtmrz/l
|
|||||||
import type { FileEventItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import type { FileEventItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import type { IStorageEventManagerAdapter } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
|
import type { IStorageEventManagerAdapter } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
|
||||||
import type {
|
import type {
|
||||||
IStorageEventTypeGuardAdapter,
|
|
||||||
IStorageEventPersistenceAdapter,
|
IStorageEventPersistenceAdapter,
|
||||||
IStorageEventWatchAdapter,
|
IStorageEventWatchAdapter,
|
||||||
IStorageEventStatusAdapter,
|
IStorageEventStatusAdapter,
|
||||||
@@ -15,47 +14,26 @@ import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
|||||||
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
|
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
|
||||||
import { fsPromises as fs, getNodeFileCreationTime, path, type Stats } from "@vrtmrz/livesync-commonlib/node";
|
import { fsPromises as fs, getNodeFileCreationTime, path, type Stats } from "@vrtmrz/livesync-commonlib/node";
|
||||||
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
||||||
|
import { NodeTypeGuardAdapter } from "@/apps/cli/adapters/NodeTypeGuardAdapter";
|
||||||
/**
|
import { writeFileAtomically } from "@/apps/cli/runtimeState";
|
||||||
* CLI-specific type guard adapter
|
|
||||||
*/
|
|
||||||
class CLITypeGuardAdapter implements IStorageEventTypeGuardAdapter<NodeFile, NodeFolder> {
|
|
||||||
isFile(file: unknown): file is NodeFile {
|
|
||||||
return !!(
|
|
||||||
file &&
|
|
||||||
typeof file === "object" &&
|
|
||||||
"path" in file &&
|
|
||||||
"stat" in file &&
|
|
||||||
!(file as { isFolder?: boolean }).isFolder
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
isFolder(item: unknown): item is NodeFolder {
|
|
||||||
return !!(
|
|
||||||
item &&
|
|
||||||
typeof item === "object" &&
|
|
||||||
"path" in item &&
|
|
||||||
(item as { isFolder?: boolean }).isFolder === true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLI-specific persistence adapter (file-based snapshot)
|
* CLI-specific persistence adapter (file-based snapshot)
|
||||||
*/
|
*/
|
||||||
class CLIPersistenceAdapter implements IStorageEventPersistenceAdapter {
|
class CLIPersistenceAdapter implements IStorageEventPersistenceAdapter {
|
||||||
private snapshotPath: string;
|
private snapshotPath: () => string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
basePath: string,
|
basePath: string,
|
||||||
private reportDiagnostic: CliDiagnosticReporter = () => undefined
|
private reportDiagnostic: CliDiagnosticReporter = () => undefined,
|
||||||
|
snapshotPath?: () => string
|
||||||
) {
|
) {
|
||||||
this.snapshotPath = path.join(basePath, ".livesync-snapshot.json");
|
this.snapshotPath = snapshotPath ?? (() => path.join(basePath, ".livesync-snapshot.json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveSnapshot(snapshot: (FileEventItem | FileEventItemSentinel)[]): Promise<void> {
|
async saveSnapshot(snapshot: (FileEventItem | FileEventItemSentinel)[]): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(this.snapshotPath, JSON.stringify(snapshot, null, 2), "utf-8");
|
writeFileAtomically(this.snapshotPath(), JSON.stringify(snapshot, null, 2));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.reportDiagnostic("Failed to save snapshot:", error);
|
this.reportDiagnostic("Failed to save snapshot:", error);
|
||||||
}
|
}
|
||||||
@@ -63,7 +41,7 @@ class CLIPersistenceAdapter implements IStorageEventPersistenceAdapter {
|
|||||||
|
|
||||||
async loadSnapshot(): Promise<(FileEventItem | FileEventItemSentinel)[] | null> {
|
async loadSnapshot(): Promise<(FileEventItem | FileEventItemSentinel)[] | null> {
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(this.snapshotPath, "utf-8");
|
const content = await fs.readFile(this.snapshotPath(), "utf-8");
|
||||||
return JSON.parse(content) as (FileEventItem | FileEventItemSentinel)[];
|
return JSON.parse(content) as (FileEventItem | FileEventItemSentinel)[];
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -211,7 +189,7 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
|||||||
* Composite adapter for CLI StorageEventManager
|
* Composite adapter for CLI StorageEventManager
|
||||||
*/
|
*/
|
||||||
export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapter<NodeFile, NodeFolder> {
|
export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapter<NodeFile, NodeFolder> {
|
||||||
readonly typeGuard: CLITypeGuardAdapter;
|
readonly typeGuard: NodeTypeGuardAdapter;
|
||||||
readonly persistence: CLIPersistenceAdapter;
|
readonly persistence: CLIPersistenceAdapter;
|
||||||
readonly watch: CLIWatchAdapter;
|
readonly watch: CLIWatchAdapter;
|
||||||
readonly status: CLIStatusAdapter;
|
readonly status: CLIStatusAdapter;
|
||||||
@@ -221,10 +199,11 @@ export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapte
|
|||||||
basePath: string,
|
basePath: string,
|
||||||
ignoreRules?: IgnoreRules,
|
ignoreRules?: IgnoreRules,
|
||||||
watchEnabled: boolean = false,
|
watchEnabled: boolean = false,
|
||||||
reportDiagnostic: CliDiagnosticReporter = () => undefined
|
reportDiagnostic: CliDiagnosticReporter = () => undefined,
|
||||||
|
snapshotPath?: () => string
|
||||||
) {
|
) {
|
||||||
this.typeGuard = new CLITypeGuardAdapter();
|
this.typeGuard = new NodeTypeGuardAdapter();
|
||||||
this.persistence = new CLIPersistenceAdapter(basePath, reportDiagnostic);
|
this.persistence = new CLIPersistenceAdapter(basePath, reportDiagnostic, snapshotPath);
|
||||||
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled, reportDiagnostic);
|
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled, reportDiagnostic);
|
||||||
this.status = new CLIStatusAdapter();
|
this.status = new CLIStatusAdapter();
|
||||||
this.converter = new CLIConverterAdapter();
|
this.converter = new CLIConverterAdapter();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
import type { IStorageEventWatchHandlers } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
|
import type { IStorageEventWatchHandlers } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
|
||||||
import type { NodeFile } from "@/apps/cli/adapters/NodeTypes";
|
import type { NodeFile } from "@/apps/cli/adapters/NodeTypes";
|
||||||
|
import { fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||||
|
import { runtimeScopeDirectory } from "../runtimeState";
|
||||||
|
|
||||||
// ── chokidar mock ──────────────────────────────────────────────────────────────
|
// ── chokidar mock ──────────────────────────────────────────────────────────────
|
||||||
// Must be hoisted before imports that pull in chokidar.
|
// Must be hoisted before imports that pull in chokidar.
|
||||||
@@ -44,6 +46,27 @@ describe("CLIStorageEventManagerAdapter", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps pending snapshots with the selected database and Vault pair", async () => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-snapshot-"));
|
||||||
|
let database = "first";
|
||||||
|
let vault = "vault-a";
|
||||||
|
const adapter = new CLIStorageEventManagerAdapter(directory, undefined, false, undefined, () =>
|
||||||
|
path.join(runtimeScopeDirectory(directory, database, vault), "storage-events.json")
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await adapter.persistence.saveSnapshot([{ type: "SENTINEL_FLUSH" }]);
|
||||||
|
database = "second";
|
||||||
|
expect(await adapter.persistence.loadSnapshot()).toBeNull();
|
||||||
|
database = "first";
|
||||||
|
vault = "vault-b";
|
||||||
|
expect(await adapter.persistence.loadSnapshot()).toBeNull();
|
||||||
|
vault = "vault-a";
|
||||||
|
expect(await adapter.persistence.loadSnapshot()).toEqual([{ type: "SENTINEL_FLUSH" }]);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("beginWatch is no-op when watchEnabled=false", async () => {
|
it("beginWatch is no-op when watchEnabled=false", async () => {
|
||||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, false);
|
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, false);
|
||||||
const handlers = makeHandlers();
|
const handlers = makeHandlers();
|
||||||
@@ -201,3 +224,27 @@ describe("CLIStorageEventManagerAdapter", () => {
|
|||||||
processExitSpy.mockRestore();
|
processExitSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("CLIStorageEventManagerAdapter type guard", () => {
|
||||||
|
const typeGuard = new CLIStorageEventManagerAdapter("/base").typeGuard;
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ label: "null", value: null, expected: false },
|
||||||
|
{ label: "an object without a path", value: { stat: {} }, expected: false },
|
||||||
|
{ label: "an object without a stat", value: { path: "note.md" }, expected: false },
|
||||||
|
{ label: "a file-shaped object", value: { path: "note.md", stat: {} }, expected: true },
|
||||||
|
{ label: "a folder-shaped object", value: { path: "folder", isFolder: true }, expected: false },
|
||||||
|
])("classifies $label as a file", ({ value, expected }) => {
|
||||||
|
expect(typeGuard.isFile(value)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ label: "null", value: null, expected: false },
|
||||||
|
{ label: "an object without a path", value: { isFolder: true }, expected: false },
|
||||||
|
{ label: "an object without the folder marker", value: { path: "folder" }, expected: false },
|
||||||
|
{ label: "a folder-shaped object", value: { path: "folder", isFolder: true }, expected: true },
|
||||||
|
{ label: "a file-shaped object", value: { path: "note.md", stat: {} }, expected: false },
|
||||||
|
])("classifies $label as a folder", ({ value, expected }) => {
|
||||||
|
expect(typeGuard.isFolder(value)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { StorageEventManagerBase, type StorageEventManagerBaseDependencies } from "@vrtmrz/livesync-commonlib/compat/managers/StorageEventManager";
|
import {
|
||||||
|
StorageEventManagerBase,
|
||||||
|
type StorageEventManagerBaseDependencies,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/compat/managers/StorageEventManager";
|
||||||
import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter";
|
import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter";
|
||||||
import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
|
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
|
||||||
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||||
|
import { runtimeScopeDirectory } from "@/apps/cli/runtimeState";
|
||||||
|
import type { NodeServiceContext } from "@/apps/cli/services/NodeServiceContext";
|
||||||
|
import { path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
||||||
// import type { IMinimumLiveSyncCommands } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
// import type { IMinimumLiveSyncCommands } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||||
|
|
||||||
export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEventManagerAdapter> {
|
export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEventManagerAdapter> {
|
||||||
@@ -16,12 +22,23 @@ export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEv
|
|||||||
ignoreRules?: IgnoreRules,
|
ignoreRules?: IgnoreRules,
|
||||||
watchEnabled?: boolean
|
watchEnabled?: boolean
|
||||||
) {
|
) {
|
||||||
const adapter = new CLIStorageEventManagerAdapter(basePath, ignoreRules, watchEnabled, (message, detail) => {
|
const context = core.services.context as NodeServiceContext;
|
||||||
dependencies.APIService.addLog(message, LOG_LEVEL_NOTICE);
|
const adapter = new CLIStorageEventManagerAdapter(
|
||||||
if (detail !== undefined) {
|
basePath,
|
||||||
dependencies.APIService.addLog(detail, LOG_LEVEL_NOTICE);
|
ignoreRules,
|
||||||
}
|
watchEnabled,
|
||||||
});
|
(message, detail) => {
|
||||||
|
dependencies.APIService.addLog(message, LOG_LEVEL_NOTICE);
|
||||||
|
if (detail !== undefined) {
|
||||||
|
dependencies.APIService.addLog(detail, LOG_LEVEL_NOTICE);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() =>
|
||||||
|
nodePath.join(
|
||||||
|
runtimeScopeDirectory(context.databasePath, dependencies.vaultService.getVaultName(), basePath),
|
||||||
|
"storage-events.json"
|
||||||
|
)
|
||||||
|
);
|
||||||
super(adapter, dependencies);
|
super(adapter, dependencies);
|
||||||
this.core = core;
|
this.core = core;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"buildRun": "npm run build && npm run cli --",
|
"buildRun": "npm run build && npm run cli --",
|
||||||
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
|
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
|
||||||
"check": "tsc -p tsconfig.json",
|
"check": "tsc -p tsconfig.json",
|
||||||
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
|
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/runtimeState.unit.spec.ts src/apps/cli/services/NodeKeyValueDBService.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
|
||||||
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
|
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
|
||||||
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
|
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
|
||||||
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
|
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
|
||||||
@@ -37,9 +37,10 @@
|
|||||||
"test:e2e:coverage": "node ../../../test/e2e-cli/run-coverage.ts"
|
"test:e2e:coverage": "node ../../../test/e2e-cli/run-coverage.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-crypto/sha256-js": "5.2.0",
|
||||||
"chokidar": "^4.0.0",
|
"chokidar": "^4.0.0",
|
||||||
"minimatch": "^10.2.5",
|
"minimatch": "^10.2.5",
|
||||||
"octagonal-wheels": "^0.1.55",
|
"octagonal-wheels": "0.1.56",
|
||||||
"pouchdb-adapter-http": "^9.0.0",
|
"pouchdb-adapter-http": "^9.0.0",
|
||||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||||
"pouchdb-core": "^9.0.0",
|
"pouchdb-core": "^9.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
// This module belongs exclusively to the Node.js CLI host.
|
||||||
|
import { Sha256 } from "@aws-crypto/sha256-js";
|
||||||
|
import { fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||||
|
import type { CLICommand } from "./commands/types";
|
||||||
|
|
||||||
|
function sha256Hex(value: string): string {
|
||||||
|
const hash = new Sha256();
|
||||||
|
hash.update(new TextEncoder().encode(value));
|
||||||
|
return Array.from(hash.digestSync(), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runtimeScopeDirectory(databasePath: string, databaseName: string, vaultPath: string): string {
|
||||||
|
const scope = sha256Hex(vaultPath);
|
||||||
|
return path.join(runtimeDatabaseDirectory(databasePath, databaseName), scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runtimeDatabaseDirectory(databasePath: string, databaseName: string): string {
|
||||||
|
const database = sha256Hex(databaseName);
|
||||||
|
return path.join(databasePath, ".livesync", "runtime", "scopes", database);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeFileAtomically(filePath: string, content: string): void {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||||
|
try {
|
||||||
|
const descriptor = fs.openSync(temporaryPath, "wx", 0o600);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(descriptor, content, "utf-8");
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
fs.renameSync(temporaryPath, filePath);
|
||||||
|
syncDirectory(path.dirname(filePath));
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(temporaryPath, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDirectory(directory: string): void {
|
||||||
|
// Windows does not support opening a directory for fsync.
|
||||||
|
if (process.platform === "win32") return;
|
||||||
|
const descriptor = fs.openSync(directory, "r");
|
||||||
|
try {
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readOptionalFile(filePath: string): string | undefined {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(filePath, "utf-8");
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as { code?: string }).code === "ENOENT") return undefined;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isReconstructionCommand(command: CLICommand): command is "fetch" | "rebuild" {
|
||||||
|
return command === "fetch" || command === "rebuild";
|
||||||
|
}
|
||||||
|
|
||||||
|
const READ_COMMANDS = new Set<CLICommand>([
|
||||||
|
"cat",
|
||||||
|
"cat-rev",
|
||||||
|
"ls",
|
||||||
|
"info",
|
||||||
|
"remote-ls",
|
||||||
|
"remote-export",
|
||||||
|
"remote-status",
|
||||||
|
"p2p-peers",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isMutatingCommand(command: CLICommand): boolean {
|
||||||
|
return !READ_COMMANDS.has(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReconstructionMarker {
|
||||||
|
version: 1;
|
||||||
|
command: "fetch" | "rebuild";
|
||||||
|
databasePath: string;
|
||||||
|
vaultPath: string;
|
||||||
|
settingsPath: string;
|
||||||
|
settingsBeforeHash: string;
|
||||||
|
phase: "running" | "completed";
|
||||||
|
settingsText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashSettings(content: string | undefined): string {
|
||||||
|
return sha256Hex(JSON.stringify(content ?? null));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReconstructionState {
|
||||||
|
readonly markerPath: string;
|
||||||
|
private marker: ReconstructionMarker | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly databasePath: string,
|
||||||
|
private readonly vaultPath: string,
|
||||||
|
private readonly settingsPath: string
|
||||||
|
) {
|
||||||
|
this.markerPath = path.join(databasePath, ".livesync", "reconstruction.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare(command: CLICommand, force: boolean, writeSettings = false): void {
|
||||||
|
const contents = readOptionalFile(this.markerPath);
|
||||||
|
if (contents !== undefined) {
|
||||||
|
const parsed: unknown = JSON.parse(contents);
|
||||||
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||||
|
throw new Error(`Invalid reconstruction state: ${this.markerPath}. Preserve it for recovery.`);
|
||||||
|
}
|
||||||
|
const marker = parsed as ReconstructionMarker;
|
||||||
|
if (
|
||||||
|
marker.version !== 1 ||
|
||||||
|
!isReconstructionCommand(marker.command) ||
|
||||||
|
(marker.phase !== "running" && marker.phase !== "completed") ||
|
||||||
|
typeof marker.settingsPath !== "string" ||
|
||||||
|
typeof marker.vaultPath !== "string" ||
|
||||||
|
typeof marker.settingsBeforeHash !== "string" ||
|
||||||
|
marker.databasePath !== this.databasePath ||
|
||||||
|
(marker.phase === "completed" && typeof marker.settingsText !== "string")
|
||||||
|
) {
|
||||||
|
throw new Error(`Invalid reconstruction state: ${this.markerPath}. Preserve it for recovery.`);
|
||||||
|
}
|
||||||
|
this.marker = marker;
|
||||||
|
if (marker.phase === "completed") {
|
||||||
|
this.commit();
|
||||||
|
} else if (
|
||||||
|
(isMutatingCommand(command) || writeSettings) &&
|
||||||
|
!(
|
||||||
|
command === marker.command &&
|
||||||
|
force &&
|
||||||
|
marker.vaultPath === this.vaultPath &&
|
||||||
|
marker.settingsPath === this.settingsPath
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`An interrupted ${marker.command} requires recovery. Retry ${marker.command} --force with the same database, Vault, and settings paths. State: ${this.markerPath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isReconstructionCommand(command)) {
|
||||||
|
this.marker = {
|
||||||
|
version: 1,
|
||||||
|
command,
|
||||||
|
databasePath: this.databasePath,
|
||||||
|
vaultPath: this.vaultPath,
|
||||||
|
settingsPath: this.settingsPath,
|
||||||
|
phase: "running",
|
||||||
|
settingsBeforeHash: hashSettings(readOptionalFile(this.settingsPath)),
|
||||||
|
};
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
complete(settingsText: string): void {
|
||||||
|
if (!this.marker || this.marker.phase !== "running") throw new Error("Reconstruction was not started.");
|
||||||
|
this.marker = { ...this.marker, phase: "completed", settingsText };
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
commit(): void {
|
||||||
|
const marker = this.marker;
|
||||||
|
if (!marker || marker.phase !== "completed" || marker.settingsText === undefined) {
|
||||||
|
throw new Error("Reconstruction has no completed settings update.");
|
||||||
|
}
|
||||||
|
const current = readOptionalFile(marker.settingsPath);
|
||||||
|
if (current !== marker.settingsText && hashSettings(current) !== marker.settingsBeforeHash) {
|
||||||
|
throw new Error(
|
||||||
|
`The settings file changed after reconstruction: ${marker.settingsPath}. Preserve the reconstruction state and review both settings versions before continuing.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (current !== marker.settingsText) writeFileAtomically(marker.settingsPath, marker.settingsText);
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
fs.rmSync(this.markerPath, { force: true });
|
||||||
|
syncDirectory(path.dirname(this.markerPath));
|
||||||
|
this.marker = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private save(): void {
|
||||||
|
writeFileAtomically(this.markerPath, JSON.stringify(this.marker, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegacyStateIssue {
|
||||||
|
path: string;
|
||||||
|
reason: "unowned" | "invalid" | "unreadable";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inspectLegacyState(databasePath: string, vaultPath: string): LegacyStateIssue[] {
|
||||||
|
const issues: LegacyStateIssue[] = [];
|
||||||
|
for (const [filePath, expectArray] of [
|
||||||
|
[path.join(databasePath, ".livesync", "runtime", "keyvalue-db.json"), false],
|
||||||
|
[path.join(vaultPath, ".livesync-snapshot.json"), true],
|
||||||
|
] as const) {
|
||||||
|
let contents: string | undefined;
|
||||||
|
try {
|
||||||
|
contents = readOptionalFile(filePath);
|
||||||
|
} catch {
|
||||||
|
issues.push({ path: filePath, reason: "unreadable" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (contents === undefined) continue;
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(contents);
|
||||||
|
} catch {
|
||||||
|
issues.push({ path: filePath, reason: "invalid" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (value === null || typeof value !== "object" || Array.isArray(value) !== expectArray) {
|
||||||
|
issues.push({ path: filePath, reason: "invalid" });
|
||||||
|
} else if (Object.keys(value).length > 0) {
|
||||||
|
issues.push({ path: filePath, reason: "unowned" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
ReconstructionState,
|
||||||
|
inspectLegacyState,
|
||||||
|
readOptionalFile,
|
||||||
|
runtimeDatabaseDirectory,
|
||||||
|
runtimeScopeDirectory,
|
||||||
|
} from "./runtimeState";
|
||||||
|
|
||||||
|
describe("CLI runtime scope compatibility", () => {
|
||||||
|
it("retains SHA-256 directory names for database and Unicode Vault paths", () => {
|
||||||
|
expect(runtimeScopeDirectory("state", "headless-app", "/vault/日本語😀")).toBe(
|
||||||
|
path.join(
|
||||||
|
"state",
|
||||||
|
".livesync",
|
||||||
|
"runtime",
|
||||||
|
"scopes",
|
||||||
|
"511afda5613706862fa4add801b3f9b19e0db2acacc07b9218f2d01ff41ca61d",
|
||||||
|
"542d31d6918ad8bf915e204e41e479fdbd7251690f182fd802378323b4c6de23"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(path.basename(runtimeDatabaseDirectory("state", ""))).toBe(
|
||||||
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CLI reconstruction state", () => {
|
||||||
|
let directory: string;
|
||||||
|
let settingsPath: string;
|
||||||
|
let state: ReconstructionState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
directory = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-reconstruction-"));
|
||||||
|
settingsPath = path.join(directory, "settings.json");
|
||||||
|
fs.writeFileSync(settingsPath, JSON.stringify({ additionalSuffixOfDatabaseName: "original" }));
|
||||||
|
state = new ReconstructionState(directory, path.join(directory, "vault"), settingsPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it("retains the persisted settings fingerprint across hash implementations", () => {
|
||||||
|
state.prepare("fetch", true);
|
||||||
|
expect(JSON.parse(fs.readFileSync(state.markerPath, "utf-8"))).toMatchObject({
|
||||||
|
settingsBeforeHash: "29e04e3dd1d10b352678a922ff5a03fbe529d3505e9cf46c2ecc860f44478ae8",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists progress before returning and permits only the matching forced retry", () => {
|
||||||
|
state.prepare("fetch", true);
|
||||||
|
expect(JSON.parse(fs.readFileSync(state.markerPath, "utf-8"))).toMatchObject({
|
||||||
|
phase: "running",
|
||||||
|
command: "fetch",
|
||||||
|
});
|
||||||
|
if (process.platform !== "win32") expect(fs.statSync(state.markerPath).mode & 0o777).toBe(0o600);
|
||||||
|
expect(() => state.prepare("sync", false)).toThrow("interrupted fetch");
|
||||||
|
expect(() => state.prepare("ls", false, true)).toThrow("interrupted fetch");
|
||||||
|
expect(() => state.prepare("fetch", false)).toThrow("interrupted fetch");
|
||||||
|
expect(() => state.prepare("rebuild", true)).toThrow("interrupted fetch");
|
||||||
|
expect(() =>
|
||||||
|
new ReconstructionState(directory, path.join(directory, "other"), settingsPath).prepare("fetch", true)
|
||||||
|
).toThrow("interrupted fetch");
|
||||||
|
expect(() =>
|
||||||
|
new ReconstructionState(
|
||||||
|
directory,
|
||||||
|
path.join(directory, "vault"),
|
||||||
|
path.join(directory, "other.json")
|
||||||
|
).prepare("fetch", true)
|
||||||
|
).toThrow("interrupted fetch");
|
||||||
|
expect(() => state.prepare("ls", false)).not.toThrow();
|
||||||
|
expect(() => state.prepare("fetch", true)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers completed settings before permitting another command", () => {
|
||||||
|
state.prepare("fetch", true);
|
||||||
|
const completed = JSON.stringify({ additionalSuffixOfDatabaseName: "headless-app", retained: true });
|
||||||
|
state.complete(completed);
|
||||||
|
|
||||||
|
new ReconstructionState(directory, path.join(directory, "vault"), settingsPath).prepare("sync", false);
|
||||||
|
|
||||||
|
expect(fs.readFileSync(settingsPath, "utf-8")).toBe(completed);
|
||||||
|
expect(readOptionalFile(state.markerPath)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["normal completion", "restart"] as const)(
|
||||||
|
"preserves user settings changed during reconstruction at %s",
|
||||||
|
(phase) => {
|
||||||
|
state.prepare("rebuild", true);
|
||||||
|
state.complete(JSON.stringify({ additionalSuffixOfDatabaseName: "headless-app" }));
|
||||||
|
const edited = JSON.stringify({ additionalSuffixOfDatabaseName: "user-selection", unique: "edit" });
|
||||||
|
fs.writeFileSync(settingsPath, edited);
|
||||||
|
|
||||||
|
const commit =
|
||||||
|
phase === "restart"
|
||||||
|
? () =>
|
||||||
|
new ReconstructionState(directory, path.join(directory, "vault"), settingsPath).prepare(
|
||||||
|
"sync",
|
||||||
|
false
|
||||||
|
)
|
||||||
|
: () => state.commit();
|
||||||
|
|
||||||
|
expect(commit).toThrow("settings file changed");
|
||||||
|
expect(fs.readFileSync(settingsPath, "utf-8")).toBe(edited);
|
||||||
|
expect(JSON.parse(fs.readFileSync(state.markerPath, "utf-8"))).toMatchObject({ phase: "completed" });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("clears a completed marker after an interruption following the settings rename", () => {
|
||||||
|
state.prepare("fetch", true);
|
||||||
|
const completed = JSON.stringify({ additionalSuffixOfDatabaseName: "headless-app" });
|
||||||
|
state.complete(completed);
|
||||||
|
fs.writeFileSync(settingsPath, completed);
|
||||||
|
state.prepare("ls", false);
|
||||||
|
expect(readOptionalFile(state.markerPath)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["null", "[]", "{}", '{"version":2}', '{"version":1,"phase":"completed"}'])(
|
||||||
|
"refuses malformed state %s without clearing it",
|
||||||
|
(contents) => {
|
||||||
|
fs.mkdirSync(path.dirname(state.markerPath), { recursive: true });
|
||||||
|
fs.writeFileSync(state.markerPath, contents);
|
||||||
|
expect(() => state.prepare("sync", false)).toThrow("Invalid reconstruction state");
|
||||||
|
expect(fs.readFileSync(state.markerPath, "utf-8")).toBe(contents);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["null", "[]", '"text"', "{", ""])("reports invalid legacy KV %s without changing it", (contents) => {
|
||||||
|
const legacy = path.join(directory, ".livesync/runtime/keyvalue-db.json");
|
||||||
|
fs.mkdirSync(path.dirname(legacy), { recursive: true });
|
||||||
|
fs.writeFileSync(legacy, contents);
|
||||||
|
expect(inspectLegacyState(directory, directory)).toEqual([{ path: legacy, reason: "invalid" }]);
|
||||||
|
expect(fs.readFileSync(legacy, "utf-8")).toBe(contents);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports both unreadable KV and invalid snapshot instead of hiding the second problem", () => {
|
||||||
|
const legacy = path.join(directory, ".livesync/runtime/keyvalue-db.json");
|
||||||
|
const snapshot = path.join(directory, ".livesync-snapshot.json");
|
||||||
|
fs.mkdirSync(legacy, { recursive: true });
|
||||||
|
fs.writeFileSync(snapshot, "{}");
|
||||||
|
expect(inspectLegacyState(directory, directory)).toEqual([
|
||||||
|
{ path: legacy, reason: "unreadable" },
|
||||||
|
{ path: snapshot, reason: "invalid" },
|
||||||
|
]);
|
||||||
|
expect(fs.statSync(legacy).isDirectory()).toBe(true);
|
||||||
|
expect(fs.readFileSync(snapshot, "utf-8")).toBe("{}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves non-empty unowned legacy state and ignores empty legacy containers", () => {
|
||||||
|
const legacy = path.join(directory, ".livesync", "runtime", "keyvalue-db.json");
|
||||||
|
fs.mkdirSync(path.dirname(legacy), { recursive: true });
|
||||||
|
fs.writeFileSync(legacy, "{}");
|
||||||
|
fs.writeFileSync(path.join(directory, ".livesync-snapshot.json"), "[]");
|
||||||
|
expect(inspectLegacyState(directory, directory)).toEqual([]);
|
||||||
|
const queued = JSON.stringify({ "queued-files": ["unique-edit.md"] });
|
||||||
|
fs.writeFileSync(legacy, queued);
|
||||||
|
expect(inspectLegacyState(directory, directory)).toEqual([{ path: legacy, reason: "unowned" }]);
|
||||||
|
expect(fs.readFileSync(legacy, "utf-8")).toBe(queued);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||||
import { ServiceRebuilder } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
|
|
||||||
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
|
|
||||||
import { StorageAccessManager } from "@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager";
|
import { StorageAccessManager } from "@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager";
|
||||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
@@ -10,7 +8,7 @@ import { ServiceDatabaseFileAccessCLI } from "./DatabaseFileAccess";
|
|||||||
import { StorageEventManagerCLI } from "@/apps/cli/managers/StorageEventManagerCLI";
|
import { StorageEventManagerCLI } from "@/apps/cli/managers/StorageEventManagerCLI";
|
||||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||||
import type { IgnoreRules } from "./IgnoreRules";
|
import type { IgnoreRules } from "./IgnoreRules";
|
||||||
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
|
import { composeSharedServiceModules } from "@/apps/serviceModules/composeSharedServiceModules";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize service modules for CLI version
|
* Initialize service modules for CLI version
|
||||||
@@ -82,40 +80,7 @@ export function initialiseServiceModulesCLI(
|
|||||||
vault: services.vault,
|
vault: services.vault,
|
||||||
});
|
});
|
||||||
|
|
||||||
// File handler (platform-independent)
|
const { fileHandler, rebuilder } = composeSharedServiceModules(services, databaseFileAccess, storageAccess);
|
||||||
const fileHandler = new ServiceFileHandler({
|
|
||||||
events: services.context.events,
|
|
||||||
API: services.API,
|
|
||||||
databaseFileAccess: databaseFileAccess,
|
|
||||||
conflict: services.conflict,
|
|
||||||
setting: services.setting,
|
|
||||||
fileProcessing: services.fileProcessing,
|
|
||||||
vault: services.vault,
|
|
||||||
path: services.path,
|
|
||||||
replication: services.replication,
|
|
||||||
storageAccess: storageAccess,
|
|
||||||
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rebuilder (platform-independent)
|
|
||||||
const rebuilder = new ServiceRebuilder({
|
|
||||||
path: services.path,
|
|
||||||
events: services.context.events,
|
|
||||||
API: services.API,
|
|
||||||
database: services.database,
|
|
||||||
appLifecycle: services.appLifecycle,
|
|
||||||
setting: services.setting,
|
|
||||||
remote: services.remote,
|
|
||||||
databaseEvents: services.databaseEvents,
|
|
||||||
replication: services.replication,
|
|
||||||
replicator: services.replicator,
|
|
||||||
UI: services.UI,
|
|
||||||
vault: services.vault,
|
|
||||||
fileHandler: fileHandler,
|
|
||||||
fileProcessing: services.fileProcessing,
|
|
||||||
storageAccess: storageAccess,
|
|
||||||
control: services.control,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rebuilder,
|
rebuilder,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { IVaultService } from "@vrtmrz/livesync-commonlib/compat/services/b
|
|||||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||||
import { fs as nodeFs, path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
import { fs as nodeFs, path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
||||||
|
import { readOptionalFile, writeFileAtomically } from "@/apps/cli/runtimeState";
|
||||||
|
|
||||||
const NODE_KV_TYPED_KEY = "__nodeKvType";
|
const NODE_KV_TYPED_KEY = "__nodeKvType";
|
||||||
const NODE_KV_VALUES_KEY = "values";
|
const NODE_KV_VALUES_KEY = "values";
|
||||||
@@ -103,12 +104,11 @@ class NodeFileKeyValueDatabase implements KeyValueDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private load() {
|
private load() {
|
||||||
try {
|
const contents = readOptionalFile(this.filePath);
|
||||||
const loaded = JSON.parse(nodeFs.readFileSync(this.filePath, "utf-8")) as Record<string, unknown>;
|
if (contents === undefined) return;
|
||||||
this.data = new Map(Object.entries(loaded).map(([key, value]) => [key, deserializeFromNodeKV(value)]));
|
const loaded: unknown = JSON.parse(contents);
|
||||||
} catch {
|
if (!isRecord(loaded) || Array.isArray(loaded)) throw new Error("Invalid Node key-value database");
|
||||||
this.data = new Map();
|
this.data = new Map(Object.entries(loaded).map(([key, value]) => [key, deserializeFromNodeKV(value)]));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private flush() {
|
private flush() {
|
||||||
@@ -116,7 +116,7 @@ class NodeFileKeyValueDatabase implements KeyValueDatabase {
|
|||||||
const serializable = Object.fromEntries(
|
const serializable = Object.fromEntries(
|
||||||
[...this.data.entries()].map(([key, value]) => [key, serializeForNodeKV(value)])
|
[...this.data.entries()].map(([key, value]) => [key, serializeForNodeKV(value)])
|
||||||
);
|
);
|
||||||
nodeFs.writeFileSync(this.filePath, JSON.stringify(serializable, null, 2), "utf-8");
|
writeFileAtomically(this.filePath, JSON.stringify(serializable, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
async get<T>(key: IDBValidKey): Promise<T> {
|
async get<T>(key: IDBValidKey): Promise<T> {
|
||||||
@@ -163,9 +163,7 @@ class NodeFileKeyValueDatabase implements KeyValueDatabase {
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {}
|
||||||
this.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroy(): Promise<void> {
|
async destroy(): Promise<void> {
|
||||||
this.data.clear();
|
this.data.clear();
|
||||||
@@ -185,7 +183,8 @@ export class NodeKeyValueDBService<T extends ServiceContext = ServiceContext>
|
|||||||
{
|
{
|
||||||
private _kvDB: KeyValueDatabase | undefined;
|
private _kvDB: KeyValueDatabase | undefined;
|
||||||
private _simpleStore: SimpleStore<unknown> | undefined;
|
private _simpleStore: SimpleStore<unknown> | undefined;
|
||||||
private filePath: string;
|
private filePath: string | (() => string);
|
||||||
|
private openedFilePath: string | undefined;
|
||||||
private _log = createInstanceLogFunction("NodeKeyValueDBService");
|
private _log = createInstanceLogFunction("NodeKeyValueDBService");
|
||||||
|
|
||||||
get simpleStore() {
|
get simpleStore() {
|
||||||
@@ -202,7 +201,7 @@ export class NodeKeyValueDBService<T extends ServiceContext = ServiceContext>
|
|||||||
return this._kvDB;
|
return this._kvDB;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(context: T, dependencies: NodeKeyValueDBDependencies<T>, filePath: string) {
|
constructor(context: T, dependencies: NodeKeyValueDBDependencies<T>, filePath: string | (() => string)) {
|
||||||
super(context);
|
super(context);
|
||||||
this.filePath = filePath;
|
this.filePath = filePath;
|
||||||
|
|
||||||
@@ -215,7 +214,13 @@ export class NodeKeyValueDBService<T extends ServiceContext = ServiceContext>
|
|||||||
|
|
||||||
private async openKeyValueDB(): Promise<boolean> {
|
private async openKeyValueDB(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
this._kvDB = new NodeFileKeyValueDatabase(this.filePath);
|
const filePath = typeof this.filePath === "string" ? this.filePath : this.filePath();
|
||||||
|
if (this._kvDB && this.openedFilePath === filePath) return true;
|
||||||
|
await this._kvDB?.close();
|
||||||
|
this._kvDB = undefined;
|
||||||
|
this.openedFilePath = undefined;
|
||||||
|
this._kvDB = new NodeFileKeyValueDatabase(filePath);
|
||||||
|
this.openedFilePath = filePath;
|
||||||
return true;
|
return true;
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
this._log("Failed to open Node key-value database", LOG_LEVEL_NOTICE);
|
this._log("Failed to open Node key-value database", LOG_LEVEL_NOTICE);
|
||||||
@@ -226,8 +231,10 @@ export class NodeKeyValueDBService<T extends ServiceContext = ServiceContext>
|
|||||||
|
|
||||||
private async _everyOnResetDatabase(): Promise<boolean> {
|
private async _everyOnResetDatabase(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await this._kvDB?.del("queued-files");
|
if (!(await this.openKeyValueDB())) return false;
|
||||||
await this._kvDB?.destroy();
|
await this._kvDB?.destroy();
|
||||||
|
this._kvDB = undefined;
|
||||||
|
this.openedFilePath = undefined;
|
||||||
return await this.openKeyValueDB();
|
return await this.openKeyValueDB();
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
this._log("Failed to reset Node key-value database", LOG_LEVEL_NOTICE);
|
this._log("Failed to reset Node key-value database", LOG_LEVEL_NOTICE);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
|
import { createServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
|
||||||
import type { NodeKeyValueDBDependencies } from "./NodeKeyValueDBService";
|
import type { NodeKeyValueDBDependencies } from "./NodeKeyValueDBService";
|
||||||
import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
|
import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
|
||||||
@@ -45,3 +46,70 @@ describe("NodeKeyValueDBService.openSimpleStore", () => {
|
|||||||
await expect(store.get("key")).rejects.toThrow("KeyValueDB is not initialized yet");
|
await expect(store.get("key")).rejects.toThrow("KeyValueDB is not initialized yet");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("NodeKeyValueDBService persistence", () => {
|
||||||
|
let directory: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
directory = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-kv-"));
|
||||||
|
});
|
||||||
|
afterEach(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
function compose(filePath: string | (() => string)) {
|
||||||
|
const handlers: Record<string, () => Promise<boolean>> = {};
|
||||||
|
const event = (name: string) => ({
|
||||||
|
addHandler: (handler: () => Promise<boolean>) => {
|
||||||
|
handlers[name] = handler;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const dependencies = {
|
||||||
|
appLifecycle: { onSettingLoaded: event("load") },
|
||||||
|
databaseEvents: {
|
||||||
|
onResetDatabase: event("reset"),
|
||||||
|
onDatabaseInitialisation: event("initialise"),
|
||||||
|
onUnloadDatabase: event("unload"),
|
||||||
|
onCloseDatabase: event("close"),
|
||||||
|
},
|
||||||
|
vault: {},
|
||||||
|
} as unknown as NodeKeyValueDBDependencies;
|
||||||
|
return { service: new NodeKeyValueDBService(createServiceContext(), dependencies, filePath), handlers };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("retargets existing SimpleStore handles after database selection and resets only the selected file", async () => {
|
||||||
|
let selected = path.join(directory, "first.json");
|
||||||
|
const { service, handlers } = compose(() => selected);
|
||||||
|
const store = service.openSimpleStore("journal");
|
||||||
|
expect(await handlers.load()).toBe(true);
|
||||||
|
await store.set("checkpoint", new Set(["first"]));
|
||||||
|
selected = path.join(directory, "second.json");
|
||||||
|
expect(await handlers.initialise()).toBe(true);
|
||||||
|
expect(await store.get("checkpoint")).toBeUndefined();
|
||||||
|
await store.set("checkpoint", "second");
|
||||||
|
expect(await handlers.reset()).toBe(true);
|
||||||
|
expect(await store.get("checkpoint")).toBeUndefined();
|
||||||
|
selected = path.join(directory, "first.json");
|
||||||
|
expect(await handlers.initialise()).toBe(true);
|
||||||
|
expect(await store.get("checkpoint")).toEqual(new Set(["first"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overwrite newer data when a stale handle closes", async () => {
|
||||||
|
const filePath = path.join(directory, "state.json");
|
||||||
|
const first = compose(filePath);
|
||||||
|
await first.handlers.load();
|
||||||
|
await first.service.kvDB.set("queued-files", ["original.md"]);
|
||||||
|
const second = compose(filePath);
|
||||||
|
await second.handlers.load();
|
||||||
|
await first.service.kvDB.set("queued-files", ["unique-edit.md"]);
|
||||||
|
await second.handlers.close();
|
||||||
|
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))["queued-files"]).toEqual(["unique-edit.md"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["{", "null", "[]"])("refuses corrupt state %s without replacing it", async (contents) => {
|
||||||
|
const filePath = path.join(directory, "state.json");
|
||||||
|
fs.writeFileSync(filePath, contents);
|
||||||
|
const { service, handlers } = compose(filePath);
|
||||||
|
expect(await handlers.load()).toBe(false);
|
||||||
|
expect(() => service.kvDB).toThrow("not initialized");
|
||||||
|
expect(fs.readFileSync(filePath, "utf-8")).toBe(contents);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,8 @@ import { ServiceContext, type StandardIo } from "@vrtmrz/livesync-commonlib/cont
|
|||||||
export class NodeServiceContext extends ServiceContext {
|
export class NodeServiceContext extends ServiceContext {
|
||||||
constructor(
|
constructor(
|
||||||
readonly databasePath: string,
|
readonly databasePath: string,
|
||||||
readonly standardIo: StandardIo
|
readonly standardIo: StandardIo,
|
||||||
|
readonly runtimeVaultPath: string = databasePath
|
||||||
) {
|
) {
|
||||||
super({ events: eventHub, translate: translateLiveSyncMessage });
|
super({ events: eventHub, translate: translateLiveSyncMessage });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,14 @@ import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
|
|||||||
import { createNodeLocalStorage } from "./NodeLocalStorage";
|
import { createNodeLocalStorage } from "./NodeLocalStorage";
|
||||||
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
|
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
|
||||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import { path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
import { fs, path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
||||||
import type { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
|
import type { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
|
||||||
import { PouchDB } from "@/apps/cli/lib/pouchdb-node";
|
import { PouchDB } from "@/apps/cli/lib/pouchdb-node";
|
||||||
import { NodeServiceContext } from "./NodeServiceContext";
|
import { NodeServiceContext } from "./NodeServiceContext";
|
||||||
import { setLang } from "@/common/translation";
|
import { setLang } from "@/common/translation";
|
||||||
|
import { runtimeDatabaseDirectory, runtimeScopeDirectory } from "@/apps/cli/runtimeState";
|
||||||
|
import { createDeletableValueStore } from "@vrtmrz/value-store";
|
||||||
|
import type { SettingServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
|
||||||
|
|
||||||
export { NodeServiceContext } from "./NodeServiceContext";
|
export { NodeServiceContext } from "./NodeServiceContext";
|
||||||
|
|
||||||
@@ -86,17 +89,31 @@ class NodeUIService<T extends ServiceContext> extends UIService<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class NodeSettingService<T extends NodeServiceContext> extends InjectableSettingService<T> {
|
||||||
|
declare readonly config: InjectableSettingService<T>["config"];
|
||||||
|
|
||||||
|
constructor(context: T, dependencies: SettingServiceDependencies) {
|
||||||
|
super(context, dependencies);
|
||||||
|
const databaseConfig = this.config;
|
||||||
|
const vaultScope = nodePath.basename(runtimeScopeDirectory(context.databasePath, "", context.runtimeVaultPath));
|
||||||
|
this.config = createDeletableValueStore({
|
||||||
|
get: (key) => databaseConfig.getValue(`${key}-${vaultScope}`),
|
||||||
|
set: (key, value) => databaseConfig.setValue(`${key}-${vaultScope}`, value),
|
||||||
|
delete: (key) => databaseConfig.delete(`${key}-${vaultScope}`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServiceHub<T> {
|
export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServiceHub<T> {
|
||||||
constructor(basePath: string, context: T) {
|
constructor(basePath: string, context: T) {
|
||||||
const runtimeDir = nodePath.join(basePath, ".livesync", "runtime");
|
const runtimeDir = nodePath.join(basePath, ".livesync", "runtime");
|
||||||
const localStoragePath = nodePath.join(runtimeDir, "local-storage.json");
|
const localStoragePath = nodePath.join(runtimeDir, "local-storage.json");
|
||||||
const keyValueDBPath = nodePath.join(runtimeDir, "keyvalue-db.json");
|
|
||||||
|
|
||||||
const API = new HeadlessAPIService<T>(context);
|
const API = new HeadlessAPIService<T>(context);
|
||||||
const conflict = new InjectableConflictService(context);
|
const conflict = new InjectableConflictService(context);
|
||||||
const fileProcessing = new InjectableFileProcessingService(context);
|
const fileProcessing = new InjectableFileProcessingService(context);
|
||||||
|
|
||||||
const setting = new InjectableSettingService<T>(context, {
|
const setting = new NodeSettingService<T>(context, {
|
||||||
APIService: API,
|
APIService: API,
|
||||||
onDisplayLanguageChanged: setLang,
|
onDisplayLanguageChanged: setLang,
|
||||||
deviceValues: createNodeLocalStorage(localStoragePath),
|
deviceValues: createNodeLocalStorage(localStoragePath),
|
||||||
@@ -118,8 +135,13 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
|
|||||||
settingService: setting,
|
settingService: setting,
|
||||||
APIService: API,
|
APIService: API,
|
||||||
});
|
});
|
||||||
|
const scopeDirectory = () => runtimeScopeDirectory(basePath, vault.getVaultName(), context.runtimeVaultPath);
|
||||||
const test = new InjectableTestService(context);
|
const test = new InjectableTestService(context);
|
||||||
const databaseEvents = new InjectableDatabaseEventService(context);
|
const databaseEvents = new InjectableDatabaseEventService(context);
|
||||||
|
databaseEvents.onResetDatabase.addHandler(async () => {
|
||||||
|
fs.rmSync(runtimeDatabaseDirectory(basePath, vault.getVaultName()), { force: true, recursive: true });
|
||||||
|
return true;
|
||||||
|
});
|
||||||
const path = new PathServiceCompat(context, {
|
const path = new PathServiceCompat(context, {
|
||||||
settingService: setting,
|
settingService: setting,
|
||||||
});
|
});
|
||||||
@@ -156,7 +178,7 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
|
|||||||
databaseEvents,
|
databaseEvents,
|
||||||
vault,
|
vault,
|
||||||
},
|
},
|
||||||
keyValueDBPath
|
() => nodePath.join(scopeDirectory(), "keyvalue-db.json")
|
||||||
);
|
);
|
||||||
|
|
||||||
const control = new ControlService(context, {
|
const control = new ControlService(context, {
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
import { defaultServerConditions, defaultServerMainFields, defineConfig } from "vite";
|
import { defaultServerConditions, defaultServerMainFields, defineConfig } from "vite";
|
||||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||||
import { fileURLToPath, fs, isBuiltin, path } from "@vrtmrz/livesync-commonlib/node";
|
import { fileURLToPath, isBuiltin, path } from "@vrtmrz/livesync-commonlib/node";
|
||||||
|
import { readVersion } from "../readVersion";
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const resolve = (...args: string[]) => path.resolve(...args).replace(/\\/g, "/");
|
const resolve = (...args: string[]) => path.resolve(...args).replace(/\\/g, "/");
|
||||||
const repoRoot = path.resolve(__dirname, "../../..");
|
const repoRoot = path.resolve(__dirname, "../../..");
|
||||||
|
|
||||||
function readVersion(filePath: string): string | undefined {
|
|
||||||
const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
||||||
if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return typeof parsed.version === "string" ? parsed.version : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const packageVersion = readVersion(path.resolve(repoRoot, "package.json"));
|
const packageVersion = readVersion(path.resolve(repoRoot, "package.json"));
|
||||||
const manifestVersion = readVersion(path.resolve(repoRoot, "manifest.json"));
|
const manifestVersion = readVersion(path.resolve(repoRoot, "manifest.json"));
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { fs } from "@vrtmrz/livesync-commonlib/node";
|
||||||
|
|
||||||
|
/** Read a string-valued version field from a JSON file during Node builds. */
|
||||||
|
export function readVersion(filePath: string): string | undefined {
|
||||||
|
const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||||
|
if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return typeof parsed.version === "string" ? parsed.version : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { readVersion } from "./readVersion";
|
||||||
|
|
||||||
|
describe("readVersion", () => {
|
||||||
|
let directory: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
directory = mkdtempSync(join(tmpdir(), "livesync-read-version-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only string version values", () => {
|
||||||
|
const filePath = join(directory, "version.json");
|
||||||
|
writeFileSync(filePath, JSON.stringify({ version: "1.2.3" }));
|
||||||
|
expect(readVersion(filePath)).toBe("1.2.3");
|
||||||
|
|
||||||
|
for (const contents of ["null", "[]", "{}", '{"version": 42}', '{"version": null}']) {
|
||||||
|
writeFileSync(filePath, contents);
|
||||||
|
expect(readVersion(filePath)).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves file read and JSON parse errors", () => {
|
||||||
|
expect(() => readVersion(join(directory, "missing.json"))).toThrow();
|
||||||
|
|
||||||
|
const filePath = join(directory, "invalid.json");
|
||||||
|
writeFileSync(filePath, "not JSON");
|
||||||
|
expect(() => readVersion(filePath)).toThrow(SyntaxError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||||
|
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
|
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
|
||||||
|
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||||
|
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
|
||||||
|
import { ServiceRebuilder } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
|
||||||
|
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
|
||||||
|
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
|
||||||
|
|
||||||
|
/** Compose the service modules shared by app hosts after their storage setup. */
|
||||||
|
export function composeSharedServiceModules(
|
||||||
|
services: InjectableServiceHub<ServiceContext>,
|
||||||
|
databaseFileAccess: DatabaseFileAccess,
|
||||||
|
storageAccess: StorageAccess
|
||||||
|
): Pick<ServiceModules, "fileHandler" | "rebuilder"> {
|
||||||
|
const fileHandler = new ServiceFileHandler({
|
||||||
|
events: services.context.events,
|
||||||
|
API: services.API,
|
||||||
|
databaseFileAccess,
|
||||||
|
conflict: services.conflict,
|
||||||
|
setting: services.setting,
|
||||||
|
fileProcessing: services.fileProcessing,
|
||||||
|
vault: services.vault,
|
||||||
|
path: services.path,
|
||||||
|
replication: services.replication,
|
||||||
|
storageAccess,
|
||||||
|
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rebuilder = new ServiceRebuilder({
|
||||||
|
path: services.path,
|
||||||
|
events: services.context.events,
|
||||||
|
API: services.API,
|
||||||
|
database: services.database,
|
||||||
|
appLifecycle: services.appLifecycle,
|
||||||
|
setting: services.setting,
|
||||||
|
remote: services.remote,
|
||||||
|
databaseEvents: services.databaseEvents,
|
||||||
|
replication: services.replication,
|
||||||
|
replicator: services.replicator,
|
||||||
|
UI: services.UI,
|
||||||
|
vault: services.vault,
|
||||||
|
fileHandler,
|
||||||
|
fileProcessing: services.fileProcessing,
|
||||||
|
storageAccess,
|
||||||
|
control: services.control,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { rebuilder, fileHandler };
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||||
|
import { initialiseServiceModulesCLI } from "@/apps/cli/serviceModules/CLIServiceModules";
|
||||||
|
import { initialiseServiceModulesFSAPI } from "@/apps/webapp/serviceModules/FSAPIServiceModules";
|
||||||
|
import { FILE_REFLECTION_PROVENANCE_STORE } from "@/serviceModules/FileReflectionProvenance";
|
||||||
|
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
|
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||||
|
|
||||||
|
const composition = vi.hoisted(() => ({
|
||||||
|
calls: [] as string[],
|
||||||
|
dependencies: {} as Record<string, unknown>,
|
||||||
|
instances: {} as Record<string, unknown>,
|
||||||
|
capture(name: string, dependencies: unknown, instance: unknown) {
|
||||||
|
this.calls.push(name);
|
||||||
|
this.dependencies[name] = dependencies;
|
||||||
|
this.instances[name] = instance;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager", () => ({
|
||||||
|
StorageAccessManager: class {
|
||||||
|
constructor() {
|
||||||
|
composition.capture("StorageAccessManager", undefined, this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder", () => ({
|
||||||
|
ServiceRebuilder: class {
|
||||||
|
constructor(dependencies: unknown) {
|
||||||
|
composition.capture("ServiceRebuilder", dependencies, this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/serviceModules/FileHandler", () => ({
|
||||||
|
ServiceFileHandler: class {
|
||||||
|
constructor(dependencies: unknown) {
|
||||||
|
composition.capture("ServiceFileHandler", dependencies, this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/apps/cli/serviceModules/FileAccessCLI", () => ({
|
||||||
|
FileAccessCLI: class {
|
||||||
|
constructor(...args: unknown[]) {
|
||||||
|
composition.capture("FileAccessCLI", args, this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/apps/cli/serviceModules/ServiceFileAccessImpl", () => ({
|
||||||
|
ServiceFileAccessCLI: class {
|
||||||
|
constructor(dependencies: unknown) {
|
||||||
|
composition.capture("ServiceFileAccessCLI", dependencies, this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/apps/cli/serviceModules/DatabaseFileAccess", () => ({
|
||||||
|
ServiceDatabaseFileAccessCLI: class {
|
||||||
|
constructor(dependencies: unknown) {
|
||||||
|
composition.capture("ServiceDatabaseFileAccessCLI", dependencies, this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/apps/cli/managers/StorageEventManagerCLI", () => ({
|
||||||
|
StorageEventManagerCLI: class {
|
||||||
|
close = vi.fn(async () => undefined);
|
||||||
|
|
||||||
|
constructor(...args: unknown[]) {
|
||||||
|
composition.capture("StorageEventManagerCLI", args, this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/apps/webapp/serviceModules/FileAccessFSAPI", () => ({
|
||||||
|
FileAccessFSAPI: class {
|
||||||
|
constructor(...args: unknown[]) {
|
||||||
|
composition.capture("FileAccessFSAPI", args, this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/apps/webapp/serviceModules/ServiceFileAccessImpl", () => ({
|
||||||
|
ServiceFileAccessFSAPI: class {
|
||||||
|
constructor(dependencies: unknown) {
|
||||||
|
composition.capture("ServiceFileAccessFSAPI", dependencies, this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/apps/webapp/serviceModules/DatabaseFileAccess", () => ({
|
||||||
|
ServiceDatabaseFileAccessFSAPI: class {
|
||||||
|
constructor(dependencies: unknown) {
|
||||||
|
composition.capture("ServiceDatabaseFileAccessFSAPI", dependencies, this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/apps/webapp/managers/StorageEventManagerFSAPI", () => ({
|
||||||
|
StorageEventManagerFSAPI: class {
|
||||||
|
constructor(...args: unknown[]) {
|
||||||
|
composition.capture("StorageEventManagerFSAPI", args, this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createServices() {
|
||||||
|
const hostServices = Object.fromEntries(
|
||||||
|
[
|
||||||
|
"API",
|
||||||
|
"conflict",
|
||||||
|
"control",
|
||||||
|
"database",
|
||||||
|
"databaseEvents",
|
||||||
|
"fileProcessing",
|
||||||
|
"path",
|
||||||
|
"remote",
|
||||||
|
"replication",
|
||||||
|
"replicator",
|
||||||
|
"setting",
|
||||||
|
"UI",
|
||||||
|
"vault",
|
||||||
|
].map((name) => [name, { name }])
|
||||||
|
);
|
||||||
|
const events = { label: "host event hub" };
|
||||||
|
const addUnloadHandler = vi.fn<(handler: () => Promise<boolean>) => void>();
|
||||||
|
const openSimpleStore = vi.fn((name: string) => {
|
||||||
|
composition.calls.push("openSimpleStore");
|
||||||
|
composition.dependencies.openSimpleStore = name;
|
||||||
|
return { name };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...hostServices,
|
||||||
|
appLifecycle: { onUnload: { addHandler: addUnloadHandler } },
|
||||||
|
context: { events },
|
||||||
|
events,
|
||||||
|
keyValueDB: { openSimpleStore },
|
||||||
|
addUnloadHandler,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCore() {
|
||||||
|
return {} as LiveSyncBaseCore<ServiceContext, never>;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
composition.calls.length = 0;
|
||||||
|
composition.dependencies = {};
|
||||||
|
composition.instances = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("app service module composition", () => {
|
||||||
|
it("preserves CLI host construction, shared wiring, provenance, and watcher shutdown", async () => {
|
||||||
|
const services = createServices();
|
||||||
|
const core = createCore();
|
||||||
|
|
||||||
|
const modules = initialiseServiceModulesCLI(
|
||||||
|
"/vault",
|
||||||
|
core,
|
||||||
|
services as unknown as InjectableServiceHub<ServiceContext>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(composition.calls).toEqual([
|
||||||
|
"StorageAccessManager",
|
||||||
|
"FileAccessCLI",
|
||||||
|
"StorageEventManagerCLI",
|
||||||
|
"ServiceFileAccessCLI",
|
||||||
|
"ServiceDatabaseFileAccessCLI",
|
||||||
|
"openSimpleStore",
|
||||||
|
"ServiceFileHandler",
|
||||||
|
"ServiceRebuilder",
|
||||||
|
]);
|
||||||
|
expect(composition.dependencies.openSimpleStore).toBe(FILE_REFLECTION_PROVENANCE_STORE);
|
||||||
|
|
||||||
|
const [basePath, fileAccessOptions] = composition.dependencies.FileAccessCLI as [
|
||||||
|
string,
|
||||||
|
Record<string, unknown>,
|
||||||
|
];
|
||||||
|
const [eventBasePath, eventCore, eventDependencies, ignoreRules, watchEnabled] = composition.dependencies
|
||||||
|
.StorageEventManagerCLI as [string, unknown, Record<string, unknown>, unknown, boolean];
|
||||||
|
const storageAccessDependencies = composition.dependencies.ServiceFileAccessCLI as Record<string, unknown>;
|
||||||
|
const databaseAccessDependencies = composition.dependencies.ServiceDatabaseFileAccessCLI as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const fileHandlerDependencies = composition.dependencies.ServiceFileHandler as Record<string, unknown>;
|
||||||
|
const rebuilderDependencies = composition.dependencies.ServiceRebuilder as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(basePath).toBe("/vault");
|
||||||
|
expect(fileAccessOptions).toEqual({
|
||||||
|
storageAccessManager: composition.instances.StorageAccessManager,
|
||||||
|
vaultService: services.vault,
|
||||||
|
settingService: services.setting,
|
||||||
|
APIService: services.API,
|
||||||
|
pathService: services.path,
|
||||||
|
});
|
||||||
|
expect(eventBasePath).toBe("/vault");
|
||||||
|
expect(eventCore).toBe(core);
|
||||||
|
expect(eventDependencies).toEqual({
|
||||||
|
fileProcessing: services.fileProcessing,
|
||||||
|
setting: services.setting,
|
||||||
|
vaultService: services.vault,
|
||||||
|
storageAccessManager: composition.instances.StorageAccessManager,
|
||||||
|
APIService: services.API,
|
||||||
|
});
|
||||||
|
expect(ignoreRules).toBeUndefined();
|
||||||
|
expect(watchEnabled).toBe(false);
|
||||||
|
expect(storageAccessDependencies).toEqual({
|
||||||
|
API: services.API,
|
||||||
|
setting: services.setting,
|
||||||
|
fileProcessing: services.fileProcessing,
|
||||||
|
vault: services.vault,
|
||||||
|
appLifecycle: services.appLifecycle,
|
||||||
|
storageEventManager: composition.instances.StorageEventManagerCLI,
|
||||||
|
storageAccessManager: composition.instances.StorageAccessManager,
|
||||||
|
vaultAccess: composition.instances.FileAccessCLI,
|
||||||
|
});
|
||||||
|
expect(databaseAccessDependencies).toEqual({
|
||||||
|
events: services.events,
|
||||||
|
API: services.API,
|
||||||
|
database: services.database,
|
||||||
|
path: services.path,
|
||||||
|
storageAccess: modules.storageAccess,
|
||||||
|
vault: services.vault,
|
||||||
|
});
|
||||||
|
expect(modules.databaseFileAccess).toBe(composition.instances.ServiceDatabaseFileAccessCLI);
|
||||||
|
expect(modules.fileHandler).toBe(composition.instances.ServiceFileHandler);
|
||||||
|
expect(modules.rebuilder).toBe(composition.instances.ServiceRebuilder);
|
||||||
|
expect(fileHandlerDependencies).toEqual({
|
||||||
|
events: services.events,
|
||||||
|
API: services.API,
|
||||||
|
databaseFileAccess: modules.databaseFileAccess,
|
||||||
|
conflict: services.conflict,
|
||||||
|
setting: services.setting,
|
||||||
|
fileProcessing: services.fileProcessing,
|
||||||
|
vault: services.vault,
|
||||||
|
path: services.path,
|
||||||
|
replication: services.replication,
|
||||||
|
storageAccess: modules.storageAccess,
|
||||||
|
fileReflectionProvenance: expect.any(Object),
|
||||||
|
});
|
||||||
|
expect(rebuilderDependencies).toEqual({
|
||||||
|
path: services.path,
|
||||||
|
events: services.events,
|
||||||
|
API: services.API,
|
||||||
|
database: services.database,
|
||||||
|
appLifecycle: services.appLifecycle,
|
||||||
|
setting: services.setting,
|
||||||
|
remote: services.remote,
|
||||||
|
databaseEvents: services.databaseEvents,
|
||||||
|
replication: services.replication,
|
||||||
|
replicator: services.replicator,
|
||||||
|
UI: services.UI,
|
||||||
|
vault: services.vault,
|
||||||
|
fileHandler: modules.fileHandler,
|
||||||
|
fileProcessing: services.fileProcessing,
|
||||||
|
storageAccess: modules.storageAccess,
|
||||||
|
control: services.control,
|
||||||
|
});
|
||||||
|
|
||||||
|
const unloadHandler = services.addUnloadHandler.mock.calls[0]?.[0];
|
||||||
|
expect(unloadHandler).toBeDefined();
|
||||||
|
await expect(unloadHandler?.()).resolves.toBe(true);
|
||||||
|
expect(
|
||||||
|
(composition.instances.StorageEventManagerCLI as { close: ReturnType<typeof vi.fn> }).close
|
||||||
|
).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves WebApp host construction and returns its vault access and event manager", () => {
|
||||||
|
const services = createServices();
|
||||||
|
const core = createCore();
|
||||||
|
const rootHandle = {} as FileSystemDirectoryHandle;
|
||||||
|
|
||||||
|
const modules = initialiseServiceModulesFSAPI(
|
||||||
|
rootHandle,
|
||||||
|
core,
|
||||||
|
services as unknown as InjectableServiceHub<ServiceContext>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(composition.calls).toEqual([
|
||||||
|
"StorageAccessManager",
|
||||||
|
"FileAccessFSAPI",
|
||||||
|
"StorageEventManagerFSAPI",
|
||||||
|
"ServiceFileAccessFSAPI",
|
||||||
|
"ServiceDatabaseFileAccessFSAPI",
|
||||||
|
"openSimpleStore",
|
||||||
|
"ServiceFileHandler",
|
||||||
|
"ServiceRebuilder",
|
||||||
|
]);
|
||||||
|
expect(composition.dependencies.openSimpleStore).toBe(FILE_REFLECTION_PROVENANCE_STORE);
|
||||||
|
const [fileAccessRoot, fileAccessOptions] = composition.dependencies.FileAccessFSAPI as [
|
||||||
|
FileSystemDirectoryHandle,
|
||||||
|
Record<string, unknown>,
|
||||||
|
];
|
||||||
|
const [eventRoot, eventCore, eventDependencies] = composition.dependencies.StorageEventManagerFSAPI as [
|
||||||
|
FileSystemDirectoryHandle,
|
||||||
|
unknown,
|
||||||
|
Record<string, unknown>,
|
||||||
|
];
|
||||||
|
const storageAccessDependencies = composition.dependencies.ServiceFileAccessFSAPI as Record<string, unknown>;
|
||||||
|
const databaseAccessDependencies = composition.dependencies.ServiceDatabaseFileAccessFSAPI as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
|
expect(fileAccessRoot).toBe(rootHandle);
|
||||||
|
expect(fileAccessOptions).toEqual({
|
||||||
|
storageAccessManager: composition.instances.StorageAccessManager,
|
||||||
|
vaultService: services.vault,
|
||||||
|
settingService: services.setting,
|
||||||
|
APIService: services.API,
|
||||||
|
pathService: services.path,
|
||||||
|
});
|
||||||
|
expect(eventRoot).toBe(rootHandle);
|
||||||
|
expect(eventCore).toBe(core);
|
||||||
|
expect(eventDependencies).toEqual({
|
||||||
|
fileProcessing: services.fileProcessing,
|
||||||
|
setting: services.setting,
|
||||||
|
vaultService: services.vault,
|
||||||
|
storageAccessManager: composition.instances.StorageAccessManager,
|
||||||
|
APIService: services.API,
|
||||||
|
});
|
||||||
|
expect(storageAccessDependencies).toEqual({
|
||||||
|
API: services.API,
|
||||||
|
setting: services.setting,
|
||||||
|
fileProcessing: services.fileProcessing,
|
||||||
|
vault: services.vault,
|
||||||
|
appLifecycle: services.appLifecycle,
|
||||||
|
storageEventManager: composition.instances.StorageEventManagerFSAPI,
|
||||||
|
storageAccessManager: composition.instances.StorageAccessManager,
|
||||||
|
vaultAccess: composition.instances.FileAccessFSAPI,
|
||||||
|
});
|
||||||
|
expect(databaseAccessDependencies).toEqual({
|
||||||
|
events: services.events,
|
||||||
|
API: services.API,
|
||||||
|
database: services.database,
|
||||||
|
path: services.path,
|
||||||
|
storageAccess: modules.storageAccess,
|
||||||
|
vault: services.vault,
|
||||||
|
});
|
||||||
|
expect(modules.vaultAccess).toBe(composition.instances.FileAccessFSAPI);
|
||||||
|
expect(modules.storageEventManager).toBe(composition.instances.StorageEventManagerFSAPI);
|
||||||
|
expect(composition.dependencies.ServiceFileHandler).toEqual({
|
||||||
|
events: services.events,
|
||||||
|
API: services.API,
|
||||||
|
databaseFileAccess: modules.databaseFileAccess,
|
||||||
|
conflict: services.conflict,
|
||||||
|
setting: services.setting,
|
||||||
|
fileProcessing: services.fileProcessing,
|
||||||
|
vault: services.vault,
|
||||||
|
path: services.path,
|
||||||
|
replication: services.replication,
|
||||||
|
storageAccess: modules.storageAccess,
|
||||||
|
fileReflectionProvenance: expect.any(Object),
|
||||||
|
});
|
||||||
|
expect(composition.dependencies.ServiceRebuilder).toEqual({
|
||||||
|
path: services.path,
|
||||||
|
events: services.events,
|
||||||
|
API: services.API,
|
||||||
|
database: services.database,
|
||||||
|
appLifecycle: services.appLifecycle,
|
||||||
|
setting: services.setting,
|
||||||
|
remote: services.remote,
|
||||||
|
databaseEvents: services.databaseEvents,
|
||||||
|
replication: services.replication,
|
||||||
|
replicator: services.replicator,
|
||||||
|
UI: services.UI,
|
||||||
|
vault: services.vault,
|
||||||
|
fileHandler: modules.fileHandler,
|
||||||
|
fileProcessing: services.fileProcessing,
|
||||||
|
storageAccess: modules.storageAccess,
|
||||||
|
control: services.control,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import { FSAPIVaultAdapter } from "./FSAPIVaultAdapter";
|
|||||||
import type { FSAPIFile, FSAPIFolder, FSAPIStat } from "./FSAPITypes";
|
import type { FSAPIFile, FSAPIFolder, FSAPIStat } from "./FSAPITypes";
|
||||||
import { shareRunningResult } from "octagonal-wheels/concurrency/lock_v2";
|
import { shareRunningResult } from "octagonal-wheels/concurrency/lock_v2";
|
||||||
import type { WebAppLog } from "@/apps/webapp/WebAppLog";
|
import type { WebAppLog } from "@/apps/webapp/WebAppLog";
|
||||||
|
import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete file system adapter implementation for FileSystem API
|
* Complete file system adapter implementation for FileSystem API
|
||||||
@@ -85,28 +86,12 @@ export class FSAPIFileSystemAdapter implements IFileSystemAdapter<FSAPIFile, FSA
|
|||||||
*/
|
*/
|
||||||
async getAbstractFileByPathInsensitive(p: FilePath | string): Promise<FSAPIFile | null> {
|
async getAbstractFileByPathInsensitive(p: FilePath | string): Promise<FSAPIFile | null> {
|
||||||
const pathStr = this.normalisePath(p);
|
const pathStr = this.normalisePath(p);
|
||||||
const exact = await this.getAbstractFileByPath(pathStr);
|
return await findCaseInsensitiveFile(
|
||||||
if (exact) {
|
pathStr,
|
||||||
return exact;
|
() => this.getAbstractFileByPath(pathStr),
|
||||||
}
|
this.fileCache,
|
||||||
// TODO: Refactor: Very, Very heavy.
|
() => this.scanDirectory()
|
||||||
|
);
|
||||||
const lowerPath = pathStr.toLowerCase();
|
|
||||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
|
||||||
if (cachedPath.toLowerCase() === lowerPath) {
|
|
||||||
return cachedFile;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.scanDirectory();
|
|
||||||
|
|
||||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
|
||||||
if (cachedPath.toLowerCase() === lowerPath) {
|
|
||||||
return cachedFile;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFiles(): Promise<FSAPIFile[]> {
|
async getFiles(): Promise<FSAPIFile[]> {
|
||||||
|
|||||||
@@ -110,6 +110,83 @@ describe("FileSystemAccessStorageAdapter", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("FSAPIVaultAdapter creation", () => {
|
||||||
|
it.each(["text", "binary"] as const)(
|
||||||
|
"creates nested %s files with exact content and stored metadata",
|
||||||
|
async (kind) => {
|
||||||
|
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||||
|
const adapter = new FSAPIVaultAdapter(root);
|
||||||
|
const data = new Uint8Array([0, 255, 1, 128, 42]).buffer;
|
||||||
|
const path = "/nested//folder/file.dat";
|
||||||
|
const file =
|
||||||
|
kind === "text" ? await adapter.create(path, "日本語😀") : await adapter.createBinary(path, data);
|
||||||
|
const expected = kind === "text" ? new TextEncoder().encode("日本語😀") : new Uint8Array(data);
|
||||||
|
const nested = await root.getDirectoryHandle("nested");
|
||||||
|
const folder = await nested.getDirectoryHandle("folder");
|
||||||
|
const stored = await folder.getFileHandle("file.dat");
|
||||||
|
expect(file.path).toBe(path);
|
||||||
|
expect(file.handle).toBe(stored);
|
||||||
|
expect(new Uint8Array(await adapter.readBinary(file))).toEqual(expected);
|
||||||
|
expect(file.stat).toEqual({ size: expected.byteLength, mtime: 1, ctime: 1, type: "file" });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["text", "binary"] as const)("waits for the %s writer to close before reading metadata", async (kind) => {
|
||||||
|
let releaseClose!: () => void;
|
||||||
|
const closed = new Promise<void>((resolve) => {
|
||||||
|
releaseClose = resolve;
|
||||||
|
});
|
||||||
|
const events: string[] = [];
|
||||||
|
const getFile = vi.fn(async () => {
|
||||||
|
events.push("metadata");
|
||||||
|
return new File(["result"], "file.dat", { lastModified: 123 });
|
||||||
|
});
|
||||||
|
const close = vi.fn(async () => {
|
||||||
|
events.push("close-start");
|
||||||
|
await closed;
|
||||||
|
events.push("closed");
|
||||||
|
});
|
||||||
|
const write = vi.fn(async () => {
|
||||||
|
events.push("write");
|
||||||
|
});
|
||||||
|
const fileHandle = { createWritable: vi.fn(async () => ({ write, close })), getFile };
|
||||||
|
const root = { getFileHandle: vi.fn(async () => fileHandle) } as unknown as FileSystemDirectoryHandle;
|
||||||
|
const adapter = new FSAPIVaultAdapter(root);
|
||||||
|
const data = new Uint8Array([0, 255]).buffer;
|
||||||
|
const pending = kind === "text" ? adapter.create("file.dat", "text") : adapter.createBinary("file.dat", data);
|
||||||
|
try {
|
||||||
|
await vi.waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||||
|
expect(getFile).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
releaseClose();
|
||||||
|
}
|
||||||
|
const file = await pending;
|
||||||
|
expect(events).toEqual(["write", "close-start", "closed", "metadata"]);
|
||||||
|
expect(write).toHaveBeenCalledWith(kind === "text" ? "text" : data);
|
||||||
|
expect(file.stat).toEqual({ size: 6, ctime: 123, mtime: 123, type: "file" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["text", "binary"] as const)("propagates a %s write failure without returning metadata", async (kind) => {
|
||||||
|
const failure = new Error("fixture write failed");
|
||||||
|
const getFile = vi.fn();
|
||||||
|
const fileHandle = {
|
||||||
|
createWritable: vi.fn(async () => ({
|
||||||
|
write: vi.fn(async () => {
|
||||||
|
throw failure;
|
||||||
|
}),
|
||||||
|
close: vi.fn(),
|
||||||
|
})),
|
||||||
|
getFile,
|
||||||
|
};
|
||||||
|
const root = { getFileHandle: vi.fn(async () => fileHandle) } as unknown as FileSystemDirectoryHandle;
|
||||||
|
const adapter = new FSAPIVaultAdapter(root);
|
||||||
|
const pending =
|
||||||
|
kind === "text" ? adapter.create("file.dat", "text") : adapter.createBinary("file.dat", new ArrayBuffer(2));
|
||||||
|
await expect(pending).rejects.toBe(failure);
|
||||||
|
expect(getFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("FSAPIVaultAdapter.rename", () => {
|
describe("FSAPIVaultAdapter.rename", () => {
|
||||||
it("moves the file through a temporary copy while preserving content", async () => {
|
it("moves the file through a temporary copy while preserving content", async () => {
|
||||||
const memoryRoot = new MemoryDirectoryHandle("root");
|
const memoryRoot = new MemoryDirectoryHandle("root");
|
||||||
@@ -142,6 +219,20 @@ describe("FSAPIVaultAdapter.rename", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("FSAPIFileSystemAdapter path case", () => {
|
describe("FSAPIFileSystemAdapter path case", () => {
|
||||||
|
it("returns an exact path without scanning the directory", async () => {
|
||||||
|
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||||
|
const vault = new FSAPIVaultAdapter(root);
|
||||||
|
await vault.create("Calculus.md", "content");
|
||||||
|
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||||
|
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||||
|
|
||||||
|
await expect(adapter.getAbstractFileByPathInsensitive("Calculus.md")).resolves.toMatchObject({
|
||||||
|
path: "Calculus.md",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scanDirectory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("returns the stored path case from a case-insensitive file system", async () => {
|
it("returns the stored path case from a case-insensitive file system", async () => {
|
||||||
const memoryRoot = new MemoryDirectoryHandle("root", true);
|
const memoryRoot = new MemoryDirectoryHandle("root", true);
|
||||||
const root = memoryRoot as unknown as FileSystemDirectoryHandle;
|
const root = memoryRoot as unknown as FileSystemDirectoryHandle;
|
||||||
@@ -155,6 +246,48 @@ describe("FSAPIFileSystemAdapter path case", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns a cached case-insensitive match without rescanning", async () => {
|
||||||
|
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||||
|
const vault = new FSAPIVaultAdapter(root);
|
||||||
|
await vault.create("Calculus.md", "content");
|
||||||
|
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||||
|
await adapter.scanDirectory();
|
||||||
|
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||||
|
|
||||||
|
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||||
|
path: "Calculus.md",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scanDirectory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rescans once and returns a newly cached case-insensitive match", async () => {
|
||||||
|
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||||
|
const vault = new FSAPIVaultAdapter(root);
|
||||||
|
await vault.create("Calculus.md", "content");
|
||||||
|
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||||
|
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||||
|
|
||||||
|
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||||
|
path: "Calculus.md",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||||
|
await expect(adapter.getAbstractFileByPath("Calculus.md")).resolves.toMatchObject({ path: "Calculus.md" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null after one scan when no cached path matches", async () => {
|
||||||
|
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||||
|
const vault = new FSAPIVaultAdapter(root);
|
||||||
|
await vault.create("other.md", "content");
|
||||||
|
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||||
|
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||||
|
|
||||||
|
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||||
|
|
||||||
|
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it("reports scan failures through the injected Webapp log", async () => {
|
it("reports scan failures through the injected Webapp log", async () => {
|
||||||
const root = {
|
const root = {
|
||||||
name: "root",
|
name: "root",
|
||||||
@@ -173,4 +306,25 @@ describe("FSAPIFileSystemAdapter path case", () => {
|
|||||||
"fsapi-scan"
|
"fsapi-scan"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns null and reports scan failure during case-insensitive lookup", async () => {
|
||||||
|
const root = {
|
||||||
|
name: "root",
|
||||||
|
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
|
||||||
|
throw new Error("scan failed");
|
||||||
|
},
|
||||||
|
} as unknown as FileSystemDirectoryHandle;
|
||||||
|
const addLog = vi.fn();
|
||||||
|
const adapter = new FSAPIFileSystemAdapter(root, addLog);
|
||||||
|
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||||
|
|
||||||
|
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||||
|
|
||||||
|
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||||
|
expect(addLog).toHaveBeenCalledWith(
|
||||||
|
"Error scanning directory '.': Error: scan failed",
|
||||||
|
LOG_LEVEL_NOTICE,
|
||||||
|
"fsapi-scan"
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -36,37 +36,14 @@ export class FSAPIVaultAdapter implements IVaultAdapter<FSAPIFile> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(p: string, data: string, options?: UXDataWriteOptions): Promise<FSAPIFile> {
|
async create(p: string, data: string, options?: UXDataWriteOptions): Promise<FSAPIFile> {
|
||||||
const parts = p.split("/").filter((part) => part !== "");
|
return await this.createFile(p, data);
|
||||||
const fileName = parts[parts.length - 1];
|
|
||||||
|
|
||||||
// Navigate to parent directory, creating as needed
|
|
||||||
let currentHandle = this.rootHandle;
|
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
|
||||||
currentHandle = await currentHandle.getDirectoryHandle(parts[i], { create: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the file
|
|
||||||
const fileHandle = await currentHandle.getFileHandle(fileName, { create: true });
|
|
||||||
const writable = await fileHandle.createWritable();
|
|
||||||
await writable.write(data);
|
|
||||||
await writable.close();
|
|
||||||
|
|
||||||
// Get file metadata
|
|
||||||
const fileObject = await fileHandle.getFile();
|
|
||||||
|
|
||||||
return {
|
|
||||||
path: p as FilePath,
|
|
||||||
stat: {
|
|
||||||
size: fileObject.size,
|
|
||||||
mtime: fileObject.lastModified,
|
|
||||||
ctime: fileObject.lastModified,
|
|
||||||
type: "file",
|
|
||||||
},
|
|
||||||
handle: fileHandle,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<FSAPIFile> {
|
async createBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<FSAPIFile> {
|
||||||
|
return await this.createFile(p, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createFile(p: string, data: string | ArrayBuffer): Promise<FSAPIFile> {
|
||||||
const parts = p.split("/").filter((part) => part !== "");
|
const parts = p.split("/").filter((part) => part !== "");
|
||||||
const fileName = parts[parts.length - 1];
|
const fileName = parts[parts.length - 1];
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { FilePath, UXFileInfoStub, UXInternalFileInfoStub } from "@vrtmrz/l
|
|||||||
import type { FileEventItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import type { FileEventItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import type { IStorageEventManagerAdapter } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
|
import type { IStorageEventManagerAdapter } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
|
||||||
import type {
|
import type {
|
||||||
IStorageEventTypeGuardAdapter,
|
|
||||||
IStorageEventPersistenceAdapter,
|
IStorageEventPersistenceAdapter,
|
||||||
IStorageEventWatchAdapter,
|
IStorageEventWatchAdapter,
|
||||||
IStorageEventStatusAdapter,
|
IStorageEventStatusAdapter,
|
||||||
@@ -14,6 +13,7 @@ import type { FSAPIFile, FSAPIFolder } from "@/apps/webapp/adapters/FSAPITypes";
|
|||||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import type { WebAppLog } from "@/apps/webapp/WebAppLog";
|
import type { WebAppLog } from "@/apps/webapp/WebAppLog";
|
||||||
|
import { FSAPITypeGuardAdapter } from "@/apps/webapp/adapters/FSAPITypeGuardAdapter";
|
||||||
|
|
||||||
type FileSystemObserverRecord = {
|
type FileSystemObserverRecord = {
|
||||||
changedHandle?: FileSystemFileHandle | FileSystemDirectoryHandle;
|
changedHandle?: FileSystemFileHandle | FileSystemDirectoryHandle;
|
||||||
@@ -34,32 +34,6 @@ type GlobalWithFileSystemObserver = typeof compatGlobal & {
|
|||||||
FileSystemObserver?: FileSystemObserverConstructor;
|
FileSystemObserver?: FileSystemObserverConstructor;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* FileSystem API-specific type guard adapter
|
|
||||||
*/
|
|
||||||
class FSAPITypeGuardAdapter implements IStorageEventTypeGuardAdapter<FSAPIFile, FSAPIFolder> {
|
|
||||||
isFile(file: unknown): file is FSAPIFile {
|
|
||||||
return !!(
|
|
||||||
file &&
|
|
||||||
typeof file === "object" &&
|
|
||||||
"path" in file &&
|
|
||||||
"stat" in file &&
|
|
||||||
"handle" in file &&
|
|
||||||
!(file as { isFolder?: boolean }).isFolder
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
isFolder(item: unknown): item is FSAPIFolder {
|
|
||||||
return !!(
|
|
||||||
item &&
|
|
||||||
typeof item === "object" &&
|
|
||||||
"path" in item &&
|
|
||||||
(item as { isFolder?: boolean }).isFolder === true &&
|
|
||||||
"handle" in item
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FileSystem API-specific persistence adapter (IndexedDB-based snapshot)
|
* FileSystem API-specific persistence adapter (IndexedDB-based snapshot)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { FSAPIStorageEventManagerAdapter } from "./FSAPIStorageEventManagerAdapter";
|
||||||
|
|
||||||
|
describe("FSAPIStorageEventManagerAdapter type guard", () => {
|
||||||
|
const typeGuard = new FSAPIStorageEventManagerAdapter({} as FileSystemDirectoryHandle, vi.fn()).typeGuard;
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ label: "null", value: null, expected: false },
|
||||||
|
{ label: "an object without a path", value: { stat: {}, handle: undefined }, expected: false },
|
||||||
|
{ label: "an object without a stat", value: { path: "note.md", handle: undefined }, expected: false },
|
||||||
|
{ label: "an object without a handle", value: { path: "note.md", stat: {} }, expected: false },
|
||||||
|
{
|
||||||
|
label: "a file-shaped object with a handle property",
|
||||||
|
value: { path: "note.md", stat: {}, handle: undefined },
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "a folder-shaped object",
|
||||||
|
value: { path: "folder", handle: undefined, isFolder: true },
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
])("classifies $label as a file", ({ value, expected }) => {
|
||||||
|
expect(typeGuard.isFile(value)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ label: "null", value: null, expected: false },
|
||||||
|
{ label: "an object without a path", value: { isFolder: true, handle: undefined }, expected: false },
|
||||||
|
{ label: "an object without the folder marker", value: { path: "folder", handle: undefined }, expected: false },
|
||||||
|
{ label: "an object without a handle", value: { path: "folder", isFolder: true }, expected: false },
|
||||||
|
{
|
||||||
|
label: "a folder-shaped object with a handle property",
|
||||||
|
value: { path: "folder", isFolder: true, handle: undefined },
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{ label: "a file-shaped object", value: { path: "note.md", stat: {}, handle: undefined }, expected: false },
|
||||||
|
])("classifies $label as a folder", ({ value, expected }) => {
|
||||||
|
expect(typeGuard.isFolder(value)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webapp/browser-smoke.test.ts"
|
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webapp/browser-smoke.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"octagonal-wheels": "^0.1.55"
|
"octagonal-wheels": "0.1.56"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||||
import { ServiceRebuilder } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
|
|
||||||
|
|
||||||
import { StorageAccessManager } from "@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager";
|
import { StorageAccessManager } from "@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager";
|
||||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||||
@@ -9,8 +8,7 @@ import { ServiceFileAccessFSAPI } from "./ServiceFileAccessImpl";
|
|||||||
import { ServiceDatabaseFileAccessFSAPI } from "./DatabaseFileAccess";
|
import { ServiceDatabaseFileAccessFSAPI } from "./DatabaseFileAccess";
|
||||||
import { StorageEventManagerFSAPI } from "@/apps/webapp/managers/StorageEventManagerFSAPI";
|
import { StorageEventManagerFSAPI } from "@/apps/webapp/managers/StorageEventManagerFSAPI";
|
||||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||||
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
|
import { composeSharedServiceModules } from "@/apps/serviceModules/composeSharedServiceModules";
|
||||||
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
|
|
||||||
|
|
||||||
export interface FSAPIServiceModules extends ServiceModules {
|
export interface FSAPIServiceModules extends ServiceModules {
|
||||||
vaultAccess: FileAccessFSAPI;
|
vaultAccess: FileAccessFSAPI;
|
||||||
@@ -73,40 +71,7 @@ export function initialiseServiceModulesFSAPI(
|
|||||||
vault: services.vault,
|
vault: services.vault,
|
||||||
});
|
});
|
||||||
|
|
||||||
// File handler (platform-independent)
|
const { fileHandler, rebuilder } = composeSharedServiceModules(services, databaseFileAccess, storageAccess);
|
||||||
const fileHandler = new ServiceFileHandler({
|
|
||||||
events: services.context.events,
|
|
||||||
API: services.API,
|
|
||||||
databaseFileAccess: databaseFileAccess,
|
|
||||||
conflict: services.conflict,
|
|
||||||
setting: services.setting,
|
|
||||||
fileProcessing: services.fileProcessing,
|
|
||||||
vault: services.vault,
|
|
||||||
path: services.path,
|
|
||||||
replication: services.replication,
|
|
||||||
storageAccess: storageAccess,
|
|
||||||
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rebuilder (platform-independent)
|
|
||||||
const rebuilder = new ServiceRebuilder({
|
|
||||||
path: services.path,
|
|
||||||
events: services.context.events,
|
|
||||||
API: services.API,
|
|
||||||
database: services.database,
|
|
||||||
appLifecycle: services.appLifecycle,
|
|
||||||
setting: services.setting,
|
|
||||||
remote: services.remote,
|
|
||||||
databaseEvents: services.databaseEvents,
|
|
||||||
replication: services.replication,
|
|
||||||
replicator: services.replicator,
|
|
||||||
UI: services.UI,
|
|
||||||
vault: services.vault,
|
|
||||||
fileHandler: fileHandler,
|
|
||||||
fileProcessing: services.fileProcessing,
|
|
||||||
storageAccess: storageAccess,
|
|
||||||
control: services.control,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rebuilder,
|
rebuilder,
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||||
import { fileURLToPath, fs, path } from "@vrtmrz/livesync-commonlib/node";
|
import { fileURLToPath, path } from "@vrtmrz/livesync-commonlib/node";
|
||||||
|
import { readVersion } from "../readVersion";
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.resolve(__dirname, "../../..");
|
const repoRoot = path.resolve(__dirname, "../../..");
|
||||||
|
|
||||||
function readVersion(filePath: string): string | undefined {
|
|
||||||
const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
||||||
if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return typeof parsed.version === "string" ? parsed.version : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const packageVersion = readVersion(path.resolve(repoRoot, "package.json"));
|
const packageVersion = readVersion(path.resolve(repoRoot, "package.json"));
|
||||||
const manifestVersion = readVersion(path.resolve(repoRoot, "manifest.json"));
|
const manifestVersion = readVersion(path.resolve(repoRoot, "manifest.json"));
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webpeer/browser-smoke.test.ts"
|
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webpeer/browser-smoke.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"octagonal-wheels": "^0.1.55"
|
"octagonal-wheels": "0.1.56"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint-plugin-svelte": "^3.19.0",
|
"eslint-plugin-svelte": "^3.19.0",
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export class WebPeerRuntime {
|
|||||||
|
|
||||||
private showPeerMenu(peer: PeerStatus, event: MouseEvent): void {
|
private showPeerMenu(peer: PeerStatus, event: MouseEvent): void {
|
||||||
this.menu?.hide();
|
this.menu?.hide();
|
||||||
this.menu = new Menu()
|
this.menu = new Menu(this.services.browserUi)
|
||||||
.addItem((item) =>
|
.addItem((item) =>
|
||||||
item.setTitle("📥 Only fetch").onClick(async () => {
|
item.setTitle("📥 Only fetch").onClick(async () => {
|
||||||
await this.p2p.targetedTransfer.pullFromPeer(peer.peerId);
|
await this.p2p.targetedTransfer.pullFromPeer(peer.peerId);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { stepCyclicIndex } from "octagonal-wheels/collection";
|
||||||
|
|
||||||
|
export function navigateDiffNodes(
|
||||||
|
root: ParentNode,
|
||||||
|
selector: string,
|
||||||
|
navigation: {
|
||||||
|
currentDiffIndex: number;
|
||||||
|
diffNavIndicator: { setText(text: string): void };
|
||||||
|
},
|
||||||
|
direction: "prev" | "next"
|
||||||
|
): void {
|
||||||
|
const diffElements = root.querySelectorAll<HTMLElement>(selector);
|
||||||
|
if (diffElements.length === 0) return;
|
||||||
|
|
||||||
|
const previousFocused = root.querySelector(".diff-focused");
|
||||||
|
if (previousFocused) {
|
||||||
|
previousFocused.classList.remove("diff-focused");
|
||||||
|
}
|
||||||
|
|
||||||
|
navigation.currentDiffIndex = stepCyclicIndex(navigation.currentDiffIndex, diffElements.length, direction);
|
||||||
|
const target = diffElements[navigation.currentDiffIndex];
|
||||||
|
target.classList.add("diff-focused");
|
||||||
|
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
navigation.diffNavIndicator.setText(`${navigation.currentDiffIndex + 1}/${diffElements.length}`);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { withOwnedResource, type DisposableResource } from "octagonal-wheels/resource";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run a finite operation with a flow-owned remote resource and release it
|
* Run a finite operation with a flow-owned remote resource and release it
|
||||||
* after either success or failure.
|
* after either success or failure.
|
||||||
@@ -6,13 +8,9 @@
|
|||||||
* caller's ownership boundary explicit and prevents finite flows from leaking
|
* caller's ownership boundary explicit and prevents finite flows from leaking
|
||||||
* a provider-owned resource when their operation rejects.
|
* a provider-owned resource when their operation rejects.
|
||||||
*/
|
*/
|
||||||
export async function withOwnedRemoteResource<TResource extends { dispose(): Promise<void> }, TResult>(
|
export function withOwnedRemoteResource<TResource extends DisposableResource & { dispose(): Promise<void> }, TResult>(
|
||||||
resource: TResource,
|
resource: TResource,
|
||||||
operation: (ownedResource: TResource) => Promise<TResult>
|
operation: (ownedResource: TResource) => Promise<TResult>
|
||||||
): Promise<TResult> {
|
): Promise<TResult> {
|
||||||
try {
|
return withOwnedResource(resource, operation);
|
||||||
return await operation(resource);
|
|
||||||
} finally {
|
|
||||||
await resource.dispose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -26,3 +26,47 @@ describe("flow-owned remote resources", () => {
|
|||||||
expect(dispose).toHaveBeenCalledOnce();
|
expect(dispose).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("owned remote resource completion", () => {
|
||||||
|
it("waits for cleanup and preserves its receiver", async () => {
|
||||||
|
let finish!: () => void;
|
||||||
|
let started!: () => void;
|
||||||
|
const disposing = new Promise<void>((resolve) => {
|
||||||
|
started = resolve;
|
||||||
|
});
|
||||||
|
const cleanup = new Promise<void>((resolve) => {
|
||||||
|
finish = resolve;
|
||||||
|
});
|
||||||
|
const resource = {
|
||||||
|
dispose: vi.fn(function (this: unknown) {
|
||||||
|
expect(this).toBe(resource);
|
||||||
|
started();
|
||||||
|
return cleanup;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let settled = false;
|
||||||
|
const result = withOwnedRemoteResource(resource, async () => "done");
|
||||||
|
void result.then(() => {
|
||||||
|
settled = true;
|
||||||
|
});
|
||||||
|
await disposing;
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
finish();
|
||||||
|
await expect(result).resolves.toBe("done");
|
||||||
|
expect(resource.dispose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps finally precedence when both the operation and cleanup fail", async () => {
|
||||||
|
const operationError = new Error("operation");
|
||||||
|
const disposalError = new Error("cleanup");
|
||||||
|
const dispose = vi.fn(async () => {
|
||||||
|
throw disposalError;
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
withOwnedRemoteResource({ dispose }, async () => {
|
||||||
|
throw operationError;
|
||||||
|
})
|
||||||
|
).rejects.toBe(disposalError);
|
||||||
|
expect(dispose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { findPathComponentsExceedingUtf8Limit as findKitPathComponentsExceedingUtf8Limit } from "octagonal-wheels/path";
|
||||||
|
|
||||||
export const ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY = 255;
|
export const ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY = 255;
|
||||||
|
|
||||||
export interface OversizedPathComponent {
|
export interface OversizedPathComponent {
|
||||||
@@ -5,8 +7,6 @@ export interface OversizedPathComponent {
|
|||||||
utf8Bytes: number;
|
utf8Bytes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const utf8Encoder = new TextEncoder();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return path components which exceed the conservative Android/Linux
|
* Return path components which exceed the conservative Android/Linux
|
||||||
* compatibility boundary.
|
* compatibility boundary.
|
||||||
@@ -18,9 +18,5 @@ export function findPathComponentsExceedingUtf8Limit(
|
|||||||
path: string,
|
path: string,
|
||||||
maxBytes: number = ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY
|
maxBytes: number = ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY
|
||||||
): OversizedPathComponent[] {
|
): OversizedPathComponent[] {
|
||||||
return path
|
return findKitPathComponentsExceedingUtf8Limit(path, maxBytes);
|
||||||
.split("/")
|
|
||||||
.filter((component) => component.length > 0)
|
|
||||||
.map((component) => ({ component, utf8Bytes: utf8Encoder.encode(component).byteLength }))
|
|
||||||
.filter(({ utf8Bytes }) => utf8Bytes > maxBytes);
|
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ async function runOneShotWithOutcome(
|
|||||||
|
|
||||||
// Manual and unattended wrappers share the provider transfer operation, but
|
// Manual and unattended wrappers share the provider transfer operation, but
|
||||||
// keep interaction authority and result presentation explicit at this boundary.
|
// keep interaction authority and result presentation explicit at this boundary.
|
||||||
const couchDBUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
|
const userInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
|
||||||
return await runOneShotWithOutcome(
|
return await runOneShotWithOutcome(
|
||||||
instance,
|
instance,
|
||||||
setting,
|
setting,
|
||||||
@@ -81,20 +81,7 @@ const couchDBUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance,
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const couchDBUnattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
|
const unattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
|
||||||
if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required");
|
|
||||||
return await runOneShotWithOutcome(instance, setting, false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const objectStorageUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
|
|
||||||
return await runOneShotWithOutcome(
|
|
||||||
instance,
|
|
||||||
setting,
|
|
||||||
request.progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const objectStorageUnattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
|
|
||||||
if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required");
|
if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required");
|
||||||
return await runOneShotWithOutcome(instance, setting, false);
|
return await runOneShotWithOutcome(instance, setting, false);
|
||||||
};
|
};
|
||||||
@@ -136,8 +123,8 @@ export function createCentralReplicatorProviderDefinitions(
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
centralRemoteAdministration: COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
centralRemoteAdministration: COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||||
userInitiatedOneShot: supportedCapability(couchDBUserInitiatedOneShot),
|
userInitiatedOneShot: supportedCapability(userInitiatedOneShot),
|
||||||
unattendedOneShot: supportedCapability(couchDBUnattendedOneShot),
|
unattendedOneShot: supportedCapability(unattendedOneShot),
|
||||||
continuous: supportedOpenReplicationContinuous(),
|
continuous: supportedOpenReplicationContinuous(),
|
||||||
stopActiveTransfer: supportedStopActiveTransfer(),
|
stopActiveTransfer: supportedStopActiveTransfer(),
|
||||||
},
|
},
|
||||||
@@ -166,8 +153,8 @@ export function createCentralReplicatorProviderDefinitions(
|
|||||||
[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: CAPABILITY_NOT_APPLICABLE,
|
[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: CAPABILITY_NOT_APPLICABLE,
|
||||||
},
|
},
|
||||||
centralRemoteAdministration: OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
centralRemoteAdministration: OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||||
userInitiatedOneShot: supportedCapability(objectStorageUserInitiatedOneShot),
|
userInitiatedOneShot: supportedCapability(userInitiatedOneShot),
|
||||||
unattendedOneShot: supportedCapability(objectStorageUnattendedOneShot),
|
unattendedOneShot: supportedCapability(unattendedOneShot),
|
||||||
continuous: CAPABILITY_NOT_APPLICABLE,
|
continuous: CAPABILITY_NOT_APPLICABLE,
|
||||||
stopActiveTransfer: supportedStopActiveTransfer(),
|
stopActiveTransfer: supportedStopActiveTransfer(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -209,6 +209,37 @@ describe("central Replicator provider definitions", () => {
|
|||||||
expect(openOneShotReplicationWithOutcome).toHaveBeenNthCalledWith(2, setting, true);
|
expect(openOneShotReplicationWithOutcome).toHaveBeenNthCalledWith(2, setting, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([REMOTE_COUCHDB, REMOTE_MINIO] as const)(
|
||||||
|
"keeps unattended %s work quiet and rejects interaction before opening replication",
|
||||||
|
async (remoteType) => {
|
||||||
|
const definition = createCentralReplicatorProviderDefinitions({} as never).get(remoteType)!;
|
||||||
|
if (definition.unattendedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||||
|
throw new Error("Unattended OneShot is unavailable");
|
||||||
|
}
|
||||||
|
const setting = Object.assign(createNewVaultSettings(), { remoteType });
|
||||||
|
const instance = {
|
||||||
|
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||||
|
openReplication: vi.fn(async () => true),
|
||||||
|
terminateSync: vi.fn(),
|
||||||
|
closeReplication: vi.fn(),
|
||||||
|
openOneShotReplicationWithOutcome: vi.fn(async () => REPLICATION_COMPLETED),
|
||||||
|
};
|
||||||
|
const blocked = await definition.unattendedOneShot.run(instance, setting, {
|
||||||
|
trigger: "resume",
|
||||||
|
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||||
|
} as never);
|
||||||
|
expect(blocked).toEqual({ status: "blocked", reason: "interaction-required" });
|
||||||
|
expect(instance.openOneShotReplicationWithOutcome).not.toHaveBeenCalled();
|
||||||
|
await expect(
|
||||||
|
definition.unattendedOneShot.run(instance, setting, {
|
||||||
|
trigger: "resume",
|
||||||
|
interaction: NO_INTERACTION,
|
||||||
|
})
|
||||||
|
).resolves.toBe(REPLICATION_COMPLETED);
|
||||||
|
expect(instance.openOneShotReplicationWithOutcome).toHaveBeenCalledExactlyOnceWith(setting, false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it("dispatches central finite work through the declared operation rather than constructor identity", async () => {
|
it("dispatches central finite work through the declared operation rather than constructor identity", async () => {
|
||||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||||
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createOwnedResource } from "octagonal-wheels/resource";
|
||||||
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,13 +14,8 @@ export interface ResourceReplicator {
|
|||||||
|
|
||||||
/** Create one idempotent asynchronous disposer for a private Replicator. */
|
/** Create one idempotent asynchronous disposer for a private Replicator. */
|
||||||
export function createReplicatorDisposer(replicator: ResourceReplicator): () => Promise<void> {
|
export function createReplicatorDisposer(replicator: ResourceReplicator): () => Promise<void> {
|
||||||
let disposal: Promise<void> | undefined;
|
const resource = createOwnedResource(replicator, (owned) => Promise.resolve().then(() => owned.closeReplication()));
|
||||||
return () => {
|
return () => Promise.resolve(resource.dispose());
|
||||||
if (disposal === undefined) {
|
|
||||||
disposal = Promise.resolve().then(() => replicator.closeReplication());
|
|
||||||
}
|
|
||||||
return disposal;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fence a finite resource from later edits to its source settings object. */
|
/** Fence a finite resource from later edits to its source settings object. */
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createReplicatorDisposer } from "./shared";
|
||||||
|
|
||||||
|
describe("Replicator disposal", () => {
|
||||||
|
it("defers closing, preserves the receiver, and retains one completion promise", async () => {
|
||||||
|
let finish!: () => void;
|
||||||
|
const closed = new Promise<void>((resolve) => {
|
||||||
|
finish = resolve;
|
||||||
|
});
|
||||||
|
const replicator = {
|
||||||
|
closeReplication: vi.fn(function (this: unknown) {
|
||||||
|
expect(this).toBe(replicator);
|
||||||
|
return closed;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const dispose = createReplicatorDisposer(replicator);
|
||||||
|
const pending = dispose();
|
||||||
|
expect(replicator.closeReplication).not.toHaveBeenCalled();
|
||||||
|
expect(dispose()).toBe(pending);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||||
|
finish();
|
||||||
|
await pending;
|
||||||
|
expect(dispose()).toBe(pending);
|
||||||
|
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains synchronous closing failures as one rejected promise", async () => {
|
||||||
|
const error = new Error("close");
|
||||||
|
const replicator = {
|
||||||
|
closeReplication: vi.fn(() => {
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const dispose = createReplicatorDisposer(replicator);
|
||||||
|
const pending = dispose();
|
||||||
|
await expect(pending).rejects.toBe(error);
|
||||||
|
expect(dispose()).toBe(pending);
|
||||||
|
await expect(dispose()).rejects.toBe(error);
|
||||||
|
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -389,13 +389,4 @@ export async function autosaveCache<K, V>(db: KeyValueDatabase, mapKey: string):
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onlyInNTimes(n: number, proc: (progress: number) => unknown) {
|
|
||||||
let counter = 0;
|
|
||||||
return function () {
|
|
||||||
if (counter++ % n == 0) {
|
|
||||||
proc(counter);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
export { displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/deps.ts", () => ({
|
||||||
|
addIcon: vi.fn(),
|
||||||
|
diff_match_patch: class DiffMatchPatch {},
|
||||||
|
normalizePath: vi.fn((path: string) => path),
|
||||||
|
parseYaml: vi.fn(),
|
||||||
|
Platform: {},
|
||||||
|
}));
|
||||||
|
vi.mock("./PluginDialogModal.ts", () => ({
|
||||||
|
PluginDialogModal: class PluginDialogModal {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||||
|
JsonResolveModal: class JsonResolveModal {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||||
|
ConflictResolveModal: class ConflictResolveModal {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/LiveSyncCommands.ts", () => ({
|
||||||
|
LiveSyncCommands: class LiveSyncCommands {
|
||||||
|
core!: { services: unknown };
|
||||||
|
get services() {
|
||||||
|
return this.core.services;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/types.ts", () => ({
|
||||||
|
ICXHeader: "ix:",
|
||||||
|
PERIODIC_PLUGIN_SWEEP: 60,
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/utils.ts", () => ({
|
||||||
|
cancelTask: vi.fn(),
|
||||||
|
EVEN: Symbol("even"),
|
||||||
|
isCustomisationSyncMetadata: vi.fn(),
|
||||||
|
isPluginMetadata: vi.fn(),
|
||||||
|
scheduleTask: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||||
|
PeriodicProcessor: class PeriodicProcessor {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/events.ts", () => ({
|
||||||
|
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
|
||||||
|
eventHub: {
|
||||||
|
onEvent: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/translation", () => ({
|
||||||
|
$msg: vi.fn((message: string) => message),
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||||
|
getObsidianCommunityPluginManager: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import { ConfigSync, type PluginDataExDisplayV2 } from "./CmdConfigSync";
|
||||||
|
|
||||||
|
const BASE_DIR = ".obsidian";
|
||||||
|
const DEVICE_NAME = "test-device";
|
||||||
|
const STAT = { mtime: 123456, ctime: 123000 };
|
||||||
|
|
||||||
|
type ApplyFile = {
|
||||||
|
filename: string;
|
||||||
|
datatype: "newnote" | "plain";
|
||||||
|
data: string[];
|
||||||
|
mtime: number;
|
||||||
|
ctime: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeApplyFile(datatype: ApplyFile["datatype"], data: string[]): ApplyFile {
|
||||||
|
return {
|
||||||
|
filename: "plugins/example/data.json",
|
||||||
|
datatype,
|
||||||
|
data,
|
||||||
|
mtime: STAT.mtime,
|
||||||
|
ctime: STAT.ctime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(file: ApplyFile, currentText = "before", currentBinary = new ArrayBuffer(0)) {
|
||||||
|
const path = `${BASE_DIR}/${file.filename}`;
|
||||||
|
const events: string[] = [];
|
||||||
|
const storageAccess = {
|
||||||
|
ensureDir: vi.fn(async (_path: string) => {
|
||||||
|
events.push("ensure");
|
||||||
|
}),
|
||||||
|
readHiddenFileText: vi.fn(async (_path: string) => {
|
||||||
|
events.push("read-text");
|
||||||
|
return currentText;
|
||||||
|
}),
|
||||||
|
readHiddenFileBinary: vi.fn(async (_path: string) => {
|
||||||
|
events.push("read-binary");
|
||||||
|
return currentBinary;
|
||||||
|
}),
|
||||||
|
writeHiddenFileAuto: vi.fn(
|
||||||
|
async (_path: string, _content: string | ArrayBuffer, _stat?: { mtime: number; ctime: number }) => {
|
||||||
|
events.push("write");
|
||||||
|
}
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const storeCustomisationFileV2 = vi.fn(async (_path: string, _term: string) => {
|
||||||
|
events.push("store");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const log = vi.fn();
|
||||||
|
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
|
||||||
|
Object.assign(configSync, {
|
||||||
|
core: {
|
||||||
|
services: {
|
||||||
|
API: { getSystemConfigDir: () => BASE_DIR },
|
||||||
|
setting: { getDeviceAndVaultName: () => DEVICE_NAME },
|
||||||
|
},
|
||||||
|
storageAccess,
|
||||||
|
},
|
||||||
|
_log: log,
|
||||||
|
storeCustomisationFileV2,
|
||||||
|
});
|
||||||
|
const data = {
|
||||||
|
displayName: "Example plug-in",
|
||||||
|
name: "example",
|
||||||
|
files: [file],
|
||||||
|
} as unknown as PluginDataExDisplayV2;
|
||||||
|
return { configSync, data, events, log, path, storageAccess, storeCustomisationFileV2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedInputs = [
|
||||||
|
{
|
||||||
|
kind: "text",
|
||||||
|
file: makeApplyFile("plain", ["replacement text"]),
|
||||||
|
currentText: "previous text",
|
||||||
|
expectedText: "replacement text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "binary",
|
||||||
|
file: makeApplyFile("newnote", ["AQID"]),
|
||||||
|
currentBinary: new Uint8Array([9]).buffer,
|
||||||
|
expectedBytes: [1, 2, 3],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const unchangedInputs = [
|
||||||
|
{
|
||||||
|
kind: "text",
|
||||||
|
file: makeApplyFile("plain", ["unchanged text"]),
|
||||||
|
currentText: "unchanged text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "binary",
|
||||||
|
file: makeApplyFile("newnote", ["AQID"]),
|
||||||
|
currentBinary: new Uint8Array([1, 2, 3]).buffer,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe("ConfigSync V2 content application", () => {
|
||||||
|
it.each(changedInputs)("writes changed $kind content, then stores it", async (input) => {
|
||||||
|
const fixture = setup(
|
||||||
|
input.file,
|
||||||
|
input.kind === "text" ? input.currentText : undefined,
|
||||||
|
input.kind === "binary" ? input.currentBinary : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(fixture.configSync.applyDataV2(fixture.data)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(fixture.storageAccess.ensureDir).toHaveBeenCalledWith(fixture.path);
|
||||||
|
expect(fixture.storageAccess.writeHiddenFileAuto).toHaveBeenCalledOnce();
|
||||||
|
expect(fixture.storageAccess.writeHiddenFileAuto).toHaveBeenCalledWith(
|
||||||
|
fixture.path,
|
||||||
|
input.kind === "text" ? input.expectedText : expect.any(ArrayBuffer),
|
||||||
|
STAT
|
||||||
|
);
|
||||||
|
if (input.kind === "binary") {
|
||||||
|
const written = fixture.storageAccess.writeHiddenFileAuto.mock.calls[0]?.[1];
|
||||||
|
expect(written).toBeInstanceOf(ArrayBuffer);
|
||||||
|
expect(Array.from(new Uint8Array(written as ArrayBuffer))).toEqual(input.expectedBytes);
|
||||||
|
}
|
||||||
|
expect(fixture.events).toEqual([
|
||||||
|
"ensure",
|
||||||
|
input.kind === "text" ? "read-text" : "read-binary",
|
||||||
|
"write",
|
||||||
|
"store",
|
||||||
|
]);
|
||||||
|
expect(fixture.storeCustomisationFileV2).toHaveBeenCalledWith(fixture.path, DEVICE_NAME);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the file timestamps captured before reading existing content", async () => {
|
||||||
|
const file = makeApplyFile("plain", ["replacement text"]);
|
||||||
|
const fixture = setup(file, "previous text");
|
||||||
|
fixture.storageAccess.readHiddenFileText.mockImplementation(async () => {
|
||||||
|
fixture.events.push("read-text");
|
||||||
|
file.mtime = STAT.mtime + 1;
|
||||||
|
file.ctime = STAT.ctime + 1;
|
||||||
|
return "previous text";
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fixture.configSync.applyDataV2(fixture.data)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(fixture.storageAccess.writeHiddenFileAuto).toHaveBeenCalledWith(fixture.path, "replacement text", STAT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(unchangedInputs)("skips writing and storing unchanged $kind content", async (input) => {
|
||||||
|
const fixture = setup(
|
||||||
|
input.file,
|
||||||
|
input.kind === "text" ? input.currentText : undefined,
|
||||||
|
input.kind === "binary" ? input.currentBinary : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(fixture.configSync.applyDataV2(fixture.data)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(fixture.storageAccess.writeHiddenFileAuto).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.events).toEqual(["ensure", input.kind === "text" ? "read-text" : "read-binary"]);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(
|
||||||
|
`The file ${input.file.filename} is already up-to-date`,
|
||||||
|
LOG_LEVEL_VERBOSE
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ kind: "text", file: makeApplyFile("plain", ["replacement text"]) },
|
||||||
|
{ kind: "binary", file: makeApplyFile("newnote", ["AQID"]) },
|
||||||
|
])("uses empty $kind content when reading the existing file fails", async ({ kind, file }) => {
|
||||||
|
const fixture = setup(file);
|
||||||
|
const readError = new Error("Could not read existing content");
|
||||||
|
if (kind === "text") {
|
||||||
|
fixture.storageAccess.readHiddenFileText.mockRejectedValueOnce(readError);
|
||||||
|
} else {
|
||||||
|
fixture.storageAccess.readHiddenFileBinary.mockRejectedValueOnce(readError);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(fixture.configSync.applyDataV2(fixture.data)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(fixture.storageAccess.writeHiddenFileAuto).toHaveBeenCalledOnce();
|
||||||
|
expect(fixture.storeCustomisationFileV2).toHaveBeenCalledWith(fixture.path, DEVICE_NAME);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(`Could not read the file ${file.filename}`, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(readError, LOG_LEVEL_VERBOSE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("awaits the write before storing the applied content", async () => {
|
||||||
|
const file = makeApplyFile("plain", ["replacement text"]);
|
||||||
|
const fixture = setup(file, "previous text");
|
||||||
|
let finishWrite!: () => void;
|
||||||
|
let signalWriteStarted!: () => void;
|
||||||
|
const writeGate = new Promise<void>((resolve) => {
|
||||||
|
finishWrite = resolve;
|
||||||
|
});
|
||||||
|
const writeStarted = new Promise<void>((resolve) => {
|
||||||
|
signalWriteStarted = resolve;
|
||||||
|
});
|
||||||
|
fixture.storageAccess.writeHiddenFileAuto.mockImplementation(async () => {
|
||||||
|
fixture.events.push("write-start");
|
||||||
|
signalWriteStarted();
|
||||||
|
await writeGate;
|
||||||
|
fixture.events.push("write-finished");
|
||||||
|
});
|
||||||
|
|
||||||
|
const applying = fixture.configSync.applyDataV2(fixture.data);
|
||||||
|
await writeStarted;
|
||||||
|
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
finishWrite();
|
||||||
|
await expect(applying).resolves.toBe(true);
|
||||||
|
expect(fixture.events.slice(-3)).toEqual(["write-start", "write-finished", "store"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false after a write failure and does not store the file", async () => {
|
||||||
|
const fixture = setup(makeApplyFile("plain", ["replacement text"]), "previous text");
|
||||||
|
const writeError = new Error("Write failed");
|
||||||
|
fixture.storageAccess.writeHiddenFileAuto.mockRejectedValueOnce(writeError);
|
||||||
|
|
||||||
|
await expect(fixture.configSync.applyDataV2(fixture.data)).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith("Applying Example plug-in.. Failed", LOG_LEVEL_NOTICE);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(writeError, LOG_LEVEL_VERBOSE);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -84,7 +84,7 @@ async function catalogue() {
|
|||||||
pluginListGeneration: 0,
|
pluginListGeneration: 0,
|
||||||
loadedManifest_mTime: new Map(),
|
loadedManifest_mTime: new Map(),
|
||||||
updatingV2Count: 0,
|
updatingV2Count: 0,
|
||||||
pluginScanProcessor: { clearQueue: vi.fn(), enqueue: vi.fn() },
|
pluginScanProcessor: { clearPending: vi.fn(), enqueue: vi.fn() },
|
||||||
_log: vi.fn(),
|
_log: vi.fn(),
|
||||||
storeCustomisationFileV2: vi.fn(async () => true),
|
storeCustomisationFileV2: vi.fn(async () => true),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ describe("ConfigSync commands", () => {
|
|||||||
expect(showPluginSyncModal).toHaveBeenCalledOnce();
|
expect(showPluginSyncModal).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cancels the pending configuration Notice before releasing its owned UI", () => {
|
it("cancels the pending configuration Notice before releasing its owned UI", async () => {
|
||||||
const notices = { hide: vi.fn() };
|
const notices = { hide: vi.fn() };
|
||||||
const periodicPluginSweepProcessor = { disable: vi.fn() };
|
const periodicPluginSweepProcessor = { disable: vi.fn() };
|
||||||
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
|
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
|
||||||
@@ -102,9 +102,13 @@ describe("ConfigSync commands", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
periodicPluginSweepProcessor,
|
periodicPluginSweepProcessor,
|
||||||
|
pluginList: [],
|
||||||
|
pluginListGeneration: 0,
|
||||||
|
loadedManifest_mTime: new Map(),
|
||||||
|
pluginScanProcessor: { clearPending: vi.fn(), stop: vi.fn(async () => {}) },
|
||||||
});
|
});
|
||||||
|
|
||||||
configSync.onunload();
|
await configSync.onunload();
|
||||||
|
|
||||||
expect(cancelTask).toHaveBeenCalledWith("config-sync:updated-configuration");
|
expect(cancelTask).toHaveBeenCalledWith("config-sync:updated-configuration");
|
||||||
expect(notices.hide).toHaveBeenCalledWith("config-sync:updated-configuration");
|
expect(notices.hide).toHaveBeenCalledWith("config-sync:updated-configuration");
|
||||||
|
|||||||
@@ -0,0 +1,591 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/deps.ts", () => ({
|
||||||
|
addIcon: vi.fn(),
|
||||||
|
diff_match_patch: class DiffMatchPatch {},
|
||||||
|
normalizePath: (path: string) => path,
|
||||||
|
parseYaml: vi.fn(),
|
||||||
|
Platform: {},
|
||||||
|
}));
|
||||||
|
vi.mock("./PluginDialogModal.ts", () => ({ PluginDialogModal: class PluginDialogModal {} }));
|
||||||
|
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
|
||||||
|
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||||
|
ConflictResolveModal: class ConflictResolveModal {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
|
||||||
|
vi.mock("@/common/utils.ts", () => ({
|
||||||
|
cancelTask: vi.fn(),
|
||||||
|
EVEN: Symbol("even"),
|
||||||
|
isCustomisationSyncMetadata: vi.fn(),
|
||||||
|
isPluginMetadata: vi.fn(),
|
||||||
|
scheduleTask: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||||
|
PeriodicProcessor: class PeriodicProcessor {
|
||||||
|
disable = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/events.ts", () => ({
|
||||||
|
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
|
||||||
|
eventHub: { onEvent: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/translation", () => ({ $msg: (message: string) => message }));
|
||||||
|
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({ getObsidianCommunityPluginManager: vi.fn() }));
|
||||||
|
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { getDocData } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||||
|
import {
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
|
type AnyEntry,
|
||||||
|
type FilePathWithPrefix,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
|
||||||
|
import { ConfigSync, pluginIsEnumerating, pluginList } from "./CmdConfigSync";
|
||||||
|
|
||||||
|
const queueOwners: ConfigSync[] = [];
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: () => void;
|
||||||
|
const promise = new Promise<void>((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForCondition(predicate: () => boolean, message: string) {
|
||||||
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
|
if (predicate()) return;
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueEntry(path: string, data: unknown = {}): AnyEntry {
|
||||||
|
const documentPath = path as FilePathWithPrefix;
|
||||||
|
return {
|
||||||
|
_id: documentPath,
|
||||||
|
path: documentPath,
|
||||||
|
mtime: 2_000,
|
||||||
|
data: [JSON.stringify(data)],
|
||||||
|
} as unknown as AnyEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyCatalogueEntry(path: string, name: string, mtime = 2_000): AnyEntry {
|
||||||
|
return queueEntry(path, {
|
||||||
|
category: "PLUGIN_MAIN",
|
||||||
|
name,
|
||||||
|
term: "device",
|
||||||
|
mtime,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
filename: `plugins/${name}/main.js`,
|
||||||
|
data: [`legacy source for ${name}`],
|
||||||
|
mtime,
|
||||||
|
size: name.length,
|
||||||
|
hash: `hash-${name}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createQueueConfigSync(
|
||||||
|
settings: typeof DEFAULT_SETTINGS,
|
||||||
|
localDatabase: unknown,
|
||||||
|
services: unknown = {
|
||||||
|
API: { addLog: vi.fn(), addRibbonIcon: vi.fn() },
|
||||||
|
path: { getPath: (entry: { path: string }) => entry.path },
|
||||||
|
context: { notices: { hide: vi.fn() } },
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
vi.spyOn(ConfigSync.prototype, "onBindFunction").mockImplementation(() => {});
|
||||||
|
const configSync = new ConfigSync({
|
||||||
|
settings,
|
||||||
|
localDatabase,
|
||||||
|
services,
|
||||||
|
} as unknown as ConstructorParameters<typeof ConfigSync>[0]);
|
||||||
|
queueOwners.push(configSync);
|
||||||
|
return configSync;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const configSync of queueOwners.splice(0)) {
|
||||||
|
await configSync.pluginScanProcessor.stop();
|
||||||
|
}
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
pluginList.set([]);
|
||||||
|
pluginIsEnumerating.set(false);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ConfigSync legacy catalogue queue", () => {
|
||||||
|
it("starts duplicate entries in FIFO order with ten concurrent items", async () => {
|
||||||
|
const settings = { ...DEFAULT_SETTINGS, usePluginSync: true, usePluginSyncV2: true };
|
||||||
|
const configSync = createQueueConfigSync(settings, {});
|
||||||
|
const paths = [
|
||||||
|
"ix:device/PLUGIN_MAIN/first.md",
|
||||||
|
"ix:device/PLUGIN_MAIN/second.md",
|
||||||
|
"ix:device/PLUGIN_MAIN/duplicate.md",
|
||||||
|
"ix:device/PLUGIN_MAIN/third.md",
|
||||||
|
"ix:device/PLUGIN_MAIN/duplicate.md",
|
||||||
|
"ix:device/PLUGIN_MAIN/fourth.md",
|
||||||
|
"ix:device/PLUGIN_MAIN/fifth.md",
|
||||||
|
"ix:device/PLUGIN_MAIN/sixth.md",
|
||||||
|
"ix:device/PLUGIN_MAIN/seventh.md",
|
||||||
|
"ix:device/PLUGIN_MAIN/eighth.md",
|
||||||
|
"ix:device/PLUGIN_MAIN/ninth.md",
|
||||||
|
"ix:device/PLUGIN_MAIN/tenth.md",
|
||||||
|
];
|
||||||
|
const gate = deferred();
|
||||||
|
const started: string[] = [];
|
||||||
|
let active = 0;
|
||||||
|
let maximumActive = 0;
|
||||||
|
vi.spyOn(configSync, "migrateV1ToV2").mockImplementation(async (_showMessage, entry) => {
|
||||||
|
started.push(entry.path);
|
||||||
|
active++;
|
||||||
|
maximumActive = Math.max(maximumActive, active);
|
||||||
|
await gate.promise;
|
||||||
|
active--;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const path of paths)
|
||||||
|
configSync.pluginScanProcessor.enqueue({ entry: queueEntry(path), generation: 0 });
|
||||||
|
await waitForCondition(() => started.length === 10, "The first ten queue items did not start");
|
||||||
|
|
||||||
|
expect(started).toEqual(paths.slice(0, 10));
|
||||||
|
expect(maximumActive).toBe(10);
|
||||||
|
|
||||||
|
gate.resolve();
|
||||||
|
await expect(configSync.pluginScanProcessor.waitForIdle()).resolves.toBeUndefined();
|
||||||
|
expect(started).toEqual(paths);
|
||||||
|
} finally {
|
||||||
|
gate.resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues with queued items after a migration throws", async () => {
|
||||||
|
const settings = { ...DEFAULT_SETTINGS, usePluginSync: true, usePluginSyncV2: true };
|
||||||
|
const configSync = createQueueConfigSync(settings, {});
|
||||||
|
const paths = Array.from({ length: 11 }, (_, index) => `ix:device/PLUGIN_MAIN/item-${index}.md`);
|
||||||
|
const gate = deferred();
|
||||||
|
const started: string[] = [];
|
||||||
|
vi.spyOn(configSync, "migrateV1ToV2").mockImplementation(async (_showMessage, entry) => {
|
||||||
|
started.push(entry.path);
|
||||||
|
if (entry.path === paths[0]) throw new Error("migration failed");
|
||||||
|
if (paths.slice(1, 10).includes(entry.path)) await gate.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const path of paths)
|
||||||
|
configSync.pluginScanProcessor.enqueue({ entry: queueEntry(path), generation: 0 });
|
||||||
|
await waitForCondition(() => started.length === 10, "The first ten queue items did not start");
|
||||||
|
await waitForCondition(() => started.length === 11, "The item after the failed migration did not start");
|
||||||
|
|
||||||
|
gate.resolve();
|
||||||
|
await expect(configSync.pluginScanProcessor.waitForIdle()).resolves.toBeUndefined();
|
||||||
|
expect(started).toEqual(paths);
|
||||||
|
} finally {
|
||||||
|
gate.resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not restore an old read result after reloading a newer catalogue", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const oldPath = "ix:device/PLUGIN_MAIN/old" as FilePathWithPrefix;
|
||||||
|
const newerPath = "ix:device/PLUGIN_MAIN/newer" as FilePathWithPrefix;
|
||||||
|
const oldEntry = legacyCatalogueEntry(oldPath, "old");
|
||||||
|
const newerEntry = legacyCatalogueEntry(newerPath, "newer", 3_000);
|
||||||
|
const oldRead = deferred();
|
||||||
|
let scanCount = 0;
|
||||||
|
let oldReadStarted = false;
|
||||||
|
const localDatabase = {
|
||||||
|
findEntries: vi.fn(async function* () {
|
||||||
|
for (const entry of scanCount++ === 0 ? [oldEntry] : [newerEntry]) yield entry;
|
||||||
|
}),
|
||||||
|
getDBEntry: vi.fn(async (path: FilePathWithPrefix) => {
|
||||||
|
if (path === oldPath) {
|
||||||
|
oldReadStarted = true;
|
||||||
|
await oldRead.promise;
|
||||||
|
return oldEntry;
|
||||||
|
}
|
||||||
|
return newerEntry;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const settings = { ...DEFAULT_SETTINGS, usePluginSync: true, usePluginSyncV2: false };
|
||||||
|
const configSync = createQueueConfigSync(settings, localDatabase);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await configSync.updatePluginList(false);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
await waitForCondition(() => oldReadStarted, "The old catalogue read did not start");
|
||||||
|
|
||||||
|
await configSync.reloadPluginList(false);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(configSync.pluginList.map((entry) => entry.name)).toEqual(["newer"]);
|
||||||
|
|
||||||
|
oldRead.resolve();
|
||||||
|
await expect(configSync.pluginScanProcessor.waitForIdle()).resolves.toBeUndefined();
|
||||||
|
expect(configSync.pluginList.map((entry) => entry.name)).toEqual(["newer"]);
|
||||||
|
expect(get(pluginList).map((entry) => entry.name)).toEqual(["newer"]);
|
||||||
|
} finally {
|
||||||
|
oldRead.resolve();
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["catalogue update", "settings application", "temporary suspension"] as const)(
|
||||||
|
"does not restore an active legacy read after disabling through %s and re-enabling",
|
||||||
|
async (route) => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const oldPath = "ix:device/PLUGIN_MAIN/old" as FilePathWithPrefix;
|
||||||
|
const oldEntry = legacyCatalogueEntry(oldPath, "old");
|
||||||
|
const oldRead = deferred();
|
||||||
|
let oldReadStarted = false;
|
||||||
|
let scanCount = 0;
|
||||||
|
const localDatabase = {
|
||||||
|
findEntries: vi.fn(async function* () {
|
||||||
|
if (scanCount++ === 0) yield oldEntry;
|
||||||
|
}),
|
||||||
|
getDBEntry: vi.fn(async () => {
|
||||||
|
oldReadStarted = true;
|
||||||
|
await oldRead.promise;
|
||||||
|
return oldEntry;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const settings = { ...DEFAULT_SETTINGS, usePluginSync: true, usePluginSyncV2: false };
|
||||||
|
const configSync = createQueueConfigSync(settings, localDatabase);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await configSync.updatePluginList(false);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
await waitForCondition(() => oldReadStarted, "The active legacy read did not start");
|
||||||
|
|
||||||
|
settings.usePluginSync = false;
|
||||||
|
if (route === "catalogue update") {
|
||||||
|
await configSync.updatePluginList(false);
|
||||||
|
} else if (route === "settings application") {
|
||||||
|
await configSync._everyRealizeSettingSyncMode();
|
||||||
|
} else {
|
||||||
|
await (
|
||||||
|
configSync as unknown as { _allSuspendExtraSync(): Promise<boolean> }
|
||||||
|
)._allSuspendExtraSync();
|
||||||
|
}
|
||||||
|
settings.usePluginSync = true;
|
||||||
|
await configSync.updatePluginList(false);
|
||||||
|
|
||||||
|
oldRead.resolve();
|
||||||
|
await expect(configSync.pluginScanProcessor.waitForIdle()).resolves.toBeUndefined();
|
||||||
|
expect(configSync.pluginList).toEqual([]);
|
||||||
|
expect(get(pluginList)).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
oldRead.resolve();
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("stops admission on unload and joins a read without changing the replacement instance's list", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const entry = legacyCatalogueEntry("ix:device/PLUGIN_MAIN/old", "old");
|
||||||
|
const read = deferred();
|
||||||
|
const localDatabase = {
|
||||||
|
findEntries: vi.fn(async function* () {
|
||||||
|
yield entry;
|
||||||
|
}),
|
||||||
|
getDBEntry: vi.fn(async () => {
|
||||||
|
await read.promise;
|
||||||
|
return entry;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const settings = { ...DEFAULT_SETTINGS, usePluginSync: true, usePluginSyncV2: false };
|
||||||
|
const configSync = createQueueConfigSync(settings, localDatabase);
|
||||||
|
try {
|
||||||
|
await configSync.updatePluginList(false);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(localDatabase.getDBEntry).toHaveBeenCalledOnce();
|
||||||
|
let stopped = false;
|
||||||
|
const done = configSync.onunload().then(() => {
|
||||||
|
stopped = true;
|
||||||
|
});
|
||||||
|
const replacement = { name: "replacement" } as (typeof configSync.pluginList)[number];
|
||||||
|
pluginList.set([replacement]);
|
||||||
|
await configSync.updatePluginList(false);
|
||||||
|
await configSync.reloadPluginList(false);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(localDatabase.findEntries).toHaveBeenCalledOnce();
|
||||||
|
expect(stopped).toBe(false);
|
||||||
|
expect(configSync.pluginScanProcessor.remaining).toBe(1);
|
||||||
|
read.resolve();
|
||||||
|
await done;
|
||||||
|
expect(configSync.pluginList).toEqual([]);
|
||||||
|
expect(get(pluginList)).toEqual([replacement]);
|
||||||
|
expect(configSync.pluginScanProcessor.remaining).toBe(0);
|
||||||
|
} finally {
|
||||||
|
read.resolve();
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels unstarted legacy reads when the first ten are blocked and the module is disabled", async () => {
|
||||||
|
const paths = Array.from({ length: 12 }, (_, index) => `ix:device/PLUGIN_MAIN/item-${index}`);
|
||||||
|
const entries = paths.map((path, index) => legacyCatalogueEntry(path, `item-${index}`));
|
||||||
|
const gates = entries.slice(0, 10).map(() => deferred());
|
||||||
|
const started: string[] = [];
|
||||||
|
const localDatabase = {
|
||||||
|
findEntries: vi.fn(async function* () {
|
||||||
|
for (const entry of entries) yield entry;
|
||||||
|
}),
|
||||||
|
getDBEntry: vi.fn(async (path: FilePathWithPrefix) => {
|
||||||
|
const index = paths.indexOf(path);
|
||||||
|
started.push(path);
|
||||||
|
if (index >= 0 && index < gates.length) await gates[index].promise;
|
||||||
|
return entries[index];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const settings = { ...DEFAULT_SETTINGS, usePluginSync: true, usePluginSyncV2: false };
|
||||||
|
const configSync = createQueueConfigSync(settings, localDatabase);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await configSync.updatePluginList(false);
|
||||||
|
await waitForCondition(() => started.length === 10, "The first ten legacy reads did not start");
|
||||||
|
|
||||||
|
settings.usePluginSync = false;
|
||||||
|
await configSync.updatePluginList(false);
|
||||||
|
for (const gate of gates) gate.resolve();
|
||||||
|
await expect(configSync.pluginScanProcessor.waitForIdle()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(started).toEqual(paths.slice(0, 10));
|
||||||
|
} finally {
|
||||||
|
for (const gate of gates) gate.resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finishes a started V1-to-V2 migration but does not start pending migrations after disable", async () => {
|
||||||
|
const fixture = await createOptionalFileSyncDatabase();
|
||||||
|
const v1Path = "ix:device/PLUGIN_MAIN/migrate.md" as FilePathWithPrefix;
|
||||||
|
const v2Path = "ix:device/PLUGIN_MAIN/migrate%main.js" as FilePathWithPrefix;
|
||||||
|
const source = "module.exports = 'migrated';";
|
||||||
|
const data = {
|
||||||
|
category: "PLUGIN_MAIN",
|
||||||
|
name: "migrate",
|
||||||
|
term: "device",
|
||||||
|
mtime: 2_000,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
filename: "plugins/migrate/main.js",
|
||||||
|
data: [source],
|
||||||
|
mtime: 2_000,
|
||||||
|
size: source.length,
|
||||||
|
hash: "migration-source-hash",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const settings = fixture.settings;
|
||||||
|
const configSync = createQueueConfigSync(settings, fixture.localDatabase, {
|
||||||
|
...fixture.services,
|
||||||
|
API: { ...fixture.services.API, addRibbonIcon: vi.fn() },
|
||||||
|
});
|
||||||
|
const pendingGate = deferred();
|
||||||
|
const putGate = deferred();
|
||||||
|
const migrationFinished = deferred();
|
||||||
|
const started: string[] = [];
|
||||||
|
let v2PutStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
fixture.localDatabase.putDBEntry(optionalFileEntry(v1Path, JSON.stringify(data)))
|
||||||
|
).resolves.toMatchObject({ ok: true });
|
||||||
|
|
||||||
|
const loadedLegacy = {
|
||||||
|
...optionalFileEntry(v1Path, JSON.stringify(data)),
|
||||||
|
data: [JSON.stringify(data)],
|
||||||
|
} as unknown as Awaited<ReturnType<typeof fixture.localDatabase.getDBEntry>>;
|
||||||
|
let migrationRead = false;
|
||||||
|
const read = fixture.localDatabase.getDBEntry.bind(fixture.localDatabase);
|
||||||
|
vi.spyOn(fixture.localDatabase, "getDBEntry").mockImplementation(async (path, ...args) => {
|
||||||
|
if (path === v1Path && !migrationRead) {
|
||||||
|
migrationRead = true;
|
||||||
|
return loadedLegacy;
|
||||||
|
}
|
||||||
|
return await read(path, ...args);
|
||||||
|
});
|
||||||
|
const migrate = configSync.migrateV1ToV2.bind(configSync);
|
||||||
|
vi.spyOn(configSync, "migrateV1ToV2").mockImplementation(async (showMessage, entry) => {
|
||||||
|
started.push(entry.path);
|
||||||
|
if (entry.path === v1Path) {
|
||||||
|
try {
|
||||||
|
await migrate(showMessage, entry);
|
||||||
|
} finally {
|
||||||
|
migrationFinished.resolve();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await pendingGate.promise;
|
||||||
|
});
|
||||||
|
const put = fixture.localDatabase.putDBEntry.bind(fixture.localDatabase);
|
||||||
|
vi.spyOn(fixture.localDatabase, "putDBEntry").mockImplementation(async (...args) => {
|
||||||
|
if ((args[0] as { path?: string }).path === v2Path) {
|
||||||
|
v2PutStarted = true;
|
||||||
|
await putGate.promise;
|
||||||
|
}
|
||||||
|
return await put(...args);
|
||||||
|
});
|
||||||
|
|
||||||
|
const paths = [
|
||||||
|
v1Path,
|
||||||
|
...Array.from(
|
||||||
|
{ length: 10 },
|
||||||
|
(_, index) => `ix:device/PLUGIN_MAIN/pending-${index}.md` as FilePathWithPrefix
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (const path of paths)
|
||||||
|
configSync.pluginScanProcessor.enqueue({ entry: queueEntry(path), generation: 0 });
|
||||||
|
await waitForCondition(
|
||||||
|
() => started.length === 10,
|
||||||
|
`The first ten migrations did not start: ${started.join(", ")}`
|
||||||
|
);
|
||||||
|
await waitForCondition(() => v2PutStarted, "The migration did not reach its write");
|
||||||
|
|
||||||
|
settings.usePluginSync = false;
|
||||||
|
await configSync.updatePluginList(false);
|
||||||
|
putGate.resolve();
|
||||||
|
await migrationFinished.promise;
|
||||||
|
expect.soft(started).toEqual(paths.slice(0, 10));
|
||||||
|
|
||||||
|
pendingGate.resolve();
|
||||||
|
await expect(configSync.pluginScanProcessor.waitForIdle()).resolves.toBeUndefined();
|
||||||
|
expect.soft(started).toEqual(paths.slice(0, 10));
|
||||||
|
|
||||||
|
const migrated = await fixture.localDatabase.getDBEntry(v2Path, undefined, false, false);
|
||||||
|
expect(migrated).not.toBe(false);
|
||||||
|
if (migrated !== false) expect(getDocData(migrated.data)).toContain(source);
|
||||||
|
await expect(fixture.localDatabase.getDBEntry(v1Path)).resolves.toBe(false);
|
||||||
|
} finally {
|
||||||
|
putGate.resolve();
|
||||||
|
pendingGate.resolve();
|
||||||
|
await configSync.pluginScanProcessor.waitForIdle();
|
||||||
|
await fixture.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads legacy catalogue contents through the database with native timers", async () => {
|
||||||
|
const fixture = await createOptionalFileSyncDatabase();
|
||||||
|
const path = "ix:device/PLUGIN_MAIN/native-timers" as FilePathWithPrefix;
|
||||||
|
const data = {
|
||||||
|
category: "PLUGIN_MAIN",
|
||||||
|
name: "native-timers",
|
||||||
|
term: "device",
|
||||||
|
mtime: 2_000,
|
||||||
|
files: [{ filename: "main.js", data: ["stored content"], mtime: 2_000, size: 14, hash: "stored-hash" }],
|
||||||
|
};
|
||||||
|
let configSync: ConfigSync | undefined;
|
||||||
|
try {
|
||||||
|
fixture.settings.usePluginSyncV2 = false;
|
||||||
|
await expect(
|
||||||
|
fixture.localDatabase.putDBEntry(optionalFileEntry(path, JSON.stringify(data)))
|
||||||
|
).resolves.toMatchObject({ ok: true });
|
||||||
|
vi.spyOn(ConfigSync.prototype, "onBindFunction").mockImplementation(() => {});
|
||||||
|
configSync = new ConfigSync({
|
||||||
|
settings: fixture.settings,
|
||||||
|
localDatabase: fixture.localDatabase,
|
||||||
|
services: {
|
||||||
|
...fixture.services,
|
||||||
|
API: { ...fixture.services.API, addRibbonIcon: vi.fn() },
|
||||||
|
},
|
||||||
|
} as unknown as ConstructorParameters<typeof ConfigSync>[0]);
|
||||||
|
queueOwners.push(configSync);
|
||||||
|
await configSync.updatePluginList(false);
|
||||||
|
await expect(configSync.pluginScanProcessor.waitForIdle()).resolves.toBeUndefined();
|
||||||
|
expect(configSync.pluginList).toEqual([
|
||||||
|
{
|
||||||
|
...data,
|
||||||
|
documentPath: path,
|
||||||
|
files: [{ ...data.files[0], data: ["stored-hash"] }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(get(pluginList)).toEqual(configSync.pluginList);
|
||||||
|
expect(configSync.pluginScanProcessor.remaining).toBe(0);
|
||||||
|
} finally {
|
||||||
|
await configSync?.pluginScanProcessor.stop();
|
||||||
|
await fixture.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns after enumeration, then loads a queued legacy catalogue item after its configured delay", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
pluginList.set([]);
|
||||||
|
pluginIsEnumerating.set(false);
|
||||||
|
|
||||||
|
const path = "ix:device/PLUGIN_MAIN/example" as FilePathWithPrefix;
|
||||||
|
const catalogueData = {
|
||||||
|
category: "PLUGIN_MAIN",
|
||||||
|
name: "example",
|
||||||
|
term: "device",
|
||||||
|
mtime: 2_000,
|
||||||
|
files: [
|
||||||
|
{ filename: "main.js", data: ["legacy plugin source"], mtime: 2_000, size: 20, hash: "legacy-hash" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const entry = { _id: path, path, mtime: 2_000, data: [JSON.stringify(catalogueData)] };
|
||||||
|
const readGate = deferred();
|
||||||
|
let loadHasStarted = false;
|
||||||
|
const localDatabase = {
|
||||||
|
findEntries: vi.fn(async function* () {
|
||||||
|
yield entry;
|
||||||
|
}),
|
||||||
|
getDBEntry: vi.fn(async () => {
|
||||||
|
loadHasStarted = true;
|
||||||
|
await readGate.promise;
|
||||||
|
return entry;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
vi.spyOn(ConfigSync.prototype, "onBindFunction").mockImplementation(() => {});
|
||||||
|
const configSync = new ConfigSync({
|
||||||
|
settings: { ...DEFAULT_SETTINGS, usePluginSync: true, usePluginSyncV2: false },
|
||||||
|
localDatabase,
|
||||||
|
services: {
|
||||||
|
API: { addLog: vi.fn(), addRibbonIcon: vi.fn() },
|
||||||
|
path: { getPath: (document: { path: string }) => document.path },
|
||||||
|
},
|
||||||
|
} as unknown as ConstructorParameters<typeof ConfigSync>[0]);
|
||||||
|
queueOwners.push(configSync);
|
||||||
|
|
||||||
|
let drained = false;
|
||||||
|
try {
|
||||||
|
await configSync.updatePluginList(false);
|
||||||
|
|
||||||
|
expect(configSync.pluginScanProcessor.remaining).toBe(1);
|
||||||
|
expect(configSync.pluginList).toEqual([]);
|
||||||
|
expect(get(pluginIsEnumerating)).toBe(false);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(99);
|
||||||
|
expect(loadHasStarted).toBe(false);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(loadHasStarted).toBe(true);
|
||||||
|
expect(configSync.pluginScanProcessor.remaining).toBe(1);
|
||||||
|
const drain = configSync.pluginScanProcessor.waitForIdle();
|
||||||
|
void drain.then(() => {
|
||||||
|
drained = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
readGate.resolve();
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await waitForCondition(() => drained, "The legacy catalogue queue did not drain");
|
||||||
|
|
||||||
|
expect(configSync.pluginList).toEqual([
|
||||||
|
{
|
||||||
|
...catalogueData,
|
||||||
|
documentPath: path,
|
||||||
|
files: [{ ...catalogueData.files[0], data: ["legacy-hash"] }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(get(pluginList)).toEqual(configSync.pluginList);
|
||||||
|
expect(configSync.pluginScanProcessor.remaining).toBe(0);
|
||||||
|
expect(get(pluginIsEnumerating)).toBe(false);
|
||||||
|
} finally {
|
||||||
|
readGate.resolve();
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/deps.ts", () => ({
|
||||||
|
addIcon: vi.fn(),
|
||||||
|
diff_match_patch: class DiffMatchPatch {},
|
||||||
|
normalizePath: vi.fn((path: string) => path),
|
||||||
|
parseYaml: vi.fn(),
|
||||||
|
Platform: {},
|
||||||
|
}));
|
||||||
|
vi.mock("./PluginDialogModal.ts", () => ({
|
||||||
|
PluginDialogModal: class PluginDialogModal {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||||
|
JsonResolveModal: class JsonResolveModal {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||||
|
ConflictResolveModal: class ConflictResolveModal {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/LiveSyncCommands.ts", () => ({
|
||||||
|
LiveSyncCommands: class LiveSyncCommands {
|
||||||
|
core!: { services: unknown; localDatabase: unknown };
|
||||||
|
get localDatabase() {
|
||||||
|
return this.core.localDatabase;
|
||||||
|
}
|
||||||
|
get services() {
|
||||||
|
return this.core.services;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/types.ts", () => ({
|
||||||
|
ICXHeader: "ix:",
|
||||||
|
PERIODIC_PLUGIN_SWEEP: 60,
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/utils.ts", () => ({
|
||||||
|
cancelTask: vi.fn(),
|
||||||
|
EVEN: Symbol("even"),
|
||||||
|
isCustomisationSyncMetadata: vi.fn(),
|
||||||
|
isPluginMetadata: vi.fn(),
|
||||||
|
scheduleTask: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||||
|
PeriodicProcessor: class PeriodicProcessor {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/events.ts", () => ({
|
||||||
|
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
|
||||||
|
eventHub: {
|
||||||
|
onEvent: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/translation", () => ({
|
||||||
|
$msg: vi.fn((message: string) => message),
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||||
|
getObsidianCommunityPluginManager: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("octagonal-wheels/concurrency/scoped.iteration", { spy: true });
|
||||||
|
|
||||||
|
import { LOG_LEVEL_VERBOSE, type FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import * as iterationModule from "octagonal-wheels/concurrency/scoped.iteration";
|
||||||
|
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||||
|
import { ConfigSync } from "./CmdConfigSync";
|
||||||
|
|
||||||
|
const term = "device";
|
||||||
|
const prefix = `ix:${term}/`;
|
||||||
|
const keyOf = (path: string) => prefix + path;
|
||||||
|
type Row = { _id: string; path: string };
|
||||||
|
const row = (name: string, path = name): Row => ({ _id: keyOf(name), path });
|
||||||
|
|
||||||
|
function setup(files: string[] = [], rows: Row[] = []) {
|
||||||
|
const events: string[] = [];
|
||||||
|
const findEntries = vi.fn(async function* () {
|
||||||
|
yield* rows;
|
||||||
|
});
|
||||||
|
const scanInternalFiles = vi.fn(async () => files as FilePath[]);
|
||||||
|
const storeCustomisationFileV2 = vi.fn(async (path: string, _term: string) => {
|
||||||
|
events.push(`store:${path}`);
|
||||||
|
});
|
||||||
|
const deleteConfigOnDatabase = vi.fn(async (key: string) => {
|
||||||
|
events.push(`delete:${key}`);
|
||||||
|
});
|
||||||
|
const updatePluginList = vi.fn(async (_showMessage: boolean) => {});
|
||||||
|
const log = vi.fn();
|
||||||
|
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
|
||||||
|
Object.assign(configSync, {
|
||||||
|
core: {
|
||||||
|
settings: { usePluginSyncV2: true },
|
||||||
|
localDatabase: { findEntries },
|
||||||
|
services: { setting: { getDeviceAndVaultName: () => term } },
|
||||||
|
},
|
||||||
|
scanInternalFiles,
|
||||||
|
isTargetPath: (path: string) => path !== "ignored",
|
||||||
|
filenameWithUnifiedKey: (path: string, _term: string) => keyOf(path),
|
||||||
|
unifiedKeyPrefixOfTerminal: (_term: string) => prefix,
|
||||||
|
storeCustomisationFileV2,
|
||||||
|
deleteConfigOnDatabase,
|
||||||
|
updatePluginList,
|
||||||
|
_log: log,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
configSync,
|
||||||
|
events,
|
||||||
|
findEntries,
|
||||||
|
scanInternalFiles,
|
||||||
|
storeCustomisationFileV2,
|
||||||
|
deleteConfigOnDatabase,
|
||||||
|
updatePluginList,
|
||||||
|
log,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ConfigSync V2 scan behaviour", () => {
|
||||||
|
it("finishes existing database entries before storing extra files and retains target filtering", async () => {
|
||||||
|
const fixture = setup(
|
||||||
|
["matched", "extra", "ignored"],
|
||||||
|
[row("matched"), row("stale"), row("encoded", "encoded%path")]
|
||||||
|
);
|
||||||
|
await fixture.configSync.scanAllConfigFiles(false);
|
||||||
|
|
||||||
|
expect(fixture.events).toEqual(["store:matched", `delete:${keyOf("stale")}`, "store:extra"]);
|
||||||
|
expect(fixture.storeCustomisationFileV2.mock.calls).toEqual([
|
||||||
|
["matched", term],
|
||||||
|
["extra", term],
|
||||||
|
]);
|
||||||
|
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledExactlyOnceWith(keyOf("stale"));
|
||||||
|
expect(fixture.findEntries).toHaveBeenCalledExactlyOnceWith(prefix, `${prefix}\u{10ffff}`, {
|
||||||
|
include_docs: true,
|
||||||
|
});
|
||||||
|
expect(fixture.updatePluginList).toHaveBeenCalledExactlyOnceWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finishes database enumeration before dispatching database work", async () => {
|
||||||
|
const fixture = setup();
|
||||||
|
const enumerationPaused = promiseWithResolvers<void>();
|
||||||
|
const continueEnumeration = promiseWithResolvers<void>();
|
||||||
|
fixture.findEntries.mockImplementation(async function* () {
|
||||||
|
yield row("stale");
|
||||||
|
enumerationPaused.resolve();
|
||||||
|
await continueEnumeration.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
const scan = fixture.configSync.scanAllConfigFiles(false);
|
||||||
|
try {
|
||||||
|
await enumerationPaused.promise;
|
||||||
|
expect(fixture.deleteConfigOnDatabase).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
continueEnumeration.resolve();
|
||||||
|
await scan;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledExactlyOnceWith(keyOf("stale"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("limits actual database operations to ten and completes that batch before the extra batch", async () => {
|
||||||
|
const fixture = setup(
|
||||||
|
["extra"],
|
||||||
|
Array.from({ length: 11 }, (_, index) => row(String(index)))
|
||||||
|
);
|
||||||
|
const gates = Array.from({ length: 11 }, () => promiseWithResolvers<void>());
|
||||||
|
const tenStarted = promiseWithResolvers<void>();
|
||||||
|
const lastStarted = promiseWithResolvers<void>();
|
||||||
|
let active = 0;
|
||||||
|
let maximum = 0;
|
||||||
|
let started = 0;
|
||||||
|
fixture.deleteConfigOnDatabase.mockImplementation(async (key) => {
|
||||||
|
const index = Number(key.slice(prefix.length));
|
||||||
|
active++;
|
||||||
|
maximum = Math.max(maximum, active);
|
||||||
|
started++;
|
||||||
|
if (started === 10) tenStarted.resolve();
|
||||||
|
if (index === 10) lastStarted.resolve();
|
||||||
|
try {
|
||||||
|
await gates[index].promise;
|
||||||
|
} finally {
|
||||||
|
active--;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const scan = fixture.configSync.scanAllConfigFiles(false);
|
||||||
|
try {
|
||||||
|
await tenStarted.promise;
|
||||||
|
expect(active).toBe(10);
|
||||||
|
expect(started).toBe(10);
|
||||||
|
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||||
|
gates[0].resolve();
|
||||||
|
await lastStarted.promise;
|
||||||
|
expect(active).toBe(10);
|
||||||
|
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
gates.forEach((gate) => gate.resolve());
|
||||||
|
await scan;
|
||||||
|
}
|
||||||
|
expect(maximum).toBe(10);
|
||||||
|
expect(active).toBe(0);
|
||||||
|
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledTimes(11);
|
||||||
|
expect(fixture.storeCustomisationFileV2).toHaveBeenCalledExactlyOnceWith("extra", term);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs a database processing error and continues through the extra batch", async () => {
|
||||||
|
const fixture = setup(["extra"], [row("bad"), row("other")]);
|
||||||
|
const error = new Error("delete");
|
||||||
|
fixture.deleteConfigOnDatabase.mockRejectedValueOnce(error);
|
||||||
|
await fixture.configSync.scanAllConfigFiles(false);
|
||||||
|
|
||||||
|
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(`scanAllConfigFiles - Error: ${keyOf("bad")}`, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(fixture.storeCustomisationFileV2).toHaveBeenCalledExactlyOnceWith("extra", term);
|
||||||
|
expect(fixture.updatePluginList).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains a matched local file for the extra batch when its first store fails", async () => {
|
||||||
|
const fixture = setup(["matched"], [row("matched")]);
|
||||||
|
const error = new Error("first store");
|
||||||
|
fixture.storeCustomisationFileV2.mockRejectedValueOnce(error);
|
||||||
|
await fixture.configSync.scanAllConfigFiles(false);
|
||||||
|
|
||||||
|
expect(fixture.storeCustomisationFileV2.mock.calls).toEqual([
|
||||||
|
["matched", term],
|
||||||
|
["matched", term],
|
||||||
|
]);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(`scanAllConfigFiles - Error: ${keyOf("matched")}`, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(fixture.events).toEqual(["store:matched"]);
|
||||||
|
expect(fixture.updatePluginList).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs an extra-file processing error and continues with the remaining files", async () => {
|
||||||
|
const fixture = setup(["bad", "other"]);
|
||||||
|
const error = new Error("store");
|
||||||
|
fixture.storeCustomisationFileV2.mockRejectedValueOnce(error);
|
||||||
|
await fixture.configSync.scanAllConfigFiles(false);
|
||||||
|
|
||||||
|
expect(fixture.storeCustomisationFileV2.mock.calls).toEqual([
|
||||||
|
["bad", term],
|
||||||
|
["other", term],
|
||||||
|
]);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith("scanAllConfigFiles - Error: bad", LOG_LEVEL_VERBOSE);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(fixture.updatePluginList).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ConfigSync scoped iteration composition", () => {
|
||||||
|
it("uses bounded child scopes for both batches with the same shared values", async () => {
|
||||||
|
const fixture = setup(["matched", "extra"], [row("matched"), row("stale")]);
|
||||||
|
await fixture.configSync.scanAllConfigFiles(false);
|
||||||
|
|
||||||
|
expect(iterationModule.forEachInScope).toHaveBeenCalledTimes(2);
|
||||||
|
const [database, extra] = vi.mocked(iterationModule.forEachInScope).mock.calls;
|
||||||
|
expect(database[0]).toBe(extra[0]);
|
||||||
|
expect(database[0].value).toEqual({ term, localFileMap: new Map([[keyOf("extra"), "extra"]]) });
|
||||||
|
expect(database[2]).toEqual({ concurrency: 10 });
|
||||||
|
expect(extra[2]).toEqual({ concurrency: 10 });
|
||||||
|
expect(fixture.events).toEqual(["store:matched", `delete:${keyOf("stale")}`, "store:extra"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["database", "extra"])("propagates a %s runner failure outside per-item recovery", async (batch) => {
|
||||||
|
const fixture = setup(["extra"], [row("stale")]);
|
||||||
|
const error = new Error("iteration");
|
||||||
|
const actual = await vi.importActual<typeof import("octagonal-wheels/concurrency/scoped.iteration")>(
|
||||||
|
"octagonal-wheels/concurrency/scoped.iteration"
|
||||||
|
);
|
||||||
|
if (batch === "extra") {
|
||||||
|
vi.mocked(iterationModule.forEachInScope).mockImplementationOnce(actual.forEachInScope);
|
||||||
|
}
|
||||||
|
vi.mocked(iterationModule.forEachInScope).mockRejectedValueOnce(error);
|
||||||
|
await expect(fixture.configSync.scanAllConfigFiles(false)).rejects.toBe(error);
|
||||||
|
|
||||||
|
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledTimes(batch === "extra" ? 1 : 0);
|
||||||
|
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.log).not.toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(fixture.updatePluginList).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,11 +55,11 @@ import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_b
|
|||||||
import { cancelTask, EVEN, isCustomisationSyncMetadata, isPluginMetadata, scheduleTask } from "@/common/utils.ts";
|
import { cancelTask, EVEN, isCustomisationSyncMetadata, isPluginMetadata, scheduleTask } from "@/common/utils.ts";
|
||||||
import { PeriodicProcessor } from "@/common/PeriodicProcessor.ts";
|
import { PeriodicProcessor } from "@/common/PeriodicProcessor.ts";
|
||||||
import { JsonResolveModal } from "@/features/HiddenFileCommon/JsonResolveModal.ts";
|
import { JsonResolveModal } from "@/features/HiddenFileCommon/JsonResolveModal.ts";
|
||||||
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
|
||||||
import { pluginScanningCount } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
|
import { pluginScanningCount } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
|
||||||
import { base64ToArrayBuffer, base64ToString } from "octagonal-wheels/binary/base64";
|
import { base64ToArrayBuffer, base64ToString } from "octagonal-wheels/binary/base64";
|
||||||
import { ConflictResolveModal } from "@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
|
import { ConflictResolveModal } from "@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
import { forEachInScope } from "octagonal-wheels/concurrency/scoped.iteration";
|
||||||
|
import { withScope } from "octagonal-wheels/resource/scope";
|
||||||
import { EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG, eventHub } from "@/common/events.ts";
|
import { EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG, eventHub } from "@/common/events.ts";
|
||||||
import { PluginDialogModal } from "./PluginDialogModal.ts";
|
import { PluginDialogModal } from "./PluginDialogModal.ts";
|
||||||
import { $msg } from "@/common/translation";
|
import { $msg } from "@/common/translation";
|
||||||
@@ -68,10 +68,12 @@ import type { LiveSyncCore } from "@/main.ts";
|
|||||||
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
||||||
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
||||||
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
||||||
|
import { createModificationHandler } from "octagonal-wheels/modification";
|
||||||
|
|
||||||
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||||
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||||
import { PendingConfigSaves } from "./PendingConfigSaves";
|
import { PendingConfigSaves } from "./PendingConfigSaves";
|
||||||
|
import { ConfigScanQueue } from "./ConfigScanQueue";
|
||||||
|
|
||||||
const d = "\u200b";
|
const d = "\u200b";
|
||||||
const d2 = "\n";
|
const d2 = "\n";
|
||||||
@@ -285,6 +287,10 @@ export type PluginDataExDisplay = {
|
|||||||
mtime: number;
|
mtime: number;
|
||||||
};
|
};
|
||||||
type LoadedEntryPluginDataExFile = LoadedEntry & PluginDataExFile;
|
type LoadedEntryPluginDataExFile = LoadedEntry & PluginDataExFile;
|
||||||
|
type ConfigFileApplicationTarget = {
|
||||||
|
readonly path: FilePath;
|
||||||
|
readonly stat: { readonly mtime: number; readonly ctime: number };
|
||||||
|
};
|
||||||
|
|
||||||
function categoryToFolder(category: string, configDir: string = ""): string {
|
function categoryToFolder(category: string, configDir: string = ""): string {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
@@ -388,13 +394,6 @@ export type PluginDataEx = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class ConfigSync extends LiveSyncCommands {
|
export class ConfigSync extends LiveSyncCommands {
|
||||||
constructor(core: LiveSyncCore) {
|
|
||||||
super(core);
|
|
||||||
pluginScanningCount.onChanged((e) => {
|
|
||||||
const total = e.value;
|
|
||||||
pluginIsEnumerating.set(total != 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
get configDir() {
|
get configDir() {
|
||||||
return this.core.services.API.getSystemConfigDir();
|
return this.core.services.API.getSystemConfigDir();
|
||||||
}
|
}
|
||||||
@@ -417,6 +416,7 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
|
|
||||||
pluginList: IPluginDataExDisplay[] = [];
|
pluginList: IPluginDataExDisplay[] = [];
|
||||||
private pluginListGeneration = 0;
|
private pluginListGeneration = 0;
|
||||||
|
private unloaded = false;
|
||||||
showPluginSyncModal() {
|
showPluginSyncModal() {
|
||||||
if (!this.isThisModuleEnabled()) {
|
if (!this.isThisModuleEnabled()) {
|
||||||
return;
|
return;
|
||||||
@@ -435,11 +435,18 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
this.pluginDialog = undefined;
|
this.pluginDialog = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onunload() {
|
onunload(): Promise<void> {
|
||||||
|
this.unloaded = true;
|
||||||
|
this.resetPluginList();
|
||||||
|
const stopped = this.pluginScanProcessor.stop();
|
||||||
|
pluginScanningCount.value = 0;
|
||||||
|
pluginIsEnumerating.set(false);
|
||||||
|
cancelTask("updatePluginListV2");
|
||||||
cancelTask(UPDATED_CONFIGURATION_NOTICE_KEY);
|
cancelTask(UPDATED_CONFIGURATION_NOTICE_KEY);
|
||||||
this.hidePluginSyncModal();
|
this.hidePluginSyncModal();
|
||||||
this.periodicPluginSweepProcessor?.disable();
|
this.periodicPluginSweepProcessor?.disable();
|
||||||
this.services.context.notices.hide(UPDATED_CONFIGURATION_NOTICE_KEY);
|
this.services.context.notices.hide(UPDATED_CONFIGURATION_NOTICE_KEY);
|
||||||
|
return stopped;
|
||||||
}
|
}
|
||||||
addRibbonIcon = this.services.API.addRibbonIcon.bind(this.services.API);
|
addRibbonIcon = this.services.API.addRibbonIcon.bind(this.services.API);
|
||||||
onload() {
|
onload() {
|
||||||
@@ -536,11 +543,21 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
q?.toggleClass("sls-hidden", !this.isThisModuleEnabled());
|
q?.toggleClass("sls-hidden", !this.isThisModuleEnabled());
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
}
|
}
|
||||||
async reloadPluginList(showMessage: boolean) {
|
private isCurrentPluginList(generation: number): boolean {
|
||||||
|
return !this.unloaded && generation === this.pluginListGeneration && this.isThisModuleEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetPluginList(): void {
|
||||||
this.pluginListGeneration++;
|
this.pluginListGeneration++;
|
||||||
|
this.pluginScanProcessor.clearPending();
|
||||||
this.pluginList = [];
|
this.pluginList = [];
|
||||||
this.loadedManifest_mTime.clear();
|
this.loadedManifest_mTime.clear();
|
||||||
pluginList.set(this.pluginList);
|
pluginList.set(this.pluginList);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reloadPluginList(showMessage: boolean) {
|
||||||
|
if (this.unloaded) return;
|
||||||
|
this.resetPluginList();
|
||||||
await this.updatePluginList(showMessage);
|
await this.updatePluginList(showMessage);
|
||||||
}
|
}
|
||||||
async loadPluginData(path: FilePathWithPrefix): Promise<PluginDataExDisplay | false> {
|
async loadPluginData(path: FilePathWithPrefix): Promise<PluginDataExDisplay | false> {
|
||||||
@@ -575,77 +592,37 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
pluginScanProcessor = new QueueProcessor(
|
pluginScanProcessor = new ConfigScanQueue<{ entry: AnyEntry; generation: number }>({
|
||||||
async (v: AnyEntry[]) => {
|
process: async ({ entry: plugin, generation }) => {
|
||||||
const plugin = v[0];
|
if (!this.isCurrentPluginList(generation)) return;
|
||||||
if (this.useV2) {
|
|
||||||
await this.migrateV1ToV2(false, plugin);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const path = plugin.path || this.getPath(plugin);
|
|
||||||
const oldEntry = this.pluginList.find((e) => e.documentPath == path);
|
|
||||||
if (oldEntry && oldEntry.mtime == plugin.mtime) return [];
|
|
||||||
try {
|
try {
|
||||||
|
if (this.useV2) {
|
||||||
|
// Once started, migration owns its writes through completion.
|
||||||
|
await this.migrateV1ToV2(false, plugin);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const path = plugin.path || this.getPath(plugin);
|
||||||
|
const oldEntry = this.pluginList.find((entry) => entry.documentPath == path);
|
||||||
|
if (oldEntry && oldEntry.mtime == plugin.mtime) return;
|
||||||
const pluginData = await this.loadPluginData(path);
|
const pluginData = await this.loadPluginData(path);
|
||||||
|
if (!this.isCurrentPluginList(generation)) return;
|
||||||
if (pluginData) {
|
if (pluginData) {
|
||||||
let newList = [...this.pluginList];
|
const newList = this.pluginList.filter((entry) => entry.documentPath != pluginData.documentPath);
|
||||||
newList = newList.filter((x) => x.documentPath != pluginData.documentPath);
|
|
||||||
newList.push(pluginData);
|
newList.push(pluginData);
|
||||||
this.pluginList = newList;
|
this.pluginList = newList;
|
||||||
pluginList.set(newList);
|
pluginList.set(newList);
|
||||||
}
|
}
|
||||||
// Failed to load
|
|
||||||
return [];
|
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
this._log(`Something happened at enumerating customization :${path}`, LOG_LEVEL_NOTICE);
|
this._log(`Something happened at enumerating customization :${plugin.path}`, LOG_LEVEL_NOTICE);
|
||||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||||
}
|
}
|
||||||
return [];
|
|
||||||
},
|
},
|
||||||
{
|
onProgress: (remaining) => {
|
||||||
suspended: false,
|
if (this.unloaded) return;
|
||||||
batchSize: 1,
|
pluginScanningCount.value = remaining;
|
||||||
concurrentLimit: 10,
|
pluginIsEnumerating.set(remaining !== 0);
|
||||||
delay: 100,
|
|
||||||
yieldThreshold: 10,
|
|
||||||
maintainDelay: false,
|
|
||||||
totalRemainingReactiveSource: pluginScanningCount,
|
|
||||||
}
|
|
||||||
).startPipeline();
|
|
||||||
|
|
||||||
pluginScanProcessorV2 = new QueueProcessor(
|
|
||||||
async (v: AnyEntry[]) => {
|
|
||||||
const plugin = v[0];
|
|
||||||
const path = plugin.path || this.getPath(plugin);
|
|
||||||
const oldEntry = this.pluginList.find((e) => e.documentPath == path);
|
|
||||||
if (oldEntry && oldEntry.mtime == plugin.mtime) return [];
|
|
||||||
try {
|
|
||||||
const pluginData = await this.loadPluginData(path);
|
|
||||||
if (pluginData) {
|
|
||||||
let newList = [...this.pluginList];
|
|
||||||
newList = newList.filter((x) => x.documentPath != pluginData.documentPath);
|
|
||||||
newList.push(pluginData);
|
|
||||||
this.pluginList = newList;
|
|
||||||
pluginList.set(newList);
|
|
||||||
}
|
|
||||||
// Failed to load
|
|
||||||
return [];
|
|
||||||
} catch (ex) {
|
|
||||||
this._log(`Something happened at enumerating customization :${path}`, LOG_LEVEL_NOTICE);
|
|
||||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
},
|
||||||
{
|
});
|
||||||
suspended: false,
|
|
||||||
batchSize: 1,
|
|
||||||
concurrentLimit: 10,
|
|
||||||
delay: 100,
|
|
||||||
yieldThreshold: 10,
|
|
||||||
maintainDelay: false,
|
|
||||||
totalRemainingReactiveSource: pluginScanningCount,
|
|
||||||
}
|
|
||||||
).startPipeline();
|
|
||||||
|
|
||||||
filenameToUnifiedKey(path: string, termOverRide?: string) {
|
filenameToUnifiedKey(path: string, termOverRide?: string) {
|
||||||
const term = termOverRide || this.services.setting.getDeviceAndVaultName();
|
const term = termOverRide || this.services.setting.getDeviceAndVaultName();
|
||||||
@@ -768,7 +745,7 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
|
|
||||||
async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise<void> {
|
async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise<void> {
|
||||||
const generation = this.pluginListGeneration;
|
const generation = this.pluginListGeneration;
|
||||||
const isCurrent = () => generation === this.pluginListGeneration && this.isThisModuleEnabled();
|
const isCurrent = () => this.isCurrentPluginList(generation);
|
||||||
try {
|
try {
|
||||||
this.updatingV2Count++;
|
this.updatingV2Count++;
|
||||||
pluginV2Progress.set(this.updatingV2Count);
|
pluginV2Progress.set(this.updatingV2Count);
|
||||||
@@ -883,11 +860,9 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void> {
|
async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void> {
|
||||||
|
if (this.unloaded) return;
|
||||||
if (!this.isThisModuleEnabled()) {
|
if (!this.isThisModuleEnabled()) {
|
||||||
this.pluginListGeneration++;
|
this.resetPluginList();
|
||||||
this.pluginScanProcessor.clearQueue();
|
|
||||||
this.pluginList = [];
|
|
||||||
pluginList.set(this.pluginList);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const generation = this.pluginListGeneration;
|
const generation = this.pluginListGeneration;
|
||||||
@@ -903,7 +878,7 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
})
|
})
|
||||||
: this.localDatabase.findEntries(ICXHeader + "", `${ICXHeader}\u{10ffff}`, { include_docs: true });
|
: this.localDatabase.findEntries(ICXHeader + "", `${ICXHeader}\u{10ffff}`, { include_docs: true });
|
||||||
for await (const v of plugins) {
|
for await (const v of plugins) {
|
||||||
if (generation !== this.pluginListGeneration || !this.isThisModuleEnabled()) return;
|
if (!this.isCurrentPluginList(generation)) return;
|
||||||
if (v.deleted || v._deleted) continue;
|
if (v.deleted || v._deleted) continue;
|
||||||
if (v.path.indexOf("%") !== -1) {
|
if (v.path.indexOf("%") !== -1) {
|
||||||
fireAndForget(() => this.updatePluginListV2(showMessage, v.path));
|
fireAndForget(() => this.updatePluginListV2(showMessage, v.path));
|
||||||
@@ -912,7 +887,7 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
|
|
||||||
const path = v.path || this.getPath(v);
|
const path = v.path || this.getPath(v);
|
||||||
if (updatedDocumentPath && updatedDocumentPath != path) continue;
|
if (updatedDocumentPath && updatedDocumentPath != path) continue;
|
||||||
this.pluginScanProcessor.enqueue(v);
|
this.pluginScanProcessor.enqueue({ entry: v, generation });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pluginIsEnumerating.set(false);
|
pluginIsEnumerating.set(false);
|
||||||
@@ -1027,44 +1002,63 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
await this.storeCustomisationFileV2(path, this.services.setting.getDeviceAndVaultName());
|
await this.storeCustomisationFileV2(path, this.services.setting.getDeviceAndVaultName());
|
||||||
} else {
|
} else {
|
||||||
const files = data.files;
|
const files = data.files;
|
||||||
|
const applyBinaryContent = createModificationHandler<
|
||||||
|
ConfigFileApplicationTarget,
|
||||||
|
LoadedEntryPluginDataExFile,
|
||||||
|
ArrayBuffer,
|
||||||
|
ArrayBuffer
|
||||||
|
>({
|
||||||
|
read: (target) => this.core.storageAccess.readHiddenFileBinary(target.path),
|
||||||
|
onReadError: (ex, _path, file) => {
|
||||||
|
this._log(`Could not read the file ${file.filename}`, LOG_LEVEL_VERBOSE);
|
||||||
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||||
|
return new ArrayBuffer(0);
|
||||||
|
},
|
||||||
|
decode: (file) => base64ToArrayBuffer(file.data),
|
||||||
|
isSame: (oldData, content) => isDocContentSame(oldData, content),
|
||||||
|
write: (target, content) =>
|
||||||
|
this.core.storageAccess.writeHiddenFileAuto(target.path, content, target.stat),
|
||||||
|
});
|
||||||
|
const applyTextContent = createModificationHandler<
|
||||||
|
ConfigFileApplicationTarget,
|
||||||
|
LoadedEntryPluginDataExFile,
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
>({
|
||||||
|
read: (target) => this.core.storageAccess.readHiddenFileText(target.path),
|
||||||
|
onReadError: (ex, _path, file) => {
|
||||||
|
this._log(`Could not read the file ${file.filename}`, LOG_LEVEL_VERBOSE);
|
||||||
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
decode: (file) => getDocData(file.data),
|
||||||
|
isSame: (oldData, content) => isDocContentSame(oldData, content),
|
||||||
|
write: (target, content) =>
|
||||||
|
this.core.storageAccess.writeHiddenFileAuto(target.path, content, target.stat),
|
||||||
|
});
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
// If files have applied, modified time will be updated to the current time.
|
// If files have applied, modified time will be updated to the current time.
|
||||||
const stat = { mtime: f.mtime, ctime: f.ctime };
|
|
||||||
const path = `${baseDir}/${f.filename}` as FilePath;
|
const path = `${baseDir}/${f.filename}` as FilePath;
|
||||||
|
const target: ConfigFileApplicationTarget = {
|
||||||
|
path,
|
||||||
|
stat: { mtime: f.mtime, ctime: f.ctime },
|
||||||
|
};
|
||||||
this._log(`Applying ${f.filename} of ${data.displayName || data.name}..`);
|
this._log(`Applying ${f.filename} of ${data.displayName || data.name}..`);
|
||||||
// const contentEach = createBlob(f.data);
|
// const contentEach = createBlob(f.data);
|
||||||
await this.core.storageAccess.ensureDir(path);
|
await this.core.storageAccess.ensureDir(path);
|
||||||
|
|
||||||
if (f.datatype == "newnote") {
|
if (f.datatype == "newnote") {
|
||||||
let oldData;
|
const result = await applyBinaryContent(target, f);
|
||||||
try {
|
if (result == "unchanged") {
|
||||||
oldData = await this.core.storageAccess.readHiddenFileBinary(path);
|
|
||||||
} catch (ex) {
|
|
||||||
this._log(`Could not read the file ${f.filename}`, LOG_LEVEL_VERBOSE);
|
|
||||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
|
||||||
oldData = new ArrayBuffer(0);
|
|
||||||
}
|
|
||||||
const content = base64ToArrayBuffer(f.data);
|
|
||||||
if (await isDocContentSame(oldData, content)) {
|
|
||||||
this._log(`The file ${f.filename} is already up-to-date`, LOG_LEVEL_VERBOSE);
|
this._log(`The file ${f.filename} is already up-to-date`, LOG_LEVEL_VERBOSE);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await this.core.storageAccess.writeHiddenFileAuto(path, content, stat);
|
|
||||||
} else {
|
} else {
|
||||||
let oldData;
|
const result = await applyTextContent(target, f);
|
||||||
try {
|
if (result == "unchanged") {
|
||||||
oldData = await this.core.storageAccess.readHiddenFileText(path);
|
|
||||||
} catch (ex) {
|
|
||||||
this._log(`Could not read the file ${f.filename}`, LOG_LEVEL_VERBOSE);
|
|
||||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
|
||||||
oldData = "";
|
|
||||||
}
|
|
||||||
const content = getDocData(f.data);
|
|
||||||
if (await isDocContentSame(oldData, content)) {
|
|
||||||
this._log(`The file ${f.filename} is already up-to-date`, LOG_LEVEL_VERBOSE);
|
this._log(`The file ${f.filename} is already up-to-date`, LOG_LEVEL_VERBOSE);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await this.core.storageAccess.writeHiddenFileAuto(path, content, stat);
|
|
||||||
}
|
}
|
||||||
this._log(`Applied ${f.filename} of ${data.displayName || data.name}..`);
|
this._log(`Applied ${f.filename} of ${data.displayName || data.name}..`);
|
||||||
await this.storeCustomisationFileV2(path, this.services.setting.getDeviceAndVaultName());
|
await this.storeCustomisationFileV2(path, this.services.setting.getDeviceAndVaultName());
|
||||||
@@ -1215,6 +1209,7 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
_everyRealizeSettingSyncMode(): Promise<boolean> {
|
_everyRealizeSettingSyncMode(): Promise<boolean> {
|
||||||
|
if (!this.isThisModuleEnabled()) this.resetPluginList();
|
||||||
// applySettings calls onResuming after this handler when synchronisation is active.
|
// applySettings calls onResuming after this handler when synchronisation is active.
|
||||||
// That handler scans and restarts the sweep; doing so here would scan twice.
|
// That handler scans and restarts the sweep; doing so here would scan twice.
|
||||||
this.periodicPluginSweepProcessor?.disable();
|
this.periodicPluginSweepProcessor?.disable();
|
||||||
@@ -1613,49 +1608,35 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
const entries = this.localDatabase.findEntries(prefix + "", `${prefix}\u{10ffff}`, {
|
const entries = this.localDatabase.findEntries(prefix + "", `${prefix}\u{10ffff}`, {
|
||||||
include_docs: true,
|
include_docs: true,
|
||||||
});
|
});
|
||||||
const tasks = [] as (() => Promise<void>)[];
|
const databaseEntries: { _id: string; path: FilePathWithPrefix }[] = [];
|
||||||
const concurrency = 10;
|
|
||||||
const semaphore = Semaphore(concurrency);
|
|
||||||
for await (const item of entries) {
|
for await (const item of entries) {
|
||||||
if (item.path.indexOf("%") !== -1) {
|
if (item.path.indexOf("%") === -1) databaseEntries.push(item);
|
||||||
continue;
|
}
|
||||||
}
|
await withScope({ term, localFileMap }, async (scope) => {
|
||||||
tasks.push(async () => {
|
await forEachInScope(scope, databaseEntries, { concurrency: 10 }, async (item, ctx) => {
|
||||||
const releaser = await semaphore.acquire();
|
|
||||||
try {
|
try {
|
||||||
const unifiedFilenameWithKey = `${item._id}` as FilePathWithPrefix;
|
const unifiedFilenameWithKey = `${item._id}` as FilePathWithPrefix;
|
||||||
const localPath = localFileMap.get(unifiedFilenameWithKey);
|
const localPath = ctx.value.localFileMap.get(unifiedFilenameWithKey);
|
||||||
if (localPath) {
|
if (localPath) {
|
||||||
await this.storeCustomisationFileV2(localPath, term);
|
await this.storeCustomisationFileV2(localPath, ctx.value.term);
|
||||||
localFileMap.delete(unifiedFilenameWithKey);
|
ctx.value.localFileMap.delete(unifiedFilenameWithKey);
|
||||||
} else {
|
} else {
|
||||||
await this.deleteConfigOnDatabase(unifiedFilenameWithKey);
|
await this.deleteConfigOnDatabase(unifiedFilenameWithKey);
|
||||||
}
|
}
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
this._log(`scanAllConfigFiles - Error: ${item._id}`, LOG_LEVEL_VERBOSE);
|
this._log(`scanAllConfigFiles - Error: ${item._id}`, LOG_LEVEL_VERBOSE);
|
||||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||||
} finally {
|
|
||||||
releaser();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
await forEachInScope(scope, localFileMap.values(), { concurrency: 10 }, async (filePath, ctx) => {
|
||||||
await Promise.all(tasks.map((e) => e()));
|
|
||||||
// Extra files
|
|
||||||
const taskExtra = [] as (() => Promise<void>)[];
|
|
||||||
for (const [, filePath] of localFileMap) {
|
|
||||||
taskExtra.push(async () => {
|
|
||||||
const releaser = await semaphore.acquire();
|
|
||||||
try {
|
try {
|
||||||
await this.storeCustomisationFileV2(filePath, term);
|
await this.storeCustomisationFileV2(filePath, ctx.value.term);
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
this._log(`scanAllConfigFiles - Error: ${filePath}`, LOG_LEVEL_VERBOSE);
|
this._log(`scanAllConfigFiles - Error: ${filePath}`, LOG_LEVEL_VERBOSE);
|
||||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||||
} finally {
|
|
||||||
releaser();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
await Promise.all(taskExtra.map((e) => e()));
|
|
||||||
fireAndForget(() => this.updatePluginList(false));
|
fireAndForget(() => this.updatePluginList(false));
|
||||||
} else {
|
} else {
|
||||||
const files = filesAll
|
const files = filesAll
|
||||||
@@ -1765,6 +1746,7 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
this.core.settings.usePluginSync = false;
|
this.core.settings.usePluginSync = false;
|
||||||
this.core.settings.autoSweepPlugins = false;
|
this.core.settings.autoSweepPlugins = false;
|
||||||
}
|
}
|
||||||
|
this.resetPluginList();
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { compatGlobal, type CompatTimeoutHandle } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||||
|
import { createNotifier } from "octagonal-wheels/concurrency/notifier";
|
||||||
|
import { forEachInScope } from "octagonal-wheels/concurrency/scoped.iteration";
|
||||||
|
import { createAsyncQueue, type AsyncQueue } from "octagonal-wheels/iterable/asyncQueue";
|
||||||
|
import { withScope, type Awaitable } from "octagonal-wheels/resource/scope";
|
||||||
|
|
||||||
|
interface ConfigScanQueueOptions<T> {
|
||||||
|
/** The caller logs recoverable item failures and decides whether to continue. */
|
||||||
|
process: (item: T) => Awaitable<void>;
|
||||||
|
onProgress?: (remaining: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingQueue<T> {
|
||||||
|
fifo: AsyncQueue<T>;
|
||||||
|
iterator: AsyncIterator<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingScan<T> {
|
||||||
|
value: T;
|
||||||
|
queue: PendingQueue<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owns FIFO admission, the initial scan delay, and completion for one catalogue. */
|
||||||
|
export class ConfigScanQueue<T> {
|
||||||
|
private pending: PendingQueue<T>;
|
||||||
|
private pendingCount = 0;
|
||||||
|
private active = 0;
|
||||||
|
private ready = false;
|
||||||
|
private stopped = false;
|
||||||
|
private stoppingAfterFailure = false;
|
||||||
|
private readonly failures: unknown[] = [];
|
||||||
|
private timer?: CompatTimeoutHandle;
|
||||||
|
private running?: Promise<void>;
|
||||||
|
private readonly changed = createNotifier();
|
||||||
|
|
||||||
|
constructor(private readonly options: ConfigScanQueueOptions<T>) {
|
||||||
|
this.pending = this.createPendingQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createPendingQueue(): PendingQueue<T> {
|
||||||
|
const fifo = createAsyncQueue<T>();
|
||||||
|
return { fifo, iterator: fifo[Symbol.asyncIterator]() };
|
||||||
|
}
|
||||||
|
|
||||||
|
get remaining(): number {
|
||||||
|
return this.pendingCount + this.active;
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(value: T): void {
|
||||||
|
if (!this.canDispatch()) throw new Error("The customisation scan queue is closed.");
|
||||||
|
this.pending.fifo.enqueue(value);
|
||||||
|
this.pendingCount++;
|
||||||
|
this.reportProgress();
|
||||||
|
this.schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discard unstarted scans; active reads and migrations retain their slots. */
|
||||||
|
clearPending(): void {
|
||||||
|
const discarded = this.pending;
|
||||||
|
this.pending = this.createPendingQueue();
|
||||||
|
this.pendingCount = 0;
|
||||||
|
this.ready = false;
|
||||||
|
this.clearTimer();
|
||||||
|
discarded.fifo.dispose();
|
||||||
|
this.reportProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop admission and join active work. A processor must not await its own stop. */
|
||||||
|
stop(): Promise<void> {
|
||||||
|
this.stopped = true;
|
||||||
|
this.clearPending();
|
||||||
|
return this.waitForIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Include later arrivals until the queue is idle; this does not close admission. */
|
||||||
|
async waitForIdle(): Promise<void> {
|
||||||
|
this.schedule();
|
||||||
|
while (this.running || this.timer !== undefined) await this.changed.wait();
|
||||||
|
if (this.failures.length === 1) throw this.failures[0];
|
||||||
|
if (this.failures.length > 1) {
|
||||||
|
throw Object.assign(new Error("Customisation scans failed."), { errors: [...this.failures] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private canDispatch(): boolean {
|
||||||
|
return !this.stopped && !this.stoppingAfterFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimer(): void {
|
||||||
|
if (this.timer === undefined) return;
|
||||||
|
compatGlobal.clearTimeout(this.timer);
|
||||||
|
this.timer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordFailure(error: unknown): void {
|
||||||
|
this.stoppingAfterFailure = true;
|
||||||
|
this.failures.push(error);
|
||||||
|
this.clearTimer();
|
||||||
|
this.changed.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportProgress(): void {
|
||||||
|
if (this.failures.length === 0) {
|
||||||
|
try {
|
||||||
|
this.options.onProgress?.(this.remaining);
|
||||||
|
} catch (error) {
|
||||||
|
this.recordFailure(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.changed.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private claim(entry: PendingScan<T>): boolean {
|
||||||
|
if (!this.canDispatch() || this.pending !== entry.queue) return false;
|
||||||
|
this.pendingCount--;
|
||||||
|
if (this.pendingCount === 0) this.ready = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async *availableWork() {
|
||||||
|
while (this.canDispatch()) {
|
||||||
|
const pending = this.pending;
|
||||||
|
if (!this.ready) {
|
||||||
|
if (this.active === 0 && this.pendingCount === 0) return;
|
||||||
|
await this.changed.wait();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pending.fifo.size > 0) {
|
||||||
|
const entry = await pending.iterator.next();
|
||||||
|
if (entry.done) {
|
||||||
|
if (pending !== this.pending) continue;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The callback checks queue identity so clearing a pulled item cancels it.
|
||||||
|
yield { value: entry.value, queue: pending };
|
||||||
|
} else if (this.active > 0 || this.pendingCount > 0) {
|
||||||
|
// Keep free slots available to later arrivals while a slow scan finishes.
|
||||||
|
await this.changed.wait();
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule(): void {
|
||||||
|
if (!this.canDispatch() || this.pendingCount === 0) return;
|
||||||
|
if (!this.ready) {
|
||||||
|
if (this.pendingCount > 10) {
|
||||||
|
this.ready = true;
|
||||||
|
this.clearTimer();
|
||||||
|
} else if (this.timer === undefined) {
|
||||||
|
this.timer = compatGlobal.setTimeout(() => {
|
||||||
|
this.timer = undefined;
|
||||||
|
this.ready = true;
|
||||||
|
this.schedule();
|
||||||
|
this.changed.notify();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!this.ready || this.running) return;
|
||||||
|
this.running = Promise.resolve()
|
||||||
|
.then(() =>
|
||||||
|
withScope(undefined, (parent) =>
|
||||||
|
forEachInScope(parent, this.availableWork(), { concurrency: 10 }, async (entry, child) => {
|
||||||
|
if (!this.claim(entry)) return;
|
||||||
|
this.active++;
|
||||||
|
child.defer(() => {
|
||||||
|
this.active--;
|
||||||
|
this.reportProgress();
|
||||||
|
});
|
||||||
|
this.reportProgress();
|
||||||
|
if (!this.canDispatch()) return;
|
||||||
|
try {
|
||||||
|
await this.options.process(entry.value);
|
||||||
|
} catch (error) {
|
||||||
|
this.stoppingAfterFailure = true;
|
||||||
|
this.clearTimer();
|
||||||
|
this.changed.notify();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
(): void => undefined,
|
||||||
|
(error: unknown) => this.recordFailure(error)
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
this.running = undefined;
|
||||||
|
this.schedule();
|
||||||
|
this.changed.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ConfigScanQueue } from "./ConfigScanQueue";
|
||||||
|
|
||||||
|
const queues: { stop(): Promise<void> }[] = [];
|
||||||
|
const releases: (() => void)[] = [];
|
||||||
|
|
||||||
|
function gate() {
|
||||||
|
let resolve!: () => void;
|
||||||
|
const promise = new Promise<void>((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
releases.push(resolve);
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function queue<T>(options: ConstructorParameters<typeof ConfigScanQueue<T>>[0]) {
|
||||||
|
const result = new ConfigScanQueue(options);
|
||||||
|
queues.push(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const release of releases.splice(0)) release();
|
||||||
|
await Promise.all(queues.splice(0).map((item) => item.stop().catch(() => {})));
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ConfigScanQueue", () => {
|
||||||
|
it("waits from the first arrival without extending the delay for later arrivals", async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const scans = queue<string>({
|
||||||
|
process: (item) => {
|
||||||
|
seen.push(item);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scans.enqueue("first");
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
scans.enqueue("second");
|
||||||
|
await vi.advanceTimersByTimeAsync(49);
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
await scans.waitForIdle();
|
||||||
|
expect(seen).toEqual(["first", "second"]);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bypasses the delay only when pending work exceeds ten items", async () => {
|
||||||
|
const seen: number[] = [];
|
||||||
|
const scans = queue<number>({
|
||||||
|
process: (item) => {
|
||||||
|
seen.push(item);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (let item = 0; item < 10; item++) scans.enqueue(item);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
scans.enqueue(10);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
await scans.waitForIdle();
|
||||||
|
expect(seen).toEqual(Array.from({ length: 11 }, (_, index) => index));
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps duplicate items in FIFO order and admits at most ten active scans", async () => {
|
||||||
|
const finish = Array.from({ length: 13 }, () => gate());
|
||||||
|
const input = Array.from({ length: 13 }, (_, index) => index % 3);
|
||||||
|
const seen: number[] = [];
|
||||||
|
let active = 0;
|
||||||
|
let maximum = 0;
|
||||||
|
const progress: number[] = [];
|
||||||
|
const scans = queue<number>({
|
||||||
|
process: async (item) => {
|
||||||
|
const index = seen.length;
|
||||||
|
seen.push(item);
|
||||||
|
maximum = Math.max(maximum, ++active);
|
||||||
|
await finish[index].promise;
|
||||||
|
active--;
|
||||||
|
},
|
||||||
|
onProgress: (remaining) => progress.push(remaining),
|
||||||
|
});
|
||||||
|
input.forEach((item) => scans.enqueue(item));
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(seen).toEqual(input.slice(0, 10));
|
||||||
|
expect(scans.remaining).toBe(13);
|
||||||
|
finish[3].resolve();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(seen).toEqual(input.slice(0, 11));
|
||||||
|
finish.forEach((item) => item.resolve());
|
||||||
|
await scans.waitForIdle();
|
||||||
|
expect(seen).toEqual(input);
|
||||||
|
expect(maximum).toBe(10);
|
||||||
|
expect(scans.remaining).toBe(0);
|
||||||
|
expect(progress[progress.length - 1]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a later arrival in a free slot before an earlier slow scan finishes", async () => {
|
||||||
|
const slow = gate();
|
||||||
|
const seen: string[] = [];
|
||||||
|
const scans = queue<string>({
|
||||||
|
process: async (item) => {
|
||||||
|
seen.push(item);
|
||||||
|
if (item === "slow") await slow.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scans.enqueue("slow");
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
let idle = false;
|
||||||
|
const done = scans.waitForIdle().then(() => {
|
||||||
|
idle = true;
|
||||||
|
});
|
||||||
|
scans.enqueue("later");
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(seen).toEqual(["slow", "later"]);
|
||||||
|
expect(idle).toBe(false);
|
||||||
|
slow.resolve();
|
||||||
|
await done;
|
||||||
|
expect(scans.remaining).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears delayed scans and gives replacement work its own delay", async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const scans = queue<string>({
|
||||||
|
process: (item) => {
|
||||||
|
seen.push(item);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scans.enqueue("old");
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
scans.clearPending();
|
||||||
|
expect(scans.remaining).toBe(0);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
await scans.waitForIdle();
|
||||||
|
scans.enqueue("new");
|
||||||
|
await vi.advanceTimersByTimeAsync(99);
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
await scans.waitForIdle();
|
||||||
|
expect(seen).toEqual(["new"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards all unstarted work while active scans remain tracked", async () => {
|
||||||
|
const finish = gate();
|
||||||
|
const seen: number[] = [];
|
||||||
|
const scans = queue<number>({
|
||||||
|
process: async (item) => {
|
||||||
|
seen.push(item);
|
||||||
|
await finish.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (let item = 0; item < 50; item++) scans.enqueue(item);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(seen).toHaveLength(10);
|
||||||
|
scans.clearPending();
|
||||||
|
expect(scans.remaining).toBe(10);
|
||||||
|
let idle = false;
|
||||||
|
const done = scans.waitForIdle().then(() => {
|
||||||
|
idle = true;
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(idle).toBe(false);
|
||||||
|
finish.resolve();
|
||||||
|
await done;
|
||||||
|
expect(seen).toEqual(Array.from({ length: 10 }, (_, index) => index));
|
||||||
|
expect(scans.remaining).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates prefetched work after clearing and admits replacements alongside active scans", async () => {
|
||||||
|
const finishFirst = gate();
|
||||||
|
const finishReplacement = gate();
|
||||||
|
let scans!: ConfigScanQueue<string>;
|
||||||
|
const seen: string[] = [];
|
||||||
|
scans = queue<string>({
|
||||||
|
process: async (item) => {
|
||||||
|
seen.push(item);
|
||||||
|
if (item === "first") {
|
||||||
|
void Promise.resolve().then(() => Promise.resolve().then(() => scans.clearPending()));
|
||||||
|
await finishFirst.promise;
|
||||||
|
} else if (item === "replacement") {
|
||||||
|
await finishReplacement.promise;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scans.enqueue("first");
|
||||||
|
scans.enqueue("stale");
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(seen).toEqual(["first"]);
|
||||||
|
expect(scans.remaining).toBe(1);
|
||||||
|
|
||||||
|
scans.enqueue("replacement");
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(seen).toEqual(["first", "replacement"]);
|
||||||
|
expect(scans.remaining).toBe(2);
|
||||||
|
|
||||||
|
finishFirst.resolve();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(scans.remaining).toBe(1);
|
||||||
|
finishReplacement.resolve();
|
||||||
|
await scans.waitForIdle();
|
||||||
|
expect(scans.remaining).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the ten-scan limit across clear and replacement work", async () => {
|
||||||
|
const finish = Array.from({ length: 12 }, () => gate());
|
||||||
|
const seen: number[] = [];
|
||||||
|
let active = 0;
|
||||||
|
let maximum = 0;
|
||||||
|
const scans = queue<number>({
|
||||||
|
process: async (item) => {
|
||||||
|
seen.push(item);
|
||||||
|
maximum = Math.max(maximum, ++active);
|
||||||
|
await finish[item].promise;
|
||||||
|
active--;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (let item = 0; item < 10; item++) scans.enqueue(item);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(seen).toEqual(Array.from({ length: 10 }, (_, index) => index));
|
||||||
|
|
||||||
|
scans.clearPending();
|
||||||
|
scans.enqueue(10);
|
||||||
|
scans.enqueue(11);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(seen).toEqual(Array.from({ length: 10 }, (_, index) => index));
|
||||||
|
expect(maximum).toBe(10);
|
||||||
|
|
||||||
|
finish[0].resolve();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(seen).toEqual(Array.from({ length: 11 }, (_, index) => index));
|
||||||
|
expect(maximum).toBe(10);
|
||||||
|
finish.forEach((item) => item.resolve());
|
||||||
|
await scans.waitForIdle();
|
||||||
|
expect(seen).toEqual(Array.from({ length: 12 }, (_, index) => index));
|
||||||
|
expect(maximum).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops while waiting for input, joins active work, and rejects later admission", async () => {
|
||||||
|
const finish = gate();
|
||||||
|
const seen: number[] = [];
|
||||||
|
const scans = queue<number>({
|
||||||
|
process: async (item) => {
|
||||||
|
seen.push(item);
|
||||||
|
await finish.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scans.enqueue(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
scans.enqueue(2);
|
||||||
|
let stopped = false;
|
||||||
|
const done = scans.stop().then(() => {
|
||||||
|
stopped = true;
|
||||||
|
});
|
||||||
|
expect(() => scans.enqueue(3)).toThrow("closed");
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(stopped).toBe(false);
|
||||||
|
finish.resolve();
|
||||||
|
await done;
|
||||||
|
expect(seen).toEqual([1]);
|
||||||
|
expect(scans.remaining).toBe(0);
|
||||||
|
await scans.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops before the first scan without leaving a timer", async () => {
|
||||||
|
const process = vi.fn();
|
||||||
|
const scans = queue({ process });
|
||||||
|
scans.enqueue("pending");
|
||||||
|
await scans.stop();
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(process).not.toHaveBeenCalled();
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("observes an unhandled item failure after other started work settles", async () => {
|
||||||
|
const fail = gate();
|
||||||
|
const finish = gate();
|
||||||
|
const failure = new Error("read failed");
|
||||||
|
const seen: number[] = [];
|
||||||
|
const scans = queue<number>({
|
||||||
|
process: async (item) => {
|
||||||
|
seen.push(item);
|
||||||
|
if (item === 0) {
|
||||||
|
await fail.promise;
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
await finish.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (let item = 0; item < 12; item++) scans.enqueue(item);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
let error: unknown;
|
||||||
|
const done = scans.waitForIdle().catch((reason: unknown) => {
|
||||||
|
error = reason;
|
||||||
|
});
|
||||||
|
fail.resolve();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
expect(seen).toHaveLength(10);
|
||||||
|
expect(() => scans.enqueue(12)).toThrow("closed");
|
||||||
|
finish.resolve();
|
||||||
|
await done;
|
||||||
|
expect(error).toBe(failure);
|
||||||
|
expect(scans.remaining).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes work enqueued by an active scan in an existing idle wait", async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const scans = queue<string>({
|
||||||
|
process: (item) => {
|
||||||
|
seen.push(item);
|
||||||
|
if (item === "first") scans.enqueue("follow-up");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scans.enqueue("first");
|
||||||
|
let idle = false;
|
||||||
|
const done = scans.waitForIdle().then(() => {
|
||||||
|
idle = true;
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(seen).toEqual(["first"]);
|
||||||
|
expect(idle).toBe(false);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
await done;
|
||||||
|
expect(seen).toEqual(["first", "follow-up"]);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wakes an input wait when its only active scan fails", async () => {
|
||||||
|
const finish = gate();
|
||||||
|
const failure = new Error("read failed");
|
||||||
|
const scans = queue<string>({
|
||||||
|
process: async () => {
|
||||||
|
await finish.promise;
|
||||||
|
throw failure;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scans.enqueue("only");
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
const done = expect(scans.waitForIdle()).rejects.toBe(failure);
|
||||||
|
finish.resolve();
|
||||||
|
await done;
|
||||||
|
expect(scans.remaining).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an existing idle wait if observing completion fails", async () => {
|
||||||
|
const finish = gate();
|
||||||
|
const failure = new Error("observer failed");
|
||||||
|
const scans = queue<string>({
|
||||||
|
process: () => finish.promise,
|
||||||
|
onProgress: (remaining) => {
|
||||||
|
if (remaining === 0) throw failure;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scans.enqueue("active");
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
const done = expect(scans.waitForIdle()).rejects.toBe(failure);
|
||||||
|
finish.resolve();
|
||||||
|
await done;
|
||||||
|
expect(scans.remaining).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains both an observer failure and a later failure from active work", async () => {
|
||||||
|
const finish = gate();
|
||||||
|
const observerFailure = new Error("observer failed");
|
||||||
|
const readFailure = new Error("read failed");
|
||||||
|
const scans = queue<string>({
|
||||||
|
process: async () => {
|
||||||
|
await finish.promise;
|
||||||
|
throw readFailure;
|
||||||
|
},
|
||||||
|
onProgress: (remaining) => {
|
||||||
|
if (remaining === 2) throw observerFailure;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scans.enqueue("active");
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
scans.enqueue("pending");
|
||||||
|
const done = expect(scans.waitForIdle()).rejects.toMatchObject({
|
||||||
|
message: "Customisation scans failed.",
|
||||||
|
errors: [observerFailure, readFailure],
|
||||||
|
});
|
||||||
|
finish.resolve();
|
||||||
|
await done;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wakes an idle waiter when the progress observer fails", async () => {
|
||||||
|
const failure = new Error("observer failed");
|
||||||
|
const process = vi.fn();
|
||||||
|
const scans = queue<string>({
|
||||||
|
process,
|
||||||
|
onProgress: () => {
|
||||||
|
throw failure;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scans.enqueue("pending");
|
||||||
|
await expect(scans.waitForIdle()).rejects.toBe(failure);
|
||||||
|
expect(process).not.toHaveBeenCalled();
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,11 +43,12 @@ export class JsonResolveModal extends Modal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async UICallback(keepRev?: string, mergedStr?: string) {
|
async UICallback(keepRev?: string, mergedStr?: string) {
|
||||||
if (this.callback) {
|
const callback = this.callback;
|
||||||
await this.callback(keepRev, mergedStr);
|
this.callback = undefined;
|
||||||
|
if (callback) {
|
||||||
|
await callback(keepRev, mergedStr);
|
||||||
}
|
}
|
||||||
this.close();
|
this.close();
|
||||||
this.callback = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override onOpen() {
|
override onOpen() {
|
||||||
@@ -78,8 +79,10 @@ export class JsonResolveModal extends Modal {
|
|||||||
const { contentEl } = this;
|
const { contentEl } = this;
|
||||||
contentEl.empty();
|
contentEl.empty();
|
||||||
// contentEl.empty();
|
// contentEl.empty();
|
||||||
if (this.callback != undefined) {
|
const callback = this.callback;
|
||||||
void this.callback(undefined);
|
this.callback = undefined;
|
||||||
|
if (callback) {
|
||||||
|
void callback(undefined);
|
||||||
}
|
}
|
||||||
if (this.component != undefined) {
|
if (this.component != undefined) {
|
||||||
void unmount(this.component);
|
void unmount(this.component);
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import { sendSignal } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||||
|
import { JsonResolveModal } from "./JsonResolveModal.ts";
|
||||||
|
|
||||||
|
vi.mock("@/deps.ts", () => ({
|
||||||
|
App: class App {},
|
||||||
|
Modal: class Modal {
|
||||||
|
contentEl = { empty: vi.fn() };
|
||||||
|
close() {
|
||||||
|
(this as { onClose?: () => void }).onClose?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("./JsonResolvePane.svelte", () => ({ default: {} }));
|
||||||
|
vi.mock("svelte", () => ({ mount: vi.fn(), unmount: vi.fn() }));
|
||||||
|
|
||||||
|
describe("JsonResolveModal completion", () => {
|
||||||
|
it("applies a selection once when closing the dialogue", async () => {
|
||||||
|
const filename = ".obsidian/selected-once.json" as FilePath;
|
||||||
|
const callback = vi.fn(async () => {});
|
||||||
|
const modal = new JsonResolveModal({} as never, filename, [], callback);
|
||||||
|
|
||||||
|
await modal.UICallback("2-kept");
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledExactlyOnceWith("2-kept", undefined);
|
||||||
|
sendSignal(`cancel-internal-conflict:${filename}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a late selection after cancellation", async () => {
|
||||||
|
const filename = ".obsidian/cancelled-once.json" as FilePath;
|
||||||
|
const callback = vi.fn(async () => {});
|
||||||
|
const modal = new JsonResolveModal({} as never, filename, [], callback);
|
||||||
|
|
||||||
|
sendSignal(`cancel-internal-conflict:${filename}`);
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledOnce());
|
||||||
|
await modal.UICallback("2-stale");
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledExactlyOnceWith(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an admitted write pending when cancellation closes its dialogue", async () => {
|
||||||
|
const filename = ".obsidian/writing-once.json" as FilePath;
|
||||||
|
let finish!: () => void;
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
finish = resolve;
|
||||||
|
});
|
||||||
|
const callback = vi.fn(async () => await gate);
|
||||||
|
const modal = new JsonResolveModal({} as never, filename, [], callback);
|
||||||
|
const close = vi.spyOn(modal, "close");
|
||||||
|
let completed = false;
|
||||||
|
const selected = modal.UICallback("2-kept").then(() => {
|
||||||
|
completed = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
sendSignal(`cancel-internal-conflict:${filename}`);
|
||||||
|
await vi.waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledExactlyOnceWith("2-kept", undefined);
|
||||||
|
expect(completed).toBe(false);
|
||||||
|
} finally {
|
||||||
|
finish();
|
||||||
|
await selected;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
type DocumentID,
|
||||||
|
type FilePath,
|
||||||
|
type FilePathWithPrefix,
|
||||||
|
type MetaEntry,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
|
||||||
|
vi.mock("@/deps.ts", () => ({}));
|
||||||
|
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||||
|
JsonResolveModal: class JsonResolveModal {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/LiveSyncCommands.ts", () => ({
|
||||||
|
LiveSyncCommands: class LiveSyncCommands {
|
||||||
|
plugin!: { app: unknown };
|
||||||
|
core!: { services: unknown; settings: unknown };
|
||||||
|
get app() {
|
||||||
|
return this.plugin.app;
|
||||||
|
}
|
||||||
|
get services() {
|
||||||
|
return this.core.services;
|
||||||
|
}
|
||||||
|
get settings() {
|
||||||
|
return this.core.settings;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("./configureHiddenFileSyncMode.ts", () => ({
|
||||||
|
configureHiddenFileSyncMode: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { HiddenFileSync } from "./CmdHiddenFileSync.ts";
|
||||||
|
|
||||||
|
function createProgress(events: string[] = []) {
|
||||||
|
return {
|
||||||
|
log: vi.fn((message: string) => {
|
||||||
|
if (/^(?:\d+\/\d+|Checking \d+\/\d+)$/.test(message)) events.push(message);
|
||||||
|
}),
|
||||||
|
once: vi.fn(),
|
||||||
|
done: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStoragePaths(count: number): FilePath[] {
|
||||||
|
return Array.from({ length: count }, (_, index) => `.obsidian/plugins/example/${index}.json` as FilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDatabaseEntries(count: number): MetaEntry[] {
|
||||||
|
return Array.from(
|
||||||
|
{ length: count },
|
||||||
|
(_, index) =>
|
||||||
|
({
|
||||||
|
_id: `i:.obsidian/plugins/example/${index}.json` as DocumentID,
|
||||||
|
_rev: "1-test",
|
||||||
|
path: `i:.obsidian/plugins/example/${index}.json` as FilePathWithPrefix,
|
||||||
|
ctime: 1,
|
||||||
|
mtime: 1,
|
||||||
|
size: 0,
|
||||||
|
type: "plain",
|
||||||
|
datatype: "plain",
|
||||||
|
children: [],
|
||||||
|
eden: {},
|
||||||
|
deleted: false,
|
||||||
|
}) as MetaEntry
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHiddenFileSync(count: number, events: string[] = []) {
|
||||||
|
const progress = createProgress(events);
|
||||||
|
const statHidden = vi.fn(async () => {
|
||||||
|
events.push(`stat:${statHidden.mock.calls.length}`);
|
||||||
|
return { mtime: 1 };
|
||||||
|
});
|
||||||
|
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||||
|
Object.assign(hiddenFileSync, {
|
||||||
|
_progress: vi.fn(() => progress),
|
||||||
|
_log: vi.fn(),
|
||||||
|
_fileInfoLastProcessed: new Map(),
|
||||||
|
_databaseInfoLastProcessed: new Map(),
|
||||||
|
scanInternalFileNames: vi.fn(async () => createStoragePaths(count)),
|
||||||
|
getAllDatabaseFiles: vi.fn(async () => createDatabaseEntries(count)),
|
||||||
|
getPath: vi.fn((file: MetaEntry) => file.path),
|
||||||
|
updateLastProcessed: vi.fn(),
|
||||||
|
scanAllStorageChanges: vi.fn(async () => undefined),
|
||||||
|
scanAllDatabaseChanges: vi.fn(async () => undefined),
|
||||||
|
resetLastProcessedDatabase: vi.fn(),
|
||||||
|
resetLastProcessedFile: vi.fn(),
|
||||||
|
useStorageFiles: vi.fn(async () => false),
|
||||||
|
useDatabaseFiles: vi.fn(async () => false),
|
||||||
|
trackStorageFileModification: vi.fn(async () => false),
|
||||||
|
trackDatabaseFileModification: vi.fn(async () => false),
|
||||||
|
core: { storageAccess: { statHidden } },
|
||||||
|
});
|
||||||
|
return { hiddenFileSync, progress, statHidden };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("HiddenFileSync attempt progress", () => {
|
||||||
|
it("reports applyOfflineChanges attempts at 1 and each 25 items, without a final partial count", async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const { hiddenFileSync, progress } = createHiddenFileSync(52, events);
|
||||||
|
|
||||||
|
await hiddenFileSync.applyOfflineChanges(false);
|
||||||
|
|
||||||
|
expect(progress.log.mock.calls.map(([message]) => message).filter((message) => /^\d+\/\d+$/.test(message))).toEqual([
|
||||||
|
"1/52",
|
||||||
|
"26/52",
|
||||||
|
"51/52",
|
||||||
|
]);
|
||||||
|
expect(events.indexOf("51/52")).toBeLessThan(events.indexOf("stat:1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports rebuildMerging attempts at 1 and each 100 items, without a final partial count", async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const { hiddenFileSync, progress } = createHiddenFileSync(202, events);
|
||||||
|
|
||||||
|
await hiddenFileSync.rebuildMerging(false);
|
||||||
|
|
||||||
|
expect(progress.log.mock.calls.map(([message]) => message).filter((message) => message.startsWith("Checking "))).toEqual([
|
||||||
|
"Checking 1/202",
|
||||||
|
"Checking 101/202",
|
||||||
|
"Checking 201/202",
|
||||||
|
]);
|
||||||
|
expect(events.indexOf("Checking 101/202")).toBeLessThan(events.indexOf("stat:101"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves stat failures from applyOfflineChanges", async () => {
|
||||||
|
const { hiddenFileSync, progress, statHidden } = createHiddenFileSync(1);
|
||||||
|
const error = new Error("stat failed");
|
||||||
|
statHidden.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
await expect(hiddenFileSync.applyOfflineChanges(false)).rejects.toBe(error);
|
||||||
|
expect(progress.done).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves stat failures from rebuildMerging", async () => {
|
||||||
|
const { hiddenFileSync, progress, statHidden } = createHiddenFileSync(1);
|
||||||
|
const error = new Error("stat failed");
|
||||||
|
statHidden.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
await expect(hiddenFileSync.rebuildMerging(false)).rejects.toBe(error);
|
||||||
|
expect(progress.done).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -90,6 +90,39 @@ afterEach(async () => {
|
|||||||
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
|
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Hidden File Sync initial modification times", () => {
|
||||||
|
it.each([
|
||||||
|
{ label: "newer", localMtime: 30_000, expected: "local content" },
|
||||||
|
{ label: "older", localMtime: 10_000, expected: "database content" },
|
||||||
|
{ label: "equal", localMtime: 20_000, expected: "database content" },
|
||||||
|
])("stores only a newer local file when the local timestamp is $label", async ({ localMtime, expected }) => {
|
||||||
|
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
|
||||||
|
const entry = optionalFileEntry(`i:${path}`, "database content");
|
||||||
|
entry.mtime = 20_000;
|
||||||
|
await fixture.localDatabase.putDBEntry(entry);
|
||||||
|
storageAccess.setFile("local content", localMtime);
|
||||||
|
|
||||||
|
await hiddenFileSync.trackStorageFileModification(path, true);
|
||||||
|
|
||||||
|
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||||
|
expect(saved).not.toBe(false);
|
||||||
|
if (saved) {
|
||||||
|
expect(await getText(saved)).toBe(expected);
|
||||||
|
expect(saved.mtime).toBe(expected === "local content" ? localMtime : 20_000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores a local-only file when only newer changes are requested", async () => {
|
||||||
|
const { fixture, hiddenFileSync } = await createHiddenFileSync();
|
||||||
|
|
||||||
|
await hiddenFileSync.trackStorageFileModification(path, true);
|
||||||
|
|
||||||
|
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||||
|
expect(saved).not.toBe(false);
|
||||||
|
if (saved) expect(await getText(saved)).toBe(content);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Hidden File Sync optional save outcomes", () => {
|
describe("Hidden File Sync optional save outcomes", () => {
|
||||||
it("retries a failed database write when the storage fingerprint is unchanged", async () => {
|
it("retries a failed database write when the storage fingerprint is unchanged", async () => {
|
||||||
const { fixture, hiddenFileSync } = await createHiddenFileSync();
|
const { fixture, hiddenFileSync } = await createHiddenFileSync();
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
LOG_LEVEL_VERBOSE,
|
||||||
|
type DocumentID,
|
||||||
|
type FilePath,
|
||||||
|
type FilePathWithPrefix,
|
||||||
|
type MetaEntry,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
|
||||||
|
vi.mock("@/deps.ts", () => ({}));
|
||||||
|
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||||
|
JsonResolveModal: class JsonResolveModal {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/LiveSyncCommands.ts", () => ({
|
||||||
|
LiveSyncCommands: class LiveSyncCommands {
|
||||||
|
plugin!: { app: unknown };
|
||||||
|
core!: { services: unknown; settings: unknown };
|
||||||
|
get app() {
|
||||||
|
return this.plugin.app;
|
||||||
|
}
|
||||||
|
get services() {
|
||||||
|
return this.core.services;
|
||||||
|
}
|
||||||
|
get settings() {
|
||||||
|
return this.core.settings;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("./configureHiddenFileSyncMode.ts", () => ({
|
||||||
|
configureHiddenFileSyncMode: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { HiddenFileSync } from "./CmdHiddenFileSync.ts";
|
||||||
|
|
||||||
|
function createProgress() {
|
||||||
|
return {
|
||||||
|
log: vi.fn(),
|
||||||
|
once: vi.fn(),
|
||||||
|
done: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHiddenFileSync() {
|
||||||
|
const progress = createProgress();
|
||||||
|
const log = vi.fn();
|
||||||
|
const trackStorageFileModification = vi.fn(async (_file: FilePath) => false);
|
||||||
|
const trackDatabaseFileModification = vi.fn(async (_path: FilePath) => false);
|
||||||
|
const isTargetFile = vi.fn(async (_path: FilePath) => true);
|
||||||
|
const getPath = vi.fn((file: MetaEntry) => file.path);
|
||||||
|
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||||
|
|
||||||
|
Object.assign(hiddenFileSync, {
|
||||||
|
_progress: vi.fn(() => progress),
|
||||||
|
_log: log,
|
||||||
|
trackStorageFileModification,
|
||||||
|
trackDatabaseFileModification,
|
||||||
|
isTargetFile,
|
||||||
|
getPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
hiddenFileSync,
|
||||||
|
progress,
|
||||||
|
log,
|
||||||
|
trackStorageFileModification,
|
||||||
|
trackDatabaseFileModification,
|
||||||
|
isTargetFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStoragePaths(count: number): FilePath[] {
|
||||||
|
return Array.from({ length: count }, (_, index) => `.obsidian/plugins/example/${index}.json` as FilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDatabaseEntries(count: number): MetaEntry[] {
|
||||||
|
return Array.from(
|
||||||
|
{ length: count },
|
||||||
|
(_, index) =>
|
||||||
|
({
|
||||||
|
_id: `i:.obsidian/plugins/example/${index}.json` as DocumentID,
|
||||||
|
_rev: "1-test",
|
||||||
|
path: `i:.obsidian/plugins/example/${index}.json` as FilePathWithPrefix,
|
||||||
|
ctime: 1,
|
||||||
|
mtime: 1,
|
||||||
|
size: 0,
|
||||||
|
type: "plain",
|
||||||
|
datatype: "plain",
|
||||||
|
children: [],
|
||||||
|
eden: {},
|
||||||
|
deleted: false,
|
||||||
|
}) as MetaEntry
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("HiddenFileSync batch progress consumers", () => {
|
||||||
|
it("counts completed storage modifications even when they return false", async () => {
|
||||||
|
const { hiddenFileSync, progress, trackStorageFileModification } = createHiddenFileSync();
|
||||||
|
const files = createStoragePaths(101);
|
||||||
|
|
||||||
|
await hiddenFileSync.trackScannedStorageChanges(files);
|
||||||
|
|
||||||
|
expect(trackStorageFileModification).toHaveBeenCalledTimes(101);
|
||||||
|
expect(progress.log.mock.calls).toEqual([["1/101"], ["101/101"]]);
|
||||||
|
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and skips failed storage items when counting progress", async () => {
|
||||||
|
const { hiddenFileSync, progress, log, trackStorageFileModification } = createHiddenFileSync();
|
||||||
|
const files = createStoragePaths(100);
|
||||||
|
const failingFile = files[50];
|
||||||
|
const error = new Error("Storage item failed");
|
||||||
|
trackStorageFileModification.mockImplementation(async (file) => {
|
||||||
|
if (file === failingFile) throw error;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
await hiddenFileSync.trackScannedStorageChanges(files);
|
||||||
|
|
||||||
|
expect(progress.once).toHaveBeenCalledWith(`Failed to process storage change file:${failingFile}`);
|
||||||
|
expect(log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(progress.log.mock.calls).toEqual([["1/100"]]);
|
||||||
|
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts completed database items, including filtered items and false returns", async () => {
|
||||||
|
const { hiddenFileSync, progress, trackDatabaseFileModification, isTargetFile } = createHiddenFileSync();
|
||||||
|
const files = createDatabaseEntries(101);
|
||||||
|
const filteredPath = ".obsidian/plugins/example/50.json" as FilePath;
|
||||||
|
isTargetFile.mockImplementation(async (path) => path !== filteredPath);
|
||||||
|
|
||||||
|
await hiddenFileSync.trackScannedDatabaseChange(files);
|
||||||
|
|
||||||
|
expect(isTargetFile).toHaveBeenCalledTimes(101);
|
||||||
|
expect(trackDatabaseFileModification).toHaveBeenCalledTimes(100);
|
||||||
|
expect(progress.log.mock.calls).toEqual([["1/101"], ["101/101"]]);
|
||||||
|
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and skips failed database scan items when counting progress", async () => {
|
||||||
|
const { hiddenFileSync, progress, log, trackDatabaseFileModification } = createHiddenFileSync();
|
||||||
|
const files = createDatabaseEntries(101);
|
||||||
|
const failingPath = ".obsidian/plugins/example/50.json" as FilePath;
|
||||||
|
const error = new Error("Database scan item failed");
|
||||||
|
trackDatabaseFileModification.mockImplementation(async (path) => {
|
||||||
|
if (path === failingPath) throw error;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
await hiddenFileSync.trackScannedDatabaseChange(files);
|
||||||
|
|
||||||
|
expect(log).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Failed to process storage change file:"),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
expect(log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(progress.log.mock.calls).toEqual([["1/101"]]);
|
||||||
|
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the database-file progress interval and counts false returns", async () => {
|
||||||
|
const { hiddenFileSync, progress, trackDatabaseFileModification } = createHiddenFileSync();
|
||||||
|
const files = createDatabaseEntries(26);
|
||||||
|
|
||||||
|
await expect(hiddenFileSync.useDatabaseFiles(files)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(trackDatabaseFileModification).toHaveBeenCalledTimes(26);
|
||||||
|
expect(progress.log.mock.calls).toEqual([["Scanning database files..."], ["1/26"], ["26/26"]]);
|
||||||
|
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and skips failed database-file items when counting progress", async () => {
|
||||||
|
const { hiddenFileSync, progress, log, trackDatabaseFileModification } = createHiddenFileSync();
|
||||||
|
const files = createDatabaseEntries(26);
|
||||||
|
const failingPath = ".obsidian/plugins/example/12.json" as FilePath;
|
||||||
|
const error = new Error("Database file item failed");
|
||||||
|
trackDatabaseFileModification.mockImplementation(async (path) => {
|
||||||
|
if (path === failingPath) throw error;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(hiddenFileSync.useDatabaseFiles(files)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(log).toHaveBeenCalledWith(expect.stringContaining("Failed to process database changes:"));
|
||||||
|
expect(log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(progress.log.mock.calls).toEqual([["Scanning database files..."], ["1/26"]]);
|
||||||
|
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts all storage work eagerly and calls done only after all promises resolve", async () => {
|
||||||
|
const { hiddenFileSync, progress, trackStorageFileModification } = createHiddenFileSync();
|
||||||
|
const files = createStoragePaths(101);
|
||||||
|
const resolveModifications: Array<(result: boolean) => void> = [];
|
||||||
|
trackStorageFileModification.mockImplementation(
|
||||||
|
() => new Promise<boolean>((resolve) => resolveModifications.push(resolve))
|
||||||
|
);
|
||||||
|
|
||||||
|
const operation = hiddenFileSync.trackScannedStorageChanges(files);
|
||||||
|
expect(trackStorageFileModification).toHaveBeenCalledTimes(files.length);
|
||||||
|
expect(progress.done).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
for (const resolveModification of resolveModifications) resolveModification(false);
|
||||||
|
await operation;
|
||||||
|
|
||||||
|
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call done when the item error handler rejects the batch", async () => {
|
||||||
|
const { hiddenFileSync, progress, trackStorageFileModification } = createHiddenFileSync();
|
||||||
|
const itemError = new Error("Storage item failed");
|
||||||
|
const handlerError = new Error("Progress error handler failed");
|
||||||
|
trackStorageFileModification.mockRejectedValueOnce(itemError);
|
||||||
|
progress.once.mockImplementation(() => {
|
||||||
|
throw handlerError;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(hiddenFileSync.trackScannedStorageChanges(createStoragePaths(1))).rejects.toBe(handlerError);
|
||||||
|
expect(progress.done).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/deps.ts", () => ({}));
|
||||||
|
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
|
||||||
|
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
|
||||||
|
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||||
|
PeriodicProcessor: class PeriodicProcessor {
|
||||||
|
disable = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("./configureHiddenFileSyncMode.ts", () => ({ configureHiddenFileSyncMode: vi.fn() }));
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
|
type DocumentID,
|
||||||
|
type FilePathWithPrefix,
|
||||||
|
type LoadedEntry,
|
||||||
|
type MetaEntry,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import { HiddenFileSync } from "./CmdHiddenFileSync";
|
||||||
|
import { sendSignal, waitForSignal } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||||
|
|
||||||
|
const queueOwners: HiddenFileSync[] = [];
|
||||||
|
|
||||||
|
function deferred<T = void>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
|
const promise = new Promise<T>((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForCondition(predicate: () => boolean, message: string) {
|
||||||
|
for (let attempt = 0; attempt < 30; attempt++) {
|
||||||
|
if (predicate()) return;
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHiddenFileSync(localDatabase: object) {
|
||||||
|
vi.spyOn(HiddenFileSync.prototype, "onBindFunction").mockImplementation(() => {});
|
||||||
|
const hiddenFileSync = new HiddenFileSync({
|
||||||
|
settings: {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
syncInternalFiles: true,
|
||||||
|
syncInternalFileOverwritePatterns: "",
|
||||||
|
},
|
||||||
|
localDatabase,
|
||||||
|
services: { API: { addLog: vi.fn() }, context: { app: {} } },
|
||||||
|
} as unknown as ConstructorParameters<typeof HiddenFileSync>[0]);
|
||||||
|
hiddenFileSync._log = vi.fn();
|
||||||
|
queueOwners.push(hiddenFileSync);
|
||||||
|
return { hiddenFileSync };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const hiddenFileSync of queueOwners.splice(0)) {
|
||||||
|
hiddenFileSync.conflictResolutionProcessor.dispose();
|
||||||
|
}
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HiddenFileSync conflict queue", () => {
|
||||||
|
it("keeps a self-requeued conflict check and waits for its follow-up", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const path = "i:.obsidian/plugins/example/data.txt" as FilePathWithPrefix;
|
||||||
|
const id = String(path) as DocumentID;
|
||||||
|
const conflictDoc = {
|
||||||
|
_id: id,
|
||||||
|
_rev: "2-current",
|
||||||
|
path,
|
||||||
|
_conflicts: ["2-conflicted"],
|
||||||
|
} as MetaEntry;
|
||||||
|
const resolvedDoc = { _id: id, _rev: "3-resolved", path } as MetaEntry;
|
||||||
|
let rawRead = 0;
|
||||||
|
const localDatabase = {
|
||||||
|
findEntries: vi.fn(async function* () {
|
||||||
|
yield conflictDoc;
|
||||||
|
}),
|
||||||
|
getRaw: vi.fn(async () => (rawRead++ === 0 ? conflictDoc : resolvedDoc)),
|
||||||
|
};
|
||||||
|
const { hiddenFileSync } = createHiddenFileSync(localDatabase);
|
||||||
|
hiddenFileSync.path2id = async () => id;
|
||||||
|
|
||||||
|
let resolutionCalls = 0;
|
||||||
|
let requeued = false;
|
||||||
|
hiddenFileSync.resolveByNewerEntry = vi.fn(async () => {
|
||||||
|
resolutionCalls++;
|
||||||
|
hiddenFileSync.requeueConflictCheck(path);
|
||||||
|
requeued = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
let completed = false;
|
||||||
|
hiddenFileSync.queueConflictCheck(path);
|
||||||
|
try {
|
||||||
|
await waitForCondition(
|
||||||
|
() => hiddenFileSync.conflictResolutionProcessor.remaining === 1,
|
||||||
|
"The initial conflict check was not queued"
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(9);
|
||||||
|
expect(localDatabase.getRaw).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
await waitForCondition(
|
||||||
|
() =>
|
||||||
|
requeued &&
|
||||||
|
hiddenFileSync.conflictResolutionProcessor.nowProcessing === 0 &&
|
||||||
|
hiddenFileSync.conflictResolutionProcessor.remaining === 1,
|
||||||
|
"The conflict follow-up was not retained after the first pass"
|
||||||
|
);
|
||||||
|
void hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed().then(() => {
|
||||||
|
completed = true;
|
||||||
|
});
|
||||||
|
expect(completed).toBe(false);
|
||||||
|
expect(localDatabase.getRaw).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(9);
|
||||||
|
expect(localDatabase.getRaw).toHaveBeenCalledTimes(1);
|
||||||
|
expect(completed).toBe(false);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await waitForCondition(() => completed, "The conflict pipeline did not await its follow-up");
|
||||||
|
|
||||||
|
expect(localDatabase.getRaw).toHaveBeenCalledTimes(2);
|
||||||
|
expect(resolutionCalls).toBe(1);
|
||||||
|
expect(hiddenFileSync.pendingConflictChecks.has(path)).toBe(false);
|
||||||
|
expect(hiddenFileSync.conflictResolutionProcessor.remaining).toBe(0);
|
||||||
|
} finally {
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["prefixed", "unprefixed"])(
|
||||||
|
"cancels the older same-file dialogue and presents the latest revisions for a %s request",
|
||||||
|
async (form) => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const filename = `.obsidian/replacement-${form}.json`;
|
||||||
|
const path = `i:${filename}` as FilePathWithPrefix;
|
||||||
|
const id = String(path) as DocumentID;
|
||||||
|
let generation = 2;
|
||||||
|
const localDatabase = {
|
||||||
|
getRaw: vi.fn(async (_id: DocumentID, options?: { revs_info?: boolean }) =>
|
||||||
|
options?.revs_info
|
||||||
|
? {}
|
||||||
|
: { _id: id, path, _rev: `${generation}-current`, _conflicts: [`${generation}-other`] }
|
||||||
|
),
|
||||||
|
getDBEntry: vi.fn(
|
||||||
|
async (_path: FilePathWithPrefix, options: { rev: string }) =>
|
||||||
|
({ _id: id, path, _rev: options.rev }) as LoadedEntry
|
||||||
|
),
|
||||||
|
managers: { conflictManager: { mergeObject: vi.fn(async () => false) } },
|
||||||
|
};
|
||||||
|
const { hiddenFileSync } = createHiddenFileSync(localDatabase);
|
||||||
|
hiddenFileSync.path2id = async () => id;
|
||||||
|
const finishFirst = deferred<boolean>();
|
||||||
|
let cancelled = false;
|
||||||
|
const opened: string[][] = [];
|
||||||
|
hiddenFileSync.showJSONMergeDialogAndMerge = vi.fn(async (left, right) => {
|
||||||
|
opened.push([left._rev!, right._rev!]);
|
||||||
|
if (opened.length > 1) return false;
|
||||||
|
return await Promise.race([
|
||||||
|
finishFirst.promise,
|
||||||
|
waitForSignal(`cancel-internal-conflict:${filename}`).then(() => {
|
||||||
|
cancelled = true;
|
||||||
|
return false;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
hiddenFileSync.queueConflictCheck(path);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(opened).toEqual([["2-current", "2-other"]]);
|
||||||
|
|
||||||
|
generation = 3;
|
||||||
|
hiddenFileSync.queueConflictCheck((form === "prefixed" ? path : filename) as FilePathWithPrefix);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
expect(cancelled).toBe(true);
|
||||||
|
expect(opened).toEqual([
|
||||||
|
["2-current", "2-other"],
|
||||||
|
["3-current", "3-other"],
|
||||||
|
]);
|
||||||
|
expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(true);
|
||||||
|
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
|
||||||
|
} finally {
|
||||||
|
finishFirst.resolve(false);
|
||||||
|
sendSignal(`cancel-internal-conflict:${filename}`);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["same", "different"])(
|
||||||
|
"closes only a dialogue for the %s file when a replicated resolution arrives",
|
||||||
|
async (target) => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const filename = `.obsidian/received-${target}.json`;
|
||||||
|
const path = `i:${filename}` as FilePathWithPrefix;
|
||||||
|
const incomingPath = (target === "same" ? path : "i:.obsidian/other-resolved.json") as FilePathWithPrefix;
|
||||||
|
const id = String(path) as DocumentID;
|
||||||
|
let resolved = false;
|
||||||
|
const localDatabase = {
|
||||||
|
getRaw: vi.fn(async (_id: DocumentID, options?: { revs_info?: boolean }) =>
|
||||||
|
options?.revs_info
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
_id: id,
|
||||||
|
path,
|
||||||
|
_rev: resolved ? "3-resolved" : "2-current",
|
||||||
|
_conflicts: resolved ? [] : ["2-other"],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
getDBEntry: vi.fn(async () => ({ _id: id, path }) as LoadedEntry),
|
||||||
|
managers: { conflictManager: { mergeObject: vi.fn(async () => false) } },
|
||||||
|
};
|
||||||
|
const { hiddenFileSync } = createHiddenFileSync(localDatabase);
|
||||||
|
hiddenFileSync.path2id = async () => id;
|
||||||
|
hiddenFileSync.getPath = () => incomingPath;
|
||||||
|
hiddenFileSync.isTargetFile = vi.fn(async () => true);
|
||||||
|
hiddenFileSync.trackDatabaseFileModification = vi.fn(async () => true);
|
||||||
|
const finishFirst = deferred<boolean>();
|
||||||
|
let cancelled = false;
|
||||||
|
hiddenFileSync.showJSONMergeDialogAndMerge = vi.fn(
|
||||||
|
async () =>
|
||||||
|
await Promise.race([
|
||||||
|
finishFirst.promise,
|
||||||
|
waitForSignal(`cancel-internal-conflict:${filename}`).then(() => {
|
||||||
|
cancelled = true;
|
||||||
|
return false;
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
hiddenFileSync.queueConflictCheck(path);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(hiddenFileSync.showJSONMergeDialogAndMerge).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
resolved = target === "same";
|
||||||
|
const incoming = {
|
||||||
|
_id: String(incomingPath) as DocumentID,
|
||||||
|
path: incomingPath,
|
||||||
|
_rev: "3-received",
|
||||||
|
} as LoadedEntry;
|
||||||
|
expect(await hiddenFileSync._anyProcessOptionalSyncFiles(incoming)).toBe(true);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
expect(cancelled).toBe(target === "same");
|
||||||
|
expect(hiddenFileSync.showJSONMergeDialogAndMerge).toHaveBeenCalledOnce();
|
||||||
|
expect(hiddenFileSync.pendingConflictChecks.size).toBe(target === "same" ? 0 : 1);
|
||||||
|
if (target === "same") {
|
||||||
|
expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
finishFirst.resolve(false);
|
||||||
|
sendSignal(`cancel-internal-conflict:${filename}`);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("waits for a cancelled manual merge dialogue and then finishes without requeueing", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const path = "i:.obsidian/plugins/example/data.json" as FilePathWithPrefix;
|
||||||
|
const id = String(path) as DocumentID;
|
||||||
|
const conflictDoc = {
|
||||||
|
_id: id,
|
||||||
|
_rev: "2-current",
|
||||||
|
path,
|
||||||
|
_conflicts: ["2-conflicted"],
|
||||||
|
} as MetaEntry;
|
||||||
|
const historyDoc = {} as unknown as MetaEntry;
|
||||||
|
const loaded = { _id: id, _rev: "2-current", path } as LoadedEntry;
|
||||||
|
const dialogue = deferred<boolean>();
|
||||||
|
let dialogueOpened = false;
|
||||||
|
const localDatabase = {
|
||||||
|
findEntries: vi.fn(async function* () {
|
||||||
|
yield conflictDoc;
|
||||||
|
}),
|
||||||
|
getRaw: vi.fn(async (_id: DocumentID, options?: { revs_info?: boolean }) =>
|
||||||
|
options?.revs_info ? historyDoc : conflictDoc
|
||||||
|
),
|
||||||
|
getDBEntry: vi.fn(async () => loaded),
|
||||||
|
managers: {
|
||||||
|
conflictManager: {
|
||||||
|
mergeObject: vi.fn(async () => false),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { hiddenFileSync } = createHiddenFileSync(localDatabase);
|
||||||
|
hiddenFileSync.path2id = async () => id;
|
||||||
|
hiddenFileSync.showJSONMergeDialogAndMerge = vi.fn(async () => {
|
||||||
|
dialogueOpened = true;
|
||||||
|
return await dialogue.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
let completed = false;
|
||||||
|
hiddenFileSync.queueConflictCheck(path);
|
||||||
|
try {
|
||||||
|
await waitForCondition(
|
||||||
|
() => hiddenFileSync.conflictResolutionProcessor.remaining === 1,
|
||||||
|
"The initial conflict check was not queued"
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(dialogueOpened).toBe(true);
|
||||||
|
void hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed().then(() => {
|
||||||
|
completed = true;
|
||||||
|
});
|
||||||
|
expect(completed).toBe(false);
|
||||||
|
expect(hiddenFileSync.conflictResolutionProcessor.totalRemaining).toBeGreaterThan(0);
|
||||||
|
expect(localDatabase.managers.conflictManager.mergeObject).toHaveBeenCalledOnce();
|
||||||
|
expect(localDatabase.getDBEntry).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
dialogue.resolve(false);
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await waitForCondition(() => completed, "The cancelled dialogue did not release the pipeline");
|
||||||
|
|
||||||
|
expect(hiddenFileSync.pendingConflictChecks.has(path)).toBe(false);
|
||||||
|
expect(hiddenFileSync.conflictResolutionProcessor.totalRemaining).toBe(0);
|
||||||
|
} finally {
|
||||||
|
dialogue.resolve(false);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HiddenFileSync pipeline contracts", () => {
|
||||||
|
it("starts above the threshold, limits analysis to five, and retains FIFO input", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const gates = Array.from({ length: 11 }, () => deferred<MetaEntry>());
|
||||||
|
const paths = gates.map((_, index) => `i:.obsidian/${index}.txt` as FilePathWithPrefix);
|
||||||
|
const reads: string[] = [];
|
||||||
|
const getRaw = vi.fn((id: DocumentID) => {
|
||||||
|
reads.push(id);
|
||||||
|
return gates[paths.indexOf(id as unknown as FilePathWithPrefix)].promise;
|
||||||
|
});
|
||||||
|
const { hiddenFileSync } = createHiddenFileSync({ getRaw });
|
||||||
|
hiddenFileSync.path2id = async (path) => path as unknown as DocumentID;
|
||||||
|
for (const path of paths.slice(0, 10)) hiddenFileSync.queueConflictCheck(path);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
hiddenFileSync.queueConflictCheck(paths[10]);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(reads).toEqual(paths.slice(0, 5));
|
||||||
|
hiddenFileSync.queueConflictCheck(paths[0]);
|
||||||
|
expect(hiddenFileSync.pendingConflictChecks.size).toBe(11);
|
||||||
|
gates[0].resolve({ path: paths[0] } as MetaEntry);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(reads).toEqual(paths.slice(0, 6));
|
||||||
|
gates.forEach((gate, index) => gate.resolve({ path: paths[index] } as MetaEntry));
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(true);
|
||||||
|
expect(reads).toEqual([...paths, paths[0]]);
|
||||||
|
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues analysis while one manual dialogue is open and serialises the dialogues", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const paths = Array.from({ length: 12 }, (_, index) => `i:.obsidian/${index}.json` as FilePathWithPrefix);
|
||||||
|
const gates = paths.map(() => deferred<boolean>());
|
||||||
|
const firstDialogue = deferred<boolean>();
|
||||||
|
let firstCancelled = false;
|
||||||
|
const localDatabase = {
|
||||||
|
getRaw: vi.fn(async (id: DocumentID, options?: { revs_info?: boolean }) =>
|
||||||
|
options?.revs_info
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
_id: id,
|
||||||
|
path: id,
|
||||||
|
_rev: "2-current",
|
||||||
|
_conflicts: ["2-other"],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
getDBEntry: vi.fn(async (path: FilePathWithPrefix) => ({ path }) as LoadedEntry),
|
||||||
|
managers: { conflictManager: { mergeObject: vi.fn(async () => false) } },
|
||||||
|
};
|
||||||
|
const { hiddenFileSync } = createHiddenFileSync(localDatabase);
|
||||||
|
hiddenFileSync.path2id = async (path) => path as unknown as DocumentID;
|
||||||
|
const opened: FilePathWithPrefix[] = [];
|
||||||
|
hiddenFileSync.showJSONMergeDialogAndMerge = vi.fn((doc) => {
|
||||||
|
opened.push(doc.path);
|
||||||
|
if (opened.length === 1) {
|
||||||
|
return Promise.race([
|
||||||
|
firstDialogue.promise,
|
||||||
|
waitForSignal(`cancel-internal-conflict:${doc.path.slice(2)}`).then(() => {
|
||||||
|
firstCancelled = true;
|
||||||
|
return false;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return gates[paths.indexOf(doc.path)].promise;
|
||||||
|
});
|
||||||
|
paths.forEach((path) => hiddenFileSync.queueConflictCheck(path));
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(localDatabase.managers.conflictManager.mergeObject).toHaveBeenCalledTimes(12);
|
||||||
|
expect(opened).toEqual([paths[0]]);
|
||||||
|
paths.forEach((path) => hiddenFileSync.queueConflictCheck(path));
|
||||||
|
expect(hiddenFileSync.pendingConflictChecks.size).toBe(12);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(firstCancelled).toBe(true);
|
||||||
|
expect(opened).toEqual([paths[0], paths[0]]);
|
||||||
|
expect(localDatabase.managers.conflictManager.mergeObject).toHaveBeenCalledTimes(24);
|
||||||
|
let completed = false;
|
||||||
|
void hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed().then(() => {
|
||||||
|
completed = true;
|
||||||
|
});
|
||||||
|
for (let index = 0; index < paths.length; index++) {
|
||||||
|
expect(opened).toEqual([paths[0], ...paths.slice(0, index + 1)]);
|
||||||
|
expect(completed).toBe(false);
|
||||||
|
gates[index].resolve(false);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
}
|
||||||
|
expect(completed).toBe(true);
|
||||||
|
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs an analysis error and continues with later paths", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const failure = new Error("Injected read failure");
|
||||||
|
const getRaw = vi.fn().mockRejectedValueOnce(failure).mockResolvedValue({});
|
||||||
|
const { hiddenFileSync } = createHiddenFileSync({ getRaw });
|
||||||
|
hiddenFileSync.path2id = async (path) => path as unknown as DocumentID;
|
||||||
|
hiddenFileSync.queueConflictCheck("i:bad.txt" as FilePathWithPrefix);
|
||||||
|
hiddenFileSync.queueConflictCheck("i:good.txt" as FilePathWithPrefix);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(true);
|
||||||
|
expect(getRaw).toHaveBeenCalledTimes(2);
|
||||||
|
expect(hiddenFileSync._log).toHaveBeenCalledWith(failure, expect.anything());
|
||||||
|
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects a conflict enumeration before resuming analysis", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const gate = deferred();
|
||||||
|
const path = "i:.obsidian/scanned.txt" as FilePathWithPrefix;
|
||||||
|
const getRaw = vi.fn(async () => ({}));
|
||||||
|
const { hiddenFileSync } = createHiddenFileSync({
|
||||||
|
getRaw,
|
||||||
|
findEntries: async function* () {
|
||||||
|
await gate.promise;
|
||||||
|
yield { _id: path, path, _conflicts: ["2-other"] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
hiddenFileSync.path2id = async (value) => value as unknown as DocumentID;
|
||||||
|
const result = hiddenFileSync.resolveConflictOnInternalFiles();
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
hiddenFileSync.queueConflictCheck(path);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(getRaw).not.toHaveBeenCalled();
|
||||||
|
gate.resolve();
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
await result;
|
||||||
|
expect(getRaw).toHaveBeenCalledOnce();
|
||||||
|
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HiddenFileSync conflict queue ownership", () => {
|
||||||
|
it("stops delayed work on unload and rejects later conflict events", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const getRaw = vi.fn(async () => ({}));
|
||||||
|
const { hiddenFileSync } = createHiddenFileSync({ getRaw });
|
||||||
|
hiddenFileSync.path2id = async (path) => path as unknown as DocumentID;
|
||||||
|
const path = "i:.obsidian/queued.txt" as FilePathWithPrefix;
|
||||||
|
hiddenFileSync.queueConflictCheck(path);
|
||||||
|
const idle = hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed();
|
||||||
|
hiddenFileSync.onunload();
|
||||||
|
hiddenFileSync.queueConflictCheck(path);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(await idle).toBe(false);
|
||||||
|
expect(getRaw).not.toHaveBeenCalled();
|
||||||
|
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finishes an admitted analysis without handing its result to a new dialogue after unload", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const gate = deferred<MetaEntry>();
|
||||||
|
const path = "i:.obsidian/active.json" as FilePathWithPrefix;
|
||||||
|
const getRaw = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(() => gate.promise)
|
||||||
|
.mockResolvedValue({});
|
||||||
|
const getDBEntry = vi.fn(async () => ({ path }));
|
||||||
|
const mergeObject = vi.fn(async () => false);
|
||||||
|
const { hiddenFileSync } = createHiddenFileSync({
|
||||||
|
getRaw,
|
||||||
|
getDBEntry,
|
||||||
|
managers: { conflictManager: { mergeObject } },
|
||||||
|
});
|
||||||
|
hiddenFileSync.path2id = async () => path as unknown as DocumentID;
|
||||||
|
hiddenFileSync.queueConflictCheck(path);
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
expect(getRaw).toHaveBeenCalledOnce();
|
||||||
|
hiddenFileSync.onunload();
|
||||||
|
expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(false);
|
||||||
|
gate.resolve({
|
||||||
|
_id: String(path) as DocumentID,
|
||||||
|
path,
|
||||||
|
_rev: "2-current",
|
||||||
|
_conflicts: ["2-other"],
|
||||||
|
} as MetaEntry);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(mergeObject).toHaveBeenCalledOnce();
|
||||||
|
expect(getDBEntry).not.toHaveBeenCalled();
|
||||||
|
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not requeue a manual resolution which finishes after unload", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const gate = deferred<boolean>();
|
||||||
|
const path = "i:.obsidian/dialogue.json" as FilePathWithPrefix;
|
||||||
|
const getRaw = vi.fn(async (_id: DocumentID, options?: { revs_info?: boolean }) =>
|
||||||
|
options?.revs_info
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
_id: path,
|
||||||
|
path,
|
||||||
|
_rev: "2-current",
|
||||||
|
_conflicts: ["2-other"],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const getDBEntry = vi.fn(async () => ({ path }) as LoadedEntry);
|
||||||
|
const { hiddenFileSync } = createHiddenFileSync({
|
||||||
|
getRaw,
|
||||||
|
getDBEntry,
|
||||||
|
managers: { conflictManager: { mergeObject: async () => false } },
|
||||||
|
});
|
||||||
|
hiddenFileSync.path2id = async () => path as unknown as DocumentID;
|
||||||
|
hiddenFileSync.showJSONMergeDialogAndMerge = vi.fn(() => gate.promise);
|
||||||
|
hiddenFileSync.queueConflictCheck(path);
|
||||||
|
await vi.advanceTimersByTimeAsync(20);
|
||||||
|
expect(hiddenFileSync.showJSONMergeDialogAndMerge).toHaveBeenCalledOnce();
|
||||||
|
hiddenFileSync.onunload();
|
||||||
|
expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(false);
|
||||||
|
gate.resolve(true);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(getRaw).toHaveBeenCalledTimes(2);
|
||||||
|
expect(getDBEntry).toHaveBeenCalledTimes(2);
|
||||||
|
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
|
||||||
|
expect(hiddenFileSync.conflictResolutionProcessor.totalRemaining).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,7 +35,6 @@ import {
|
|||||||
getLogLevel,
|
getLogLevel,
|
||||||
autosaveCache,
|
autosaveCache,
|
||||||
type MapLike,
|
type MapLike,
|
||||||
onlyInNTimes,
|
|
||||||
BASE_IS_NEW,
|
BASE_IS_NEW,
|
||||||
EVEN,
|
EVEN,
|
||||||
displayRev,
|
displayRev,
|
||||||
@@ -45,7 +44,8 @@ import { serialized, skipIfDuplicated } from "octagonal-wheels/concurrency/lock"
|
|||||||
import { JsonResolveModal } from "@/features/HiddenFileCommon/JsonResolveModal.ts";
|
import { JsonResolveModal } from "@/features/HiddenFileCommon/JsonResolveModal.ts";
|
||||||
import { LiveSyncCommands } from "@/features/LiveSyncCommands.ts";
|
import { LiveSyncCommands } from "@/features/LiveSyncCommands.ts";
|
||||||
import { addPrefix, stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
import { addPrefix, stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||||
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
import { createCoalescingNotifier } from "octagonal-wheels/concurrency/coalescingNotifier";
|
||||||
|
import { HiddenFileConflictQueue } from "./HiddenFileConflictQueue.ts";
|
||||||
import {
|
import {
|
||||||
hiddenFilesEventCount,
|
hiddenFilesEventCount,
|
||||||
hiddenFilesProcessingCount,
|
hiddenFilesProcessingCount,
|
||||||
@@ -57,6 +57,7 @@ import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.d
|
|||||||
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||||
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||||
import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts";
|
import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts";
|
||||||
|
import { performBatchWithProgress } from "./performBatchWithProgress.ts";
|
||||||
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
||||||
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
||||||
import { $msg } from "@/common/translation";
|
import { $msg } from "@/common/translation";
|
||||||
@@ -107,6 +108,8 @@ export class HiddenFileSync extends LiveSyncCommands {
|
|||||||
}
|
}
|
||||||
onunload() {
|
onunload() {
|
||||||
this.periodicInternalFileScanProcessor?.disable();
|
this.periodicInternalFileScanProcessor?.disable();
|
||||||
|
this.conflictResolutionProcessor.dispose();
|
||||||
|
this.pendingConflictChecks.clear();
|
||||||
}
|
}
|
||||||
onload() {
|
onload() {
|
||||||
this.services.API.addCommand({
|
this.services.API.addCommand({
|
||||||
@@ -571,18 +574,15 @@ export class HiddenFileSync extends LiveSyncCommands {
|
|||||||
) {
|
) {
|
||||||
const logLevel = getLogLevel(showNotice);
|
const logLevel = getLogLevel(showNotice);
|
||||||
const p = this._progress(`[⚙ Storage -> DB ]\n`, logLevel);
|
const p = this._progress(`[⚙ Storage -> DB ]\n`, logLevel);
|
||||||
const notifyProgress = onlyInNTimes(100, (progress) => p.log(`${progress}/${processFiles.length}`));
|
await performBatchWithProgress(processFiles, 100, {
|
||||||
const processes = processFiles.map(async (file, i) => {
|
processItem: (file) => this.trackStorageFileModification(file, onlyNew, forceWriteAll, includeDeleted),
|
||||||
try {
|
onProgress: (progress) => p.log(`${progress}/${processFiles.length}`),
|
||||||
await this.trackStorageFileModification(file, onlyNew, forceWriteAll, includeDeleted);
|
onItemError: (ex, file) => {
|
||||||
notifyProgress();
|
|
||||||
} catch (ex) {
|
|
||||||
p.once(`Failed to process storage change file:${file}`);
|
p.once(`Failed to process storage change file:${file}`);
|
||||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||||
}
|
},
|
||||||
|
onDone: () => p.done(),
|
||||||
});
|
});
|
||||||
await Promise.all(processes);
|
|
||||||
p.done();
|
|
||||||
}
|
}
|
||||||
async scanAllStorageChanges(
|
async scanAllStorageChanges(
|
||||||
showNotice: boolean = false,
|
showNotice: boolean = false,
|
||||||
@@ -688,7 +688,7 @@ Offline Changed files: ${processFiles.length}`;
|
|||||||
const dbMTime = getComparingMTime(filesOnDB, includeDeleted);
|
const dbMTime = getComparingMTime(filesOnDB, includeDeleted);
|
||||||
const diff = compareMTime(storageMTime, dbMTime);
|
const diff = compareMTime(storageMTime, dbMTime);
|
||||||
|
|
||||||
if (diff != TARGET_IS_NEW) {
|
if (diff != BASE_IS_NEW) {
|
||||||
this._log(`Hidden file:${path} is not new.`, LOG_LEVEL_VERBOSE);
|
this._log(`Hidden file:${path} is not new.`, LOG_LEVEL_VERBOSE);
|
||||||
if (filesOnDB && stat) {
|
if (filesOnDB && stat) {
|
||||||
// OnlyNew not handles the deletion.
|
// OnlyNew not handles the deletion.
|
||||||
@@ -741,21 +741,33 @@ Offline Changed files: ${processFiles.length}`;
|
|||||||
|
|
||||||
// --> Conflict processing
|
// --> Conflict processing
|
||||||
|
|
||||||
// Keep one in-flight conflict check per path so repeated sync events do not close the active merge dialogue.
|
// Coalesce unstarted checks; retain a replacement once processing has begun.
|
||||||
pendingConflictChecks = new Set<FilePathWithPrefix>();
|
pendingConflictChecks = new Map<FilePathWithPrefix, { started: boolean; recheck: boolean }>();
|
||||||
|
|
||||||
queueConflictCheck(path: FilePathWithPrefix) {
|
queueConflictCheck(path: FilePathWithPrefix) {
|
||||||
if (this.pendingConflictChecks.has(path)) return;
|
path = addPrefix(path, ICHeader);
|
||||||
this.pendingConflictChecks.add(path);
|
const pending = this.pendingConflictChecks.get(path);
|
||||||
this.conflictResolutionProcessor.enqueue(path);
|
if (pending) {
|
||||||
|
if (pending.started) {
|
||||||
|
pending.recheck = true;
|
||||||
|
sendSignal(`cancel-internal-conflict:${stripAllPrefixes(path)}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pendingConflictChecks.set(path, { started: false, recheck: false });
|
||||||
|
if (!this.conflictResolutionProcessor.enqueue(path)) this.pendingConflictChecks.delete(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
finishConflictCheck(path: FilePathWithPrefix) {
|
finishConflictCheck(path: FilePathWithPrefix) {
|
||||||
|
path = addPrefix(path, ICHeader);
|
||||||
|
const pending = this.pendingConflictChecks.get(path);
|
||||||
this.pendingConflictChecks.delete(path);
|
this.pendingConflictChecks.delete(path);
|
||||||
|
if (pending?.recheck) this.queueConflictCheck(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
requeueConflictCheck(path: FilePathWithPrefix) {
|
requeueConflictCheck(path: FilePathWithPrefix) {
|
||||||
this.finishConflictCheck(path);
|
path = addPrefix(path, ICHeader);
|
||||||
|
this.pendingConflictChecks.delete(path);
|
||||||
this.queueConflictCheck(path);
|
this.queueConflictCheck(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -804,13 +816,18 @@ Offline Changed files: ${processFiles.length}`;
|
|||||||
}
|
}
|
||||||
// check the file again
|
// check the file again
|
||||||
}
|
}
|
||||||
conflictResolutionProcessor = new QueueProcessor(
|
conflictResolutionProcessor = new HiddenFileConflictQueue(
|
||||||
async (paths: FilePathWithPrefix[]) => {
|
async (path: FilePathWithPrefix) => {
|
||||||
const path = paths[0];
|
const check = this.pendingConflictChecks.get(path);
|
||||||
|
if (check) check.started = true;
|
||||||
try {
|
try {
|
||||||
// Retrieve data
|
// Retrieve data
|
||||||
const id = await this.path2id(path, ICHeader);
|
const id = await this.path2id(path, ICHeader);
|
||||||
const doc = await this.localDatabase.getRaw<MetaEntry>(id, { conflicts: true });
|
const doc = await this.localDatabase.getRaw<MetaEntry>(id, { conflicts: true });
|
||||||
|
if (check?.recheck) {
|
||||||
|
this.requeueConflictCheck(path);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
if (doc._conflicts === undefined) {
|
if (doc._conflicts === undefined) {
|
||||||
this.finishConflictCheck(path);
|
this.finishConflictCheck(path);
|
||||||
return [];
|
return [];
|
||||||
@@ -839,6 +856,10 @@ Offline Changed files: ${processFiles.length}`;
|
|||||||
doc._rev,
|
doc._rev,
|
||||||
conflictedRev
|
conflictedRev
|
||||||
);
|
);
|
||||||
|
if (check?.recheck) {
|
||||||
|
this.requeueConflictCheck(path);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
if (result) {
|
if (result) {
|
||||||
this._log(`Object merge:${path}`, LOG_LEVEL_INFO);
|
this._log(`Object merge:${path}`, LOG_LEVEL_INFO);
|
||||||
const filename = stripAllPrefixes(path);
|
const filename = stripAllPrefixes(path);
|
||||||
@@ -862,7 +883,7 @@ Offline Changed files: ${processFiles.length}`;
|
|||||||
await this.resolveByNewerEntry(id, path, doc, revA, revB);
|
await this.resolveByNewerEntry(id, path, doc, revA, revB);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return [{ path, revA, revB, id, doc }];
|
return [{ path, revA, revB, id, doc, check }];
|
||||||
}
|
}
|
||||||
// When not JSON file, resolve conflicts by choosing a newer one.
|
// When not JSON file, resolve conflicts by choosing a newer one.
|
||||||
await this.resolveByNewerEntry(id, path, doc, revA, revB);
|
await this.resolveByNewerEntry(id, path, doc, revA, revB);
|
||||||
@@ -874,46 +895,40 @@ Offline Changed files: ${processFiles.length}`;
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
async (result) => {
|
||||||
suspended: false,
|
const { id, doc, path, revA, revB, check } = result;
|
||||||
batchSize: 1,
|
if (check?.recheck) {
|
||||||
concurrentLimit: 5,
|
this.requeueConflictCheck(path);
|
||||||
delay: 10,
|
return;
|
||||||
keepResultUntilDownstreamConnected: true,
|
}
|
||||||
yieldThreshold: 10,
|
const prefixedPath = addPrefix(path, ICHeader);
|
||||||
pipeTo: new QueueProcessor(
|
const docAMerge = await this.localDatabase.getDBEntry(prefixedPath, { rev: revA });
|
||||||
async (results) => {
|
const docBMerge = await this.localDatabase.getDBEntry(prefixedPath, { rev: revB });
|
||||||
const { id, doc, path, revA, revB } = results[0];
|
if (check?.recheck) {
|
||||||
const prefixedPath = addPrefix(path, ICHeader);
|
this.requeueConflictCheck(path);
|
||||||
const docAMerge = await this.localDatabase.getDBEntry(prefixedPath, { rev: revA });
|
return;
|
||||||
const docBMerge = await this.localDatabase.getDBEntry(prefixedPath, { rev: revB });
|
}
|
||||||
try {
|
try {
|
||||||
if (docAMerge != false && docBMerge != false) {
|
if (docAMerge != false && docBMerge != false) {
|
||||||
if (await this.showJSONMergeDialogAndMerge(docAMerge, docBMerge)) {
|
if (await this.showJSONMergeDialogAndMerge(docAMerge, docBMerge)) {
|
||||||
// Again for other conflicted revisions.
|
// Again for other conflicted revisions.
|
||||||
this.requeueConflictCheck(path);
|
this.requeueConflictCheck(path);
|
||||||
} else {
|
} else {
|
||||||
this.finishConflictCheck(path);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
// If either revision could not read, force resolving by the newer one.
|
|
||||||
await this.resolveByNewerEntry(id, path, doc, revA, revB);
|
|
||||||
}
|
|
||||||
} catch (ex) {
|
|
||||||
this.finishConflictCheck(path);
|
this.finishConflictCheck(path);
|
||||||
throw ex;
|
|
||||||
}
|
}
|
||||||
},
|
return;
|
||||||
{
|
} else {
|
||||||
suspended: false,
|
// If either revision could not read, force resolving by the newer one.
|
||||||
batchSize: 1,
|
await this.resolveByNewerEntry(id, path, doc, revA, revB);
|
||||||
concurrentLimit: 1,
|
|
||||||
delay: 10,
|
|
||||||
keepResultUntilDownstreamConnected: false,
|
|
||||||
yieldThreshold: 10,
|
|
||||||
}
|
}
|
||||||
),
|
} catch (ex) {
|
||||||
|
this.finishConflictCheck(path);
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
this._log("Processor error!");
|
||||||
|
this._log(error, LOG_LEVEL_VERBOSE);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -927,7 +942,7 @@ Offline Changed files: ${processFiles.length}`;
|
|||||||
const displayFilename = `${storeFilePath}`;
|
const displayFilename = `${storeFilePath}`;
|
||||||
// const path = this.prefixedConfigDir2configDir(stripAllPrefixes(docA.path)) || docA.path;
|
// const path = this.prefixedConfigDir2configDir(stripAllPrefixes(docA.path)) || docA.path;
|
||||||
// Cancel only when replacing an existing dialogue for the same path, not on every queue pass.
|
// Cancel only when replacing an existing dialogue for the same path, not on every queue pass.
|
||||||
sendSignal(`cancel-internal-conflict:${docA.path}`);
|
sendSignal(`cancel-internal-conflict:${storageFilePath}`);
|
||||||
const modal = new JsonResolveModal(this.app, storageFilePath, [docA, docB], async (keep, result) => {
|
const modal = new JsonResolveModal(this.app, storageFilePath, [docA, docB], async (keep, result) => {
|
||||||
// modal.close();
|
// modal.close();
|
||||||
try {
|
try {
|
||||||
@@ -1013,6 +1028,9 @@ Offline Changed files: ${processFiles.length}`;
|
|||||||
async processReplicationResult(doc: LoadedEntry): Promise<boolean> {
|
async processReplicationResult(doc: LoadedEntry): Promise<boolean> {
|
||||||
const info = this.getDocProps(doc);
|
const info = this.getDocProps(doc);
|
||||||
const path = info.path;
|
const path = info.path;
|
||||||
|
if (this.pendingConflictChecks.has(addPrefix(path, ICHeader))) {
|
||||||
|
this.queueConflictCheck(info.prefixedPath);
|
||||||
|
}
|
||||||
const headerLine = `Tracking DB ${info.path} (${info.revDisplay}) :`;
|
const headerLine = `Tracking DB ${info.path} (${info.revDisplay}) :`;
|
||||||
const ret = await this.trackDatabaseFileModification(path, headerLine);
|
const ret = await this.trackDatabaseFileModification(path, headerLine);
|
||||||
this._log(`${headerLine} Done: ${info.shortenedId})`, LOG_LEVEL_VERBOSE);
|
this._log(`${headerLine} Done: ${info.shortenedId})`, LOG_LEVEL_VERBOSE);
|
||||||
@@ -1134,9 +1152,8 @@ Offline Changed files: ${processFiles.length}`;
|
|||||||
) {
|
) {
|
||||||
const logLevel = getLogLevel(showNotice);
|
const logLevel = getLogLevel(showNotice);
|
||||||
const p = this._progress(`[⚙ DB -> Storage ]\n`, logLevel);
|
const p = this._progress(`[⚙ DB -> Storage ]\n`, logLevel);
|
||||||
const notifyProgress = onlyInNTimes(100, (progress) => p.log(`${progress}/${processFiles.length}`));
|
await performBatchWithProgress(processFiles, 100, {
|
||||||
const processes = processFiles.map(async (file) => {
|
processItem: async (file) => {
|
||||||
try {
|
|
||||||
const path = stripAllPrefixes(this.getPath(file));
|
const path = stripAllPrefixes(this.getPath(file));
|
||||||
if (!(await this.isTargetFile(path))) {
|
if (!(await this.isTargetFile(path))) {
|
||||||
this._log(
|
this._log(
|
||||||
@@ -1153,14 +1170,14 @@ Offline Changed files: ${processFiles.length}`;
|
|||||||
includeDeletion
|
includeDeletion
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
notifyProgress();
|
},
|
||||||
} catch (ex) {
|
onProgress: (progress) => p.log(`${progress}/${processFiles.length}`),
|
||||||
|
onItemError: (ex, file) => {
|
||||||
this._log(`Failed to process storage change file:${tryGetFilePath(file)}`, logLevel);
|
this._log(`Failed to process storage change file:${tryGetFilePath(file)}`, logLevel);
|
||||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||||
}
|
},
|
||||||
|
onDone: () => p.done(),
|
||||||
});
|
});
|
||||||
await Promise.all(processes);
|
|
||||||
p.done();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async applyOfflineChanges(showNotice: boolean) {
|
async applyOfflineChanges(showNotice: boolean) {
|
||||||
@@ -1188,9 +1205,14 @@ Untracked database files: ${untrackedDatabase.length}
|
|||||||
Common untracked files: ${bothUntracked.length}`;
|
Common untracked files: ${bothUntracked.length}`;
|
||||||
p.once(stat);
|
p.once(stat);
|
||||||
const semaphores = Semaphore(10);
|
const semaphores = Semaphore(10);
|
||||||
const notifyProgress = onlyInNTimes(25, (progress) => p.log(`${progress}/${bothUntracked.length}`));
|
const notifyProgress = createCoalescingNotifier<number>({
|
||||||
|
everyCount: 25,
|
||||||
|
leading: true,
|
||||||
|
onNotify: (progress) => p.log(`${progress}/${bothUntracked.length}`),
|
||||||
|
});
|
||||||
|
let attempted = 0;
|
||||||
const allProcesses = bothUntracked.map(async (file) => {
|
const allProcesses = bothUntracked.map(async (file) => {
|
||||||
notifyProgress();
|
notifyProgress.update(++attempted);
|
||||||
const rel = await semaphores.acquire();
|
const rel = await semaphores.acquire();
|
||||||
try {
|
try {
|
||||||
const fileStat = await this.core.storageAccess.statHidden(file);
|
const fileStat = await this.core.storageAccess.statHidden(file);
|
||||||
@@ -1260,20 +1282,18 @@ Offline Changed files: ${files.length}`;
|
|||||||
const logLevel = getLogLevel(showNotice);
|
const logLevel = getLogLevel(showNotice);
|
||||||
const p = this._progress(`[⚙ Scanning DB -> Storage ]\n`, logLevel);
|
const p = this._progress(`[⚙ Scanning DB -> Storage ]\n`, logLevel);
|
||||||
p.log("Scanning database files...");
|
p.log("Scanning database files...");
|
||||||
const notifyProgress = onlyInNTimes(25, (progress) => p.log(`${progress}/${files.length}`));
|
await performBatchWithProgress(files, 25, {
|
||||||
const processFiles = files.map(async (file) => {
|
processItem: async (file) => {
|
||||||
try {
|
|
||||||
const path = stripAllPrefixes(this.getPath(file));
|
const path = stripAllPrefixes(this.getPath(file));
|
||||||
await this.trackDatabaseFileModification(path, "[Scanning]", true, onlyNew, file);
|
await this.trackDatabaseFileModification(path, "[Scanning]", true, onlyNew, file);
|
||||||
notifyProgress();
|
},
|
||||||
} catch (ex) {
|
onProgress: (progress) => p.log(`${progress}/${files.length}`),
|
||||||
|
onItemError: (ex, file) => {
|
||||||
this._log(`Failed to process database changes:${tryGetFilePath(file)}`);
|
this._log(`Failed to process database changes:${tryGetFilePath(file)}`);
|
||||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||||
}
|
},
|
||||||
return;
|
onDone: () => p.done(),
|
||||||
});
|
});
|
||||||
await Promise.all(processFiles);
|
|
||||||
p.done();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1435,9 +1455,14 @@ Offline Changed files: ${files.length}`;
|
|||||||
const storageToDatabase = [] as FilePath[];
|
const storageToDatabase = [] as FilePath[];
|
||||||
const databaseToStorage = [] as MetaEntry[];
|
const databaseToStorage = [] as MetaEntry[];
|
||||||
|
|
||||||
const eachProgress = onlyInNTimes(100, (progress) => p.log(`Checking ${progress}/${allFileNames.size}`));
|
const eachProgress = createCoalescingNotifier<number>({
|
||||||
|
everyCount: 100,
|
||||||
|
leading: true,
|
||||||
|
onNotify: (progress) => p.log(`Checking ${progress}/${allFileNames.size}`),
|
||||||
|
});
|
||||||
|
let attempted = 0;
|
||||||
for (const file of allFileNames) {
|
for (const file of allFileNames) {
|
||||||
eachProgress();
|
eachProgress.update(++attempted);
|
||||||
const storageMTime = await this.core.storageAccess.statHidden(file);
|
const storageMTime = await this.core.storageAccess.statHidden(file);
|
||||||
const mtimeStorage = getComparingMTime(storageMTime);
|
const mtimeStorage = getComparingMTime(storageMTime);
|
||||||
const dbEntry = allDatabaseMap.get(file)!;
|
const dbEntry = allDatabaseMap.get(file)!;
|
||||||
|
|||||||
@@ -98,6 +98,85 @@ function createHiddenRevisionOperation() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("HiddenFileSync storage loading", () => {
|
||||||
|
function createStorageReader(content: string | ArrayBuffer) {
|
||||||
|
const path = ".obsidian/plugins/example/data.json" as FilePath;
|
||||||
|
const stat = {
|
||||||
|
ctime: 123,
|
||||||
|
mtime: 456,
|
||||||
|
size: typeof content === "string" ? new TextEncoder().encode(content).byteLength : content.byteLength,
|
||||||
|
type: "file" as const,
|
||||||
|
};
|
||||||
|
const storageAccess = {
|
||||||
|
statHidden: vi.fn(async (): Promise<typeof stat | null> => stat),
|
||||||
|
readHiddenFileAuto: vi.fn(async () => content),
|
||||||
|
};
|
||||||
|
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||||
|
Object.assign(hiddenFileSync, { core: { storageAccess } });
|
||||||
|
return { hiddenFileSync, storageAccess, path, stat };
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
kind: "text",
|
||||||
|
content: "first\r\n日本語 🐙",
|
||||||
|
expected: new TextEncoder().encode("first\r\n日本語 🐙"),
|
||||||
|
mime: "text/plain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "binary",
|
||||||
|
content: new Uint8Array([0, 128, 255]).buffer,
|
||||||
|
expected: new Uint8Array([0, 128, 255]),
|
||||||
|
mime: "application/octet-stream",
|
||||||
|
},
|
||||||
|
{ kind: "empty text", content: "", expected: new Uint8Array(0), mime: "text/plain" },
|
||||||
|
{
|
||||||
|
kind: "empty binary",
|
||||||
|
content: new ArrayBuffer(0),
|
||||||
|
expected: new Uint8Array(0),
|
||||||
|
mime: "application/octet-stream",
|
||||||
|
},
|
||||||
|
])("preserves $kind content and its file metadata", async ({ content, expected, mime }) => {
|
||||||
|
const { hiddenFileSync, storageAccess, path, stat } = createStorageReader(content);
|
||||||
|
|
||||||
|
const file = await hiddenFileSync.loadFileWithInfo(path);
|
||||||
|
|
||||||
|
expect(storageAccess.statHidden).toHaveBeenCalledWith(path);
|
||||||
|
expect(storageAccess.readHiddenFileAuto).toHaveBeenCalledWith(path);
|
||||||
|
expect(file).toMatchObject({ name: "data.json", path, stat, isInternal: true, deleted: false });
|
||||||
|
expect(file.body).toBeInstanceOf(Blob);
|
||||||
|
expect(file.body.type).toBe(mime);
|
||||||
|
expect(new Uint8Array(await file.body.arrayBuffer())).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("represents a missing file as a deletion without reading storage content", async () => {
|
||||||
|
const { hiddenFileSync, storageAccess, path } = createStorageReader("unavailable");
|
||||||
|
storageAccess.statHidden.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const file = await hiddenFileSync.loadFileWithInfo(path);
|
||||||
|
|
||||||
|
expect(storageAccess.readHiddenFileAuto).not.toHaveBeenCalled();
|
||||||
|
expect(file).toMatchObject({
|
||||||
|
name: "data.json",
|
||||||
|
path,
|
||||||
|
stat: { ctime: 0, mtime: 0, size: 0, type: "file" },
|
||||||
|
isInternal: true,
|
||||||
|
deleted: true,
|
||||||
|
});
|
||||||
|
expect(file.body.type).toBe("application/octet-stream");
|
||||||
|
expect(file.body.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a read failure for an existing file", async () => {
|
||||||
|
const { hiddenFileSync, storageAccess, path } = createStorageReader("unavailable");
|
||||||
|
const error = new Error("Storage read failed");
|
||||||
|
storageAccess.readHiddenFileAuto.mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(hiddenFileSync.loadFileWithInfo(path)).rejects.toBe(error);
|
||||||
|
expect(storageAccess.readHiddenFileAuto).toHaveBeenCalledWith(path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("HiddenFileSync configuration-change notices", () => {
|
describe("HiddenFileSync configuration-change notices", () => {
|
||||||
it.each(["pushForce", "pullForce", "safe"] as const)(
|
it.each(["pushForce", "pullForce", "safe"] as const)(
|
||||||
"refuses %s initialisation before processing files while Hidden File Sync is disabled",
|
"refuses %s initialisation before processing files while Hidden File Sync is disabled",
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { compatGlobal, type CompatTimeoutHandle } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||||
|
import { withCancellation } from "octagonal-wheels/concurrency/cancellation";
|
||||||
|
import { createNotifier } from "octagonal-wheels/concurrency/notifier";
|
||||||
|
import { forEachInScope } from "octagonal-wheels/concurrency/scoped.iteration";
|
||||||
|
import { createAsyncQueue } from "octagonal-wheels/iterable/asyncQueue";
|
||||||
|
import { withOwnedResource } from "octagonal-wheels/resource";
|
||||||
|
|
||||||
|
/** The scheduling used by one hidden-file conflict stage. */
|
||||||
|
class ConflictStage<T> {
|
||||||
|
private readonly input = createAsyncQueue<T>();
|
||||||
|
private readonly reader = this.input[Symbol.asyncIterator]();
|
||||||
|
private readonly changed = createNotifier();
|
||||||
|
private readonly cancellation = new AbortController();
|
||||||
|
private timer: CompatTimeoutHandle | undefined;
|
||||||
|
private ready = false;
|
||||||
|
private suspended = false;
|
||||||
|
private stopped = false;
|
||||||
|
private outstanding = 0;
|
||||||
|
active = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
concurrency: number,
|
||||||
|
process: (item: T) => Promise<unknown>,
|
||||||
|
private readonly notify: () => void,
|
||||||
|
onError: (error: unknown) => void,
|
||||||
|
onFailure: (error: unknown) => void
|
||||||
|
) {
|
||||||
|
// The operation owns its input until started callbacks have settled.
|
||||||
|
// Abort also releases input immediately, before that final cleanup.
|
||||||
|
void withCancellation(undefined, { signal: this.cancellation.signal }, (scope) => {
|
||||||
|
const stopInput = () => {
|
||||||
|
this.input.dispose();
|
||||||
|
this.clearTimer();
|
||||||
|
this.changed.notify();
|
||||||
|
};
|
||||||
|
scope.signal.addEventListener("abort", stopInput, { once: true });
|
||||||
|
scope.defer(() => scope.signal.removeEventListener("abort", stopInput));
|
||||||
|
return withOwnedResource(this.input, () =>
|
||||||
|
forEachInScope(scope, this.availableItems(), { concurrency }, async (entry) => {
|
||||||
|
while (this.suspended && !this.stopped) await this.changed.wait();
|
||||||
|
if (this.stopped) return;
|
||||||
|
this.active++;
|
||||||
|
try {
|
||||||
|
await process(entry.value);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
onError(error);
|
||||||
|
} catch (reportingError) {
|
||||||
|
// Wake a pending input pull before the runner joins
|
||||||
|
// its other callbacks after an unexpected failure.
|
||||||
|
onFailure(reportingError);
|
||||||
|
throw reportingError;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.active--;
|
||||||
|
this.outstanding--;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}).catch(onFailure);
|
||||||
|
}
|
||||||
|
|
||||||
|
get remaining() {
|
||||||
|
return this.outstanding;
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(item: T): boolean {
|
||||||
|
if (this.stopped) return false;
|
||||||
|
this.input.enqueue(item);
|
||||||
|
this.outstanding++;
|
||||||
|
this.schedule();
|
||||||
|
this.notify();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimer() {
|
||||||
|
if (this.timer === undefined) return;
|
||||||
|
compatGlobal.clearTimeout(this.timer);
|
||||||
|
this.timer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule() {
|
||||||
|
if (this.stopped || this.suspended || this.input.size === 0) return;
|
||||||
|
if (this.ready || this.input.size > 10) {
|
||||||
|
this.clearTimer();
|
||||||
|
this.ready = true;
|
||||||
|
this.changed.notify();
|
||||||
|
} else if (this.timer === undefined) {
|
||||||
|
this.timer = compatGlobal.setTimeout(() => {
|
||||||
|
this.timer = undefined;
|
||||||
|
this.ready = true;
|
||||||
|
this.changed.notify();
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async *availableItems() {
|
||||||
|
while (!this.stopped) {
|
||||||
|
while (!this.stopped && (this.suspended || !this.ready || this.input.size === 0)) {
|
||||||
|
await this.changed.wait();
|
||||||
|
}
|
||||||
|
if (this.stopped) return;
|
||||||
|
const entry = await this.reader.next();
|
||||||
|
if (entry.done) return;
|
||||||
|
if (this.input.size === 0) {
|
||||||
|
this.ready = false;
|
||||||
|
this.clearTimer();
|
||||||
|
}
|
||||||
|
// Outstanding includes this pulled item until its callback finishes.
|
||||||
|
yield { value: entry.value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend() {
|
||||||
|
this.suspended = true;
|
||||||
|
this.ready = false;
|
||||||
|
this.clearTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
resume() {
|
||||||
|
if (!this.suspended || this.stopped) return;
|
||||||
|
this.suspended = false;
|
||||||
|
this.schedule();
|
||||||
|
this.changed.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.stopped) return;
|
||||||
|
this.stopped = true;
|
||||||
|
this.outstanding = this.active;
|
||||||
|
this.cancellation.abort();
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two hidden-file stages: five analyses, followed by one manual resolution. */
|
||||||
|
export class HiddenFileConflictQueue<T, U> {
|
||||||
|
private readonly changed = createNotifier();
|
||||||
|
private readonly analysis: ConflictStage<T>;
|
||||||
|
private readonly resolution: ConflictStage<U>;
|
||||||
|
private stopped = false;
|
||||||
|
private failure: { error: unknown } | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
analyse: (item: T) => Promise<U[]>,
|
||||||
|
resolve: (item: U) => Promise<unknown>,
|
||||||
|
onError: (error: unknown) => void
|
||||||
|
) {
|
||||||
|
const notify = () => this.changed.notify();
|
||||||
|
const onFailure = (error: unknown) => {
|
||||||
|
this.failure ??= { error };
|
||||||
|
this.dispose();
|
||||||
|
};
|
||||||
|
this.resolution = new ConflictStage(1, resolve, notify, onError, onFailure);
|
||||||
|
this.analysis = new ConflictStage(
|
||||||
|
5,
|
||||||
|
async (item) => {
|
||||||
|
const results = await analyse(item);
|
||||||
|
for (const result of results) this.resolution.enqueue(result);
|
||||||
|
},
|
||||||
|
notify,
|
||||||
|
onError,
|
||||||
|
onFailure
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(item: T): boolean {
|
||||||
|
return this.analysis.enqueue(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
get nowProcessing() {
|
||||||
|
return this.analysis.active;
|
||||||
|
}
|
||||||
|
|
||||||
|
get remaining() {
|
||||||
|
return this.analysis.remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalRemaining() {
|
||||||
|
return this.analysis.remaining + this.resolution.remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend() {
|
||||||
|
this.analysis.suspend();
|
||||||
|
}
|
||||||
|
|
||||||
|
startPipeline(): this {
|
||||||
|
this.analysis.resume();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Includes handoff and self-requeues; disposal is never successful drainage. */
|
||||||
|
async waitForAllProcessed(): Promise<boolean> {
|
||||||
|
this.startPipeline();
|
||||||
|
while (!this.stopped && this.totalRemaining > 0) await this.changed.wait();
|
||||||
|
if (this.failure) throw this.failure.error;
|
||||||
|
return !this.stopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop admission and release pending work without waiting for active dialogue or writes. */
|
||||||
|
dispose() {
|
||||||
|
if (this.stopped) return;
|
||||||
|
this.stopped = true;
|
||||||
|
this.analysis.dispose();
|
||||||
|
this.resolution.dispose();
|
||||||
|
this.changed.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { HiddenFileConflictQueue } from "./HiddenFileConflictQueue.ts";
|
||||||
|
|
||||||
|
const owners: { dispose(): void }[] = [];
|
||||||
|
function deferred<T = void>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
|
const promise = new Promise<T>((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
function own<T, U>(queue: HiddenFileConflictQueue<T, U>) {
|
||||||
|
owners.push(queue);
|
||||||
|
return queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
owners.splice(0).forEach((owner) => owner.dispose());
|
||||||
|
if (vi.isFakeTimers()) await vi.runAllTimersAsync();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HiddenFileConflictQueue", () => {
|
||||||
|
it("awaits repeated handoffs and self-requeues with native timers", async () => {
|
||||||
|
const seen: number[] = [];
|
||||||
|
let queue!: HiddenFileConflictQueue<number, number>;
|
||||||
|
queue = own(
|
||||||
|
new HiddenFileConflictQueue(
|
||||||
|
async (value: number) => [value],
|
||||||
|
async (value) => {
|
||||||
|
seen.push(value);
|
||||||
|
if (value < 3) queue.enqueue(value + 1);
|
||||||
|
},
|
||||||
|
vi.fn()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
queue.enqueue(0);
|
||||||
|
expect(await queue.waitForAllProcessed()).toBe(true);
|
||||||
|
expect(seen).toEqual([0, 1, 2, 3]);
|
||||||
|
expect(queue.totalRemaining).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the first arrival's ten-millisecond delay in each stage", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const analyse = vi.fn(async (value: number) => [value]);
|
||||||
|
const resolve = vi.fn(async (_value: number) => {});
|
||||||
|
const queue = own(new HiddenFileConflictQueue(analyse, resolve, vi.fn()));
|
||||||
|
queue.enqueue(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(9);
|
||||||
|
queue.enqueue(2);
|
||||||
|
expect(analyse).not.toHaveBeenCalled();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(analyse.mock.calls).toEqual([[1], [2]]);
|
||||||
|
expect(resolve).not.toHaveBeenCalled();
|
||||||
|
await vi.advanceTimersByTimeAsync(9);
|
||||||
|
expect(resolve).not.toHaveBeenCalled();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(resolve.mock.calls).toEqual([[1], [2]]);
|
||||||
|
expect(await queue.waitForAllProcessed()).toBe(true);
|
||||||
|
expect(queue.totalRemaining).toBe(0);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts both stages immediately above ten waiting items", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const gate = deferred();
|
||||||
|
const analyse = vi.fn(async (value: number) => [value]);
|
||||||
|
const resolve = vi.fn(async (_value: number) => {
|
||||||
|
await gate.promise;
|
||||||
|
});
|
||||||
|
const queue = own(new HiddenFileConflictQueue(analyse, resolve, vi.fn()));
|
||||||
|
for (let value = 0; value < 11; value++) queue.enqueue(value);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(analyse).toHaveBeenCalledTimes(11);
|
||||||
|
expect(resolve).toHaveBeenCalledTimes(1);
|
||||||
|
expect(queue.totalRemaining).toBe(11);
|
||||||
|
gate.resolve();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(resolve.mock.calls).toEqual(Array.from({ length: 11 }, (_, value) => [value]));
|
||||||
|
expect(await queue.waitForAllProcessed()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs item failures in both stages and continues without implicit retry", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const first = new Error("analysis failed");
|
||||||
|
const second = new Error("resolution failed");
|
||||||
|
const analyse = vi.fn(async (value: number) => {
|
||||||
|
if (value === 1) throw first;
|
||||||
|
return [value];
|
||||||
|
});
|
||||||
|
const resolve = vi.fn(async (value: number) => {
|
||||||
|
if (value === 2) throw second;
|
||||||
|
});
|
||||||
|
const onError = vi.fn();
|
||||||
|
const queue = own(new HiddenFileConflictQueue(analyse, resolve, onError));
|
||||||
|
[1, 2, 3].forEach((value) => queue.enqueue(value));
|
||||||
|
await vi.advanceTimersByTimeAsync(20);
|
||||||
|
expect(onError.mock.calls).toEqual([[first], [second]]);
|
||||||
|
expect(analyse.mock.calls).toEqual([[1], [2], [3]]);
|
||||||
|
expect(resolve.mock.calls).toEqual([[2], [3]]);
|
||||||
|
expect(await queue.waitForAllProcessed()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards delayed input and releases idle waiters on disposal", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const analyse = vi.fn(async (value: number) => [value]);
|
||||||
|
const resolve = vi.fn(async (_value: number) => {});
|
||||||
|
const queue = own(new HiddenFileConflictQueue(analyse, resolve, vi.fn()));
|
||||||
|
queue.enqueue(1);
|
||||||
|
const idle = queue.waitForAllProcessed();
|
||||||
|
queue.dispose();
|
||||||
|
queue.dispose();
|
||||||
|
expect(await idle).toBe(false);
|
||||||
|
expect(queue.enqueue(2)).toBe(false);
|
||||||
|
expect(queue.totalRemaining).toBe(0);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(analyse).not.toHaveBeenCalled();
|
||||||
|
expect(resolve).not.toHaveBeenCalled();
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not wait for active analysis or admit its late handoff and self-requeue after disposal", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const gate = deferred();
|
||||||
|
const attempted: boolean[] = [];
|
||||||
|
let finished = 0;
|
||||||
|
const analyse = vi.fn(async (value: number) => {
|
||||||
|
await gate.promise;
|
||||||
|
attempted.push(queue.enqueue(value));
|
||||||
|
finished++;
|
||||||
|
return [value];
|
||||||
|
});
|
||||||
|
const resolve = vi.fn(async (_value: number) => {});
|
||||||
|
const queue = own(new HiddenFileConflictQueue(analyse, resolve, vi.fn()));
|
||||||
|
for (let value = 0; value < 12; value++) queue.enqueue(value);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(analyse).toHaveBeenCalledTimes(5);
|
||||||
|
const idle = queue.waitForAllProcessed();
|
||||||
|
queue.dispose();
|
||||||
|
expect(await idle).toBe(false);
|
||||||
|
expect(finished).toBe(0);
|
||||||
|
expect(queue.totalRemaining).toBe(5);
|
||||||
|
gate.resolve();
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(finished).toBe(5);
|
||||||
|
expect(attempted).toEqual([false, false, false, false, false]);
|
||||||
|
expect(analyse).toHaveBeenCalledTimes(5);
|
||||||
|
expect(resolve).not.toHaveBeenCalled();
|
||||||
|
expect(queue.totalRemaining).toBe(0);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an active dialogue running and discards the next dialogue", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const gate = deferred();
|
||||||
|
let finished = false;
|
||||||
|
const resolve = vi.fn(async (_value: number) => {
|
||||||
|
await gate.promise;
|
||||||
|
finished = true;
|
||||||
|
});
|
||||||
|
const queue = own(new HiddenFileConflictQueue(async (value: number) => [value], resolve, vi.fn()));
|
||||||
|
queue.enqueue(1);
|
||||||
|
queue.enqueue(2);
|
||||||
|
await vi.advanceTimersByTimeAsync(20);
|
||||||
|
expect(resolve).toHaveBeenCalledTimes(1);
|
||||||
|
queue.dispose();
|
||||||
|
expect(await queue.waitForAllProcessed()).toBe(false);
|
||||||
|
expect(finished).toBe(false);
|
||||||
|
gate.resolve();
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(finished).toBe(true);
|
||||||
|
expect(resolve).toHaveBeenCalledTimes(1);
|
||||||
|
expect(queue.totalRemaining).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suspends analysis while already handed-off manual work continues", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const analyse = vi.fn(async (value: number) => [value]);
|
||||||
|
const resolve = vi.fn(async (_value: number) => {});
|
||||||
|
const queue = own(new HiddenFileConflictQueue(analyse, resolve, vi.fn()));
|
||||||
|
queue.enqueue(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
queue.suspend();
|
||||||
|
queue.enqueue(2);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(analyse.mock.calls).toEqual([[1]]);
|
||||||
|
expect(resolve.mock.calls).toEqual([[1]]);
|
||||||
|
queue.startPipeline();
|
||||||
|
await vi.advanceTimersByTimeAsync(20);
|
||||||
|
expect(analyse.mock.calls).toEqual([[1], [2]]);
|
||||||
|
expect(resolve.mock.calls).toEqual([[1], [2]]);
|
||||||
|
expect(await queue.waitForAllProcessed()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { createCoalescingNotifier } from "octagonal-wheels/concurrency/coalescingNotifier";
|
||||||
|
|
||||||
|
interface PerformBatchWithProgressCallbacks<T> {
|
||||||
|
/** Runs one item. Its resolved value is ignored. */
|
||||||
|
processItem: (item: T) => Promise<unknown>;
|
||||||
|
/** Receives the number of successfully completed items. */
|
||||||
|
onProgress: (completed: number) => void;
|
||||||
|
/** Handles an item or progress callback error. */
|
||||||
|
onItemError: (error: unknown, item: T) => void;
|
||||||
|
/** Runs once after all per-item promises resolve. */
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs an eager batch and coalesces progress notifications by successful item count.
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @param items - Items to process concurrently.
|
||||||
|
* @param eachCount - Number of completed items between progress notifications.
|
||||||
|
* @param callbacks - Item processing, progress, error, and completion callbacks.
|
||||||
|
* @returns A promise that resolves with no value after the batch and completion callback finish.
|
||||||
|
* @throws When notifier configuration is invalid, an error handler throws, or the completion callback throws.
|
||||||
|
* @remarks The first completed item triggers progress immediately. Work and the progress callback share
|
||||||
|
* one error boundary; errors are passed to `onItemError`. A rejected item operation does not advance
|
||||||
|
* the count. The batch has no concurrency limit or retry, and pending progress is not flushed when
|
||||||
|
* processing ends.
|
||||||
|
*/
|
||||||
|
export async function performBatchWithProgress<T>(
|
||||||
|
items: readonly T[],
|
||||||
|
eachCount: number,
|
||||||
|
callbacks: PerformBatchWithProgressCallbacks<T>
|
||||||
|
): Promise<void> {
|
||||||
|
const notifier = createCoalescingNotifier<number>({
|
||||||
|
everyCount: eachCount,
|
||||||
|
leading: true,
|
||||||
|
onNotify: callbacks.onProgress,
|
||||||
|
});
|
||||||
|
let completed = 0;
|
||||||
|
const processes = items.map(async (item) => {
|
||||||
|
try {
|
||||||
|
await callbacks.processItem(item);
|
||||||
|
notifier.update(++completed);
|
||||||
|
} catch (error) {
|
||||||
|
callbacks.onItemError(error, item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(processes);
|
||||||
|
callbacks.onDone();
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { performBatchWithProgress } from "./performBatchWithProgress.ts";
|
||||||
|
|
||||||
|
describe("performBatchWithProgress", () => {
|
||||||
|
it("counts resolved false values, continues after item errors, and does not flush partial progress", async () => {
|
||||||
|
const error = new Error("Item failed");
|
||||||
|
const processItem = vi.fn(async (item: number) => {
|
||||||
|
if (item === 2) throw error;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
const onProgress = vi.fn();
|
||||||
|
const onItemError = vi.fn();
|
||||||
|
const onDone = vi.fn();
|
||||||
|
|
||||||
|
const result = await performBatchWithProgress([1, 2, 3, 4, 5, 6, 7], 2, {
|
||||||
|
processItem,
|
||||||
|
onProgress,
|
||||||
|
onItemError,
|
||||||
|
onDone,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(processItem).toHaveBeenCalledTimes(7);
|
||||||
|
expect(onItemError).toHaveBeenCalledWith(error, 2);
|
||||||
|
expect(onProgress.mock.calls).toEqual([[1], [3], [5]]);
|
||||||
|
expect(onDone).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts every item eagerly and calls onDone only after all item promises resolve", async () => {
|
||||||
|
const items = [1, 2, 3];
|
||||||
|
const resolvers: Array<() => void> = [];
|
||||||
|
const processItem = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
resolvers.push(resolve);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const onProgress = vi.fn();
|
||||||
|
const onItemError = vi.fn();
|
||||||
|
const onDone = vi.fn();
|
||||||
|
|
||||||
|
const operation = performBatchWithProgress(items, 100, { processItem, onProgress, onItemError, onDone });
|
||||||
|
|
||||||
|
expect(processItem).toHaveBeenCalledTimes(items.length);
|
||||||
|
expect(onDone).not.toHaveBeenCalled();
|
||||||
|
for (const resolve of resolvers) resolve();
|
||||||
|
await operation;
|
||||||
|
|
||||||
|
expect(onDone).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes a progress callback error through the item error handler", async () => {
|
||||||
|
const error = new Error("Progress callback failed");
|
||||||
|
const onProgress = vi.fn(() => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const onItemError = vi.fn();
|
||||||
|
const onDone = vi.fn();
|
||||||
|
|
||||||
|
await performBatchWithProgress(["item"], 100, {
|
||||||
|
processItem: async () => false,
|
||||||
|
onProgress,
|
||||||
|
onItemError,
|
||||||
|
onDone,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onItemError).toHaveBeenCalledWith(error, "item");
|
||||||
|
expect(onDone).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the item error handler throws and skips onDone", async () => {
|
||||||
|
const itemError = new Error("Item failed");
|
||||||
|
const handlerError = new Error("Error handler failed");
|
||||||
|
const onItemError = vi.fn(() => {
|
||||||
|
throw handlerError;
|
||||||
|
});
|
||||||
|
const onDone = vi.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
performBatchWithProgress(["item"], 100, {
|
||||||
|
processItem: async () => {
|
||||||
|
throw itemError;
|
||||||
|
},
|
||||||
|
onProgress: vi.fn(),
|
||||||
|
onItemError,
|
||||||
|
onDone,
|
||||||
|
})
|
||||||
|
).rejects.toBe(handlerError);
|
||||||
|
|
||||||
|
expect(onItemError).toHaveBeenCalledWith(itemError, "item");
|
||||||
|
expect(onDone).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,7 +43,7 @@ export abstract class LiveSyncCommands {
|
|||||||
this._log = createInstanceLogFunction(this.constructor.name, this.services.API);
|
this._log = createInstanceLogFunction(this.constructor.name, this.services.API);
|
||||||
// __$checkInstanceBinding(this);
|
// __$checkInstanceBinding(this);
|
||||||
}
|
}
|
||||||
abstract onunload(): void;
|
abstract onunload(): void | Promise<void>;
|
||||||
abstract onload(): void | Promise<void>;
|
abstract onload(): void | Promise<void>;
|
||||||
|
|
||||||
_isMainReady() {
|
_isMainReady() {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReviewHarnessScenarioResult } from "./reviewHarnessTypes";
|
import type { ReviewHarnessScenarioResult } from "./reviewHarnessTypes";
|
||||||
|
import { createReviewHarnessHeapSampler } from "./reviewHarnessHeapSampling";
|
||||||
|
|
||||||
export const REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS = [
|
export const REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS = [
|
||||||
{ label: "4 KiB", bytes: 4 * 1024 },
|
{ label: "4 KiB", bytes: 4 * 1024 },
|
||||||
@@ -23,15 +24,6 @@ type BenchmarkPerformance = Pick<Performance, "now"> & {
|
|||||||
|
|
||||||
type HashAlgorithm = "SHA-256" | "xxHash64";
|
type HashAlgorithm = "SHA-256" | "xxHash64";
|
||||||
|
|
||||||
function readHeap(clock: BenchmarkPerformance): number | undefined {
|
|
||||||
try {
|
|
||||||
const bytes = clock.memory?.usedJSHeapSize;
|
|
||||||
return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function summary(samples: readonly number[]): string {
|
function summary(samples: readonly number[]): string {
|
||||||
const sorted = [...samples].sort((a, b) => a - b);
|
const sorted = [...samples].sort((a, b) => a - b);
|
||||||
const middle = sorted[Math.floor(sorted.length / 2)];
|
const middle = sorted[Math.floor(sorted.length / 2)];
|
||||||
@@ -63,19 +55,13 @@ export async function runReviewHarnessFileContentHashBenchmark(
|
|||||||
throw new Error("The xxHash64 initialisation time is invalid.");
|
throw new Error("The xxHash64 initialisation time is invalid.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const before = readHeap(clock);
|
const heap = createReviewHarnessHeapSampler(clock);
|
||||||
let highest = before;
|
|
||||||
const sampleHeap = () => {
|
|
||||||
const value = readHeap(clock);
|
|
||||||
if (value !== undefined) highest = Math.max(highest ?? value, value);
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputs: { readonly label: string; readonly bytes: number; readonly content: Blob }[] = [];
|
const inputs: { readonly label: string; readonly bytes: number; readonly content: Blob }[] = [];
|
||||||
for (const { label, bytes } of REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS) {
|
for (const { label, bytes } of REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS) {
|
||||||
await yieldControl();
|
await yieldControl();
|
||||||
inputs.push({ label, bytes, content: operations.createInput(bytes) });
|
inputs.push({ label, bytes, content: operations.createInput(bytes) });
|
||||||
sampleHeap();
|
heap.sample();
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = (algorithm: HashAlgorithm, content: Blob) =>
|
const hash = (algorithm: HashAlgorithm, content: Blob) =>
|
||||||
@@ -99,7 +85,7 @@ export async function runReviewHarnessFileContentHashBenchmark(
|
|||||||
const elapsed = clock.now() - started;
|
const elapsed = clock.now() - started;
|
||||||
checkHash(algorithm, inputs[0].label, result);
|
checkHash(algorithm, inputs[0].label, result);
|
||||||
warmupTimes.set(algorithm, elapsed);
|
warmupTimes.set(algorithm, elapsed);
|
||||||
sampleHeap();
|
heap.sample();
|
||||||
}
|
}
|
||||||
|
|
||||||
const observations = [
|
const observations = [
|
||||||
@@ -124,7 +110,7 @@ export async function runReviewHarnessFileContentHashBenchmark(
|
|||||||
throw new Error(`The ${algorithm} measurement returned an invalid duration.`);
|
throw new Error(`The ${algorithm} measurement returned an invalid duration.`);
|
||||||
}
|
}
|
||||||
samples[algorithm].push(elapsed);
|
samples[algorithm].push(elapsed);
|
||||||
sampleHeap();
|
heap.sample();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const algorithm of ["SHA-256", "xxHash64"] as const) {
|
for (const algorithm of ["SHA-256", "xxHash64"] as const) {
|
||||||
@@ -132,16 +118,7 @@ export async function runReviewHarnessFileContentHashBenchmark(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const after = sampleHeap();
|
observations.push(heap.finish());
|
||||||
if (highest === undefined) {
|
|
||||||
observations.push("JavaScript heap: unavailable on this device.");
|
|
||||||
} else {
|
|
||||||
const mib = (bytes: number | undefined) =>
|
|
||||||
bytes === undefined ? "unavailable" : `${(bytes / 1048576).toFixed(2)} MiB`;
|
|
||||||
observations.push(
|
|
||||||
`JavaScript heap: before=${mib(before)}; highest sampled=${mib(highest)}; after=${mib(after)}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
observations.push(
|
observations.push(
|
||||||
"Heap samples are approximate, may include other Obsidian work, and are affected by garbage collection. They are neither total app RAM nor a true peak."
|
"Heap samples are approximate, may include other Obsidian work, and are affected by garbage collection. They are neither total app RAM nor a true peak."
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ describe("Review Harness file content hash measurements", () => {
|
|||||||
|
|
||||||
expect(result.status).toBe("passed");
|
expect(result.status).toBe("passed");
|
||||||
expect(result.detail).toBe("File content hash measurements completed.");
|
expect(result.detail).toBe("File content hash measurements completed.");
|
||||||
|
expect(report).toContain("JavaScript heap: unavailable on this device.");
|
||||||
expect([...f.inputs().contentBySize.keys()]).toEqual(
|
expect([...f.inputs().contentBySize.keys()]).toEqual(
|
||||||
REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS.map(({ bytes }) => bytes)
|
REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS.map(({ bytes }) => bytes)
|
||||||
);
|
);
|
||||||
@@ -107,6 +108,49 @@ describe("Review Harness file content hash measurements", () => {
|
|||||||
expect(report).not.toContain("4096n");
|
expect(report).not.toContain("4096n");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tracks the sampled maximum and reports a lower final heap sample", async () => {
|
||||||
|
const f = fixture();
|
||||||
|
let reads = 0;
|
||||||
|
const clock = {
|
||||||
|
now: f.now,
|
||||||
|
get memory() {
|
||||||
|
const sample = reads++;
|
||||||
|
const mebibytes =
|
||||||
|
sample === 0 ? 2 : sample === 1 ? 5 : sample === 31 ? 1 : 3;
|
||||||
|
return { usedJSHeapSize: mebibytes * 1048576 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await runReviewHarnessFileContentHashBenchmark(f.operations, clock, f.yieldControl);
|
||||||
|
|
||||||
|
expect(result.observations).toContain(
|
||||||
|
"JavaScript heap: before=2.00 MiB; highest sampled=5.00 MiB; after=1.00 MiB."
|
||||||
|
);
|
||||||
|
expect(result.observations.join("\n")).toContain("neither total app RAM nor a true peak");
|
||||||
|
expect(reads).toBe(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([Number.NaN, Number.POSITIVE_INFINITY, -1, "throws"] as const)(
|
||||||
|
"keeps timings usable when the heap API returns %s",
|
||||||
|
async (value) => {
|
||||||
|
const f = fixture();
|
||||||
|
const result = await runReviewHarnessFileContentHashBenchmark(
|
||||||
|
f.operations,
|
||||||
|
{
|
||||||
|
now: f.now,
|
||||||
|
get memory() {
|
||||||
|
if (value === "throws") throw new Error("Heap API unavailable");
|
||||||
|
return { usedJSHeapSize: value as number };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
f.yieldControl
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe("passed");
|
||||||
|
expect(result.observations).toContain("JavaScript heap: unavailable on this device.");
|
||||||
|
expect(result.observations.join("\n")).not.toMatch(/NaN|Infinity/u);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it("uses the same deterministic byte sequence for short and long inputs", async () => {
|
it("uses the same deterministic byte sequence for short and long inputs", async () => {
|
||||||
const shortInput = createFixedFileContentHashInput(4 * 1024);
|
const shortInput = createFixedFileContentHashInput(4 * 1024);
|
||||||
const longInput = createFixedFileContentHashInput(64 * 1024);
|
const longInput = createFixedFileContentHashInput(64 * 1024);
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
type HeapPerformance = {
|
||||||
|
readonly memory?: { readonly usedJSHeapSize: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
function readHeap(clock: HeapPerformance): number | undefined {
|
||||||
|
try {
|
||||||
|
const bytes = clock.memory?.usedJSHeapSize;
|
||||||
|
return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createReviewHarnessHeapSampler(clock: HeapPerformance): {
|
||||||
|
sample(): void;
|
||||||
|
finish(): string;
|
||||||
|
} {
|
||||||
|
const before = readHeap(clock);
|
||||||
|
let highest = before;
|
||||||
|
let after = before;
|
||||||
|
const sample = () => {
|
||||||
|
after = readHeap(clock);
|
||||||
|
if (after !== undefined) highest = Math.max(highest ?? after, after);
|
||||||
|
};
|
||||||
|
const finish = () => {
|
||||||
|
sample();
|
||||||
|
if (highest === undefined) return "JavaScript heap: unavailable on this device.";
|
||||||
|
const mib = (bytes: number | undefined) =>
|
||||||
|
bytes === undefined ? "unavailable" : `${(bytes / 1048576).toFixed(2)} MiB`;
|
||||||
|
return `JavaScript heap: before=${mib(before)}; highest sampled=${mib(highest)}; after=${mib(after)}.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { sample, finish };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReviewHarnessScenarioResult } from "./reviewHarnessTypes";
|
import type { ReviewHarnessScenarioResult } from "./reviewHarnessTypes";
|
||||||
|
import { createReviewHarnessHeapSampler } from "./reviewHarnessHeapSampling";
|
||||||
|
|
||||||
export interface IdBenchmarkOperations {
|
export interface IdBenchmarkOperations {
|
||||||
deriveKey(): Promise<unknown>;
|
deriveKey(): Promise<unknown>;
|
||||||
@@ -15,15 +16,6 @@ const SAMPLES = 3;
|
|||||||
const BATCH_SIZE = 100;
|
const BATCH_SIZE = 100;
|
||||||
const WARMUP_COUNT = 32;
|
const WARMUP_COUNT = 32;
|
||||||
|
|
||||||
function readHeap(clock: BenchmarkPerformance): number | undefined {
|
|
||||||
try {
|
|
||||||
const bytes = clock.memory?.usedJSHeapSize;
|
|
||||||
return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function summary(samples: readonly number[]): string {
|
function summary(samples: readonly number[]): string {
|
||||||
const sorted = [...samples].sort((a, b) => a - b);
|
const sorted = [...samples].sort((a, b) => a - b);
|
||||||
return `median=${sorted[1].toFixed(2)} ms; range=${sorted[0].toFixed(2)}–${sorted[2].toFixed(2)} ms`;
|
return `median=${sorted[1].toFixed(2)} ms; range=${sorted[0].toFixed(2)}–${sorted[2].toFixed(2)} ms`;
|
||||||
@@ -34,13 +26,7 @@ export async function runReviewHarnessIdBenchmark(
|
|||||||
clock: BenchmarkPerformance = performance,
|
clock: BenchmarkPerformance = performance,
|
||||||
yieldControl: () => Promise<void> = () => new Promise((resolve) => window.setTimeout(resolve, 0))
|
yieldControl: () => Promise<void> = () => new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||||
): Promise<ReviewHarnessScenarioResult> {
|
): Promise<ReviewHarnessScenarioResult> {
|
||||||
const before = readHeap(clock);
|
const heap = createReviewHarnessHeapSampler(clock);
|
||||||
let highest = before;
|
|
||||||
const sampleHeap = () => {
|
|
||||||
const value = readHeap(clock);
|
|
||||||
if (value !== undefined) highest = Math.max(highest ?? value, value);
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
const observations = [
|
const observations = [
|
||||||
"Fixed synthetic inputs; 3 samples, alternating legacy/independent order; 32 warm-up IDs per sample. Legacy Chunk algorithm: xxhash64.",
|
"Fixed synthetic inputs; 3 samples, alternating legacy/independent order; 32 warm-up IDs per sample. Legacy Chunk algorithm: xxhash64.",
|
||||||
"Compute timings include input construction and awaited ID generation. Initialisation, warm-up, and pauses between batches are excluded. This does not measure a Rebuild or remote transfer.",
|
"Compute timings include input construction and awaited ID generation. Initialisation, warm-up, and pauses between batches are excluded. This does not measure a Rebuild or remote transfer.",
|
||||||
@@ -51,7 +37,7 @@ export async function runReviewHarnessIdBenchmark(
|
|||||||
const started = clock.now();
|
const started = clock.now();
|
||||||
await operations.deriveKey();
|
await operations.deriveKey();
|
||||||
derivationSamples.push(clock.now() - started);
|
derivationSamples.push(clock.now() - started);
|
||||||
sampleHeap();
|
heap.sample();
|
||||||
}
|
}
|
||||||
observations.push(`ID key derivation at save time: ${summary(derivationSamples)} per derivation.`);
|
observations.push(`ID key derivation at save time: ${summary(derivationSamples)} per derivation.`);
|
||||||
|
|
||||||
@@ -80,7 +66,7 @@ export async function runReviewHarnessIdBenchmark(
|
|||||||
const started = clock.now();
|
const started = clock.now();
|
||||||
for (let i = batch; i < batch + BATCH_SIZE; i++) await scenario.run(i, independent);
|
for (let i = batch; i < batch + BATCH_SIZE; i++) await scenario.run(i, independent);
|
||||||
elapsed += clock.now() - started;
|
elapsed += clock.now() - started;
|
||||||
sampleHeap();
|
heap.sample();
|
||||||
}
|
}
|
||||||
samples[independent ? 1 : 0].push(elapsed);
|
samples[independent ? 1 : 0].push(elapsed);
|
||||||
}
|
}
|
||||||
@@ -92,16 +78,7 @@ export async function runReviewHarnessIdBenchmark(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const after = sampleHeap();
|
observations.push(heap.finish());
|
||||||
if (highest === undefined) {
|
|
||||||
observations.push("JavaScript heap: unavailable on this device.");
|
|
||||||
} else {
|
|
||||||
const mib = (bytes: number | undefined) =>
|
|
||||||
bytes === undefined ? "unavailable" : `${(bytes / 1048576).toFixed(2)} MiB`;
|
|
||||||
observations.push(
|
|
||||||
`JavaScript heap: before=${mib(before)}; highest sampled=${mib(highest)}; after=${mib(after)}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
observations.push(
|
observations.push(
|
||||||
"Heap samples are approximate, may include other Obsidian work, and are affected by garbage collection. They are neither total app RAM nor a true peak."
|
"Heap samples are approximate, may include other Obsidian work, and are affected by garbage collection. They are neither total app RAM nor a true peak."
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ describe("Review Harness ID measurements", () => {
|
|||||||
"JavaScript heap: before=2.00 MiB; highest sampled=5.00 MiB; after=1.00 MiB."
|
"JavaScript heap: before=2.00 MiB; highest sampled=5.00 MiB; after=1.00 MiB."
|
||||||
);
|
);
|
||||||
expect(result.observations.join("\n")).toContain("neither total app RAM nor a true peak");
|
expect(result.observations.join("\n")).toContain("neither total app RAM nor a true peak");
|
||||||
|
expect(reads).toBe(245);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([Number.NaN, Number.POSITIVE_INFINITY, -1, "throws"])(
|
it.each([Number.NaN, Number.POSITIVE_INFINITY, -1, "throws"])(
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createOwnedResource } from "octagonal-wheels/resource";
|
||||||
|
import { LOG_LEVEL_URGENT } from "octagonal-wheels/common/logger";
|
||||||
|
import { bindableFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||||
|
|
||||||
|
const unusedStartupDependencies: readonly [string, readonly string[]][] = [
|
||||||
|
["@/serviceFeatures/useP2PSettingsPreparation", ["useP2PSettingsPreparation"]],
|
||||||
|
["@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", ["setGetLanguage"]],
|
||||||
|
["./features/LiveSyncCommands.ts", ["LiveSyncCommands"]],
|
||||||
|
["./features/HiddenFileSync/CmdHiddenFileSync.ts", ["HiddenFileSync"]],
|
||||||
|
["./features/ConfigSync/CmdConfigSync.ts", ["ConfigSync"]],
|
||||||
|
["./modules/features/ModuleLog.ts", ["ModuleLog"]],
|
||||||
|
["./modules/essentialObsidian/ModuleObsidianEvents.ts", ["ModuleObsidianEvents"]],
|
||||||
|
["./modules/features/ModuleObsidianSettingTab.ts", ["ModuleObsidianSettingDialogue"]],
|
||||||
|
["./modules/features/ModuleObsidianDocumentHistory.ts", ["ModuleObsidianDocumentHistory"]],
|
||||||
|
["./modules/features/ModuleGlobalHistory.ts", ["ModuleObsidianGlobalHistory"]],
|
||||||
|
["./features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts", ["LocalDatabaseMaintenance"]],
|
||||||
|
["./modules/services/ObsidianServiceHub.ts", ["ObsidianServiceHub"]],
|
||||||
|
["@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder", ["ServiceRebuilder"]],
|
||||||
|
["@/serviceModules/DatabaseFileAccess.ts", ["ServiceDatabaseFileAccess"]],
|
||||||
|
["@/serviceModules/ServiceFileAccessImpl.ts", ["ServiceFileAccessObsidian"]],
|
||||||
|
["@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager", ["StorageAccessManager"]],
|
||||||
|
["./serviceModules/FileHandler.ts", ["ServiceFileHandler"]],
|
||||||
|
["./serviceModules/FileAccessObsidian.ts", ["FileAccessObsidian"]],
|
||||||
|
["./managers/StorageEventManagerObsidian.ts", ["StorageEventManagerObsidian"]],
|
||||||
|
["@vrtmrz/livesync-commonlib/compat/mock_and_interop/wrapper", ["setNoticeClass"]],
|
||||||
|
["./LiveSyncBaseCore.ts", ["LiveSyncBaseCore"]],
|
||||||
|
["./modules/features/ModuleObsidianSettingAsMarkdown.ts", ["ModuleObsidianSettingsAsMarkdown"]],
|
||||||
|
["./modules/features/SetupManager.ts", ["SetupManager"]],
|
||||||
|
["./serviceFeatures/onLayoutReady/enablei18n.ts", ["enableI18nFeature"]],
|
||||||
|
["@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner", ["useOfflineScanner"]],
|
||||||
|
["@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig", ["useRemoteConfiguration"]],
|
||||||
|
["@vrtmrz/livesync-commonlib/compat/serviceFeatures/checkRemoteSize", ["useCheckRemoteSize"]],
|
||||||
|
["./serviceFeatures/redFlag.ts", ["useRedFlagFeatures"]],
|
||||||
|
["./serviceFeatures/setupObsidian/setupProtocol.ts", ["useSetupProtocolFeature"]],
|
||||||
|
["@/serviceFeatures/setupObsidian/qrCode", ["useSetupQRCodeFeature"]],
|
||||||
|
["@/serviceFeatures/setupObsidian/setupUri", ["useSetupURIFeature"]],
|
||||||
|
[
|
||||||
|
"./serviceFeatures/setupObsidian/setupManagerHandlers.ts",
|
||||||
|
["showOnboardingInvitation", "useSetupManagerHandlersFeature"],
|
||||||
|
],
|
||||||
|
["@vrtmrz/livesync-commonlib/p2p", ["useP2PReplicatorCommands", "useP2PReplicatorFeature"]],
|
||||||
|
["./serviceFeatures/useP2PReplicatorUI.ts", ["useP2PReplicatorUI"]],
|
||||||
|
["./serviceFeatures/useReviewHarness.ts", ["useReviewHarness"]],
|
||||||
|
["./features/P2PSync/P2PReplicator/P2PReplicationUI.ts", ["createOpenReplicationUI", "createOpenRebuildUI"]],
|
||||||
|
["./serviceFeatures/compatibilityReview.ts", ["useCompatibilityReview"]],
|
||||||
|
["./serviceFeatures/compatibilityReviewObsidian.ts", ["createObsidianCompatibilityReviewUi"]],
|
||||||
|
["./serviceModules/FileReflectionProvenance.ts", ["createFileReflectionProvenance"]],
|
||||||
|
["./serviceFeatures/interactiveConflictResolution", ["useInteractiveConflictResolutionFeature"]],
|
||||||
|
["./modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", ["ConflictResolveModal"]],
|
||||||
|
["./serviceFeatures/obsidianReplicationRibbon.ts", ["useObsidianReplicationRibbonFeature"]],
|
||||||
|
["./serviceFeatures/syncGuard", ["useSyncGuardFeature"]],
|
||||||
|
["./serviceFeatures/syncGuard/SyncGuardOverlay", ["SyncGuardOverlay"]],
|
||||||
|
["./serviceFeatures/startupLifecycle", ["useStartupLifecycleFeature"]],
|
||||||
|
["./serviceFeatures/useJoinedDevices", ["useJoinedDevicesFeature"]],
|
||||||
|
["./serviceFeatures/useDatabaseDiagnostics", ["useDatabaseDiagnosticsFeature"]],
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock("./deps", () => ({ Plugin: class {}, Notice: class {}, getLanguage: () => "en" }));
|
||||||
|
for (const [specifier, names] of unusedStartupDependencies) {
|
||||||
|
vi.doMock(specifier, () => Object.fromEntries(names.map((name) => [name, vi.fn()])));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Obsidian plug-in unload logging", () => {
|
||||||
|
it.each(["complete", "fail"] as const)(
|
||||||
|
"retains its log connection through shutdown and retires it afterwards: %s",
|
||||||
|
async (outcome) => {
|
||||||
|
const { default: LiveSyncPlugin } = await import("./main.ts");
|
||||||
|
const events: string[] = [];
|
||||||
|
const sink = vi.fn((message: unknown) => events.push(String(message)));
|
||||||
|
const addLog = bindableFunction<(message: unknown, level?: number, key?: string) => void>("unload log");
|
||||||
|
const unregister = addLog.setHandler(sink);
|
||||||
|
const dispose = vi.fn(() => {
|
||||||
|
unregister();
|
||||||
|
events.push("disposed");
|
||||||
|
});
|
||||||
|
const logResource = createOwnedResource(sink, dispose);
|
||||||
|
let finish!: () => void;
|
||||||
|
let fail!: (reason: Error) => void;
|
||||||
|
const shutdown = new Promise<void>((resolve, reject) => {
|
||||||
|
finish = resolve;
|
||||||
|
fail = reject;
|
||||||
|
});
|
||||||
|
const onUnload = vi.fn(() => shutdown);
|
||||||
|
const core = {
|
||||||
|
getModule: vi.fn(() => ({ logResource })),
|
||||||
|
services: { API: { addLog }, control: { onUnload } },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(LiveSyncPlugin.prototype.onunload.call({ core } as never)).toBeUndefined();
|
||||||
|
expect(onUnload).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispose).not.toHaveBeenCalled();
|
||||||
|
addLog("during shutdown");
|
||||||
|
|
||||||
|
const failure = new Error("database close failed");
|
||||||
|
if (outcome === "fail") {
|
||||||
|
fail(failure);
|
||||||
|
} else {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
await vi.waitFor(() => expect(dispose).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
if (outcome === "fail") {
|
||||||
|
const message = "Failed to unload Self-hosted LiveSync: " + String(failure);
|
||||||
|
expect(sink).toHaveBeenCalledWith(message, LOG_LEVEL_URGENT, "shutdown");
|
||||||
|
expect(events).toEqual(["during shutdown", message, "disposed"]);
|
||||||
|
} else {
|
||||||
|
expect(events).toEqual(["during shutdown", "disposed"]);
|
||||||
|
}
|
||||||
|
expect(() => addLog("after shutdown")).toThrow("not assigned");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
+10
-7
@@ -9,6 +9,7 @@ import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts";
|
|||||||
// import { ModuleDev } from "./modules/extras/ModuleDev.ts";
|
// import { ModuleDev } from "./modules/extras/ModuleDev.ts";
|
||||||
|
|
||||||
import { ModuleLog } from "./modules/features/ModuleLog.ts";
|
import { ModuleLog } from "./modules/features/ModuleLog.ts";
|
||||||
|
import { withOwnedResource } from "octagonal-wheels/resource";
|
||||||
import { ModuleObsidianEvents } from "./modules/essentialObsidian/ModuleObsidianEvents.ts";
|
import { ModuleObsidianEvents } from "./modules/essentialObsidian/ModuleObsidianEvents.ts";
|
||||||
import { ModuleObsidianSettingDialogue } from "./modules/features/ModuleObsidianSettingTab.ts";
|
import { ModuleObsidianSettingDialogue } from "./modules/features/ModuleObsidianSettingTab.ts";
|
||||||
import { ModuleObsidianDocumentHistory } from "./modules/features/ModuleObsidianDocumentHistory.ts";
|
import { ModuleObsidianDocumentHistory } from "./modules/features/ModuleObsidianDocumentHistory.ts";
|
||||||
@@ -237,12 +238,14 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
|||||||
void this._startUp();
|
void this._startUp();
|
||||||
}
|
}
|
||||||
override onunload(): void {
|
override onunload(): void {
|
||||||
void this.core.services.control.onUnload().catch((error) => {
|
void withOwnedResource(this.core.getModule(ModuleLog).logResource, () =>
|
||||||
this.core.services.API.addLog(
|
this.core.services.control.onUnload().catch((error) => {
|
||||||
"Failed to unload Self-hosted LiveSync: " + String(error),
|
this.core.services.API.addLog(
|
||||||
LOG_LEVEL_URGENT,
|
"Failed to unload Self-hosted LiveSync: " + String(error),
|
||||||
"shutdown"
|
LOG_LEVEL_URGENT,
|
||||||
);
|
"shutdown"
|
||||||
});
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/deps.ts", () => ({
|
||||||
|
Modal: class Modal {},
|
||||||
|
TFile: class TFile {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/main.ts", () => ({
|
||||||
|
default: class ObsidianLiveSyncPlugin {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/common/utils.ts", () => ({
|
||||||
|
getPathFromTFile: (file: { path: string }) => file.path,
|
||||||
|
isValidPath: vi.fn(() => true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("octagonal-wheels/concurrency/lock", () => ({
|
||||||
|
scheduleOnceIfDuplicated: vi.fn((_key: string, callback: () => unknown) => callback()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { scheduleOnceIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
||||||
|
import { DocumentHistoryModal } from "./DocumentHistoryModal.ts";
|
||||||
|
|
||||||
|
type NavigationTarget = {
|
||||||
|
classList: { add: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> };
|
||||||
|
scrollIntoView: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createDiffView(count: number) {
|
||||||
|
const focused: { current: NavigationTarget | undefined } = { current: undefined };
|
||||||
|
const createTargets = (targetCount: number) =>
|
||||||
|
Array.from({ length: targetCount }, () => {
|
||||||
|
const target: NavigationTarget = {
|
||||||
|
classList: {
|
||||||
|
add: vi.fn(() => {
|
||||||
|
focused.current = target;
|
||||||
|
}),
|
||||||
|
remove: vi.fn(() => {
|
||||||
|
if (focused.current === target) focused.current = undefined;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
scrollIntoView: vi.fn(),
|
||||||
|
};
|
||||||
|
return target;
|
||||||
|
});
|
||||||
|
let targets = createTargets(count);
|
||||||
|
const contentView = {
|
||||||
|
querySelectorAll: vi.fn(() => targets),
|
||||||
|
querySelector: vi.fn(() => (targets.includes(focused.current as NavigationTarget) ? focused.current : null)),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
contentView,
|
||||||
|
targets,
|
||||||
|
replaceTargets(targetCount: number) {
|
||||||
|
focused.current = undefined;
|
||||||
|
targets = createTargets(targetCount);
|
||||||
|
return targets;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHistoryModal() {
|
||||||
|
return Object.create(DocumentHistoryModal.prototype) as DocumentHistoryModal;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DocumentHistoryModal cyclic navigation", () => {
|
||||||
|
it("commits the selected index before scrolling, including when scrolling fails", () => {
|
||||||
|
const modal = createHistoryModal();
|
||||||
|
const view = createDiffView(2);
|
||||||
|
const indicator = { setText: vi.fn() };
|
||||||
|
const failure = new Error("fixture scroll failed");
|
||||||
|
Object.assign(modal, { contentView: view.contentView, currentDiffIndex: -1, diffNavIndicator: indicator });
|
||||||
|
view.targets[0].scrollIntoView.mockImplementation(() => {
|
||||||
|
expect(modal.currentDiffIndex).toBe(0);
|
||||||
|
throw failure;
|
||||||
|
});
|
||||||
|
expect(() => modal.navigateDiff("next")).toThrow(failure);
|
||||||
|
expect(modal.currentDiffIndex).toBe(0);
|
||||||
|
expect(indicator.setText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts at the first diff for next and at the last diff for previous", () => {
|
||||||
|
const nextModal = createHistoryModal();
|
||||||
|
const nextView = createDiffView(3);
|
||||||
|
const nextIndicator = { setText: vi.fn() };
|
||||||
|
Object.assign(nextModal, {
|
||||||
|
contentView: nextView.contentView,
|
||||||
|
currentDiffIndex: -1,
|
||||||
|
diffNavIndicator: nextIndicator,
|
||||||
|
});
|
||||||
|
|
||||||
|
nextModal.navigateDiff("next");
|
||||||
|
|
||||||
|
expect(nextModal.currentDiffIndex).toBe(0);
|
||||||
|
expect(nextView.targets[0].classList.add).toHaveBeenCalledWith("diff-focused");
|
||||||
|
expect(nextView.targets[0].scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
|
||||||
|
expect(nextIndicator.setText).toHaveBeenCalledWith("1/3");
|
||||||
|
|
||||||
|
const previousModal = createHistoryModal();
|
||||||
|
const previousView = createDiffView(3);
|
||||||
|
const previousIndicator = { setText: vi.fn() };
|
||||||
|
Object.assign(previousModal, {
|
||||||
|
contentView: previousView.contentView,
|
||||||
|
currentDiffIndex: -1,
|
||||||
|
diffNavIndicator: previousIndicator,
|
||||||
|
});
|
||||||
|
|
||||||
|
previousModal.navigateDiff("prev");
|
||||||
|
|
||||||
|
expect(previousModal.currentDiffIndex).toBe(2);
|
||||||
|
expect(previousView.targets[2].classList.add).toHaveBeenCalledWith("diff-focused");
|
||||||
|
expect(previousView.targets[2].scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
|
||||||
|
expect(previousIndicator.setText).toHaveBeenCalledWith("3/3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps in both directions while moving focus, scrolling, and updating the indicator", () => {
|
||||||
|
const modal = createHistoryModal();
|
||||||
|
const { contentView, targets } = createDiffView(3);
|
||||||
|
const indicator = { setText: vi.fn() };
|
||||||
|
Object.assign(modal, { contentView, currentDiffIndex: -1, diffNavIndicator: indicator });
|
||||||
|
|
||||||
|
modal.navigateDiff("next");
|
||||||
|
modal.navigateDiff("next");
|
||||||
|
modal.navigateDiff("next");
|
||||||
|
modal.navigateDiff("next");
|
||||||
|
modal.navigateDiff("prev");
|
||||||
|
|
||||||
|
expect(modal.currentDiffIndex).toBe(2);
|
||||||
|
expect(targets.map((target) => target.classList.add.mock.calls.length)).toEqual([2, 1, 2]);
|
||||||
|
expect(targets.map((target) => target.classList.remove.mock.calls.length)).toEqual([2, 1, 1]);
|
||||||
|
expect(targets.map((target) => target.scrollIntoView.mock.calls.length)).toEqual([2, 1, 2]);
|
||||||
|
expect(indicator.setText.mock.calls.map(([text]) => text)).toEqual(["1/3", "2/3", "3/3", "1/3", "3/3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the current navigation index and content view untouched when no diff exists", () => {
|
||||||
|
const modal = createHistoryModal();
|
||||||
|
const { contentView } = createDiffView(0);
|
||||||
|
const indicator = { setText: vi.fn() };
|
||||||
|
Object.assign(modal, { contentView, currentDiffIndex: 1, diffNavIndicator: indicator });
|
||||||
|
|
||||||
|
modal.navigateDiff("next");
|
||||||
|
|
||||||
|
expect(modal.currentDiffIndex).toBe(1);
|
||||||
|
expect(contentView.querySelector).not.toHaveBeenCalled();
|
||||||
|
expect(indicator.setText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("looks up diff nodes again after the content view is replaced", () => {
|
||||||
|
const modal = createHistoryModal();
|
||||||
|
const view = createDiffView(2);
|
||||||
|
const indicator = { setText: vi.fn() };
|
||||||
|
Object.assign(modal, { contentView: view.contentView, currentDiffIndex: -1, diffNavIndicator: indicator });
|
||||||
|
|
||||||
|
modal.navigateDiff("next");
|
||||||
|
const replacementTargets = view.replaceTargets(3);
|
||||||
|
modal.navigateDiff("next");
|
||||||
|
|
||||||
|
expect(modal.currentDiffIndex).toBe(1);
|
||||||
|
expect(view.contentView.querySelectorAll).toHaveBeenCalledTimes(2);
|
||||||
|
expect(view.contentView.querySelector).toHaveBeenCalledTimes(2);
|
||||||
|
expect(replacementTargets[1].classList.add).toHaveBeenCalledWith("diff-focused");
|
||||||
|
expect(replacementTargets[1].scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
|
||||||
|
expect(indicator.setText.mock.calls.map(([text]) => text)).toEqual(["1/2", "2/3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps navigation indices and focus independent between modal instances", () => {
|
||||||
|
const firstModal = createHistoryModal();
|
||||||
|
const secondModal = createHistoryModal();
|
||||||
|
const firstView = createDiffView(2);
|
||||||
|
const secondView = createDiffView(2);
|
||||||
|
Object.assign(firstModal, {
|
||||||
|
contentView: firstView.contentView,
|
||||||
|
currentDiffIndex: -1,
|
||||||
|
diffNavIndicator: { setText: vi.fn() },
|
||||||
|
});
|
||||||
|
Object.assign(secondModal, {
|
||||||
|
contentView: secondView.contentView,
|
||||||
|
currentDiffIndex: -1,
|
||||||
|
diffNavIndicator: { setText: vi.fn() },
|
||||||
|
});
|
||||||
|
|
||||||
|
firstModal.navigateDiff("next");
|
||||||
|
secondModal.navigateDiff("next");
|
||||||
|
firstModal.navigateDiff("next");
|
||||||
|
|
||||||
|
expect(firstModal.currentDiffIndex).toBe(1);
|
||||||
|
expect(secondModal.currentDiffIndex).toBe(0);
|
||||||
|
expect(firstView.targets[0].classList.remove).toHaveBeenCalledWith("diff-focused");
|
||||||
|
expect(secondView.targets[0].classList.remove).not.toHaveBeenCalled();
|
||||||
|
expect(secondView.targets[0].classList.add).toHaveBeenCalledWith("diff-focused");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets the indicator and visibility according to showDiff", () => {
|
||||||
|
const modal = createHistoryModal();
|
||||||
|
const view = createDiffView(2);
|
||||||
|
const indicator = { setText: vi.fn() };
|
||||||
|
const diffNavContainer = { setCssStyles: vi.fn() };
|
||||||
|
const diffOnlyLabel = { setCssStyles: vi.fn() };
|
||||||
|
Object.assign(modal, {
|
||||||
|
contentView: view.contentView,
|
||||||
|
currentDiffIndex: 1,
|
||||||
|
diffNavIndicator: indicator,
|
||||||
|
diffNavContainer,
|
||||||
|
diffOnlyLabel,
|
||||||
|
showDiff: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.resetDiffNavigation();
|
||||||
|
|
||||||
|
expect(modal.currentDiffIndex).toBe(-1);
|
||||||
|
expect(view.contentView.querySelectorAll).toHaveBeenCalledWith(".history-added, .history-deleted");
|
||||||
|
expect(indicator.setText).toHaveBeenLastCalledWith("0/2");
|
||||||
|
expect(diffNavContainer.setCssStyles).toHaveBeenLastCalledWith({ display: "flex" });
|
||||||
|
expect(diffOnlyLabel.setCssStyles).toHaveBeenLastCalledWith({ display: "inline-block" });
|
||||||
|
|
||||||
|
view.replaceTargets(0);
|
||||||
|
modal.resetDiffNavigation();
|
||||||
|
expect(indicator.setText).toHaveBeenLastCalledWith("\u2014");
|
||||||
|
|
||||||
|
Object.assign(modal, { showDiff: false });
|
||||||
|
modal.resetDiffNavigation();
|
||||||
|
expect(view.contentView.querySelectorAll).toHaveBeenCalledTimes(2);
|
||||||
|
expect(indicator.setText).toHaveBeenLastCalledWith("\u2014");
|
||||||
|
expect(diffNavContainer.setCssStyles).toHaveBeenLastCalledWith({ display: "none" });
|
||||||
|
expect(diffOnlyLabel.setCssStyles).toHaveBeenLastCalledWith({ display: "none" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates visibility without an indicator", () => {
|
||||||
|
const modal = createHistoryModal();
|
||||||
|
const { contentView } = createDiffView(2);
|
||||||
|
const diffNavContainer = { setCssStyles: vi.fn() };
|
||||||
|
Object.assign(modal, { contentView, currentDiffIndex: 1, diffNavContainer, showDiff: true });
|
||||||
|
|
||||||
|
expect(() => modal.resetDiffNavigation()).not.toThrow();
|
||||||
|
|
||||||
|
expect(modal.currentDiffIndex).toBe(-1);
|
||||||
|
expect(contentView.querySelectorAll).not.toHaveBeenCalled();
|
||||||
|
expect(diffNavContainer.setCssStyles).toHaveBeenCalledWith({ display: "flex" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps selected search results to the revision slider and loads each wrapped selection", () => {
|
||||||
|
const modal = createHistoryModal();
|
||||||
|
const scheduleLoad = vi.mocked(scheduleOnceIfDuplicated);
|
||||||
|
scheduleLoad.mockClear();
|
||||||
|
const indicator = { setText: vi.fn() };
|
||||||
|
Object.assign(modal, {
|
||||||
|
searchResults: [
|
||||||
|
{ rev: "newest", index: 0, matchType: "Content" },
|
||||||
|
{ rev: "middle", index: 2, matchType: "Diff" },
|
||||||
|
{ rev: "oldest", index: 4, matchType: "Content" },
|
||||||
|
],
|
||||||
|
currentSearchIndex: -1,
|
||||||
|
revs_info: Array.from({ length: 5 }, (_, index) => ({ rev: `rev-${index}` })),
|
||||||
|
range: { value: "" },
|
||||||
|
loadRevs: vi.fn(),
|
||||||
|
searchResultIndicator: indicator,
|
||||||
|
searchPrevBtn: { disabled: true },
|
||||||
|
searchNextBtn: { disabled: true },
|
||||||
|
searchKeyword: "needle",
|
||||||
|
showDiff: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.navigateSearch("next");
|
||||||
|
expect(modal.currentSearchIndex).toBe(0);
|
||||||
|
expect(modal.range.value).toBe("4");
|
||||||
|
expect(modal.loadRevs).toHaveBeenCalledTimes(1);
|
||||||
|
expect(scheduleLoad).toHaveBeenCalledWith("loadRevs", expect.any(Function));
|
||||||
|
expect(indicator.setText).toHaveBeenLastCalledWith("1/3 matches");
|
||||||
|
|
||||||
|
modal.navigateSearch("next");
|
||||||
|
expect(modal.currentSearchIndex).toBe(1);
|
||||||
|
expect(modal.range.value).toBe("2");
|
||||||
|
expect(modal.loadRevs).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
modal.navigateSearch("next");
|
||||||
|
expect(modal.currentSearchIndex).toBe(2);
|
||||||
|
expect(modal.range.value).toBe("0");
|
||||||
|
|
||||||
|
modal.navigateSearch("next");
|
||||||
|
expect(modal.currentSearchIndex).toBe(0);
|
||||||
|
expect(modal.range.value).toBe("4");
|
||||||
|
|
||||||
|
modal.navigateSearch("prev");
|
||||||
|
expect(modal.currentSearchIndex).toBe(2);
|
||||||
|
expect(modal.range.value).toBe("0");
|
||||||
|
expect(indicator.setText.mock.calls.map(([text]) => text)).toEqual([
|
||||||
|
"1/3 matches",
|
||||||
|
"2/3 matches",
|
||||||
|
"3/3 matches",
|
||||||
|
"1/3 matches",
|
||||||
|
"3/3 matches",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves search state and controls untouched when there are no results", () => {
|
||||||
|
const modal = createHistoryModal();
|
||||||
|
const indicator = { setText: vi.fn() };
|
||||||
|
const searchPrevBtn = { disabled: true };
|
||||||
|
const searchNextBtn = { disabled: true };
|
||||||
|
Object.assign(modal, {
|
||||||
|
searchResults: [],
|
||||||
|
currentSearchIndex: -1,
|
||||||
|
range: { value: "unchanged" },
|
||||||
|
loadRevs: vi.fn(),
|
||||||
|
searchResultIndicator: indicator,
|
||||||
|
searchPrevBtn,
|
||||||
|
searchNextBtn,
|
||||||
|
searchKeyword: "needle",
|
||||||
|
showDiff: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.navigateSearch("next");
|
||||||
|
|
||||||
|
expect(modal.currentSearchIndex).toBe(-1);
|
||||||
|
expect(modal.range.value).toBe("unchanged");
|
||||||
|
expect(modal.loadRevs).not.toHaveBeenCalled();
|
||||||
|
expect(indicator.setText).not.toHaveBeenCalled();
|
||||||
|
expect(searchPrevBtn.disabled).toBe(true);
|
||||||
|
expect(searchNextBtn.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,8 @@ import { isErrorOfMissingDoc } from "@vrtmrz/livesync-commonlib/compat/pouchdb/u
|
|||||||
import { fireAndForget, getDocData } 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 { isPlainText, stripPrefix } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||||
import { scheduleOnceIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
import { scheduleOnceIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
||||||
|
import { stepCyclicIndex } from "octagonal-wheels/collection";
|
||||||
|
import { navigateDiffNodes } from "@/common/diffNavigation.ts";
|
||||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||||
import {
|
import {
|
||||||
@@ -375,26 +377,7 @@ export class DocumentHistoryModal extends Modal {
|
|||||||
* Only effective when diff highlighting is enabled.
|
* Only effective when diff highlighting is enabled.
|
||||||
*/
|
*/
|
||||||
navigateDiff(direction: "prev" | "next") {
|
navigateDiff(direction: "prev" | "next") {
|
||||||
const diffElements = this.contentView.querySelectorAll(".history-added, .history-deleted");
|
navigateDiffNodes(this.contentView, ".history-added, .history-deleted", this, direction);
|
||||||
if (diffElements.length === 0) return;
|
|
||||||
|
|
||||||
// Remove previous focus highlight
|
|
||||||
const prevFocused = this.contentView.querySelector(".diff-focused");
|
|
||||||
if (prevFocused) {
|
|
||||||
prevFocused.classList.remove("diff-focused");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (direction === "next") {
|
|
||||||
this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length;
|
|
||||||
} else {
|
|
||||||
this.currentDiffIndex = this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = diffElements[this.currentDiffIndex];
|
|
||||||
target.classList.add("diff-focused");
|
|
||||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
|
|
||||||
this.diffNavIndicator.setText(`${this.currentDiffIndex + 1}/${diffElements.length}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -519,12 +502,7 @@ export class DocumentHistoryModal extends Modal {
|
|||||||
navigateSearch(direction: "prev" | "next") {
|
navigateSearch(direction: "prev" | "next") {
|
||||||
if (this.searchResults.length === 0) return;
|
if (this.searchResults.length === 0) return;
|
||||||
|
|
||||||
if (direction === "next") {
|
this.currentSearchIndex = stepCyclicIndex(this.currentSearchIndex, this.searchResults.length, direction);
|
||||||
this.currentSearchIndex = (this.currentSearchIndex + 1) % this.searchResults.length;
|
|
||||||
} else {
|
|
||||||
this.currentSearchIndex =
|
|
||||||
this.currentSearchIndex <= 0 ? this.searchResults.length - 1 : this.currentSearchIndex - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = this.searchResults[this.currentSearchIndex];
|
const match = this.searchResults[this.currentSearchIndex];
|
||||||
this.range.value = `${this.revs_info.length - 1 - match.index}`;
|
this.range.value = `${this.revs_info.length - 1 - match.index}`;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type FilePathWithPrefix,
|
type FilePathWithPrefix,
|
||||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED, eventHub } from "@/common/events.ts";
|
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED, eventHub } from "@/common/events.ts";
|
||||||
|
import { navigateDiffNodes } from "@/common/diffNavigation.ts";
|
||||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||||
import { POSTPONED, type MergeDialogResult } from "@/serviceFeatures/interactiveConflictResolution/types";
|
import { POSTPONED, type MergeDialogResult } from "@/serviceFeatures/interactiveConflictResolution/types";
|
||||||
|
|
||||||
@@ -83,24 +84,7 @@ export class ConflictResolveModal extends Modal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
navigateDiff(direction: "prev" | "next") {
|
navigateDiff(direction: "prev" | "next") {
|
||||||
const diffElements = this.diffView.querySelectorAll(".added, .deleted");
|
navigateDiffNodes(this.diffView, ".added, .deleted", this, direction);
|
||||||
if (diffElements.length === 0) return;
|
|
||||||
|
|
||||||
const prevFocused = this.diffView.querySelector(".diff-focused");
|
|
||||||
if (prevFocused) {
|
|
||||||
prevFocused.classList.remove("diff-focused");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (direction === "next") {
|
|
||||||
this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length;
|
|
||||||
} else {
|
|
||||||
this.currentDiffIndex = this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = diffElements[this.currentDiffIndex];
|
|
||||||
target.classList.add("diff-focused");
|
|
||||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
this.diffNavIndicator.setText(`${this.currentDiffIndex + 1}/${diffElements.length}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resetDiffNavigation() {
|
resetDiffNavigation() {
|
||||||
|
|||||||
+184
-5
@@ -106,11 +106,7 @@ describe("ConflictResolveModal result lifecycle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("closes and completes its result when the plug-in unloads", async () => {
|
it("closes and completes its result when the plug-in unloads", async () => {
|
||||||
const modal = new ConflictResolveModal(
|
const modal = new ConflictResolveModal({} as never, "open-during-unload.md" as FilePathWithPrefix, conflict);
|
||||||
{} as never,
|
|
||||||
"open-during-unload.md" as FilePathWithPrefix,
|
|
||||||
conflict
|
|
||||||
);
|
|
||||||
modal.onOpen();
|
modal.onOpen();
|
||||||
|
|
||||||
eventHub.emitEvent(EVENT_PLUGIN_UNLOADED);
|
eventHub.emitEvent(EVENT_PLUGIN_UNLOADED);
|
||||||
@@ -165,3 +161,186 @@ describe("ConflictResolveModal result lifecycle", () => {
|
|||||||
expect(previousState).toBe("still-open");
|
expect(previousState).toBe("still-open");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type NavigationTarget = {
|
||||||
|
classList: { add: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> };
|
||||||
|
scrollIntoView: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createDiffView(count: number) {
|
||||||
|
const focused: { current: NavigationTarget | undefined } = { current: undefined };
|
||||||
|
const createTargets = (targetCount: number) =>
|
||||||
|
Array.from({ length: targetCount }, () => {
|
||||||
|
const target: NavigationTarget = {
|
||||||
|
classList: {
|
||||||
|
add: vi.fn(() => {
|
||||||
|
focused.current = target;
|
||||||
|
}),
|
||||||
|
remove: vi.fn(() => {
|
||||||
|
if (focused.current === target) focused.current = undefined;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
scrollIntoView: vi.fn(),
|
||||||
|
};
|
||||||
|
return target;
|
||||||
|
});
|
||||||
|
let targets = createTargets(count);
|
||||||
|
const diffView = {
|
||||||
|
querySelectorAll: vi.fn(() => targets),
|
||||||
|
querySelector: vi.fn(() => (targets.includes(focused.current as NavigationTarget) ? focused.current : null)),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
diffView,
|
||||||
|
targets,
|
||||||
|
replaceTargets(targetCount: number) {
|
||||||
|
focused.current = undefined;
|
||||||
|
targets = createTargets(targetCount);
|
||||||
|
return targets;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ConflictResolveModal cyclic navigation", () => {
|
||||||
|
it("commits the selected index before scrolling, including when scrolling fails", () => {
|
||||||
|
const modal = Object.create(ConflictResolveModal.prototype) as ConflictResolveModal;
|
||||||
|
const view = createDiffView(2);
|
||||||
|
const indicator = { setText: vi.fn() };
|
||||||
|
const failure = new Error("fixture scroll failed");
|
||||||
|
Object.assign(modal, { diffView: view.diffView, currentDiffIndex: -1, diffNavIndicator: indicator });
|
||||||
|
view.targets[0].scrollIntoView.mockImplementation(() => {
|
||||||
|
expect(modal.currentDiffIndex).toBe(0);
|
||||||
|
throw failure;
|
||||||
|
});
|
||||||
|
expect(() => modal.navigateDiff("next")).toThrow(failure);
|
||||||
|
expect(modal.currentDiffIndex).toBe(0);
|
||||||
|
expect(indicator.setText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts at the first diff for next and at the last diff for previous", () => {
|
||||||
|
const nextModal = new ConflictResolveModal({} as never, "next.md" as FilePathWithPrefix, conflict);
|
||||||
|
const nextView = createDiffView(3);
|
||||||
|
const nextIndicator = { setText: vi.fn() };
|
||||||
|
Object.assign(nextModal, {
|
||||||
|
diffView: nextView.diffView,
|
||||||
|
currentDiffIndex: -1,
|
||||||
|
diffNavIndicator: nextIndicator,
|
||||||
|
});
|
||||||
|
|
||||||
|
nextModal.navigateDiff("next");
|
||||||
|
|
||||||
|
expect(nextModal.currentDiffIndex).toBe(0);
|
||||||
|
expect(nextView.targets[0].classList.add).toHaveBeenCalledWith("diff-focused");
|
||||||
|
expect(nextView.targets[0].scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
|
||||||
|
expect(nextIndicator.setText).toHaveBeenCalledWith("1/3");
|
||||||
|
|
||||||
|
const previousModal = new ConflictResolveModal({} as never, "previous.md" as FilePathWithPrefix, conflict);
|
||||||
|
const previousView = createDiffView(3);
|
||||||
|
const previousIndicator = { setText: vi.fn() };
|
||||||
|
Object.assign(previousModal, {
|
||||||
|
diffView: previousView.diffView,
|
||||||
|
currentDiffIndex: -1,
|
||||||
|
diffNavIndicator: previousIndicator,
|
||||||
|
});
|
||||||
|
|
||||||
|
previousModal.navigateDiff("prev");
|
||||||
|
|
||||||
|
expect(previousModal.currentDiffIndex).toBe(2);
|
||||||
|
expect(previousView.targets[2].classList.add).toHaveBeenCalledWith("diff-focused");
|
||||||
|
expect(previousView.targets[2].scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
|
||||||
|
expect(previousIndicator.setText).toHaveBeenCalledWith("3/3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps in both directions while moving focus, scrolling, and updating the indicator", () => {
|
||||||
|
const modal = new ConflictResolveModal({} as never, "wrap.md" as FilePathWithPrefix, conflict);
|
||||||
|
const { diffView, targets } = createDiffView(3);
|
||||||
|
const indicator = { setText: vi.fn() };
|
||||||
|
Object.assign(modal, { diffView, currentDiffIndex: -1, diffNavIndicator: indicator });
|
||||||
|
|
||||||
|
modal.navigateDiff("next");
|
||||||
|
modal.navigateDiff("next");
|
||||||
|
modal.navigateDiff("next");
|
||||||
|
modal.navigateDiff("next");
|
||||||
|
modal.navigateDiff("prev");
|
||||||
|
|
||||||
|
expect(modal.currentDiffIndex).toBe(2);
|
||||||
|
expect(targets.map((target) => target.classList.add.mock.calls.length)).toEqual([2, 1, 2]);
|
||||||
|
expect(targets.map((target) => target.classList.remove.mock.calls.length)).toEqual([2, 1, 1]);
|
||||||
|
expect(targets.map((target) => target.scrollIntoView.mock.calls.length)).toEqual([2, 1, 2]);
|
||||||
|
expect(indicator.setText.mock.calls.map(([text]) => text)).toEqual(["1/3", "2/3", "3/3", "1/3", "3/3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the current navigation index and diff view untouched when no diff exists", () => {
|
||||||
|
const modal = new ConflictResolveModal({} as never, "empty.md" as FilePathWithPrefix, conflict);
|
||||||
|
const { diffView } = createDiffView(0);
|
||||||
|
const indicator = { setText: vi.fn() };
|
||||||
|
Object.assign(modal, { diffView, currentDiffIndex: 1, diffNavIndicator: indicator });
|
||||||
|
|
||||||
|
modal.navigateDiff("next");
|
||||||
|
|
||||||
|
expect(modal.currentDiffIndex).toBe(1);
|
||||||
|
expect(diffView.querySelector).not.toHaveBeenCalled();
|
||||||
|
expect(indicator.setText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("looks up diff nodes again after the diff view is replaced", () => {
|
||||||
|
const modal = new ConflictResolveModal({} as never, "replace.md" as FilePathWithPrefix, conflict);
|
||||||
|
const view = createDiffView(2);
|
||||||
|
const indicator = { setText: vi.fn() };
|
||||||
|
Object.assign(modal, { diffView: view.diffView, currentDiffIndex: -1, diffNavIndicator: indicator });
|
||||||
|
|
||||||
|
modal.navigateDiff("next");
|
||||||
|
const replacementTargets = view.replaceTargets(3);
|
||||||
|
modal.navigateDiff("next");
|
||||||
|
|
||||||
|
expect(modal.currentDiffIndex).toBe(1);
|
||||||
|
expect(view.diffView.querySelectorAll).toHaveBeenCalledTimes(2);
|
||||||
|
expect(view.diffView.querySelector).toHaveBeenCalledTimes(2);
|
||||||
|
expect(replacementTargets[1].classList.add).toHaveBeenCalledWith("diff-focused");
|
||||||
|
expect(replacementTargets[1].scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
|
||||||
|
expect(indicator.setText.mock.calls.map(([text]) => text)).toEqual(["1/2", "2/3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps navigation indices and focus independent between modal instances", () => {
|
||||||
|
const firstModal = new ConflictResolveModal({} as never, "first.md" as FilePathWithPrefix, conflict);
|
||||||
|
const secondModal = new ConflictResolveModal({} as never, "second.md" as FilePathWithPrefix, conflict);
|
||||||
|
const firstView = createDiffView(2);
|
||||||
|
const secondView = createDiffView(2);
|
||||||
|
Object.assign(firstModal, {
|
||||||
|
diffView: firstView.diffView,
|
||||||
|
currentDiffIndex: -1,
|
||||||
|
diffNavIndicator: { setText: vi.fn() },
|
||||||
|
});
|
||||||
|
Object.assign(secondModal, {
|
||||||
|
diffView: secondView.diffView,
|
||||||
|
currentDiffIndex: -1,
|
||||||
|
diffNavIndicator: { setText: vi.fn() },
|
||||||
|
});
|
||||||
|
|
||||||
|
firstModal.navigateDiff("next");
|
||||||
|
secondModal.navigateDiff("next");
|
||||||
|
firstModal.navigateDiff("next");
|
||||||
|
|
||||||
|
expect(firstModal.currentDiffIndex).toBe(1);
|
||||||
|
expect(secondModal.currentDiffIndex).toBe(0);
|
||||||
|
expect(firstView.targets[0].classList.remove).toHaveBeenCalledWith("diff-focused");
|
||||||
|
expect(secondView.targets[0].classList.remove).not.toHaveBeenCalled();
|
||||||
|
expect(secondView.targets[0].classList.add).toHaveBeenCalledWith("diff-focused");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always resets the indicator using the current diff count", () => {
|
||||||
|
const modal = new ConflictResolveModal({} as never, "reset.md" as FilePathWithPrefix, conflict);
|
||||||
|
const view = createDiffView(2);
|
||||||
|
const indicator = { setText: vi.fn() };
|
||||||
|
Object.assign(modal, { diffView: view.diffView, currentDiffIndex: 1, diffNavIndicator: indicator });
|
||||||
|
|
||||||
|
modal.resetDiffNavigation();
|
||||||
|
|
||||||
|
expect(modal.currentDiffIndex).toBe(-1);
|
||||||
|
expect(view.diffView.querySelectorAll).toHaveBeenCalledWith(".added, .deleted");
|
||||||
|
expect(indicator.setText).toHaveBeenLastCalledWith("0/2");
|
||||||
|
|
||||||
|
view.replaceTargets(0);
|
||||||
|
modal.resetDiffNavigation();
|
||||||
|
expect(indicator.setText).toHaveBeenLastCalledWith("\u2014");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import { computed, reactive, reactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
|
import {
|
||||||
|
computed,
|
||||||
|
reactive,
|
||||||
|
reactiveSource,
|
||||||
|
type ReactiveSource,
|
||||||
|
type ReactiveValue,
|
||||||
|
} from "octagonal-wheels/dataobject/reactive";
|
||||||
import {
|
import {
|
||||||
LOG_LEVEL_DEBUG,
|
LOG_LEVEL_DEBUG,
|
||||||
LOG_LEVEL_INFO,
|
LOG_LEVEL_INFO,
|
||||||
@@ -7,6 +13,7 @@ import {
|
|||||||
type LOG_LEVEL,
|
type LOG_LEVEL,
|
||||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||||
|
import { createOwnedResource, type OwnedResource } from "octagonal-wheels/resource";
|
||||||
import { fireAndForget, isDirty, throttle } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
import { fireAndForget, isDirty, throttle } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||||
import {
|
import {
|
||||||
chunkFetchCounts,
|
chunkFetchCounts,
|
||||||
@@ -46,7 +53,11 @@ import {
|
|||||||
isValidFilenameInDarwin,
|
isValidFilenameInDarwin,
|
||||||
isValidFilenameInWidows,
|
isValidFilenameInWidows,
|
||||||
} from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
} from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||||
import { MARK_LOG_NETWORK_ERROR, MARK_LOG_SEPARATOR } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
import {
|
||||||
|
MARK_LOG_NETWORK_ERROR,
|
||||||
|
MARK_LOG_SEPARATOR,
|
||||||
|
type LogFunction,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||||
import { NetworkWarningStyles } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
import { NetworkWarningStyles } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
||||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||||
import { generateReport } from "@/common/reportTool.ts";
|
import { generateReport } from "@/common/reportTool.ts";
|
||||||
@@ -57,20 +68,22 @@ import {
|
|||||||
|
|
||||||
// This module cannot be a core module because it depends on the Obsidian UI.
|
// This module cannot be a core module because it depends on the Obsidian UI.
|
||||||
|
|
||||||
// DI the log again.
|
function createBufferedLog(entries: ReactiveSource<LogEntry[]>) {
|
||||||
const recentLogEntries = reactiveSource<LogEntry[]>([]);
|
return (message: unknown, level?: number, key?: string) => {
|
||||||
const globalLogFunction = (message: unknown, level?: number, key?: string) => {
|
const messageX =
|
||||||
const messageX =
|
message instanceof Error
|
||||||
message instanceof Error
|
? new LiveSyncError("[Error Logged]: " + message.message, { cause: message })
|
||||||
? new LiveSyncError("[Error Logged]: " + message.message, { cause: message })
|
: typeof message === "string"
|
||||||
: typeof message === "string"
|
? message
|
||||||
? message
|
: JSON.stringify(message);
|
||||||
: JSON.stringify(message);
|
const entry = { message: messageX, level, key } as LogEntry;
|
||||||
const entry = { message: messageX, level, key } as LogEntry;
|
entries.value = [...entries.value, entry];
|
||||||
recentLogEntries.value = [...recentLogEntries.value, entry];
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
setGlobalLogFunction(globalLogFunction);
|
// Legacy Logger calls use this shared buffer; service logs use their owner's buffer.
|
||||||
|
const recentGlobalLogEntries = reactiveSource<LogEntry[]>([]);
|
||||||
|
setGlobalLogFunction(createBufferedLog(recentGlobalLogEntries));
|
||||||
// Keep the recent logs in memory for display, but also keep a longer history in logForDump for when the user wants to see more logs.
|
// Keep the recent logs in memory for display, but also keep a longer history in logForDump for when the user wants to see more logs.
|
||||||
// logForDump is not reactive and is only used for dumping logs when requested, while recentLogs is reactive and is used for displaying logs in the UI.
|
// logForDump is not reactive and is only used for dumping logs when requested, while recentLogs is reactive and is used for displaying logs in the UI.
|
||||||
const logForDump = [] as string[];
|
const logForDump = [] as string[];
|
||||||
@@ -113,6 +126,16 @@ function redactLog(log: string) {
|
|||||||
const showDebugLog = false;
|
const showDebugLog = false;
|
||||||
export const MARK_DONE = "\u{2009}\u{2009}";
|
export const MARK_DONE = "\u{2009}\u{2009}";
|
||||||
export class ModuleLog extends AbstractObsidianModule {
|
export class ModuleLog extends AbstractObsidianModule {
|
||||||
|
/** The host releases this connection after the complete asynchronous unload. */
|
||||||
|
logResource!: OwnedResource<LogFunction>;
|
||||||
|
private readonly recentLogEntries = reactiveSource<LogEntry[]>([]);
|
||||||
|
private readonly displayLogEntries = () => {
|
||||||
|
if (this.recentLogEntries.value.length === 0) return;
|
||||||
|
const entries = [...this.recentLogEntries.value];
|
||||||
|
this.recentLogEntries.value = [];
|
||||||
|
entries.forEach((entry) => this.__addLog(entry.message, entry.level, entry.key));
|
||||||
|
};
|
||||||
|
|
||||||
statusBar?: HTMLElement;
|
statusBar?: HTMLElement;
|
||||||
|
|
||||||
statusDiv?: HTMLElement;
|
statusDiv?: HTMLElement;
|
||||||
@@ -460,12 +483,7 @@ ${stringifyYaml(info)}
|
|||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
}
|
}
|
||||||
private _everyOnloadAfterLoadSettings(): Promise<boolean> {
|
private _everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||||
recentLogEntries.onChanged((entries) => {
|
this.recentLogEntries.onChanged(this.displayLogEntries);
|
||||||
if (entries.value.length === 0) return;
|
|
||||||
const newEntries = [...entries.value];
|
|
||||||
recentLogEntries.value = [];
|
|
||||||
newEntries.forEach((e) => this.__addLog(e.message, e.level, e.key));
|
|
||||||
});
|
|
||||||
eventHub.onEvent(EVENT_FILE_RENAMED, (data) => {
|
eventHub.onEvent(EVENT_FILE_RENAMED, (data) => {
|
||||||
void this.setFileStatus();
|
void this.setFileStatus();
|
||||||
});
|
});
|
||||||
@@ -597,7 +615,22 @@ ${stringifyYaml(info)}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||||
services.API.addLog.setHandler(globalLogFunction);
|
const log = createBufferedLog(this.recentLogEntries);
|
||||||
|
const unregisterLog = services.API.addLog.setHandler(log);
|
||||||
|
const collectGlobalLogs = () => {
|
||||||
|
const entries = recentGlobalLogEntries.value;
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
recentGlobalLogEntries.value = [];
|
||||||
|
this.recentLogEntries.value = [...this.recentLogEntries.value, ...entries];
|
||||||
|
};
|
||||||
|
this.logResource = createOwnedResource(log, () => {
|
||||||
|
unregisterLog();
|
||||||
|
recentGlobalLogEntries.offChanged(collectGlobalLogs);
|
||||||
|
this.recentLogEntries.offChanged(this.displayLogEntries);
|
||||||
|
this.recentLogEntries.value = [];
|
||||||
|
});
|
||||||
|
recentGlobalLogEntries.onChanged(collectGlobalLogs);
|
||||||
|
collectGlobalLogs();
|
||||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||||
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
|
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
|
||||||
services.appLifecycle.onLoaded.addHandler(this._everyOnload.bind(this));
|
services.appLifecycle.onLoaded.addHandler(this._everyOnload.bind(this));
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/deps.ts", () => ({
|
||||||
|
addIcon: vi.fn(),
|
||||||
|
debounce: (callback: () => void) => callback,
|
||||||
|
normalizePath: (path: string) => path,
|
||||||
|
Notice: class {},
|
||||||
|
stringifyYaml: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("./Log/LogPaneView.ts", () => ({ LogPaneView: class {}, VIEW_TYPE_LOG: "log" }));
|
||||||
|
vi.mock("@/common/reportTool.ts", () => ({ generateReport: vi.fn() }));
|
||||||
|
vi.mock("@/common/utils.ts", () => ({ isValidPath: () => true }));
|
||||||
|
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
|
||||||
|
vi.mock("@/common/events.ts", () => ({
|
||||||
|
EVENT_FILE_RENAMED: "renamed",
|
||||||
|
EVENT_LAYOUT_READY: "ready",
|
||||||
|
EVENT_LEAF_ACTIVE_CHANGED: "active",
|
||||||
|
EVENT_ON_UNRESOLVED_ERROR: "error",
|
||||||
|
eventHub: { onEvent: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector", () => ({
|
||||||
|
P2PLogCollector: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function createHarness() {
|
||||||
|
const { ModuleLog } = await import("./ModuleLog.ts");
|
||||||
|
const { bindableFunction, allFunction } =
|
||||||
|
await import("@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils");
|
||||||
|
const services = {
|
||||||
|
API: {
|
||||||
|
addLog: bindableFunction<(message: unknown, level?: number, key?: string) => void>("log"),
|
||||||
|
addCommand: vi.fn(),
|
||||||
|
registerWindow: vi.fn(),
|
||||||
|
addRibbonIcon: vi.fn(),
|
||||||
|
registerProtocolHandler: vi.fn(),
|
||||||
|
},
|
||||||
|
context: { events: {}, notices: { hide: vi.fn() } },
|
||||||
|
appLifecycle: {
|
||||||
|
onInitialise: allFunction<() => Promise<boolean>>(),
|
||||||
|
onSettingLoaded: allFunction<() => Promise<boolean>>(),
|
||||||
|
onLoaded: allFunction<() => Promise<boolean>>(),
|
||||||
|
onBeforeUnload: allFunction<() => Promise<boolean>>(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const core = { services, settings: {} };
|
||||||
|
const module = new ModuleLog({ app: {} } as never, core as never);
|
||||||
|
const sink = vi.spyOn(module, "__addLog").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(module, "observeForLogs").mockImplementation(() => undefined);
|
||||||
|
vi.spyOn(module, "adjustStatusDivPosition").mockImplementation(() => undefined);
|
||||||
|
module.onBindFunction(core as never, services as never);
|
||||||
|
const { Logger } = await import("octagonal-wheels/common/logger");
|
||||||
|
return { module, services, sink, Logger };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ModuleLog service connection", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.stubGlobal("document", { querySelectorAll: () => [] });
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buffers startup logs and preserves global and service call order", async () => {
|
||||||
|
const { services, sink, Logger } = await createHarness();
|
||||||
|
Logger("before-global");
|
||||||
|
services.API.addLog("before-service", 32, "startup");
|
||||||
|
Logger("between-global");
|
||||||
|
expect(sink).not.toHaveBeenCalled();
|
||||||
|
await services.appLifecycle.onSettingLoaded();
|
||||||
|
const messages = sink.mock.calls.map(([message]) => message);
|
||||||
|
expect(messages.filter((message) => typeof message === "string" && /^(before|between)-/.test(message))).toEqual(
|
||||||
|
["before-global", "before-service", "between-global"]
|
||||||
|
);
|
||||||
|
expect(sink).toHaveBeenCalledWith("before-service", 32, "startup");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delivers service logs only to their own module", async () => {
|
||||||
|
const first = await createHarness();
|
||||||
|
const second = await createHarness();
|
||||||
|
await first.services.appLifecycle.onSettingLoaded();
|
||||||
|
await second.services.appLifecycle.onSettingLoaded();
|
||||||
|
first.sink.mockClear();
|
||||||
|
second.sink.mockClear();
|
||||||
|
|
||||||
|
first.services.API.addLog("first", 32, "first-key");
|
||||||
|
second.services.API.addLog("second", 64, "second-key");
|
||||||
|
expect(first.sink.mock.calls).toEqual([["first", 32, "first-key"]]);
|
||||||
|
expect(second.sink.mock.calls).toEqual([["second", 64, "second-key"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves message conversion, levels, keys, and error causes", async () => {
|
||||||
|
const { services, sink } = await createHarness();
|
||||||
|
await services.appLifecycle.onSettingLoaded();
|
||||||
|
sink.mockClear();
|
||||||
|
const error = new Error("failed");
|
||||||
|
services.API.addLog(error, 128, "failure");
|
||||||
|
services.API.addLog({ result: "value" }, 32, "object");
|
||||||
|
services.API.addLog("plain");
|
||||||
|
const converted = sink.mock.calls[0][0] as Error & { cause: unknown };
|
||||||
|
expect(converted.message).toBe("[Error Logged]: failed");
|
||||||
|
expect(converted.cause).toBe(error);
|
||||||
|
expect(sink.mock.calls[0].slice(1)).toEqual([128, "failure"]);
|
||||||
|
expect(sink.mock.calls.slice(1)).toEqual([
|
||||||
|
['{"result":"value"}', 32, "object"],
|
||||||
|
["plain", undefined, undefined],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detaches both service registration and legacy subscription on disposal", async () => {
|
||||||
|
const first = await createHarness();
|
||||||
|
const second = await createHarness();
|
||||||
|
await first.services.appLifecycle.onSettingLoaded();
|
||||||
|
await second.services.appLifecycle.onSettingLoaded();
|
||||||
|
first.sink.mockClear();
|
||||||
|
second.sink.mockClear();
|
||||||
|
|
||||||
|
await first.module.logResource.dispose();
|
||||||
|
await first.module.logResource.dispose();
|
||||||
|
expect(() => first.services.API.addLog("retired")).toThrow("not assigned");
|
||||||
|
second.services.API.addLog("active", 32, "service");
|
||||||
|
first.Logger("legacy-after-retirement", 32, "legacy");
|
||||||
|
expect(first.sink).not.toHaveBeenCalled();
|
||||||
|
expect(second.sink.mock.calls).toEqual([
|
||||||
|
["active", 32, "service"],
|
||||||
|
["legacy-after-retirement", 32, "legacy"],
|
||||||
|
]);
|
||||||
|
await second.module.logResource.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a replacement service handler registered when the old owner is disposed", async () => {
|
||||||
|
const { services, module, sink, Logger } = await createHarness();
|
||||||
|
await services.appLifecycle.onSettingLoaded();
|
||||||
|
const replacement = vi.fn();
|
||||||
|
services.API.addLog.setHandler(replacement, true);
|
||||||
|
sink.mockClear();
|
||||||
|
|
||||||
|
await module.logResource.dispose();
|
||||||
|
await module.logResource.dispose();
|
||||||
|
services.API.addLog("replacement", 32, "key");
|
||||||
|
Logger("detached-legacy");
|
||||||
|
expect(replacement).toHaveBeenCalledExactlyOnceWith("replacement", 32, "key");
|
||||||
|
expect(sink).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases a connection which never reached the settings-loaded phase", async () => {
|
||||||
|
const { services, module, sink, Logger } = await createHarness();
|
||||||
|
services.API.addLog("pending");
|
||||||
|
await module.logResource.dispose();
|
||||||
|
Logger("after-disposal");
|
||||||
|
expect(sink).not.toHaveBeenCalled();
|
||||||
|
expect(() => services.API.addLog("retired")).toThrow("not assigned");
|
||||||
|
|
||||||
|
const replacement = await createHarness();
|
||||||
|
await replacement.services.appLifecycle.onSettingLoaded();
|
||||||
|
const messages = replacement.sink.mock.calls.map(([message]) => message);
|
||||||
|
expect(messages).toContain("after-disposal");
|
||||||
|
expect(messages).not.toContain("pending");
|
||||||
|
await replacement.module.logResource.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["complete", "fail"] as const)(
|
||||||
|
"keeps logging through asynchronous database shutdown and releases after it: %s",
|
||||||
|
async (outcome) => {
|
||||||
|
const { services, module, sink, Logger } = await createHarness();
|
||||||
|
await services.appLifecycle.onSettingLoaded();
|
||||||
|
sink.mockClear();
|
||||||
|
const { ControlService } = await import("@vrtmrz/livesync-commonlib/compat/services/base/ControlService");
|
||||||
|
const { ServiceContext } = await import("@vrtmrz/livesync-commonlib/context");
|
||||||
|
const { withOwnedResource } = await import("octagonal-wheels/resource");
|
||||||
|
let finish!: () => void;
|
||||||
|
let fail!: (reason: Error) => void;
|
||||||
|
let started!: () => void;
|
||||||
|
const closing = new Promise<void>((resolve) => {
|
||||||
|
started = resolve;
|
||||||
|
});
|
||||||
|
const closed = new Promise<void>((resolve, reject) => {
|
||||||
|
finish = resolve;
|
||||||
|
fail = reject;
|
||||||
|
});
|
||||||
|
const control = new ControlService(new ServiceContext({ translate: (key) => key }), {
|
||||||
|
appLifecycleService: {
|
||||||
|
...services.appLifecycle,
|
||||||
|
onAppUnload: vi.fn(async () => []),
|
||||||
|
onUnload: vi.fn(async () => {
|
||||||
|
services.API.addLog("lifecycle-unload");
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
APIService: services.API,
|
||||||
|
databaseService: {
|
||||||
|
localDatabaseDirect: {
|
||||||
|
onunload: vi.fn(() => services.API.addLog("database-unload")),
|
||||||
|
close: vi.fn(() => {
|
||||||
|
started();
|
||||||
|
return closed;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const unloading = withOwnedResource(module.logResource, () => control.onUnload());
|
||||||
|
await closing;
|
||||||
|
services.API.addLog("during-close");
|
||||||
|
expect(sink.mock.calls.map(([message]) => message)).toEqual([
|
||||||
|
"lifecycle-unload",
|
||||||
|
"database-unload",
|
||||||
|
"during-close",
|
||||||
|
]);
|
||||||
|
if (outcome === "fail") {
|
||||||
|
const failure = new Error("database close failed");
|
||||||
|
const rejected = expect(unloading).rejects.toBe(failure);
|
||||||
|
fail(failure);
|
||||||
|
await rejected;
|
||||||
|
} else {
|
||||||
|
finish();
|
||||||
|
await unloading;
|
||||||
|
expect(sink.mock.calls[sink.mock.calls.length - 1][0]).toContain(
|
||||||
|
"moduleLiveSyncMain.logUnloadingPlugin"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(() => services.API.addLog("retired")).toThrow("not assigned");
|
||||||
|
sink.mockClear();
|
||||||
|
Logger("after-unload");
|
||||||
|
expect(sink).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
statusDisplay,
|
statusDisplay,
|
||||||
type ConfigurationItem,
|
type ConfigurationItem,
|
||||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import { type ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||||
import {
|
import {
|
||||||
type AllSettingItemKey,
|
type AllSettingItemKey,
|
||||||
getConfig,
|
getConfig,
|
||||||
@@ -24,7 +24,8 @@ import {
|
|||||||
type AllBooleanItemKey,
|
type AllBooleanItemKey,
|
||||||
} from "./settingConstants.ts";
|
} from "./settingConstants.ts";
|
||||||
import { $msg } from "@/common/translation";
|
import { $msg } from "@/common/translation";
|
||||||
import { wrapMemo, type AutoWireOption, type OnUpdateResult } from "./SettingPane.ts";
|
import type { AutoWireOption, OnUpdateResult } from "./SettingPane.ts";
|
||||||
|
import { onlyWhenChanged } from "octagonal-wheels/function";
|
||||||
import { setButtonDestructiveState } from "./settingComponentStyles.ts";
|
import { setButtonDestructiveState } from "./settingComponentStyles.ts";
|
||||||
|
|
||||||
export class LiveSyncSetting extends Setting {
|
export class LiveSyncSetting extends Setting {
|
||||||
@@ -109,11 +110,15 @@ export class LiveSyncSetting extends Setting {
|
|||||||
}
|
}
|
||||||
LiveSyncSetting.env.requestUpdate();
|
LiveSyncSetting.env.requestUpdate();
|
||||||
}
|
}
|
||||||
autoWireText(key: AllStringItemKey, opt?: AutoWireOption) {
|
private autoWireStringComponent<TComponent extends TextComponent | TextAreaComponent>(
|
||||||
|
key: AllStringItemKey,
|
||||||
|
addComponent: (callback: (component: TComponent) => void) => void,
|
||||||
|
opt?: AutoWireOption
|
||||||
|
) {
|
||||||
const conf = this.autoWireSetting(key, opt);
|
const conf = this.autoWireSetting(key, opt);
|
||||||
this.addText((text) => {
|
addComponent((text) => {
|
||||||
this.autoWiredComponent = text;
|
this.autoWiredComponent = text;
|
||||||
const setValue = wrapMemo((value: string) => {
|
const setValue = onlyWhenChanged((value: string) => {
|
||||||
text.setValue(value);
|
text.setValue(value);
|
||||||
});
|
});
|
||||||
this.invalidateValue = () => setValue(`${LiveSyncSetting.env.editingSettings[key]}`);
|
this.invalidateValue = () => setValue(`${LiveSyncSetting.env.editingSettings[key]}`);
|
||||||
@@ -127,26 +132,25 @@ export class LiveSyncSetting extends Setting {
|
|||||||
}
|
}
|
||||||
this.autoWireComponent(this.autoWiredComponent, conf, opt);
|
this.autoWireComponent(this.autoWiredComponent, conf, opt);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
autoWireText(key: AllStringItemKey, opt?: AutoWireOption) {
|
||||||
|
this.autoWireStringComponent<TextComponent>(
|
||||||
|
key,
|
||||||
|
(callback) => {
|
||||||
|
this.addText(callback);
|
||||||
|
},
|
||||||
|
opt
|
||||||
|
);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
autoWireTextArea(key: AllStringItemKey, opt?: AutoWireOption) {
|
autoWireTextArea(key: AllStringItemKey, opt?: AutoWireOption) {
|
||||||
const conf = this.autoWireSetting(key, opt);
|
this.autoWireStringComponent<TextAreaComponent>(
|
||||||
this.addTextArea((text) => {
|
key,
|
||||||
this.autoWiredComponent = text;
|
(callback) => {
|
||||||
const setValue = wrapMemo((value: string) => {
|
this.addTextArea(callback);
|
||||||
text.setValue(value);
|
},
|
||||||
});
|
opt
|
||||||
this.invalidateValue = () => setValue(`${LiveSyncSetting.env.editingSettings[key]}`);
|
);
|
||||||
this.invalidateValue();
|
|
||||||
text.onChange(async (value) => {
|
|
||||||
await this.commitValue(value);
|
|
||||||
});
|
|
||||||
if (opt?.isPassword) {
|
|
||||||
text.inputEl.setAttribute("type", "password");
|
|
||||||
this.hasPassword = true;
|
|
||||||
}
|
|
||||||
this.autoWireComponent(this.autoWiredComponent, conf, opt);
|
|
||||||
});
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
autoWireNumeric(
|
autoWireNumeric(
|
||||||
@@ -163,7 +167,7 @@ export class LiveSyncSetting extends Setting {
|
|||||||
text.inputEl.setAttribute("max", `${opt.clampMax}`);
|
text.inputEl.setAttribute("max", `${opt.clampMax}`);
|
||||||
}
|
}
|
||||||
let lastError = false;
|
let lastError = false;
|
||||||
const setValue = wrapMemo((value: string) => {
|
const setValue = onlyWhenChanged((value: string) => {
|
||||||
text.setValue(value);
|
text.setValue(value);
|
||||||
});
|
});
|
||||||
this.invalidateValue = () => {
|
this.invalidateValue = () => {
|
||||||
@@ -209,7 +213,7 @@ export class LiveSyncSetting extends Setting {
|
|||||||
const conf = this.autoWireSetting(key, opt);
|
const conf = this.autoWireSetting(key, opt);
|
||||||
this.addToggle((toggle) => {
|
this.addToggle((toggle) => {
|
||||||
this.autoWiredComponent = toggle;
|
this.autoWiredComponent = toggle;
|
||||||
const setValue = wrapMemo((value: boolean) => {
|
const setValue = onlyWhenChanged((value: boolean) => {
|
||||||
toggle.setValue(opt?.invert ? !value : value);
|
toggle.setValue(opt?.invert ? !value : value);
|
||||||
});
|
});
|
||||||
this.invalidateValue = () =>
|
this.invalidateValue = () =>
|
||||||
@@ -228,7 +232,7 @@ export class LiveSyncSetting extends Setting {
|
|||||||
const conf = this.autoWireSetting(key, opt);
|
const conf = this.autoWireSetting(key, opt);
|
||||||
this.addDropdown((dropdown) => {
|
this.addDropdown((dropdown) => {
|
||||||
this.autoWiredComponent = dropdown;
|
this.autoWiredComponent = dropdown;
|
||||||
const setValue = wrapMemo((value: string) => {
|
const setValue = onlyWhenChanged((value: string) => {
|
||||||
dropdown.setValue(value);
|
dropdown.setValue(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -248,7 +252,7 @@ export class LiveSyncSetting extends Setting {
|
|||||||
const conf = this.autoWireSetting(key, opt);
|
const conf = this.autoWireSetting(key, opt);
|
||||||
this.addDropdown((dropdown) => {
|
this.addDropdown((dropdown) => {
|
||||||
this.autoWiredComponent = dropdown;
|
this.autoWiredComponent = dropdown;
|
||||||
const setValue = wrapMemo((value: string) => {
|
const setValue = onlyWhenChanged((value: string) => {
|
||||||
dropdown.setValue(value);
|
dropdown.setValue(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const liveSyncSettingHarness = vi.hoisted(() => ({
|
||||||
|
components: [] as unknown[],
|
||||||
|
settingElements: [] as unknown[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/deps.ts", () => {
|
||||||
|
class StringComponent {
|
||||||
|
values: string[] = [];
|
||||||
|
changeHandler?: (value: string) => Promise<void>;
|
||||||
|
placeholder?: string;
|
||||||
|
inputEl = {
|
||||||
|
attributes: new Map<string, string>(),
|
||||||
|
setAttribute(name: string, value: string) {
|
||||||
|
this.attributes.set(name, value);
|
||||||
|
},
|
||||||
|
toggleClass: (_name: string, _enabled: boolean) => {},
|
||||||
|
};
|
||||||
|
setValue(value: string) {
|
||||||
|
this.values.push(value);
|
||||||
|
}
|
||||||
|
onChange(handler: (value: string) => Promise<void>) {
|
||||||
|
this.changeHandler = handler;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
setPlaceholder(value: string) {
|
||||||
|
this.placeholder = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
constructor() {
|
||||||
|
liveSyncSettingHarness.components.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class TextComponent extends StringComponent {}
|
||||||
|
class TextAreaComponent extends StringComponent {}
|
||||||
|
class Setting {
|
||||||
|
settingEl = {
|
||||||
|
classes: [] as { name: string; enabled: boolean }[],
|
||||||
|
toggleClass(name: string, enabled: boolean) {
|
||||||
|
this.classes.push({ name, enabled });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
constructor(_containerEl: HTMLElement) {
|
||||||
|
liveSyncSettingHarness.settingElements.push(this.settingEl);
|
||||||
|
}
|
||||||
|
setDesc(_description: string | DocumentFragment) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
setName(_name: string | DocumentFragment) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
addText(callback: (component: TextComponent) => void) {
|
||||||
|
callback(new TextComponent());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
addTextArea(callback: (component: TextAreaComponent) => void) {
|
||||||
|
callback(new TextAreaComponent());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
setTooltip(_description: string) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
setDisabled(_disabled: boolean) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { Setting, TextComponent, TextAreaComponent, ButtonComponent: class {} };
|
||||||
|
});
|
||||||
|
vi.mock("./ObsidianLiveSyncSettingTab.ts", () => ({}));
|
||||||
|
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
|
||||||
|
vi.mock("@vrtmrz/livesync-commonlib/compat/common/types", () => ({
|
||||||
|
LEVEL_ADVANCED: 1,
|
||||||
|
LEVEL_POWER_USER: 2,
|
||||||
|
statusDisplay: () => "",
|
||||||
|
}));
|
||||||
|
vi.mock("./settingConstants.ts", () => ({
|
||||||
|
getConfig: (key: string) => ({
|
||||||
|
name: key,
|
||||||
|
desc: `description for ${key}`,
|
||||||
|
placeHolder: `configured placeholder for ${key}`,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("./settingComponentStyles.ts", () => ({ setButtonDestructiveState: vi.fn() }));
|
||||||
|
vi.mock("octagonal-wheels/collection", () => ({ unique: (items: unknown[]) => [...new Set(items)] }));
|
||||||
|
|
||||||
|
import { LiveSyncSetting } from "./LiveSyncSetting.ts";
|
||||||
|
|
||||||
|
type WireKind = "text" | "textarea";
|
||||||
|
type StringComponentHarness = {
|
||||||
|
values: string[];
|
||||||
|
changeHandler?: (value: string) => Promise<void>;
|
||||||
|
placeholder?: string;
|
||||||
|
inputEl: { attributes: Map<string, string> };
|
||||||
|
};
|
||||||
|
type SettingElementHarness = { classes: { name: string; enabled: boolean }[] };
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: () => void;
|
||||||
|
const promise = new Promise<void>((resolvePromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireStringSetting(
|
||||||
|
kind: WireKind,
|
||||||
|
options: {
|
||||||
|
holdValue?: boolean;
|
||||||
|
isPassword?: boolean;
|
||||||
|
placeHolder?: string;
|
||||||
|
onUpdate?: () => { visibility?: boolean };
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const key = kind === "text" ? "syncOnlyRegEx" : "ignoreFiles";
|
||||||
|
const events: string[] = [];
|
||||||
|
const editingSettings: Record<string, unknown> = { [key]: 17 };
|
||||||
|
const saveSettings = vi.fn(async (_keys: string[]) => {});
|
||||||
|
const requestUpdate = vi.fn(() => events.push("update"));
|
||||||
|
const env = {
|
||||||
|
settingComponents: [] as unknown[],
|
||||||
|
editingSettings,
|
||||||
|
saveSettings,
|
||||||
|
requestUpdate,
|
||||||
|
};
|
||||||
|
LiveSyncSetting.env = env as never;
|
||||||
|
|
||||||
|
const setting = new LiveSyncSetting({} as HTMLElement);
|
||||||
|
const onUpdate = vi.fn(options.onUpdate ?? (() => ({ visibility: false })));
|
||||||
|
const wireOptions = { ...options, onUpdate };
|
||||||
|
if (kind === "text") {
|
||||||
|
setting.autoWireText(key as never, wireOptions);
|
||||||
|
} else {
|
||||||
|
setting.autoWireTextArea(key as never, wireOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
events,
|
||||||
|
editingSettings,
|
||||||
|
saveSettings,
|
||||||
|
requestUpdate,
|
||||||
|
setting,
|
||||||
|
component: setting.autoWiredComponent as unknown as StringComponentHarness,
|
||||||
|
settingElement: setting.settingEl as unknown as SettingElementHarness,
|
||||||
|
onUpdate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LiveSyncSetting string controls", () => {
|
||||||
|
it.each(["text", "textarea"] as const)("wires %s through the shared string commit contract", async (kind) => {
|
||||||
|
const {
|
||||||
|
key,
|
||||||
|
events,
|
||||||
|
editingSettings,
|
||||||
|
saveSettings,
|
||||||
|
requestUpdate,
|
||||||
|
setting,
|
||||||
|
component,
|
||||||
|
settingElement,
|
||||||
|
onUpdate,
|
||||||
|
} = wireStringSetting(kind, { isPassword: true, placeHolder: "option placeholder" });
|
||||||
|
|
||||||
|
expect(component.values).toEqual(["17"]);
|
||||||
|
setting.invalidateValue?.();
|
||||||
|
expect(component.values).toEqual(["17"]);
|
||||||
|
editingSettings[key] = 18;
|
||||||
|
setting.invalidateValue?.();
|
||||||
|
expect(component.values).toEqual(["17", "18"]);
|
||||||
|
expect(component.inputEl.attributes.get("type")).toBe("password");
|
||||||
|
expect(setting.hasPassword).toBe(true);
|
||||||
|
expect(setting.updateHandlers.size).toBe(1);
|
||||||
|
expect(onUpdate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setting._getComputedStatus()).toEqual({ visibility: false });
|
||||||
|
expect(onUpdate).toHaveBeenCalledTimes(2);
|
||||||
|
expect(settingElement.classes).toContainEqual({ name: "sls-setting-hidden", enabled: true });
|
||||||
|
if (kind === "text") {
|
||||||
|
expect(component.placeholder).toBe("configured placeholder for syncOnlyRegEx");
|
||||||
|
} else {
|
||||||
|
expect(component.placeholder).toBeUndefined();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingSave = deferred();
|
||||||
|
saveSettings.mockImplementationOnce(async (keys) => {
|
||||||
|
events.push(`save:${keys.join(",")}`);
|
||||||
|
await pendingSave.promise;
|
||||||
|
events.push("saved");
|
||||||
|
});
|
||||||
|
const commit = component.changeHandler!("changed");
|
||||||
|
expect(editingSettings[key]).toBe("changed");
|
||||||
|
expect(saveSettings).toHaveBeenCalledWith([key]);
|
||||||
|
expect(events).toEqual([`save:${key}`]);
|
||||||
|
expect(requestUpdate).not.toHaveBeenCalled();
|
||||||
|
pendingSave.resolve();
|
||||||
|
await commit;
|
||||||
|
expect(events).toEqual([`save:${key}`, "saved", "update"]);
|
||||||
|
|
||||||
|
events.length = 0;
|
||||||
|
await component.changeHandler!("changed");
|
||||||
|
expect(saveSettings).toHaveBeenCalledTimes(1);
|
||||||
|
expect(events).toEqual(["update"]);
|
||||||
|
|
||||||
|
events.length = 0;
|
||||||
|
const failure = new Error("setting save failed");
|
||||||
|
saveSettings.mockImplementationOnce(async (keys) => {
|
||||||
|
events.push(`save:${keys.join(",")}`);
|
||||||
|
throw failure;
|
||||||
|
});
|
||||||
|
await expect(component.changeHandler!("failed")).rejects.toBe(failure);
|
||||||
|
expect(editingSettings[key]).toBe("failed");
|
||||||
|
expect(events).toEqual([`save:${key}`]);
|
||||||
|
expect(requestUpdate).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["text", "textarea"] as const)("keeps %s held values in the editing copy without saving", async (kind) => {
|
||||||
|
const { key, editingSettings, saveSettings, requestUpdate, component, events } = wireStringSetting(kind, {
|
||||||
|
holdValue: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await component.changeHandler!("held value");
|
||||||
|
|
||||||
|
expect(editingSettings[key]).toBe("held value");
|
||||||
|
expect(saveSettings).not.toHaveBeenCalled();
|
||||||
|
expect(requestUpdate).toHaveBeenCalledOnce();
|
||||||
|
expect(events).toEqual(["update"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import { LEVEL_ADVANCED, type CustomRegExpSource } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import {
|
||||||
|
LEVEL_ADVANCED,
|
||||||
|
type CustomRegExpSource,
|
||||||
|
type CustomRegExpSourceList,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import { constructCustomRegExpList, splitCustomRegExpList } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
import { constructCustomRegExpList, splitCustomRegExpList } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||||
import MultipleRegExpControl from "./MultipleRegExpControl.svelte";
|
import MultipleRegExpControl from "./MultipleRegExpControl.svelte";
|
||||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||||
@@ -7,6 +11,35 @@ import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts
|
|||||||
import type { PageFunctions } from "./SettingPane.ts";
|
import type { PageFunctions } from "./SettingPane.ts";
|
||||||
import { visibleOnly } from "./SettingPane.ts";
|
import { visibleOnly } from "./SettingPane.ts";
|
||||||
import { $msg } from "@/common/translation";
|
import { $msg } from "@/common/translation";
|
||||||
|
|
||||||
|
type RegExpControlOwner = Pick<
|
||||||
|
ObsidianLiveSyncSettingTab,
|
||||||
|
"saveAllDirtySettings" | "requestPageRefresh" | "lifetimeComponent"
|
||||||
|
>;
|
||||||
|
|
||||||
|
function mountMultipleRegExpControl<D extends "|[]|" | ",">(
|
||||||
|
owner: RegExpControlOwner,
|
||||||
|
target: HTMLElement,
|
||||||
|
source: CustomRegExpSourceList<D>,
|
||||||
|
delimiter: D,
|
||||||
|
assign: (serializedPatterns: CustomRegExpSourceList<D>) => void
|
||||||
|
): void {
|
||||||
|
const patterns = splitCustomRegExpList(source, delimiter);
|
||||||
|
const control = mount(MultipleRegExpControl, {
|
||||||
|
target,
|
||||||
|
props: {
|
||||||
|
patterns,
|
||||||
|
originals: [...patterns],
|
||||||
|
apply: async (newPatterns: CustomRegExpSource[]) => {
|
||||||
|
assign(constructCustomRegExpList(newPatterns, delimiter));
|
||||||
|
await owner.saveAllDirtySettings();
|
||||||
|
owner.requestPageRefresh();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
owner.lifetimeComponent.register(() => void unmount(control));
|
||||||
|
}
|
||||||
|
|
||||||
export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
|
export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
|
||||||
void addPanel(paneEl, $msg("Normal Files")).then((paneEl) => {
|
void addPanel(paneEl, $msg("Normal Files")).then((paneEl) => {
|
||||||
const syncFilesSetting = new Setting(paneEl)
|
const syncFilesSetting = new Setting(paneEl)
|
||||||
@@ -16,37 +49,29 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
|||||||
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns."
|
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns."
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const syncFilesControl = mount(MultipleRegExpControl, {
|
mountMultipleRegExpControl(
|
||||||
target: syncFilesSetting.controlEl,
|
this,
|
||||||
props: {
|
syncFilesSetting.controlEl,
|
||||||
patterns: splitCustomRegExpList(this.editingSettings.syncOnlyRegEx, "|[]|"),
|
this.editingSettings.syncOnlyRegEx,
|
||||||
originals: splitCustomRegExpList(this.editingSettings.syncOnlyRegEx, "|[]|"),
|
"|[]|",
|
||||||
apply: async (newPatterns: CustomRegExpSource[]) => {
|
(value) => {
|
||||||
this.editingSettings.syncOnlyRegEx = constructCustomRegExpList(newPatterns, "|[]|");
|
this.editingSettings.syncOnlyRegEx = value;
|
||||||
await this.saveAllDirtySettings();
|
}
|
||||||
this.requestPageRefresh();
|
);
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.lifetimeComponent.register(() => void unmount(syncFilesControl));
|
|
||||||
|
|
||||||
const nonSyncFilesSetting = new Setting(paneEl)
|
const nonSyncFilesSetting = new Setting(paneEl)
|
||||||
.setName($msg("Non-Synchronising files"))
|
.setName($msg("Non-Synchronising files"))
|
||||||
.setDesc($msg("(RegExp) Skip changes to matching normal and hidden files, locally and remotely."));
|
.setDesc($msg("(RegExp) Skip changes to matching normal and hidden files, locally and remotely."));
|
||||||
|
|
||||||
const nonSyncFilesControl = mount(MultipleRegExpControl, {
|
mountMultipleRegExpControl(
|
||||||
target: nonSyncFilesSetting.controlEl,
|
this,
|
||||||
props: {
|
nonSyncFilesSetting.controlEl,
|
||||||
patterns: splitCustomRegExpList(this.editingSettings.syncIgnoreRegEx, "|[]|"),
|
this.editingSettings.syncIgnoreRegEx,
|
||||||
originals: splitCustomRegExpList(this.editingSettings.syncIgnoreRegEx, "|[]|"),
|
"|[]|",
|
||||||
apply: async (newPatterns: CustomRegExpSource[]) => {
|
(value) => {
|
||||||
this.editingSettings.syncIgnoreRegEx = constructCustomRegExpList(newPatterns, "|[]|");
|
this.editingSettings.syncIgnoreRegEx = value;
|
||||||
await this.saveAllDirtySettings();
|
}
|
||||||
this.requestPageRefresh();
|
);
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.lifetimeComponent.register(() => void unmount(nonSyncFilesControl));
|
|
||||||
new Setting(paneEl).autoWireNumeric("syncMaxSizeInMB", { clampMin: 0 });
|
new Setting(paneEl).autoWireNumeric("syncMaxSizeInMB", { clampMin: 0 });
|
||||||
|
|
||||||
new Setting(paneEl).autoWireToggle("useIgnoreFiles");
|
new Setting(paneEl).autoWireToggle("useIgnoreFiles");
|
||||||
@@ -58,41 +83,30 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
|||||||
const targetPatternSetting = new Setting(paneEl)
|
const targetPatternSetting = new Setting(paneEl)
|
||||||
.setName($msg("Target patterns"))
|
.setName($msg("Target patterns"))
|
||||||
.setDesc($msg("Patterns to match files for syncing"));
|
.setDesc($msg("Patterns to match files for syncing"));
|
||||||
const patTarget = splitCustomRegExpList(this.editingSettings.syncInternalFilesTargetPatterns, ",");
|
mountMultipleRegExpControl(
|
||||||
const targetPatternControl = mount(MultipleRegExpControl, {
|
this,
|
||||||
target: targetPatternSetting.controlEl,
|
targetPatternSetting.controlEl,
|
||||||
props: {
|
this.editingSettings.syncInternalFilesTargetPatterns,
|
||||||
patterns: patTarget,
|
",",
|
||||||
originals: [...patTarget],
|
(value) => {
|
||||||
apply: async (newPatterns: CustomRegExpSource[]) => {
|
this.editingSettings.syncInternalFilesTargetPatterns = value;
|
||||||
this.editingSettings.syncInternalFilesTargetPatterns = constructCustomRegExpList(newPatterns, ",");
|
}
|
||||||
await this.saveAllDirtySettings();
|
);
|
||||||
this.requestPageRefresh();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.lifetimeComponent.register(() => void unmount(targetPatternControl));
|
|
||||||
|
|
||||||
const defaultSkipPattern = "\\/node_modules\\/, \\/\\.git\\/, ^\\.git\\/, \\/obsidian-livesync\\/";
|
const defaultSkipPattern = "\\/node_modules\\/, \\/\\.git\\/, ^\\.git\\/, \\/obsidian-livesync\\/";
|
||||||
const defaultSkipPatternXPlat =
|
const defaultSkipPatternXPlat =
|
||||||
defaultSkipPattern + ",\\/workspace$ ,\\/workspace.json$,\\/workspace-mobile.json$";
|
defaultSkipPattern + ",\\/workspace$ ,\\/workspace.json$,\\/workspace-mobile.json$";
|
||||||
|
|
||||||
const pat = splitCustomRegExpList(this.editingSettings.syncInternalFilesIgnorePatterns, ",");
|
|
||||||
const patSetting = new Setting(paneEl).setName($msg("Ignore patterns")).setDesc("");
|
const patSetting = new Setting(paneEl).setName($msg("Ignore patterns")).setDesc("");
|
||||||
|
mountMultipleRegExpControl(
|
||||||
const ignorePatternControl = mount(MultipleRegExpControl, {
|
this,
|
||||||
target: patSetting.controlEl,
|
patSetting.controlEl,
|
||||||
props: {
|
this.editingSettings.syncInternalFilesIgnorePatterns,
|
||||||
patterns: pat,
|
",",
|
||||||
originals: [...pat],
|
(value) => {
|
||||||
apply: async (newPatterns: CustomRegExpSource[]) => {
|
this.editingSettings.syncInternalFilesIgnorePatterns = value;
|
||||||
this.editingSettings.syncInternalFilesIgnorePatterns = constructCustomRegExpList(newPatterns, ",");
|
}
|
||||||
await this.saveAllDirtySettings();
|
);
|
||||||
this.requestPageRefresh();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.lifetimeComponent.register(() => void unmount(ignorePatternControl));
|
|
||||||
|
|
||||||
const addDefaultPatterns = async (patterns: string) => {
|
const addDefaultPatterns = async (patterns: string) => {
|
||||||
const oldList = splitCustomRegExpList(this.editingSettings.syncInternalFilesIgnorePatterns, ",");
|
const oldList = splitCustomRegExpList(this.editingSettings.syncInternalFilesIgnorePatterns, ",");
|
||||||
@@ -122,22 +136,14 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
|||||||
const overwritePatterns = new Setting(paneEl)
|
const overwritePatterns = new Setting(paneEl)
|
||||||
.setName($msg("Overwrite patterns"))
|
.setName($msg("Overwrite patterns"))
|
||||||
.setDesc($msg("Patterns to match files for overwriting instead of merging"));
|
.setDesc($msg("Patterns to match files for overwriting instead of merging"));
|
||||||
const patTarget2 = splitCustomRegExpList(this.editingSettings.syncInternalFileOverwritePatterns, ",");
|
mountMultipleRegExpControl(
|
||||||
const overwritePatternControl = mount(MultipleRegExpControl, {
|
this,
|
||||||
target: overwritePatterns.controlEl,
|
overwritePatterns.controlEl,
|
||||||
props: {
|
this.editingSettings.syncInternalFileOverwritePatterns,
|
||||||
patterns: patTarget2,
|
",",
|
||||||
originals: [...patTarget2],
|
(value) => {
|
||||||
apply: async (newPatterns: CustomRegExpSource[]) => {
|
this.editingSettings.syncInternalFileOverwritePatterns = value;
|
||||||
this.editingSettings.syncInternalFileOverwritePatterns = constructCustomRegExpList(
|
}
|
||||||
newPatterns,
|
);
|
||||||
","
|
|
||||||
);
|
|
||||||
await this.saveAllDirtySettings();
|
|
||||||
this.requestPageRefresh();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.lifetimeComponent.register(() => void unmount(overwritePatternControl));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const paneSelectorHarness = vi.hoisted(() => ({
|
||||||
|
mounts: [] as { component: unknown; target: unknown; props: Record<string, unknown>; handle: unknown }[],
|
||||||
|
unmounted: [] as unknown[],
|
||||||
|
settings: [] as { containerEl: HTMLElement; controlEl: unknown }[],
|
||||||
|
buttons: [] as { text: string; handler?: () => void | Promise<void> }[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/common/translation", () => ({ $msg: (value: string) => value }));
|
||||||
|
vi.mock("./MultipleRegExpControl.svelte", () => ({ default: { name: "MultipleRegExpControl" } }));
|
||||||
|
vi.mock("svelte", () => ({
|
||||||
|
mount: vi.fn((component: unknown, options: { target: unknown; props: Record<string, unknown> }) => {
|
||||||
|
const handle = { id: paneSelectorHarness.mounts.length };
|
||||||
|
paneSelectorHarness.mounts.push({ component, target: options.target, props: options.props, handle });
|
||||||
|
return handle;
|
||||||
|
}),
|
||||||
|
unmount: vi.fn((component: unknown) => {
|
||||||
|
paneSelectorHarness.unmounted.push(component);
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("./LiveSyncSetting.ts", () => ({
|
||||||
|
LiveSyncSetting: class {
|
||||||
|
controlEl: { id: number };
|
||||||
|
constructor(containerEl: HTMLElement) {
|
||||||
|
this.controlEl = { id: paneSelectorHarness.settings.length };
|
||||||
|
paneSelectorHarness.settings.push({ containerEl, controlEl: this.controlEl });
|
||||||
|
}
|
||||||
|
setName(_name: string) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
setDesc(_description: string) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
autoWireNumeric(_key: string, _options: unknown) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
autoWireToggle(_key: string) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
autoWireTextArea(_key: string, _options: unknown) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
addButton(
|
||||||
|
callback: (button: {
|
||||||
|
setButtonText: (text: string) => unknown;
|
||||||
|
onClick: (handler: () => void | Promise<void>) => unknown;
|
||||||
|
}) => void
|
||||||
|
) {
|
||||||
|
const button = {
|
||||||
|
text: "",
|
||||||
|
handler: undefined as (() => void | Promise<void>) | undefined,
|
||||||
|
setButtonText(text: string) {
|
||||||
|
this.text = text;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
onClick(handler: () => void | Promise<void>) {
|
||||||
|
this.handler = handler;
|
||||||
|
paneSelectorHarness.buttons.push(this);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
callback(button);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { paneSelector } from "./PaneSelector.ts";
|
||||||
|
|
||||||
|
type PatternControlProps = {
|
||||||
|
patterns: string[];
|
||||||
|
originals: string[];
|
||||||
|
apply: (patterns: string[]) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: () => void;
|
||||||
|
const promise = new Promise<void>((resolvePromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSelector() {
|
||||||
|
const normalPanel = { id: "normal" } as unknown as HTMLElement;
|
||||||
|
const hiddenPanel = { id: "hidden" } as unknown as HTMLElement;
|
||||||
|
const addPanel = vi.fn((_parent: HTMLElement, title: string) =>
|
||||||
|
Promise.resolve(title === "Normal Files" ? normalPanel : hiddenPanel)
|
||||||
|
);
|
||||||
|
const disposers: (() => void)[] = [];
|
||||||
|
const tab = {
|
||||||
|
editingSettings: {
|
||||||
|
syncOnlyRegEx: "alpha|[]|beta",
|
||||||
|
syncIgnoreRegEx: "skip|[]|also-skip",
|
||||||
|
syncInternalFilesTargetPatterns: "target-a,target-b",
|
||||||
|
syncInternalFilesIgnorePatterns: "existing,\\/node_modules\\/",
|
||||||
|
syncInternalFileOverwritePatterns: "overwrite-a,overwrite-b",
|
||||||
|
},
|
||||||
|
saveAllDirtySettings: vi.fn(async () => {}),
|
||||||
|
requestPageRefresh: vi.fn(),
|
||||||
|
lifetimeComponent: { register: (dispose: () => void) => disposers.push(dispose) },
|
||||||
|
};
|
||||||
|
|
||||||
|
paneSelector.call(tab as never, {} as HTMLElement, { addPanel } as never);
|
||||||
|
await vi.waitFor(() => expect(paneSelectorHarness.mounts).toHaveLength(5));
|
||||||
|
return { tab, addPanel, disposers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function controlProps(index: number): PatternControlProps {
|
||||||
|
return paneSelectorHarness.mounts[index]!.props as unknown as PatternControlProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
paneSelectorHarness.mounts.length = 0;
|
||||||
|
paneSelectorHarness.unmounted.length = 0;
|
||||||
|
paneSelectorHarness.settings.length = 0;
|
||||||
|
paneSelectorHarness.buttons.length = 0;
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("paneSelector multiple-pattern controls", () => {
|
||||||
|
it("keeps all five bindings, independent originals, delimiter choices, save ordering, and owned cleanup", async () => {
|
||||||
|
const { tab, addPanel, disposers } = await renderSelector();
|
||||||
|
expect(addPanel.mock.calls.map(([, title]) => title)).toEqual(["Normal Files", "Hidden Files"]);
|
||||||
|
expect(paneSelectorHarness.mounts.map(({ props }) => (props as PatternControlProps).patterns)).toEqual([
|
||||||
|
["alpha", "beta"],
|
||||||
|
["skip", "also-skip"],
|
||||||
|
["target-a", "target-b"],
|
||||||
|
["existing", "\\/node_modules\\/"],
|
||||||
|
["overwrite-a", "overwrite-b"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (let index = 0; index < paneSelectorHarness.mounts.length; index++) {
|
||||||
|
const { patterns, originals } = controlProps(index);
|
||||||
|
expect(originals).toEqual(patterns);
|
||||||
|
expect(originals).not.toBe(patterns);
|
||||||
|
patterns.push("edited-current-value");
|
||||||
|
expect(originals).not.toContain("edited-current-value");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
key: "syncOnlyRegEx",
|
||||||
|
patterns: ["new-normal-a", "new-normal-b"],
|
||||||
|
value: "new-normal-a|[]|new-normal-b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
key: "syncIgnoreRegEx",
|
||||||
|
patterns: ["new-skip-a", "new-skip-b"],
|
||||||
|
value: "new-skip-a|[]|new-skip-b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 2,
|
||||||
|
key: "syncInternalFilesTargetPatterns",
|
||||||
|
patterns: ["new-target-a", "new-target-b"],
|
||||||
|
value: "new-target-a,new-target-b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 3,
|
||||||
|
key: "syncInternalFilesIgnorePatterns",
|
||||||
|
patterns: ["new-ignore-a", "new-ignore-b"],
|
||||||
|
value: "new-ignore-a,new-ignore-b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 4,
|
||||||
|
key: "syncInternalFileOverwritePatterns",
|
||||||
|
patterns: ["new-overwrite-a", "new-overwrite-b"],
|
||||||
|
value: "new-overwrite-a,new-overwrite-b",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
const events: string[] = [];
|
||||||
|
for (let index = 0; index < cases.length; index++) {
|
||||||
|
const item = cases[index]!;
|
||||||
|
const pendingSave = deferred();
|
||||||
|
events.length = 0;
|
||||||
|
tab.saveAllDirtySettings.mockImplementationOnce(async () => {
|
||||||
|
events.push(`save:${item.key}`);
|
||||||
|
await pendingSave.promise;
|
||||||
|
events.push("saved");
|
||||||
|
});
|
||||||
|
tab.requestPageRefresh.mockImplementationOnce(() => {
|
||||||
|
events.push("refresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
const applying = controlProps(item.index).apply([...item.patterns]);
|
||||||
|
expect(tab.editingSettings[item.key as keyof typeof tab.editingSettings]).toBe(item.value);
|
||||||
|
expect(tab.saveAllDirtySettings).toHaveBeenCalledTimes(index + 1);
|
||||||
|
expect(events).toEqual([`save:${item.key}`]);
|
||||||
|
expect(tab.requestPageRefresh).toHaveBeenCalledTimes(index);
|
||||||
|
|
||||||
|
pendingSave.resolve();
|
||||||
|
await applying;
|
||||||
|
expect(events).toEqual([`save:${item.key}`, "saved", "refresh"]);
|
||||||
|
expect(tab.requestPageRefresh).toHaveBeenCalledTimes(index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const failure = new Error("settings save failed");
|
||||||
|
tab.saveAllDirtySettings.mockRejectedValueOnce(failure);
|
||||||
|
await expect(controlProps(2).apply(["assigned-before-failure"])).rejects.toBe(failure);
|
||||||
|
expect(tab.editingSettings.syncInternalFilesTargetPatterns).toBe("assigned-before-failure");
|
||||||
|
expect(tab.requestPageRefresh).toHaveBeenCalledTimes(cases.length);
|
||||||
|
|
||||||
|
expect(disposers).toHaveLength(5);
|
||||||
|
disposers.forEach((dispose) => dispose());
|
||||||
|
expect(paneSelectorHarness.unmounted).toEqual(paneSelectorHarness.mounts.map(({ handle }) => handle));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges default hidden-file ignores with existing patterns and removes duplicates", async () => {
|
||||||
|
const { tab } = await renderSelector();
|
||||||
|
const defaultButton = paneSelectorHarness.buttons.find(({ text }) => text === "Default");
|
||||||
|
const crossPlatformButton = paneSelectorHarness.buttons.find(({ text }) => text === "Cross-platform");
|
||||||
|
expect(defaultButton?.handler).toBeDefined();
|
||||||
|
expect(crossPlatformButton?.handler).toBeDefined();
|
||||||
|
|
||||||
|
await defaultButton!.handler!();
|
||||||
|
expect(tab.editingSettings.syncInternalFilesIgnorePatterns).toBe(
|
||||||
|
"existing,\\/node_modules\\/, \\/\\.git\\/, ^\\.git\\/, \\/obsidian-livesync\\/"
|
||||||
|
);
|
||||||
|
await crossPlatformButton!.handler!();
|
||||||
|
expect(tab.editingSettings.syncInternalFilesIgnorePatterns).toBe(
|
||||||
|
"existing,\\/node_modules\\/, \\/\\.git\\/, ^\\.git\\/, \\/obsidian-livesync\\/,\\/workspace$ ,\\/workspace.json$,\\/workspace-mobile.json$"
|
||||||
|
);
|
||||||
|
expect(tab.saveAllDirtySettings).toHaveBeenCalledTimes(2);
|
||||||
|
expect(tab.requestPageRefresh).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -97,16 +97,6 @@ export function findAttrFromParent(el: HTMLElement, attr: string): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wrapMemo<T>(func: (arg: T) => void) {
|
|
||||||
let buf: T | undefined = undefined;
|
|
||||||
return (arg: T) => {
|
|
||||||
if (buf !== arg) {
|
|
||||||
func(arg);
|
|
||||||
buf = arg;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defers pane construction until the owning settings page can confirm that its
|
* Defers pane construction until the owning settings page can confirm that its
|
||||||
* render scope is still active.
|
* render scope is still active.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { generateCredentialObject } from "@vrtmrz/livesync-commonlib/compat/repl
|
|||||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||||
import { isUnauthorizedError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
import { isUnauthorizedError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||||
import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration";
|
import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration";
|
||||||
|
import { isCouchDBVersionAtLeast, normaliseResponseHeaderKeys } from "@/modules/features/couchDBDiagnosticValues";
|
||||||
|
|
||||||
export const checkConfig = async (
|
export const checkConfig = async (
|
||||||
checkResultDiv: HTMLDivElement | undefined,
|
checkResultDiv: HTMLDivElement | undefined,
|
||||||
@@ -89,19 +90,6 @@ export const checkConfig = async (
|
|||||||
addResult($msg("obsidianLiveSyncSettingTab.serverVersion", { info: serverBanner }));
|
addResult($msg("obsidianLiveSyncSettingTab.serverVersion", { info: serverBanner }));
|
||||||
const versionMatch = serverBanner.match(/CouchDB(\/([0-9.]+))?/);
|
const versionMatch = serverBanner.match(/CouchDB(\/([0-9.]+))?/);
|
||||||
const versionStr = versionMatch ? versionMatch[2] : "0.0.0";
|
const versionStr = versionMatch ? versionMatch[2] : "0.0.0";
|
||||||
const versionParts = `${versionStr}.0.0.0`.split(".");
|
|
||||||
// Compare version string with the target version.
|
|
||||||
// version must be a string like "3.2.1" or "3.10.2", and must be two or three parts.
|
|
||||||
function isGreaterThanOrEqual(version: string) {
|
|
||||||
const targetParts = version.split(".");
|
|
||||||
for (let i = 0; i < targetParts.length; i++) {
|
|
||||||
// compare as number if possible (so 3.10 > 3.2, 3.10.1b > 3.10.1a)
|
|
||||||
const result = versionParts[i].localeCompare(targetParts[i], undefined, { numeric: true });
|
|
||||||
if (result > 0) return true;
|
|
||||||
if (result < 0) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Admin check
|
// Admin check
|
||||||
// for database creation and deletion
|
// for database creation and deletion
|
||||||
if (!(editingSettings.couchDB_USER in responseConfig.admins)) {
|
if (!(editingSettings.couchDB_USER in responseConfig.admins)) {
|
||||||
@@ -109,7 +97,7 @@ export const checkConfig = async (
|
|||||||
} else {
|
} else {
|
||||||
addResult($msg("obsidianLiveSyncSettingTab.okAdminPrivileges"));
|
addResult($msg("obsidianLiveSyncSettingTab.okAdminPrivileges"));
|
||||||
}
|
}
|
||||||
if (isGreaterThanOrEqual("3.2.0")) {
|
if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) {
|
||||||
// HTTP user-authorization check
|
// HTTP user-authorization check
|
||||||
if (responseConfig?.chttpd?.require_valid_user != "true") {
|
if (responseConfig?.chttpd?.require_valid_user != "true") {
|
||||||
isSuccessful = false;
|
isSuccessful = false;
|
||||||
@@ -148,7 +136,7 @@ export const checkConfig = async (
|
|||||||
} else {
|
} else {
|
||||||
addResult($msg("obsidianLiveSyncSettingTab.okWwwAuth"));
|
addResult($msg("obsidianLiveSyncSettingTab.okWwwAuth"));
|
||||||
}
|
}
|
||||||
if (isGreaterThanOrEqual("3.2.0")) {
|
if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) {
|
||||||
if (responseConfig?.chttpd?.enable_cors != "true") {
|
if (responseConfig?.chttpd?.enable_cors != "true") {
|
||||||
isSuccessful = false;
|
isSuccessful = false;
|
||||||
addResult($msg("obsidianLiveSyncSettingTab.errEnableCorsChttpd"));
|
addResult($msg("obsidianLiveSyncSettingTab.errEnableCorsChttpd"));
|
||||||
@@ -240,12 +228,7 @@ export const checkConfig = async (
|
|||||||
undefined,
|
undefined,
|
||||||
customHeaders
|
customHeaders
|
||||||
);
|
);
|
||||||
const responseHeaders = Object.fromEntries(
|
const responseHeaders = normaliseResponseHeaderKeys(rr.headers);
|
||||||
Object.entries(rr.headers).map((e) => {
|
|
||||||
e[0] = `${e[0]}`.toLowerCase();
|
|
||||||
return e;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
addResult($msg("obsidianLiveSyncSettingTab.msgOriginCheck", { org }));
|
addResult($msg("obsidianLiveSyncSettingTab.msgOriginCheck", { org }));
|
||||||
if (responseHeaders["access-control-allow-credentials"] != "true") {
|
if (responseHeaders["access-control-allow-credentials"] != "true") {
|
||||||
addResult($msg("obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials"));
|
addResult($msg("obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials"));
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { generateCredentialObject } from "@vrtmrz/livesync-commonlib/compat/repl
|
|||||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||||
import { isUnauthorizedError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
import { isUnauthorizedError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||||
import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration";
|
import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration";
|
||||||
|
import { isCouchDBVersionAtLeast, normaliseResponseHeaderKeys } from "@/modules/features/couchDBDiagnosticValues";
|
||||||
|
|
||||||
export type ResultMessage = { message: string; classes: string[] };
|
export type ResultMessage = { message: string; classes: string[] };
|
||||||
export type ResultErrorMessage = { message: string; result: "error"; classes: string[] };
|
export type ResultErrorMessage = { message: string; result: "error"; classes: string[] };
|
||||||
@@ -26,24 +27,6 @@ export type ConfigCheckResult<T = unknown, U = unknown> =
|
|||||||
| ResultError<U>
|
| ResultError<U>
|
||||||
| ResultMessage
|
| ResultMessage
|
||||||
| ResultErrorMessage;
|
| ResultErrorMessage;
|
||||||
/**
|
|
||||||
* Compares two version strings to determine if the baseVersion is greater than or equal to the version.
|
|
||||||
* @param baseVersion a.b.c format
|
|
||||||
* @param version a.b.c format
|
|
||||||
* @returns true if baseVersion is greater than or equal to version, false otherwise
|
|
||||||
*/
|
|
||||||
function isGreaterThanOrEqual(baseVersion: string, version: string) {
|
|
||||||
const versionParts = `${baseVersion}.0.0.0`.split(".");
|
|
||||||
const targetParts = version.split(".");
|
|
||||||
for (let i = 0; i < targetParts.length; i++) {
|
|
||||||
// compare as number if possible (so 3.10 > 3.2, 3.10.1b > 3.10.1a)
|
|
||||||
const result = versionParts[i].localeCompare(targetParts[i], undefined, { numeric: true });
|
|
||||||
if (result > 0) return true;
|
|
||||||
if (result < 0) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the remote CouchDB setting with the given key and value.
|
* Updates the remote CouchDB setting with the given key and value.
|
||||||
* @param setting Connection settings
|
* @param setting Connection settings
|
||||||
@@ -153,7 +136,7 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
|
|||||||
} else {
|
} else {
|
||||||
addSuccess($msg("obsidianLiveSyncSettingTab.okAdminPrivileges"));
|
addSuccess($msg("obsidianLiveSyncSettingTab.okAdminPrivileges"));
|
||||||
}
|
}
|
||||||
if (isGreaterThanOrEqual(versionStr, "3.2.0")) {
|
if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) {
|
||||||
// HTTP user-authorization check
|
// HTTP user-authorization check
|
||||||
if (responseConfig?.chttpd?.require_valid_user != "true") {
|
if (responseConfig?.chttpd?.require_valid_user != "true") {
|
||||||
addError(
|
addError(
|
||||||
@@ -189,7 +172,7 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
|
|||||||
} else {
|
} else {
|
||||||
addSuccess($msg("obsidianLiveSyncSettingTab.okWwwAuth"));
|
addSuccess($msg("obsidianLiveSyncSettingTab.okWwwAuth"));
|
||||||
}
|
}
|
||||||
if (isGreaterThanOrEqual(versionStr, "3.2.0")) {
|
if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) {
|
||||||
if (responseConfig?.chttpd?.enable_cors != "true") {
|
if (responseConfig?.chttpd?.enable_cors != "true") {
|
||||||
addError(
|
addError(
|
||||||
$msg("obsidianLiveSyncSettingTab.errEnableCorsChttpd"),
|
$msg("obsidianLiveSyncSettingTab.errEnableCorsChttpd"),
|
||||||
@@ -287,12 +270,7 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
|
|||||||
undefined,
|
undefined,
|
||||||
customHeaders
|
customHeaders
|
||||||
);
|
);
|
||||||
const responseHeaders = Object.fromEntries(
|
const responseHeaders = normaliseResponseHeaderKeys(rr.headers);
|
||||||
Object.entries(rr.headers).map((e) => {
|
|
||||||
e[0] = `${e[0]}`.toLowerCase();
|
|
||||||
return e;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
addMessage($msg("obsidianLiveSyncSettingTab.msgOriginCheck", { org }));
|
addMessage($msg("obsidianLiveSyncSettingTab.msgOriginCheck", { org }));
|
||||||
if (responseHeaders["access-control-allow-credentials"] != "true") {
|
if (responseHeaders["access-control-allow-credentials"] != "true") {
|
||||||
addErrorMessage($msg("obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials"));
|
addErrorMessage($msg("obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials"));
|
||||||
|
|||||||
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user