mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-09 12:17:07 +00:00
Merge pull request #1161 from vrtmrz/refactor/startup-lifecycle-service-features
Refactor startup lifecycle into service features
This commit is contained in:
@@ -72,6 +72,7 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex
|
||||
### Flag-file recovery order
|
||||
|
||||
- For a configured Vault, evaluate and persist the compatibility gate after settings load, before Obsidian layout-ready recovery begins. This blocks ordinary and one-shot replication even while the review dialogue has not yet opened. An existing unconfigured Vault follows the deferred rule above instead.
|
||||
- Admit configured-only start-up work at priority 1, after ordinary priority-0 layout integration and before flag-file recovery. An unconfigured Vault offers onboarding and returns `false`, so recovery, compatibility review, database preparation, and configured-only request handling do not run. Treat this admission as a property of the current plug-in process: changing `isConfigured` from `false` to `true` requires the scheduled restart before configured work becomes available, and declining that restart deliberately leaves the current process inert. If an admitted process changes `isConfigured` to `false`, retire the Config Doctor and incomplete-document repair request handlers immediately, and recheck the current setting and database readiness when either handler runs.
|
||||
- Preserve the existing ordered flag-file recovery handlers: SCRAM at priority 5, fetch-all at priority 10, and rebuild-all at priority 20. These files express an explicit recovery instruction and may invoke their focused storage or rebuild service while ordinary replication remains gated.
|
||||
- Present the compatibility review at priority 30, after any selected recovery operation. A recovery handler which cancels start-up, keeps SCRAM active, or schedules a restart returns `false`, so the current process does not open a competing compatibility dialogue. If recovery completes and start-up continues, the dialogue opens before normal synchronisation is allowed to resume.
|
||||
- Keep database preparation independent of an unanswered compatibility dialogue, because the compatibility gate already blocks replication. Before Config Doctor begins its interactive checks, await the active initial review so that the two update dialogues cannot overlap.
|
||||
@@ -100,4 +101,4 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex
|
||||
- Unit and Compose tests verify that ordinary P2P replication observes the policy, explicit P2P rebuild uses the setup bypass, and replacement leaves host actions on the current replicator.
|
||||
- A real-Obsidian settings test verifies the dedicated summary and details dialogues, captures representative screenshots, confirms that the acknowledged internal version advances only after explicit resume, and confirms that the Change Log contains no acknowledgement control.
|
||||
- The real-Obsidian CouchDB workflow starts from configured plug-in data without a device-local marker, verifies the copied-or-restored Vault explanation, resumes through the actual dialogue, and then completes remote metadata, chunk, and activity checks. The two-Vault workflow performs the same review once per isolated Vault before reusing the acknowledged device state for later process launches.
|
||||
- Unit tests fix the layout-ready priority after the three flag-file recovery priorities, so a recovery which stops start-up cannot race the compatibility dialogue.
|
||||
- Unit tests fix configured Vault admission at priority 1, the three flag-file recovery priorities at 5, 10, and 20, and compatibility review at priority 30. A recovery which stops start-up therefore cannot race the compatibility dialogue.
|
||||
|
||||
@@ -34,8 +34,9 @@ Do not select `AbstractModule` or `AbstractObsidianModule` merely to obtain conv
|
||||
3. construct and register built-in and host-supplied Modules;
|
||||
4. compose the built-in Commonlib serviceFeatures;
|
||||
5. compose host-supplied serviceFeatures;
|
||||
6. construct add-ons; and
|
||||
7. call `onBindFunction()` for each registered Module.
|
||||
6. construct add-ons;
|
||||
7. compose the late core serviceFeatures whose handlers must follow host features and add-ons; and
|
||||
8. call `onBindFunction()` for each registered Module.
|
||||
|
||||
The Module constructor therefore runs before its handler bindings, while the complete Service Hub and ServiceModules already exist. `bindModuleFunctions()` then invokes every `onBindFunction()` and runs `__$checkInstanceBinding()`. That diagnostic compares underscore-prefixed prototype methods with method references found in the source text of `onBindFunction()`.
|
||||
|
||||
@@ -127,6 +128,14 @@ This split allows tests to verify:
|
||||
|
||||
The operation does not need an application Module identity.
|
||||
|
||||
### Ordered start-up composition and registration-only features
|
||||
|
||||
Configured Vault admission and the checks which follow database preparation are composed by `src/serviceFeatures/startupLifecycle/`. The directory keeps onboarding admission, compromised-chunk inspection, incomplete-document repair, Config Doctor, and the obsolete bulk-send setting migration as separate operations. One feature composer owns their order and receives the compatibility-review wait operation explicitly; an individual operation does not call the composer.
|
||||
|
||||
The layout-ready admission handler uses priority 1. This preserves ordinary priority-0 host integration before admission, while keeping an unconfigured Vault outside the flag-file recovery handlers at priorities 5, 10, and 20, and the compatibility review at priority 30. Admission belongs to one plug-in process: an initially unconfigured process remains inert until setup restarts it, and declining the requested restart does not trigger an in-process reconfiguration. Changing an admitted process back to unconfigured retires its Config Doctor and incomplete-document repair request handlers. The handlers also recheck the current configured state and database readiness when invoked, so a pending restart cannot expose partially initialised or retired state. The first-initialise handler rechecks admission before retaining the established order after the file watcher has been started: database readiness, compromised chunks, incomplete documents, compatibility review, Config Doctor, and the bulk-send setting migration.
|
||||
|
||||
Command and ribbon registration are serviceFeatures for the same dependency-visibility reason, but they are not start-up migrations. The basic commands remain a host-neutral feature composed by `LiveSyncBaseCore`, while the replication ribbon remains an Obsidian-only feature composed by the Obsidian host. Both retain `onInitialise` registration so moving them out of the Module list does not make their effects run during construction.
|
||||
|
||||
### Private state and ordered handlers: target filters
|
||||
|
||||
Commonlib's `targetFilter.ts` keeps each cache or readiness gate in the factory which owns one predicate. `useTargetFilters()` constructs those predicates and registers them in their required order.
|
||||
|
||||
@@ -27,12 +27,12 @@ import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictRes
|
||||
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 { useBasicCommandsFeature } from "./serviceFeatures/basicCommands";
|
||||
|
||||
/** Focused views returned by serviceFeatures which the host may consume during composition. */
|
||||
export interface LiveSyncCoreFeatureViews {
|
||||
@@ -45,10 +45,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[];
|
||||
|
||||
@@ -95,9 +92,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();
|
||||
}
|
||||
/**
|
||||
@@ -160,7 +158,6 @@ export class LiveSyncBaseCore<
|
||||
this._registerModule(new ModuleConflictChecker(this));
|
||||
this._registerModule(new ModuleConflictResolver(this));
|
||||
this._registerModule(new ModuleResolvingMismatchedTweaks(this));
|
||||
this._registerModule(new ModuleBasicMenu(this));
|
||||
|
||||
for (const module of extraModules) {
|
||||
this._registerModule(module);
|
||||
|
||||
+11
-7
@@ -26,10 +26,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";
|
||||
@@ -38,7 +36,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";
|
||||
@@ -46,6 +47,8 @@ import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync
|
||||
import { useCompatibilityReview } from "./serviceFeatures/compatibilityReview.ts";
|
||||
import { createObsidianCompatibilityReviewUi } from "./serviceFeatures/compatibilityReviewObsidian.ts";
|
||||
import { createFileReflectionProvenance } from "./serviceModules/FileReflectionProvenance.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;
|
||||
@@ -145,7 +148,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,
|
||||
@@ -156,7 +158,6 @@ 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),
|
||||
@@ -164,7 +165,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
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);
|
||||
@@ -200,7 +201,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;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { assertLocatorWithinViewport, assertNoHorizontalOverflow } from "@vrtmrz/obsidian-test-session";
|
||||
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
|
||||
@@ -597,26 +598,42 @@ async function verifyCompatibilityReview(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<void> {
|
||||
async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<string> {
|
||||
const screenshot = await captureObsidianDialogue(
|
||||
obsidianRemoteDebuggingPort(),
|
||||
"config-doctor-after-compatibility-review.png",
|
||||
async (page) => {
|
||||
const doctor = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }),
|
||||
});
|
||||
await doctor.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await doctor.getByText("Per-file-saved customization sync", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await doctor.getByText("Enhance chunk size", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
if ((await doctor.getByText("Data Compression", { exact: true }).count()) !== 0) {
|
||||
throw new Error("Config Doctor still treats supported Data Compression as a problem.");
|
||||
}
|
||||
await assertLocatorWithinViewport(page, doctor.locator(".modal").last(), {
|
||||
label: "Config Doctor dialogue",
|
||||
});
|
||||
await assertNoHorizontalOverflow(page, doctor.locator(".modal").last(), {
|
||||
label: "Config Doctor dialogue",
|
||||
});
|
||||
}
|
||||
);
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const doctor = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }),
|
||||
});
|
||||
await doctor.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await doctor.getByText("Per-file-saved customization sync", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await doctor.getByText("Enhance chunk size", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
if ((await doctor.getByText("Data Compression", { exact: true }).count()) !== 0) {
|
||||
throw new Error("Config Doctor still treats supported Data Compression as a problem.");
|
||||
}
|
||||
await doctor.getByRole("button", { name: /No, and do not ask again/u }).click();
|
||||
await doctor.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
async function verifyEffectiveSettings(): Promise<"declarative" | "imperative"> {
|
||||
@@ -1051,7 +1068,8 @@ async function main(): Promise<void> {
|
||||
await resumePendingCompatibilityReviewForSettings();
|
||||
} else {
|
||||
await verifyCompatibilityReview();
|
||||
await verifyConfigDoctorFollowsCompatibilityReview();
|
||||
const configDoctorScreenshot = await verifyConfigDoctorFollowsCompatibilityReview();
|
||||
console.log(`Config Doctor screenshot: ${configDoctorScreenshot}`);
|
||||
}
|
||||
settingsRenderer = await verifyEffectiveSettings();
|
||||
const initialisation = await verifyPendingSettingsInitialisationFlow();
|
||||
|
||||
@@ -4,8 +4,48 @@ import {
|
||||
inspectObsidianServiceContextContract,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const BASIC_COMMAND_IDS = [
|
||||
"livesync-replicate",
|
||||
"livesync-dump",
|
||||
"livesync-toggle",
|
||||
"livesync-suspendall",
|
||||
"livesync-scan-files",
|
||||
"livesync-runbatch",
|
||||
"livesync-abortsync",
|
||||
] as const;
|
||||
|
||||
type ObsidianCommandHost = typeof globalThis & {
|
||||
app?: { commands?: { commands?: Record<string, unknown> } };
|
||||
};
|
||||
|
||||
async function assertMenuFeaturesAreComposed(remoteDebuggingPort: number): Promise<void> {
|
||||
await withObsidianPage(remoteDebuggingPort, async (page) => {
|
||||
const registered = await page.evaluate((commandIds) => {
|
||||
const commands = (globalThis as ObsidianCommandHost).app?.commands?.commands ?? {};
|
||||
return commandIds.filter((id) => commands[`obsidian-livesync:${id}`] !== undefined);
|
||||
}, BASIC_COMMAND_IDS);
|
||||
if (registered.length !== BASIC_COMMAND_IDS.length) {
|
||||
const missing = BASIC_COMMAND_IDS.filter((id) => !registered.includes(id));
|
||||
throw new Error(`Extracted basic commands were not composed: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
const ribbonCount = await page.locator(".livesync-ribbon-replicate").count();
|
||||
if (ribbonCount !== 1) {
|
||||
throw new Error(`Expected one extracted replication ribbon action, found ${ribbonCount}.`);
|
||||
}
|
||||
|
||||
const preservedRibbonPathCount = await page
|
||||
.locator('.livesync-ribbon-replicate path[d*="c-7.66 1.98-12.2 9.61-10 17"]')
|
||||
.count();
|
||||
if (preservedRibbonPathCount !== 1) {
|
||||
throw new Error("The extracted replication ribbon does not preserve its established icon path.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
@@ -34,6 +74,8 @@ async function main(): Promise<void> {
|
||||
console.log(
|
||||
`Obsidian service Context contract passed: ${contextContract.contextType}, ${contextContract.serviceContextMismatches.length} mismatches.`
|
||||
);
|
||||
await assertMenuFeaturesAreComposed(session.remoteDebuggingPort);
|
||||
console.log("Extracted basic commands and replication ribbon were composed exactly once.");
|
||||
await new Promise((resolve) => setTimeout(resolve, Number(process.env.E2E_OBSIDIAN_SMOKE_TIMEOUT_MS ?? 1000)));
|
||||
console.log("Obsidian stayed alive after the plug-in readiness check.");
|
||||
} finally {
|
||||
|
||||
+10
@@ -12,6 +12,16 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Improved
|
||||
|
||||
- Start-up now keeps unconfigured Vaults on the onboarding path without running configured-only checks or accepting Config Doctor and incomplete-document repair requests. Returning a configured Vault to an unconfigured state also retires those requests for the current plug-in process, so completing setup admits them only after the requested restart.
|
||||
|
||||
### Testing
|
||||
|
||||
- Start-up migrations, integrity checks, Config Doctor, basic commands, and the Obsidian replication ribbon now have focused regression tests for their service composition. Real Obsidian checks cover unconfigured onboarding, configured start-up scanning, Config Doctor detection and layout, command registration, and the established ribbon icon.
|
||||
|
||||
## 1.0.24
|
||||
|
||||
3rd September, 2026
|
||||
|
||||
Reference in New Issue
Block a user