fix: await optional file database saves

This commit is contained in:
vorotamoroz committed 2026-10-25 13:26:02 +00:00
1 parent b3a43abc2e
commit 62b2edba9d
11 files changed
+1113 -20

No files matched your search

@@ -0,0 +1,220 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
addIcon: vi.fn(),
diff_match_patch: class DiffMatchPatch {},
normalizePath: vi.fn((path: string) => path),
parseYaml: vi.fn(),
Platform: {},
}));
vi.mock("./PluginDialogModal.ts", () => ({
PluginDialogModal: class PluginDialogModal {},
}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
JsonResolveModal: class JsonResolveModal {},
}));
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
ConflictResolveModal: class ConflictResolveModal {},
}));
// The readiness helpers come from the real LiveSyncCommands, so only its log view dependency is replaced.
vi.mock("@/modules/features/ModuleLog.ts", () => ({
MARK_DONE: "",
}));
vi.mock("@/common/types.ts", () => ({
ICXHeader: "ix:",
PERIODIC_PLUGIN_SWEEP: 60,
}));
vi.mock("@/common/utils.ts", () => ({
cancelTask: vi.fn(),
EVEN: Symbol("even"),
isCustomisationSyncMetadata: vi.fn(),
isPluginMetadata: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("@/common/PeriodicProcessor.ts", () => ({
PeriodicProcessor: class PeriodicProcessor {},
}));
vi.mock("@/common/events.ts", () => ({
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
eventHub: {
onEvent: vi.fn(),
},
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: vi.fn(),
}));
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { getDocData } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { ConfigSync } from "./CmdConfigSync";
import { createOptionalFileSyncDatabase } from "../../../test/fixtures/optionalFileSyncDatabase";
const path = ".obsidian/snippets/test.css" as FilePath;
const fixtures: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
async function createConfigSync(useV2 = false) {
const fixture = await createOptionalFileSyncDatabase();
fixtures.push(fixture);
fixture.settings.usePluginSyncV2 = useV2;
const files = new Map<string, { content: string; mtime: number }>([
[path, { content: "body { color: red; }", mtime: 2000 }],
]);
const storageAccess = {
statHidden: vi.fn(async (file: string) => {
const entry = files.get(file);
return entry
? { type: "file" as const, ctime: 1000, mtime: entry.mtime, size: entry.content.length }
: null;
}),
readHiddenFileBinary: vi.fn(async (file: string) => {
const entry = files.get(file);
if (!entry) throw new Error(`Missing fixture: ${file}`);
return new TextEncoder().encode(entry.content).buffer;
}),
};
const services = {
...fixture.services,
appLifecycle: { isReady: () => false, isSuspended: () => true },
database: { ...fixture.services.database, isDatabaseReady: vi.fn(() => true) },
path: {
...fixture.services.path,
isMarkedAsSameChanges: vi.fn(),
markChangesAreSame: vi.fn(),
},
};
const config = Object.create(ConfigSync.prototype) as ConfigSync;
Object.assign(config, {
core: { settings: fixture.settings, localDatabase: fixture.localDatabase, storageAccess, services },
_log: vi.fn(),
updatePluginList: vi.fn(async () => undefined),
updatePluginListV2: vi.fn(async () => undefined),
recentProcessedInternalFiles: [],
});
const databasePath = useV2 ? config.filenameWithUnifiedKey(path) : config.filenameToUnifiedKey(path);
return { fixture, config, files, services, storageAccess, databasePath };
}
afterEach(async () => {
vi.restoreAllMocks();
for (const fixture of fixtures.splice(0)) await fixture.dispose();
});
describe("Customisation Sync optional save completion", () => {
const flush = { whenFlushRequested: Promise.resolve() };
it("waits for the database write while the application is suspended or still starting", async () => {
const { fixture, config } = await createConfigSync();
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const put = fixture.localDatabase.putDBEntry.bind(fixture.localDatabase);
const spy = vi.spyOn(fixture.localDatabase, "putDBEntry").mockImplementation(async (...args) => {
await gate;
return await put(...args);
});
let completed = false;
const result = config._processOptionalFileEventWithResult(path, flush).then((outcome) => {
completed = true;
return outcome;
});
await vi.waitFor(() => expect(spy).toHaveBeenCalledOnce());
expect(completed).toBe(false);
release();
await expect(result).resolves.toEqual({ kind: "completed" });
});
it.each([false, true])("confirms identical contents and stores a same-time edit (V2: %s)", async (useV2) => {
const { fixture, config, databasePath, files } = await createConfigSync(useV2);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
const first = await fixture.database.get(databasePath);
await config._processOptionalFileEventWithResult(path, flush);
expect((await fixture.database.get(databasePath))._rev).toBe(first._rev);
files.get(path)!.content = "body { color: tan; }";
await config._processOptionalFileEventWithResult(path, flush);
expect((await fixture.database.get(databasePath))._rev).not.toBe(first._rev);
});
it.each([
{ useV2: false, throws: false },
{ useV2: true, throws: false },
{ useV2: false, throws: true },
{ useV2: true, throws: true },
])("rejects a failed write and permits the unchanged notification to retry: %j", async ({ useV2, throws }) => {
const { fixture, config, databasePath } = await createConfigSync(useV2);
const failure = new Error("temporary write failure");
const put = vi.spyOn(fixture.localDatabase, "putDBEntry");
if (throws) put.mockRejectedValueOnce(failure);
else put.mockResolvedValueOnce(false);
const failed = config._processOptionalFileEventWithResult(path, flush);
if (throws) await expect(failed).rejects.toBe(failure);
else await expect(failed).rejects.toThrow("Could not save customisation");
await expect(fixture.localDatabase.getDBEntry(databasePath)).resolves.toBe(false);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
expect(put).toHaveBeenCalledTimes(2);
expect(await fixture.localDatabase.getDBEntry(databasePath)).not.toBe(false);
});
it.each([false, true])("confirms deletion and treats a repeated deletion as complete (V2: %s)", async (useV2) => {
const { fixture, config, databasePath, files } = await createConfigSync(useV2);
await config._processOptionalFileEventWithResult(path, flush);
files.delete(path);
const put = vi.spyOn(fixture.localDatabase, "putRaw");
put.mockResolvedValueOnce({ ok: false, id: "failed", rev: "" });
await expect(config._processOptionalFileEventWithResult(path, flush)).rejects.toThrow(
"Could not delete customisation"
);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
const deleted = await fixture.database.get(databasePath);
expect("deleted" in deleted && deleted.deleted).toBe(true);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
expect((await fixture.database.get(databasePath))._rev).toBe(deleted._rev);
});
it("distinguishes a different owner and unmet prerequisites", async () => {
const { config, services, storageAccess } = await createConfigSync();
await expect(config._processOptionalFileEventWithResult("notes.md" as FilePath, flush)).resolves.toEqual({
kind: "not-applicable",
});
services.database.isDatabaseReady.mockReturnValue(false);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({
kind: "veto",
value: "database-not-ready",
});
services.database.isDatabaseReady.mockReturnValue(true);
vi.spyOn(services.setting, "getDeviceAndVaultName").mockReturnValue("");
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({
kind: "veto",
value: "device-name-not-configured",
});
expect(storageAccess.readHiddenFileBinary).not.toHaveBeenCalled();
});
it("preserves every V2 file when neighbouring plug-in files arrive together", async () => {
const { fixture, config, files } = await createConfigSync(true);
const main = ".obsidian/plugins/example/main.js" as FilePath;
const style = ".obsidian/plugins/example/styles.css" as FilePath;
files.set(main, { content: "module.exports = {};", mtime: 3000 });
files.set(style, { content: "body {}", mtime: 3000 });
await Promise.all([
config._processOptionalFileEventWithResult(main, flush),
config._processOptionalFileEventWithResult(style, flush),
]);
expect(await fixture.localDatabase.getDBEntry(config.filenameWithUnifiedKey(main))).not.toBe(false);
expect(await fixture.localDatabase.getDBEntry(config.filenameWithUnifiedKey(style))).not.toBe(false);
});
it("does not acknowledge a database deletion which was not accepted", async () => {
const { fixture, config, databasePath } = await createConfigSync();
await config.storeCustomizationFiles(path);
const original = await fixture.localDatabase.getDBEntry(databasePath);
expect(original).not.toBe(false);
vi.spyOn(fixture.localDatabase, "putRaw").mockResolvedValueOnce({ ok: false, id: "failed", rev: "" });
await expect(config.deleteConfigOnDatabase(databasePath)).resolves.toBe(false);
const unchanged = await fixture.localDatabase.getDBEntry(databasePath);
expect(unchanged && getDocData(unchanged.data)).toBe(original && getDocData(original.data));
});
});
+89 -11
View File
@@ -69,6 +69,10 @@ import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError"
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import { PendingConfigSaves } from "./PendingConfigSaves";
const d = "\u200b";
const d2 = "\n";
const UPDATED_CONFIGURATION_NOTICE_KEY = "config-sync:updated-configuration";
@@ -1206,7 +1210,7 @@ export class ConfigSync extends LiveSyncCommands {
}
recentProcessedInternalFiles = [] as string[];
async makeEntryFromFile(path: FilePath): Promise<false | PluginDataExFile> {
async makeEntryFromFile(path: FilePath, requireCompletion = false): Promise<false | PluginDataExFile> {
const stat = await this.core.storageAccess.statHidden(path);
let version: string | undefined;
let displayName: string | undefined;
@@ -1240,6 +1244,7 @@ export class ConfigSync extends LiveSyncCommands {
} catch (ex) {
this._log(`The file ${path} could not be encoded`);
this._log(ex, LOG_LEVEL_VERBOSE);
if (requireCompletion) throw ex;
return false;
}
const mtime = stat.mtime;
@@ -1254,6 +1259,15 @@ export class ConfigSync extends LiveSyncCommands {
}
async storeCustomisationFileV2(path: FilePath, term: string, force = false) {
return await this.storeCustomisationFileV2Core(path, term, force, false);
}
private async storeCustomisationFileV2Core(
path: FilePath,
term: string,
force: boolean,
requireCompletion: boolean
) {
const vf = this.filenameWithUnifiedKey(path, term);
return await serialized(`plugin-${vf}`, async () => {
const prefixedFileName = vf;
@@ -1261,7 +1275,8 @@ export class ConfigSync extends LiveSyncCommands {
const id = await this.path2id(prefixedFileName);
const stat = await this.core.storageAccess.statHidden(path);
if (!stat) {
return false;
if (!requireCompletion) return false;
return await this.deleteConfigOnDatabaseCore(vf, force, true);
}
const mtime = stat.mtime;
const content = await this.core.storageAccess.readHiddenFileBinary(path);
@@ -1285,7 +1300,10 @@ export class ConfigSync extends LiveSyncCommands {
eden: {},
};
} else {
if (this.services.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN) {
if (
!requireCompletion &&
this.services.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN
) {
this._log(
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Already checked the same)`,
LOG_LEVEL_DEBUG
@@ -1300,7 +1318,7 @@ export class ConfigSync extends LiveSyncCommands {
const dataStart = dataSrc.indexOf(DUMMY_END);
const oldContent = dataSrc.substring(dataStart + DUMMY_END.length);
const oldContentArray = base64ToArrayBuffer(oldContent);
if (await isDocContentSame(oldContentArray, content)) {
if (!old.deleted && (await isDocContentSame(oldContentArray, content))) {
this._log(
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (the same content)`,
LOG_LEVEL_VERBOSE
@@ -1320,24 +1338,36 @@ export class ConfigSync extends LiveSyncCommands {
};
}
const ret = await this.localDatabase.putDBEntry(saveData);
if (requireCompletion && (!ret || !ret.ok))
throw new LiveSyncError(`Could not save customisation: ${path}`);
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
fireAndForget(() => this.updatePluginListV2(false, this.filenameWithUnifiedKey(path)));
return ret;
} catch (ex) {
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
if (requireCompletion) throw ex;
return false;
}
});
}
async storeCustomizationFiles(path: FilePath, termOverRide?: string) {
return await this.storeCustomizationFilesCore(path, termOverRide, false);
}
private async storeCustomizationFilesCore(
path: FilePath,
termOverRide: string | undefined,
requireCompletion: boolean
) {
const term = termOverRide || this.services.setting.getDeviceAndVaultName();
if (term == "") {
this._log($msg("We have to configure the device name"), LOG_LEVEL_NOTICE);
if (requireCompletion) throw new LiveSyncError("The device name is not configured");
return;
}
if (this.useV2) {
return await this.storeCustomisationFileV2(path, term);
return await this.storeCustomisationFileV2Core(path, term, false, requireCompletion);
}
const vf = this.filenameToUnifiedKey(path, term);
// console.warn(`Storing ${path} to ${bareVF} :--> ${keyedVF}`);
@@ -1378,7 +1408,7 @@ export class ConfigSync extends LiveSyncCommands {
fileTargets = ["manifest.json", "theme.css"].map((e) => `${parentPath}/${e}` as FilePath);
}
for (const target of fileTargets) {
const data = await this.makeEntryFromFile(target);
const data = await this.makeEntryFromFile(target, requireCompletion);
if (data == false) {
this._log(`Config: skipped (Possibly is not exist): ${target} `, LOG_LEVEL_VERBOSE);
continue;
@@ -1398,9 +1428,9 @@ export class ConfigSync extends LiveSyncCommands {
// this._log(`Configuration saving: ${prefixedFileName}`);
if (dt.files.length == 0) {
this._log(`Nothing left: deleting.. ${path}`);
await this.deleteConfigOnDatabase(prefixedFileName);
const deleted = await this.deleteConfigOnDatabaseCore(prefixedFileName, false, requireCompletion);
await this.updatePluginList(false, prefixedFileName);
return;
return requireCompletion ? deleted : undefined;
}
const content = createTextBlob(serialize(dt));
@@ -1422,7 +1452,7 @@ export class ConfigSync extends LiveSyncCommands {
eden: {},
};
} else {
if (old.mtime == mtime) {
if (!requireCompletion && old.mtime == mtime) {
// this._log(`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same time)`, LOG_LEVEL_VERBOSE);
return true;
}
@@ -1443,7 +1473,7 @@ export class ConfigSync extends LiveSyncCommands {
}
});
const isSame = (await Promise.all(diffs)).every((e) => e == true);
if (isSame) {
if (isSame && !old.deleted) {
this._log(
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same content)`,
LOG_LEVEL_VERBOSE
@@ -1464,16 +1494,51 @@ export class ConfigSync extends LiveSyncCommands {
};
}
const ret = await this.localDatabase.putDBEntry(saveData);
if (requireCompletion && (!ret || !ret.ok))
throw new LiveSyncError(`Could not save customisation: ${path}`);
await this.updatePluginList(false, saveData.path);
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
return ret;
} catch (ex) {
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
if (requireCompletion) throw ex;
return false;
}
});
}
private pendingOptionalSaves?: PendingConfigSaves;
async _processOptionalFileEventWithResult(
path: FilePath,
options?: OptionalFileEventOptions
): Promise<HandlerOutcome<void, string>> {
if (!this.isThisModuleEnabled() || !this.isTargetPath(path)) return { kind: "not-applicable" };
const configDir = normalizePath(this.configDir);
const handledElsewhere = Object.values(this.settings.pluginSyncExtendedSetting)
.filter((entry) => entry.mode !== MODE_SELECTIVE && entry.mode !== MODE_SHINY)
.flatMap((entry) => entry.files)
.some((file) => `${configDir}/${file}`.toLowerCase().startsWith(path.toLowerCase()));
if (handledElsewhere) return { kind: "not-applicable" };
if (!this._isDatabaseReady()) return { kind: "veto", value: "database-not-ready" };
const term = this.services.setting.getDeviceAndVaultName();
if (!term) return { kind: "veto", value: "device-name-not-configured" };
const stat = await this.core.storageAccess.statHidden(path);
if (stat && stat.type !== "file") return { kind: "not-applicable" };
// V1 saves a complete file group; V2 persists each file separately.
const key = this.useV2 ? this.filenameWithUnifiedKey(path, term) : this.filenameToUnifiedKey(path, term);
this.pendingOptionalSaves ??= new PendingConfigSaves();
await this.pendingOptionalSaves.enqueue(
key,
async () => {
await this.storeCustomizationFilesCore(path, term, true);
},
options?.whenFlushRequested
);
return { kind: "completed" };
}
async _anyProcessOptionalFileEvent(path: FilePath): Promise<boolean> {
return await this.watchVaultRawEventsAsync(path);
}
@@ -1615,6 +1680,14 @@ export class ConfigSync extends LiveSyncCommands {
}
async deleteConfigOnDatabase(prefixedFileName: FilePathWithPrefix, forceWrite = false) {
return await this.deleteConfigOnDatabaseCore(prefixedFileName, forceWrite, false);
}
private async deleteConfigOnDatabaseCore(
prefixedFileName: FilePathWithPrefix,
forceWrite: boolean,
requireCompletion: boolean
) {
// const id = await this.path2id(prefixedFileName);
const mtime = new Date().getTime();
return await serialized("file-x-" + prefixedFileName, async () => {
@@ -1640,13 +1713,15 @@ export class ConfigSync extends LiveSyncCommands {
type: "newnote",
};
}
await this.localDatabase.putRaw(saveData);
const result = await this.localDatabase.putRaw(saveData);
if (!result.ok) throw new LiveSyncError(`Could not delete customisation: ${prefixedFileName}`);
await this.updatePluginList(false, prefixedFileName);
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Done`);
return true;
} catch (ex) {
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
if (requireCompletion) throw ex;
return false;
}
});
@@ -1756,6 +1831,9 @@ export class ConfigSync extends LiveSyncCommands {
}
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this));
services.fileProcessing.processOptionalFileEventWithResult?.addHandler(
this._processOptionalFileEventWithResult.bind(this)
);
services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this));
services.replication.processVirtualDocument.addHandler(this._anyModuleParsedReplicationResultItem.bind(this));
services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this));
@@ -0,0 +1,49 @@
import { compatGlobal, type CompatTimeoutHandle } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
type PendingSave = {
save: () => Promise<void>;
completion: Promise<void>;
resolve: () => void;
reject: (error: unknown) => void;
timer?: CompatTimeoutHandle;
started: boolean;
};
/** Owns the quiet-period delay and completion of coalesced customisation saves. */
export class PendingConfigSaves {
private readonly pending = new Map<string, PendingSave>();
enqueue(key: string, save: () => Promise<void>, whenFlushRequested?: Promise<void>): Promise<void> {
let group = this.pending.get(key);
if (!group) {
let resolve!: () => void;
let reject!: (error: unknown) => void;
const completion = new Promise<void>((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
group = { save, completion, resolve, reject, started: false };
this.pending.set(key, group);
} else {
group.save = save;
if (group.timer !== undefined) compatGlobal.clearTimeout(group.timer);
}
const captured = group;
group.timer = compatGlobal.setTimeout(() => this.run(key, captured), 100);
// Capture this group: a late flush of an earlier event must not release
// a newer group which happens to use the same customisation key.
void whenFlushRequested?.then(
() => this.run(key, captured),
() => {}
);
return group.completion;
}
private run(key: string, group: PendingSave): void {
if (group.started) return;
group.started = true;
if (group.timer !== undefined) compatGlobal.clearTimeout(group.timer);
this.pending.delete(key);
void Promise.resolve().then(group.save).then(group.resolve, group.reject);
}
}
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PendingConfigSaves } from "./PendingConfigSaves";
function deferred() {
let resolve!: () => void;
let reject!: (error: unknown) => void;
const promise = new Promise<void>((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
return { promise, resolve, reject };
}
afterEach(() => vi.useRealTimers());
describe("pending customisation saves", () => {
it("retains the quiet period and completes every coalesced notification after the actual save", async () => {
vi.useFakeTimers();
const pending = new PendingConfigSaves();
const save = deferred();
const firstSave = vi.fn(async () => {});
const lastSave = vi.fn(() => save.promise);
const first = pending.enqueue("plugin", firstSave);
await vi.advanceTimersByTimeAsync(80);
const second = pending.enqueue("plugin", lastSave);
let completed = false;
const all = Promise.all([first, second]).then(() => {
completed = true;
});
await vi.advanceTimersByTimeAsync(99);
expect(lastSave).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(firstSave).not.toHaveBeenCalled();
expect(lastSave).toHaveBeenCalledOnce();
expect(completed).toBe(false);
save.resolve();
await all;
expect(completed).toBe(true);
});
it("flushes the captured group without waiting for or releasing later notifications", async () => {
vi.useFakeTimers();
const pending = new PendingConfigSaves();
const flush = deferred();
const lateFlush = deferred();
const firstSave = deferred();
const work = vi.fn(() => firstSave.promise);
const first = pending.enqueue("plugin", work, flush.promise);
const coalesced = pending.enqueue("plugin", work, lateFlush.promise);
flush.resolve();
await vi.advanceTimersByTimeAsync(0);
expect(work).toHaveBeenCalledOnce();
const nextWork = vi.fn(async () => {});
const later = pending.enqueue("plugin", nextWork);
lateFlush.resolve();
await vi.advanceTimersByTimeAsync(0);
expect(nextWork).not.toHaveBeenCalled();
firstSave.resolve();
await Promise.all([first, coalesced]);
expect(nextWork).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(100);
await later;
expect(nextWork).toHaveBeenCalledOnce();
});
it("rejects every coalesced notification with the original failure and permits a later retry", async () => {
vi.useFakeTimers();
const pending = new PendingConfigSaves();
const failure = new Error("database write failed");
const save = async () => {
throw failure;
};
const first = pending.enqueue("plugin", save);
const second = pending.enqueue("plugin", save);
const outcomes = Promise.allSettled([first, second]);
await vi.advanceTimersByTimeAsync(100);
expect(await outcomes).toEqual([
{ status: "rejected", reason: failure },
{ status: "rejected", reason: failure },
]);
const retry = pending.enqueue("plugin", async () => {}, Promise.resolve());
await retry;
});
});
@@ -0,0 +1,260 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
vi.mock("@/deps.ts", () => ({}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
vi.mock("./configureHiddenFileSyncMode.ts", () => ({ configureHiddenFileSyncMode: vi.fn() }));
import type { FilePath, UXStat } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { HiddenFileSync } from "./CmdHiddenFileSync";
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
const path = ".obsidian/plugins/example/main.js" as FilePath;
const content = "module.exports = class ExamplePlugin {};";
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
async function getText(entry: Parameters<typeof readContent>[0]) {
const value = await readContent(entry);
return typeof value === "string" ? value : new TextDecoder().decode(value);
}
async function createHiddenFileSync() {
const fixture = await createOptionalFileSyncDatabase();
fixtureOwners.push(fixture);
fixture.settings.usePluginSync = false;
fixture.settings.syncInternalFilesTargetPatterns = "" as typeof fixture.settings.syncInternalFilesTargetPatterns;
fixture.settings.syncInternalFilesIgnorePatterns = "" as typeof fixture.settings.syncInternalFilesIgnorePatterns;
let fileContent = content;
let stat: UXStat | null = {
ctime: 1_000,
mtime: 2_000,
size: fileContent.length,
type: "file",
};
const values = new Map<string, unknown>();
const kvDB = {
get: vi.fn(async <T>(key: string) => values.get(key) as T | undefined),
set: vi.fn(async <T>(key: string, value: T) => {
values.set(key, value);
}),
};
const storageAccess = {
statHidden: vi.fn(async () => stat),
readHiddenFileAuto: vi.fn(async () => fileContent),
writeHiddenFileAuto: vi.fn(async () => undefined),
removeHidden: vi.fn(async () => true),
isExistsIncludeHidden: vi.fn(async () => true),
ensureDir: vi.fn(async () => undefined),
triggerHiddenFile: vi.fn(async () => undefined),
setFile(nextContent: string | null, mtime = 2_000) {
fileContent = nextContent ?? "";
stat = nextContent === null ? null : { ctime: 1_000, mtime, size: fileContent.length, type: "file" };
},
};
const databaseService = { isDatabaseReady: vi.fn(() => true) };
const appLifecycle = { isReady: vi.fn(() => false), isSuspended: vi.fn(() => true) };
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
settings: fixture.settings,
localDatabase: fixture.localDatabase,
kvDB,
storageAccess,
services: {
...fixture.services,
database: databaseService,
appLifecycle,
path: {
...fixture.services.path,
markChangesAreSame: vi.fn(),
unmarkChanges: vi.fn(),
},
vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) },
},
},
cacheFileRegExps: new Map(),
cacheCustomisationSyncIgnoredFiles: new Map(),
_fileInfoLastProcessed: new Map(),
_databaseInfoLastProcessed: new Map(),
_fileInfoLastKnown: new Map(),
semaphore: Semaphore(10),
_log: vi.fn(),
});
return { fixture, hiddenFileSync, storageAccess, kvDB, databaseService, appLifecycle };
}
afterEach(async () => {
vi.restoreAllMocks();
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
});
describe("Hidden File Sync optional save outcomes", () => {
it("retries a failed database write when the storage fingerprint is unchanged", async () => {
const { fixture, hiddenFileSync } = await createHiddenFileSync();
await expect(hiddenFileSync.isTargetFile(path)).resolves.toBe(true);
vi.spyOn(fixture.localDatabase, "putDBEntry").mockRejectedValueOnce(new Error("temporary database failure"));
await expect(hiddenFileSync.trackStorageFileModification(path)).resolves.toBe(false);
await expect(hiddenFileSync.trackStorageFileModification(path)).resolves.toBe(true);
expect(fixture.localDatabase.putDBEntry).toHaveBeenCalledTimes(2);
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(saved).not.toBe(false);
});
it("rejects a failed strict write and retries the same fingerprint without delaying readiness", async () => {
const { fixture, hiddenFileSync, appLifecycle } = await createHiddenFileSync();
const putDBEntry = vi
.spyOn(fixture.localDatabase, "putDBEntry")
.mockRejectedValueOnce(new Error("temporary database failure"));
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
"temporary database failure"
);
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBeUndefined();
await expect(
hiddenFileSync._processOptionalFileEventWithResult(path, {
whenFlushRequested: new Promise<void>(() => undefined),
})
).resolves.toMatchObject({
kind: "completed",
});
expect(putDBEntry).toHaveBeenCalledTimes(2);
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(saved).not.toBe(false);
if (saved) expect(await getText(saved)).toBe(content);
expect(appLifecycle.isReady()).toBe(false);
expect(appLifecycle.isSuspended()).toBe(true);
});
it("rejects a false database-write result and can store a later edit after a same-content event", async () => {
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
const putDBEntry = vi
.spyOn(fixture.localDatabase, "putDBEntry")
.mockImplementationOnce(async () => false as never);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
`Failed to store hidden file in the database: ${path}`
);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
const first = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(first).not.toBe(false);
if (first) {
const firstRevision = first._rev;
hiddenFileSync._fileInfoLastProcessed.delete(path);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
const unchanged = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(unchanged).not.toBe(false);
if (unchanged) expect(unchanged._rev).toBe(firstRevision);
}
storageAccess.setFile("module.exports = class UpdatedExamplePlugin {};", 3_000);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
const updated = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(updated).not.toBe(false);
if (updated) expect(await getText(updated)).toBe("module.exports = class UpdatedExamplePlugin {};");
expect(putDBEntry).toHaveBeenCalledTimes(3);
});
it("vetoes when the physical database is unavailable and ignores disabled or filtered events", async () => {
const { fixture, hiddenFileSync, databaseService } = await createHiddenFileSync();
databaseService.isDatabaseReady.mockReturnValue(false);
await expect(
hiddenFileSync._processOptionalFileEventWithResult(path, {
whenFlushRequested: new Promise<void>(() => undefined),
})
).resolves.toMatchObject({ kind: "veto", value: expect.any(String) });
databaseService.isDatabaseReady.mockReturnValue(true);
fixture.settings.syncInternalFiles = false;
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "not-applicable",
});
fixture.settings.syncInternalFiles = true;
fixture.settings.syncInternalFilesTargetPatterns =
"^\\.obsidian/snippets/" as typeof fixture.settings.syncInternalFilesTargetPatterns;
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "not-applicable",
});
});
it("treats an absent replayed deletion as complete and leaves cache initialisation intact", async () => {
const { hiddenFileSync, storageAccess, kvDB } = await createHiddenFileSync();
storageAccess.setFile(null);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBe("0-0");
const lifecycle = hiddenFileSync as unknown as {
_everyOnDatabaseInitialized(showNotice: boolean): Promise<boolean>;
};
hiddenFileSync.core.settings.syncInternalFiles = false;
await lifecycle._everyOnDatabaseInitialized(false);
expect(kvDB.get).toHaveBeenCalledTimes(3);
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBe("0-0");
});
it("rejects actual deletion failures and completes duplicate deletions without another write", async () => {
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
await fixture.localDatabase.putDBEntry(optionalFileEntry(`i:${path}`, content));
storageAccess.setFile(null);
const putRaw = vi
.spyOn(fixture.localDatabase, "putRaw")
.mockRejectedValueOnce(new Error("temporary deletion failure"));
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
"temporary deletion failure"
);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
hiddenFileSync.resetLastProcessedFile([path]);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
expect(putRaw).toHaveBeenCalledTimes(2);
});
it("recreates an empty hidden file when its database entry is logically deleted", async () => {
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
await fixture.localDatabase.putDBEntry(optionalFileEntry(`i:${path}`, ""));
await expect(hiddenFileSync.deleteInternalFileOnDatabase(path)).resolves.toBe(true);
const deletedBase = await fixture.localDatabase.getDBEntry(
`i:${path}` as FilePath,
undefined,
false,
true,
true
);
expect(deletedBase).not.toBe(false);
if (deletedBase) expect(deletedBase.deleted).toBe(true);
if (!deletedBase) throw new Error("The logically deleted test entry could not be loaded");
vi.spyOn(fixture.localDatabase, "getDBEntry").mockResolvedValueOnce(deletedBase);
storageAccess.setFile("", 4_000);
const putDBEntry = vi.spyOn(fixture.localDatabase, "putDBEntry");
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
expect(putDBEntry).toHaveBeenCalledTimes(1);
const recreated = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(recreated).not.toBe(false);
if (recreated) {
expect(recreated.deleted).not.toBe(true);
expect(await getText(recreated)).toBe("");
}
});
});
@@ -54,6 +54,8 @@ import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import type { LiveSyncCore } from "@/main.ts";
import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts";
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
@@ -161,9 +163,7 @@ export class HiddenFileSync extends LiveSyncCommands {
// return true;
// }
private async _everyOnDatabaseInitialized(showNotice: boolean) {
this._fileInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed");
this._databaseInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed-database");
this._fileInfoLastKnown = await autosaveCache(this.kvDB, "hidden-file-lastKnown");
await this.ensureProcessedCaches();
if (this.isThisModuleEnabled()) {
if (this._fileInfoLastProcessed.size == 0 && this._fileInfoLastProcessed.size == 0) {
this._log(`No cache found. Performing startup scan.`, LOG_LEVEL_VERBOSE);
@@ -249,6 +249,26 @@ export class HiddenFileSync extends LiveSyncCommands {
return false;
}
async _processOptionalFileEventWithResult(
path: FilePath,
_options?: OptionalFileEventOptions
): Promise<HandlerOutcome<void, string>> {
if (!this.isThisModuleEnabled()) return { kind: "not-applicable" };
if (!(await this.isTargetFile(path))) return { kind: "not-applicable" };
if (!this._isDatabaseReady()) {
return { kind: "veto", value: "The local database is not ready" };
}
await this.ensureProcessedCaches();
if (!this.isThisModuleEnabled() || !(await this.isTargetFile(path))) {
return { kind: "not-applicable" };
}
if (!(await this.trackStorageFileModificationStrict(path))) {
return { kind: "not-applicable" };
}
return { kind: "completed" };
}
_anyGetOptionalConflictCheckMethod(path: FilePathWithPrefix): Promise<boolean | "newer"> {
if (isInternalMetadata(path)) {
this.queueConflictCheck(path);
@@ -317,6 +337,51 @@ export class HiddenFileSync extends LiveSyncCommands {
_fileInfoLastKnown!: MapLike<string, number>;
_databaseInfoLastProcessed!: MapLike<string, string>;
private processedCacheStore: unknown;
private processedCacheStoreReady = false;
private processedCacheLoadStore: unknown;
private processedCacheLoad?: Promise<void>;
private async ensureProcessedCaches() {
const store = this.kvDB;
if (!store) throw new Error("The Hidden File Sync cache store is unavailable");
if (this.processedCacheStoreReady && this.processedCacheStore === store) return;
if (this.processedCacheLoad && this.processedCacheLoadStore === store) {
await this.processedCacheLoad;
if (!this.processedCacheStoreReady || this.processedCacheStore !== this.kvDB) {
throw new Error("The Hidden File Sync cache store changed while loading its processing state");
}
return;
}
let load!: Promise<void>;
load = Promise.all([
autosaveCache<string, string>(store, "hidden-file-lastProcessed"),
autosaveCache<string, string>(store, "hidden-file-lastProcessed-database"),
autosaveCache<string, number>(store, "hidden-file-lastKnown"),
])
.then(([fileInfoLastProcessed, databaseInfoLastProcessed, fileInfoLastKnown]) => {
if (this.processedCacheLoad !== load || this.kvDB !== store) return;
this._fileInfoLastProcessed = fileInfoLastProcessed;
this._databaseInfoLastProcessed = databaseInfoLastProcessed;
this._fileInfoLastKnown = fileInfoLastKnown;
this.processedCacheStore = store;
this.processedCacheStoreReady = true;
})
.finally(() => {
if (this.processedCacheLoad === load) {
this.processedCacheLoad = undefined;
this.processedCacheLoadStore = undefined;
}
});
this.processedCacheLoadStore = store;
this.processedCacheLoad = load;
await load;
if (!this.processedCacheStoreReady || this.processedCacheStore !== this.kvDB) {
throw new Error("The Hidden File Sync cache store changed while loading its processing state");
}
}
statToKey(stat: UXStat | null) {
return `${stat?.mtime ?? 0}-${stat?.size ?? 0}`;
}
@@ -600,7 +665,6 @@ Offline Changed files: ${processFiles.length}`;
this._log(`Hidden file:${path} is changed.`, LOG_LEVEL_VERBOSE);
stat = cache.stat;
}
this.updateLastProcessedFile(path, stat!);
const lastIsNotFound = !lastKey || lastKey.endsWith("-0-0");
const nowIsNotFound = cache.deleted;
const type = lastIsNotFound && nowIsNotFound ? "invalid" : nowIsNotFound ? "delete" : "modified";
@@ -651,6 +715,27 @@ Offline Changed files: ${processFiles.length}`;
return true;
}
private async trackStorageFileModificationStrict(path: FilePath): Promise<boolean> {
return await this.serializedForEvent(path, async () => {
if (!this.isThisModuleEnabled() || !(await this.isTargetFile(path))) return false;
const stat = await this.core.storageAccess.statHidden(path);
if (stat != null && stat.type != "file") return true;
const key = await this.fileToStatKey(path, stat);
if (this.getLastProcessedFileKey(path) === key) {
this._log(`${path} Already processed.`, LOG_LEVEL_DEBUG);
return true;
}
const fileInfo = await this.loadFileWithInfo(path);
if (fileInfo.deleted) {
await this.deleteInternalFileOnDatabaseStrict(path);
return true;
}
await this.storeInternalFileToDatabaseStrict(fileInfo);
return true;
});
}
// --> Conflict processing
// Keep one in-flight conflict check per path so repeated sync events do not close the active merge dialogue.
@@ -1500,6 +1585,18 @@ Offline Changed files: ${files.length}`;
// --> Storage To Database Functions
async __loadBaseSaveData(file: FilePath, includeContent = true): Promise<LoadedEntry | false> {
return await this.loadBaseSaveData(file, includeContent, false);
}
private async loadBaseSaveDataStrict(file: FilePath, includeContent = true): Promise<LoadedEntry | false> {
return await this.loadBaseSaveData(file, includeContent, true);
}
private async loadBaseSaveData(
file: FilePath,
includeContent: boolean,
strict: boolean
): Promise<LoadedEntry | false> {
const prefixedFileName = addPrefix(file, ICHeader);
const id = await this.path2id(prefixedFileName, ICHeader);
try {
@@ -1525,6 +1622,7 @@ Offline Changed files: ${files.length}`;
return old;
}
} catch (ex) {
if (strict) throw ex;
this._log(`Getting base save data failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
return false;
@@ -1552,6 +1650,18 @@ Offline Changed files: ${files.length}`;
}
async storeInternalFileToDatabase(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
return await this.storeInternalFileToDatabaseCore(file, forceWrite, false);
}
private async storeInternalFileToDatabaseStrict(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
return await this.storeInternalFileToDatabaseCore(file, forceWrite, true);
}
private async storeInternalFileToDatabaseCore(
file: InternalFileInfo | UXFileInfo,
forceWrite: boolean,
strict: boolean
) {
const storeFilePath = stripAllPrefixes(file.path);
const storageFilePath = file.path;
if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) {
@@ -1565,9 +1675,11 @@ Offline Changed files: ${files.length}`;
if (fileInfo.deleted) {
throw new Error(`Hidden file:${storeFilePath} is deleted. This should not be occurred.`);
}
const baseData = await this.__loadBaseSaveData(storeFilePath, true);
const baseData = strict
? await this.loadBaseSaveDataStrict(storeFilePath, true)
: await this.__loadBaseSaveData(storeFilePath, true);
if (baseData === false) throw new Error("Failed to load base data");
if (baseData._rev && !forceWrite) {
if (baseData._rev && !baseData.deleted && !baseData._deleted && !forceWrite) {
// Not newly created, we should check the content is actually modified.
const isSame = await isDocContentSame(readAsBlob(baseData), fileInfo.body);
if (isSame) {
@@ -1592,11 +1704,15 @@ Offline Changed files: ${files.length}`;
this.updateLastProcessed(storeFilePath, saveData, fileInfo.stat);
}
const success = ret && ret.ok;
if (!success && strict) {
throw new Error(`Failed to store hidden file in the database: ${storeFilePath}`);
}
this._log(`STORAGE --> DB:${storageFilePath}: (hidden) ${success ? "Done" : "Failed"}`);
return success;
} catch (ex) {
this._log(`STORAGE --> DB:${storageFilePath}: (hidden) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
if (strict) throw ex;
return false;
}
});
@@ -1676,6 +1792,14 @@ Offline Changed files: ${files.length}`;
}
async deleteInternalFileOnDatabase(filenameSrc: FilePath, forceWrite = false) {
return await this.deleteInternalFileOnDatabaseCore(filenameSrc, forceWrite, false);
}
private async deleteInternalFileOnDatabaseStrict(filenameSrc: FilePath, forceWrite = false) {
return await this.deleteInternalFileOnDatabaseCore(filenameSrc, forceWrite, true);
}
private async deleteInternalFileOnDatabaseCore(filenameSrc: FilePath, _forceWrite: boolean, strict: boolean) {
const storeFilePath = filenameSrc;
const storageFilePath = filenameSrc;
const displayFileName = filenameSrc;
@@ -1686,18 +1810,26 @@ Offline Changed files: ${files.length}`;
}
return await serialized("file-" + prefixedFileName, async () => {
try {
const baseData = await this.__loadBaseSaveData(storeFilePath, false);
const baseData = strict
? await this.loadBaseSaveDataStrict(storeFilePath, false)
: await this.__loadBaseSaveData(storeFilePath, false);
if (baseData === false) throw new Error("Failed to load base data during deleting");
if (baseData._conflicts !== undefined) {
for (const conflictRev of baseData._conflicts) {
await this.localDatabase.removeRevision(baseData._id, conflictRev);
const removed = await this.localDatabase.removeRevision(baseData._id, conflictRev);
if (strict && removed === false) {
throw new Error(`Failed to remove conflicted hidden-file revision ${conflictRev}`);
}
this._log(
`STORAGE -x> DB: ${displayFileName}: (hidden) conflict removed ${baseData._rev} => ${conflictRev}`,
LOG_LEVEL_VERBOSE
);
}
}
if (baseData.deleted) {
const alreadyDeleted = strict
? !baseData._rev || baseData.deleted || baseData._deleted
: baseData.deleted;
if (alreadyDeleted) {
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) already deleted`, LOG_LEVEL_VERBOSE);
this.updateLastProcessedDeletion(storeFilePath, baseData);
return true;
@@ -1718,11 +1850,13 @@ Offline Changed files: ${files.length}`;
return true;
} else {
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) Failed`);
if (strict) throw new Error(`Failed to delete hidden file from the database: ${storeFilePath}`);
return false;
}
} catch (ex) {
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
if (strict) throw ex;
return false;
}
});
@@ -2084,6 +2218,9 @@ Offline Changed files: ${files.length}`;
// services.databaseEvents.handleOnDatabaseInitialisation(this._everyOnInitializeDatabase.bind(this));
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this));
services.fileProcessing.processOptionalFileEventWithResult?.addHandler(
this._processOptionalFileEventWithResult.bind(this)
);
services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this));
services.replication.processOptionalSynchroniseResult.addHandler(this._anyProcessOptionalSyncFiles.bind(this));
services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this));