feat: add unit tests for replicateResultProcessor and refactor replicator logging

- Introduced unit tests for the replicateResultProcessor, covering various scenarios including document enqueuing, snapshot handling, and processing of non-document changes.
- Refactored replicator to utilize a logging function from the host API instead of a global logger, enhancing log management.
- Updated mismatchedTweaksResolver to include logging through the host API, ensuring consistent logging practices across the application.
- Adjusted tests to mock the new logging behavior and verify log outputs.
This commit is contained in:
vorotamoroz
2026-06-26 09:36:48 +00:00
parent f954448ef8
commit 559c3f351b
86 changed files with 2569 additions and 2009 deletions
-16
View File
@@ -1,16 +0,0 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { PeriodicProcessor } from "@/common/PeriodicProcessor";
import type { LiveSyncCore } from "@/main";
import { AbstractModule } from "@/modules/AbstractModule";
export declare class ModulePeriodicProcess extends AbstractModule {
periodicSyncProcessor: PeriodicProcessor;
disablePeriodic(): Promise<boolean>;
resumePeriodic(): Promise<boolean>;
private _allOnUnload;
private _everyBeforeRealizeSetting;
private _everyBeforeSuspendProcess;
private _everyAfterResumeProcess;
private _everyAfterRealizeSetting;
onBindFunction(core: LiveSyncCore, services: typeof core.services): void;
}
-25
View File
@@ -1,25 +0,0 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { AbstractModule } from "@/modules/AbstractModule";
import { type EntryDoc, type RemoteType } from "@lib/common/types";
import type { LiveSyncCore } from "@/main";
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
export declare class ModuleReplicator extends AbstractModule {
_replicatorType?: RemoteType;
processor: ReplicateResultProcessor;
private _unresolvedErrorManager;
clearErrors(): void;
private _everyOnloadAfterLoadSettings;
_onReplicatorInitialised(): Promise<boolean>;
_everyOnDatabaseInitialized(showNotice: boolean): Promise<boolean>;
_everyBeforeReplicate(showMessage: boolean): Promise<boolean>;
/**
* obsolete method. No longer maintained and will be removed in the future.
* @deprecated v0.24.17
* @param showMessage If true, show message to the user.
*/
cleaned(showMessage: boolean): Promise<void>;
private onReplicationFailed;
_parseReplicationResult(docs: Array<PouchDB.Core.ExistingDocument<EntryDoc>>): Promise<boolean>;
onBindFunction(core: LiveSyncCore, services: typeof core.services): void;
}
-11
View File
@@ -1,11 +0,0 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { type RemoteDBSettings } from "@lib/common/types";
import type { LiveSyncAbstractReplicator } from "@lib/replication/LiveSyncAbstractReplicator";
import { AbstractModule } from "@/modules/AbstractModule";
import type { LiveSyncCore } from "@/main";
export declare class ModuleReplicatorCouchDB extends AbstractModule {
_anyNewReplicator(settingOverride?: Partial<RemoteDBSettings>): Promise<LiveSyncAbstractReplicator | false>;
_everyAfterResumeProcess(): Promise<boolean>;
onBindFunction(core: LiveSyncCore, services: typeof core.services): void;
}
-10
View File
@@ -1,10 +0,0 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { type RemoteDBSettings } from "@lib/common/types";
import type { LiveSyncAbstractReplicator } from "@lib/replication/LiveSyncAbstractReplicator";
import type { LiveSyncCore } from "@/main";
import { AbstractModule } from "@/modules/AbstractModule";
export declare class ModuleReplicatorMinIO extends AbstractModule {
_anyNewReplicator(settingOverride?: Partial<RemoteDBSettings>): Promise<LiveSyncAbstractReplicator | false>;
onBindFunction(core: LiveSyncCore, services: typeof core.services): void;
}
-116
View File
@@ -1,116 +0,0 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { type AnyEntry, type EntryDoc, type LoadedEntry, type MetaEntry } from "@lib/common/types";
import type { ModuleReplicator } from "./ModuleReplicator";
import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
export declare class ReplicateResultProcessor {
private log;
private logError;
private replicator;
constructor(replicator: ModuleReplicator);
get localDatabase(): import("../../lib/src/pouchdb/LiveSyncLocalDB").LiveSyncLocalDB;
get services(): import("../../lib/src/services/InjectableServices").InjectableServiceHub<import("../../lib/src/services/base/ServiceBase").ServiceContext>;
get core(): LiveSyncBaseCore;
getPath(entry: AnyEntry): string;
suspend(): void;
resume(): void;
private _suspended;
get isSuspended(): boolean;
/**
* Take a snapshot of the current processing state.
* This snapshot is stored in the KV database for recovery on restart.
*/
protected _takeSnapshot(): Promise<void>;
/**
* Trigger taking a snapshot.
*/
protected _triggerTakeSnapshot(): void;
/**
* Throttled version of triggerTakeSnapshot.
*/
protected triggerTakeSnapshot: import("octagonal-wheels/function").ThrottledFunction<() => void>;
/**
* Restore from snapshot.
*/
restoreFromSnapshot(): Promise<void>;
private _restoreFromSnapshot;
/**
* Restore from snapshot only once.
* @returns Promise that resolves when restoration is complete.
*/
restoreFromSnapshotOnce(): Promise<void>;
/**
* Perform the given procedure while counting the concurrency.
* @param proc async procedure to perform
* @param countValue reactive source to count concurrency
* @returns result of the procedure
*/
withCounting<T>(proc: () => Promise<T>, countValue: ReactiveSource<number>): Promise<T>;
/**
* Report the current status.
*/
protected reportStatus(): void;
/**
* Enqueue all the given changes for processing.
* @param changes Changes to enqueue
*/
enqueueAll(changes: PouchDB.Core.ExistingDocument<EntryDoc>[]): void;
/**
* Process the change if it is not a document change.
* @param change Change to process
* @returns True if the change was processed; false otherwise
*/
protected processIfNonDocumentChange(change: PouchDB.Core.ExistingDocument<EntryDoc>): boolean;
/**
* Queue of changes to be processed.
*/
private _queuedChanges;
/**
* List of changes being processed.
*/
private _processingChanges;
/**
* Enqueue the given document change for processing.
* @param doc Document change to enqueue
* @returns
*/
protected enqueueChange(doc: PouchDB.Core.ExistingDocument<EntryDoc>): void;
/**
* Trigger processing of the queued changes.
*/
protected triggerProcessQueue(): void;
/**
* Semaphore to limit concurrent processing.
* This is the per-id semaphore + concurrency-control (max 10 concurrent = 10 documents being processed at the same time).
*/
private _semaphore;
/**
* Flag indicating whether the process queue is currently running.
*/
private _isRunningProcessQueue;
/**
* Process the queued changes.
*/
private runProcessQueue;
/**
* Parse the given document change.
* @param change
* @returns
*/
parseDocumentChange(change: PouchDB.Core.ExistingDocument<EntryDoc>): Promise<void>;
protected applyToDatabase(doc: PouchDB.Core.ExistingDocument<AnyEntry>): Promise<void>;
private _applyToDatabase;
/**
* Phase 3: Apply the given entry to storage.
* @param entry
* @returns
*/
protected applyToStorage(entry: MetaEntry): Promise<void>;
/**
* Check whether processing is required for the given document.
* @param dbDoc Document to check
* @returns True if processing is required; false otherwise
*/
protected checkIsChangeRequiredForDatabaseProcessing(dbDoc: LoadedEntry): Promise<boolean>;
}
@@ -1,15 +0,0 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { AbstractModule } from "@/modules/AbstractModule.ts";
import { type FilePathWithPrefix } from "@lib/common/types";
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
import type { InjectableServiceHub } from "@lib/services/InjectableServices.ts";
import type { LiveSyncCore } from "@/main.ts";
export declare class ModuleConflictChecker extends AbstractModule {
_queueConflictCheckIfOpen(file: FilePathWithPrefix): Promise<void>;
_queueConflictCheck(file: FilePathWithPrefix): Promise<void>;
_waitForAllConflictProcessed(): Promise<boolean>;
conflictResolveQueue: QueueProcessor<FilePathWithPrefix, unknown>;
conflictCheckQueue: QueueProcessor<FilePathWithPrefix, FilePathWithPrefix>;
onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void;
}
@@ -1,19 +0,0 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { AbstractModule } from "@/modules/AbstractModule.ts";
import { type diff_check_result, type FilePathWithPrefix } from "@lib/common/types";
import type { InjectableServiceHub } from "@lib/services/InjectableServices.ts";
import type { LiveSyncCore } from "@/main.ts";
declare global {
interface LSEvents {
"conflict-cancelled": FilePathWithPrefix;
}
}
export declare class ModuleConflictResolver extends AbstractModule {
private _resolveConflictByDeletingRev;
checkConflictAndPerformAutoMerge(path: FilePathWithPrefix): Promise<diff_check_result>;
private _resolveConflict;
private _anyResolveConflictByNewest;
private _resolveAllConflictedFilesByNewerOnes;
onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void;
}
@@ -1,36 +0,0 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { type TweakValues, type ObsidianLiveSyncSettings, type RemoteDBSettings } from "@lib/common/types.ts";
import { AbstractModule } from "@/modules/AbstractModule.ts";
import type { InjectableServiceHub } from "@lib/services/InjectableServices.ts";
import type { LiveSyncCore } from "@/main.ts";
export declare class ModuleResolvingMismatchedTweaks extends AbstractModule {
private _hasNotifiedAutoAcceptCompatibleUndefined;
private _collectMismatchedTweakKeys;
private _selectNewerTweakSide;
private _shouldAutoAcceptCompatibleLossy;
/**
* Hook before saving settings, to check if there are changes in tweak values, and if so,
* update the tweakModified timestamp to current time.
* This allows other devices to know that the tweak values have been changed and decide whether to accept the new values based on the modification time.
* @param next
* @param previous
* @returns
*/
_onBeforeSaveSettingData(next: ObsidianLiveSyncSettings, previous: ObsidianLiveSyncSettings): Promise<{
tweakModified: number;
} | undefined>;
_anyAfterConnectCheckFailed(): Promise<boolean | "CHECKAGAIN" | undefined>;
_checkAndAskResolvingMismatchedTweaks(preferred: TweakValues): Promise<[TweakValues | boolean, boolean]>;
_askResolvingMismatchedTweaks(): Promise<"OK" | "CHECKAGAIN" | "IGNORE">;
_fetchRemotePreferredTweakValues(trialSetting: RemoteDBSettings): Promise<TweakValues | false>;
_checkAndAskUseRemoteConfiguration(trialSetting: RemoteDBSettings): Promise<{
result: false | TweakValues;
requireFetch: boolean;
}>;
_askUseRemoteConfiguration(trialSetting: RemoteDBSettings, preferred: TweakValues): Promise<{
result: false | TweakValues;
requireFetch: boolean;
}>;
onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void;
}
-8
View File
@@ -1,8 +0,0 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import type { LiveSyncCore } from "@/main";
import { AbstractModule } from "@/modules/AbstractModule";
export declare class ModuleBasicMenu extends AbstractModule {
_everyOnloadStart(): Promise<boolean>;
onBindFunction(core: LiveSyncCore, services: typeof core.services): void;
}
@@ -0,0 +1,12 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { type FilePathWithPrefix } from "@lib/common/types";
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
import type { NecessaryObsidianFeature } from "@/types";
export type ConflictCheckerHost = NecessaryObsidianFeature<"API" | "conflict" | "vault" | "setting">;
export declare const queueConflictCheckIfOpenHandler: (host: ConflictCheckerHost, file: FilePathWithPrefix) => Promise<void>;
export declare const queueConflictCheckHandler: (host: ConflictCheckerHost, queue: QueueProcessor<FilePathWithPrefix, any>, file: FilePathWithPrefix) => Promise<void>; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration
export declare function useConflictChecker(host: ConflictCheckerHost): {
conflictCheckQueue: QueueProcessor<FilePathWithPrefix, FilePathWithPrefix>;
conflictResolveQueue: QueueProcessor<FilePathWithPrefix, unknown>;
};
@@ -0,0 +1,16 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { AUTO_MERGED, MISSING_OR_ERROR, type diff_check_result, type FilePathWithPrefix } from "@lib/common/types";
import type { NecessaryObsidianFeature } from "@/types";
declare global {
interface LSEvents {
"conflict-cancelled": FilePathWithPrefix;
}
}
export type ConflictResolverHost = NecessaryObsidianFeature<"API" | "conflict" | "appLifecycle" | "replication" | "vault" | "setting" | "database", "databaseFileAccess" | "fileHandler" | "storageAccess">;
export declare const resolveConflictByDeletingRevHandler: (host: ConflictResolverHost, path: FilePathWithPrefix, deleteRevision: string, subTitle?: string) => Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED>;
export declare const checkConflictAndPerformAutoMerge: (host: ConflictResolverHost, path: FilePathWithPrefix) => Promise<diff_check_result>;
export declare const resolveConflictHandler: (host: ConflictResolverHost, filename: FilePathWithPrefix) => Promise<void>;
export declare const resolveConflictByNewestHandler: (host: ConflictResolverHost, filename: FilePathWithPrefix) => Promise<boolean>;
export declare const resolveAllConflictedFilesByNewerOnesHandler: (host: ConflictResolverHost) => Promise<void>;
export declare function useConflictResolver(host: ConflictResolverHost): void;
@@ -0,0 +1,4 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
export { useConflictChecker } from "./conflictChecker";
export { useConflictResolver } from "./conflictResolver";
+1 -1
View File
@@ -4,7 +4,7 @@ import type { NecessaryObsidianServices } from "@/types.ts";
/**
* A union of service keys required by the database maintenance feature.
*/
export type DatabaseMaintenanceServices = "API" | "setting" | "UI" | "database" | "keyValueDB" | "replication" | "replicator";
export type DatabaseMaintenanceServices = "API" | "setting" | "UI" | "database" | "keyValueDB" | "replication" | "replicator" | "vault";
/**
* A union of service module keys required by the database maintenance feature.
*/
@@ -28,3 +28,10 @@ export declare function createConflict(host: DevFeatureHost): Promise<void>;
* @param message - Optional detailed stacktrace or assertion info.
*/
export declare function addTestResult(state: DevFeatureState, name: string, key: string, result: boolean, summary?: string, message?: string): void;
/**
* Dumps information of the specified document for debugging purposes.
*
* @param host - The service feature host context.
* @param file - The file path to dump.
*/
export declare function dumpDocument(host: DevFeatureHost, file: string | undefined): void;
@@ -5,4 +5,4 @@ import type { ConflictResolverServices } from "./types.ts";
* A service feature hook that initialises and manages the Interactive Conflict Resolver.
* Registers conflict resolution commands and handles user-interactive resolution flows.
*/
export declare const useInteractiveConflictResolver: import("@lib/interfaces/ServiceModule.ts").ServiceFeatureFunction<ConflictResolverServices, "databaseFileAccess", void>;
export declare const useInteractiveConflictResolver: import("@/types.ts").ObsidianServiceFeatureFunction<ConflictResolverServices, "databaseFileAccess", "app", void>;
@@ -1,6 +1,6 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { type NecessaryServices } from "@lib/interfaces/ServiceModule";
import type { NecessaryObsidianServices } from "@/types.ts";
/**
* A union of service keys required by the interactive conflict resolver feature.
*/
@@ -12,4 +12,4 @@ export type ConflictResolverModules = "databaseFileAccess";
/**
* The host type representing the injected service container with conflict resolution capabilities.
*/
export type ConflictResolverHost = NecessaryServices<ConflictResolverServices, ConflictResolverModules>;
export type ConflictResolverHost = NecessaryObsidianServices<ConflictResolverServices, ConflictResolverModules, "app">;
+1 -1
View File
@@ -4,4 +4,4 @@ import type { LogFeatureServices } from "./types.ts";
/**
* A service feature hook that initialises and manages logging, status display, and debug report generation.
*/
export declare const useLogFeature: import("@lib/interfaces/ServiceModule.ts").ServiceFeatureFunction<LogFeatureServices, "storageAccess", void>;
export declare const useLogFeature: import("@/types.ts").ObsidianServiceFeatureFunction<LogFeatureServices, "storageAccess", "app" | "liveSyncPlugin", void>;
+2 -2
View File
@@ -1,6 +1,6 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { type NecessaryServices } from "@lib/interfaces/ServiceModule";
import type { NecessaryObsidianServices } from "@/types.ts";
/**
* Service keys required by the logging and status bar feature.
*/
@@ -12,4 +12,4 @@ export type LogFeatureModules = "storageAccess";
/**
* The host type representing the injected service container with logging capabilities.
*/
export type LogFeatureHost = NecessaryServices<LogFeatureServices, LogFeatureModules>;
export type LogFeatureHost = NecessaryObsidianServices<LogFeatureServices, LogFeatureModules, "app" | "liveSyncPlugin">;
+2 -1
View File
@@ -1,3 +1,4 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
export declare const useMigrationFeature: import("@/types.ts").ObsidianServiceFeatureFunction<"path" | "setting" | "UI" | "appLifecycle" | "API" | "database" | "replicator" | "vault" | "keyValueDB", "storageAccess" | "fileHandler" | "rebuilder", never, void>;
import type { MigrationModules, MigrationServices } from "./types.ts";
export declare const useMigrationFeature: import("@/types.ts").ObsidianServiceFeatureFunction<MigrationServices, MigrationModules, never, void>;
@@ -0,0 +1,12 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import type { LogFunction } from "@lib/services/lib/logUtils.ts";
import type { MigrationHost } from "./types.ts";
export declare function migrateUsingDoctor(host: MigrationHost, skipRebuild?: boolean, activateReason?: string, forceRescan?: boolean): Promise<boolean>;
export declare function migrateDisableBulkSend(host: MigrationHost, log: LogFunction): Promise<void>;
export declare function initialMigrationMessage(): Promise<boolean>;
export declare function askAgainForSetupURI(host: MigrationHost): Promise<boolean>;
export declare function hasIncompleteDocs(host: MigrationHost, log: LogFunction, force?: boolean): Promise<boolean>;
export declare function hasCompromisedChunks(host: MigrationHost, log: LogFunction): Promise<boolean>;
export declare function runFirstInitialiseMigration(host: MigrationHost, log: LogFunction): Promise<boolean>;
export declare function bindMigrationRequestEvents(host: MigrationHost, log: LogFunction): Promise<boolean>;
+6
View File
@@ -0,0 +1,6 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import type { NecessaryObsidianServices } from "@/types.ts";
export type MigrationServices = "API" | "appLifecycle" | "setting" | "database" | "path" | "vault" | "replicator" | "UI" | "keyValueDB";
export type MigrationModules = "storageAccess" | "fileHandler" | "rebuilder";
export type MigrationHost = NecessaryObsidianServices<MigrationServices, MigrationModules>;
@@ -5,4 +5,4 @@ import type { DocumentHistoryServices } from "./types.ts";
* A service feature hook that initialises and manages Obsidian Document History commands.
* Registers ribbon commands and listens to history request events.
*/
export declare const useObsidianDocumentHistory: import("@lib/interfaces/ServiceModule.ts").ServiceFeatureFunction<DocumentHistoryServices, never, void>;
export declare const useObsidianDocumentHistory: import("@/types.ts").ObsidianServiceFeatureFunction<DocumentHistoryServices, never, "app" | "liveSyncPlugin", void>;
@@ -1,6 +1,6 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { type NecessaryServices } from "@lib/interfaces/ServiceModule";
import type { NecessaryObsidianServices } from "@/types.ts";
/**
* Service keys required by the Obsidian document history feature.
*/
@@ -12,4 +12,4 @@ export type DocumentHistoryModules = never;
/**
* The host type representing the injected service container with document history capabilities.
*/
export type DocumentHistoryHost = NecessaryServices<DocumentHistoryServices, DocumentHistoryModules>;
export type DocumentHistoryHost = NecessaryObsidianServices<DocumentHistoryServices, DocumentHistoryModules, "app" | "liveSyncPlugin">;
@@ -0,0 +1,9 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import type { LogFunction } from "@lib/services/lib/logUtils.ts";
import type { ObsidianEventsState } from "./state.ts";
import type { ObsidianEventsHost } from "./types.ts";
export declare function registerVaultAndWorkspaceEvents(host: ObsidianEventsHost): Promise<boolean>;
export declare function registerWindowWatchEvents(host: ObsidianEventsHost, log: LogFunction, state: ObsidianEventsState): void;
export declare function onObsidianEventsLayoutReady(host: ObsidianEventsHost, log: LogFunction, state: ObsidianEventsState): Promise<boolean>;
export declare function bindObsidianEventsLifecycle(host: ObsidianEventsHost, log: LogFunction, state: ObsidianEventsState): void;
+1 -1
View File
@@ -5,4 +5,4 @@ import type { ObsidianEventsServices } from "./types.ts";
* A service feature hook that initialises and manages Obsidian application event bindings.
* This hooks into vault file changes, window focus, visibility states, and schedules restarts.
*/
export declare const useObsidianEvents: import("@lib/interfaces/ServiceModule").ServiceFeatureFunction<ObsidianEventsServices, never, void>;
export declare const useObsidianEvents: import("@/types.ts").ObsidianServiceFeatureFunction<ObsidianEventsServices, never, "plugin" | "app", void>;
+2 -2
View File
@@ -1,6 +1,6 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { type NecessaryServices } from "@lib/interfaces/ServiceModule";
import type { NecessaryObsidianServices } from "@/types.ts";
/**
* A union of service keys required by the Obsidian events management feature.
*/
@@ -12,4 +12,4 @@ export type ObsidianEventsModules = never;
/**
* The host type representing the injected service container with Obsidian events capabilities.
*/
export type ObsidianEventsHost = NecessaryServices<ObsidianEventsServices, ObsidianEventsModules>;
export type ObsidianEventsHost = NecessaryObsidianServices<ObsidianEventsServices, ObsidianEventsModules, "app" | "plugin">;
+1 -1
View File
@@ -5,4 +5,4 @@
*
* Provides Obsidian-specific UI elements like ribbon icons and commands.
*/
export declare const useObsidianMenuFeature: import("@/types.ts").ObsidianServiceFeatureFunction<"replication" | "appLifecycle" | "conflict", never, "plugin", void>;
export declare const useObsidianMenuFeature: import("@/types.ts").ObsidianServiceFeatureFunction<"setting" | "replication" | "control" | "appLifecycle" | "API" | "fileProcessing" | "conflict", never, "plugin", void>;
@@ -4,4 +4,4 @@ import type { SettingDialogueServices } from "./types.ts";
/**
* A service feature hook that registers the plug-in setting tab and listens to settings dialogue triggers.
*/
export declare const useObsidianSettingDialogue: import("@lib/interfaces/ServiceModule.ts").ServiceFeatureFunction<SettingDialogueServices, never, void>;
export declare const useObsidianSettingDialogue: import("@/types.ts").ObsidianServiceFeatureFunction<SettingDialogueServices, never, "app" | "liveSyncPlugin", void>;
@@ -1,6 +1,6 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { type NecessaryServices } from "@lib/interfaces/ServiceModule";
import type { NecessaryObsidianServices } from "@/types.ts";
/**
* Service keys required by the Obsidian setting tab dialogue feature.
*/
@@ -12,4 +12,4 @@ export type SettingDialogueModules = never;
/**
* The host type representing the injected service container with setting tab capabilities.
*/
export type SettingDialogueHost = NecessaryServices<SettingDialogueServices, SettingDialogueModules>;
export type SettingDialogueHost = NecessaryObsidianServices<SettingDialogueServices, SettingDialogueModules, "app" | "liveSyncPlugin">;
@@ -0,0 +1,3 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
export { usePeriodicReplication } from "./periodicReplication";
@@ -0,0 +1,11 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { PeriodicProcessor } from "@/common/PeriodicProcessor";
import { type NecessaryObsidianFeature } from "@/types";
export type PeriodicReplicationHost = NecessaryObsidianFeature<"appLifecycle" | "setting" | "replication" | "control" | "API">;
export declare const disablePeriodicHandler: (processor: PeriodicProcessor | undefined) => Promise<boolean>;
export declare const resumePeriodicHandler: (host: PeriodicReplicationHost, processor: PeriodicProcessor) => Promise<boolean>;
export declare function usePeriodicReplication(host: PeriodicReplicationHost): {
disablePeriodic: () => Promise<boolean>;
resumePeriodic: () => Promise<boolean>;
};
+5
View File
@@ -0,0 +1,5 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import type { NecessaryServices } from "@lib/interfaces/ServiceModule.ts";
export type ReplicatorFeatureHost = NecessaryServices<"API" | "replication" | "replicator", never>;
export declare function registerReplicatorCommands(host: ReplicatorFeatureHost): void;
+4
View File
@@ -0,0 +1,4 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
export { useReplicator } from "./replicator";
export { useCouchDBReplicatorFactory, useMinIOReplicatorFactory } from "./replicatorFactories";
@@ -0,0 +1,50 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { type AnyEntry, type EntryDoc, type LoadedEntry, type MetaEntry } from "@lib/common/types";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore_v2";
import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
import type { NecessaryObsidianFeature } from "@/types";
import { type LogFunction } from "@lib/services/lib/logUtils";
export declare const KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT = "replicationResultProcessorSnapshot";
export type ReplicateResultProcessorHost = NecessaryObsidianFeature<"API" | "appLifecycle" | "database" | "keyValueDB" | "path" | "replication" | "replicator" | "setting" | "vault">;
export type ReplicateResultProcessorSnapshot = {
queued: PouchDB.Core.ExistingDocument<EntryDoc>[];
processing: PouchDB.Core.ExistingDocument<EntryDoc>[];
};
export type ReplicateResultProcessorState = {
queuedChanges: PouchDB.Core.ExistingDocument<EntryDoc>[];
processingChanges: PouchDB.Core.ExistingDocument<EntryDoc>[];
suspended: boolean;
restoreFromSnapshot: Promise<void> | undefined;
semaphore: ReturnType<typeof Semaphore>;
isRunningProcessQueue: boolean;
triggerTakeSnapshot: () => void;
};
export type ReplicateResultProcessor = {
suspend: () => void;
resume: () => void;
enqueueAll: (changes: PouchDB.Core.ExistingDocument<EntryDoc>[]) => void;
restoreFromSnapshotOnce: () => Promise<void>;
};
type ReplicateResultProcessorLog = LogFunction;
export declare function createReplicateResultProcessorLog(host: ReplicateResultProcessorHost): ReplicateResultProcessorLog;
export declare function createReplicateResultProcessorState(triggerTakeSnapshot?: () => void): ReplicateResultProcessorState;
export declare function isReplicateResultProcessorSuspended(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState): boolean;
export declare function suspendReplicateResultProcessing(state: ReplicateResultProcessorState): void;
export declare function resumeReplicateResultProcessing(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState, log?: ReplicateResultProcessorLog): void;
export declare function takeReplicateResultProcessorSnapshot(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState, log?: ReplicateResultProcessorLog): Promise<void>;
export declare function restoreReplicateResultProcessorSnapshot(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState, log?: ReplicateResultProcessorLog): Promise<void>;
export declare function restoreReplicateResultProcessorSnapshotOnce(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState, log?: ReplicateResultProcessorLog): Promise<void>;
export declare function withCounting<T>(proc: () => Promise<T>, countValue: ReactiveSource<number>): Promise<T>;
export declare function reportReplicateResultProcessorStatus(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState): void;
export declare function enqueueAllReplicateResults(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState, log: ReplicateResultProcessorLog, changes: PouchDB.Core.ExistingDocument<EntryDoc>[]): void;
export declare function processIfNonDocumentChange(host: ReplicateResultProcessorHost, log: ReplicateResultProcessorLog, change: PouchDB.Core.ExistingDocument<EntryDoc>): boolean;
export declare function enqueueReplicateResult(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState, log: ReplicateResultProcessorLog, doc: PouchDB.Core.ExistingDocument<EntryDoc>): void;
export declare function runReplicateResultProcessQueue(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState, log?: ReplicateResultProcessorLog): Promise<void>;
export declare function parseReplicateResultDocumentChange(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState, log: ReplicateResultProcessorLog, change: PouchDB.Core.ExistingDocument<EntryDoc>): Promise<void>;
export declare function applyReplicateResultToDatabase(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState, log: ReplicateResultProcessorLog, doc: PouchDB.Core.ExistingDocument<AnyEntry>): Promise<void>;
export declare function applyReplicateResultToDatabaseInternal(host: ReplicateResultProcessorHost, log: ReplicateResultProcessorLog, doc_: PouchDB.Core.ExistingDocument<AnyEntry>): Promise<void>;
export declare function applyReplicateResultToStorage(host: ReplicateResultProcessorHost, entry: MetaEntry): Promise<void>;
export declare function checkIsChangeRequiredForDatabaseProcessing(host: ReplicateResultProcessorHost, log: ReplicateResultProcessorLog, dbDoc: LoadedEntry): Promise<boolean>;
export declare function useReplicateResultProcessor(host: ReplicateResultProcessorHost): ReplicateResultProcessor;
export {};
+16
View File
@@ -0,0 +1,16 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { type EntryDoc } from "@lib/common/types";
import { type ReplicateResultProcessor } from "./replicateResultProcessor";
import { UnresolvedErrorManager } from "@lib/services/base/UnresolvedErrorManager";
import { type LogFunction } from "@lib/services/lib/logUtils";
import type { NecessaryObsidianFeature } from "@/types";
export type ReplicatorHost = NecessaryObsidianFeature<"appLifecycle" | "replication" | "replicator" | "setting" | "tweakValue" | "API" | "database" | "databaseEvents" | "keyValueDB" | "path" | "vault" | "UI", "databaseFileAccess" | "rebuilder">;
export declare const everyOnloadAfterLoadSettingsHandler: (host: ReplicatorHost, processor: ReplicateResultProcessor) => Promise<boolean>;
export declare const onReplicatorInitialisedHandler: () => Promise<boolean>;
export declare const everyOnDatabaseInitializedHandler: (processor: ReplicateResultProcessor, showNotice: boolean) => Promise<boolean>;
export declare const everyBeforeReplicateHandler: (unresolvedErrorManager: UnresolvedErrorManager, processor: ReplicateResultProcessor, showMessage: boolean) => Promise<boolean>;
export declare const cleanedHandler: (host: ReplicatorHost, showMessage: boolean, log?: LogFunction) => Promise<void>;
export declare const onReplicationFailedHandler: (host: ReplicatorHost, showMessage?: boolean, log?: LogFunction) => Promise<boolean>;
export declare const parseReplicationResultHandler: (processor: ReplicateResultProcessor, docs: Array<PouchDB.Core.ExistingDocument<EntryDoc>>) => Promise<boolean>;
export declare function useReplicator(host: ReplicatorHost): void;
@@ -0,0 +1,13 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { type RemoteDBSettings } from "@lib/common/types";
import type { LiveSyncAbstractReplicator } from "@lib/replication/LiveSyncAbstractReplicator";
import type { NecessaryObsidianFeature } from "@/types";
type CouchDBReplicatorHost = NecessaryObsidianFeature<"replicator" | "appLifecycle" | "replication" | "setting">;
export declare const createCouchDBReplicatorHandler: (host: CouchDBReplicatorHost, settingOverride?: Partial<RemoteDBSettings>) => Promise<LiveSyncAbstractReplicator | false>;
export declare const resumeCouchDBReplicationHandler: (host: CouchDBReplicatorHost) => Promise<boolean>;
export declare function useCouchDBReplicatorFactory(host: CouchDBReplicatorHost): void;
type MinIOReplicatorHost = NecessaryObsidianFeature<"replicator" | "setting">;
export declare const createMinIOReplicatorHandler: (host: MinIOReplicatorHost, settingOverride?: Partial<RemoteDBSettings>) => Promise<LiveSyncAbstractReplicator | false>;
export declare function useMinIOReplicatorFactory(host: MinIOReplicatorHost): void;
export {};
+3
View File
@@ -0,0 +1,3 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
export { useMismatchedTweaksResolver } from "./mismatchedTweaksResolver";
@@ -0,0 +1,32 @@
// @ts-nocheck
// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26
import { TweakValuesShouldMatchedTemplate, type TweakValues, type ObsidianLiveSyncSettings, type RemoteDBSettings } from "@lib/common/types.ts";
import type { NecessaryObsidianFeature } from "@/types";
import { type LogFunction } from "@lib/services/lib/logUtils";
export type MismatchedTweaksResolverHost = NecessaryObsidianFeature<"API" | "setting" | "tweakValue" | "replication" | "replicator" | "UI", "rebuilder">;
export declare function valueToString(value: string | number | boolean | object | undefined): string;
export declare const collectMismatchedTweakKeys: (current: TweakValues, preferred: Partial<TweakValues>) => ("liveSync" | "syncOnSave" | "syncOnStart" | "syncOnFileOpen" | "syncOnEditorSave" | "keepReplicationActiveInBackground" | "syncMinimumInterval" | "showVerboseLog" | "lessInformationInLog" | "showLongerLogInsideEditor" | "showStatusOnEditor" | "showStatusOnStatusbar" | "showOnlyIconsOnEditor" | "hideFileWarningNotice" | "networkWarningStyle" | "displayLanguage" | "trashInsteadDelete" | "doNotDeleteFolder" | "batchSave" | "batchSaveMinimumDelay" | "batchSaveMaximumDelay" | "syncMaxSizeInMB" | "useIgnoreFiles" | "ignoreFiles" | "processSizeMismatchedFiles" | "syncOnlyRegEx" | "syncIgnoreRegEx" | "syncAfterMerge" | "resolveConflictsByNewerFile" | "writeDocumentsIfConflicted" | "disableMarkdownAutoMerge" | "configPassphraseStore" | "encryptedPassphrase" | "encryptedCouchDBConnection" | "periodicReplication" | "periodicReplicationInterval" | "syncInternalFiles" | "syncInternalFilesBeforeReplication" | "syncInternalFilesInterval" | "syncInternalFilesIgnorePatterns" | "syncInternalFilesTargetPatterns" | "watchInternalFileChanges" | "suppressNotifyHiddenFilesChange" | "syncInternalFileOverwritePatterns" | "usePluginSync" | "usePluginSettings" | "showOwnPlugins" | "autoSweepPlugins" | "autoSweepPluginsPeriodic" | "notifyPluginOrSettingUpdated" | "deviceAndVaultName" | "usePluginSyncV2" | "usePluginEtc" | "pluginSyncExtendedSetting" | "useAdvancedMode" | "usePowerUserMode" | "useEdgeCaseMode" | "notifyThresholdOfRemoteStorageSize" | "disableWorkerForGeneratingChunks" | "processSmallFilesInUIThread" | "savingDelay" | "gcDelay" | "skipOlderFilesOnSync" | "useIndexedDBAdapter" | "enableDebugTools" | "writeLogToTheFile" | "settingSyncFile" | "writeCredentialsForSettingSync" | "notifyAllSettingSyncFile" | "suspendFileWatching" | "suspendParseReplicationResult" | "doNotSuspendOnFetching" | "maxMTimeForReflectEvents" | "versionUpFlash" | "settingVersion" | "isConfigured" | "lastReadUpdates" | "doctorProcessedVersion" | "remoteConfigurations" | "activeConfigurationId" | "P2P_ActiveRemoteConfigurationId" | "couchDB_URI" | "couchDB_USER" | "couchDB_PASSWORD" | "couchDB_DBNAME" | "couchDB_CustomHeaders" | "useJWT" | "jwtAlgorithm" | "jwtKey" | "jwtKid" | "jwtSub" | "jwtExpDuration" | "useRequestAPI" | "accessKey" | "secretKey" | "bucket" | "region" | "endpoint" | "useCustomRequestHandler" | "bucketCustomHeaders" | "bucketPrefix" | "forcePathStyle" | "remoteType" | "encrypt" | "passphrase" | "usePathObfuscation" | "E2EEAlgorithm" | "hashAlg" | "minimumChunkSize" | "customChunkSize" | "longLineThreshold" | "useSegmenter" | "enableChunkSplitterV2" | "doNotUseFixedRevisionForChunks" | "chunkSplitterVersion" | "useEden" | "maxChunksInEden" | "maxTotalLengthInEden" | "maxAgeInEden" | "tweakModified" | "checkIntegrityOnSave" | "useHistory" | "disableRequestURI" | "sendChunksBulk" | "sendChunksBulkMaxSize" | "useDynamicIterationCount" | "doNotPaceReplication" | "readChunksOnline" | "useOnlyLocalChunk" | "concurrencyOfReadChunksOnline" | "minimumIntervalOfReadChunksOnline" | "enableCompression" | "batch_size" | "batches_limit" | "ignoreVersionCheck" | "disableCheckingConfigMismatch" | "autoAcceptCompatibleTweak" | "hashCacheMaxCount" | "hashCacheMaxAmount" | "permitEmptyPassphrase" | "handleFilenameCaseSensitive" | "checkConflictOnlyOnOpen" | "showMergeDialogOnlyOnActive" | "additionalSuffixOfDatabaseName" | "useTimeouts" | "deleteMetadataOfDeletedFiles" | "automaticallyDeleteMetadataOfDeletedFiles" | "P2P_AutoAccepting" | "P2P_AutoSyncPeers" | "P2P_AutoWatchPeers" | "P2P_SyncOnReplication" | "P2P_RebuildFrom" | "P2P_AutoAcceptingPeers" | "P2P_AutoDenyingPeers" | "P2P_IsHeadless" | "P2P_Enabled" | "P2P_relays" | "P2P_roomID" | "P2P_passphrase" | "P2P_AppID" | "P2P_AutoStart" | "P2P_AutoBroadcast" | "P2P_DevicePeerName" | "P2P_turnServers" | "P2P_turnUsername" | "P2P_turnCredential" | "P2P_useDiagRTC")[];
export declare const selectNewerTweakSide: (current: TweakValues, preferred: Partial<TweakValues>, log?: LogFunction) => "REMOTE" | "CURRENT";
export declare const shouldAutoAcceptCompatibleLossy: (host: MismatchedTweaksResolverHost, state: {
hasNotifiedAutoAcceptCompatibleUndefined: boolean;
}, current: TweakValues, preferred: Partial<TweakValues>, mismatchedKeys: (keyof typeof TweakValuesShouldMatchedTemplate)[]) => Promise<"REMOTE" | "CURRENT" | undefined>;
export declare const onBeforeSaveSettingDataHandler: (next: ObsidianLiveSyncSettings, previous: ObsidianLiveSyncSettings, log?: LogFunction) => Promise<{
tweakModified: number;
} | undefined>;
export declare const anyAfterConnectCheckFailedHandler: (host: MismatchedTweaksResolverHost) => Promise<boolean | "CHECKAGAIN" | undefined>;
export declare const checkAndAskResolvingMismatchedTweaksHandler: (host: MismatchedTweaksResolverHost, state: {
hasNotifiedAutoAcceptCompatibleUndefined: boolean;
}, preferred: TweakValues) => Promise<[TweakValues | boolean, boolean]>;
export declare const askResolvingMismatchedTweaksHandler: (host: MismatchedTweaksResolverHost) => Promise<"OK" | "CHECKAGAIN" | "IGNORE">;
export declare const fetchRemotePreferredTweakValuesHandler: (host: MismatchedTweaksResolverHost, trialSetting: RemoteDBSettings) => Promise<TweakValues | false>;
export declare const checkAndAskUseRemoteConfigurationHandler: (host: MismatchedTweaksResolverHost, trialSetting: RemoteDBSettings) => Promise<{
result: false | TweakValues;
requireFetch: boolean;
}>;
export declare const askUseRemoteConfigurationHandler: (host: MismatchedTweaksResolverHost, state: {
hasNotifiedAutoAcceptCompatibleUndefined: boolean;
}, trialSetting: RemoteDBSettings, preferred: TweakValues) => Promise<{
result: false | TweakValues;
requireFetch: boolean;
}>;
export declare function useMismatchedTweaksResolver(host: MismatchedTweaksResolverHost): void;
+47 -13
View File
@@ -22,36 +22,70 @@ We have decided to refactor these modules into **'serviceFeature'**s and **'Obsi
2. **'createObsidianServiceFeature'**: To support Obsidian-specific plug-in features that require direct access to the Obsidian application context (`app`, `plugin`, or `liveSyncPlugin`), we introduced the `createObsidianServiceFeature` helper and the `NecessaryObsidianFeature` utility type. This enables type-safe injection of the Obsidian context without casting to `any`.
3. **Core Types Relocation**: All service feature utility types (`LiveSyncCore`, `NecessaryObsidianFeature`, `ObsidianServiceFeatureFunction`, and `createObsidianServiceFeature`) were moved to [src/types.ts](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/types.ts) to prevent circular dependencies.
### State and Operation Boundaries
In early refactoring, some features placed most logic directly inside the feature closure. This kept state local, but it made detailed unit testing awkward because important decisions were only reachable through registered handlers.
The preferred pattern is now:
- Keep runtime state inside the serviceFeature instance closure.
- Define the mutable state shape explicitly with a small `create...State()` factory.
- Extract business operations into dependency-explicit functions that accept `host`, `state`, and any small collaborators such as a log function.
- Keep the `use...Feature()` entrypoint thin: create state, create collaborators, bind handlers, and return only the local API needed by neighbouring features.
- Type the `host` with the exact services and service modules used by the feature, avoiding `as any` casts.
This preserves the non-global nature of serviceFeatures while making queue merging, filtering, snapshot restoration, and error branches directly testable. These operation functions are not necessarily pure; database, storage, UI, and lifecycle effects remain explicit through injected services.
### Closure Boundary Review
The feature closure should contain state ownership and wiring, not hidden business logic. When a local function does more than bind an event, register a command, or adapt an Obsidian callback, prefer extracting it into an operation that receives `host`, `state`, and `log` explicitly.
The current review classifies the refactored features as follows:
- **Thin entrypoints**: `replicator`, `conflictResolution`, `tweakMismatch`, `globalHistory`, `obsidianDocumentHistory`, `obsidianSettingDialogue`, and `interactiveConflictResolver`.
- **Recently thinned**: `obsidianEvents` now delegates Obsidian event registration and lifecycle binding to `eventBindings.ts`, while keeping only log and state creation in `index.ts`. `migration` now delegates doctor checks, incomplete document checks, compromised chunk checks, and first-initialise sequencing to `migrationOperations.ts`.
- **Still worth follow-up extraction**: `logFeature`, `devFeature`, `obsidianSettingAsMarkdown`, and `setupManager` still contain sizeable lifecycle-local functions. They are acceptable as transitional refactors, but new work in these areas should move decision-making into operation files before adding behaviour.
Direct global logging should also be avoided in serviceFeatures. Feature-local log functions should be created from `host.services.API` and passed into operations, matching the rest of the dependency-explicit pattern.
### File Naming
Files whose primary export is a class keep the class-oriented `CamelCase.ts` name. Files that contain only functions, or contain multiple cooperating exports rather than one primary class, use `snakeCase.ts`. For example, the replication result processor is implemented as functional operations and is therefore stored as `replicateResultProcessor.ts`, while its exported types may still use `ReplicateResultProcessor...` names.
## Implementation Details
### Phase 1: Core Commands ('LiveSyncCommands' Inheritors)
These contain significant state and business logic. They have been refactored into pure functional modules under `src/serviceFeatures/`:
These contain significant state and business logic. They have been refactored into functional serviceFeature modules under `src/serviceFeatures/`:
- **[hiddenFileSync/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/hiddenFileSync/)**: Split monolithic file tracking and state variables into focused functional files.
- **[configSync/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/configSync/)**: Decoupled periodic synchronisation, customisation scanning, and commands.
- **[databaseMaintenance/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/databaseMaintenance/)**: Refactored garbage collection, compaction, and diagnostics into pure modules.
- **[databaseMaintenance/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/databaseMaintenance/)**: Refactored garbage collection, compaction, and diagnostics into dependency-explicit operations.
### Phase 2: Obsidian UI & Events ('AbstractObsidianModule' Inheritors)
These modules handle Obsidian-specific event bindings, UI registrations (views, dialogue modals, and ribbon commands), and user preferences. They have been refactored into 'ObsidianServiceFeature' functions:
- **[obsidianEvents/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianEvents/)**: Decoupled reload scheduling, save command overrides, and window visibility handlers.
- **[obsidianEvents/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianEvents/)**: Decoupled reload scheduling, save command overrides, window visibility handlers, and Obsidian event lifecycle bindings.
- **Stateless UI/Command Registrars**:
- `ModuleInteractiveConflictResolver` -> [interactiveConflictResolver/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/interactiveConflictResolver/)
- `ModuleObsidianDocumentHistory` -> [obsidianDocumentHistory/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianDocumentHistory/)
- `ModuleGlobalHistory` -> [globalHistory/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/globalHistory/)
- `ModuleLog` -> [logFeature/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/logFeature/)
- `ModuleObsidianSettingTab` -> [obsidianSettingDialogue/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianSettingDialogue/)
- `ModuleDev` -> [devFeature/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/devFeature/)
- `ModuleInteractiveConflictResolver` -> [interactiveConflictResolver/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/interactiveConflictResolver/)
- `ModuleObsidianDocumentHistory` -> [obsidianDocumentHistory/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianDocumentHistory/)
- `ModuleGlobalHistory` -> [globalHistory/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/globalHistory/)
- `ModuleLog` -> [logFeature/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/logFeature/)
- `ModuleObsidianSettingTab` -> [obsidianSettingDialogue/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianSettingDialogue/)
- `ModuleDev` -> [devFeature/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/devFeature/)
- **Obsidian-Specific Tools**:
- `ModuleObsidianMenu` -> [obsidianMenu/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianMenu/)
- `ModuleObsidianSettingsAsMarkdown` -> [obsidianSettingAsMarkdown/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianSettingAsMarkdown/)
- `SetupManager` -> [setupManager/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/setupManager/)
- `ModuleMigration` -> [migration/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/migration/)
- `ModuleObsidianMenu` -> [obsidianMenu/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianMenu/)
- `ModuleObsidianSettingsAsMarkdown` -> [obsidianSettingAsMarkdown/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianSettingAsMarkdown/)
- `SetupManager` -> [setupManager/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/setupManager/)
- `ModuleMigration` -> [migration/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/migration/) with migration decisions extracted into dependency-explicit operations.
### Phase 3: Core Modules Evaluation
Foundational modules (replicators and conflict resolver engines) will be evaluated in subsequent stages to decide if they should be true services on 'ServiceHub' or standalone features.
The replication result processor is classified as a standalone serviceFeature rather than a ServiceHub service. It owns local runtime state for queued and in-progress replication results, but it is only used by the replicator feature. Its processing logic should therefore be implemented as dependency-explicit operations over a typed host and local state, with the feature entrypoint wiring it into replication and database lifecycle handlers.
## Consequences
- **Encapsulated State**: Key state variables now live safely in feature closures rather than as global class properties.
+1 -1
View File
@@ -16,7 +16,7 @@ try {
try {
console.log("[Postbuild] Type definitions generated successfully. Adding ignore comments...");
execSync("Deno run -A ./utilsdeno/types-add-ignore.ts", { stdio: "inherit" });
execSync("deno run -A ./utilsdeno/types-add-ignore.ts", { stdio: "inherit" });
console.log("[Postbuild] Ignore comments added successfully.");
} catch (error) {
console.warn("[Postbuild] Failed to add ignore comments to type definitions.");
+3 -3
View File
@@ -16210,7 +16210,7 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
"version": "0.25.77-cli",
"version": "0.25.78-cli",
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
@@ -16236,7 +16236,7 @@
},
"src/apps/webapp": {
"name": "livesync-webapp",
"version": "0.25.77-webapp",
"version": "0.25.78-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.46"
},
@@ -16251,7 +16251,7 @@
}
},
"src/apps/webpeer": {
"version": "0.25.77-webpeer",
"version": "0.25.78-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.46"
},
+634 -634
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -1,6 +1,6 @@
# Configuration Synchronisation (`configSync`)
This module manages the synchronisation of Obsidian configurations, particularly plug-in lists, plug-in manifests, and plug-in data (such as settings JSON files). It refactors the monolithic implementation of `CmdConfigSync.ts` into a set of decoupled, side-effect-free, and highly testable functions.
This module manages the synchronisation of Obsidian configurations, particularly plug-in lists, plug-in manifests, and plug-in data (such as settings JSON files). It refactors the monolithic implementation of `CmdConfigSync.ts` into a set of decoupled, dependency-explicit, and highly testable functions.
## Module Structure
@@ -13,7 +13,7 @@ graph TD
index.ts --> state.ts["state.ts (ConfigSyncState)"]
index.ts --> pluginScanner.ts["pluginScanner.ts"]
index.ts --> syncOperations.ts["syncOperations.ts"]
pluginScanner.ts --> utils.ts["utils.ts"]
pluginScanner.ts --> stores.ts["stores.ts"]
syncOperations.ts --> utils.ts
@@ -28,22 +28,25 @@ graph TD
- **`pluginScanner.ts`**: Scans the vault for installed plug-ins, parses manifests, detects active states, and updates Svelte stores.
- **`syncOperations.ts`**: Implements synchronisation tasks including comparing local plug-in configurations with the database, downloading/uploading configurations, and watching vault events.
- **`eventBindings.ts`**: Registers event handlers to Obsidian and other internal services (e.g., reacting to plug-in lifecycle events).
- **`commands.ts`**: Registers ribbon commands and command palette items (e.g., opening the plugin synchronisation dialogue).
- **`commands.ts`**: Registers ribbon commands and command palette items (e.g., opening the plug-in synchronisation dialogue).
## Key Workflows
### Plug-in Scanning & Enumeration
1. Scans the `.obsidian/plugins/` directory to discover all installed plug-ins.
2. Reads and parses their `manifest.json` files and caches the results in `pluginManifests`.
3. Checks which plug-ins are currently enabled or disabled.
4. Aggregates this information into `pluginList` for UI dialogues.
### Configuration Synchronisation (Upload and Download)
1. Fetches configuration changes from the remote database or checks local settings files.
2. Converts filepath configurations to unified key formats (e.g. prefixing with `ix:`, stripping system-specific paths).
3. Detects modifications using hash and modification time (`mtime`) comparisons.
4. Performs silent or interactive updates depending on the user's setting, applying configuration files back to the storage and triggering hot-reloading if necessary.
### Real-time Event Monitoring
1. Observes file changes in configuration directories using Obsidian vault events.
2. Hooks into plug-in activation/deactivation events to trigger synchronisation sweeps automatically.
@@ -195,8 +195,7 @@ export function bindConfigSyncEvents(
const message = `Would you like to enable **Customisation sync**?
> [!DETAILS]-
> This feature allows you to sync your customisations -- such as configurations, themes, snippets, and plugins -- across your devices in a fully controlled manner, unlike the fully automatic behaviour of hidden file synchronisation.
>
> This feature allows you to sync your customisations -- such as configurations, themes, snippets, and plug-ins -- across your devices in a fully controlled manner, unlike the fully automatic behaviour of hidden file synchronisation.
> You may use this feature alongside hidden file synchronisation. When both features are enabled, items configured as \`Automatic\` in this feature will be managed by **hidden file synchronisation**.
> Do not worry, you will be prompted to enable or keep disabled **hidden file synchronisation** after this dialogue.
`;
@@ -1,20 +1,25 @@
import { LOG_LEVEL_NOTICE, type FilePathWithPrefix } from "@lib/common/types";
import { Logger } from "octagonal-wheels/common/logger";
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
import { sendValue } from "octagonal-wheels/messagepassing/signal";
import type { NecessaryObsidianFeature } from "@/types";
import { createInstanceLogFunction, type LogFunction } from "@lib/services/lib/logUtils";
export type ConflictCheckerHost = NecessaryObsidianFeature<"conflict" | "vault" | "setting">;
export type ConflictCheckerHost = NecessaryObsidianFeature<"API" | "conflict" | "vault" | "setting">;
const noopLog: LogFunction = () => undefined;
const createConflictCheckerLog = (host: ConflictCheckerHost): LogFunction =>
host.services.API ? createInstanceLogFunction("ConflictChecker", host.services.API) : noopLog;
export const queueConflictCheckIfOpenHandler = async (
host: ConflictCheckerHost,
file: FilePathWithPrefix
): Promise<void> => {
const log = createConflictCheckerLog(host);
const path = file;
if (host.services.setting.settings.checkConflictOnlyOnOpen) {
const af = host.services.vault.getActiveFilePath();
if (af && af != path) {
Logger(`${file} is conflicted, merging process has been postponed.`, LOG_LEVEL_NOTICE);
log(`${file} is conflicted, merging process has been postponed.`, LOG_LEVEL_NOTICE);
return;
}
}
@@ -30,6 +30,11 @@ describe("conflictChecker", () => {
mockHub.services.vault.getActiveFilePath = vi.fn().mockReturnValue("other.md");
await queueConflictCheckIfOpenHandler(mockHub as any, "test.md" as any);
expect(mockHub.services.conflict.queueCheckFor).not.toHaveBeenCalled();
expect(mockHub.services.API.addLog).toHaveBeenCalledWith(
expect.stringContaining("test.md"),
expect.any(Number),
""
);
});
it("queueConflictCheckIfOpenHandler should queue if checkConflictOnlyOnOpen is false", async () => {
@@ -16,8 +16,8 @@ import { compareMTime, displayRev } from "@lib/common/utils.ts";
import diff_match_patch from "diff-match-patch";
import { stripAllPrefixes, isPlainText } from "@lib/string_and_binary/path";
import { eventHub } from "@/common/events.ts";
import { Logger } from "octagonal-wheels/common/logger";
import type { NecessaryObsidianFeature } from "@/types";
import { createInstanceLogFunction, type LogFunction } from "@lib/services/lib/logUtils";
declare global {
interface LSEvents {
@@ -26,10 +26,14 @@ declare global {
}
export type ConflictResolverHost = NecessaryObsidianFeature<
"conflict" | "appLifecycle" | "replication" | "vault" | "setting" | "database",
"API" | "conflict" | "appLifecycle" | "replication" | "vault" | "setting" | "database",
"databaseFileAccess" | "fileHandler" | "storageAccess"
>;
const noopLog: LogFunction = () => undefined;
const createConflictResolverLog = (host: ConflictResolverHost): LogFunction =>
host.services.API ? createInstanceLogFunction("ConflictResolver", host.services.API) : noopLog;
export const resolveConflictByDeletingRevHandler = async (
host: ConflictResolverHost,
path: FilePathWithPrefix,
@@ -37,31 +41,29 @@ export const resolveConflictByDeletingRevHandler = async (
subTitle = ""
): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> => {
const { serviceModules } = host;
const log = createConflictResolverLog(host);
const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`;
if (!(await serviceModules.fileHandler.deleteRevisionFromDB(path, deleteRevision))) {
Logger(
`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`,
LOG_LEVEL_NOTICE
);
log(`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
eventHub.emitEvent("conflict-cancelled", path);
Logger(`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`, LOG_LEVEL_INFO);
log(`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`, LOG_LEVEL_INFO);
if ((await serviceModules.databaseFileAccess.getConflictedRevs(path)).length != 0) {
Logger(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO);
log(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO);
return AUTO_MERGED;
}
if (isPluginMetadata(path) || isCustomisationSyncMetadata(path)) {
Logger(`${title} ${path} is a plugin metadata file, no need to write to storage`, LOG_LEVEL_INFO);
log(`${title} ${path} is a plug-in metadata file, no need to write to storage`, LOG_LEVEL_INFO);
return AUTO_MERGED;
}
// If no conflicts were found, write the resolved content to the storage.
if (!(await serviceModules.fileHandler.dbToStorage(path, stripAllPrefixes(path), true))) {
Logger(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
const level = subTitle.indexOf("same") !== -1 ? LOG_LEVEL_INFO : LOG_LEVEL_NOTICE;
Logger(`${path} has been merged automatically`, level);
log(`${path} has been merged automatically`, level);
return AUTO_MERGED;
};
@@ -70,6 +72,7 @@ export const checkConflictAndPerformAutoMerge = async (
path: FilePathWithPrefix
): Promise<diff_check_result> => {
const { services, serviceModules } = host;
const log = createConflictResolverLog(host);
const settings = services.setting.settings;
const ret = await services.database.localDatabase.tryAutoMerge(path, !settings.disableMarkdownAutoMerge);
@@ -82,7 +85,7 @@ export const checkConflictAndPerformAutoMerge = async (
// Merged content is coming.
// 1. Store the merged content to the storage
if (!(await serviceModules.databaseFileAccess.storeContent(path, p))) {
Logger(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE);
log(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
@@ -94,7 +97,7 @@ export const checkConflictAndPerformAutoMerge = async (
// should be one or more conflicts;
if (leftLeaf == false) {
// what's going on..
Logger(`could not get current revisions:${path}`, LOG_LEVEL_NOTICE);
log(`could not get current revisions:${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
if (rightLeaf == false) {
@@ -124,7 +127,7 @@ export const checkConflictAndPerformAutoMerge = async (
const dmp = new diff_match_patch();
const diff = dmp.diff_main(leftLeaf.data, rightLeaf.data);
dmp.diff_cleanupSemantic(diff);
Logger(`conflict(s) found:${path}`);
log(`conflict(s) found:${path}`);
return {
left: leftLeaf,
right: rightLeaf,
@@ -137,6 +140,7 @@ export const resolveConflictHandler = async (
filename: FilePathWithPrefix
): Promise<void> => {
const { services } = host;
const log = createConflictResolverLog(host);
const settings = services.setting.settings;
return await serialized(`conflict-resolve:${filename}`, async () => {
@@ -147,7 +151,7 @@ export const resolveConflictHandler = async (
conflictCheckResult === CANCELLED
) {
// nothing to do.
Logger(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
return;
}
if (conflictCheckResult === AUTO_MERGED) {
@@ -156,21 +160,21 @@ export const resolveConflictHandler = async (
//Wait for the running replication, if not running replication, run it once.
await services.replication.replicateByEvent();
}
Logger("[conflict] Automatically merged, but we have to check it again");
log("[conflict] Automatically merged, but we have to check it again");
await services.conflict.queueCheckFor(filename);
return;
}
if (settings.showMergeDialogOnlyOnActive) {
const af = services.vault.getActiveFilePath();
if (af && af != filename) {
Logger(
log(
`[conflict] ${filename} is conflicted. Merging process has been postponed to the file have got opened.`,
LOG_LEVEL_NOTICE
);
return;
}
}
Logger("[conflict] Manual merge required!");
log("[conflict] Manual merge required!");
eventHub.emitEvent("conflict-cancelled", filename);
await services.conflict.resolveByUserInteraction(filename, conflictCheckResult);
});
@@ -181,9 +185,10 @@ export const resolveConflictByNewestHandler = async (
filename: FilePathWithPrefix
): Promise<boolean> => {
const { services, serviceModules } = host;
const log = createConflictResolverLog(host);
const currentRev = await serviceModules.databaseFileAccess.fetchEntryMeta(filename, undefined, true);
if (currentRev == false) {
Logger(`Could not get current revision of ${filename}`);
log(`Could not get current revision of ${filename}`);
return Promise.resolve(false);
}
const revs = await serviceModules.databaseFileAccess.getConflictedRevs(filename);
@@ -210,11 +215,11 @@ export const resolveConflictByNewestHandler = async (
}
return diff;
});
Logger(
log(
`Resolving conflict by newest: ${filename} (Newest: ${new Date(mTimeAndRev[0][0]).toLocaleString()}) (${mTimeAndRev.length} revisions exists)`
);
for (let i = 1; i < mTimeAndRev.length; i++) {
Logger(
log(
`conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}`
);
await services.conflict.resolveByDeletingRevision(filename, mTimeAndRev[i][1], "NEWEST");
@@ -224,14 +229,15 @@ export const resolveConflictByNewestHandler = async (
export const resolveAllConflictedFilesByNewerOnesHandler = async (host: ConflictResolverHost) => {
const { services, serviceModules } = host;
Logger(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE);
const log = createConflictResolverLog(host);
log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE);
const files = await serviceModules.storageAccess.getFileNames();
let i = 0;
for (const file of files) {
if (i++ % 10) {
Logger(
log(
`Check and Processing ${i} / ${files.length}`,
LOG_LEVEL_NOTICE,
"resolveAllConflictedFilesByNewerOnes"
@@ -239,7 +245,7 @@ export const resolveAllConflictedFilesByNewerOnesHandler = async (host: Conflict
}
await services.conflict.resolveByNewest(file);
}
Logger(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
};
export function useConflictResolver(host: ConflictResolverHost) {
@@ -36,6 +36,11 @@ describe("conflictResolver", () => {
};
const res = await resolveConflictByDeletingRevHandler(mockHub as any, "test.md" as any, "1-abc");
expect(res).toBe(MISSING_OR_ERROR);
expect(mockHub.services.API.addLog).toHaveBeenCalledWith(
expect.stringContaining("Could not delete conflicted revision"),
expect.any(Number),
""
);
});
it("resolveConflictByDeletingRevHandler should return early with AUTO_MERGED if conflicts are left", async () => {
@@ -1,6 +1,6 @@
# Database Maintenance (`databaseMaintenance`)
This module manages database maintenance operations, including garbage collection of unused chunks, remote database compaction, and database usage diagnostics. It refactors the monolithic implementation of `CmdLocalDatabaseMainte.ts` into a set of decoupled, side-effect-free, and highly testable functions.
This module manages database maintenance operations, including garbage collection of unused chunks, remote database compaction, and database usage diagnostics. It refactors the monolithic implementation of `CmdLocalDatabaseMainte.ts` into a set of decoupled, dependency-explicit, and highly testable functions.
## Module Structure
@@ -13,7 +13,7 @@ graph TD
index.ts --> garbageCollection.ts["garbageCollection.ts"]
index.ts --> compaction.ts["compaction.ts"]
index.ts --> diagnostics.ts["diagnostics.ts"]
garbageCollection.ts --> utils.ts["utils.ts"]
compaction.ts --> utils.ts
diagnostics.ts --> utils.ts
@@ -30,6 +30,7 @@ graph TD
## Key Workflows
### Garbage Collection V3 (`gcv3`)
1. Syncs latest modifications via one-shot replication.
2. Compares progress values across all connected nodes to ensure they are synchronised.
3. Scans all active database entries to compile a set of referenced chunks.
@@ -37,6 +38,7 @@ graph TD
5. Invokes database compaction to release physical storage.
### Database Usage Audit (`analyseDatabase`)
1. Fetches all document revisions from the local database.
2. Iterates and logs chunk relationships, sorting them into unique, shared, and orphaned categories.
3. Calculates aggregated sizes and compiles details into a TSV format.
+9 -6
View File
@@ -1,6 +1,6 @@
# Hidden File Synchronization (`hiddenFileSync`)
# Hidden File Synchronisation (`hiddenFileSync`)
This module manages the synchronization of Obsidian configuration directories and files (hidden files prefix with `.`, excluding `.trash`). It detaches the monolithic implementation of `CmdHiddenFileSync.ts` into a set of decoupled, side-effect-free, and highly testable functions.
This module manages the synchronisation of Obsidian configuration directories and files (hidden files prefixed with `.`, excluding `.trash`). It detaches the monolithic implementation of `CmdHiddenFileSync.ts` into a set of decoupled, dependency-explicit, and highly testable functions.
## Module Structure
@@ -15,7 +15,7 @@ graph TD
index.ts --> syncOperations.ts["syncOperations.ts"]
index.ts --> rebuild.ts["rebuild.ts"]
index.ts --> conflictResolution.ts["conflictResolution.ts"]
syncOperations.ts --> databaseIO.ts["databaseIO.ts"]
syncOperations.ts --> stateHelpers.ts["stateHelpers.ts"]
rebuild.ts --> databaseIO.ts
@@ -23,14 +23,14 @@ graph TD
conflictResolution.ts --> databaseIO.ts
```
- **`index.ts`**: The entry point that defines the `useHiddenFileSync` service feature, initializing the state and wiring up events and commands.
- **`index.ts`**: The entry point that defines the `useHiddenFileSync` service feature, initialising the state and wiring up events and commands.
- **`types.ts`**: Defines the services required from the global `ServiceHub` (`HiddenFileSyncServices`), required `ServiceModules`, and the `HiddenFileSyncHost` interface.
- **`state.ts`**: Encapsulates all mutable runtime states (e.g. processor references, semaphores, processed file caches) inside a single state object.
- **`stateHelpers.ts`**: Pure functions for manipulating the state, comparing modification times (`mtime`), and converting files to unique keys.
- **`databaseIO.ts`**: Side-effect functions for writing/reading database entries and storage files.
- **`syncOperations.ts`**: Core synchronization logic (scanning storage/database, tracking modifications, and applying offline changes).
- **`syncOperations.ts`**: Core synchronisation logic (scanning storage/database, tracking modifications, and applying offline changes).
- **`rebuild.ts`**: Database re-initialisation logic (rebuilding storage from database, database from storage, or safe merging).
- **`conflictResolution.ts`**: Logic for managing conflicts on json and binary configuration files, showing merge dialogs, and resolving conflicts.
- **`conflictResolution.ts`**: Logic for managing conflicts on JSON and binary configuration files, showing merge dialogues, and resolving conflicts.
- **`startupScan.ts`**: Logic for checking module status and triggering the initial scan during startup.
- **`eventBindings.ts`**: Registers event handlers to Obsidian and other internal services.
- **`commands.ts`**: Registers ribbon commands and Command Palette commands.
@@ -38,15 +38,18 @@ graph TD
## Key Workflows
### Storage to Database Sync (`scanAllStorageChanges`)
1. Enumerates all target hidden files in the vault.
2. Compares the current file metadata (`stat`) with the cached metadata in `state._fileInfoLastProcessed`.
3. If changes are detected, stores the updated file content in the database using chunk-based replication.
### Database to Storage Sync (`scanAllDatabaseChanges`)
1. Queries all documents in the database matching the hidden file prefix.
2. Compares the database metadata with the cached metadata in `state._databaseInfoLastProcessed`.
3. If the database entry is newer, extracts and writes the file back to the storage (and schedules Obsidian reload if configs changed).
### Merging and Conflict Resolution
- Conflict checks are enqueued into a sequential processor (`conflictResolutionProcessor`).
- If a JSON file is conflicted, a 3-way merge is attempted. If it fails, or if it is a non-JSON file, the conflict is resolved by keeping the newer modification time (`resolveByNewerEntry`) or prompting the user (`JsonResolveModal`).
@@ -260,7 +260,7 @@ export async function getFiles(
): Promise<string[]> {
let w: any;
try {
w = await (host as any).app.vault.adapter.list(path);
w = await host.context.app.vault.adapter.list(path);
} catch (ex) {
console.warn(`Could not traverse(HiddenSync):${path}`, ex);
return [];
@@ -289,7 +289,7 @@ export async function getFiles(
* @returns A list of hidden file paths.
*/
export async function scanInternalFileNames(host: HiddenFileSyncHost, state: HiddenFileSyncState): Promise<FilePath[]> {
const root = (host as any).app.vault.getRoot();
const root = host.context.app.vault.getRoot();
const findRoot = root.path;
const filenames = await getFiles(host, state, findRoot, (path) => isTargetFile(host, () => {}, state, path));
return filenames as FilePath[];
@@ -867,8 +867,8 @@ export function notifyConfigChange(host: HiddenFileSyncHost, state: HiddenFileSy
const updatedFolders = [...state.queuedNotificationFiles];
state.queuedNotificationFiles.clear();
try {
const manifests = Object.values((host as any).app.plugins.manifests) as unknown as PluginManifest[];
const enabledPlugins = (host as any).app.plugins.enabledPlugins as Set<string>;
const manifests = Object.values((host.context.app as any).plugins.manifests) as unknown as PluginManifest[];
const enabledPlugins = (host.context.app as any).plugins.enabledPlugins as Set<string>;
const enabledPluginManifests = manifests.filter((e) => enabledPlugins.has(e.id));
const modifiedManifests = enabledPluginManifests.filter((e) => updatedFolders.indexOf(e?.dir ?? "") >= 0);
for (const manifest of modifiedManifests) {
@@ -882,8 +882,8 @@ export function notifyConfigChange(host: HiddenFileSyncHost, state: HiddenFileSy
anchor.addEventListener("click", () => {
fireAndForget(async () => {
console.log(`Unloading plugin: ${updatePluginName}`);
await (host as any).app.plugins.unloadPlugin(updatePluginId);
await (host as any).app.plugins.loadPlugin(updatePluginId);
await (host.context.app as any).plugins.unloadPlugin(updatePluginId);
await (host.context.app as any).plugins.loadPlugin(updatePluginId);
console.log(`Plugin reloaded: ${updatePluginName}`);
});
});
@@ -32,7 +32,7 @@ export async function resolveConflictByUI(
filename: FilePathWithPrefix,
conflictCheckResult: diff_result
): Promise<boolean> {
const app = (host as any).app;
const app = host.context.app;
if (!app) {
log(`Merge: App instance not available`, LOG_LEVEL_VERBOSE);
return false;
@@ -1,4 +1,4 @@
import { createServiceFeature } from "@lib/interfaces/ServiceModule.ts";
import { createObsidianServiceFeature } from "@/types.ts";
import { createInstanceLogFunction } from "@lib/services/lib/logUtils.ts";
import type { ConflictResolverServices, ConflictResolverModules } from "./types.ts";
import { resolveConflictByUI, allConflictCheck, pickFileForResolve, allScanStat } from "./conflictOperations.ts";
@@ -7,9 +7,10 @@ import { resolveConflictByUI, allConflictCheck, pickFileForResolve, allScanStat
* A service feature hook that initialises and manages the Interactive Conflict Resolver.
* Registers conflict resolution commands and handles user-interactive resolution flows.
*/
export const useInteractiveConflictResolver = createServiceFeature<
export const useInteractiveConflictResolver = createObsidianServiceFeature<
ConflictResolverServices,
ConflictResolverModules,
"app",
void
>((host) => {
const log = createInstanceLogFunction("InteractiveConflictResolver", host.services.API);
@@ -52,7 +52,9 @@ describe("InteractiveConflictResolver Operations", () => {
vi.clearAllMocks();
host = {
app: {} as any,
context: {
app: {} as any,
},
services: {
API: {
confirm: {
@@ -1,4 +1,4 @@
import { type NecessaryServices } from "@lib/interfaces/ServiceModule";
import type { NecessaryObsidianServices } from "@/types.ts";
/**
* A union of service keys required by the interactive conflict resolver feature.
@@ -21,4 +21,4 @@ export type ConflictResolverModules = "databaseFileAccess";
/**
* The host type representing the injected service container with conflict resolution capabilities.
*/
export type ConflictResolverHost = NecessaryServices<ConflictResolverServices, ConflictResolverModules>;
export type ConflictResolverHost = NecessaryObsidianServices<ConflictResolverServices, ConflictResolverModules, "app">;
+9 -4
View File
@@ -1,4 +1,4 @@
import { createServiceFeature } from "@lib/interfaces/ServiceModule.ts";
import { createObsidianServiceFeature } from "@/types.ts";
import { setGlobalLogFunction } from "octagonal-wheels/common/logger";
import { LiveSyncError } from "@lib/common/LSError.ts";
import type { LogEntry } from "@lib/mock_and_interop/stores.ts";
@@ -49,7 +49,12 @@ setGlobalLogFunction(globalLogFunction);
/**
* A service feature hook that initialises and manages logging, status display, and debug report generation.
*/
export const useLogFeature = createServiceFeature<LogFeatureServices, LogFeatureModules, void>((host) => {
export const useLogFeature = createObsidianServiceFeature<
LogFeatureServices,
LogFeatureModules,
"app" | "liveSyncPlugin",
void
>((host) => {
const state = createInitialState();
activeState = state;
@@ -93,7 +98,7 @@ export const useLogFeature = createServiceFeature<LogFeatureServices, LogFeature
},
});
const plugin = (host as any).plugin;
const plugin = host.context.liveSyncPlugin;
host.services.API.registerWindow(VIEW_TYPE_LOG, (leaf: WorkspaceLeaf) => new LogPaneView(leaf, plugin));
return Promise.resolve(true);
};
@@ -116,7 +121,7 @@ export const useLogFeature = createServiceFeature<LogFeatureServices, LogFeature
observeForLogs(host, state);
const settings = host.services.setting.settings;
const app = (host as any).app;
const app = host.context.app;
if (settings.showStatusOnEditor) {
const div = app.workspace.containerEl.createDiv({ cls: "livesync-status" });
state.statusDiv = div;
@@ -194,7 +194,7 @@ export function processAddLog(
}
export function adjustStatusDivPosition(host: LogFeatureHost, state: LogFeatureState): void {
const app = (host as any).app;
const app = host.context.app;
const mdv = app.workspace.getMostRecentLeaf();
if (mdv && state.statusDiv) {
state.statusDiv.remove();
@@ -204,7 +204,7 @@ export function adjustStatusDivPosition(host: LogFeatureHost, state: LogFeatureS
}
export async function getActiveFileStatus(host: LogFeatureHost): Promise<string> {
const app = (host as any).app;
const app = host.context.app;
const reason = [] as string[];
const reasonWarn = [] as string[];
const thisFile = app.workspace.getActiveFile();
+2 -2
View File
@@ -1,4 +1,4 @@
import { type NecessaryServices } from "@lib/interfaces/ServiceModule";
import type { NecessaryObsidianServices } from "@/types.ts";
/**
* Service keys required by the logging and status bar feature.
@@ -22,4 +22,4 @@ export type LogFeatureModules = "storageAccess";
/**
* The host type representing the injected service container with logging capabilities.
*/
export type LogFeatureHost = NecessaryServices<LogFeatureServices, LogFeatureModules>;
export type LogFeatureHost = NecessaryObsidianServices<LogFeatureServices, LogFeatureModules, "app" | "liveSyncPlugin">;
+7 -330
View File
@@ -1,337 +1,14 @@
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, Logger } from "@lib/common/logger.ts";
import {
EVENT_REQUEST_OPEN_P2P,
EVENT_REQUEST_OPEN_SETTING_WIZARD,
EVENT_REQUEST_OPEN_SETTINGS,
EVENT_REQUEST_RUN_DOCTOR,
EVENT_REQUEST_RUN_FIX_INCOMPLETE,
eventHub,
} from "@/common/events.ts";
import { $msg } from "@lib/common/i18n.ts";
import { performDoctorConsultation, RebuildOptions } from "@lib/common/configForDoc.ts";
import { isValidPath } from "@/common/utils.ts";
import { isMetaEntry } from "@lib/common/types.ts";
import { isDeletedEntry, isDocContentSame, isLoadedEntry, readAsBlob } from "@lib/common/utils.ts";
import { countCompromisedChunks } from "@lib/pouchdb/negotiation.ts";
import { createObsidianServiceFeature } from "@/types.ts";
import { getSetupManager } from "@/serviceFeatures/setupManager/index.ts";
import { createInstanceLogFunction } from "@lib/services/lib/logUtils.ts";
import { type LogFunction } from "@lib/services/lib/logUtils.ts";
import { createInstanceLogFunction, type LogFunction } from "@lib/services/lib/logUtils.ts";
type ErrorInfo = {
path: string;
recordedSize: number;
actualSize: number;
storageSize: number;
contentMatched: boolean;
isConflicted?: boolean;
};
import { bindMigrationRequestEvents, runFirstInitialiseMigration } from "./migrationOperations.ts";
import type { MigrationModules, MigrationServices } from "./types.ts";
export const useMigrationFeature = createObsidianServiceFeature<
"API" | "appLifecycle" | "setting" | "database" | "path" | "vault" | "replicator" | "UI" | "keyValueDB",
"storageAccess" | "fileHandler" | "rebuilder"
>((host) => {
const services = host.services;
const serviceModules = host.serviceModules;
const log: LogFunction = createInstanceLogFunction("Migration", services.API);
export const useMigrationFeature = createObsidianServiceFeature<MigrationServices, MigrationModules>((host) => {
const log: LogFunction = createInstanceLogFunction("Migration", host.services.API);
const migrateUsingDoctor = async (
skipRebuild: boolean = false,
activateReason = "updated",
forceRescan = false
) => {
const env = { confirm: services.UI.confirm };
const { shouldRebuild, shouldRebuildLocal, isModified, settings } = await performDoctorConsultation(
env,
services.setting.settings,
{
localRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
remoteRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
activateReason,
forceRescan,
}
);
if (isModified) {
await services.setting.applyExternalSettings(settings, true);
}
if (!skipRebuild) {
if (shouldRebuild) {
await serviceModules.rebuilder.scheduleRebuild();
services.appLifecycle.performRestart();
return false;
} else if (shouldRebuildLocal) {
await serviceModules.rebuilder.scheduleFetch();
services.appLifecycle.performRestart();
return false;
}
}
return true;
};
const migrateDisableBulkSend = async () => {
if (services.setting.settings.sendChunksBulk) {
log($msg("moduleMigration.logBulkSendCorrupted"), LOG_LEVEL_NOTICE);
await services.setting.applyExternalSettings(
{
...services.setting.settings,
sendChunksBulk: false,
sendChunksBulkMaxSize: 1,
},
true
);
}
};
const initialMessage = async () => {
return await getSetupManager().startOnBoarding();
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const askAgainForSetupURI = async () => {
const message = $msg("moduleMigration.msgRecommendSetupUri", { URI_DOC: $msg("moduleMigration.docUri") });
const USE_MINIMAL = $msg("moduleMigration.optionSetupWizard");
const USE_P2P = $msg("moduleMigration.optionSetupViaP2P");
const USE_SETUP = $msg("moduleMigration.optionManualSetup");
const NEXT = $msg("moduleMigration.optionRemindNextLaunch");
const ret = await services.UI.confirm.askSelectStringDialogue(
message,
[USE_MINIMAL, USE_SETUP, USE_P2P, NEXT],
{
title: $msg("moduleMigration.titleRecommendSetupUri"),
defaultAction: USE_MINIMAL,
}
);
if (ret === USE_MINIMAL) {
eventHub.emitEvent(EVENT_REQUEST_OPEN_SETTING_WIZARD);
return false;
}
if (ret === USE_P2P) {
eventHub.emitEvent(EVENT_REQUEST_OPEN_P2P);
return false;
}
if (ret === USE_SETUP) {
eventHub.emitEvent(EVENT_REQUEST_OPEN_SETTINGS);
return false;
} else if (ret == NEXT) {
return false;
}
return false;
};
const hasIncompleteDocs = async (force: boolean = false): Promise<boolean> => {
const kvDB = services.keyValueDB.kvDB;
const incompleteDocsChecked = (await kvDB.get<boolean>("checkIncompleteDocs")) || false;
if (incompleteDocsChecked && !force) {
log("Incomplete docs check already done, skipping.", LOG_LEVEL_VERBOSE);
return Promise.resolve(true);
}
log("Checking for incomplete documents...", LOG_LEVEL_NOTICE, "check-incomplete");
const errorFiles = [] as ErrorInfo[];
for await (const metaDoc of services.database.localDatabase.findAllNormalDocs({ conflicts: true })) {
const path = services.path.getPath(metaDoc);
if (!isValidPath(path)) {
continue;
}
if (!(await services.vault.isTargetFile(path))) {
continue;
}
if (!isMetaEntry(metaDoc)) {
continue;
}
const doc = await services.database.localDatabase.getDBEntryFromMeta(metaDoc);
if (!doc || !isLoadedEntry(doc)) {
continue;
}
if (isDeletedEntry(doc)) {
continue;
}
const isConflicted = metaDoc?._conflicts && metaDoc._conflicts.length > 0;
let storageFileContent;
try {
storageFileContent = await serviceModules.storageAccess.readHiddenFileBinary(path);
} catch (e) {
Logger(`Failed to read file ${path}: Possibly unprocessed or missing`);
Logger(e, LOG_LEVEL_VERBOSE);
continue;
}
const sizeOnStorage = storageFileContent.byteLength;
const recordedSize = doc.size;
const docBlob = readAsBlob(doc);
const actualSize = docBlob.size;
if (
recordedSize !== actualSize ||
sizeOnStorage !== actualSize ||
sizeOnStorage !== recordedSize ||
isConflicted
) {
const contentMatched = await isDocContentSame(doc.data, storageFileContent);
errorFiles.push({
path,
recordedSize,
actualSize,
storageSize: sizeOnStorage,
contentMatched,
isConflicted,
});
Logger(
`Size mismatch for ${path}: ${recordedSize} (DB Recorded) , ${actualSize} (DB Stored) , ${sizeOnStorage} (Storage Stored), ${contentMatched ? "Content Matched" : "Content Mismatched"} ${isConflicted ? "Conflicted" : "Not Conflicted"}`
);
}
}
if (errorFiles.length == 0) {
Logger("No size mismatches found", LOG_LEVEL_NOTICE);
await kvDB.set("checkIncompleteDocs", true);
return Promise.resolve(true);
}
Logger(`Found ${errorFiles.length} size mismatches`, LOG_LEVEL_NOTICE);
const recoverable = errorFiles.filter((e) => {
return e.recordedSize === e.storageSize && !e.isConflicted;
});
const unrecoverable = errorFiles.filter((e) => {
return e.recordedSize !== e.storageSize || e.isConflicted;
});
const fileInfo = (e: (typeof errorFiles)[0]) => {
return `${e.path} (M: ${e.recordedSize}, A: ${e.actualSize}, S: ${e.storageSize}) ${e.isConflicted ? "(Conflicted)" : ""}`;
};
const messageUnrecoverable =
unrecoverable.length > 0
? $msg("moduleMigration.fix0256.messageUnrecoverable", {
filesNotRecoverable: unrecoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
})
: "";
const message = $msg("moduleMigration.fix0256.message", {
files: recoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
messageUnrecoverable,
});
const CHECK_IT_LATER = $msg("moduleMigration.fix0256.buttons.checkItLater");
const FIX = $msg("moduleMigration.fix0256.buttons.fix");
const DISMISS = $msg("moduleMigration.fix0256.buttons.DismissForever");
const ret = await services.UI.confirm.askSelectStringDialogue(message, [CHECK_IT_LATER, FIX, DISMISS], {
title: $msg("moduleMigration.fix0256.title"),
defaultAction: CHECK_IT_LATER,
});
if (ret == FIX) {
for (const file of recoverable) {
const stubFile = await serviceModules.storageAccess.getFileStub(file.path);
if (stubFile == null) {
Logger(`Could not find stub file for ${file.path}`, LOG_LEVEL_NOTICE);
continue;
}
stubFile.stat.mtime = Date.now();
const result = await serviceModules.fileHandler.storeFileToDB(stubFile, true, false);
if (result) {
Logger(`Successfully restored ${file.path} from storage`);
} else {
Logger(`Failed to restore ${file.path} from storage`, LOG_LEVEL_NOTICE);
}
}
} else if (ret === DISMISS) {
await kvDB.set("checkIncompleteDocs", true);
}
return Promise.resolve(true);
};
const hasCompromisedChunks = async (): Promise<boolean> => {
Logger(`Checking for compromised chunks...`, LOG_LEVEL_VERBOSE);
if (!services.setting.settings.encrypt) {
return true;
}
const localCompromised = await countCompromisedChunks(services.database.localDatabase.localDatabase);
const remote = services.replicator.getActiveReplicator();
const remoteCompromised = services.API.isOnline ? await remote?.countCompromisedChunks() : 0;
if (localCompromised === false) {
Logger(`Failed to count compromised chunks in local database`, LOG_LEVEL_NOTICE);
return false;
}
if (remoteCompromised === false) {
Logger(`Failed to count compromised chunks in remote database`, LOG_LEVEL_NOTICE);
return false;
}
if (remoteCompromised === 0 && localCompromised === 0) {
return true;
}
Logger(
`Found compromised chunks : ${localCompromised} in local, ${remoteCompromised} in remote`,
LOG_LEVEL_NOTICE
);
const title = $msg("moduleMigration.insecureChunkExist.title");
const msg = $msg("moduleMigration.insecureChunkExist.message");
const REBUILD = $msg("moduleMigration.insecureChunkExist.buttons.rebuild");
const FETCH = $msg("moduleMigration.insecureChunkExist.buttons.fetch");
const DISMISS = $msg("moduleMigration.insecureChunkExist.buttons.later");
const buttons = [REBUILD, FETCH, DISMISS];
if (remoteCompromised != 0) {
buttons.splice(buttons.indexOf(FETCH), 1);
}
const result = await services.UI.confirm.askSelectStringDialogue(msg, buttons, {
title,
defaultAction: DISMISS,
timeout: 0,
});
if (result === REBUILD) {
await serviceModules.rebuilder.scheduleRebuild();
services.appLifecycle.performRestart();
return false;
} else if (result === FETCH) {
await serviceModules.rebuilder.scheduleFetch();
services.appLifecycle.performRestart();
return false;
} else {
log($msg("moduleMigration.insecureChunkExist.laterMessage"), LOG_LEVEL_NOTICE);
}
return true;
};
const everyOnFirstInitialize = async (): Promise<boolean> => {
if (!services.database.localDatabase.isReady) {
log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE);
return false;
}
if (services.setting.settings.isConfigured) {
if (!(await hasCompromisedChunks())) {
return false;
}
if (!(await hasIncompleteDocs())) {
return false;
}
if (!(await migrateUsingDoctor(false))) {
return false;
}
await migrateDisableBulkSend();
}
if (!services.setting.settings.isConfigured) {
if (!(await initialMessage())) {
log($msg("moduleMigration.logSetupCancelled"), LOG_LEVEL_NOTICE);
return false;
}
if (!(await migrateUsingDoctor(true))) {
return false;
}
}
return true;
};
const everyOnLayoutReady = async (): Promise<boolean> => {
eventHub.onEvent(EVENT_REQUEST_RUN_DOCTOR, async (reason) => {
await migrateUsingDoctor(false, reason, true);
});
eventHub.onEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE, async () => {
await hasIncompleteDocs(true);
});
return Promise.resolve(true);
};
services.appLifecycle.onLayoutReady.addHandler(everyOnLayoutReady);
services.appLifecycle.onFirstInitialise.addHandler(everyOnFirstInitialize);
host.services.appLifecycle.onLayoutReady.addHandler(() => bindMigrationRequestEvents(host, log));
host.services.appLifecycle.onFirstInitialise.addHandler(() => runFirstInitialiseMigration(host, log));
return {};
});
@@ -0,0 +1,331 @@
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@lib/common/logger.ts";
import {
EVENT_REQUEST_OPEN_P2P,
EVENT_REQUEST_OPEN_SETTING_WIZARD,
EVENT_REQUEST_OPEN_SETTINGS,
EVENT_REQUEST_RUN_DOCTOR,
EVENT_REQUEST_RUN_FIX_INCOMPLETE,
eventHub,
} from "@/common/events.ts";
import { $msg } from "@lib/common/i18n.ts";
import { performDoctorConsultation, RebuildOptions } from "@lib/common/configForDoc.ts";
import { isValidPath } from "@/common/utils.ts";
import { isMetaEntry } from "@lib/common/types.ts";
import { isDeletedEntry, isDocContentSame, isLoadedEntry, readAsBlob } from "@lib/common/utils.ts";
import { countCompromisedChunks } from "@lib/pouchdb/negotiation.ts";
import { getSetupManager } from "@/serviceFeatures/setupManager/index.ts";
import type { LogFunction } from "@lib/services/lib/logUtils.ts";
import type { MigrationHost } from "./types.ts";
type ErrorInfo = {
path: string;
recordedSize: number;
actualSize: number;
storageSize: number;
contentMatched: boolean;
isConflicted?: boolean;
};
export async function migrateUsingDoctor(
host: MigrationHost,
skipRebuild: boolean = false,
activateReason = "updated",
forceRescan = false
): Promise<boolean> {
const services = host.services;
const env = { confirm: services.UI.confirm };
const { shouldRebuild, shouldRebuildLocal, isModified, settings } = await performDoctorConsultation(
env,
services.setting.settings,
{
localRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
remoteRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
activateReason,
forceRescan,
}
);
if (isModified) {
await services.setting.applyExternalSettings(settings, true);
}
if (!skipRebuild) {
if (shouldRebuild) {
await host.serviceModules.rebuilder.scheduleRebuild();
services.appLifecycle.performRestart();
return false;
} else if (shouldRebuildLocal) {
await host.serviceModules.rebuilder.scheduleFetch();
services.appLifecycle.performRestart();
return false;
}
}
return true;
}
export async function migrateDisableBulkSend(host: MigrationHost, log: LogFunction): Promise<void> {
const services = host.services;
if (services.setting.settings.sendChunksBulk) {
log($msg("moduleMigration.logBulkSendCorrupted"), LOG_LEVEL_NOTICE);
await services.setting.applyExternalSettings(
{
...services.setting.settings,
sendChunksBulk: false,
sendChunksBulkMaxSize: 1,
},
true
);
}
}
export async function initialMigrationMessage(): Promise<boolean> {
return await getSetupManager().startOnBoarding();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function askAgainForSetupURI(host: MigrationHost): Promise<boolean> {
const message = $msg("moduleMigration.msgRecommendSetupUri", { URI_DOC: $msg("moduleMigration.docUri") });
const USE_MINIMAL = $msg("moduleMigration.optionSetupWizard");
const USE_P2P = $msg("moduleMigration.optionSetupViaP2P");
const USE_SETUP = $msg("moduleMigration.optionManualSetup");
const NEXT = $msg("moduleMigration.optionRemindNextLaunch");
const ret = await host.services.UI.confirm.askSelectStringDialogue(
message,
[USE_MINIMAL, USE_SETUP, USE_P2P, NEXT],
{
title: $msg("moduleMigration.titleRecommendSetupUri"),
defaultAction: USE_MINIMAL,
}
);
if (ret === USE_MINIMAL) {
eventHub.emitEvent(EVENT_REQUEST_OPEN_SETTING_WIZARD);
return false;
}
if (ret === USE_P2P) {
eventHub.emitEvent(EVENT_REQUEST_OPEN_P2P);
return false;
}
if (ret === USE_SETUP) {
eventHub.emitEvent(EVENT_REQUEST_OPEN_SETTINGS);
return false;
} else if (ret == NEXT) {
return false;
}
return false;
}
export async function hasIncompleteDocs(
host: MigrationHost,
log: LogFunction,
force: boolean = false
): Promise<boolean> {
const services = host.services;
const serviceModules = host.serviceModules;
const kvDB = services.keyValueDB.kvDB;
const incompleteDocsChecked = (await kvDB.get<boolean>("checkIncompleteDocs")) || false;
if (incompleteDocsChecked && !force) {
log("Incomplete docs check already done, skipping.", LOG_LEVEL_VERBOSE);
return Promise.resolve(true);
}
log("Checking for incomplete documents...", LOG_LEVEL_NOTICE, "check-incomplete");
const errorFiles = [] as ErrorInfo[];
for await (const metaDoc of services.database.localDatabase.findAllNormalDocs({ conflicts: true })) {
const path = services.path.getPath(metaDoc);
if (!isValidPath(path)) {
continue;
}
if (!(await services.vault.isTargetFile(path))) {
continue;
}
if (!isMetaEntry(metaDoc)) {
continue;
}
const doc = await services.database.localDatabase.getDBEntryFromMeta(metaDoc);
if (!doc || !isLoadedEntry(doc)) {
continue;
}
if (isDeletedEntry(doc)) {
continue;
}
const isConflicted = metaDoc?._conflicts && metaDoc._conflicts.length > 0;
let storageFileContent;
try {
storageFileContent = await serviceModules.storageAccess.readHiddenFileBinary(path);
} catch (e) {
log(`Failed to read file ${path}: Possibly unprocessed or missing`);
log(e, LOG_LEVEL_VERBOSE);
continue;
}
const sizeOnStorage = storageFileContent.byteLength;
const recordedSize = doc.size;
const docBlob = readAsBlob(doc);
const actualSize = docBlob.size;
if (
recordedSize !== actualSize ||
sizeOnStorage !== actualSize ||
sizeOnStorage !== recordedSize ||
isConflicted
) {
const contentMatched = await isDocContentSame(doc.data, storageFileContent);
errorFiles.push({
path,
recordedSize,
actualSize,
storageSize: sizeOnStorage,
contentMatched,
isConflicted,
});
log(
`Size mismatch for ${path}: ${recordedSize} (DB Recorded) , ${actualSize} (DB Stored) , ${sizeOnStorage} (Storage Stored), ${contentMatched ? "Content Matched" : "Content Mismatched"} ${isConflicted ? "Conflicted" : "Not Conflicted"}`
);
}
}
if (errorFiles.length == 0) {
log("No size mismatches found", LOG_LEVEL_NOTICE);
await kvDB.set("checkIncompleteDocs", true);
return Promise.resolve(true);
}
log(`Found ${errorFiles.length} size mismatches`, LOG_LEVEL_NOTICE);
const recoverable = errorFiles.filter((e) => {
return e.recordedSize === e.storageSize && !e.isConflicted;
});
const unrecoverable = errorFiles.filter((e) => {
return e.recordedSize !== e.storageSize || e.isConflicted;
});
const fileInfo = (e: (typeof errorFiles)[0]) => {
return `${e.path} (M: ${e.recordedSize}, A: ${e.actualSize}, S: ${e.storageSize}) ${e.isConflicted ? "(Conflicted)" : ""}`;
};
const messageUnrecoverable =
unrecoverable.length > 0
? $msg("moduleMigration.fix0256.messageUnrecoverable", {
filesNotRecoverable: unrecoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
})
: "";
const message = $msg("moduleMigration.fix0256.message", {
files: recoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
messageUnrecoverable,
});
const CHECK_IT_LATER = $msg("moduleMigration.fix0256.buttons.checkItLater");
const FIX = $msg("moduleMigration.fix0256.buttons.fix");
const DISMISS = $msg("moduleMigration.fix0256.buttons.DismissForever");
const ret = await services.UI.confirm.askSelectStringDialogue(message, [CHECK_IT_LATER, FIX, DISMISS], {
title: $msg("moduleMigration.fix0256.title"),
defaultAction: CHECK_IT_LATER,
});
if (ret == FIX) {
for (const file of recoverable) {
const stubFile = await serviceModules.storageAccess.getFileStub(file.path);
if (stubFile == null) {
log(`Could not find stub file for ${file.path}`, LOG_LEVEL_NOTICE);
continue;
}
stubFile.stat.mtime = Date.now();
const result = await serviceModules.fileHandler.storeFileToDB(stubFile, true, false);
if (result) {
log(`Successfully restored ${file.path} from storage`);
} else {
log(`Failed to restore ${file.path} from storage`, LOG_LEVEL_NOTICE);
}
}
} else if (ret === DISMISS) {
await kvDB.set("checkIncompleteDocs", true);
}
return Promise.resolve(true);
}
export async function hasCompromisedChunks(host: MigrationHost, log: LogFunction): Promise<boolean> {
const services = host.services;
log(`Checking for compromised chunks...`, LOG_LEVEL_VERBOSE);
if (!services.setting.settings.encrypt) {
return true;
}
const localCompromised = await countCompromisedChunks(services.database.localDatabase.localDatabase);
const remote = services.replicator.getActiveReplicator();
const remoteCompromised = services.API.isOnline ? await remote?.countCompromisedChunks() : 0;
if (localCompromised === false) {
log(`Failed to count compromised chunks in local database`, LOG_LEVEL_NOTICE);
return false;
}
if (remoteCompromised === false) {
log(`Failed to count compromised chunks in remote database`, LOG_LEVEL_NOTICE);
return false;
}
if (remoteCompromised === 0 && localCompromised === 0) {
return true;
}
log(`Found compromised chunks : ${localCompromised} in local, ${remoteCompromised} in remote`, LOG_LEVEL_NOTICE);
const title = $msg("moduleMigration.insecureChunkExist.title");
const msg = $msg("moduleMigration.insecureChunkExist.message");
const REBUILD = $msg("moduleMigration.insecureChunkExist.buttons.rebuild");
const FETCH = $msg("moduleMigration.insecureChunkExist.buttons.fetch");
const DISMISS = $msg("moduleMigration.insecureChunkExist.buttons.later");
const buttons = [REBUILD, FETCH, DISMISS];
if (remoteCompromised != 0) {
buttons.splice(buttons.indexOf(FETCH), 1);
}
const result = await services.UI.confirm.askSelectStringDialogue(msg, buttons, {
title,
defaultAction: DISMISS,
timeout: 0,
});
if (result === REBUILD) {
await host.serviceModules.rebuilder.scheduleRebuild();
services.appLifecycle.performRestart();
return false;
} else if (result === FETCH) {
await host.serviceModules.rebuilder.scheduleFetch();
services.appLifecycle.performRestart();
return false;
} else {
log($msg("moduleMigration.insecureChunkExist.laterMessage"), LOG_LEVEL_NOTICE);
}
return true;
}
export async function runFirstInitialiseMigration(host: MigrationHost, log: LogFunction): Promise<boolean> {
const services = host.services;
if (!services.database.localDatabase.isReady) {
log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE);
return false;
}
if (services.setting.settings.isConfigured) {
if (!(await hasCompromisedChunks(host, log))) {
return false;
}
if (!(await hasIncompleteDocs(host, log))) {
return false;
}
if (!(await migrateUsingDoctor(host, false))) {
return false;
}
await migrateDisableBulkSend(host, log);
}
if (!services.setting.settings.isConfigured) {
if (!(await initialMigrationMessage())) {
log($msg("moduleMigration.logSetupCancelled"), LOG_LEVEL_NOTICE);
return false;
}
if (!(await migrateUsingDoctor(host, true))) {
return false;
}
}
return true;
}
export function bindMigrationRequestEvents(host: MigrationHost, log: LogFunction): Promise<boolean> {
eventHub.onEvent(EVENT_REQUEST_RUN_DOCTOR, async (reason) => {
await migrateUsingDoctor(host, false, reason, true);
});
eventHub.onEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE, async () => {
await hasIncompleteDocs(host, log, true);
});
return Promise.resolve(true);
}
@@ -0,0 +1,241 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
performDoctorConsultation: vi.fn(),
countCompromisedChunks: vi.fn(),
startOnBoarding: vi.fn(),
onEvent: vi.fn(),
emitEvent: vi.fn(),
}));
vi.mock("@lib/common/logger.ts", () => ({
LOG_LEVEL_NOTICE: 1,
LOG_LEVEL_VERBOSE: 16,
}));
vi.mock("@/common/events.ts", () => ({
EVENT_REQUEST_OPEN_P2P: "request-open-p2p",
EVENT_REQUEST_OPEN_SETTING_WIZARD: "request-open-setting-wizard",
EVENT_REQUEST_OPEN_SETTINGS: "request-open-settings",
EVENT_REQUEST_RUN_DOCTOR: "request-run-doctor",
EVENT_REQUEST_RUN_FIX_INCOMPLETE: "request-run-fix-incomplete",
eventHub: {
onEvent: mocks.onEvent,
emitEvent: mocks.emitEvent,
},
}));
vi.mock("@lib/common/i18n.ts", () => ({
$msg: (key: string) => key,
}));
vi.mock("@lib/common/configForDoc.ts", () => ({
RebuildOptions: {
AutomaticAcceptable: 0,
SkipEvenIfRequired: 1,
},
performDoctorConsultation: mocks.performDoctorConsultation,
}));
vi.mock("@lib/pouchdb/negotiation.ts", () => ({
countCompromisedChunks: mocks.countCompromisedChunks,
}));
vi.mock("@/common/utils.ts", () => ({
isValidPath: vi.fn(() => true),
}));
vi.mock("@lib/common/types.ts", () => ({
isMetaEntry: vi.fn(() => true),
}));
vi.mock("@lib/common/utils.ts", () => ({
isDeletedEntry: vi.fn(() => false),
isDocContentSame: vi.fn(async () => true),
isLoadedEntry: vi.fn(() => true),
readAsBlob: vi.fn((doc: any) => ({ size: doc.size })),
}));
vi.mock("@/serviceFeatures/setupManager/index.ts", () => ({
getSetupManager: () => ({
startOnBoarding: mocks.startOnBoarding,
}),
}));
import {
hasCompromisedChunks,
initialMigrationMessage,
migrateDisableBulkSend,
migrateUsingDoctor,
runFirstInitialiseMigration,
} from "./migrationOperations.ts";
function createHost(overrides: any = {}) {
const settings = {
isConfigured: true,
encrypt: false,
sendChunksBulk: false,
sendChunksBulkMaxSize: 100,
...overrides.settings,
};
return {
services: {
API: {
isOnline: true,
},
UI: {
confirm: {
askSelectStringDialogue: vi.fn(),
},
},
appLifecycle: {
performRestart: vi.fn(),
},
setting: {
settings,
applyExternalSettings: vi.fn(async (next: any) => {
Object.assign(settings, next);
}),
},
database: {
localDatabase: {
isReady: true,
localDatabase: {},
},
},
keyValueDB: {
kvDB: {
get: vi.fn(),
set: vi.fn(),
},
},
path: {
getPath: vi.fn((entry: any) => entry.path),
},
vault: {
isTargetFile: vi.fn(async () => true),
},
replicator: {
getActiveReplicator: vi.fn(),
},
...overrides.services,
},
serviceModules: {
rebuilder: {
scheduleRebuild: vi.fn(),
scheduleFetch: vi.fn(),
},
storageAccess: {
readHiddenFileBinary: vi.fn(),
getFileStub: vi.fn(),
},
fileHandler: {
storeFileToDB: vi.fn(),
},
...overrides.serviceModules,
},
} as any;
}
describe("migration operations", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.performDoctorConsultation.mockResolvedValue({
shouldRebuild: false,
shouldRebuildLocal: false,
isModified: false,
settings: {},
});
mocks.startOnBoarding.mockResolvedValue(true);
});
it("schedules a remote rebuild and restarts when doctor requires it", async () => {
const host = createHost();
mocks.performDoctorConsultation.mockResolvedValue({
shouldRebuild: true,
shouldRebuildLocal: false,
isModified: false,
settings: {},
});
const result = await migrateUsingDoctor(host, false);
expect(result).toBe(false);
expect(host.serviceModules.rebuilder.scheduleRebuild).toHaveBeenCalledTimes(1);
expect(host.services.appLifecycle.performRestart).toHaveBeenCalledTimes(1);
});
it("applies modified settings reported by doctor", async () => {
const host = createHost();
mocks.performDoctorConsultation.mockResolvedValue({
shouldRebuild: false,
shouldRebuildLocal: false,
isModified: true,
settings: { isConfigured: true, sendChunksBulk: false },
});
await expect(migrateUsingDoctor(host, true)).resolves.toBe(true);
expect(host.services.setting.applyExternalSettings).toHaveBeenCalledWith(
{ isConfigured: true, sendChunksBulk: false },
true
);
expect(host.serviceModules.rebuilder.scheduleRebuild).not.toHaveBeenCalled();
expect(host.services.appLifecycle.performRestart).not.toHaveBeenCalled();
});
it("disables bulk chunk sending when the migration flag is active", async () => {
const host = createHost({ settings: { sendChunksBulk: true, sendChunksBulkMaxSize: 100 } });
const log = vi.fn();
await migrateDisableBulkSend(host, log);
expect(log).toHaveBeenCalledWith(expect.any(String), 1);
expect(host.services.setting.applyExternalSettings).toHaveBeenCalledWith(
expect.objectContaining({ sendChunksBulk: false, sendChunksBulkMaxSize: 1 }),
true
);
});
it("skips compromised chunk checks when encryption is disabled", async () => {
const host = createHost({ settings: { encrypt: false } });
await expect(hasCompromisedChunks(host, vi.fn())).resolves.toBe(true);
expect(mocks.countCompromisedChunks).not.toHaveBeenCalled();
});
it("stops first initialisation when the local database is not ready", async () => {
const host = createHost({
services: {
database: {
localDatabase: {
isReady: false,
localDatabase: {},
},
},
},
});
const log = vi.fn();
await expect(runFirstInitialiseMigration(host, log)).resolves.toBe(false);
expect(log).toHaveBeenCalledWith(expect.any(String), 1);
expect(mocks.performDoctorConsultation).not.toHaveBeenCalled();
});
it("starts onboarding for an unconfigured vault", async () => {
const host = createHost({ settings: { isConfigured: false } });
await expect(runFirstInitialiseMigration(host, vi.fn())).resolves.toBe(true);
expect(mocks.startOnBoarding).toHaveBeenCalledTimes(1);
expect(mocks.performDoctorConsultation).toHaveBeenCalledTimes(1);
});
it("delegates the initial migration message to setup manager", async () => {
await expect(initialMigrationMessage()).resolves.toBe(true);
expect(mocks.startOnBoarding).toHaveBeenCalledTimes(1);
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { NecessaryObsidianServices } from "@/types.ts";
export type MigrationServices =
| "API"
| "appLifecycle"
| "setting"
| "database"
| "path"
| "vault"
| "replicator"
| "UI"
| "keyValueDB";
export type MigrationModules = "storageAccess" | "fileHandler" | "rebuilder";
export type MigrationHost = NecessaryObsidianServices<MigrationServices, MigrationModules>;
+23
View File
@@ -39,8 +39,21 @@ export const createMockServiceHub = () => {
localDatabase: {
localDatabase: {},
tryAutoMerge: vi.fn(),
onNewLeaf: vi.fn(),
getDBEntryFromMeta: vi.fn(),
getRaw: vi.fn(),
clearCaches: vi.fn(),
},
},
keyValueDB: {
kvDB: {
get: vi.fn(),
set: vi.fn(),
},
},
path: {
getPath: vi.fn((entry) => entry.path ?? entry._id),
},
setting: {
settings,
currentSettings: vi.fn(() => settings),
@@ -51,9 +64,15 @@ export const createMockServiceHub = () => {
replicate: createEventMock(),
checkConnectionFailure: createEventMock(),
parseSynchroniseResult: createEventMock(),
processSynchroniseResult: createEventMock(),
processOptionalSynchroniseResult: createEventMock(),
processVirtualDocument: createEventMock(),
onBeforeReplicate: createEventMock(),
onReplicationFailed: createEventMock(),
replicateByEvent: vi.fn(),
databaseQueueCount: { value: 0 },
storageApplyingCount: { value: 0 },
replicationResultCount: { value: 0 },
},
conflict: {
queueCheckForIfOpen: createEventMock(),
@@ -83,9 +102,13 @@ export const createMockServiceHub = () => {
setInterval: vi.fn((fn, interval) => 123),
clearInterval: vi.fn(),
addCommand: vi.fn(),
addLog: vi.fn(),
},
vault: {
getActiveFilePath: vi.fn(),
isTargetFile: vi.fn().mockResolvedValue(true),
isFileSizeTooLarge: vi.fn().mockReturnValue(false),
isValidPath: vi.fn().mockReturnValue(true),
},
},
};
@@ -12,8 +12,8 @@ import type { LogFunction } from "@lib/services/lib/logUtils";
* @param id - Optional CouchDB document identifier.
*/
export function showHistory(host: DocumentHistoryHost, file: TFile | FilePathWithPrefix, id?: DocumentID): void {
const app = (host as any).app;
const plugin = (host as any).plugin;
const app = host.context.app;
const plugin = host.context.liveSyncPlugin;
new DocumentHistoryModal(app, host as any, plugin, file, id).open();
}
@@ -1,4 +1,4 @@
import { createServiceFeature } from "@lib/interfaces/ServiceModule.ts";
import { createObsidianServiceFeature } from "@/types.ts";
import { createInstanceLogFunction } from "@lib/services/lib/logUtils.ts";
import { eventHub } from "@/common/events.ts";
import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts";
@@ -10,34 +10,37 @@ import { showHistory, fileHistory } from "./historyOperations.ts";
* A service feature hook that initialises and manages Obsidian Document History commands.
* Registers ribbon commands and listens to history request events.
*/
export const useObsidianDocumentHistory = createServiceFeature<DocumentHistoryServices, DocumentHistoryModules, void>(
(host) => {
const log = createInstanceLogFunction("ObsidianDocumentHistory", host.services.API);
export const useObsidianDocumentHistory = createObsidianServiceFeature<
DocumentHistoryServices,
DocumentHistoryModules,
"app" | "liveSyncPlugin",
void
>((host) => {
const log = createInstanceLogFunction("ObsidianDocumentHistory", host.services.API);
const everyOnloadStart = (): Promise<boolean> => {
host.services.API.addCommand({
id: "livesync-history",
name: "Show history",
callback: () => {
const file = host.services.vault.getActiveFilePath();
if (file) showHistory(host, file, undefined);
},
});
const everyOnloadStart = (): Promise<boolean> => {
host.services.API.addCommand({
id: "livesync-history",
name: "Show history",
callback: () => {
const file = host.services.vault.getActiveFilePath();
if (file) showHistory(host, file, undefined);
},
});
host.services.API.addCommand({
id: "livesync-filehistory",
name: "Pick a file to show history",
callback: () => {
fireAndForget(async () => await fileHistory(host, log));
},
});
host.services.API.addCommand({
id: "livesync-filehistory",
name: "Pick a file to show history",
callback: () => {
fireAndForget(async () => await fileHistory(host, log));
},
});
eventHub.onEvent(EVENT_REQUEST_SHOW_HISTORY, ({ file, fileOnDB }: any) => {
showHistory(host, file, fileOnDB._id);
});
return Promise.resolve(true);
};
eventHub.onEvent(EVENT_REQUEST_SHOW_HISTORY, ({ file, fileOnDB }: any) => {
showHistory(host, file, fileOnDB._id);
});
return Promise.resolve(true);
};
host.services.appLifecycle.onInitialise.addHandler(everyOnloadStart);
}
);
host.services.appLifecycle.onInitialise.addHandler(everyOnloadStart);
});
@@ -31,8 +31,10 @@ describe("ObsidianDocumentHistory Operations", () => {
vi.clearAllMocks();
host = {
app: {},
plugin: {},
context: {
app: {},
liveSyncPlugin: {},
},
services: {
database: {
localDatabase: {
@@ -1,4 +1,4 @@
import { type NecessaryServices } from "@lib/interfaces/ServiceModule";
import type { NecessaryObsidianServices } from "@/types.ts";
/**
* Service keys required by the Obsidian document history feature.
@@ -13,4 +13,8 @@ export type DocumentHistoryModules = never;
/**
* The host type representing the injected service container with document history capabilities.
*/
export type DocumentHistoryHost = NecessaryServices<DocumentHistoryServices, DocumentHistoryModules>;
export type DocumentHistoryHost = NecessaryObsidianServices<
DocumentHistoryServices,
DocumentHistoryModules,
"app" | "liveSyncPlugin"
>;
+4 -1
View File
@@ -1,6 +1,6 @@
# Obsidian Events (`obsidianEvents`)
This module manages Obsidian-specific application event bindings, editor save command hijacking, window focus/visibility state transitions, and graceful application reload scheduling. It refactors the monolithic implementation of `ModuleObsidianEvents.ts` into a set of decoupled, side-effect-free, and highly testable functions.
This module manages Obsidian-specific application event bindings, editor save command hijacking, window focus/visibility state transitions, and graceful application reload scheduling. It refactors the monolithic implementation of `ModuleObsidianEvents.ts` into a set of decoupled, dependency-explicit, and highly testable functions.
## Module Structure
@@ -25,16 +25,19 @@ graph TD
## Key Workflows
### Editor Save Hooking
1. Intercepts the standard `editor:save-file` command callback.
2. If `syncOnEditorSave` is enabled, schedules a deferred task to execute `replicateByEvent()`.
3. Calls the original save callback to ensure default file writing behaviour continues.
### Suspend & Resume on Window Visibility
1. Monitors window focus and DOM visibility changes (`visibilitychange`).
2. If the window is hidden and background replication is disabled, invokes `appLifecycle.onSuspending()` to pause active replication feeds.
3. Once the window becomes visible and gains focus, dispatches `onResuming()` and `onResumed()` to re-establish replication channels.
### Stabilised Application Reload
1. Instead of reloading the plug-in immediately, monitors active processing counters.
2. Combines queue counts for DB transactions, remote replications, chunk transfers, and conflict resolution processors.
3. Triggers the reload once the combined active task count stabilises at 0 for a given timeout, preventing database corruption.
@@ -85,7 +85,7 @@ export function scheduleAppReload(host: ObsidianEventsHost, log: LogFunction, st
);
});
const plugin = (host as any).plugin;
const plugin = host.context.plugin;
const intervalId = compatGlobal.setInterval(() => {
tick.value++;
}, 1000);
@@ -0,0 +1,75 @@
import { EVENT_FILE_RENAMED, EVENT_LEAF_ACTIVE_CHANGED, eventHub } from "@/common/events.ts";
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
import type { FilePathWithPrefix } from "@lib/common/types.ts";
import type { LogFunction } from "@lib/services/lib/logUtils.ts";
import { askReload, isReloadingScheduled, scheduleAppReload } from "./appReload.ts";
import { swapSaveCommand } from "./saveCommandHack.ts";
import type { ObsidianEventsState } from "./state.ts";
import type { ObsidianEventsHost } from "./types.ts";
import { setHasFocus, watchOnline, watchWindowVisibility, watchWorkspaceOpen } from "./windowVisibility.ts";
export function registerVaultAndWorkspaceEvents(host: ObsidianEventsHost): Promise<boolean> {
const plugin = host.context.plugin;
const app = host.context.app;
if (plugin && app) {
plugin.registerEvent(
app.vault.on("rename", (file: any, oldPath: string) => {
eventHub.emitEvent(EVENT_FILE_RENAMED, {
newPath: file.path as FilePathWithPrefix,
old: oldPath as FilePathWithPrefix,
});
})
);
plugin.registerEvent(
app.workspace.on("active-leaf-change", () => eventHub.emitEvent(EVENT_LEAF_ACTIVE_CHANGED))
);
}
return Promise.resolve(true);
}
export function registerWindowWatchEvents(
host: ObsidianEventsHost,
log: LogFunction,
state: ObsidianEventsState
): void {
const plugin = host.context.plugin;
const app = host.context.app;
if (plugin && app) {
const currentDoc = typeof activeDocument !== "undefined" ? activeDocument : (compatGlobal as any).document;
plugin.registerEvent(app.workspace.on("file-open", (file: any) => watchWorkspaceOpen(host, log, file)));
if (currentDoc) {
plugin.registerDomEvent(currentDoc, "visibilitychange", () => watchWindowVisibility(host, log, state));
}
plugin.registerDomEvent(compatGlobal, "focus", () => setHasFocus(host, log, state, true));
plugin.registerDomEvent(compatGlobal, "blur", () => setHasFocus(host, log, state, false));
plugin.registerDomEvent(compatGlobal, "online", () => watchOnline(host, log));
plugin.registerDomEvent(compatGlobal, "offline", () => watchOnline(host, log));
}
}
export function onObsidianEventsLayoutReady(
host: ObsidianEventsHost,
log: LogFunction,
state: ObsidianEventsState
): Promise<boolean> {
swapSaveCommand(host, log, state);
registerWindowWatchEvents(host, log, state);
return Promise.resolve(true);
}
export function bindObsidianEventsLifecycle(
host: ObsidianEventsHost,
log: LogFunction,
state: ObsidianEventsState
): void {
host.services.appLifecycle.onLayoutReady.addHandler(() => onObsidianEventsLayoutReady(host, log, state));
host.services.appLifecycle.onInitialise.addHandler(() => registerVaultAndWorkspaceEvents(host));
host.services.appLifecycle.askRestart.setHandler((message?: string) => askReload(host, log, message));
host.services.appLifecycle.scheduleRestart.setHandler(() => scheduleAppReload(host, log, state));
host.services.appLifecycle.isReloadingScheduled.setHandler(() => isReloadingScheduled(state));
}
+9 -58
View File
@@ -1,70 +1,21 @@
import { createServiceFeature } from "@lib/interfaces/ServiceModule";
import { createObsidianServiceFeature } from "@/types.ts";
import { createInstanceLogFunction } from "@lib/services/lib/logUtils.ts";
import { EVENT_FILE_RENAMED, EVENT_LEAF_ACTIVE_CHANGED, eventHub } from "@/common/events.ts";
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
import type { FilePathWithPrefix } from "@lib/common/types.ts";
import type { ObsidianEventsServices, ObsidianEventsModules } from "./types.ts";
import { createObsidianEventsState } from "./state.ts";
import { askReload, scheduleAppReload, isReloadingScheduled } from "./appReload.ts";
import { swapSaveCommand } from "./saveCommandHack.ts";
import { watchWindowVisibility, watchOnline, watchWorkspaceOpen, setHasFocus } from "./windowVisibility.ts";
import { bindObsidianEventsLifecycle } from "./eventBindings.ts";
/**
* A service feature hook that initialises and manages Obsidian application event bindings.
* This hooks into vault file changes, window focus, visibility states, and schedules restarts.
*/
export const useObsidianEvents = createServiceFeature<ObsidianEventsServices, ObsidianEventsModules, void>((host) => {
export const useObsidianEvents = createObsidianServiceFeature<
ObsidianEventsServices,
ObsidianEventsModules,
"app" | "plugin",
void
>((host) => {
const log = createInstanceLogFunction("ObsidianEvents", host.services.API);
const state = createObsidianEventsState();
const plugin = (host as any).plugin;
const app = (host as any).app;
const everyOnloadStart = (): Promise<boolean> => {
if (plugin && app) {
plugin.registerEvent(
app.vault.on("rename", (file: any, oldPath: string) => {
eventHub.emitEvent(EVENT_FILE_RENAMED, {
newPath: file.path as FilePathWithPrefix,
old: oldPath as FilePathWithPrefix,
});
})
);
plugin.registerEvent(
app.workspace.on("active-leaf-change", () => eventHub.emitEvent(EVENT_LEAF_ACTIVE_CHANGED))
);
}
return Promise.resolve(true);
};
const registerWatchEvents = (): void => {
if (plugin && app) {
const currentDoc = typeof activeDocument !== "undefined" ? activeDocument : (compatGlobal as any).document;
plugin.registerEvent(app.workspace.on("file-open", (file: any) => watchWorkspaceOpen(host, log, file)));
if (currentDoc) {
plugin.registerDomEvent(currentDoc, "visibilitychange", () => watchWindowVisibility(host, log, state));
}
plugin.registerDomEvent(compatGlobal, "focus", () => setHasFocus(host, log, state, true));
plugin.registerDomEvent(compatGlobal, "blur", () => setHasFocus(host, log, state, false));
plugin.registerDomEvent(compatGlobal, "online", () => watchOnline(host, log));
plugin.registerDomEvent(compatGlobal, "offline", () => watchOnline(host, log));
}
};
const everyOnLayoutReady = (): Promise<boolean> => {
swapSaveCommand(host, log, state);
registerWatchEvents();
return Promise.resolve(true);
};
// Bind event handlers onto the appLifecycle service
host.services.appLifecycle.onLayoutReady.addHandler(everyOnLayoutReady);
host.services.appLifecycle.onInitialise.addHandler(everyOnloadStart);
(host.services.appLifecycle as any).askRestart.setHandler((message?: string) => askReload(host, log, message));
(host.services.appLifecycle as any).scheduleRestart.setHandler(() => scheduleAppReload(host, log, state));
(host.services.appLifecycle as any).isReloadingScheduled.setHandler(() => isReloadingScheduled(state));
bindObsidianEventsLifecycle(host, log, state);
});
@@ -208,8 +208,10 @@ describe("useObsidianEvents Feature Hook", () => {
},
appLifecycle,
},
plugin,
app,
context: {
plugin,
app,
},
} as any;
useObsidianEvents(host);
@@ -356,8 +358,10 @@ describe("scheduleAppReload and isReloadingScheduled", () => {
performRestart,
},
},
plugin: {
registerInterval: vi.fn(),
context: {
plugin: {
registerInterval: vi.fn(),
},
},
} as any;
@@ -368,7 +372,7 @@ describe("scheduleAppReload and isReloadingScheduled", () => {
scheduleAppReload(host, log, state);
expect(isReloadingScheduled(state)).toBe(true);
expect(host.plugin.registerInterval).toHaveBeenCalled();
expect(host.context.plugin.registerInterval).toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(log).toHaveBeenCalledWith(
@@ -415,8 +419,10 @@ describe("scheduleAppReload and isReloadingScheduled", () => {
performRestart,
},
},
plugin: {
registerInterval: vi.fn(),
context: {
plugin: {
registerInterval: vi.fn(),
},
},
} as any;
@@ -455,12 +461,14 @@ describe("swapSaveCommand", () => {
};
const replicateByEvent = vi.fn(async () => {});
const host = {
app: {
commands: {
context: {
app: {
commands: {
"editor:save-file": saveCommandDefinition,
commands: {
"editor:save-file": saveCommandDefinition,
},
executeCommandById: vi.fn(),
},
executeCommandById: vi.fn(),
},
},
services: {
@@ -499,10 +507,12 @@ describe("swapSaveCommand", () => {
callback: originalCallback,
};
const host = {
app: {
commands: {
context: {
app: {
commands: {
"editor:save-file": saveCommandDefinition,
commands: {
"editor:save-file": saveCommandDefinition,
},
},
},
},
@@ -15,7 +15,7 @@ import type { ObsidianEventsState } from "./state.ts";
*/
export function swapSaveCommand(host: ObsidianEventsHost, log: LogFunction, state: ObsidianEventsState): void {
log("Modifying callback of the save command", LOG_LEVEL_VERBOSE);
const appAny = (host as any).app;
const appAny = host.context.app as any;
const saveCommandDefinition = appAny?.commands?.commands?.["editor:save-file"];
const save = saveCommandDefinition?.callback;
if (typeof save === "function") {
+6 -2
View File
@@ -1,4 +1,4 @@
import { type NecessaryServices } from "@lib/interfaces/ServiceModule";
import type { NecessaryObsidianServices } from "@/types.ts";
/**
* A union of service keys required by the Obsidian events management feature.
@@ -23,4 +23,8 @@ export type ObsidianEventsModules = never;
/**
* The host type representing the injected service container with Obsidian events capabilities.
*/
export type ObsidianEventsHost = NecessaryServices<ObsidianEventsServices, ObsidianEventsModules>;
export type ObsidianEventsHost = NecessaryObsidianServices<
ObsidianEventsServices,
ObsidianEventsModules,
"app" | "plugin"
>;
@@ -1,4 +1,4 @@
import { createServiceFeature } from "@lib/interfaces/ServiceModule.ts";
import { createObsidianServiceFeature } from "@/types.ts";
import { ObsidianLiveSyncSettingTab } from "@/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts";
import { EVENT_REQUEST_OPEN_SETTING_WIZARD, EVENT_REQUEST_OPEN_SETTINGS, eventHub } from "@/common/events.ts";
import type { SettingDialogueServices, SettingDialogueModules } from "./types.ts";
@@ -8,25 +8,28 @@ import { openSetting, openSettingWizard } from "./settingOperations.ts";
/**
* A service feature hook that registers the plug-in setting tab and listens to settings dialogue triggers.
*/
export const useObsidianSettingDialogue = createServiceFeature<SettingDialogueServices, SettingDialogueModules, void>(
(host) => {
const state = createInitialState();
export const useObsidianSettingDialogue = createObsidianServiceFeature<
SettingDialogueServices,
SettingDialogueModules,
"app" | "liveSyncPlugin",
void
>((host) => {
const state = createInitialState();
const everyOnloadStart = (): Promise<boolean> => {
const app = (host as any).app;
const plugin = (host as any).plugin;
const everyOnloadStart = (): Promise<boolean> => {
const app = host.context.app;
const plugin = host.context.liveSyncPlugin;
state.settingTab = new ObsidianLiveSyncSettingTab(app, plugin);
plugin.addSettingTab(state.settingTab);
state.settingTab = new ObsidianLiveSyncSettingTab(app, plugin);
plugin.addSettingTab(state.settingTab);
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => openSetting(host));
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTING_WIZARD, () => {
void openSettingWizard(host, state);
});
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => openSetting(host));
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTING_WIZARD, () => {
void openSettingWizard(host, state);
});
return Promise.resolve(true);
};
return Promise.resolve(true);
};
host.services.appLifecycle.onInitialise.addHandler(everyOnloadStart);
}
);
host.services.appLifecycle.onInitialise.addHandler(everyOnloadStart);
});
@@ -29,11 +29,14 @@ describe("ObsidianSettingDialogue Operations", () => {
vi.clearAllMocks();
host = {
app: {
setting: {
open: mockOpen,
openTabById: mockOpenTabById,
context: {
app: {
setting: {
open: mockOpen,
openTabById: mockOpenTabById,
},
},
liveSyncPlugin: {},
},
} as unknown as SettingDialogueHost;
});
@@ -7,8 +7,8 @@ import type { SettingDialogueState } from "./state.ts";
* @param host - The service feature host context.
*/
export function openSetting(host: SettingDialogueHost): void {
const app = (host as any).app;
if (app && app.setting) {
const app = host.context.app as any;
if (app?.setting) {
try {
app.setting.open();
app.setting.openTabById("obsidian-livesync");
@@ -1,4 +1,4 @@
import { type NecessaryServices } from "@lib/interfaces/ServiceModule";
import type { NecessaryObsidianServices } from "@/types.ts";
/**
* Service keys required by the Obsidian setting tab dialogue feature.
@@ -13,4 +13,8 @@ export type SettingDialogueModules = never;
/**
* The host type representing the injected service container with setting tab capabilities.
*/
export type SettingDialogueHost = NecessaryServices<SettingDialogueServices, SettingDialogueModules>;
export type SettingDialogueHost = NecessaryObsidianServices<
SettingDialogueServices,
SettingDialogueModules,
"app" | "liveSyncPlugin"
>;
@@ -1,485 +0,0 @@
import {
SYNCINFO_ID,
VER,
type AnyEntry,
type EntryDoc,
type EntryLeaf,
type LoadedEntry,
type MetaEntry,
} from "@lib/common/types";
import { isChunk } from "@lib/common/typeUtils";
import {
LOG_LEVEL_DEBUG,
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
Logger,
type LOG_LEVEL,
} from "@lib/common/logger";
import { fireAndForget, isAnyNote, throttle } from "@lib/common/utils";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore_v2";
import { serialized } from "octagonal-wheels/concurrency/lock";
import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { isNotFoundError } from "@lib/common/utils.doc";
const KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT = "replicationResultProcessorSnapshot";
type ReplicateResultProcessorState = {
queued: PouchDB.Core.ExistingDocument<EntryDoc>[];
processing: PouchDB.Core.ExistingDocument<EntryDoc>[];
};
function shortenId(id: string): string {
return id.length > 10 ? id.substring(0, 10) : id;
}
function shortenRev(rev: string | undefined): string {
if (!rev) return "undefined";
return rev.length > 10 ? rev.substring(0, 10) : rev;
}
export class ReplicateResultProcessor {
private log(message: string, level: LOG_LEVEL = LOG_LEVEL_INFO) {
Logger(`[ReplicateResultProcessor] ${message}`, level);
}
private logError(e: unknown) {
Logger(e, LOG_LEVEL_VERBOSE);
}
private _core: LiveSyncBaseCore;
constructor(core: LiveSyncBaseCore) {
this._core = core;
}
get localDatabase() {
return this._core.localDatabase;
}
get services() {
return this._core.services;
}
get core() {
return this._core;
}
getPath(entry: AnyEntry): string {
return this.services.path.getPath(entry);
}
public suspend() {
this._suspended = true;
}
public resume() {
this._suspended = false;
fireAndForget(() => this.runProcessQueue());
}
// Whether the processing is suspended
// If true, the processing queue processor bails the loop.
private _suspended: boolean = false;
public get isSuspended() {
return (
this._suspended ||
!this.services.appLifecycle.isReady() ||
this.services.setting.settings.suspendParseReplicationResult ||
this.services.appLifecycle.isSuspended()
);
}
/**
* Take a snapshot of the current processing state.
* This snapshot is stored in the KV database for recovery on restart.
*/
protected async _takeSnapshot() {
const snapshot = {
queued: this._queuedChanges.slice(),
processing: this._processingChanges.slice(),
} satisfies ReplicateResultProcessorState;
await this.core.kvDB.set(KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT, snapshot);
this.log(
`Snapshot taken. Queued: ${snapshot.queued.length}, Processing: ${snapshot.processing.length}`,
LOG_LEVEL_DEBUG
);
this.reportStatus();
}
/**
* Trigger taking a snapshot.
*/
protected _triggerTakeSnapshot() {
fireAndForget(() => this._takeSnapshot());
}
/**
* Throttled version of triggerTakeSnapshot.
*/
protected triggerTakeSnapshot = throttle(() => this._triggerTakeSnapshot(), 50);
/**
* Restore from snapshot.
*/
public async restoreFromSnapshot() {
const snapshot = await this.core.kvDB.get<ReplicateResultProcessorState>(
KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT
);
if (snapshot) {
// Restoring the snapshot re-runs processing for both queued and processing items.
const newQueue = [...snapshot.processing, ...snapshot.queued, ...this._queuedChanges];
this._queuedChanges = [];
this.enqueueAll(newQueue);
this.log(
`Restored from snapshot (${snapshot.processing.length + snapshot.queued.length} items)`,
LOG_LEVEL_INFO
);
// await this._takeSnapshot();
}
}
private _restoreFromSnapshot: Promise<void> | undefined = undefined;
/**
* Restore from snapshot only once.
* @returns Promise that resolves when restoration is complete.
*/
public restoreFromSnapshotOnce() {
if (!this._restoreFromSnapshot) {
this._restoreFromSnapshot = this.restoreFromSnapshot();
}
return this._restoreFromSnapshot;
}
/**
* Perform the given procedure while counting the concurrency.
* @param proc async procedure to perform
* @param countValue reactive source to count concurrency
* @returns result of the procedure
*/
async withCounting<T>(proc: () => Promise<T>, countValue: ReactiveSource<number>) {
countValue.value++;
try {
return await proc();
} finally {
countValue.value--;
}
}
/**
* Report the current status.
*/
protected reportStatus() {
this.services.replication.replicationResultCount.value =
this._queuedChanges.length + this._processingChanges.length;
}
/**
* Enqueue all the given changes for processing.
* @param changes Changes to enqueue
*/
public enqueueAll(changes: PouchDB.Core.ExistingDocument<EntryDoc>[]) {
for (const change of changes) {
// Check if the change is not a document change (e.g., chunk, versioninfo, syncinfo), and processed it directly.
const isProcessed = this.processIfNonDocumentChange(change);
if (!isProcessed) {
this.enqueueChange(change);
}
}
}
/**
* Process the change if it is not a document change.
* @param change Change to process
* @returns True if the change was processed; false otherwise
*/
protected processIfNonDocumentChange(change: PouchDB.Core.ExistingDocument<EntryDoc>) {
if (!change) {
this.log(`Received empty change`, LOG_LEVEL_VERBOSE);
return true;
}
if (isChunk(change._id)) {
// Emit event for new chunk
this.localDatabase.onNewLeaf(change as EntryLeaf);
this.log(`Processed chunk: ${shortenId(change._id)}`, LOG_LEVEL_DEBUG);
return true;
}
if (change.type == "versioninfo") {
this.log(`Version info document received: ${change._id}`, LOG_LEVEL_VERBOSE);
if (change.version > VER) {
// Incompatible version, stop replication.
this.core.replicator.closeReplication();
this.log(
`Remote database updated to incompatible version. update your Self-hosted LiveSync plugin.`,
LOG_LEVEL_NOTICE
);
}
return true;
}
if (
change._id == SYNCINFO_ID || // Synchronisation information data
change._id.startsWith("_design") //design document
) {
this.log(`Skipped system document: ${change._id}`, LOG_LEVEL_VERBOSE);
return true;
}
return false;
}
/**
* Queue of changes to be processed.
*/
private _queuedChanges: PouchDB.Core.ExistingDocument<EntryDoc>[] = [];
/**
* List of changes being processed.
*/
private _processingChanges: PouchDB.Core.ExistingDocument<EntryDoc>[] = [];
/**
* Enqueue the given document change for processing.
* @param doc Document change to enqueue
* @returns
*/
protected enqueueChange(doc: PouchDB.Core.ExistingDocument<EntryDoc>) {
const old = this._queuedChanges.find((e) => e._id == doc._id);
const path = "path" in doc ? this.getPath(doc) : "<unknown>";
const docNote = `${path} (${shortenId(doc._id)}, ${shortenRev(doc._rev)})`;
if (old) {
if (old._rev == doc._rev) {
this.log(`[Enqueue] skipped (Already queued): ${docNote}`, LOG_LEVEL_VERBOSE);
return;
}
const oldRev = old._rev ?? "";
const isDeletedBefore = old._deleted === true || ("deleted" in old && old.deleted === true);
const isDeletedNow = doc._deleted === true || ("deleted" in doc && doc.deleted === true);
// Replace the old queued change (This may performed batched updates, actually process performed always with the latest version, hence we can simply replace it if the change is the same type).
if (isDeletedBefore === isDeletedNow) {
this._queuedChanges = this._queuedChanges.filter((e) => e._id != doc._id);
this.log(`[Enqueue] requeued: ${docNote} (from rev: ${shortenRev(oldRev)})`, LOG_LEVEL_VERBOSE);
}
}
// Enqueue the change
this._queuedChanges.push(doc);
this.triggerTakeSnapshot();
this.triggerProcessQueue();
}
/**
* Trigger processing of the queued changes.
*/
protected triggerProcessQueue() {
fireAndForget(() => this.runProcessQueue());
}
/**
* Semaphore to limit concurrent processing.
* This is the per-id semaphore + concurrency-control (max 10 concurrent = 10 documents being processed at the same time).
*/
private _semaphore = Semaphore(10);
/**
* Flag indicating whether the process queue is currently running.
*/
private _isRunningProcessQueue: boolean = false;
/**
* Process the queued changes.
*/
private async runProcessQueue() {
// Avoid re-entrance, suspend processing, or empty queue loop consumption.
if (this._isRunningProcessQueue) return;
if (this.isSuspended) return;
if (this._queuedChanges.length == 0) return;
try {
this._isRunningProcessQueue = true;
while (this._queuedChanges.length > 0) {
// If getting suspended, bail the loop. Some concurrent tasks may still be running.
if (this.isSuspended) {
this.log(
`Processing has got suspended. Remaining items in queue: ${this._queuedChanges.length}`,
LOG_LEVEL_INFO
);
break;
}
// Acquire semaphore for new processing slot
// (per-document serialisation caps concurrency).
const releaser = await this._semaphore.acquire();
releaser();
// Dequeue the next change
const doc = this._queuedChanges.shift();
if (doc) {
this._processingChanges.push(doc);
void this.parseDocumentChange(doc);
}
// Take snapshot (to be restored on next startup if needed)
this.triggerTakeSnapshot();
}
} finally {
this._isRunningProcessQueue = false;
}
}
// Phase 1: parse replication result
/**
* Parse the given document change.
* @param change
* @returns
*/
async parseDocumentChange(change: PouchDB.Core.ExistingDocument<EntryDoc>) {
try {
if (isAnyNote(change)) {
const docMtime = change.mtime ?? 0;
const maxMTime = this.core.settings.maxMTimeForReflectEvents;
if (maxMTime > 0 && docMtime > maxMTime) {
const docPath = this.getPath(change);
this.log(
`Processing ${docPath} has been skipped due to modification time (${new Date(
docMtime * 1000
).toISOString()}) exceeding the limit`,
LOG_LEVEL_INFO
);
return;
}
}
// If the document is a virtual document, process it in the virtual document processor.
if (await this.services.replication.processVirtualDocument(change)) return;
// If the document is version info, check compatibility and return.
if (isAnyNote(change)) {
const docPath = this.getPath(change);
if (!(await this.services.vault.isTargetFile(docPath))) {
this.log(`Skipped: ${docPath}`, LOG_LEVEL_VERBOSE);
return;
}
const size = change.size;
// Note that this size check depends size that in metadata, not the actual content size.
if (this.services.vault.isFileSizeTooLarge(size)) {
this.log(
`Processing ${docPath} has been skipped due to file size exceeding the limit`,
LOG_LEVEL_NOTICE
);
return;
}
return await this.applyToDatabase(change);
}
this.log(`Skipped unexpected non-note document: ${change._id}`, LOG_LEVEL_INFO);
return;
} finally {
// Remove from processing queue
this._processingChanges = this._processingChanges.filter((e) => e !== change);
this.triggerTakeSnapshot();
}
}
// Phase 2: apply the document to database
protected applyToDatabase(doc: PouchDB.Core.ExistingDocument<AnyEntry>) {
return this.withCounting(async () => {
let releaser: Awaited<ReturnType<typeof this._semaphore.acquire>> | undefined = undefined;
try {
releaser = await this._semaphore.acquire();
await this._applyToDatabase(doc);
} catch (e) {
this.log(`Error while processing replication result`, LOG_LEVEL_NOTICE);
this.logError(e);
} finally {
// Remove from processing queue (To remove from "in-progress" list, and snapshot will not include it)
if (releaser) {
releaser();
}
}
}, this.services.replication.databaseQueueCount);
}
// Phase 2.1: process the document and apply to storage
// This function is serialized per document to avoid race-condition for the same document.
private _applyToDatabase(doc_: PouchDB.Core.ExistingDocument<AnyEntry>) {
const dbDoc = doc_ as LoadedEntry; // It has no `data`
const path = this.getPath(dbDoc);
return serialized(`replication-process:${dbDoc._id}`, async () => {
const docNote = `${path} (${shortenId(dbDoc._id)}, ${shortenRev(dbDoc._rev)})`;
const isRequired = await this.checkIsChangeRequiredForDatabaseProcessing(dbDoc);
if (!isRequired) {
this.log(`Skipped (Not latest): ${docNote}`, LOG_LEVEL_VERBOSE);
return;
}
// If `Read chunks online` is disabled, chunks should be transferred before here.
// However, in some cases, chunks are after that. So, if missing chunks exist, we have to wait for them.
// (If `Use Only Local Chunks` is enabled, we should not attempt to fetch chunks online automatically).
const isDeleted = dbDoc._deleted === true || ("deleted" in dbDoc && dbDoc.deleted === true);
// Gather full document if not deleted
const doc = isDeleted
? { ...dbDoc, data: "" }
: await this.localDatabase.getDBEntryFromMeta({ ...dbDoc }, false, true);
if (!doc) {
// Failed to gather content
this.log(`Failed to gather content of ${docNote}`, LOG_LEVEL_NOTICE);
return;
}
// Check if other processor wants to process this document, if so, skip processing here.
if (await this.services.replication.processOptionalSynchroniseResult(dbDoc)) {
// Already processed
this.log(`Processed by other processor: ${docNote}`, LOG_LEVEL_DEBUG);
} else if (this.services.vault.isValidPath(this.getPath(doc))) {
// Apply to storage if the path is valid
await this.applyToStorage(doc as MetaEntry);
this.log(`Processed: ${docNote}`, LOG_LEVEL_DEBUG);
} else {
// Should process, but have an invalid path
this.log(`Unprocessed (Invalid path): ${docNote}`, LOG_LEVEL_VERBOSE);
}
return;
});
}
/**
* Phase 3: Apply the given entry to storage.
* @param entry
* @returns
*/
protected applyToStorage(entry: MetaEntry) {
return this.withCounting(async () => {
await this.services.replication.processSynchroniseResult(entry);
}, this.services.replication.storageApplyingCount);
}
/**
* Check whether processing is required for the given document.
* @param dbDoc Document to check
* @returns True if processing is required; false otherwise
*/
protected async checkIsChangeRequiredForDatabaseProcessing(dbDoc: LoadedEntry): Promise<boolean> {
const path = this.getPath(dbDoc);
try {
const savedDoc = await this.localDatabase.getRaw<LoadedEntry>(dbDoc._id, {
conflicts: true,
revs_info: true,
});
const newRev = dbDoc._rev ?? "";
const latestRev = savedDoc._rev ?? "";
const revisions = savedDoc._revs_info?.map((e) => e.rev) ?? [];
if (savedDoc._conflicts && savedDoc._conflicts.length > 0) {
// There are conflicts, so we have to process it.
// (May auto-resolve or user intervention will be occurred).
return true;
}
if (newRev == latestRev) {
// The latest revision. Simply we can process it.
return true;
}
const index = revisions.indexOf(newRev);
if (index >= 0) {
// The revision has been inserted before.
return false; // This means that the document already processed (While no conflict existed).
}
return true; // This mostly should not happen, but we have to process it just in case.
} catch (e) {
if (isNotFoundError(e)) {
// getRaw failed due to not existing, it may not be happened normally especially on replication.
// If the process caused by some other reason, we **probably** have to process it.
// Note that this is not a common case.
return true;
} else {
this.log(
`Failed to get existing document for ${path} (${shortenId(dbDoc._id)}, ${shortenRev(dbDoc._rev)}) `,
LOG_LEVEL_NOTICE
);
this.logError(e);
return false;
}
}
}
}
@@ -1,27 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
import { createMockServiceHub } from "../mockServiceHub";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
describe("ReplicateResultProcessor", () => {
let mockHub: ReturnType<typeof createMockServiceHub>;
beforeEach(() => {
mockHub = createMockServiceHub();
});
it("should instantiate and bind core correctly", () => {
const processor = new ReplicateResultProcessor(mockHub as any as LiveSyncBaseCore);
expect(processor).toBeDefined();
});
it("should process items and take snapshot", async () => {
const processor = new ReplicateResultProcessor(mockHub as any as LiveSyncBaseCore);
// Mock simple behaviors
(processor as any).enqueue = vi.fn();
(processor as any).enqueue({ id: "test", doc: { _id: "test" } });
expect((processor as any).enqueue).toHaveBeenCalled();
});
});
@@ -0,0 +1,427 @@
import {
SYNCINFO_ID,
VER,
type AnyEntry,
type EntryDoc,
type EntryLeaf,
type LoadedEntry,
type MetaEntry,
} from "@lib/common/types";
import { isChunk } from "@lib/common/typeUtils";
import { LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@lib/common/logger";
import { fireAndForget, isAnyNote, throttle } from "@lib/common/utils";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore_v2";
import { serialized } from "octagonal-wheels/concurrency/lock";
import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
import { isNotFoundError } from "@lib/common/utils.doc";
import type { NecessaryObsidianFeature } from "@/types";
import { createInstanceLogFunction, type LogFunction } from "@lib/services/lib/logUtils";
export const KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT = "replicationResultProcessorSnapshot";
export type ReplicateResultProcessorHost = NecessaryObsidianFeature<
"API" | "appLifecycle" | "database" | "keyValueDB" | "path" | "replication" | "replicator" | "setting" | "vault"
>;
export type ReplicateResultProcessorSnapshot = {
queued: PouchDB.Core.ExistingDocument<EntryDoc>[];
processing: PouchDB.Core.ExistingDocument<EntryDoc>[];
};
export type ReplicateResultProcessorState = {
queuedChanges: PouchDB.Core.ExistingDocument<EntryDoc>[];
processingChanges: PouchDB.Core.ExistingDocument<EntryDoc>[];
suspended: boolean;
restoreFromSnapshot: Promise<void> | undefined;
semaphore: ReturnType<typeof Semaphore>;
isRunningProcessQueue: boolean;
triggerTakeSnapshot: () => void;
};
export type ReplicateResultProcessor = {
suspend: () => void;
resume: () => void;
enqueueAll: (changes: PouchDB.Core.ExistingDocument<EntryDoc>[]) => void;
restoreFromSnapshotOnce: () => Promise<void>;
};
type ReplicateResultProcessorLog = LogFunction;
const noopReplicateResultProcessorLog: ReplicateResultProcessorLog = () => undefined;
function shortenId(id: string): string {
return id.length > 10 ? id.substring(0, 10) : id;
}
function shortenRev(rev: string | undefined): string {
if (!rev) return "undefined";
return rev.length > 10 ? rev.substring(0, 10) : rev;
}
export function createReplicateResultProcessorLog(host: ReplicateResultProcessorHost): ReplicateResultProcessorLog {
return createInstanceLogFunction("ReplicateResultProcessor", host.services.API);
}
export function createReplicateResultProcessorState(
triggerTakeSnapshot: () => void = () => undefined
): ReplicateResultProcessorState {
return {
queuedChanges: [],
processingChanges: [],
suspended: false,
restoreFromSnapshot: undefined,
semaphore: Semaphore(10),
isRunningProcessQueue: false,
triggerTakeSnapshot,
};
}
export function isReplicateResultProcessorSuspended(
host: ReplicateResultProcessorHost,
state: ReplicateResultProcessorState
) {
return (
state.suspended ||
!host.services.appLifecycle.isReady() ||
host.services.setting.settings.suspendParseReplicationResult ||
host.services.appLifecycle.isSuspended()
);
}
export function suspendReplicateResultProcessing(state: ReplicateResultProcessorState) {
state.suspended = true;
}
export function resumeReplicateResultProcessing(
host: ReplicateResultProcessorHost,
state: ReplicateResultProcessorState,
log: ReplicateResultProcessorLog = noopReplicateResultProcessorLog
) {
state.suspended = false;
fireAndForget(() => runReplicateResultProcessQueue(host, state, log));
}
export async function takeReplicateResultProcessorSnapshot(
host: ReplicateResultProcessorHost,
state: ReplicateResultProcessorState,
log: ReplicateResultProcessorLog = noopReplicateResultProcessorLog
) {
const snapshot = {
queued: state.queuedChanges.slice(),
processing: state.processingChanges.slice(),
} satisfies ReplicateResultProcessorSnapshot;
await host.services.keyValueDB.kvDB.set(KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT, snapshot);
log(
`Snapshot taken. Queued: ${snapshot.queued.length}, Processing: ${snapshot.processing.length}`,
LOG_LEVEL_DEBUG
);
reportReplicateResultProcessorStatus(host, state);
}
export async function restoreReplicateResultProcessorSnapshot(
host: ReplicateResultProcessorHost,
state: ReplicateResultProcessorState,
log: ReplicateResultProcessorLog = noopReplicateResultProcessorLog
) {
const snapshot = await host.services.keyValueDB.kvDB.get<ReplicateResultProcessorSnapshot>(
KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT
);
if (!snapshot) return;
// Restoring the snapshot re-runs processing for both queued and processing items.
const newQueue = [...snapshot.processing, ...snapshot.queued, ...state.queuedChanges];
state.queuedChanges = [];
enqueueAllReplicateResults(host, state, log, newQueue);
log(`Restored from snapshot (${snapshot.processing.length + snapshot.queued.length} items)`, LOG_LEVEL_INFO);
}
export function restoreReplicateResultProcessorSnapshotOnce(
host: ReplicateResultProcessorHost,
state: ReplicateResultProcessorState,
log: ReplicateResultProcessorLog = noopReplicateResultProcessorLog
) {
if (!state.restoreFromSnapshot) {
state.restoreFromSnapshot = restoreReplicateResultProcessorSnapshot(host, state, log);
}
return state.restoreFromSnapshot;
}
export async function withCounting<T>(proc: () => Promise<T>, countValue: ReactiveSource<number>) {
countValue.value++;
try {
return await proc();
} finally {
countValue.value--;
}
}
export function reportReplicateResultProcessorStatus(
host: ReplicateResultProcessorHost,
state: ReplicateResultProcessorState
) {
host.services.replication.replicationResultCount.value =
state.queuedChanges.length + state.processingChanges.length;
}
export function enqueueAllReplicateResults(
host: ReplicateResultProcessorHost,
state: ReplicateResultProcessorState,
log: ReplicateResultProcessorLog,
changes: PouchDB.Core.ExistingDocument<EntryDoc>[]
) {
for (const change of changes) {
const isProcessed = processIfNonDocumentChange(host, log, change);
if (!isProcessed) {
enqueueReplicateResult(host, state, log, change);
}
}
}
export function processIfNonDocumentChange(
host: ReplicateResultProcessorHost,
log: ReplicateResultProcessorLog,
change: PouchDB.Core.ExistingDocument<EntryDoc>
) {
if (!change) {
log(`Received empty change`, LOG_LEVEL_VERBOSE);
return true;
}
if (isChunk(change._id)) {
host.services.database.localDatabase.onNewLeaf(change as EntryLeaf);
log(`Processed chunk: ${shortenId(change._id)}`, LOG_LEVEL_DEBUG);
return true;
}
if (change.type == "versioninfo") {
log(`Version info document received: ${change._id}`, LOG_LEVEL_VERBOSE);
if (change.version > VER) {
host.services.replicator.getActiveReplicator()?.closeReplication();
log(
`Remote database updated to incompatible version. update your Self-hosted LiveSync plugin.`,
LOG_LEVEL_NOTICE
);
}
return true;
}
if (change._id == SYNCINFO_ID || change._id.startsWith("_design")) {
log(`Skipped system document: ${change._id}`, LOG_LEVEL_VERBOSE);
return true;
}
return false;
}
export function enqueueReplicateResult(
host: ReplicateResultProcessorHost,
state: ReplicateResultProcessorState,
log: ReplicateResultProcessorLog,
doc: PouchDB.Core.ExistingDocument<EntryDoc>
) {
const old = state.queuedChanges.find((e) => e._id == doc._id);
const path = "path" in doc ? host.services.path.getPath(doc) : "<unknown>";
const docNote = `${path} (${shortenId(doc._id)}, ${shortenRev(doc._rev)})`;
if (old) {
if (old._rev == doc._rev) {
log(`[Enqueue] skipped (Already queued): ${docNote}`, LOG_LEVEL_VERBOSE);
return;
}
const oldRev = old._rev ?? "";
const isDeletedBefore = old._deleted === true || ("deleted" in old && old.deleted === true);
const isDeletedNow = doc._deleted === true || ("deleted" in doc && doc.deleted === true);
if (isDeletedBefore === isDeletedNow) {
state.queuedChanges = state.queuedChanges.filter((e) => e._id != doc._id);
log(`[Enqueue] requeued: ${docNote} (from rev: ${shortenRev(oldRev)})`, LOG_LEVEL_VERBOSE);
}
}
state.queuedChanges.push(doc);
state.triggerTakeSnapshot();
fireAndForget(() => runReplicateResultProcessQueue(host, state, log));
}
export async function runReplicateResultProcessQueue(
host: ReplicateResultProcessorHost,
state: ReplicateResultProcessorState,
log: ReplicateResultProcessorLog = noopReplicateResultProcessorLog
) {
if (state.isRunningProcessQueue) return;
if (isReplicateResultProcessorSuspended(host, state)) return;
if (state.queuedChanges.length == 0) return;
try {
state.isRunningProcessQueue = true;
while (state.queuedChanges.length > 0) {
if (isReplicateResultProcessorSuspended(host, state)) {
log(
`Processing has got suspended. Remaining items in queue: ${state.queuedChanges.length}`,
LOG_LEVEL_INFO
);
break;
}
const releaser = await state.semaphore.acquire();
releaser();
const doc = state.queuedChanges.shift();
if (doc) {
state.processingChanges.push(doc);
void parseReplicateResultDocumentChange(host, state, log, doc);
}
state.triggerTakeSnapshot();
}
} finally {
state.isRunningProcessQueue = false;
}
}
export async function parseReplicateResultDocumentChange(
host: ReplicateResultProcessorHost,
state: ReplicateResultProcessorState,
log: ReplicateResultProcessorLog,
change: PouchDB.Core.ExistingDocument<EntryDoc>
) {
try {
if (isAnyNote(change)) {
const docMtime = change.mtime ?? 0;
const maxMTime = host.services.setting.settings.maxMTimeForReflectEvents;
if (maxMTime > 0 && docMtime > maxMTime) {
const docPath = host.services.path.getPath(change);
log(
`Processing ${docPath} has been skipped due to modification time (${new Date(
docMtime * 1000
).toISOString()}) exceeding the limit`,
LOG_LEVEL_INFO
);
return;
}
}
if (await host.services.replication.processVirtualDocument(change)) return;
if (isAnyNote(change)) {
const docPath = host.services.path.getPath(change);
if (!(await host.services.vault.isTargetFile(docPath))) {
log(`Skipped: ${docPath}`, LOG_LEVEL_VERBOSE);
return;
}
const size = change.size;
if (host.services.vault.isFileSizeTooLarge(size)) {
log(`Processing ${docPath} has been skipped due to file size exceeding the limit`, LOG_LEVEL_NOTICE);
return;
}
return await applyReplicateResultToDatabase(host, state, log, change);
}
log(`Skipped unexpected non-note document: ${change._id}`, LOG_LEVEL_INFO);
return;
} finally {
state.processingChanges = state.processingChanges.filter((e) => e !== change);
state.triggerTakeSnapshot();
}
}
export function applyReplicateResultToDatabase(
host: ReplicateResultProcessorHost,
state: ReplicateResultProcessorState,
log: ReplicateResultProcessorLog,
doc: PouchDB.Core.ExistingDocument<AnyEntry>
) {
return withCounting(async () => {
let releaser: Awaited<ReturnType<typeof state.semaphore.acquire>> | undefined = undefined;
try {
releaser = await state.semaphore.acquire();
await applyReplicateResultToDatabaseInternal(host, log, doc);
} catch (e) {
log(`Error while processing replication result`, LOG_LEVEL_NOTICE);
log(e, LOG_LEVEL_VERBOSE);
} finally {
releaser?.();
}
}, host.services.replication.databaseQueueCount);
}
export function applyReplicateResultToDatabaseInternal(
host: ReplicateResultProcessorHost,
log: ReplicateResultProcessorLog,
doc_: PouchDB.Core.ExistingDocument<AnyEntry>
) {
const dbDoc = doc_ as LoadedEntry;
const path = host.services.path.getPath(dbDoc);
return serialized(`replication-process:${dbDoc._id}`, async () => {
const docNote = `${path} (${shortenId(dbDoc._id)}, ${shortenRev(dbDoc._rev)})`;
const isRequired = await checkIsChangeRequiredForDatabaseProcessing(host, log, dbDoc);
if (!isRequired) {
log(`Skipped (Not latest): ${docNote}`, LOG_LEVEL_VERBOSE);
return;
}
const isDeleted = dbDoc._deleted === true || ("deleted" in dbDoc && dbDoc.deleted === true);
const doc = isDeleted
? { ...dbDoc, data: "" }
: await host.services.database.localDatabase.getDBEntryFromMeta({ ...dbDoc }, false, true);
if (!doc) {
log(`Failed to gather content of ${docNote}`, LOG_LEVEL_NOTICE);
return;
}
if (await host.services.replication.processOptionalSynchroniseResult(dbDoc)) {
log(`Processed by other processor: ${docNote}`, LOG_LEVEL_DEBUG);
} else if (host.services.vault.isValidPath(host.services.path.getPath(doc))) {
await applyReplicateResultToStorage(host, doc as MetaEntry);
log(`Processed: ${docNote}`, LOG_LEVEL_DEBUG);
} else {
log(`Unprocessed (Invalid path): ${docNote}`, LOG_LEVEL_VERBOSE);
}
});
}
export function applyReplicateResultToStorage(host: ReplicateResultProcessorHost, entry: MetaEntry) {
return withCounting(async () => {
await host.services.replication.processSynchroniseResult(entry);
}, host.services.replication.storageApplyingCount);
}
export async function checkIsChangeRequiredForDatabaseProcessing(
host: ReplicateResultProcessorHost,
log: ReplicateResultProcessorLog,
dbDoc: LoadedEntry
): Promise<boolean> {
const path = host.services.path.getPath(dbDoc);
try {
const savedDoc = await host.services.database.localDatabase.getRaw<LoadedEntry>(dbDoc._id, {
conflicts: true,
revs_info: true,
});
const newRev = dbDoc._rev ?? "";
const latestRev = savedDoc._rev ?? "";
const revisions = savedDoc._revs_info?.map((e) => e.rev) ?? [];
if (savedDoc._conflicts && savedDoc._conflicts.length > 0) {
return true;
}
if (newRev == latestRev) {
return true;
}
const index = revisions.indexOf(newRev);
if (index >= 0) {
return false;
}
return true;
} catch (e) {
if (isNotFoundError(e)) {
return true;
}
log(
`Failed to get existing document for ${path} (${shortenId(dbDoc._id)}, ${shortenRev(dbDoc._rev)}) `,
LOG_LEVEL_NOTICE
);
log(e, LOG_LEVEL_VERBOSE);
return false;
}
}
export function useReplicateResultProcessor(host: ReplicateResultProcessorHost): ReplicateResultProcessor {
const log = createReplicateResultProcessorLog(host);
const state = createReplicateResultProcessorState();
state.triggerTakeSnapshot = throttle(() => {
fireAndForget(() => takeReplicateResultProcessorSnapshot(host, state, log));
}, 50);
return {
suspend: () => suspendReplicateResultProcessing(state),
resume: () => resumeReplicateResultProcessing(host, state, log),
enqueueAll: (changes) => enqueueAllReplicateResults(host, state, log, changes),
restoreFromSnapshotOnce: () => restoreReplicateResultProcessorSnapshotOnce(host, state, log),
};
}
@@ -0,0 +1,224 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SYNCINFO_ID, VER, type EntryDoc, type LoadedEntry } from "@lib/common/types";
import { createMockServiceHub } from "../mockServiceHub";
import {
applyReplicateResultToDatabaseInternal,
checkIsChangeRequiredForDatabaseProcessing,
createReplicateResultProcessorState,
enqueueReplicateResult,
parseReplicateResultDocumentChange,
processIfNonDocumentChange,
restoreReplicateResultProcessorSnapshotOnce,
takeReplicateResultProcessorSnapshot,
useReplicateResultProcessor,
createReplicateResultProcessorLog,
type ReplicateResultProcessorHost,
} from "./replicateResultProcessor";
describe("ReplicateResultProcessor", () => {
let host: ReplicateResultProcessorHost;
const log = vi.fn();
beforeEach(() => {
host = createMockServiceHub() as unknown as ReplicateResultProcessorHost;
log.mockClear();
(host.services.replicator as any).getActiveReplicator = vi.fn();
});
it("creates a processor API with closure-scoped state", () => {
const processor = useReplicateResultProcessor(host);
expect(processor).toEqual({
suspend: expect.any(Function),
resume: expect.any(Function),
enqueueAll: expect.any(Function),
restoreFromSnapshotOnce: expect.any(Function),
});
});
it("logs through the host API rather than the global logger", () => {
const featureLog = createReplicateResultProcessorLog(host);
featureLog("hello", 16 as any);
expect(host.services.API.addLog).toHaveBeenCalledWith(expect.stringContaining("hello"), 16, "");
});
it("replaces a queued document when the new revision has the same deletion state", () => {
const state = createReplicateResultProcessorState();
state.triggerTakeSnapshot = vi.fn();
const oldDoc = { _id: "note", _rev: "1-old", path: "note.md", type: "plain", deleted: false } as any;
const newDoc = { _id: "note", _rev: "2-new", path: "note.md", type: "plain", deleted: false } as any;
enqueueReplicateResult(host, state, log, oldDoc);
enqueueReplicateResult(host, state, log, newDoc);
expect(state.queuedChanges).toEqual([newDoc]);
expect(state.triggerTakeSnapshot).toHaveBeenCalledTimes(2);
});
it("keeps both queued document revisions when deletion state differs", () => {
const state = createReplicateResultProcessorState();
state.triggerTakeSnapshot = vi.fn();
const oldDoc = { _id: "note", _rev: "1-old", path: "note.md", type: "plain", deleted: false } as any;
const deletedDoc = { _id: "note", _rev: "2-new", path: "note.md", type: "plain", deleted: true } as any;
enqueueReplicateResult(host, state, log, oldDoc);
enqueueReplicateResult(host, state, log, deletedDoc);
expect(state.queuedChanges).toEqual([oldDoc, deletedDoc]);
});
it("takes and restores a processing snapshot once", async () => {
const queued = [{ _id: "queued", _rev: "1-a", type: "plain" }] as PouchDB.Core.ExistingDocument<EntryDoc>[];
const processing = [
{ _id: "processing", _rev: "1-b", type: "plain" },
] as PouchDB.Core.ExistingDocument<EntryDoc>[];
const state = createReplicateResultProcessorState();
state.queuedChanges = queued.slice();
state.processingChanges = processing.slice();
await takeReplicateResultProcessorSnapshot(host, state, log);
expect(host.services.keyValueDB.kvDB.set).toHaveBeenCalledWith("replicationResultProcessorSnapshot", {
queued,
processing,
});
(host.services.keyValueDB.kvDB.get as any).mockResolvedValue({ queued, processing });
const restored = createReplicateResultProcessorState();
restored.suspended = true;
restored.triggerTakeSnapshot = vi.fn();
await restoreReplicateResultProcessorSnapshotOnce(host, restored, log);
await restoreReplicateResultProcessorSnapshotOnce(host, restored, log);
expect(host.services.keyValueDB.kvDB.get).toHaveBeenCalledTimes(1);
expect(restored.queuedChanges.map((e) => e._id)).toEqual(["processing", "queued"]);
});
it("processes non-document changes directly", () => {
const chunk = { _id: "h:chunk", _rev: "1-a" } as any;
expect(processIfNonDocumentChange(host, log, chunk)).toBe(true);
expect(host.services.database.localDatabase.onNewLeaf).toHaveBeenCalledWith(chunk);
expect(processIfNonDocumentChange(host, log, { _id: SYNCINFO_ID } as any)).toBe(true);
expect(processIfNonDocumentChange(host, log, { _id: "_design/local" } as any)).toBe(true);
});
it("closes active replication on an incompatible version document", () => {
const activeReplicator = { closeReplication: vi.fn() };
(host.services.replicator.getActiveReplicator as any).mockReturnValue(activeReplicator);
const result = processIfNonDocumentChange(host, log, {
_id: "version",
type: "versioninfo",
version: VER + 1,
} as any);
expect(result).toBe(true);
expect(activeReplicator.closeReplication).toHaveBeenCalled();
});
it("detects whether a database change still needs processing", async () => {
const dbDoc = { _id: "note", _rev: "2-new", path: "note.md" } as LoadedEntry;
(host.services.database.localDatabase.getRaw as any).mockResolvedValue({
_id: "note",
_rev: "3-latest",
_revs_info: [{ rev: "2-new" }],
});
await expect(checkIsChangeRequiredForDatabaseProcessing(host, log, dbDoc)).resolves.toBe(false);
(host.services.database.localDatabase.getRaw as any).mockResolvedValue({
_id: "note",
_rev: "3-latest",
_conflicts: ["2-conflict"],
_revs_info: [{ rev: "2-new" }],
});
await expect(checkIsChangeRequiredForDatabaseProcessing(host, log, dbDoc)).resolves.toBe(true);
});
it("handles not-found and unexpected errors while checking database processing necessity", async () => {
const dbDoc = { _id: "note", _rev: "2-new", path: "note.md" } as LoadedEntry;
(host.services.database.localDatabase.getRaw as any).mockRejectedValue({ status: 404 });
await expect(checkIsChangeRequiredForDatabaseProcessing(host, log, dbDoc)).resolves.toBe(true);
(host.services.database.localDatabase.getRaw as any).mockRejectedValue({ status: 500 });
await expect(checkIsChangeRequiredForDatabaseProcessing(host, log, dbDoc)).resolves.toBe(false);
expect(log).toHaveBeenCalledWith(
expect.stringContaining("Failed to get existing document"),
expect.any(Number)
);
});
it("skips note parsing when the replicated file is too large", async () => {
const state = createReplicateResultProcessorState();
const change = {
_id: "note",
_rev: "1-a",
type: "plain",
path: "note.md",
size: 100,
} as any;
state.processingChanges = [change];
state.triggerTakeSnapshot = vi.fn();
(host.services.vault.isFileSizeTooLarge as any).mockReturnValue(true);
await parseReplicateResultDocumentChange(host, state, log, change);
expect(host.services.database.localDatabase.getDBEntryFromMeta).not.toHaveBeenCalled();
expect(state.processingChanges).toEqual([]);
expect(state.triggerTakeSnapshot).toHaveBeenCalled();
});
it("lets virtual document handlers consume replicated documents", async () => {
const state = createReplicateResultProcessorState();
const change = {
_id: "virtual",
_rev: "1-a",
type: "plain",
path: "virtual.md",
size: 1,
} as any;
(host.services.replication.processVirtualDocument as any).mockResolvedValue(true);
await parseReplicateResultDocumentChange(host, state, log, change);
expect(host.services.vault.isTargetFile).not.toHaveBeenCalled();
expect(host.services.database.localDatabase.getDBEntryFromMeta).not.toHaveBeenCalled();
});
it("applies gathered replicated documents to storage when no optional processor handles them", async () => {
const dbDoc = { _id: "note", _rev: "2-new", path: "note.md", type: "plain", size: 1 } as any;
const fullDoc = { ...dbDoc, data: "hello" };
(host.services.database.localDatabase.getRaw as any).mockResolvedValue({ _id: "note", _rev: "2-new" });
(host.services.database.localDatabase.getDBEntryFromMeta as any).mockResolvedValue(fullDoc);
(host.services.replication.processOptionalSynchroniseResult as any).mockResolvedValue(false);
await applyReplicateResultToDatabaseInternal(host, log, dbDoc);
expect(host.services.replication.processSynchroniseResult).toHaveBeenCalledWith(fullDoc);
});
it("skips storage application when an optional processor handles the document", async () => {
const dbDoc = { _id: "note", _rev: "2-new", path: "note.md", type: "plain", size: 1 } as any;
(host.services.database.localDatabase.getRaw as any).mockResolvedValue({ _id: "note", _rev: "2-new" });
(host.services.database.localDatabase.getDBEntryFromMeta as any).mockResolvedValue({ ...dbDoc, data: "hello" });
(host.services.replication.processOptionalSynchroniseResult as any).mockResolvedValue(true);
await applyReplicateResultToDatabaseInternal(host, log, dbDoc);
expect(host.services.replication.processSynchroniseResult).not.toHaveBeenCalled();
});
it("skips storage application for invalid replicated paths", async () => {
const dbDoc = { _id: "note", _rev: "2-new", path: "note.md", type: "plain", size: 1 } as any;
(host.services.database.localDatabase.getRaw as any).mockResolvedValue({ _id: "note", _rev: "2-new" });
(host.services.database.localDatabase.getDBEntryFromMeta as any).mockResolvedValue({ ...dbDoc, data: "hello" });
(host.services.replication.processOptionalSynchroniseResult as any).mockResolvedValue(false);
(host.services.vault.isValidPath as any).mockReturnValue(false);
await applyReplicateResultToDatabaseInternal(host, log, dbDoc);
expect(host.services.replication.processSynchroniseResult).not.toHaveBeenCalled();
});
});
+24 -17
View File
@@ -1,6 +1,6 @@
import { fireAndForget } from "octagonal-wheels/promises";
import { registerReplicatorCommands } from "./commands";
import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "octagonal-wheels/common/logger";
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE } from "@lib/common/types";
import { skipIfDuplicated } from "octagonal-wheels/concurrency/lock";
import { balanceChunkPurgedDBs } from "@lib/pouchdb/chunks";
import { purgeUnreferencedChunks } from "@lib/pouchdb/chunks";
@@ -11,13 +11,15 @@ import { scheduleTask } from "octagonal-wheels/concurrency/task";
import { EVENT_FILE_SAVED, EVENT_SETTING_SAVED, eventHub } from "@/common/events";
import { $msg } from "@lib/common/i18n";
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
import { useReplicateResultProcessor, type ReplicateResultProcessor } from "./replicateResultProcessor";
import { UnresolvedErrorManager } from "@lib/services/base/UnresolvedErrorManager";
import { clearHandlers } from "@lib/replication/SyncParamsHandler";
import type { NecessaryServices } from "@lib/interfaces/ServiceModule";
import { MARK_LOG_NETWORK_ERROR } from "@lib/services/lib/logUtils";
import { createInstanceLogFunction, MARK_LOG_NETWORK_ERROR, type LogFunction } from "@lib/services/lib/logUtils";
import type { NecessaryObsidianFeature } from "@/types";
const noopLog: LogFunction = () => undefined;
function isOnlineAndCanReplicate(
errorManager: UnresolvedErrorManager,
host: NecessaryServices<"API", never>,
@@ -64,7 +66,9 @@ export type ReplicatorHost = NecessaryObsidianFeature<
| "API"
| "database"
| "databaseEvents"
| "keyValueDB"
| "path"
| "vault"
| "UI",
"databaseFileAccess" | "rebuilder"
>;
@@ -114,10 +118,10 @@ export const everyBeforeReplicateHandler = async (
return true;
};
export const cleanedHandler = async (host: ReplicatorHost, showMessage: boolean) => {
export const cleanedHandler = async (host: ReplicatorHost, showMessage: boolean, log: LogFunction = noopLog) => {
const { services, serviceModules } = host;
const settings = services.setting.settings;
Logger(`The remote database has been cleaned.`, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
log(`The remote database has been cleaned.`, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
await skipIfDuplicated("cleanup", async () => {
const count = await purgeUnreferencedChunks(services.database.localDatabase.localDatabase, true);
const message = `The remote database has been cleaned up.
@@ -143,7 +147,7 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
if (!(replicator instanceof LiveSyncCouchDBReplicator)) return;
const remoteDB = await replicator.connectRemoteCouchDBWithSetting(settings, services.API.isMobile(), true);
if (typeof remoteDB == "string") {
Logger(remoteDB, LOG_LEVEL_NOTICE);
log(remoteDB, LOG_LEVEL_NOTICE);
return false;
}
@@ -155,9 +159,9 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
await purgeUnreferencedChunks(services.database.localDatabase.localDatabase, false);
services.database.localDatabase.clearCaches();
await activeReplicator.markRemoteResolved(settings);
Logger("The local database has been cleaned up.", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
log("The local database has been cleaned up.", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
} else {
Logger(
log(
"Replication has been cancelled. Please try it again.",
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
);
@@ -168,13 +172,14 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
export const onReplicationFailedHandler = async (
host: ReplicatorHost,
showMessage: boolean = false
showMessage: boolean = false,
log: LogFunction = noopLog
): Promise<boolean> => {
const { services, serviceModules } = host;
const settings = services.setting.settings;
const activeReplicator = services.replicator.getActiveReplicator();
if (!activeReplicator) {
Logger(`No active replicator found`, LOG_LEVEL_INFO);
log(`No active replicator found`, LOG_LEVEL_INFO);
return false;
}
if (activeReplicator.tweakSettingsMismatched && activeReplicator.preferredTweakValue) {
@@ -182,7 +187,7 @@ export const onReplicationFailedHandler = async (
} else {
if (activeReplicator.remoteLockedAndDeviceNotAccepted) {
if (activeReplicator.remoteCleaned && settings.useIndexedDBAdapter) {
await cleanedHandler(host, showMessage);
await cleanedHandler(host, showMessage, log);
} else {
const message = $msg("Replicator.Dialogue.Locked.Message");
const CHOICE_FETCH = $msg("Replicator.Dialogue.Locked.Action.Fetch");
@@ -198,13 +203,13 @@ export const onReplicationFailedHandler = async (
}
);
if (ret == CHOICE_FETCH) {
Logger($msg("Replicator.Dialogue.Locked.Message.Fetch"), LOG_LEVEL_NOTICE);
log($msg("Replicator.Dialogue.Locked.Message.Fetch"), LOG_LEVEL_NOTICE);
await serviceModules.rebuilder.scheduleFetch();
services.appLifecycle.scheduleRestart();
return false;
} else if (ret == CHOICE_UNLOCK) {
await activeReplicator.markRemoteResolved(settings);
Logger($msg("Replicator.Dialogue.Locked.Message.Unlocked"), LOG_LEVEL_NOTICE);
log($msg("Replicator.Dialogue.Locked.Message.Unlocked"), LOG_LEVEL_NOTICE);
return false;
}
}
@@ -222,10 +227,10 @@ export const parseReplicationResultHandler = (
};
export function useReplicator(host: ReplicatorHost) {
const { services, serviceModules } = host;
const settings = services.setting.settings;
const { services } = host;
const log = createInstanceLogFunction("Replicator", services.API);
const processor = new ReplicateResultProcessor(host as any);
const processor = useReplicateResultProcessor(host);
const unresolvedErrorManager = new UnresolvedErrorManager(services.appLifecycle);
services.replicator.onReplicatorInitialised.addHandler(onReplicatorInitialisedHandler);
@@ -253,7 +258,9 @@ export function useReplicator(host: ReplicatorHost) {
everyBeforeReplicateHandler.bind(null, unresolvedErrorManager, processor),
100
);
services.replication.onReplicationFailed.addHandler(onReplicationFailedHandler.bind(null, host));
services.replication.onReplicationFailed.addHandler((showMessage) =>
onReplicationFailedHandler(host, showMessage, log)
);
registerReplicatorCommands(host);
}
@@ -10,19 +10,19 @@ import {
onReplicationFailedHandler,
} from "./replicator";
import { createMockServiceHub } from "../mockServiceHub";
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
import type { ReplicateResultProcessor } from "./replicateResultProcessor";
import { eventHub, EVENT_FILE_SAVED, EVENT_SETTING_SAVED } from "@/common/events";
import { LiveSyncCouchDBReplicator } from "@lib/replication/couchdb/LiveSyncReplicator";
import { $msg } from "@lib/common/i18n";
vi.mock("./ReplicateResultProcessor", () => {
vi.mock("./replicateResultProcessor", () => {
return {
ReplicateResultProcessor: class {
suspend = vi.fn();
resume = vi.fn();
restoreFromSnapshotOnce = vi.fn().mockResolvedValue(true);
enqueueAll = vi.fn();
},
useReplicateResultProcessor: vi.fn(() => ({
suspend: vi.fn(),
resume: vi.fn(),
restoreFromSnapshotOnce: vi.fn().mockResolvedValue(true),
enqueueAll: vi.fn(),
})),
};
});
@@ -56,6 +56,13 @@ vi.mock("@lib/replication/couchdb/LiveSyncReplicator", () => {
describe("useReplicator", () => {
let mockHub: ReturnType<typeof createMockServiceHub>;
const createMockProcessor = (): ReplicateResultProcessor => ({
suspend: vi.fn(),
resume: vi.fn(),
restoreFromSnapshotOnce: vi.fn().mockResolvedValue(undefined),
enqueueAll: vi.fn(),
});
beforeEach(() => {
mockHub = createMockServiceHub();
(mockHub.services as any).tweakValue = {
@@ -88,7 +95,7 @@ describe("useReplicator", () => {
});
it("parseReplicationResultHandler should enqueue docs", async () => {
const mockProcessor = new ReplicateResultProcessor(mockHub as any);
const mockProcessor = createMockProcessor();
const res = await parseReplicationResultHandler(mockProcessor, []);
expect(mockProcessor.enqueueAll).toHaveBeenCalledWith([]);
expect(res).toBe(true);
@@ -144,7 +151,7 @@ describe("useReplicator", () => {
it("everyOnloadAfterLoadSettingsHandler should register event listeners", async () => {
vi.useFakeTimers();
try {
const mockProcessor = new ReplicateResultProcessor(mockHub as any);
const mockProcessor = createMockProcessor();
await everyOnloadAfterLoadSettingsHandler(mockHub as any, mockProcessor);
(mockHub.services.setting.settings as any).syncOnSave = true;
@@ -168,7 +175,7 @@ describe("useReplicator", () => {
});
it("everyOnDatabaseInitializedHandler and everyBeforeReplicateHandler", async () => {
const mockProcessor = new ReplicateResultProcessor(mockHub as any);
const mockProcessor = createMockProcessor();
await everyOnDatabaseInitializedHandler(mockProcessor, false);
await new Promise((resolve) => setTimeout(resolve, 1));
@@ -1,4 +1,4 @@
import { Logger, LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
import { extractObject } from "octagonal-wheels/object";
import {
TweakValuesShouldMatchedTemplate,
@@ -15,12 +15,17 @@ import { escapeMarkdownValue } from "@lib/common/utils.ts";
import { $msg } from "@lib/common/i18n.ts";
import { REMOTE_P2P } from "@lib/common/models/setting.const.ts";
import type { NecessaryObsidianFeature } from "@/types";
import { createInstanceLogFunction, type LogFunction } from "@lib/services/lib/logUtils";
export type MismatchedTweaksResolverHost = NecessaryObsidianFeature<
"setting" | "tweakValue" | "replication" | "replicator" | "UI",
"API" | "setting" | "tweakValue" | "replication" | "replicator" | "UI",
"rebuilder"
>;
const noopLog: LogFunction = () => undefined;
const createMismatchedTweaksLog = (host: MismatchedTweaksResolverHost): LogFunction =>
host.services.API ? createInstanceLogFunction("TweakMismatch", host.services.API) : noopLog;
export function valueToString(value: string | number | boolean | object | undefined): string {
if (typeof value === "boolean") {
return value ? "true" : "false";
@@ -36,8 +41,12 @@ export const collectMismatchedTweakKeys = (current: TweakValues, preferred: Part
return items.filter((key) => current[key] !== preferred[key]);
};
export const selectNewerTweakSide = (current: TweakValues, preferred: Partial<TweakValues>): "REMOTE" | "CURRENT" => {
Logger(`Modified: ${current.tweakModified} (current) vs ${preferred.tweakModified} (preferred)`);
export const selectNewerTweakSide = (
current: TweakValues,
preferred: Partial<TweakValues>,
log: LogFunction = noopLog
): "REMOTE" | "CURRENT" => {
log(`Modified: ${current.tweakModified} (current) vs ${preferred.tweakModified} (preferred)`);
const currentModified = current.tweakModified;
const preferredModified = preferred.tweakModified;
const hasCurrentModified = typeof currentModified === "number" && currentModified > 0;
@@ -58,6 +67,7 @@ export const shouldAutoAcceptCompatibleLossy = async (
mismatchedKeys: (keyof typeof TweakValuesShouldMatchedTemplate)[]
): Promise<"REMOTE" | "CURRENT" | undefined> => {
const { services } = host;
const log = createMismatchedTweaksLog(host);
if (mismatchedKeys.length === 0) return undefined;
const hasOnlyCompatibleLossyMismatches = mismatchedKeys.every(
(key) => CompatibleButLossyChanges.indexOf(key) !== -1
@@ -87,26 +97,27 @@ export const shouldAutoAcceptCompatibleLossy = async (
},
true
);
Logger("Auto-accept for compatible tweak mismatch has been enabled.");
log("Auto-accept for compatible tweak mismatch has been enabled.");
}
if (services.setting.settings.autoAcceptCompatibleTweak !== true) return undefined;
return selectNewerTweakSide(current, preferred);
return selectNewerTweakSide(current, preferred, log);
};
export const onBeforeSaveSettingDataHandler = async (
next: ObsidianLiveSyncSettings,
previous: ObsidianLiveSyncSettings
previous: ObsidianLiveSyncSettings,
log: LogFunction = noopLog
) => {
const tweakKeys = Object.keys(TweakValuesTemplate) as (keyof TweakValues)[];
const tweakKeysForUpdate = tweakKeys.filter((key) => key !== "tweakModified");
const hasChangedTweak = tweakKeysForUpdate.some((key) => next[key] !== previous[key]);
if (!hasChangedTweak) return;
Logger(
log(
`Some tweak values have been changed. ${tweakKeysForUpdate.filter((key) => next[key] !== previous[key]).join(", ")}`
);
const modified = Date.now();
Logger(`Modified: ${modified}`);
log(`Modified: ${modified}`);
return await Promise.resolve({
tweakModified: modified,
});
@@ -239,6 +250,7 @@ export const askResolvingMismatchedTweaksHandler = async (
host: MismatchedTweaksResolverHost
): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> => {
const { services, serviceModules } = host;
const log = createMismatchedTweaksLog(host);
if (!services.replicator.getActiveReplicator()?.tweakSettingsMismatched) {
return "OK";
}
@@ -254,7 +266,7 @@ export const askResolvingMismatchedTweaksHandler = async (
if (rebuildRequired) {
await serviceModules.rebuilder.$rebuildRemote();
}
Logger($msg("TweakMismatchResolve.Message.remoteUpdated"), LOG_LEVEL_NOTICE);
log($msg("TweakMismatchResolve.Message.remoteUpdated"), LOG_LEVEL_NOTICE);
return "CHECKAGAIN";
}
if (conf) {
@@ -264,7 +276,7 @@ export const askResolvingMismatchedTweaksHandler = async (
if (rebuildRequired) {
await serviceModules.rebuilder.$fetchLocal();
}
Logger($msg("TweakMismatchResolve.Message.mineUpdated"), LOG_LEVEL_NOTICE);
log($msg("TweakMismatchResolve.Message.mineUpdated"), LOG_LEVEL_NOTICE);
return "CHECKAGAIN";
}
return "IGNORE";
@@ -275,9 +287,10 @@ export const fetchRemotePreferredTweakValuesHandler = async (
trialSetting: RemoteDBSettings
): Promise<TweakValues | false> => {
const { services } = host;
const log = createMismatchedTweaksLog(host);
const replicator = await services.replicator.getNewReplicator(trialSetting);
if (!replicator) {
Logger("The remote type is not supported for fetching preferred tweak values.", LOG_LEVEL_NOTICE);
log("The remote type is not supported for fetching preferred tweak values.", LOG_LEVEL_NOTICE);
return false;
}
if (await replicator.tryConnectRemote(trialSetting)) {
@@ -285,10 +298,10 @@ export const fetchRemotePreferredTweakValuesHandler = async (
if (preferred) {
return preferred;
}
Logger("Failed to get the preferred tweak values from the remote server.", LOG_LEVEL_NOTICE);
log("Failed to get the preferred tweak values from the remote server.", LOG_LEVEL_NOTICE);
return false;
}
Logger("Failed to connect to the remote server.", LOG_LEVEL_NOTICE);
log("Failed to connect to the remote server.", LOG_LEVEL_NOTICE);
return false;
};
@@ -314,6 +327,7 @@ export const askUseRemoteConfigurationHandler = async (
preferred: TweakValues
): Promise<{ result: false | TweakValues; requireFetch: boolean }> => {
const { services } = host;
const log = createMismatchedTweaksLog(host);
const localTweaks = extractObject(TweakValuesTemplate, services.setting.settings) as TweakValues;
const mismatchedKeys = collectMismatchedTweakKeys(localTweaks, preferred);
const autoAcceptSide = await shouldAutoAcceptCompatibleLossy(host, state, localTweaks, preferred, mismatchedKeys);
@@ -367,7 +381,7 @@ export const askUseRemoteConfigurationHandler = async (
}
if (differenceCount === 0) {
Logger("The settings in the remote database are the same as the local database.", LOG_LEVEL_NOTICE);
log("The settings in the remote database are the same as the local database.", LOG_LEVEL_NOTICE);
return { result: false, requireFetch: false };
}
const additionalMessage =
@@ -404,9 +418,12 @@ export const askUseRemoteConfigurationHandler = async (
export function useMismatchedTweaksResolver(host: MismatchedTweaksResolverHost) {
const { services } = host;
const log = createMismatchedTweaksLog(host);
const state = { hasNotifiedAutoAcceptCompatibleUndefined: false };
services.setting.onBeforeSaveSettingData.addHandler(onBeforeSaveSettingDataHandler);
services.setting.onBeforeSaveSettingData.addHandler((next, previous) =>
onBeforeSaveSettingDataHandler(next, previous, log)
);
services.tweakValue.fetchRemotePreferred.setHandler(fetchRemotePreferredTweakValuesHandler.bind(null, host));
services.tweakValue.checkAndAskResolvingMismatched.setHandler(
checkAndAskResolvingMismatchedTweaksHandler.bind(null, host, state)
@@ -61,6 +61,9 @@ function createFeature(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {})
askSelectStringDialogue,
},
},
API: {
addLog: vi.fn(),
},
},
serviceModules: {
rebuilder: {
@@ -69,7 +72,7 @@ function createFeature(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {})
},
},
} as unknown as NecessaryObsidianFeature<
"setting" | "tweakValue" | "replication" | "replicator" | "UI",
"API" | "setting" | "tweakValue" | "replication" | "replicator" | "UI",
"rebuilder"
>;
@@ -90,6 +93,25 @@ describe("useMismatchedTweaksResolver", () => {
expect((host.services.tweakValue.checkAndAskResolvingMismatched as any).setHandler).toHaveBeenCalled();
});
it("should log through the host API when applying mine", async () => {
const host = createFeature().host;
const mockReplicator = {
tweakSettingsMismatched: true,
preferredTweakValue: { tweakModified: 200 },
setPreferredRemoteTweakSettings: vi.fn().mockResolvedValue(true),
};
host.services.replicator.getActiveReplicator = vi.fn().mockReturnValue(mockReplicator);
(host.services.tweakValue.checkAndAskResolvingMismatched as any) = vi.fn().mockResolvedValue([true, false]);
await askResolvingMismatchedTweaksHandler(host);
expect(host.services.API.addLog).toHaveBeenCalledWith(
expect.stringContaining($msg("TweakMismatchResolve.Message.remoteUpdated")),
expect.any(Number),
""
);
});
it("should auto-accept compatible mismatches on connect check using newer remote tweakModified", async () => {
const { checkAndAskResolvingMismatchedTweaks, askSelectStringDialogue } = createFeature({
autoAcceptCompatibleTweak: true,