mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-16 07:37:06 +00:00
Merge latest main into conflict resolution refactor
This commit is contained in:
@@ -25,13 +25,13 @@ import { AbstractModule } from "./modules/AbstractModule";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
|
||||
import { ModuleLiveSyncMain } from "./modules/main/ModuleLiveSyncMain";
|
||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { ModuleBasicMenu } from "./modules/essential/ModuleBasicMenu";
|
||||
import { usePrepareDatabaseForUse } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/prepareDatabaseForUse";
|
||||
import type { Constructor } from "@vrtmrz/livesync-commonlib/compat/common/utils.type";
|
||||
import { useReplicationScheduling, type ReplicationSchedulingControl } from "./serviceFeatures/replicationScheduling";
|
||||
import { createCentralReplicatorProviderDefinitions } from "./common/replicatorProviders";
|
||||
import { useReplicationFeature } from "./serviceFeatures/replication";
|
||||
import { useConflictResolutionFeature } from "./serviceFeatures/conflictResolution";
|
||||
import { useBasicCommandsFeature } from "./serviceFeatures/basicCommands";
|
||||
|
||||
/** Focused views returned by serviceFeatures which the host may consume during composition. */
|
||||
export interface LiveSyncCoreFeatureViews {
|
||||
@@ -44,10 +44,7 @@ export class LiveSyncBaseCore<
|
||||
T extends ServiceContext = ServiceContext,
|
||||
TCommands extends IMinimumLiveSyncCommands = IMinimumLiveSyncCommands,
|
||||
>
|
||||
implements
|
||||
LiveSyncLocalDBEnv,
|
||||
LiveSyncCouchDBReplicatorEnv,
|
||||
HasSettings<ObsidianLiveSyncSettings>
|
||||
implements LiveSyncLocalDBEnv, LiveSyncCouchDBReplicatorEnv, HasSettings<ObsidianLiveSyncSettings>
|
||||
{
|
||||
addOns = [] as TCommands[];
|
||||
|
||||
@@ -94,9 +91,10 @@ export class LiveSyncBaseCore<
|
||||
for (const addOn of addOns) {
|
||||
this._registerAddOn(addOn);
|
||||
}
|
||||
// Register host features and add-ons before replication, then bind
|
||||
// Compose late core features after host features and add-ons, then bind
|
||||
// legacy modules so lifecycle handlers observe the required order.
|
||||
useReplicationFeature(this);
|
||||
useBasicCommandsFeature(this);
|
||||
this.bindModuleFunctions();
|
||||
}
|
||||
/**
|
||||
@@ -157,7 +155,6 @@ export class LiveSyncBaseCore<
|
||||
public registerModules(extraModules: AbstractModule[] = []) {
|
||||
this._registerModule(new ModuleLiveSyncMain(this));
|
||||
this._registerModule(new ModuleResolvingMismatchedTweaks(this));
|
||||
this._registerModule(new ModuleBasicMenu(this));
|
||||
|
||||
for (const module of extraModules) {
|
||||
this._registerModule(module);
|
||||
|
||||
+11
-7
@@ -25,10 +25,8 @@ import type { ServiceModules } from "./types.ts";
|
||||
import { setNoticeClass } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/wrapper";
|
||||
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||
import { LiveSyncBaseCore } from "./LiveSyncBaseCore.ts";
|
||||
import { ModuleObsidianMenu } from "./modules/essentialObsidian/ModuleObsidianMenu.ts";
|
||||
import { ModuleObsidianSettingsAsMarkdown } from "./modules/features/ModuleObsidianSettingAsMarkdown.ts";
|
||||
import { SetupManager } from "./modules/features/SetupManager.ts";
|
||||
import { ModuleMigration } from "./modules/essential/ModuleMigration.ts";
|
||||
import { enableI18nFeature } from "./serviceFeatures/onLayoutReady/enablei18n.ts";
|
||||
import { useOfflineScanner } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
|
||||
@@ -37,7 +35,10 @@ import { useRedFlagFeatures } from "./serviceFeatures/redFlag.ts";
|
||||
import { useSetupProtocolFeature } from "./serviceFeatures/setupObsidian/setupProtocol.ts";
|
||||
import { useSetupQRCodeFeature } from "@/serviceFeatures/setupObsidian/qrCode";
|
||||
import { useSetupURIFeature } from "@/serviceFeatures/setupObsidian/setupUri";
|
||||
import { useSetupManagerHandlersFeature } from "./serviceFeatures/setupObsidian/setupManagerHandlers.ts";
|
||||
import {
|
||||
showOnboardingInvitation,
|
||||
useSetupManagerHandlersFeature,
|
||||
} from "./serviceFeatures/setupObsidian/setupManagerHandlers.ts";
|
||||
import { useP2PReplicatorCommands, useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { useP2PReplicatorUI } from "./serviceFeatures/useP2PReplicatorUI.ts";
|
||||
import { useReviewHarness } from "./serviceFeatures/useReviewHarness.ts";
|
||||
@@ -47,6 +48,8 @@ import { createObsidianCompatibilityReviewUi } from "./serviceFeatures/compatibi
|
||||
import { createFileReflectionProvenance } from "./serviceModules/FileReflectionProvenance.ts";
|
||||
import { useInteractiveConflictResolutionFeature } from "./serviceFeatures/interactiveConflictResolution";
|
||||
import { ConflictResolveModal } from "./modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||
import { useObsidianReplicationRibbonFeature } from "./serviceFeatures/obsidianReplicationRibbon.ts";
|
||||
import { useStartupLifecycleFeature } from "./serviceFeatures/startupLifecycle";
|
||||
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
|
||||
export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
core: LiveSyncCore;
|
||||
@@ -146,7 +149,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
setNoticeClass(Notice);
|
||||
|
||||
const serviceHub = new ObsidianServiceHub(this);
|
||||
let waitForCompatibilityReview = (): Promise<void> => Promise.resolve();
|
||||
|
||||
this.core = new LiveSyncBaseCore(
|
||||
serviceHub,
|
||||
@@ -157,14 +159,12 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
const extraModules = [
|
||||
new ModuleObsidianEvents(this, core),
|
||||
new ModuleObsidianSettingDialogue(this, core),
|
||||
new ModuleObsidianMenu(core),
|
||||
new ModuleObsidianSettingsAsMarkdown(core),
|
||||
new ModuleLog(this, core),
|
||||
new ModuleObsidianDocumentHistory(this, core),
|
||||
new ModuleObsidianGlobalHistory(this, core),
|
||||
// new ModuleDev(this, core),
|
||||
new SetupManager(core), // this should be moved to core?
|
||||
new ModuleMigration(core, () => waitForCompatibilityReview()),
|
||||
];
|
||||
return extraModules;
|
||||
},
|
||||
@@ -187,6 +187,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
setupManager.registerP2PSetupConnectionProbe(replicator.connectionProbe);
|
||||
useP2PReplicatorCommands(core, replicator);
|
||||
useP2PReplicatorUI(core, core, replicator, createInteractiveP2PReplication(replicator));
|
||||
useObsidianReplicationRibbonFeature(core);
|
||||
useRemoteConfiguration(core);
|
||||
|
||||
useSetupProtocolFeature(core, setupManager);
|
||||
@@ -203,7 +204,10 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
core,
|
||||
createObsidianCompatibilityReviewUi(core.confirm)
|
||||
);
|
||||
waitForCompatibilityReview = () => compatibilityReview.openReview();
|
||||
useStartupLifecycleFeature(core, {
|
||||
inviteToOnboarding: () => showOnboardingInvitation(core, setupManager),
|
||||
waitForCompatibilityReview: () => compatibilityReview.openReview(),
|
||||
});
|
||||
useReviewHarness(core, this, compatibilityReview);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { AbstractModule } from "@/modules/AbstractModule";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { copyFileDatabaseInfo } from "@/serviceFeatures/fileDatabaseInfo";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
// Separated Module for basic menu commands, which are not related to obsidian specific features. It is expected to be used in other platforms with minimal changes.
|
||||
// However, it is odd that it has here at all; it really ought to be in each respective feature. It will likely be moved eventually. Until now, addCommand pointed to Obsidian's version.
|
||||
export class ModuleBasicMenu extends AbstractModule {
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
this.addCommand({
|
||||
id: "livesync-replicate",
|
||||
name: $msg("Sync now"),
|
||||
callback: async () => {
|
||||
await this.services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-dump",
|
||||
name: $msg("Copy database information for the active file"),
|
||||
checkCallback: (checking) => {
|
||||
const file = this.services.vault.getActiveFilePath();
|
||||
if (!file) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => copyFileDatabaseInfo(this.core, file));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-toggle",
|
||||
name: "Toggle LiveSync",
|
||||
callback: async () => {
|
||||
if (this.settings.liveSync) {
|
||||
this.settings.liveSync = false;
|
||||
this._log("LiveSync Disabled.", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
this.settings.liveSync = true;
|
||||
this._log("LiveSync Enabled.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await this.services.control.applySettings();
|
||||
await this.services.setting.saveSettingData();
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-suspendall",
|
||||
name: "Toggle All Sync.",
|
||||
callback: async () => {
|
||||
if (this.services.appLifecycle.isSuspended()) {
|
||||
this.services.appLifecycle.setSuspended(false);
|
||||
this._log("Self-hosted LiveSync resumed", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
this.services.appLifecycle.setSuspended(true);
|
||||
this._log("Self-hosted LiveSync suspended", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await this.services.control.applySettings();
|
||||
await this.services.setting.saveSettingData();
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "livesync-scan-files",
|
||||
name: "Scan storage and database again",
|
||||
checkCallback: (checking) => {
|
||||
if (!this.settings.useAdvancedMode) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => this.services.vault.scanVault(true));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "livesync-runbatch",
|
||||
name: $msg("Apply pending changes now"),
|
||||
callback: async () => {
|
||||
await this.services.fileProcessing.commitPendingFileEvents();
|
||||
},
|
||||
});
|
||||
|
||||
// TODO, Replicator is possibly one of features. It should be moved to features.
|
||||
this.addCommand({
|
||||
id: "livesync-abortsync",
|
||||
name: "Abort synchronization immediately",
|
||||
checkCallback: (checking) => {
|
||||
if (!this.settings.useAdvancedMode) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => this.services.replication.stopActiveTransfer());
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Command } from "@/deps";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { ModuleBasicMenu } from "./ModuleBasicMenu";
|
||||
|
||||
type RegisteredCommand = Command & {
|
||||
checkCallback?: (checking: boolean) => boolean | void;
|
||||
};
|
||||
|
||||
function createFixture() {
|
||||
const commands: RegisteredCommand[] = [];
|
||||
const settings = {
|
||||
liveSync: false,
|
||||
useAdvancedMode: false,
|
||||
enableDebugTools: false,
|
||||
};
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn((command: RegisteredCommand) => {
|
||||
commands.push(command);
|
||||
return command;
|
||||
}),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
replication: {
|
||||
replicateUserInitiated: vi.fn(async () => ({ status: "completed" as const })),
|
||||
stopActiveTransfer: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
vault: {
|
||||
getActiveFilePath: vi.fn((): string | null => "note.md"),
|
||||
scanVault: vi.fn(async () => undefined),
|
||||
},
|
||||
control: {
|
||||
applySettings: vi.fn(async () => undefined),
|
||||
},
|
||||
setting: {
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
appLifecycle: {
|
||||
isSuspended: vi.fn(() => false),
|
||||
setSuspended: vi.fn(),
|
||||
},
|
||||
fileProcessing: {
|
||||
commitPendingFileEvents: vi.fn(async () => true),
|
||||
},
|
||||
UI: {
|
||||
promptCopyToClipboard: vi.fn(async (_title: string, _value: string) => true),
|
||||
},
|
||||
path: {
|
||||
path2id: vi.fn(async () => "f:note"),
|
||||
},
|
||||
};
|
||||
const core = {
|
||||
settings,
|
||||
_services: services,
|
||||
services,
|
||||
localDatabase: {
|
||||
getDBEntry: vi.fn(async () => false),
|
||||
localDatabase: {
|
||||
get: vi.fn(async () => ({
|
||||
_id: "f:note",
|
||||
_rev: "2-current",
|
||||
_conflicts: [],
|
||||
path: "note.md",
|
||||
ctime: 100,
|
||||
mtime: 200,
|
||||
size: 12,
|
||||
type: "plain",
|
||||
children: ["h:private-chunk-id"],
|
||||
eden: {},
|
||||
})),
|
||||
},
|
||||
getDBEntryMeta: vi.fn(async () => ({
|
||||
_id: "f:note",
|
||||
_rev: "2-current",
|
||||
_conflicts: [],
|
||||
path: "note.md",
|
||||
ctime: 100,
|
||||
mtime: 200,
|
||||
size: 12,
|
||||
type: "plain",
|
||||
datatype: "plain",
|
||||
data: "",
|
||||
children: ["h:private-chunk-id"],
|
||||
eden: {},
|
||||
})),
|
||||
allDocsRaw: vi.fn(async () => ({
|
||||
rows: [{ id: "h:private-chunk-id", key: "h:private-chunk-id", value: { rev: "1-chunk" } }],
|
||||
})),
|
||||
},
|
||||
storageAccess: {
|
||||
isExistsIncludeHidden: vi.fn(async () => true),
|
||||
statHidden: vi.fn(async () => ({ ctime: 100, mtime: 200, size: 12, type: "file" })),
|
||||
},
|
||||
replicator: {
|
||||
terminateSync: vi.fn(),
|
||||
},
|
||||
};
|
||||
const module = new ModuleBasicMenu(core as never);
|
||||
|
||||
return {
|
||||
commands,
|
||||
core,
|
||||
module,
|
||||
services,
|
||||
settings,
|
||||
getCommand(id: string) {
|
||||
const command = commands.find((candidate) => candidate.id === id);
|
||||
expect(command, `command ${id}`).toBeDefined();
|
||||
return command!;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ModuleBasicMenu command palette", () => {
|
||||
it("uses clear user-facing names without changing the established command IDs", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
expect(fixture.getCommand("livesync-replicate").name).toBe("Sync now");
|
||||
expect(fixture.getCommand("livesync-runbatch").name).toBe("Apply pending changes now");
|
||||
});
|
||||
|
||||
it("keeps Sync now progress quiet while retaining failure-recovery authority", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
await fixture.getCommand("livesync-replicate").callback?.();
|
||||
|
||||
expect(fixture.services.replication.replicateUserInitiated).toHaveBeenCalledWith({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps maintenance commands out of the normal palette", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(false);
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(false);
|
||||
|
||||
fixture.settings.useAdvancedMode = true;
|
||||
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(true);
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(true);
|
||||
});
|
||||
|
||||
it("routes an explicit stop through the active provider capability", async () => {
|
||||
const fixture = createFixture();
|
||||
fixture.settings.useAdvancedMode = true;
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(false)).toBe(true);
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.services.replication.stopActiveTransfer).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(fixture.core.replicator.terminateSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps active-file database information available and opens it in a copy dialogue", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
const command = fixture.getCommand("livesync-dump");
|
||||
expect(command.name).toBe("Copy database information for the active file");
|
||||
expect(command.checkCallback?.(true)).toBe(true);
|
||||
|
||||
command.checkCallback?.(false);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.services.UI.promptCopyToClipboard).toHaveBeenCalledOnce();
|
||||
});
|
||||
const [title, report] = fixture.services.UI.promptCopyToClipboard.mock.calls[0];
|
||||
expect(title).toBe("Database information for note.md");
|
||||
expect(report).toContain("note.md");
|
||||
expect(report).toContain("2-current");
|
||||
expect(report).toContain("h:private-chunk-id");
|
||||
expect(report).toContain("1-chunk");
|
||||
expect(fixture.core.localDatabase.getDBEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the active-file database report when no file is active", async () => {
|
||||
const fixture = createFixture();
|
||||
fixture.services.vault.getActiveFilePath.mockReturnValue(null);
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
expect(fixture.getCommand("livesync-dump").checkCallback?.(true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,346 +0,0 @@
|
||||
import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
Logger,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { EVENT_REQUEST_RUN_DOCTOR, EVENT_REQUEST_RUN_FIX_INCOMPLETE, eventHub } from "@/common/events.ts";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { performDoctorConsultation, RebuildOptions } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
|
||||
import { isValidPath } from "@/common/utils.ts";
|
||||
import { isMetaEntry } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
isDeletedEntry,
|
||||
isDocContentSame,
|
||||
isLoadedEntry,
|
||||
readAsBlob,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { countCompromisedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { SetupManager } from "@/modules/features/SetupManager.ts";
|
||||
import { showOnboardingInvitation } from "@/serviceFeatures/setupObsidian/setupManagerHandlers.ts";
|
||||
import {
|
||||
runConfiguredStartupLifecycle,
|
||||
runStartupEntryLifecycle,
|
||||
} from "@/serviceFeatures/configuredStartupLifecycle.ts";
|
||||
import { disableLegacyBulkChunkPreSend } from "@/common/compatibilitySettings.ts";
|
||||
|
||||
type ErrorInfo = {
|
||||
path: string;
|
||||
recordedSize: number;
|
||||
actualSize: number;
|
||||
storageSize: number;
|
||||
contentMatched: boolean;
|
||||
isConflicted?: boolean;
|
||||
};
|
||||
|
||||
const INCOMPLETE_DOCUMENT_NOTICE_GROUP = "startup-integrity-check";
|
||||
|
||||
interface CompromisedChunkCounter {
|
||||
countCompromisedChunks(): Promise<number | boolean>;
|
||||
}
|
||||
|
||||
function hasCompromisedChunkCounter(value: object | undefined): value is CompromisedChunkCounter {
|
||||
return (
|
||||
value !== undefined && "countCompromisedChunks" in value && typeof value.countCompromisedChunks === "function"
|
||||
);
|
||||
}
|
||||
|
||||
export class ModuleMigration extends AbstractModule<LiveSyncCore> {
|
||||
constructor(
|
||||
core: LiveSyncCore,
|
||||
private readonly waitForCompatibilityReview: () => Promise<void> = () => Promise.resolve()
|
||||
) {
|
||||
super(core);
|
||||
}
|
||||
|
||||
async migrateUsingDoctor(skipRebuild: boolean = false, activateReason = "updated", forceRescan = false) {
|
||||
const { shouldRebuild, shouldRebuildLocal, isModified, settings } = await performDoctorConsultation(
|
||||
{
|
||||
confirm: this.core.confirm,
|
||||
translate: this.services.context.translate,
|
||||
},
|
||||
this.settings,
|
||||
{
|
||||
localRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
|
||||
remoteRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
|
||||
activateReason,
|
||||
forceRescan,
|
||||
}
|
||||
);
|
||||
if (isModified) {
|
||||
this.settings = settings;
|
||||
await this.saveSettings();
|
||||
}
|
||||
if (!skipRebuild) {
|
||||
if (shouldRebuild) {
|
||||
await this.core.rebuilder.scheduleRebuild();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
} else if (shouldRebuildLocal) {
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async migrateDisableBulkSend() {
|
||||
if (disableLegacyBulkChunkPreSend(this.settings)) {
|
||||
this._log($msg("moduleMigration.logBulkSendCorrupted"), LOG_LEVEL_NOTICE);
|
||||
await this.saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
initialMessage() {
|
||||
const manager = this.core.getModule(SetupManager);
|
||||
showOnboardingInvitation(this.core, manager);
|
||||
}
|
||||
|
||||
async hasIncompleteDocs(force: boolean = false): Promise<boolean> {
|
||||
const incompleteDocsChecked = (await this.core.kvDB.get<boolean>("checkIncompleteDocs")) || false;
|
||||
if (incompleteDocsChecked && !force) {
|
||||
this._log("Incomplete docs check already done, skipping.", LOG_LEVEL_VERBOSE);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
const noticeGroups = this.core.services.context.noticeGroups;
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "checking", {
|
||||
message: "Checking for incomplete documents...",
|
||||
});
|
||||
this._log("Checking for incomplete documents...", LOG_LEVEL_VERBOSE);
|
||||
|
||||
try {
|
||||
const errorFiles = [] as ErrorInfo[];
|
||||
for await (const metaDoc of this.localDatabase.findAllNormalDocs({ conflicts: true })) {
|
||||
const path = this.getPath(metaDoc);
|
||||
|
||||
if (!isValidPath(path)) {
|
||||
continue;
|
||||
}
|
||||
if (!(await this.services.vault.isTargetFile(path))) {
|
||||
continue;
|
||||
}
|
||||
if (!isMetaEntry(metaDoc)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const doc = await this.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 this.core.storageAccess.readHiddenFileBinary(path);
|
||||
} catch (e) {
|
||||
Logger(`Failed to read file ${path}: Possibly unprocessed or missing`);
|
||||
Logger(e, LOG_LEVEL_VERBOSE);
|
||||
continue;
|
||||
}
|
||||
// const storageFileBlob = createBlob(storageFileContent);
|
||||
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_INFO);
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: "No size mismatches found",
|
||||
});
|
||||
await this.core.kvDB.set("checkIncompleteDocs", true);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
Logger(`Found ${errorFiles.length} size mismatches`, LOG_LEVEL_INFO);
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: `Found ${errorFiles.length} size mismatches`,
|
||||
});
|
||||
// We have to repair them following rules and situations:
|
||||
// A. DB Recorded != DB Stored
|
||||
// A.1. DB Recorded == Storage Stored
|
||||
// Possibly recoverable from storage. Just overwrite the DB content with storage content.
|
||||
// A.2. Neither
|
||||
// Probably it cannot be resolved on this device. Even if the storage content is larger than DB Recorded, it possibly corrupted.
|
||||
// We do not fix it automatically. Leave it as is. Possibly other device can do this.
|
||||
// B. DB Recorded == DB Stored , < Storage Stored
|
||||
// Very fragile, if DB Recorded size is less than Storage Stored size, we possibly repair the content (The issue was `unexpectedly shortened file`).
|
||||
// We do not fix it automatically, but it will be automatically overwritten in other process.
|
||||
// C. DB Recorded == DB Stored , > Storage Stored
|
||||
// Probably restored by the user by resolving A or B on other device, We should overwrite the storage
|
||||
// Also do not fix it automatically. It should be overwritten by replication.
|
||||
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 this.core.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) {
|
||||
// Overwrite the database with the files on the storage
|
||||
const stubFile = await this.core.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 this.core.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) {
|
||||
// User chose to dismiss the issue
|
||||
await this.core.kvDB.set("checkIncompleteDocs", true);
|
||||
}
|
||||
|
||||
return Promise.resolve(true);
|
||||
} catch (error) {
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: "The incomplete document check could not be completed.",
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
noticeGroups.finish(INCOMPLETE_DOCUMENT_NOTICE_GROUP);
|
||||
}
|
||||
}
|
||||
|
||||
async hasCompromisedChunks(): Promise<boolean> {
|
||||
Logger(`Checking for compromised chunks...`, LOG_LEVEL_VERBOSE);
|
||||
if (!this.settings.encrypt) {
|
||||
// If not encrypted, we do not need to check for compromised chunks.
|
||||
return true;
|
||||
}
|
||||
// Check local database for compromised chunks
|
||||
const localCompromised = await countCompromisedChunks(this.localDatabase.localDatabase);
|
||||
const remote = this.services.replicator.getActiveReplicator();
|
||||
const remoteCompromised =
|
||||
this.services.API.isOnline && hasCompromisedChunkCounter(remote)
|
||||
? 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 this.core.confirm.askSelectStringDialogue(msg, buttons, {
|
||||
title,
|
||||
defaultAction: DISMISS,
|
||||
timeout: 0,
|
||||
});
|
||||
if (result === REBUILD) {
|
||||
// Rebuild the database
|
||||
await this.core.rebuilder.scheduleRebuild();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
} else if (result === FETCH) {
|
||||
// Fetch the latest data from remote
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
} else {
|
||||
// User chose to dismiss the issue
|
||||
this._log($msg("moduleMigration.insecureChunkExist.laterMessage"), LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async _everyOnFirstInitialize(): Promise<boolean> {
|
||||
return await runConfiguredStartupLifecycle({
|
||||
databaseReady: this.localDatabase.isReady,
|
||||
reportDatabaseNotReady: () => this._log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE),
|
||||
hasCompromisedChunks: () => this.hasCompromisedChunks(),
|
||||
hasIncompleteDocuments: () => this.hasIncompleteDocs(),
|
||||
waitForCompatibilityReview: () => this.waitForCompatibilityReview(),
|
||||
runDoctor: () => this.migrateUsingDoctor(false),
|
||||
migrateBulkSend: () => this.migrateDisableBulkSend(),
|
||||
});
|
||||
}
|
||||
_everyOnLayoutReady(): Promise<boolean> {
|
||||
const shouldInitialiseDatabase = runStartupEntryLifecycle({
|
||||
configured: this.settings.isConfigured === true,
|
||||
inviteToOnboarding: () => this.initialMessage(),
|
||||
});
|
||||
if (!shouldInitialiseDatabase) return Promise.resolve(false);
|
||||
eventHub.onEvent(EVENT_REQUEST_RUN_DOCTOR, async (reason) => {
|
||||
await this.migrateUsingDoctor(false, reason, true);
|
||||
});
|
||||
eventHub.onEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE, async () => {
|
||||
await this.hasIncompleteDocs(true);
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
super.onBindFunction(core, services);
|
||||
services.appLifecycle.onLayoutReady.addHandler(this._everyOnLayoutReady.bind(this));
|
||||
services.appLifecycle.onFirstInitialise.addHandler(this._everyOnFirstInitialize.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/modules/features/SetupManager.ts", () => ({
|
||||
SetupManager: class SetupManager {},
|
||||
}));
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
isValidPath: () => true,
|
||||
}));
|
||||
|
||||
import { ModuleMigration } from "./ModuleMigration.ts";
|
||||
|
||||
async function* noDocuments() {
|
||||
return;
|
||||
}
|
||||
|
||||
async function* failedDocumentScan() {
|
||||
throw new Error("scan failed");
|
||||
}
|
||||
|
||||
function createMigration(
|
||||
findAllNormalDocs: typeof noDocuments | typeof failedDocumentScan = noDocuments,
|
||||
settings = { sendChunksBulk: false, sendChunksBulkMaxSize: 1 }
|
||||
) {
|
||||
const noticeGroups = {
|
||||
setItem: vi.fn(),
|
||||
finish: vi.fn(() => true),
|
||||
};
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
context: { noticeGroups },
|
||||
setting: { saveSettingData: vi.fn(async () => undefined) },
|
||||
vault: { isTargetFile: vi.fn(async () => true) },
|
||||
path: { getPath: vi.fn() },
|
||||
};
|
||||
const core = {
|
||||
_services: services,
|
||||
services,
|
||||
kvDB: {
|
||||
get: vi.fn(async () => false),
|
||||
set: vi.fn(async () => undefined),
|
||||
},
|
||||
localDatabase: { findAllNormalDocs },
|
||||
storageAccess: {},
|
||||
settings,
|
||||
};
|
||||
return {
|
||||
migration: new ModuleMigration(core as never),
|
||||
noticeGroups,
|
||||
saveSettingData: services.setting.saveSettingData,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ModuleMigration obsolete-setting migration", () => {
|
||||
it("persists the removal of an enabled automatic bulk chunk pre-send setting", async () => {
|
||||
const settings = { sendChunksBulk: true, sendChunksBulkMaxSize: 16 };
|
||||
const { migration, saveSettingData } = createMigration(noDocuments, settings);
|
||||
|
||||
await migration.migrateDisableBulkSend();
|
||||
|
||||
expect(settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 1 });
|
||||
expect(saveSettingData).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not persist an already disabled automatic bulk chunk pre-send setting", async () => {
|
||||
const settings = { sendChunksBulk: false, sendChunksBulkMaxSize: 16 };
|
||||
const { migration, saveSettingData } = createMigration(noDocuments, settings);
|
||||
|
||||
await migration.migrateDisableBulkSend();
|
||||
|
||||
expect(settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 16 });
|
||||
expect(saveSettingData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleMigration incomplete-document notice", () => {
|
||||
it("keeps the check and its result in one persistent named group", async () => {
|
||||
const { migration, noticeGroups } = createMigration();
|
||||
|
||||
await expect(migration.hasIncompleteDocs()).resolves.toBe(true);
|
||||
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(1, "startup-integrity-check", "checking", {
|
||||
message: "Checking for incomplete documents...",
|
||||
});
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(2, "startup-integrity-check", "result", {
|
||||
message: "No size mismatches found",
|
||||
});
|
||||
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
|
||||
});
|
||||
|
||||
it("finishes the group with a failure result when the scan throws", async () => {
|
||||
const { migration, noticeGroups } = createMigration(failedDocumentScan);
|
||||
|
||||
await expect(migration.hasIncompleteDocs()).rejects.toThrow("scan failed");
|
||||
|
||||
expect(noticeGroups.setItem).toHaveBeenLastCalledWith("startup-integrity-check", "result", {
|
||||
message: "The incomplete document check could not be completed.",
|
||||
});
|
||||
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({ addIcon: vi.fn() }));
|
||||
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { ModuleObsidianMenu } from "./ModuleObsidianMenu";
|
||||
|
||||
describe("ModuleObsidianMenu ribbon", () => {
|
||||
it("retains visible progress and full interaction authority", async () => {
|
||||
let runRibbonAction: (() => Promise<void>) | undefined;
|
||||
const addClass = vi.fn();
|
||||
const replicateUserInitiated = vi.fn(async () => ({ status: "completed" as const }));
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
addRibbonIcon: vi.fn((_icon: string, _title: string, callback: () => Promise<void>) => {
|
||||
runRibbonAction = callback;
|
||||
return { addClass };
|
||||
}),
|
||||
},
|
||||
replication: { replicateUserInitiated },
|
||||
};
|
||||
const module = new ModuleObsidianMenu({ _services: services, services } as never);
|
||||
|
||||
await module._everyOnloadStart();
|
||||
await runRibbonAction?.();
|
||||
|
||||
expect(replicateUserInitiated).toHaveBeenCalledWith({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
expect(addClass).toHaveBeenCalledWith("livesync-ribbon-replicate");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { copyFileDatabaseInfo, type FileDatabaseInfoCore } from "@/serviceFeatures/fileDatabaseInfo";
|
||||
|
||||
/**
|
||||
* Services required by the platform-independent command palette actions.
|
||||
*
|
||||
* The database report deliberately receives a structural adapter rather than
|
||||
* the whole host. This keeps the report helper independent from the core while
|
||||
* retaining the same local database, storage, settings, path, and UI sources.
|
||||
*/
|
||||
export type BasicCommandsHost = NecessaryServices<
|
||||
| "API"
|
||||
| "appLifecycle"
|
||||
| "control"
|
||||
| "database"
|
||||
| "fileProcessing"
|
||||
| "path"
|
||||
| "replication"
|
||||
| "setting"
|
||||
| "UI"
|
||||
| "vault",
|
||||
"storageAccess"
|
||||
>;
|
||||
|
||||
function createFileDatabaseInfoCore(host: BasicCommandsHost): FileDatabaseInfoCore {
|
||||
const { services, serviceModules } = host;
|
||||
return {
|
||||
localDatabase: services.database.localDatabase,
|
||||
services: {
|
||||
path: services.path,
|
||||
UI: services.UI,
|
||||
},
|
||||
settings: services.setting.currentSettings(),
|
||||
storageAccess: serviceModules.storageAccess,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the platform-independent command palette actions.
|
||||
*
|
||||
* Registration remains tied to `onInitialise`, matching the legacy module's
|
||||
* timing and allowing hosts to compose the feature before the lifecycle runs.
|
||||
*/
|
||||
export function useBasicCommandsFeature(host: BasicCommandsHost): void {
|
||||
const { services } = host;
|
||||
const log = createInstanceLogFunction("SF:BasicCommands", services.API);
|
||||
|
||||
services.appLifecycle.onInitialise.addHandler(() => {
|
||||
services.API.addCommand({
|
||||
id: "livesync-replicate",
|
||||
name: $msg("Sync now"),
|
||||
callback: async () => {
|
||||
await services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
id: "livesync-dump",
|
||||
name: $msg("Copy database information for the active file"),
|
||||
checkCallback: (checking) => {
|
||||
const file = services.vault.getActiveFilePath();
|
||||
if (!file) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => copyFileDatabaseInfo(createFileDatabaseInfoCore(host), file));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
id: "livesync-toggle",
|
||||
name: "Toggle LiveSync",
|
||||
callback: async () => {
|
||||
const settings = services.setting.currentSettings();
|
||||
if (settings.liveSync) {
|
||||
settings.liveSync = false;
|
||||
log("LiveSync Disabled.", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
settings.liveSync = true;
|
||||
log("LiveSync Enabled.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await services.control.applySettings();
|
||||
await services.setting.saveSettingData();
|
||||
},
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
id: "livesync-suspendall",
|
||||
name: "Toggle All Sync.",
|
||||
callback: async () => {
|
||||
if (services.appLifecycle.isSuspended()) {
|
||||
services.appLifecycle.setSuspended(false);
|
||||
log("Self-hosted LiveSync resumed", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
services.appLifecycle.setSuspended(true);
|
||||
log("Self-hosted LiveSync suspended", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await services.control.applySettings();
|
||||
await services.setting.saveSettingData();
|
||||
},
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
id: "livesync-scan-files",
|
||||
name: "Scan storage and database again",
|
||||
checkCallback: (checking) => {
|
||||
if (!services.setting.currentSettings().useAdvancedMode) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => services.vault.scanVault(true));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
id: "livesync-runbatch",
|
||||
name: $msg("Apply pending changes now"),
|
||||
callback: async () => {
|
||||
await services.fileProcessing.commitPendingFileEvents();
|
||||
},
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
id: "livesync-abortsync",
|
||||
name: "Abort synchronization immediately",
|
||||
checkCallback: (checking) => {
|
||||
if (!services.setting.currentSettings().useAdvancedMode) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => services.replication.stopActiveTransfer());
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ICommandCompat } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { copyFileDatabaseInfo } from "./fileDatabaseInfo";
|
||||
import { useBasicCommandsFeature, type BasicCommandsHost } from "./basicCommands";
|
||||
|
||||
vi.mock("./fileDatabaseInfo", () => ({
|
||||
copyFileDatabaseInfo: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
type RegisteredCommand = ICommandCompat & {
|
||||
checkCallback?: (checking: boolean) => boolean | void;
|
||||
};
|
||||
|
||||
function createFixture() {
|
||||
const commands: RegisteredCommand[] = [];
|
||||
const initialiseHandlers: Array<() => Promise<unknown>> = [];
|
||||
const settings = {
|
||||
liveSync: false,
|
||||
useAdvancedMode: false,
|
||||
};
|
||||
const api = {
|
||||
addCommand: vi.fn((command: RegisteredCommand) => {
|
||||
commands.push(command);
|
||||
return command;
|
||||
}),
|
||||
addLog: vi.fn(),
|
||||
};
|
||||
const services = {
|
||||
API: api,
|
||||
appLifecycle: {
|
||||
onInitialise: {
|
||||
addHandler: vi.fn((handler: () => Promise<unknown>) => {
|
||||
initialiseHandlers.push(handler);
|
||||
}),
|
||||
},
|
||||
isSuspended: vi.fn(() => false),
|
||||
setSuspended: vi.fn(),
|
||||
},
|
||||
control: {
|
||||
applySettings: vi.fn(async () => undefined),
|
||||
},
|
||||
database: {
|
||||
localDatabase: { databaseMarker: "local" },
|
||||
},
|
||||
fileProcessing: {
|
||||
commitPendingFileEvents: vi.fn(async () => true),
|
||||
},
|
||||
path: {
|
||||
path2id: vi.fn(async () => "f:note"),
|
||||
},
|
||||
replication: {
|
||||
replicateUserInitiated: vi.fn(async () => ({ status: "completed" as const })),
|
||||
stopActiveTransfer: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
setting: {
|
||||
currentSettings: vi.fn(() => settings),
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
UI: {
|
||||
promptCopyToClipboard: vi.fn(async () => true),
|
||||
},
|
||||
vault: {
|
||||
getActiveFilePath: vi.fn((): string | undefined => "note.md"),
|
||||
scanVault: vi.fn(async () => true),
|
||||
},
|
||||
};
|
||||
const serviceModules = {
|
||||
storageAccess: {
|
||||
isExistsIncludeHidden: vi.fn(async () => true),
|
||||
statHidden: vi.fn(async () => ({ ctime: 0, mtime: 0, size: 0, type: "file" })),
|
||||
},
|
||||
};
|
||||
const host = { services, serviceModules } as unknown as BasicCommandsHost;
|
||||
|
||||
return {
|
||||
api,
|
||||
commands,
|
||||
host,
|
||||
initialiseHandlers,
|
||||
services,
|
||||
serviceModules,
|
||||
settings,
|
||||
getCommand(id: string) {
|
||||
const command = commands.find((candidate) => candidate.id === id);
|
||||
expect(command, `command ${id}`).toBeDefined();
|
||||
return command!;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function initialise(fixture: ReturnType<typeof createFixture>) {
|
||||
useBasicCommandsFeature(fixture.host);
|
||||
expect(fixture.initialiseHandlers).toHaveLength(1);
|
||||
expect(fixture.commands).toHaveLength(0);
|
||||
await fixture.initialiseHandlers[0]?.();
|
||||
}
|
||||
|
||||
describe("useBasicCommandsFeature", () => {
|
||||
it("registers all established commands only when initialisation runs", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
useBasicCommandsFeature(fixture.host);
|
||||
|
||||
expect(fixture.services.appLifecycle.onInitialise.addHandler).toHaveBeenCalledOnce();
|
||||
expect(fixture.api.addCommand).not.toHaveBeenCalled();
|
||||
|
||||
await fixture.initialiseHandlers[0]?.();
|
||||
|
||||
expect(fixture.commands.map(({ id }) => id)).toEqual([
|
||||
"livesync-replicate",
|
||||
"livesync-dump",
|
||||
"livesync-toggle",
|
||||
"livesync-suspendall",
|
||||
"livesync-scan-files",
|
||||
"livesync-runbatch",
|
||||
"livesync-abortsync",
|
||||
]);
|
||||
expect(fixture.getCommand("livesync-replicate").name).toBe("Sync now");
|
||||
expect(fixture.getCommand("livesync-dump").name).toBe("Copy database information for the active file");
|
||||
expect(fixture.getCommand("livesync-toggle").name).toBe("Toggle LiveSync");
|
||||
expect(fixture.getCommand("livesync-suspendall").name).toBe("Toggle All Sync.");
|
||||
expect(fixture.getCommand("livesync-scan-files").name).toBe("Scan storage and database again");
|
||||
expect(fixture.getCommand("livesync-runbatch").name).toBe("Apply pending changes now");
|
||||
expect(fixture.getCommand("livesync-abortsync").name).toBe("Abort synchronization immediately");
|
||||
});
|
||||
|
||||
it("retains the manual replication authority and quiet progress presentation", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
await fixture.getCommand("livesync-replicate").callback?.();
|
||||
|
||||
expect(fixture.services.replication.replicateUserInitiated).toHaveBeenCalledWith({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles LiveSync and persists the updated setting", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
await fixture.getCommand("livesync-toggle").callback?.();
|
||||
|
||||
expect(fixture.settings.liveSync).toBe(true);
|
||||
expect(fixture.api.addLog).toHaveBeenCalledWith("LiveSync Enabled.", expect.anything(), "");
|
||||
expect(fixture.services.control.applySettings).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.setting.saveSettingData).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.control.applySettings.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
fixture.services.setting.saveSettingData.mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
await fixture.getCommand("livesync-toggle").callback?.();
|
||||
|
||||
expect(fixture.settings.liveSync).toBe(false);
|
||||
expect(fixture.api.addLog).toHaveBeenCalledWith("LiveSync Disabled.", expect.anything(), "");
|
||||
});
|
||||
|
||||
it("toggles all synchronisation through the app lifecycle and persists it", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
await fixture.getCommand("livesync-suspendall").callback?.();
|
||||
|
||||
expect(fixture.services.appLifecycle.setSuspended).toHaveBeenCalledWith(true);
|
||||
expect(fixture.api.addLog).toHaveBeenCalledWith("Self-hosted LiveSync suspended", expect.anything(), "");
|
||||
expect(fixture.services.control.applySettings).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.setting.saveSettingData).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.control.applySettings.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
fixture.services.setting.saveSettingData.mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
fixture.services.appLifecycle.isSuspended.mockReturnValue(true);
|
||||
await fixture.getCommand("livesync-suspendall").callback?.();
|
||||
|
||||
expect(fixture.services.appLifecycle.setSuspended).toHaveBeenLastCalledWith(false);
|
||||
expect(fixture.api.addLog).toHaveBeenCalledWith("Self-hosted LiveSync resumed", expect.anything(), "");
|
||||
});
|
||||
|
||||
it("keeps advanced maintenance checks gated and invokes their exact actions", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(false);
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(false);
|
||||
|
||||
fixture.settings.useAdvancedMode = true;
|
||||
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(true);
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(true);
|
||||
expect(fixture.services.vault.scanVault).not.toHaveBeenCalled();
|
||||
expect(fixture.services.replication.stopActiveTransfer).not.toHaveBeenCalled();
|
||||
|
||||
fixture.getCommand("livesync-scan-files").checkCallback?.(false);
|
||||
fixture.getCommand("livesync-abortsync").checkCallback?.(false);
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.services.vault.scanVault).toHaveBeenCalledWith(true);
|
||||
expect(fixture.services.replication.stopActiveTransfer).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it("copies active-file database information through a narrow structural adapter", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
const dump = fixture.getCommand("livesync-dump");
|
||||
vi.mocked(copyFileDatabaseInfo).mockClear();
|
||||
expect(dump.checkCallback?.(true)).toBe(true);
|
||||
expect(copyFileDatabaseInfo).not.toHaveBeenCalled();
|
||||
dump.checkCallback?.(false);
|
||||
|
||||
await vi.waitFor(() => expect(copyFileDatabaseInfo).toHaveBeenCalledOnce());
|
||||
const [adapter, path] = vi.mocked(copyFileDatabaseInfo).mock.calls[0] ?? [];
|
||||
expect(path).toBe("note.md");
|
||||
expect(adapter).toEqual({
|
||||
localDatabase: fixture.services.database.localDatabase,
|
||||
services: {
|
||||
path: fixture.services.path,
|
||||
UI: fixture.services.UI,
|
||||
},
|
||||
settings: fixture.settings,
|
||||
storageAccess: fixture.serviceModules.storageAccess,
|
||||
});
|
||||
expect(adapter).not.toBe(fixture.host);
|
||||
});
|
||||
|
||||
it("commits pending file events from the batch command", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
await fixture.getCommand("livesync-runbatch").callback?.();
|
||||
|
||||
expect(fixture.services.fileProcessing.commitPendingFileEvents).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps the active-file report unavailable without an active file", async () => {
|
||||
const fixture = createFixture();
|
||||
fixture.services.vault.getActiveFilePath.mockReturnValue(undefined);
|
||||
await initialise(fixture);
|
||||
|
||||
expect(fixture.getCommand("livesync-dump").checkCallback?.(true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
runConfiguredStartupLifecycle,
|
||||
runStartupEntryLifecycle,
|
||||
type ConfiguredStartupLifecycleRuntime,
|
||||
} from "./configuredStartupLifecycle";
|
||||
|
||||
function createRuntime(): ConfiguredStartupLifecycleRuntime & { events: string[] } {
|
||||
const events: string[] = [];
|
||||
return {
|
||||
events,
|
||||
databaseReady: true,
|
||||
reportDatabaseNotReady: vi.fn(() => events.push("database-not-ready")),
|
||||
hasCompromisedChunks: vi.fn(async () => {
|
||||
events.push("compromised-chunks");
|
||||
return true;
|
||||
}),
|
||||
hasIncompleteDocuments: vi.fn(async () => {
|
||||
events.push("incomplete-documents");
|
||||
return true;
|
||||
}),
|
||||
waitForCompatibilityReview: vi.fn(async () => {}),
|
||||
runDoctor: vi.fn(async () => {
|
||||
events.push("doctor");
|
||||
return true;
|
||||
}),
|
||||
migrateBulkSend: vi.fn(async () => {
|
||||
events.push("bulk-send");
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("runConfiguredStartupLifecycle", () => {
|
||||
it("runs configured checks in order before allowing initialisation", async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(true);
|
||||
|
||||
expect(runtime.events).toEqual(["compromised-chunks", "incomplete-documents", "doctor", "bulk-send"]);
|
||||
});
|
||||
|
||||
it("keeps Config Doctor behind the initial compatibility review", async () => {
|
||||
const runtime = createRuntime();
|
||||
Object.assign(runtime, {
|
||||
waitForCompatibilityReview: vi.fn(async () => {
|
||||
runtime.events.push("compatibility-review");
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(true);
|
||||
|
||||
expect(runtime.events).toEqual([
|
||||
"compromised-chunks",
|
||||
"incomplete-documents",
|
||||
"compatibility-review",
|
||||
"doctor",
|
||||
"bulk-send",
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops before onboarding or checks when the database is unavailable", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.databaseReady = false;
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
|
||||
expect(runtime.events).toEqual(["database-not-ready"]);
|
||||
});
|
||||
|
||||
it("stops the configured sequence at the first failed check", async () => {
|
||||
const runtime = createRuntime();
|
||||
vi.mocked(runtime.hasIncompleteDocuments).mockImplementation(async () => {
|
||||
runtime.events.push("incomplete-documents");
|
||||
return false;
|
||||
});
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
|
||||
expect(runtime.events).toEqual(["compromised-chunks", "incomplete-documents"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runStartupEntryLifecycle", () => {
|
||||
it("offers onboarding and stops before database initialisation on an unconfigured Vault", () => {
|
||||
const inviteToOnboarding = vi.fn();
|
||||
|
||||
expect(
|
||||
runStartupEntryLifecycle({
|
||||
configured: false,
|
||||
inviteToOnboarding,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(inviteToOnboarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("allows a configured Vault to continue to database initialisation", () => {
|
||||
const inviteToOnboarding = vi.fn();
|
||||
|
||||
expect(
|
||||
runStartupEntryLifecycle({
|
||||
configured: true,
|
||||
inviteToOnboarding,
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(inviteToOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+22
-18
@@ -1,27 +1,35 @@
|
||||
import { addIcon } from "@/deps.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
// Obsidian specific menu commands.
|
||||
export class ModuleObsidianMenu extends AbstractModule {
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
// UI
|
||||
addIcon(
|
||||
"replicate",
|
||||
`<g transform="matrix(1.15 0 0 1.15 -8.31 -9.52)" fill="currentColor" fill-rule="evenodd">
|
||||
|
||||
export type ObsidianReplicationRibbonHost = NecessaryServices<"API" | "appLifecycle" | "replication", never>;
|
||||
|
||||
/** The established SVG used by the Obsidian replication ribbon action. */
|
||||
const REPLICATE_ICON_SVG = `<g transform="matrix(1.15 0 0 1.15 -8.31 -9.52)" fill="currentColor" fill-rule="evenodd">
|
||||
<path d="m85 22.2c-0.799-4.74-4.99-8.37-9.88-8.37-0.499 0-1.1 0.101-1.6 0.101-2.4-3.03-6.09-4.94-10.3-4.94-6.09 0-11.2 4.14-12.8 9.79-5.59 1.11-9.78 6.05-9.78 12 0 6.76 5.39 12.2 12 12.2h29.9c5.79 0 10.1-4.74 10.1-10.6 0-4.84-3.29-8.88-7.68-10.2zm-2.99 14.7h-29.5c-2.3-0.202-4.29-1.51-5.29-3.53-0.899-2.12-0.699-4.54 0.698-6.46 1.2-1.61 2.99-2.52 4.89-2.52 0.299 0 0.698 0 0.998 0.101l1.8 0.303v-2.02c0-3.63 2.4-6.76 5.89-7.57 0.599-0.101 1.2-0.202 1.8-0.202 2.89 0 5.49 1.62 6.79 4.24l0.598 1.21 1.3-0.504c0.599-0.202 1.3-0.303 2-0.303 1.3 0 2.5 0.404 3.59 1.11 1.6 1.21 2.6 3.13 2.6 5.15v1.61h2c2.6 0 4.69 2.12 4.69 4.74-0.099 2.52-2.2 4.64-4.79 4.64z"/>
|
||||
<path d="m53.2 49.2h-41.6c-1.8 0-3.2 1.4-3.2 3.2v28.6c0 1.8 1.4 3.2 3.2 3.2h15.8v4h-7v6h24v-6h-7v-4h15.8c1.8 0 3.2-1.4 3.2-3.2v-28.6c0-1.8-1.4-3.2-3.2-3.2zm-2.8 29h-36v-23h36z"/>
|
||||
<path d="m73 49.2c1.02 1.29 1.53 2.97 1.53 4.56 0 2.97-1.74 5.65-4.39 7.04v-4.06l-7.46 7.33 7.46 7.14v-4.06c7.66-1.98 12.2-9.61 10-17-0.102-0.297-0.205-0.595-0.307-0.892z"/>
|
||||
<path d="m24.1 43c-0.817-0.991-1.53-2.97-1.53-4.56 0-2.97 1.74-5.65 4.39-7.04v4.06l7.46-7.33-7.46-7.14v4.06c-7.66 1.98-12.2 9.61-10 17 0.102 0.297 0.205 0.595 0.307 0.892z"/>
|
||||
</g>`
|
||||
);
|
||||
</g>`;
|
||||
|
||||
this.addRibbonIcon("replicate", $msg("moduleObsidianMenu.replicate"), async () => {
|
||||
await this.services.replication.replicateUserInitiated({
|
||||
/**
|
||||
* Register the Obsidian-only replication ribbon action.
|
||||
*
|
||||
* The icon and ribbon element are intentionally kept out of the generic Basic
|
||||
* commands feature; other hosts can compose the latter without Obsidian UI.
|
||||
*/
|
||||
export function useObsidianReplicationRibbonFeature(host: ObsidianReplicationRibbonHost): void {
|
||||
const { services } = host;
|
||||
|
||||
services.appLifecycle.onInitialise.addHandler(() => {
|
||||
addIcon("replicate", REPLICATE_ICON_SVG);
|
||||
|
||||
services.API.addRibbonIcon("replicate", $msg("moduleObsidianMenu.replicate"), async () => {
|
||||
await services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
@@ -29,9 +37,5 @@ export class ModuleObsidianMenu extends AbstractModule {
|
||||
}).addClass("livesync-ribbon-replicate");
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const addIcon = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/deps.ts", () => ({ addIcon }));
|
||||
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { useObsidianReplicationRibbonFeature, type ObsidianReplicationRibbonHost } from "./obsidianReplicationRibbon";
|
||||
|
||||
describe("useObsidianReplicationRibbonFeature", () => {
|
||||
it("registers the established icon and ribbon callback during initialisation", async () => {
|
||||
let initialise: (() => Promise<unknown>) | undefined;
|
||||
let ribbonCallback: (() => Promise<void>) | undefined;
|
||||
const addClass = vi.fn();
|
||||
const replicateUserInitiated = vi.fn(async () => ({ status: "completed" as const }));
|
||||
const addRibbonIcon = vi.fn((_icon: string, _title: string, callback: () => Promise<void>) => {
|
||||
ribbonCallback = callback;
|
||||
return { addClass } as unknown as HTMLElement;
|
||||
});
|
||||
const host = {
|
||||
services: {
|
||||
API: { addRibbonIcon },
|
||||
appLifecycle: {
|
||||
onInitialise: {
|
||||
addHandler: vi.fn((handler: () => Promise<unknown>) => {
|
||||
initialise = handler;
|
||||
}),
|
||||
},
|
||||
},
|
||||
replication: { replicateUserInitiated },
|
||||
},
|
||||
} as unknown as ObsidianReplicationRibbonHost;
|
||||
|
||||
useObsidianReplicationRibbonFeature(host);
|
||||
|
||||
expect(addIcon).not.toHaveBeenCalled();
|
||||
expect(addRibbonIcon).not.toHaveBeenCalled();
|
||||
|
||||
await expect(initialise?.()).resolves.toBe(true);
|
||||
|
||||
expect(addIcon).toHaveBeenCalledWith("replicate", expect.any(String));
|
||||
expect(addIcon).toHaveBeenCalledWith("replicate", expect.stringContaining("c-7.66 1.98-12.2 9.61-10 17"));
|
||||
expect(addRibbonIcon).toHaveBeenCalledWith(
|
||||
"replicate",
|
||||
$msg("moduleObsidianMenu.replicate"),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(addClass).toHaveBeenCalledWith("livesync-ribbon-replicate");
|
||||
|
||||
await ribbonCallback?.();
|
||||
expect(replicateUserInitiated).toHaveBeenCalledWith({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { disableLegacyBulkChunkPreSend } from "@/common/compatibilitySettings";
|
||||
import type { LegacyBulkSendSettings } from "./types";
|
||||
|
||||
/** Collaborators required to persist the obsolete bulk-send setting migration. */
|
||||
export interface BulkSettingMigrationDependencies {
|
||||
readonly settings: LegacyBulkSendSettings;
|
||||
readonly log: LogFunction;
|
||||
readonly saveSettings: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the removed automatic bulk chunk pre-send setting, retaining the
|
||||
* former notice text and persistence boundary.
|
||||
*/
|
||||
export async function migrateBulkSendSetting(dependencies: BulkSettingMigrationDependencies): Promise<void> {
|
||||
if (disableLegacyBulkChunkPreSend(dependencies.settings)) {
|
||||
dependencies.log($msg("moduleMigration.logBulkSendCorrupted"), LOG_LEVEL_NOTICE);
|
||||
await dependencies.saveSettings();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { migrateBulkSendSetting, type BulkSettingMigrationDependencies } from "./bulkSettingMigration";
|
||||
|
||||
function createDependencies(settings: { sendChunksBulk: boolean; sendChunksBulkMaxSize: number }) {
|
||||
const dependencies: BulkSettingMigrationDependencies = {
|
||||
settings,
|
||||
log: vi.fn(),
|
||||
saveSettings: vi.fn(async () => undefined),
|
||||
};
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
describe("migrateBulkSendSetting", () => {
|
||||
it("disables and persists an enabled obsolete bulk-send setting", async () => {
|
||||
const dependencies = createDependencies({ sendChunksBulk: true, sendChunksBulkMaxSize: 16 });
|
||||
|
||||
await migrateBulkSendSetting(dependencies);
|
||||
|
||||
expect(dependencies.settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 1 });
|
||||
expect(dependencies.log).toHaveBeenCalledWith(expect.any(String), expect.anything());
|
||||
expect(dependencies.saveSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not persist an already disabled obsolete setting", async () => {
|
||||
const dependencies = createDependencies({ sendChunksBulk: false, sendChunksBulkMaxSize: 16 });
|
||||
|
||||
await migrateBulkSendSetting(dependencies);
|
||||
|
||||
expect(dependencies.settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 16 });
|
||||
expect(dependencies.log).not.toHaveBeenCalled();
|
||||
expect(dependencies.saveSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
Logger,
|
||||
type LOG_LEVEL,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { countCompromisedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
|
||||
import type { Rebuilder } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseRebuilder";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import { $msg } from "@/common/translation";
|
||||
|
||||
interface CompromisedChunkCounter {
|
||||
countCompromisedChunks(): Promise<number | boolean>;
|
||||
}
|
||||
|
||||
/** Focused collaborators for checking and recovering insecure chunks. */
|
||||
export interface CompromisedChunksDependencies {
|
||||
readonly settings: Pick<ObsidianLiveSyncSettings, "encrypt">;
|
||||
readonly localDatabase: {
|
||||
readonly localDatabase: Parameters<typeof countCompromisedChunks>[0];
|
||||
};
|
||||
readonly isOnline: boolean | (() => boolean);
|
||||
readonly getActiveReplicator: () => object | undefined;
|
||||
readonly confirm: Pick<Confirm, "askSelectStringDialogue">;
|
||||
readonly rebuilder: Pick<Rebuilder, "scheduleRebuild" | "scheduleFetch">;
|
||||
readonly performRestart: () => void;
|
||||
readonly log: (message: unknown, level?: LOG_LEVEL) => void;
|
||||
}
|
||||
|
||||
function hasCompromisedChunkCounter(value: object | undefined): value is CompromisedChunkCounter {
|
||||
return (
|
||||
value !== undefined && "countCompromisedChunks" in value && typeof value.countCompromisedChunks === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function readOnline(value: boolean | (() => boolean)): boolean {
|
||||
return typeof value === "function" ? value() : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check local and active-remote databases for insecure chunks and apply the
|
||||
* former rebuild, fetch, or dismiss dialogue semantics.
|
||||
*/
|
||||
export async function checkCompromisedChunks(dependencies: CompromisedChunksDependencies): Promise<boolean> {
|
||||
Logger(`Checking for compromised chunks...`, LOG_LEVEL_VERBOSE);
|
||||
if (!dependencies.settings.encrypt) {
|
||||
// If not encrypted, we do not need to check for compromised chunks.
|
||||
return true;
|
||||
}
|
||||
// Check local database for compromised chunks
|
||||
const localCompromised = await countCompromisedChunks(dependencies.localDatabase.localDatabase);
|
||||
const remote = dependencies.getActiveReplicator();
|
||||
const remoteCompromised =
|
||||
readOnline(dependencies.isOnline) && hasCompromisedChunkCounter(remote)
|
||||
? 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 dependencies.confirm.askSelectStringDialogue(msg, buttons, {
|
||||
title,
|
||||
defaultAction: DISMISS,
|
||||
timeout: 0,
|
||||
});
|
||||
if (result === REBUILD) {
|
||||
// Rebuild the database
|
||||
await dependencies.rebuilder.scheduleRebuild();
|
||||
dependencies.performRestart();
|
||||
return false;
|
||||
} else if (result === FETCH) {
|
||||
// Fetch the latest data from remote
|
||||
await dependencies.rebuilder.scheduleFetch();
|
||||
dependencies.performRestart();
|
||||
return false;
|
||||
} else {
|
||||
// User chose to dismiss the issue
|
||||
dependencies.log($msg("moduleMigration.insecureChunkExist.laterMessage"), LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { countCompromisedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
|
||||
import { checkCompromisedChunks, type CompromisedChunksDependencies } from "./compromisedChunks";
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation", () => ({
|
||||
countCompromisedChunks: vi.fn(),
|
||||
}));
|
||||
|
||||
function selectButton(index: number) {
|
||||
return async (...args: unknown[]): Promise<string | false> => {
|
||||
const buttons = args[1] as readonly string[];
|
||||
return buttons[index] ?? false;
|
||||
};
|
||||
}
|
||||
|
||||
function createDependencies() {
|
||||
const askSelectStringDialogue = vi.fn(selectButton(2));
|
||||
const scheduleRebuild = vi.fn(async () => true);
|
||||
const scheduleFetch = vi.fn(async () => true);
|
||||
const performRestart = vi.fn();
|
||||
const getActiveReplicator = vi.fn((): object | undefined => undefined);
|
||||
const log = vi.fn();
|
||||
const dependencies: CompromisedChunksDependencies = {
|
||||
settings: { encrypt: true },
|
||||
localDatabase: { localDatabase: {} as never },
|
||||
isOnline: true,
|
||||
getActiveReplicator,
|
||||
confirm: { askSelectStringDialogue },
|
||||
rebuilder: { scheduleRebuild, scheduleFetch },
|
||||
performRestart,
|
||||
log,
|
||||
};
|
||||
return {
|
||||
askSelectStringDialogue,
|
||||
dependencies,
|
||||
getActiveReplicator,
|
||||
log,
|
||||
performRestart,
|
||||
scheduleFetch,
|
||||
scheduleRebuild,
|
||||
};
|
||||
}
|
||||
|
||||
describe("checkCompromisedChunks", () => {
|
||||
it("skips the database scan when encryption is disabled", async () => {
|
||||
const fixture = createDependencies();
|
||||
fixture.dependencies.settings.encrypt = false;
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(countCompromisedChunks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows start-up when local and active remote databases contain no compromised chunks", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(0);
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits when local chunk inspection fails", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(false);
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(fixture.askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
expect(fixture.performRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits when the active remote chunk inspection fails", async () => {
|
||||
const fixture = createDependencies();
|
||||
const remoteCount = vi.fn(async () => false);
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(0);
|
||||
fixture.getActiveReplicator.mockReturnValue({ countCompromisedChunks: remoteCount });
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(remoteCount).toHaveBeenCalledOnce();
|
||||
expect(fixture.askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
|
||||
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(fixture.performRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the fetch choice when compromised chunks are found on the remote", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(1);
|
||||
fixture.getActiveReplicator.mockReturnValue({
|
||||
countCompromisedChunks: vi.fn(async () => 2),
|
||||
});
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(1));
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.askSelectStringDialogue).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ timeout: 0 })
|
||||
);
|
||||
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[1]).toHaveLength(2);
|
||||
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(fixture.log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("schedules the selected recovery and stops start-up", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(1);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(0));
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(fixture.scheduleRebuild).toHaveBeenCalledOnce();
|
||||
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(fixture.performRestart).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("fetches local-only compromised chunks when FETCH is selected", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(1);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(1));
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[1]).toHaveLength(3);
|
||||
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
|
||||
expect(fixture.scheduleFetch).toHaveBeenCalledOnce();
|
||||
expect(fixture.performRestart).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps start-up running when compromised chunks are explicitly dismissed", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(1);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(2));
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
|
||||
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(fixture.performRestart).not.toHaveBeenCalled();
|
||||
expect(fixture.log).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { performDoctorConsultation, RebuildOptions } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import type { Rebuilder } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseRebuilder";
|
||||
import type { MessageTranslator } from "@vrtmrz/livesync-commonlib/context";
|
||||
|
||||
/** Collaborators required to run one Config Doctor consultation. */
|
||||
export interface ConfigDoctorDependencies {
|
||||
readonly confirm: Confirm;
|
||||
readonly translate: MessageTranslator;
|
||||
readonly settings: ObsidianLiveSyncSettings;
|
||||
readonly setSettings: (settings: ObsidianLiveSyncSettings) => void;
|
||||
readonly saveSettings: () => Promise<void>;
|
||||
readonly rebuilder: Pick<Rebuilder, "scheduleRebuild" | "scheduleFetch">;
|
||||
readonly performRestart: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Config Doctor and, when requested by its result, reserve the next-start
|
||||
* rebuild or fetch operation before restarting the application.
|
||||
*
|
||||
* The positional arguments retain the established defaults and operation
|
||||
* semantics for both start-up and request-event callers.
|
||||
*/
|
||||
export async function runConfigDoctor(
|
||||
dependencies: ConfigDoctorDependencies,
|
||||
skipRebuild: boolean = false,
|
||||
activateReason = "updated",
|
||||
forceRescan = false
|
||||
): Promise<boolean> {
|
||||
const { shouldRebuild, shouldRebuildLocal, isModified, settings } = await performDoctorConsultation(
|
||||
{
|
||||
confirm: dependencies.confirm,
|
||||
translate: dependencies.translate,
|
||||
},
|
||||
dependencies.settings,
|
||||
{
|
||||
localRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
|
||||
remoteRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
|
||||
activateReason,
|
||||
forceRescan,
|
||||
}
|
||||
);
|
||||
if (isModified) {
|
||||
dependencies.setSettings(settings);
|
||||
await dependencies.saveSettings();
|
||||
}
|
||||
if (!skipRebuild) {
|
||||
if (shouldRebuild) {
|
||||
await dependencies.rebuilder.scheduleRebuild();
|
||||
dependencies.performRestart();
|
||||
return false;
|
||||
} else if (shouldRebuildLocal) {
|
||||
await dependencies.rebuilder.scheduleFetch();
|
||||
dependencies.performRestart();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { performDoctorConsultation } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
|
||||
import { runConfigDoctor, type ConfigDoctorDependencies } from "./configDoctor";
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/configForDoc", async () => {
|
||||
const actual = await vi.importActual<typeof import("@vrtmrz/livesync-commonlib/compat/common/configForDoc")>(
|
||||
"@vrtmrz/livesync-commonlib/compat/common/configForDoc"
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
performDoctorConsultation: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
function createDependencies() {
|
||||
const settings = { isConfigured: true } as never;
|
||||
const setSettings = vi.fn();
|
||||
const saveSettings = vi.fn(async () => undefined);
|
||||
const scheduleRebuild = vi.fn(async () => true);
|
||||
const scheduleFetch = vi.fn(async () => true);
|
||||
const performRestart = vi.fn();
|
||||
const dependencies: ConfigDoctorDependencies = {
|
||||
confirm: {} as never,
|
||||
translate: String,
|
||||
settings,
|
||||
setSettings,
|
||||
saveSettings,
|
||||
rebuilder: { scheduleRebuild, scheduleFetch },
|
||||
performRestart,
|
||||
};
|
||||
return { dependencies, performRestart, saveSettings, scheduleFetch, scheduleRebuild, setSettings, settings };
|
||||
}
|
||||
|
||||
describe("runConfigDoctor", () => {
|
||||
it("persists a modified setting and keeps the configured start-up sequence running", async () => {
|
||||
const fixture = createDependencies();
|
||||
const nextSettings = { isConfigured: true, changed: true } as never;
|
||||
vi.mocked(performDoctorConsultation).mockResolvedValue({
|
||||
settings: nextSettings,
|
||||
shouldRebuild: false,
|
||||
shouldRebuildLocal: false,
|
||||
isModified: true,
|
||||
});
|
||||
|
||||
await expect(runConfigDoctor(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(performDoctorConsultation).toHaveBeenCalledWith(
|
||||
{ confirm: fixture.dependencies.confirm, translate: fixture.dependencies.translate },
|
||||
fixture.settings,
|
||||
expect.objectContaining({
|
||||
activateReason: "updated",
|
||||
forceRescan: false,
|
||||
})
|
||||
);
|
||||
expect(fixture.setSettings).toHaveBeenCalledWith(nextSettings);
|
||||
expect(fixture.saveSettings).toHaveBeenCalledOnce();
|
||||
expect(fixture.performRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("schedules a rebuild and restarts when Doctor requires remote reconstruction", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(performDoctorConsultation).mockResolvedValue({
|
||||
settings: fixture.settings,
|
||||
shouldRebuild: true,
|
||||
shouldRebuildLocal: false,
|
||||
isModified: false,
|
||||
});
|
||||
|
||||
await expect(runConfigDoctor(fixture.dependencies, false, "manual", true)).resolves.toBe(false);
|
||||
|
||||
expect(fixture.scheduleRebuild).toHaveBeenCalledOnce();
|
||||
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(fixture.performRestart).toHaveBeenCalledOnce();
|
||||
expect(performDoctorConsultation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ activateReason: "manual", forceRescan: true })
|
||||
);
|
||||
});
|
||||
|
||||
it("schedules a local fetch and restarts when Doctor requires local reconstruction", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(performDoctorConsultation).mockResolvedValue({
|
||||
settings: fixture.settings,
|
||||
shouldRebuild: false,
|
||||
shouldRebuildLocal: true,
|
||||
isModified: false,
|
||||
});
|
||||
|
||||
await expect(runConfigDoctor(fixture.dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
|
||||
expect(fixture.scheduleFetch).toHaveBeenCalledOnce();
|
||||
expect(fixture.performRestart).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("skips both recovery schedules and restart when rebuilds are skipped", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(performDoctorConsultation).mockResolvedValue({
|
||||
settings: fixture.settings,
|
||||
shouldRebuild: true,
|
||||
shouldRebuildLocal: true,
|
||||
isModified: false,
|
||||
});
|
||||
|
||||
await expect(runConfigDoctor(fixture.dependencies, true)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
|
||||
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(fixture.performRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+12
-16
@@ -1,34 +1,30 @@
|
||||
export interface ConfiguredStartupLifecycleRuntime {
|
||||
databaseReady: boolean;
|
||||
reportDatabaseNotReady(): void;
|
||||
hasCompromisedChunks(): Promise<boolean>;
|
||||
hasIncompleteDocuments(): Promise<boolean>;
|
||||
waitForCompatibilityReview(): Promise<void>;
|
||||
runDoctor(): Promise<boolean>;
|
||||
migrateBulkSend(): Promise<void>;
|
||||
}
|
||||
import type { ConfiguredStartupLifecycleOperations, StartupLifecycleValue } from "./types";
|
||||
|
||||
export interface StartupEntryLifecycleRuntime {
|
||||
configured: boolean;
|
||||
inviteToOnboarding(): void;
|
||||
function readValue<T>(value: StartupLifecycleValue<T>): T {
|
||||
return typeof value === "function" ? (value as () => T)() : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps an unconfigured Vault outside database initialisation and all
|
||||
* configured-only start-up work while offering an explicit setup action.
|
||||
*/
|
||||
export interface StartupEntryLifecycleRuntime {
|
||||
readonly configured: StartupLifecycleValue<boolean>;
|
||||
readonly inviteToOnboarding: () => void;
|
||||
}
|
||||
|
||||
export function runStartupEntryLifecycle(runtime: StartupEntryLifecycleRuntime): boolean {
|
||||
if (runtime.configured) return true;
|
||||
if (readValue(runtime.configured)) return true;
|
||||
runtime.inviteToOnboarding();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Separates the inert, unconfigured startup path from checks which must run
|
||||
* Separates the inert, unconfigured start-up path from checks which must run
|
||||
* before an already configured device is allowed to synchronise.
|
||||
*/
|
||||
export async function runConfiguredStartupLifecycle(runtime: ConfiguredStartupLifecycleRuntime): Promise<boolean> {
|
||||
if (!runtime.databaseReady) {
|
||||
export async function runConfiguredStartupLifecycle(runtime: ConfiguredStartupLifecycleOperations): Promise<boolean> {
|
||||
if (!readValue(runtime.databaseReady)) {
|
||||
runtime.reportDatabaseNotReady();
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
runConfiguredStartupLifecycle,
|
||||
runStartupEntryLifecycle,
|
||||
type StartupEntryLifecycleRuntime,
|
||||
} from "./configuredStartupLifecycle";
|
||||
import type { ConfiguredStartupLifecycleOperations } from "./types";
|
||||
|
||||
function createRuntime(events: string[] = []): ConfiguredStartupLifecycleOperations {
|
||||
return {
|
||||
databaseReady: true,
|
||||
reportDatabaseNotReady: vi.fn(() => events.push("database-not-ready")),
|
||||
hasCompromisedChunks: vi.fn(async () => {
|
||||
events.push("compromised-chunks");
|
||||
return true;
|
||||
}),
|
||||
hasIncompleteDocuments: vi.fn(async () => {
|
||||
events.push("incomplete-documents");
|
||||
return true;
|
||||
}),
|
||||
waitForCompatibilityReview: vi.fn(async () => {
|
||||
events.push("compatibility-review");
|
||||
}),
|
||||
runDoctor: vi.fn(async () => {
|
||||
events.push("doctor");
|
||||
return true;
|
||||
}),
|
||||
migrateBulkSend: vi.fn(async () => {
|
||||
events.push("bulk-send");
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("runConfiguredStartupLifecycle", () => {
|
||||
it("runs all configured checks in their established order", async () => {
|
||||
const events: string[] = [];
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(createRuntime(events))).resolves.toBe(true);
|
||||
|
||||
expect(events).toEqual([
|
||||
"compromised-chunks",
|
||||
"incomplete-documents",
|
||||
"compatibility-review",
|
||||
"doctor",
|
||||
"bulk-send",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not invoke later operations after database or integrity failure", async () => {
|
||||
const events: string[] = [];
|
||||
const runtime = createRuntime(events);
|
||||
Object.assign(runtime, { databaseReady: false });
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
expect(events).toEqual(["database-not-ready"]);
|
||||
|
||||
Object.assign(runtime, { databaseReady: true });
|
||||
vi.mocked(runtime.hasCompromisedChunks).mockImplementation(async () => {
|
||||
events.push("compromised-chunks");
|
||||
return false;
|
||||
});
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
expect(events).toEqual(["database-not-ready", "compromised-chunks"]);
|
||||
expect(runtime.hasIncompleteDocuments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not invoke later operations when incomplete-document checking fails", async () => {
|
||||
const events: string[] = [];
|
||||
const runtime = createRuntime(events);
|
||||
vi.mocked(runtime.hasIncompleteDocuments).mockImplementation(async () => {
|
||||
events.push("incomplete-documents");
|
||||
return false;
|
||||
});
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
|
||||
expect(events).toEqual(["compromised-chunks", "incomplete-documents"]);
|
||||
expect(runtime.waitForCompatibilityReview).not.toHaveBeenCalled();
|
||||
expect(runtime.runDoctor).not.toHaveBeenCalled();
|
||||
expect(runtime.migrateBulkSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not migrate bulk-send settings when Config Doctor fails", async () => {
|
||||
const events: string[] = [];
|
||||
const runtime = createRuntime(events);
|
||||
vi.mocked(runtime.runDoctor).mockImplementation(async () => {
|
||||
events.push("doctor");
|
||||
return false;
|
||||
});
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
|
||||
expect(events).toEqual(["compromised-chunks", "incomplete-documents", "compatibility-review", "doctor"]);
|
||||
expect(runtime.migrateBulkSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls Config Doctor without start-up-only arguments", async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
await runConfiguredStartupLifecycle(runtime);
|
||||
|
||||
expect(runtime.runDoctor).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runStartupEntryLifecycle", () => {
|
||||
it("invites an unconfigured Vault and stops configured start-up", () => {
|
||||
const inviteToOnboarding = vi.fn();
|
||||
const runtime: StartupEntryLifecycleRuntime = {
|
||||
configured: false,
|
||||
inviteToOnboarding,
|
||||
};
|
||||
|
||||
expect(runStartupEntryLifecycle(runtime)).toBe(false);
|
||||
expect(inviteToOnboarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("admits a configured Vault without inviting it to onboarding", () => {
|
||||
const inviteToOnboarding = vi.fn();
|
||||
|
||||
expect(
|
||||
runStartupEntryLifecycle({
|
||||
configured: true,
|
||||
inviteToOnboarding,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(inviteToOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
Logger,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import {
|
||||
isDeletedEntry,
|
||||
isDocContentSame,
|
||||
isLoadedEntry,
|
||||
readAsBlob,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { isMetaEntry } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
|
||||
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
|
||||
import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler";
|
||||
import type { KeyValueDatabase } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { isValidPath } from "@/common/utils";
|
||||
import type { StartupPathReader } from "./types";
|
||||
|
||||
type NoticeGroups = {
|
||||
setItem(groupKey: string, itemKey: string, item: { message: string }): void;
|
||||
finish(groupKey: string): void;
|
||||
};
|
||||
|
||||
/** Focused collaborators for the incomplete-document integrity scan and repair. */
|
||||
export interface IncompleteDocumentsDependencies {
|
||||
readonly localDatabase: Pick<LiveSyncLocalDB, "findAllNormalDocs" | "getDBEntryFromMeta">;
|
||||
readonly getPath: StartupPathReader;
|
||||
readonly isTargetFile: (path: string) => Promise<boolean>;
|
||||
readonly storageAccess: Pick<StorageAccess, "readHiddenFileBinary" | "getFileStub">;
|
||||
readonly fileHandler: Pick<IFileHandler, "storeFileToDB">;
|
||||
readonly keyValueDB: Pick<KeyValueDatabase, "get" | "set">;
|
||||
readonly noticeGroups: NoticeGroups;
|
||||
readonly confirm: Pick<Confirm, "askSelectStringDialogue">;
|
||||
readonly log: LogFunction;
|
||||
}
|
||||
|
||||
type ErrorInfo = {
|
||||
path: string;
|
||||
recordedSize: number;
|
||||
actualSize: number;
|
||||
storageSize: number;
|
||||
contentMatched: boolean;
|
||||
isConflicted?: boolean;
|
||||
};
|
||||
|
||||
const INCOMPLETE_DOCUMENT_NOTICE_GROUP = "startup-integrity-check";
|
||||
|
||||
/**
|
||||
* Scan database metadata against hidden storage and preserve the former
|
||||
* recoverable-file dialogue and repair rules.
|
||||
*/
|
||||
export async function checkIncompleteDocuments(
|
||||
dependencies: IncompleteDocumentsDependencies,
|
||||
force: boolean = false
|
||||
): Promise<boolean> {
|
||||
const incompleteDocsChecked = (await dependencies.keyValueDB.get<boolean>("checkIncompleteDocs")) || false;
|
||||
if (incompleteDocsChecked && !force) {
|
||||
dependencies.log("Incomplete docs check already done, skipping.", LOG_LEVEL_VERBOSE);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
const noticeGroups = dependencies.noticeGroups;
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "checking", {
|
||||
message: "Checking for incomplete documents...",
|
||||
});
|
||||
dependencies.log("Checking for incomplete documents...", LOG_LEVEL_VERBOSE);
|
||||
|
||||
try {
|
||||
const errorFiles = [] as ErrorInfo[];
|
||||
for await (const metaDoc of dependencies.localDatabase.findAllNormalDocs({ conflicts: true })) {
|
||||
const path = dependencies.getPath(metaDoc);
|
||||
|
||||
if (!isValidPath(path)) {
|
||||
continue;
|
||||
}
|
||||
if (!(await dependencies.isTargetFile(path))) {
|
||||
continue;
|
||||
}
|
||||
if (!isMetaEntry(metaDoc)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const doc = await dependencies.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 dependencies.storageAccess.readHiddenFileBinary(path);
|
||||
} catch (e) {
|
||||
Logger(`Failed to read file ${path}: Possibly unprocessed or missing`);
|
||||
Logger(e, LOG_LEVEL_VERBOSE);
|
||||
continue;
|
||||
}
|
||||
// const storageFileBlob = createBlob(storageFileContent);
|
||||
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_INFO);
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: "No size mismatches found",
|
||||
});
|
||||
await dependencies.keyValueDB.set("checkIncompleteDocs", true);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
Logger(`Found ${errorFiles.length} size mismatches`, LOG_LEVEL_INFO);
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: `Found ${errorFiles.length} size mismatches`,
|
||||
});
|
||||
// We have to repair them following rules and situations:
|
||||
// A. DB Recorded != DB Stored
|
||||
// A.1. DB Recorded == Storage Stored
|
||||
// Possibly recoverable from storage. Just overwrite the DB content with storage content.
|
||||
// A.2. Neither
|
||||
// Probably it cannot be resolved on this device. Even if the storage content is larger than DB Recorded, it possibly corrupted.
|
||||
// We do not fix it automatically. Leave it as is. Possibly other device can do this.
|
||||
// B. DB Recorded == DB Stored , < Storage Stored
|
||||
// Very fragile, if DB Recorded size is less than Storage Stored size, we possibly repair the content (The issue was `unexpectedly shortened file`).
|
||||
// We do not fix it automatically, but it will be automatically overwritten in other process.
|
||||
// C. DB Recorded == DB Stored , > Storage Stored
|
||||
// Probably restored by the user by resolving A or B on other device, We should overwrite the storage
|
||||
// Also do not fix it automatically. It should be overwritten by replication.
|
||||
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 dependencies.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) {
|
||||
// Overwrite the database with the files on the storage
|
||||
const stubFile = await dependencies.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 dependencies.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) {
|
||||
// User chose to dismiss the issue
|
||||
await dependencies.keyValueDB.set("checkIncompleteDocs", true);
|
||||
}
|
||||
|
||||
return Promise.resolve(true);
|
||||
} catch (error) {
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: "The incomplete document check could not be completed.",
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
noticeGroups.finish(INCOMPLETE_DOCUMENT_NOTICE_GROUP);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { LoadedEntry, MetaEntry, UXFileInfoStub } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { checkIncompleteDocuments, type IncompleteDocumentsDependencies } from "./incompleteDocuments";
|
||||
|
||||
vi.mock("@/common/utils", () => ({
|
||||
isValidPath: () => true,
|
||||
}));
|
||||
|
||||
type DocumentFixture = {
|
||||
meta: MetaEntry;
|
||||
loaded: LoadedEntry;
|
||||
storage: ArrayBuffer;
|
||||
stub: UXFileInfoStub;
|
||||
};
|
||||
|
||||
type FindAllNormalDocs = () => AsyncGenerator<MetaEntry>;
|
||||
|
||||
async function* noDocuments(): AsyncGenerator<MetaEntry> {
|
||||
return;
|
||||
}
|
||||
|
||||
async function* failedDocumentScan(): AsyncGenerator<MetaEntry> {
|
||||
throw new Error("scan failed");
|
||||
}
|
||||
|
||||
function documentFixture(
|
||||
path: string,
|
||||
options: { recordedSize?: number; storageContent?: string; conflicts?: string[] } = {}
|
||||
): DocumentFixture {
|
||||
const storageContent = options.storageContent ?? "hello";
|
||||
const meta = {
|
||||
_id: `f:${path}`,
|
||||
_rev: "1-test",
|
||||
path,
|
||||
ctime: 1,
|
||||
mtime: 2,
|
||||
size: options.recordedSize ?? storageContent.length,
|
||||
children: ["h:test"],
|
||||
type: "plain",
|
||||
eden: {},
|
||||
...(options.conflicts ? { _conflicts: options.conflicts } : {}),
|
||||
} as MetaEntry;
|
||||
const loaded = {
|
||||
...meta,
|
||||
data: "abc",
|
||||
datatype: "plain",
|
||||
} as LoadedEntry;
|
||||
const stub = {
|
||||
name: path.split("/").pop() ?? path,
|
||||
path,
|
||||
stat: {
|
||||
ctime: 1,
|
||||
mtime: 2,
|
||||
size: storageContent.length,
|
||||
type: "file",
|
||||
},
|
||||
} as UXFileInfoStub;
|
||||
return {
|
||||
meta,
|
||||
loaded,
|
||||
storage: new TextEncoder().encode(storageContent).buffer as ArrayBuffer,
|
||||
stub,
|
||||
};
|
||||
}
|
||||
|
||||
function documentsFrom(fixtures: DocumentFixture[]): FindAllNormalDocs {
|
||||
return async function* () {
|
||||
yield* fixtures.map((fixture) => fixture.meta);
|
||||
};
|
||||
}
|
||||
|
||||
function selectButton(index: number) {
|
||||
return async (...args: unknown[]): Promise<string | false> => {
|
||||
const buttons = args[1] as readonly string[];
|
||||
return buttons[index] ?? false;
|
||||
};
|
||||
}
|
||||
|
||||
function createDependencies(findAllNormalDocs: FindAllNormalDocs = noDocuments, fixtures: DocumentFixture[] = []) {
|
||||
const fixtureByPath = new Map<string, DocumentFixture>(
|
||||
fixtures.map((fixture) => [fixture.meta.path as string, fixture])
|
||||
);
|
||||
const noticeGroups = {
|
||||
setItem: vi.fn(),
|
||||
finish: vi.fn(),
|
||||
};
|
||||
const getFixture = (path: string) => fixtureByPath.get(path);
|
||||
const getDBEntryFromMeta = vi.fn(async (meta: { path: string }) => getFixture(meta.path)?.loaded);
|
||||
const readHiddenFileBinary = vi.fn(async (path: string) => getFixture(path)?.storage ?? new ArrayBuffer(0));
|
||||
const getFileStub = vi.fn(async (path: string) => getFixture(path)?.stub ?? null);
|
||||
const storeFileToDB = vi.fn(async () => true);
|
||||
const askSelectStringDialogue = vi.fn();
|
||||
const dependencies = {
|
||||
localDatabase: {
|
||||
findAllNormalDocs,
|
||||
getDBEntryFromMeta,
|
||||
},
|
||||
getPath: vi.fn((entry: { path: string }) => entry.path),
|
||||
isTargetFile: vi.fn(async () => true),
|
||||
storageAccess: {
|
||||
readHiddenFileBinary,
|
||||
getFileStub,
|
||||
},
|
||||
fileHandler: { storeFileToDB },
|
||||
keyValueDB: {
|
||||
get: vi.fn(async () => false),
|
||||
set: vi.fn(async () => undefined),
|
||||
},
|
||||
noticeGroups,
|
||||
confirm: { askSelectStringDialogue },
|
||||
log: vi.fn(),
|
||||
} as unknown as IncompleteDocumentsDependencies;
|
||||
return {
|
||||
askSelectStringDialogue,
|
||||
dependencies,
|
||||
getFileStub,
|
||||
noticeGroups,
|
||||
readHiddenFileBinary,
|
||||
storeFileToDB,
|
||||
};
|
||||
}
|
||||
|
||||
describe("checkIncompleteDocuments", () => {
|
||||
it("keeps the check and result in one persistent named group", async () => {
|
||||
const { dependencies, noticeGroups } = createDependencies();
|
||||
|
||||
await expect(checkIncompleteDocuments(dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(1, "startup-integrity-check", "checking", {
|
||||
message: "Checking for incomplete documents...",
|
||||
});
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(2, "startup-integrity-check", "result", {
|
||||
message: "No size mismatches found",
|
||||
});
|
||||
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
|
||||
expect(dependencies.keyValueDB.set).toHaveBeenCalledWith("checkIncompleteDocs", true);
|
||||
});
|
||||
|
||||
it("skips the non-forced check after a successful prior scan", async () => {
|
||||
const { dependencies, noticeGroups } = createDependencies();
|
||||
vi.mocked(dependencies.keyValueDB.get).mockResolvedValue(true);
|
||||
|
||||
await expect(checkIncompleteDocuments(dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(noticeGroups.setItem).not.toHaveBeenCalled();
|
||||
expect(noticeGroups.finish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finishes the group with a failure result when the scan throws", async () => {
|
||||
const { dependencies, noticeGroups } = createDependencies(failedDocumentScan);
|
||||
|
||||
await expect(checkIncompleteDocuments(dependencies)).rejects.toThrow("scan failed");
|
||||
|
||||
expect(noticeGroups.setItem).toHaveBeenLastCalledWith("startup-integrity-check", "result", {
|
||||
message: "The incomplete document check could not be completed.",
|
||||
});
|
||||
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
|
||||
});
|
||||
|
||||
it("repairs a recoverable document when FIX is selected", async () => {
|
||||
const recoverable = documentFixture("recoverable.md");
|
||||
const fixture = createDependencies(documentsFrom([recoverable]), [recoverable]);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(1));
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[1]).toHaveLength(3);
|
||||
expect(fixture.getFileStub).toHaveBeenCalledWith("recoverable.md");
|
||||
expect(fixture.storeFileToDB).toHaveBeenCalledWith(recoverable.stub, true, false);
|
||||
expect(fixture.dependencies.keyValueDB.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves recoverable documents unchanged when CHECK_IT_LATER is selected", async () => {
|
||||
const recoverable = documentFixture("recoverable.md");
|
||||
const fixture = createDependencies(documentsFrom([recoverable]), [recoverable]);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(0));
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.getFileStub).not.toHaveBeenCalled();
|
||||
expect(fixture.storeFileToDB).not.toHaveBeenCalled();
|
||||
expect(fixture.dependencies.keyValueDB.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records a permanent dismissal of recoverable document warnings", async () => {
|
||||
const recoverable = documentFixture("recoverable.md");
|
||||
const fixture = createDependencies(documentsFrom([recoverable]), [recoverable]);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(2));
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.getFileStub).not.toHaveBeenCalled();
|
||||
expect(fixture.storeFileToDB).not.toHaveBeenCalled();
|
||||
expect(fixture.dependencies.keyValueDB.set).toHaveBeenCalledWith("checkIncompleteDocs", true);
|
||||
});
|
||||
|
||||
it("stores only recoverable, non-conflicted documents from a mixed scan", async () => {
|
||||
const recoverable = documentFixture("recoverable.md");
|
||||
const unrecoverable = documentFixture("unrecoverable.md", { recordedSize: 4 });
|
||||
const conflicted = documentFixture("conflicted.md", { conflicts: ["2-conflict"] });
|
||||
const fixture = createDependencies(documentsFrom([recoverable, unrecoverable, conflicted]), [
|
||||
recoverable,
|
||||
unrecoverable,
|
||||
conflicted,
|
||||
]);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(1));
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.getFileStub).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.getFileStub).toHaveBeenCalledWith("recoverable.md");
|
||||
expect(fixture.storeFileToDB).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.storeFileToDB).toHaveBeenCalledWith(recoverable.stub, true, false);
|
||||
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[0]).toEqual(expect.stringContaining("unrecoverable.md"));
|
||||
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[0]).toEqual(expect.stringContaining("conflicted.md"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export { runConfiguredStartupLifecycle, runStartupEntryLifecycle } from "./configuredStartupLifecycle";
|
||||
export type { StartupEntryLifecycleRuntime } from "./configuredStartupLifecycle";
|
||||
export { runConfigDoctor } from "./configDoctor";
|
||||
export { checkCompromisedChunks } from "./compromisedChunks";
|
||||
export { checkIncompleteDocuments } from "./incompleteDocuments";
|
||||
export { migrateBulkSendSetting } from "./bulkSettingMigration";
|
||||
export { STARTUP_LIFECYCLE_LAYOUT_PRIORITY, useStartupLifecycleFeature } from "./startupLifecycle";
|
||||
export type { BulkSettingMigrationDependencies } from "./bulkSettingMigration";
|
||||
export type { CompromisedChunksDependencies } from "./compromisedChunks";
|
||||
export type { ConfigDoctorDependencies } from "./configDoctor";
|
||||
export type { IncompleteDocumentsDependencies } from "./incompleteDocuments";
|
||||
export type {
|
||||
ConfiguredStartupLifecycleOperations,
|
||||
LegacyBulkSendSettings,
|
||||
StartupLifecycleContext,
|
||||
StartupLifecycleFeatureOptions,
|
||||
StartupLifecycleHost,
|
||||
StartupLifecycleValue,
|
||||
StartupPathReader,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const operationMocks = vi.hoisted(() => ({
|
||||
checkCompromisedChunks: vi.fn(),
|
||||
checkIncompleteDocuments: vi.fn(),
|
||||
migrateBulkSendSetting: vi.fn(),
|
||||
runConfigDoctor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./compromisedChunks", () => ({
|
||||
checkCompromisedChunks: operationMocks.checkCompromisedChunks,
|
||||
}));
|
||||
vi.mock("./incompleteDocuments", () => ({
|
||||
checkIncompleteDocuments: operationMocks.checkIncompleteDocuments,
|
||||
}));
|
||||
vi.mock("./bulkSettingMigration", () => ({
|
||||
migrateBulkSendSetting: operationMocks.migrateBulkSendSetting,
|
||||
}));
|
||||
vi.mock("./configDoctor", () => ({
|
||||
runConfigDoctor: operationMocks.runConfigDoctor,
|
||||
}));
|
||||
|
||||
import { useStartupLifecycleFeature, type StartupLifecycleHost } from "./index";
|
||||
|
||||
describe("useStartupLifecycleFeature default operation wiring", () => {
|
||||
it("maps the host services to every configured start-up operation in order", async () => {
|
||||
const order: string[] = [];
|
||||
const log = vi.fn();
|
||||
const settings = { isConfigured: true, encrypt: true, sendChunksBulk: false, sendChunksBulkMaxSize: 1 };
|
||||
const localDatabase = { isReady: true, localDatabase: { name: "local" } };
|
||||
const confirm = { askSelectStringDialogue: vi.fn() };
|
||||
const activeReplicator = { name: "remote" };
|
||||
const storageAccess = { name: "storage" };
|
||||
const fileHandler = { name: "file-handler" };
|
||||
const rebuilder = { name: "rebuilder" };
|
||||
const kvDB = { name: "key-value" };
|
||||
const addLayoutHandler = vi.fn();
|
||||
const addFirstInitialiseHandler = vi.fn();
|
||||
const setting = {
|
||||
settings,
|
||||
currentSettings: vi.fn(() => settings),
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
};
|
||||
const appLifecycle = {
|
||||
onLayoutReady: { addHandler: addLayoutHandler },
|
||||
onFirstInitialise: { addHandler: addFirstInitialiseHandler },
|
||||
performRestart: vi.fn(),
|
||||
};
|
||||
const path = { getPath: vi.fn(() => "note.md") };
|
||||
const vault = { isTargetFile: vi.fn(async () => true) };
|
||||
const host = {
|
||||
services: {
|
||||
API: { isOnline: true },
|
||||
UI: { confirm },
|
||||
appLifecycle,
|
||||
context: {
|
||||
events: { onEvent: vi.fn(() => vi.fn()) },
|
||||
noticeGroups: { setItem: vi.fn(), finish: vi.fn() },
|
||||
translate: String,
|
||||
},
|
||||
database: { localDatabase },
|
||||
keyValueDB: { kvDB },
|
||||
path,
|
||||
replicator: { getActiveReplicator: vi.fn(() => activeReplicator) },
|
||||
setting,
|
||||
vault,
|
||||
},
|
||||
serviceModules: { fileHandler, rebuilder, storageAccess },
|
||||
} as unknown as StartupLifecycleHost;
|
||||
|
||||
operationMocks.checkCompromisedChunks.mockImplementation(async () => {
|
||||
order.push("compromised");
|
||||
return true;
|
||||
});
|
||||
operationMocks.checkIncompleteDocuments.mockImplementation(async () => {
|
||||
order.push("incomplete");
|
||||
return true;
|
||||
});
|
||||
operationMocks.runConfigDoctor.mockImplementation(async () => {
|
||||
order.push("doctor");
|
||||
return true;
|
||||
});
|
||||
operationMocks.migrateBulkSendSetting.mockImplementation(async () => {
|
||||
order.push("bulk");
|
||||
});
|
||||
const waitForCompatibilityReview = vi.fn(async () => {
|
||||
order.push("compatibility");
|
||||
});
|
||||
|
||||
useStartupLifecycleFeature(host, {
|
||||
inviteToOnboarding: vi.fn(),
|
||||
waitForCompatibilityReview,
|
||||
log,
|
||||
});
|
||||
|
||||
expect(operationMocks.checkCompromisedChunks).not.toHaveBeenCalled();
|
||||
expect(operationMocks.checkIncompleteDocuments).not.toHaveBeenCalled();
|
||||
expect(operationMocks.runConfigDoctor).not.toHaveBeenCalled();
|
||||
expect(operationMocks.migrateBulkSendSetting).not.toHaveBeenCalled();
|
||||
expect(waitForCompatibilityReview).not.toHaveBeenCalled();
|
||||
|
||||
const layoutAdmission = addLayoutHandler.mock.calls[0]?.[0] as () => Promise<boolean>;
|
||||
const firstInitialise = addFirstInitialiseHandler.mock.calls[0]?.[0] as () => Promise<boolean>;
|
||||
await expect(layoutAdmission()).resolves.toBe(true);
|
||||
await expect(firstInitialise()).resolves.toBe(true);
|
||||
|
||||
expect(order).toEqual(["compromised", "incomplete", "compatibility", "doctor", "bulk"]);
|
||||
|
||||
const compromised = operationMocks.checkCompromisedChunks.mock.calls[0]?.[0];
|
||||
expect(compromised).toMatchObject({ settings, localDatabase, confirm, rebuilder, log });
|
||||
expect(compromised?.isOnline()).toBe(true);
|
||||
expect(compromised?.getActiveReplicator()).toBe(activeReplicator);
|
||||
compromised?.performRestart();
|
||||
expect(appLifecycle.performRestart).toHaveBeenCalledOnce();
|
||||
|
||||
const [incomplete, force] = operationMocks.checkIncompleteDocuments.mock.calls[0] ?? [];
|
||||
expect(force).toBe(false);
|
||||
expect(incomplete).toMatchObject({ localDatabase, storageAccess, fileHandler, keyValueDB: kvDB, confirm, log });
|
||||
expect(incomplete?.getPath({} as never)).toBe("note.md");
|
||||
await expect(incomplete?.isTargetFile("note.md")).resolves.toBe(true);
|
||||
|
||||
const doctor = operationMocks.runConfigDoctor.mock.calls[0]?.[0];
|
||||
expect(doctor).toMatchObject({ confirm, settings, rebuilder });
|
||||
const nextSettings = { ...settings, liveSync: true } as never;
|
||||
doctor?.setSettings(nextSettings);
|
||||
expect(setting.settings).toBe(nextSettings);
|
||||
await doctor?.saveSettings();
|
||||
expect(setting.saveSettingData).toHaveBeenCalledOnce();
|
||||
|
||||
const bulk = operationMocks.migrateBulkSendSetting.mock.calls[0]?.[0];
|
||||
expect(bulk).toMatchObject({ settings, log });
|
||||
await bulk?.saveSettings();
|
||||
expect(setting.saveSettingData).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { EVENT_REQUEST_RUN_DOCTOR, EVENT_REQUEST_RUN_FIX_INCOMPLETE, EVENT_SETTING_SAVED } from "@/common/events";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import type {
|
||||
StartupLifecycleHost,
|
||||
StartupLifecycleFeatureOptions,
|
||||
ConfiguredStartupLifecycleOperations,
|
||||
} from "./types";
|
||||
import { runConfiguredStartupLifecycle, runStartupEntryLifecycle } from "./configuredStartupLifecycle";
|
||||
import { runConfigDoctor } from "./configDoctor";
|
||||
import { checkCompromisedChunks } from "./compromisedChunks";
|
||||
import { checkIncompleteDocuments } from "./incompleteDocuments";
|
||||
import { migrateBulkSendSetting } from "./bulkSettingMigration";
|
||||
|
||||
/** Layout admission runs after ordinary priority-0 host handlers. */
|
||||
export const STARTUP_LIFECYCLE_LAYOUT_PRIORITY = 1 as const;
|
||||
|
||||
function readValue<T>(value: T | (() => T)): T {
|
||||
return typeof value === "function" ? (value as () => T)() : value;
|
||||
}
|
||||
|
||||
function createDefaultOperations(
|
||||
host: StartupLifecycleHost,
|
||||
options: StartupLifecycleFeatureOptions,
|
||||
log: ReturnType<typeof createInstanceLogFunction>
|
||||
): ConfiguredStartupLifecycleOperations {
|
||||
const { services } = host;
|
||||
const defaultOperations = {
|
||||
databaseReady: () => services.database.localDatabase.isReady,
|
||||
reportDatabaseNotReady: () => log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE),
|
||||
hasCompromisedChunks: () =>
|
||||
checkCompromisedChunks({
|
||||
settings: services.setting.currentSettings(),
|
||||
localDatabase: services.database.localDatabase,
|
||||
isOnline: () => services.API.isOnline,
|
||||
getActiveReplicator: () => services.replicator.getActiveReplicator(),
|
||||
confirm: services.UI.confirm,
|
||||
rebuilder: host.serviceModules.rebuilder,
|
||||
performRestart: () => services.appLifecycle.performRestart(),
|
||||
log,
|
||||
}),
|
||||
hasIncompleteDocuments: (force = false) =>
|
||||
checkIncompleteDocuments(
|
||||
{
|
||||
localDatabase: services.database.localDatabase,
|
||||
getPath: (entry) => services.path.getPath(entry),
|
||||
isTargetFile: (path) => services.vault.isTargetFile(path),
|
||||
storageAccess: host.serviceModules.storageAccess,
|
||||
fileHandler: host.serviceModules.fileHandler,
|
||||
keyValueDB: services.keyValueDB.kvDB,
|
||||
noticeGroups: services.context.noticeGroups,
|
||||
confirm: services.UI.confirm,
|
||||
log,
|
||||
},
|
||||
force
|
||||
),
|
||||
runDoctor: (skipRebuild = false, activateReason = "updated", forceRescan = false) =>
|
||||
runConfigDoctor(
|
||||
{
|
||||
confirm: services.UI.confirm,
|
||||
translate: services.context.translate,
|
||||
settings: services.setting.currentSettings(),
|
||||
setSettings: (settings) => {
|
||||
services.setting.settings = settings;
|
||||
},
|
||||
saveSettings: () => services.setting.saveSettingData(),
|
||||
rebuilder: host.serviceModules.rebuilder,
|
||||
performRestart: () => services.appLifecycle.performRestart(),
|
||||
},
|
||||
skipRebuild,
|
||||
activateReason,
|
||||
forceRescan
|
||||
),
|
||||
migrateBulkSend: () =>
|
||||
migrateBulkSendSetting({
|
||||
settings: services.setting.currentSettings(),
|
||||
log,
|
||||
saveSettings: () => services.setting.saveSettingData(),
|
||||
}),
|
||||
} satisfies Omit<ConfiguredStartupLifecycleOperations, "waitForCompatibilityReview">;
|
||||
|
||||
return {
|
||||
databaseReady: options.databaseReady ?? defaultOperations.databaseReady,
|
||||
reportDatabaseNotReady: options.reportDatabaseNotReady ?? defaultOperations.reportDatabaseNotReady,
|
||||
hasCompromisedChunks: options.hasCompromisedChunks ?? defaultOperations.hasCompromisedChunks,
|
||||
hasIncompleteDocuments: options.hasIncompleteDocuments ?? defaultOperations.hasIncompleteDocuments,
|
||||
waitForCompatibilityReview: options.waitForCompatibilityReview,
|
||||
runDoctor: options.runDoctor ?? defaultOperations.runDoctor,
|
||||
migrateBulkSend: options.migrateBulkSend ?? defaultOperations.migrateBulkSend,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose configured Vault admission, start-up integrity checks, migrations,
|
||||
* and their request events around one host-owned service context.
|
||||
*
|
||||
* Event listeners are deliberately registered from the successful layout
|
||||
* admission handler. An unconfigured Vault therefore cannot receive a
|
||||
* Config Doctor or incomplete-document request before onboarding.
|
||||
*/
|
||||
export function useStartupLifecycleFeature(host: StartupLifecycleHost, options: StartupLifecycleFeatureOptions): void {
|
||||
const log = options.log ?? createInstanceLogFunction("SF:StartupLifecycle", host.services.API);
|
||||
const operations = createDefaultOperations(host, options, log);
|
||||
let layoutAdmitted = false;
|
||||
let layoutEvaluated = false;
|
||||
let generationRetired = false;
|
||||
let eventsBound = false;
|
||||
let eventUnsubscribers: Array<() => void> = [];
|
||||
|
||||
const isConfigured = () => {
|
||||
const configured = options.configured;
|
||||
return configured === undefined
|
||||
? host.services.setting.currentSettings().isConfigured === true
|
||||
: readValue(configured);
|
||||
};
|
||||
|
||||
const isDatabaseReady = () => readValue(operations.databaseReady);
|
||||
|
||||
const retireGeneration = () => {
|
||||
if (generationRetired) return;
|
||||
generationRetired = true;
|
||||
layoutAdmitted = false;
|
||||
for (const unsubscribe of eventUnsubscribers) unsubscribe();
|
||||
eventUnsubscribers = [];
|
||||
};
|
||||
|
||||
const bindRequestEvents = () => {
|
||||
if (eventsBound || generationRetired) return;
|
||||
eventsBound = true;
|
||||
eventUnsubscribers = [
|
||||
host.services.context.events.onEvent(EVENT_REQUEST_RUN_DOCTOR, async (reason) => {
|
||||
if (!layoutAdmitted || generationRetired || !isConfigured() || !isDatabaseReady()) return;
|
||||
await operations.runDoctor(false, reason, true);
|
||||
}),
|
||||
host.services.context.events.onEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE, async () => {
|
||||
if (!layoutAdmitted || generationRetired || !isConfigured() || !isDatabaseReady()) return;
|
||||
await operations.hasIncompleteDocuments(true);
|
||||
}),
|
||||
host.services.context.events.onEvent(EVENT_SETTING_SAVED, (settings) => {
|
||||
if (settings.isConfigured !== true) retireGeneration();
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
const layoutAdmission = (): Promise<boolean> => {
|
||||
if (generationRetired) return Promise.resolve(false);
|
||||
if (layoutEvaluated) {
|
||||
if (layoutAdmitted && !isConfigured()) retireGeneration();
|
||||
return Promise.resolve(layoutAdmitted);
|
||||
}
|
||||
|
||||
layoutEvaluated = true;
|
||||
const admitted = runStartupEntryLifecycle({
|
||||
configured: isConfigured,
|
||||
inviteToOnboarding: options.inviteToOnboarding,
|
||||
});
|
||||
if (!admitted) {
|
||||
retireGeneration();
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
layoutAdmitted = true;
|
||||
bindRequestEvents();
|
||||
return Promise.resolve(true);
|
||||
};
|
||||
|
||||
const firstInitialise = async (): Promise<boolean> => {
|
||||
if (!layoutAdmitted || generationRetired || !isConfigured()) return false;
|
||||
return await runConfiguredStartupLifecycle(operations);
|
||||
};
|
||||
|
||||
host.services.appLifecycle.onLayoutReady.addHandler(layoutAdmission, STARTUP_LIFECYCLE_LAYOUT_PRIORITY);
|
||||
host.services.appLifecycle.onFirstInitialise.addHandler(firstInitialise);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EVENT_REQUEST_RUN_DOCTOR, EVENT_REQUEST_RUN_FIX_INCOMPLETE, EVENT_SETTING_SAVED } from "@/common/events";
|
||||
vi.mock("@/common/utils", () => ({
|
||||
isValidPath: () => true,
|
||||
}));
|
||||
import {
|
||||
STARTUP_LIFECYCLE_LAYOUT_PRIORITY,
|
||||
useStartupLifecycleFeature,
|
||||
type StartupLifecycleFeatureOptions,
|
||||
type StartupLifecycleHost,
|
||||
} from "./index";
|
||||
|
||||
function createHost() {
|
||||
const addLayoutHandler = vi.fn();
|
||||
const addFirstInitialiseHandler = vi.fn();
|
||||
const eventHandlers = new Map<
|
||||
string,
|
||||
{ callback: (...args: unknown[]) => unknown; unsubscribe: ReturnType<typeof vi.fn> }
|
||||
>();
|
||||
const onEvent = vi.fn((event: string, callback: (...args: unknown[]) => unknown) => {
|
||||
const unsubscribe = vi.fn();
|
||||
eventHandlers.set(event, { callback, unsubscribe });
|
||||
return unsubscribe;
|
||||
});
|
||||
const host = {
|
||||
services: {
|
||||
API: {},
|
||||
UI: {},
|
||||
appLifecycle: {
|
||||
onLayoutReady: { addHandler: addLayoutHandler },
|
||||
onFirstInitialise: { addHandler: addFirstInitialiseHandler },
|
||||
},
|
||||
context: {
|
||||
events: { onEvent },
|
||||
noticeGroups: {},
|
||||
translate: String,
|
||||
},
|
||||
setting: {
|
||||
currentSettings: vi.fn(() => ({ isConfigured: true })),
|
||||
},
|
||||
},
|
||||
serviceModules: {},
|
||||
} as unknown as StartupLifecycleHost;
|
||||
return { addFirstInitialiseHandler, addLayoutHandler, eventHandlers, host, onEvent };
|
||||
}
|
||||
|
||||
function createOptions(events: string[] = []): StartupLifecycleFeatureOptions {
|
||||
return {
|
||||
inviteToOnboarding: vi.fn(() => events.push("invite")),
|
||||
waitForCompatibilityReview: vi.fn(async () => {
|
||||
events.push("compatibility-review");
|
||||
}),
|
||||
databaseReady: true,
|
||||
reportDatabaseNotReady: vi.fn(() => events.push("database-not-ready")),
|
||||
hasCompromisedChunks: vi.fn(async () => {
|
||||
events.push("compromised-chunks");
|
||||
return true;
|
||||
}),
|
||||
hasIncompleteDocuments: vi.fn(async () => {
|
||||
events.push("incomplete-documents");
|
||||
return true;
|
||||
}),
|
||||
runDoctor: vi.fn(async () => {
|
||||
events.push("doctor");
|
||||
return true;
|
||||
}),
|
||||
migrateBulkSend: vi.fn(async () => {
|
||||
events.push("bulk-send");
|
||||
}),
|
||||
log: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("useStartupLifecycleFeature", () => {
|
||||
it("registers layout admission at priority 1 and first-initialise in the established order", async () => {
|
||||
const events: string[] = [];
|
||||
const { addFirstInitialiseHandler, addLayoutHandler, host } = createHost();
|
||||
const options = createOptions(events);
|
||||
useStartupLifecycleFeature(host, options);
|
||||
|
||||
expect(addLayoutHandler).toHaveBeenCalledWith(expect.any(Function), STARTUP_LIFECYCLE_LAYOUT_PRIORITY);
|
||||
expect(addFirstInitialiseHandler).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
const layoutAdmission = addLayoutHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
await expect(layoutAdmission()).resolves.toBe(true);
|
||||
const firstInitialise = addFirstInitialiseHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
await expect(firstInitialise()).resolves.toBe(true);
|
||||
expect(events).toEqual([
|
||||
"compromised-chunks",
|
||||
"incomplete-documents",
|
||||
"compatibility-review",
|
||||
"doctor",
|
||||
"bulk-send",
|
||||
]);
|
||||
});
|
||||
|
||||
it("short-circuits first-initialise when database readiness or an integrity check fails", async () => {
|
||||
const events: string[] = [];
|
||||
const { addFirstInitialiseHandler, addLayoutHandler, host } = createHost();
|
||||
const options = createOptions(events);
|
||||
let databaseReady = false;
|
||||
Object.assign(options, { databaseReady: () => databaseReady });
|
||||
|
||||
useStartupLifecycleFeature(host, options);
|
||||
const layoutAdmission = addLayoutHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
await expect(layoutAdmission()).resolves.toBe(true);
|
||||
const firstInitialise = addFirstInitialiseHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
await expect(firstInitialise()).resolves.toBe(false);
|
||||
expect(events).toEqual(["database-not-ready"]);
|
||||
|
||||
databaseReady = true;
|
||||
vi.mocked(options.hasCompromisedChunks!).mockImplementation(async () => {
|
||||
events.push("compromised-chunks");
|
||||
return false;
|
||||
});
|
||||
await expect(firstInitialise()).resolves.toBe(false);
|
||||
expect(events).toEqual(["database-not-ready", "compromised-chunks"]);
|
||||
expect(options.hasIncompleteDocuments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not re-admit a Vault which was unconfigured at its first layout invocation", async () => {
|
||||
const { addFirstInitialiseHandler, addLayoutHandler, eventHandlers, host } = createHost();
|
||||
const inviteToOnboarding = vi.fn();
|
||||
const options = {
|
||||
...createOptions(),
|
||||
configured: false,
|
||||
inviteToOnboarding,
|
||||
} satisfies StartupLifecycleFeatureOptions;
|
||||
useStartupLifecycleFeature(host, options);
|
||||
const layoutAdmission = addLayoutHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
|
||||
await expect(layoutAdmission()).resolves.toBe(false);
|
||||
expect(inviteToOnboarding).toHaveBeenCalledOnce();
|
||||
expect(eventHandlers.has(EVENT_REQUEST_RUN_DOCTOR)).toBe(false);
|
||||
expect(eventHandlers.has(EVENT_REQUEST_RUN_FIX_INCOMPLETE)).toBe(false);
|
||||
|
||||
Object.assign(options, { configured: true });
|
||||
await expect(layoutAdmission()).resolves.toBe(false);
|
||||
expect(eventHandlers.has(EVENT_REQUEST_RUN_DOCTOR)).toBe(false);
|
||||
expect(eventHandlers.has(EVENT_REQUEST_RUN_FIX_INCOMPLETE)).toBe(false);
|
||||
|
||||
const firstInitialise = addFirstInitialiseHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
await expect(firstInitialise()).resolves.toBe(false);
|
||||
expect(options.runDoctor).not.toHaveBeenCalled();
|
||||
expect(eventHandlers.has(EVENT_SETTING_SAVED)).toBe(false);
|
||||
});
|
||||
|
||||
it("retires an admitted generation when settings become unconfigured and guards request races", async () => {
|
||||
const { addFirstInitialiseHandler, addLayoutHandler, eventHandlers, host } = createHost();
|
||||
let configured = true;
|
||||
let databaseReady = true;
|
||||
const options = {
|
||||
...createOptions(),
|
||||
configured: () => configured,
|
||||
databaseReady: () => databaseReady,
|
||||
} satisfies StartupLifecycleFeatureOptions;
|
||||
useStartupLifecycleFeature(host, options);
|
||||
const layoutAdmission = addLayoutHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
|
||||
await expect(layoutAdmission()).resolves.toBe(true);
|
||||
expect(eventHandlers.get(EVENT_REQUEST_RUN_DOCTOR)).toBeDefined();
|
||||
expect(eventHandlers.get(EVENT_REQUEST_RUN_FIX_INCOMPLETE)).toBeDefined();
|
||||
|
||||
const runDoctor = eventHandlers.get(EVENT_REQUEST_RUN_DOCTOR)!.callback as (reason: string) => Promise<void>;
|
||||
const fixIncomplete = eventHandlers.get(EVENT_REQUEST_RUN_FIX_INCOMPLETE)!.callback as () => Promise<void>;
|
||||
const settingSaved = eventHandlers.get(EVENT_SETTING_SAVED)!.callback as (settings: unknown) => unknown;
|
||||
|
||||
await settingSaved({ isConfigured: true });
|
||||
expect(eventHandlers.get(EVENT_REQUEST_RUN_DOCTOR)!.unsubscribe).not.toHaveBeenCalled();
|
||||
expect(eventHandlers.get(EVENT_REQUEST_RUN_FIX_INCOMPLETE)!.unsubscribe).not.toHaveBeenCalled();
|
||||
|
||||
databaseReady = false;
|
||||
await runDoctor("database race");
|
||||
await fixIncomplete();
|
||||
expect(options.runDoctor).not.toHaveBeenCalled();
|
||||
expect(options.hasIncompleteDocuments).not.toHaveBeenCalled();
|
||||
|
||||
databaseReady = true;
|
||||
configured = false;
|
||||
await runDoctor("configuration race");
|
||||
await fixIncomplete();
|
||||
expect(options.runDoctor).not.toHaveBeenCalled();
|
||||
expect(options.hasIncompleteDocuments).not.toHaveBeenCalled();
|
||||
|
||||
await settingSaved({ isConfigured: false });
|
||||
expect(eventHandlers.get(EVENT_REQUEST_RUN_DOCTOR)!.unsubscribe).toHaveBeenCalledOnce();
|
||||
expect(eventHandlers.get(EVENT_REQUEST_RUN_FIX_INCOMPLETE)!.unsubscribe).toHaveBeenCalledOnce();
|
||||
expect(eventHandlers.get(EVENT_SETTING_SAVED)!.unsubscribe).toHaveBeenCalledOnce();
|
||||
|
||||
configured = true;
|
||||
await expect(layoutAdmission()).resolves.toBe(false);
|
||||
await runDoctor("retired generation");
|
||||
await fixIncomplete();
|
||||
expect(options.runDoctor).not.toHaveBeenCalled();
|
||||
expect(options.hasIncompleteDocuments).not.toHaveBeenCalled();
|
||||
|
||||
const firstInitialise = addFirstInitialiseHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
await expect(firstInitialise()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("keeps doctor and incomplete-document request operations behind layout admission", async () => {
|
||||
const { addLayoutHandler, eventHandlers, host, onEvent } = createHost();
|
||||
const options = createOptions();
|
||||
useStartupLifecycleFeature(host, options);
|
||||
const layoutAdmission = addLayoutHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
|
||||
await layoutAdmission();
|
||||
await layoutAdmission();
|
||||
expect(onEvent.mock.calls.filter(([event]) => event === EVENT_REQUEST_RUN_DOCTOR)).toHaveLength(1);
|
||||
expect(onEvent.mock.calls.filter(([event]) => event === EVENT_REQUEST_RUN_FIX_INCOMPLETE)).toHaveLength(1);
|
||||
|
||||
const runDoctor = eventHandlers.get(EVENT_REQUEST_RUN_DOCTOR)!.callback as (reason: string) => Promise<void>;
|
||||
const fixIncomplete = eventHandlers.get(EVENT_REQUEST_RUN_FIX_INCOMPLETE)!.callback as () => Promise<void>;
|
||||
await runDoctor("manual request");
|
||||
await fixIncomplete();
|
||||
|
||||
expect(options.runDoctor).toHaveBeenCalledWith(false, "manual request", true);
|
||||
expect(options.hasIncompleteDocuments).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AnyEntry, ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import type { LiveSyncEventHub, ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import type { ObsidianNoticeGroups } from "@/modules/services/ObsidianNoticeGroups";
|
||||
|
||||
/** A value which may be read once or evaluated when a lifecycle handler runs. */
|
||||
export type StartupLifecycleValue<T> = T | (() => T);
|
||||
|
||||
/** The minimum Context extension needed by the Obsidian start-up integrity check. */
|
||||
export type StartupLifecycleContext = ServiceContext & {
|
||||
readonly events: LiveSyncEventHub;
|
||||
readonly noticeGroups: Pick<ObsidianNoticeGroups, "setItem" | "finish">;
|
||||
};
|
||||
|
||||
/** Services and ServiceModules consumed by the start-up feature composer. */
|
||||
export type StartupLifecycleHost = NecessaryServices<
|
||||
"API" | "UI" | "appLifecycle" | "setting" | "replicator" | "vault" | "path" | "keyValueDB" | "database",
|
||||
"storageAccess" | "fileHandler" | "rebuilder"
|
||||
> & {
|
||||
services: NecessaryServices<
|
||||
"API" | "UI" | "appLifecycle" | "setting" | "replicator" | "vault" | "path" | "keyValueDB" | "database",
|
||||
"storageAccess" | "fileHandler" | "rebuilder"
|
||||
>["services"] & {
|
||||
context: StartupLifecycleContext;
|
||||
};
|
||||
};
|
||||
|
||||
/** Focused operations which make up the configured Vault first-initialise gate. */
|
||||
export interface ConfiguredStartupLifecycleOperations {
|
||||
readonly databaseReady: StartupLifecycleValue<boolean>;
|
||||
readonly reportDatabaseNotReady: () => void;
|
||||
readonly hasCompromisedChunks: () => Promise<boolean>;
|
||||
readonly hasIncompleteDocuments: (force?: boolean) => Promise<boolean>;
|
||||
readonly waitForCompatibilityReview: () => Promise<void>;
|
||||
readonly runDoctor: (skipRebuild?: boolean, activateReason?: string, forceRescan?: boolean) => Promise<boolean>;
|
||||
readonly migrateBulkSend: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Explicit host decisions and optional operation overrides for composition. */
|
||||
export interface StartupLifecycleFeatureOptions extends Partial<ConfiguredStartupLifecycleOperations> {
|
||||
/** Invites an unconfigured Vault to begin onboarding. */
|
||||
readonly inviteToOnboarding: () => void;
|
||||
/** Waits for the compatibility review before opening Config Doctor. */
|
||||
readonly waitForCompatibilityReview: () => Promise<void>;
|
||||
/** Current configured-state query; defaults to the loaded setting. */
|
||||
readonly configured?: StartupLifecycleValue<boolean>;
|
||||
/** Logger used by the default operations. */
|
||||
readonly log?: LogFunction;
|
||||
}
|
||||
|
||||
/** Minimal mutable settings view required by the obsolete bulk-send migration. */
|
||||
export type LegacyBulkSendSettings = Pick<ObsidianLiveSyncSettings, "sendChunksBulk" | "sendChunksBulkMaxSize">;
|
||||
|
||||
/** Keep path conversion visible at the incomplete-document operation boundary. */
|
||||
export type StartupPathReader = (entry: AnyEntry) => string;
|
||||
Reference in New Issue
Block a user