mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-27 11:33:21 +00:00
fix: await optional file database saves
This commit is contained in:
1 parent
b3a43abc2e
commit
62b2edba9d
11 files changed
+1113
-20
No files matched your search
@@ -0,0 +1,220 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
addIcon: vi.fn(),
|
||||
diff_match_patch: class DiffMatchPatch {},
|
||||
normalizePath: vi.fn((path: string) => path),
|
||||
parseYaml: vi.fn(),
|
||||
Platform: {},
|
||||
}));
|
||||
vi.mock("./PluginDialogModal.ts", () => ({
|
||||
PluginDialogModal: class PluginDialogModal {},
|
||||
}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||
JsonResolveModal: class JsonResolveModal {},
|
||||
}));
|
||||
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||
ConflictResolveModal: class ConflictResolveModal {},
|
||||
}));
|
||||
// The readiness helpers come from the real LiveSyncCommands, so only its log view dependency is replaced.
|
||||
vi.mock("@/modules/features/ModuleLog.ts", () => ({
|
||||
MARK_DONE: "",
|
||||
}));
|
||||
vi.mock("@/common/types.ts", () => ({
|
||||
ICXHeader: "ix:",
|
||||
PERIODIC_PLUGIN_SWEEP: 60,
|
||||
}));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
cancelTask: vi.fn(),
|
||||
EVEN: Symbol("even"),
|
||||
isCustomisationSyncMetadata: vi.fn(),
|
||||
isPluginMetadata: vi.fn(),
|
||||
scheduleTask: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||
PeriodicProcessor: class PeriodicProcessor {},
|
||||
}));
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
|
||||
eventHub: {
|
||||
onEvent: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: vi.fn((message: string) => message),
|
||||
}));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||
getObsidianCommunityPluginManager: vi.fn(),
|
||||
}));
|
||||
|
||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { getDocData } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { ConfigSync } from "./CmdConfigSync";
|
||||
import { createOptionalFileSyncDatabase } from "../../../test/fixtures/optionalFileSyncDatabase";
|
||||
|
||||
const path = ".obsidian/snippets/test.css" as FilePath;
|
||||
const fixtures: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
|
||||
|
||||
async function createConfigSync(useV2 = false) {
|
||||
const fixture = await createOptionalFileSyncDatabase();
|
||||
fixtures.push(fixture);
|
||||
fixture.settings.usePluginSyncV2 = useV2;
|
||||
const files = new Map<string, { content: string; mtime: number }>([
|
||||
[path, { content: "body { color: red; }", mtime: 2000 }],
|
||||
]);
|
||||
const storageAccess = {
|
||||
statHidden: vi.fn(async (file: string) => {
|
||||
const entry = files.get(file);
|
||||
return entry
|
||||
? { type: "file" as const, ctime: 1000, mtime: entry.mtime, size: entry.content.length }
|
||||
: null;
|
||||
}),
|
||||
readHiddenFileBinary: vi.fn(async (file: string) => {
|
||||
const entry = files.get(file);
|
||||
if (!entry) throw new Error(`Missing fixture: ${file}`);
|
||||
return new TextEncoder().encode(entry.content).buffer;
|
||||
}),
|
||||
};
|
||||
const services = {
|
||||
...fixture.services,
|
||||
appLifecycle: { isReady: () => false, isSuspended: () => true },
|
||||
database: { ...fixture.services.database, isDatabaseReady: vi.fn(() => true) },
|
||||
path: {
|
||||
...fixture.services.path,
|
||||
isMarkedAsSameChanges: vi.fn(),
|
||||
markChangesAreSame: vi.fn(),
|
||||
},
|
||||
};
|
||||
const config = Object.create(ConfigSync.prototype) as ConfigSync;
|
||||
Object.assign(config, {
|
||||
core: { settings: fixture.settings, localDatabase: fixture.localDatabase, storageAccess, services },
|
||||
_log: vi.fn(),
|
||||
updatePluginList: vi.fn(async () => undefined),
|
||||
updatePluginListV2: vi.fn(async () => undefined),
|
||||
recentProcessedInternalFiles: [],
|
||||
});
|
||||
const databasePath = useV2 ? config.filenameWithUnifiedKey(path) : config.filenameToUnifiedKey(path);
|
||||
return { fixture, config, files, services, storageAccess, databasePath };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
for (const fixture of fixtures.splice(0)) await fixture.dispose();
|
||||
});
|
||||
|
||||
describe("Customisation Sync optional save completion", () => {
|
||||
const flush = { whenFlushRequested: Promise.resolve() };
|
||||
|
||||
it("waits for the database write while the application is suspended or still starting", async () => {
|
||||
const { fixture, config } = await createConfigSync();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const put = fixture.localDatabase.putDBEntry.bind(fixture.localDatabase);
|
||||
const spy = vi.spyOn(fixture.localDatabase, "putDBEntry").mockImplementation(async (...args) => {
|
||||
await gate;
|
||||
return await put(...args);
|
||||
});
|
||||
let completed = false;
|
||||
const result = config._processOptionalFileEventWithResult(path, flush).then((outcome) => {
|
||||
completed = true;
|
||||
return outcome;
|
||||
});
|
||||
await vi.waitFor(() => expect(spy).toHaveBeenCalledOnce());
|
||||
expect(completed).toBe(false);
|
||||
release();
|
||||
await expect(result).resolves.toEqual({ kind: "completed" });
|
||||
});
|
||||
|
||||
it.each([false, true])("confirms identical contents and stores a same-time edit (V2: %s)", async (useV2) => {
|
||||
const { fixture, config, databasePath, files } = await createConfigSync(useV2);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
|
||||
const first = await fixture.database.get(databasePath);
|
||||
await config._processOptionalFileEventWithResult(path, flush);
|
||||
expect((await fixture.database.get(databasePath))._rev).toBe(first._rev);
|
||||
files.get(path)!.content = "body { color: tan; }";
|
||||
await config._processOptionalFileEventWithResult(path, flush);
|
||||
expect((await fixture.database.get(databasePath))._rev).not.toBe(first._rev);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ useV2: false, throws: false },
|
||||
{ useV2: true, throws: false },
|
||||
{ useV2: false, throws: true },
|
||||
{ useV2: true, throws: true },
|
||||
])("rejects a failed write and permits the unchanged notification to retry: %j", async ({ useV2, throws }) => {
|
||||
const { fixture, config, databasePath } = await createConfigSync(useV2);
|
||||
const failure = new Error("temporary write failure");
|
||||
const put = vi.spyOn(fixture.localDatabase, "putDBEntry");
|
||||
if (throws) put.mockRejectedValueOnce(failure);
|
||||
else put.mockResolvedValueOnce(false);
|
||||
const failed = config._processOptionalFileEventWithResult(path, flush);
|
||||
if (throws) await expect(failed).rejects.toBe(failure);
|
||||
else await expect(failed).rejects.toThrow("Could not save customisation");
|
||||
await expect(fixture.localDatabase.getDBEntry(databasePath)).resolves.toBe(false);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
|
||||
expect(put).toHaveBeenCalledTimes(2);
|
||||
expect(await fixture.localDatabase.getDBEntry(databasePath)).not.toBe(false);
|
||||
});
|
||||
|
||||
it.each([false, true])("confirms deletion and treats a repeated deletion as complete (V2: %s)", async (useV2) => {
|
||||
const { fixture, config, databasePath, files } = await createConfigSync(useV2);
|
||||
await config._processOptionalFileEventWithResult(path, flush);
|
||||
files.delete(path);
|
||||
const put = vi.spyOn(fixture.localDatabase, "putRaw");
|
||||
put.mockResolvedValueOnce({ ok: false, id: "failed", rev: "" });
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).rejects.toThrow(
|
||||
"Could not delete customisation"
|
||||
);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
|
||||
const deleted = await fixture.database.get(databasePath);
|
||||
expect("deleted" in deleted && deleted.deleted).toBe(true);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
|
||||
expect((await fixture.database.get(databasePath))._rev).toBe(deleted._rev);
|
||||
});
|
||||
|
||||
it("distinguishes a different owner and unmet prerequisites", async () => {
|
||||
const { config, services, storageAccess } = await createConfigSync();
|
||||
await expect(config._processOptionalFileEventWithResult("notes.md" as FilePath, flush)).resolves.toEqual({
|
||||
kind: "not-applicable",
|
||||
});
|
||||
services.database.isDatabaseReady.mockReturnValue(false);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({
|
||||
kind: "veto",
|
||||
value: "database-not-ready",
|
||||
});
|
||||
services.database.isDatabaseReady.mockReturnValue(true);
|
||||
vi.spyOn(services.setting, "getDeviceAndVaultName").mockReturnValue("");
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({
|
||||
kind: "veto",
|
||||
value: "device-name-not-configured",
|
||||
});
|
||||
expect(storageAccess.readHiddenFileBinary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves every V2 file when neighbouring plug-in files arrive together", async () => {
|
||||
const { fixture, config, files } = await createConfigSync(true);
|
||||
const main = ".obsidian/plugins/example/main.js" as FilePath;
|
||||
const style = ".obsidian/plugins/example/styles.css" as FilePath;
|
||||
files.set(main, { content: "module.exports = {};", mtime: 3000 });
|
||||
files.set(style, { content: "body {}", mtime: 3000 });
|
||||
await Promise.all([
|
||||
config._processOptionalFileEventWithResult(main, flush),
|
||||
config._processOptionalFileEventWithResult(style, flush),
|
||||
]);
|
||||
expect(await fixture.localDatabase.getDBEntry(config.filenameWithUnifiedKey(main))).not.toBe(false);
|
||||
expect(await fixture.localDatabase.getDBEntry(config.filenameWithUnifiedKey(style))).not.toBe(false);
|
||||
});
|
||||
|
||||
it("does not acknowledge a database deletion which was not accepted", async () => {
|
||||
const { fixture, config, databasePath } = await createConfigSync();
|
||||
await config.storeCustomizationFiles(path);
|
||||
const original = await fixture.localDatabase.getDBEntry(databasePath);
|
||||
expect(original).not.toBe(false);
|
||||
vi.spyOn(fixture.localDatabase, "putRaw").mockResolvedValueOnce({ ok: false, id: "failed", rev: "" });
|
||||
await expect(config.deleteConfigOnDatabase(databasePath)).resolves.toBe(false);
|
||||
const unchanged = await fixture.localDatabase.getDBEntry(databasePath);
|
||||
expect(unchanged && getDocData(unchanged.data)).toBe(original && getDocData(original.data));
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,10 @@ import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError"
|
||||
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
||||
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
||||
|
||||
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import { PendingConfigSaves } from "./PendingConfigSaves";
|
||||
|
||||
const d = "\u200b";
|
||||
const d2 = "\n";
|
||||
const UPDATED_CONFIGURATION_NOTICE_KEY = "config-sync:updated-configuration";
|
||||
@@ -1206,7 +1210,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
|
||||
recentProcessedInternalFiles = [] as string[];
|
||||
async makeEntryFromFile(path: FilePath): Promise<false | PluginDataExFile> {
|
||||
async makeEntryFromFile(path: FilePath, requireCompletion = false): Promise<false | PluginDataExFile> {
|
||||
const stat = await this.core.storageAccess.statHidden(path);
|
||||
let version: string | undefined;
|
||||
let displayName: string | undefined;
|
||||
@@ -1240,6 +1244,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
} catch (ex) {
|
||||
this._log(`The file ${path} could not be encoded`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (requireCompletion) throw ex;
|
||||
return false;
|
||||
}
|
||||
const mtime = stat.mtime;
|
||||
@@ -1254,6 +1259,15 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
|
||||
async storeCustomisationFileV2(path: FilePath, term: string, force = false) {
|
||||
return await this.storeCustomisationFileV2Core(path, term, force, false);
|
||||
}
|
||||
|
||||
private async storeCustomisationFileV2Core(
|
||||
path: FilePath,
|
||||
term: string,
|
||||
force: boolean,
|
||||
requireCompletion: boolean
|
||||
) {
|
||||
const vf = this.filenameWithUnifiedKey(path, term);
|
||||
return await serialized(`plugin-${vf}`, async () => {
|
||||
const prefixedFileName = vf;
|
||||
@@ -1261,7 +1275,8 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
const id = await this.path2id(prefixedFileName);
|
||||
const stat = await this.core.storageAccess.statHidden(path);
|
||||
if (!stat) {
|
||||
return false;
|
||||
if (!requireCompletion) return false;
|
||||
return await this.deleteConfigOnDatabaseCore(vf, force, true);
|
||||
}
|
||||
const mtime = stat.mtime;
|
||||
const content = await this.core.storageAccess.readHiddenFileBinary(path);
|
||||
@@ -1285,7 +1300,10 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
eden: {},
|
||||
};
|
||||
} else {
|
||||
if (this.services.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN) {
|
||||
if (
|
||||
!requireCompletion &&
|
||||
this.services.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN
|
||||
) {
|
||||
this._log(
|
||||
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Already checked the same)`,
|
||||
LOG_LEVEL_DEBUG
|
||||
@@ -1300,7 +1318,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
const dataStart = dataSrc.indexOf(DUMMY_END);
|
||||
const oldContent = dataSrc.substring(dataStart + DUMMY_END.length);
|
||||
const oldContentArray = base64ToArrayBuffer(oldContent);
|
||||
if (await isDocContentSame(oldContentArray, content)) {
|
||||
if (!old.deleted && (await isDocContentSame(oldContentArray, content))) {
|
||||
this._log(
|
||||
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (the same content)`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
@@ -1320,24 +1338,36 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
};
|
||||
}
|
||||
const ret = await this.localDatabase.putDBEntry(saveData);
|
||||
if (requireCompletion && (!ret || !ret.ok))
|
||||
throw new LiveSyncError(`Could not save customisation: ${path}`);
|
||||
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
|
||||
fireAndForget(() => this.updatePluginListV2(false, this.filenameWithUnifiedKey(path)));
|
||||
return ret;
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (requireCompletion) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
async storeCustomizationFiles(path: FilePath, termOverRide?: string) {
|
||||
return await this.storeCustomizationFilesCore(path, termOverRide, false);
|
||||
}
|
||||
|
||||
private async storeCustomizationFilesCore(
|
||||
path: FilePath,
|
||||
termOverRide: string | undefined,
|
||||
requireCompletion: boolean
|
||||
) {
|
||||
const term = termOverRide || this.services.setting.getDeviceAndVaultName();
|
||||
if (term == "") {
|
||||
this._log($msg("We have to configure the device name"), LOG_LEVEL_NOTICE);
|
||||
if (requireCompletion) throw new LiveSyncError("The device name is not configured");
|
||||
return;
|
||||
}
|
||||
if (this.useV2) {
|
||||
return await this.storeCustomisationFileV2(path, term);
|
||||
return await this.storeCustomisationFileV2Core(path, term, false, requireCompletion);
|
||||
}
|
||||
const vf = this.filenameToUnifiedKey(path, term);
|
||||
// console.warn(`Storing ${path} to ${bareVF} :--> ${keyedVF}`);
|
||||
@@ -1378,7 +1408,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
fileTargets = ["manifest.json", "theme.css"].map((e) => `${parentPath}/${e}` as FilePath);
|
||||
}
|
||||
for (const target of fileTargets) {
|
||||
const data = await this.makeEntryFromFile(target);
|
||||
const data = await this.makeEntryFromFile(target, requireCompletion);
|
||||
if (data == false) {
|
||||
this._log(`Config: skipped (Possibly is not exist): ${target} `, LOG_LEVEL_VERBOSE);
|
||||
continue;
|
||||
@@ -1398,9 +1428,9 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
// this._log(`Configuration saving: ${prefixedFileName}`);
|
||||
if (dt.files.length == 0) {
|
||||
this._log(`Nothing left: deleting.. ${path}`);
|
||||
await this.deleteConfigOnDatabase(prefixedFileName);
|
||||
const deleted = await this.deleteConfigOnDatabaseCore(prefixedFileName, false, requireCompletion);
|
||||
await this.updatePluginList(false, prefixedFileName);
|
||||
return;
|
||||
return requireCompletion ? deleted : undefined;
|
||||
}
|
||||
|
||||
const content = createTextBlob(serialize(dt));
|
||||
@@ -1422,7 +1452,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
eden: {},
|
||||
};
|
||||
} else {
|
||||
if (old.mtime == mtime) {
|
||||
if (!requireCompletion && old.mtime == mtime) {
|
||||
// this._log(`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same time)`, LOG_LEVEL_VERBOSE);
|
||||
return true;
|
||||
}
|
||||
@@ -1443,7 +1473,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
});
|
||||
const isSame = (await Promise.all(diffs)).every((e) => e == true);
|
||||
if (isSame) {
|
||||
if (isSame && !old.deleted) {
|
||||
this._log(
|
||||
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same content)`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
@@ -1464,16 +1494,51 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
};
|
||||
}
|
||||
const ret = await this.localDatabase.putDBEntry(saveData);
|
||||
if (requireCompletion && (!ret || !ret.ok))
|
||||
throw new LiveSyncError(`Could not save customisation: ${path}`);
|
||||
await this.updatePluginList(false, saveData.path);
|
||||
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
|
||||
return ret;
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (requireCompletion) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
private pendingOptionalSaves?: PendingConfigSaves;
|
||||
|
||||
async _processOptionalFileEventWithResult(
|
||||
path: FilePath,
|
||||
options?: OptionalFileEventOptions
|
||||
): Promise<HandlerOutcome<void, string>> {
|
||||
if (!this.isThisModuleEnabled() || !this.isTargetPath(path)) return { kind: "not-applicable" };
|
||||
const configDir = normalizePath(this.configDir);
|
||||
const handledElsewhere = Object.values(this.settings.pluginSyncExtendedSetting)
|
||||
.filter((entry) => entry.mode !== MODE_SELECTIVE && entry.mode !== MODE_SHINY)
|
||||
.flatMap((entry) => entry.files)
|
||||
.some((file) => `${configDir}/${file}`.toLowerCase().startsWith(path.toLowerCase()));
|
||||
if (handledElsewhere) return { kind: "not-applicable" };
|
||||
if (!this._isDatabaseReady()) return { kind: "veto", value: "database-not-ready" };
|
||||
const term = this.services.setting.getDeviceAndVaultName();
|
||||
if (!term) return { kind: "veto", value: "device-name-not-configured" };
|
||||
const stat = await this.core.storageAccess.statHidden(path);
|
||||
if (stat && stat.type !== "file") return { kind: "not-applicable" };
|
||||
|
||||
// V1 saves a complete file group; V2 persists each file separately.
|
||||
const key = this.useV2 ? this.filenameWithUnifiedKey(path, term) : this.filenameToUnifiedKey(path, term);
|
||||
this.pendingOptionalSaves ??= new PendingConfigSaves();
|
||||
await this.pendingOptionalSaves.enqueue(
|
||||
key,
|
||||
async () => {
|
||||
await this.storeCustomizationFilesCore(path, term, true);
|
||||
},
|
||||
options?.whenFlushRequested
|
||||
);
|
||||
return { kind: "completed" };
|
||||
}
|
||||
|
||||
async _anyProcessOptionalFileEvent(path: FilePath): Promise<boolean> {
|
||||
return await this.watchVaultRawEventsAsync(path);
|
||||
}
|
||||
@@ -1615,6 +1680,14 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
|
||||
async deleteConfigOnDatabase(prefixedFileName: FilePathWithPrefix, forceWrite = false) {
|
||||
return await this.deleteConfigOnDatabaseCore(prefixedFileName, forceWrite, false);
|
||||
}
|
||||
|
||||
private async deleteConfigOnDatabaseCore(
|
||||
prefixedFileName: FilePathWithPrefix,
|
||||
forceWrite: boolean,
|
||||
requireCompletion: boolean
|
||||
) {
|
||||
// const id = await this.path2id(prefixedFileName);
|
||||
const mtime = new Date().getTime();
|
||||
return await serialized("file-x-" + prefixedFileName, async () => {
|
||||
@@ -1640,13 +1713,15 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
type: "newnote",
|
||||
};
|
||||
}
|
||||
await this.localDatabase.putRaw(saveData);
|
||||
const result = await this.localDatabase.putRaw(saveData);
|
||||
if (!result.ok) throw new LiveSyncError(`Could not delete customisation: ${prefixedFileName}`);
|
||||
await this.updatePluginList(false, prefixedFileName);
|
||||
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Done`);
|
||||
return true;
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (requireCompletion) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -1756,6 +1831,9 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this));
|
||||
services.fileProcessing.processOptionalFileEventWithResult?.addHandler(
|
||||
this._processOptionalFileEventWithResult.bind(this)
|
||||
);
|
||||
services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this));
|
||||
services.replication.processVirtualDocument.addHandler(this._anyModuleParsedReplicationResultItem.bind(this));
|
||||
services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this));
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { compatGlobal, type CompatTimeoutHandle } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
type PendingSave = {
|
||||
save: () => Promise<void>;
|
||||
completion: Promise<void>;
|
||||
resolve: () => void;
|
||||
reject: (error: unknown) => void;
|
||||
timer?: CompatTimeoutHandle;
|
||||
started: boolean;
|
||||
};
|
||||
|
||||
/** Owns the quiet-period delay and completion of coalesced customisation saves. */
|
||||
export class PendingConfigSaves {
|
||||
private readonly pending = new Map<string, PendingSave>();
|
||||
|
||||
enqueue(key: string, save: () => Promise<void>, whenFlushRequested?: Promise<void>): Promise<void> {
|
||||
let group = this.pending.get(key);
|
||||
if (!group) {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const completion = new Promise<void>((onResolve, onReject) => {
|
||||
resolve = onResolve;
|
||||
reject = onReject;
|
||||
});
|
||||
group = { save, completion, resolve, reject, started: false };
|
||||
this.pending.set(key, group);
|
||||
} else {
|
||||
group.save = save;
|
||||
if (group.timer !== undefined) compatGlobal.clearTimeout(group.timer);
|
||||
}
|
||||
const captured = group;
|
||||
group.timer = compatGlobal.setTimeout(() => this.run(key, captured), 100);
|
||||
// Capture this group: a late flush of an earlier event must not release
|
||||
// a newer group which happens to use the same customisation key.
|
||||
void whenFlushRequested?.then(
|
||||
() => this.run(key, captured),
|
||||
() => {}
|
||||
);
|
||||
return group.completion;
|
||||
}
|
||||
|
||||
private run(key: string, group: PendingSave): void {
|
||||
if (group.started) return;
|
||||
group.started = true;
|
||||
if (group.timer !== undefined) compatGlobal.clearTimeout(group.timer);
|
||||
this.pending.delete(key);
|
||||
void Promise.resolve().then(group.save).then(group.resolve, group.reject);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { PendingConfigSaves } from "./PendingConfigSaves";
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<void>((onResolve, onReject) => {
|
||||
resolve = onResolve;
|
||||
reject = onReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("pending customisation saves", () => {
|
||||
it("retains the quiet period and completes every coalesced notification after the actual save", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pending = new PendingConfigSaves();
|
||||
const save = deferred();
|
||||
const firstSave = vi.fn(async () => {});
|
||||
const lastSave = vi.fn(() => save.promise);
|
||||
const first = pending.enqueue("plugin", firstSave);
|
||||
await vi.advanceTimersByTimeAsync(80);
|
||||
const second = pending.enqueue("plugin", lastSave);
|
||||
let completed = false;
|
||||
const all = Promise.all([first, second]).then(() => {
|
||||
completed = true;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(99);
|
||||
expect(lastSave).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(firstSave).not.toHaveBeenCalled();
|
||||
expect(lastSave).toHaveBeenCalledOnce();
|
||||
expect(completed).toBe(false);
|
||||
save.resolve();
|
||||
await all;
|
||||
expect(completed).toBe(true);
|
||||
});
|
||||
|
||||
it("flushes the captured group without waiting for or releasing later notifications", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pending = new PendingConfigSaves();
|
||||
const flush = deferred();
|
||||
const lateFlush = deferred();
|
||||
const firstSave = deferred();
|
||||
const work = vi.fn(() => firstSave.promise);
|
||||
const first = pending.enqueue("plugin", work, flush.promise);
|
||||
const coalesced = pending.enqueue("plugin", work, lateFlush.promise);
|
||||
flush.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(work).toHaveBeenCalledOnce();
|
||||
const nextWork = vi.fn(async () => {});
|
||||
const later = pending.enqueue("plugin", nextWork);
|
||||
lateFlush.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(nextWork).not.toHaveBeenCalled();
|
||||
firstSave.resolve();
|
||||
await Promise.all([first, coalesced]);
|
||||
expect(nextWork).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await later;
|
||||
expect(nextWork).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects every coalesced notification with the original failure and permits a later retry", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pending = new PendingConfigSaves();
|
||||
const failure = new Error("database write failed");
|
||||
const save = async () => {
|
||||
throw failure;
|
||||
};
|
||||
const first = pending.enqueue("plugin", save);
|
||||
const second = pending.enqueue("plugin", save);
|
||||
const outcomes = Promise.allSettled([first, second]);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(await outcomes).toEqual([
|
||||
{ status: "rejected", reason: failure },
|
||||
{ status: "rejected", reason: failure },
|
||||
]);
|
||||
const retry = pending.enqueue("plugin", async () => {}, Promise.resolve());
|
||||
await retry;
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user