Refactor startup lifecycle into service features

This commit is contained in:
vorotamoroz committed 2026-09-03 12:20:05 +00:00
1 parent 3b2d5aa5af
commit 338aecd888
30 files changed
+2184 -963

No files matched your search

@@ -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();
});
});
@@ -0,0 +1,37 @@
import type { ConfiguredStartupLifecycleOperations, StartupLifecycleValue } from "./types";
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 (readValue(runtime.configured)) return true;
runtime.inviteToOnboarding();
return false;
}
/**
* 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: ConfiguredStartupLifecycleOperations): Promise<boolean> {
if (!readValue(runtime.databaseReady)) {
runtime.reportDatabaseNotReady();
return false;
}
if (!(await runtime.hasCompromisedChunks())) return false;
if (!(await runtime.hasIncompleteDocuments())) return false;
await runtime.waitForCompatibilityReview();
if (!(await runtime.runDoctor())) return false;
await runtime.migrateBulkSend();
return true;
}
@@ -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,129 @@
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,
});
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,175 @@
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;
return {
databaseReady: options.databaseReady ?? (() => services.database.localDatabase.isReady),
reportDatabaseNotReady:
options.reportDatabaseNotReady ??
(() => log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE)),
hasCompromisedChunks:
options.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:
options.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
)),
waitForCompatibilityReview: options.waitForCompatibilityReview,
runDoctor:
options.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:
options.migrateBulkSend ??
(() =>
migrateBulkSendSetting({
settings: services.setting.currentSettings(),
log,
saveSettings: () => services.setting.saveSettingData(),
})),
};
}
/**
* 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;